integration/server/allocator.ts
// SERVER ONLY. The authenticated backend supplies user:<service-user-id> internally.
// Never pass an untrusted request field or a UR client ID as that key.
// Set URNETWORK_ROOT_JWT, URNETWORK_CLIENT_MAP (absolute path, existing service-owned
// parent) and optional URNETWORK_API_URL. A crash may leave .lock; only remove a stale lock.
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import {randomUUID} from "node:crypto";
import assert from "node:assert/strict";
interface RequestBody {description: string; device_spec: string; client_id?: string; source_client_id?: never}
interface ClientMap {version: number; clients: Record<string, string>}
interface ClientResult {client_id: string; by_client_jwt: string}
type Provision = (body: RequestBody) => Promise<string>;
const limit = 1 << 20;
const userPattern = /^user:[A-Za-z0-9][A-Za-z0-9_.:@-]{0,122}(?![\s\S])/;
const idPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}(?![\s\S])/;
function serviceUser(args: string[]) {
if (args.length !== 1 || !userPattern.test(args[0])) throw Error("expected one user:<service-user-id>");
return args[0];
}
function endpoint(base: string) {
const u = new URL(base);
if (u.username || u.password || u.pathname !== "/" || u.search || u.hash ||
(u.protocol !== "https:" && !(u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))))
throw Error("API must be an HTTPS origin (explicit loopback HTTP mocks are allowed)");
return u.origin + "/network/auth-client";
}
function requestFor(user: string, client: string | undefined = undefined): RequestBody {
serviceUser([user]);
const body = {description: "service " + user, device_spec: "urnetwork-examples/typescript-server"};
if (client !== undefined) {
if (!idPattern.test(client)) throw Error("invalid mapped client ID");
Object.assign(body, {client_id: client});
}
return body;
}
function parseResponse(raw: string, expected: string | undefined = undefined): ClientResult {
const obj = JSON.parse(raw);
if (!obj || Array.isArray(obj) || obj.error != null || typeof obj.client_id !== "string" ||
!idPattern.test(obj.client_id) || typeof obj.by_client_jwt !== "string") throw Error("invalid API response");
const parts: string[] = obj.by_client_jwt.split(".");
if (parts.length !== 3 || parts.some(x => !x) || !/^[A-Za-z0-9_-]+(?![\s\S])/.test(parts[1])) throw Error("invalid client JWT");
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
if (claims.client_id !== obj.client_id || (expected !== undefined && expected !== obj.client_id))
throw Error("scoped client identity mismatch");
// Claim checking establishes consistency, not local signature verification.
return {client_id: obj.client_id, by_client_jwt: obj.by_client_jwt};
}
function loadMap(file: string): ClientMap {
if (!fs.existsSync(file)) return {version: 1, clients: {}};
const stat = fs.lstatSync(file);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit ||
(process.platform !== "win32" && (stat.mode & 0o077))) throw Error("mapping must be private (0600)");
const map = JSON.parse(fs.readFileSync(file, "utf8"));
if (map.version !== 1 || !map.clients || Array.isArray(map.clients) || typeof map.clients !== "object") throw Error("invalid mapping");
const seen = new Set();
for (const [user, client] of Object.entries(map.clients)) {
serviceUser([user]);
if (typeof client !== "string" || !idPattern.test(client) || seen.has(client)) throw Error("invalid mapped client");
seen.add(client);
}
return map;
}
function saveMap(file: string, map: ClientMap) {
const temporary = file + "." + randomUUID();
const fd = fs.openSync(temporary, "wx", 0o600);
try {
fs.writeFileSync(fd, JSON.stringify(map));
fs.fsyncSync(fd);
} finally { fs.closeSync(fd); }
try { fs.renameSync(temporary, file); }
finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
}
async function allocate(user: string, file: string, call: Provision) {
if (!path.isAbsolute(file) || !fs.statSync(path.dirname(file)).isDirectory()) throw Error("mapping needs an absolute path and existing parent");
const lock = file + ".lock";
fs.mkdirSync(lock, {mode: 0o700}); // Exclusive across load, remote allocation and atomic save.
try {
const map = loadMap(file);
const old = map.clients[user];
const result = parseResponse(await call(requestFor(user, old)), old);
if (old === undefined) {
if (Object.values(map.clients).includes(result.client_id)) throw Error("client assigned to another user");
map.clients[user] = result.client_id;
saveMap(file, map);
}
return result;
} finally { fs.rmdirSync(lock); }
}
async function post(url: string, root: string, body: RequestBody) {
if (!root || /\s/.test(root)) throw Error("set backend root JWT");
const response = await fetch(url, {method: "POST", headers: {authorization: "Bearer " + root, "content-type": "application/json"},
body: JSON.stringify(body), redirect: "error", signal: AbortSignal.timeout(15000)});
if (!response.ok || !response.body) throw Error("provisioning HTTP failure");
const reader = response.body.getReader();
const chunks: Uint8Array[] = []; let size = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
size += next.value.length;
if (size > limit) throw Error("response too large");
chunks.push(next.value);
}
} finally { await reader.cancel(); }
return Buffer.concat(chunks).toString("utf8");
}
async function selfTest() {
const client = "11111111-1111-1111-1111-111111111111";
const jwt = "e30." + Buffer.from(JSON.stringify({client_id: client})).toString("base64url") + ".test";
const response = JSON.stringify({client_id: client, by_client_jwt: jwt});
const calls: RequestBody[] = [];
const mock: Provision = async body => { calls.push(body); return response; };
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ur-allocator-"));
try {
const file = path.join(directory, "clients.json");
assert.equal((await allocate("user:alice", file, mock)).client_id, client);
assert.equal((await allocate("user:alice", file, mock)).by_client_jwt, jwt);
assert.equal(calls[0].client_id, undefined);
assert.equal(calls[0].source_client_id, undefined);
assert.equal(calls[1].client_id, client);
assert.equal(calls[1].source_client_id, undefined);
assert.deepEqual(loadMap(file).clients, {"user:alice": client});
assert.equal(endpoint("http://127.0.0.1:1234"), "http://127.0.0.1:1234/network/auth-client");
for (const reject of [() => serviceUser([client]), () => serviceUser(["user:a", "--client-id", client]),
() => serviceUser(["user:../a"]), () => serviceUser(["user:alice\n"]), () => endpoint("http://example.com"), () => endpoint("https://example.com/path"),
() => parseResponse('{"error":{}}'), () => parseResponse(response, "22222222-2222-2222-2222-222222222222")]) assert.throws(reject);
} finally { fs.rmSync(directory, {recursive: true, force: true}); }
console.log("allocator self-test passed");
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === "--self-test") return selfTest();
const user = serviceUser(args);
const url = endpoint(process.env.URNETWORK_API_URL || "https://api.bringyour.com");
const root = process.env.URNETWORK_ROOT_JWT;
const file = process.env.URNETWORK_CLIENT_MAP;
if (!root || !file) throw Error("set backend environment");
console.log(JSON.stringify(await allocate(user, file, body => post(url, root, body))));
}
main().catch(() => { console.error("allocator failed: check service key, private mapping and backend API configuration"); process.exitCode = 1; });