Add scoped completion exchange service transport

This commit is contained in:
Thomas Durieux committed 2026-09-27 15:18:49 +00:00
1 parent d909bf281b
commit 7fe60a89aa
7 files changed
+858 -60

No files matched your search

@@ -0,0 +1,262 @@
import { randomBytes } from "crypto";
import { RequestHandler } from "express";
import {
createReviewOwnerConsent,
ReviewConsentError,
} from "./review-owner-consent";
import {
createReviewServiceAuth,
singleReviewHeader,
} from "./review-service-auth";
import { decodeReviewJSON } from "./review-json";
const contract = "4open.artifacts/1";
const opaque = /^[a-f0-9]{32}$/;
function exact(
value: unknown,
keys: string[],
): value is Record<string, unknown> {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).sort().join(",") === keys.sort().join(",")
);
}
function receipt(value: unknown, clientId: string, intentId: string): unknown {
if (
!exact(value, [
"contract",
"clientId",
"intentId",
"bindingId",
"submissionRef",
"policy",
"entitlementId",
]) ||
value.contract !== contract ||
value.clientId !== clientId ||
value.intentId !== intentId ||
![value.bindingId, value.submissionRef, value.entitlementId].every(
(id) => typeof id === "string" && opaque.test(id),
) ||
!exact(value.policy, ["version", "access", "retainUntil"])
)
throw new Error("Invalid receipt");
const policy = value.policy;
if (
!Number.isSafeInteger(policy.version) ||
(policy.version as number) < 1 ||
!["anonymous-link", "restricted-review"].includes(
policy.access as string,
) ||
typeof policy.retainUntil !== "string" ||
!/^2[01][0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(
policy.retainUntil,
) ||
!Number.isFinite(Date.parse(policy.retainUntil)) ||
new Date(policy.retainUntil).toISOString() !==
policy.retainUntil.replace("Z", ".000Z")
)
throw new Error("Invalid receipt");
return value;
}
/** Mount before global JSON parsing and browser authentication. Disabled without
* its own scoped registry. This factory is intentionally not mounted at startup. */
export function createReviewCompletionService(
raw: string | undefined,
backend: Pick<
ReturnType<typeof createReviewOwnerConsent>,
"exchange" | "receipt"
>,
now: () => number = Date.now,
): RequestHandler {
const auth =
raw === undefined ? undefined : createReviewServiceAuth(raw, now);
const rates = new Map<string, { minute: number; count: number }>();
let active = 0;
return async (req, res) => {
res.set({
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
});
res.removeHeader("Access-Control-Allow-Origin");
const fail = (status: number, code: string) => {
if (res.headersSent || res.destroyed) return;
res.setHeader("Connection", "close");
res
.status(status)
.json({ contract, code, requestId: randomBytes(16).toString("hex") });
};
const exchange = req.originalUrl === "/service/v1/completions/exchange";
const read = /^\/service\/v1\/intents\/([a-f0-9]{32})\/receipt$/.exec(
req.originalUrl,
);
if (!auth || (!exchange && !read)) return fail(404, "not-found");
if (req.method !== (exchange ? "POST" : "GET"))
return fail(405, "invalid-request");
if (
!req.secure ||
[
"cookie",
"origin",
"referer",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"sec-fetch-user",
].some((name) => req.headers[name] !== undefined)
)
return fail(403, "forbidden");
if (req.body !== undefined) return fail(503, "unavailable");
if (req.headers["content-encoding"] !== undefined)
return fail(400, "invalid-request");
for (const name of ["content-type", "content-length", "transfer-encoding"])
if (
req.headers[name] !== undefined &&
singleReviewHeader(req, name) === undefined
)
return fail(400, "invalid-request");
if (exchange) {
if (
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
singleReviewHeader(req, "content-type") || "",
)
)
return fail(400, "invalid-request");
const length = req.headers["content-length"];
if (
length !== undefined &&
(!/^\d+$/.test(String(length)) || Number(length) > 65536)
)
return fail(400, "invalid-request");
} else if (
req.headers["transfer-encoding"] !== undefined ||
req.headers["content-type"] !== undefined ||
(req.headers["content-length"] !== undefined &&
req.headers["content-length"] !== "0")
)
return fail(400, "invalid-request");
const principal = auth.authenticate(
req,
exchange ? "completion.exchange" : "receipt.read",
);
if (!principal) return fail(401, "unauthorized");
const minute = Math.floor(now() / 60000);
let rate = rates.get(principal.clientId);
if (!rate || rate.minute !== minute) {
rate = { minute, count: 0 };
rates.set(principal.clientId, rate);
}
if (rate.count++ >= 60) return fail(429, "rate-limited");
if (active >= 4) return fail(503, "unavailable");
active++;
let cancelBody: (() => void) | undefined;
let ended = false;
const close = () => {
ended = true;
cancelBody?.();
};
res.once("close", close);
const timer = setTimeout(() => {
ended = true;
fail(503, "unavailable");
cancelBody?.();
}, 15000);
try {
let intentId = read?.[1] || "";
let result: unknown;
if (exchange) {
const bytes = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
const cleanup = () => {
req.off("data", data);
req.off("end", end);
req.off("aborted", abort);
req.off("error", abort);
cancelBody = undefined;
};
const abort = () => {
cleanup();
reject(new Error("Request unavailable"));
};
const data = (chunk: Buffer) => {
size += chunk.length;
if (size > 65536) {
fail(400, "invalid-request");
abort();
} else chunks.push(chunk);
};
const end = () => {
cleanup();
resolve(Buffer.concat(chunks));
};
cancelBody = abort;
req.on("data", data);
req.once("end", end);
req.once("aborted", abort);
req.once("error", abort);
});
if (ended) return;
let command: unknown;
try {
command = decodeReviewJSON(bytes);
} catch {
return fail(400, "invalid-request");
}
if (
!exact(command, [
"contract",
"clientId",
"intentId",
"code",
"requestId",
]) ||
typeof command.contract !== "string" ||
![command.clientId, command.intentId, command.requestId].every(
(id) => typeof id === "string" && opaque.test(id),
) ||
typeof command.code !== "string" ||
!/^[a-f0-9]{64}$/.test(command.code)
)
return fail(400, "invalid-request");
if (command.contract !== contract)
return fail(422, "unsupported-version");
if (command.clientId !== principal.clientId)
return fail(403, "forbidden");
if (!principal.valid()) return fail(401, "unauthorized");
intentId = command.intentId as string;
result = await backend.exchange(
principal.clientId,
command as Parameters<typeof backend.exchange>[1],
);
} else {
if (!principal.valid()) return fail(401, "unauthorized");
result = await backend.receipt(principal.clientId, intentId);
}
if (ended) return;
if (!principal.valid()) return fail(401, "unauthorized");
res.status(200).json(receipt(result, principal.clientId, intentId));
} catch (error) {
if (ended) return;
if (error instanceof ReviewConsentError) {
const codes = {
invalid: [400, "invalid-request"],
forbidden: [403, "forbidden"],
expired: [410, "expired"],
conflict: [409, "conflict"],
unavailable: [503, "unavailable"],
} as const;
const [status, code] = codes[error.kind];
fail(status, code);
} else fail(503, "unavailable");
} finally {
clearTimeout(timer);
res.off("close", close);
active--;
}
};
}
+4 -59
View File
@@ -1,3 +1,4 @@
import { decodeReviewJSON } from "./review-json";
import * as https from "https";
import { ClientRequest } from "http";
@@ -81,70 +82,14 @@ function id(value: unknown): value is string {
return typeof value === "string" && opaque.test(value);
}
// JSON.parse checks syntax; this bounded walk additionally rejects duplicate
// decoded keys, including escaped spellings, before typed field validation.
function decode(bytes: Buffer): unknown {
if (bytes.length > limit) fail("protocol");
const text = bytes.toString("utf8");
if (!Buffer.from(text, "utf8").equals(bytes)) fail("protocol");
let parsed: unknown;
try {
parsed = JSON.parse(text);
return decodeReviewJSON(bytes);
} catch {
fail("protocol");
}
let at = 0;
const space = () => {
while (/\s/.test(text[at] || "") && at < text.length) at++;
};
const string = (): string => {
const start = at++;
while (at < text.length) {
if (text[at] === "\\") {
at += 2;
continue;
}
if (text[at++] === '"') return JSON.parse(text.slice(start, at));
}
return fail("protocol");
};
const walk = (depth: number): void => {
if (depth > 8) fail("protocol");
space();
if (text[at] === '"') {
string();
return;
}
if (text[at] === "{" || text[at] === "[") {
const isObject = text[at++] === "{",
end = isObject ? "}" : "]",
keys = new Set<string>();
space();
if (text[at] === end) {
at++;
return;
}
for (;;) {
space();
if (isObject) {
const key = string();
if (keys.has(key)) fail("protocol");
keys.add(key);
space();
at++; // colon; syntax already checked
}
walk(depth + 1);
space();
if (text[at++] === end) return;
}
}
while (at < text.length && !/[\s,}\]]/.test(text[at])) at++;
};
walk(0);
space();
if (at !== text.length) fail("protocol");
return parsed;
}
}
function intent(
bytes: Buffer,
clientId: string,
+68
View File
@@ -0,0 +1,68 @@
function invalid(): never {
throw new Error("Invalid review JSON");
}
// JSON.parse checks syntax; this bounded walk additionally rejects duplicate
// decoded keys, including escaped spellings, before typed field validation.
export function decodeReviewJSON(bytes: Buffer): unknown {
if (bytes.length > 65536) invalid();
const text = bytes.toString("utf8");
if (!Buffer.from(text, "utf8").equals(bytes)) invalid();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
invalid();
}
let at = 0;
const space = () => {
while (/\s/.test(text[at] || "") && at < text.length) at++;
};
const string = (): string => {
const start = at++;
while (at < text.length) {
if (text[at] === "\\") {
at += 2;
continue;
}
if (text[at++] === '"') return JSON.parse(text.slice(start, at));
}
return invalid();
};
const walk = (depth: number): void => {
if (depth > 8) invalid();
space();
if (text[at] === '"') {
string();
return;
}
if (text[at] === "{" || text[at] === "[") {
const isObject = text[at++] === "{",
end = isObject ? "}" : "]",
keys = new Set<string>();
space();
if (text[at] === end) {
at++;
return;
}
for (;;) {
space();
if (isObject) {
const key = string();
if (keys.has(key)) invalid();
keys.add(key);
space();
at++; // colon; syntax already checked
}
walk(depth + 1);
space();
if (text[at++] === end) return;
}
}
while (at < text.length && !/[\s,}\]]/.test(text[at])) at++;
};
walk(0);
space();
if (at !== text.length) invalid();
return parsed;
}
+160
View File
@@ -0,0 +1,160 @@
import { createHash, timingSafeEqual } from "crypto";
import { Request } from "express";
import { decodeReviewJSON } from "./review-json";
type Scope = "completion.exchange" | "receipt.read";
type Key = {
clientId: string;
digest: Buffer;
from: number;
until: number;
scopes: Scope[];
};
const opaque = /^[a-f0-9]{32}$/;
const secret = /^[a-f0-9]{64}$/;
const invalid = (): never => {
throw new Error("Invalid review exchange credentials");
};
function fields(value: unknown, names: string[]): Record<string, unknown> {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).sort().join(",") !== names.sort().join(",")
)
return invalid();
return value as Record<string, unknown>;
}
function date(value: unknown): number {
if (
typeof value !== "string" ||
!/^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
)
return invalid();
const number = Date.parse(value);
if (
!Number.isFinite(number) ||
new Date(number).toISOString() !== value.replace("Z", ".000Z")
)
return invalid();
return number;
}
export function singleReviewHeader(
req: Request,
name: string,
): string | undefined {
let count = 0;
for (let i = 0; i < req.rawHeaders.length; i += 2)
if (req.rawHeaders[i].toLowerCase() === name) count++;
const value = req.headers[name];
return count === 1 && typeof value === "string" ? value : undefined;
}
/** Separate scoped registry: the capabilities-only registry is not accepted. */
export function createReviewServiceAuth(
raw: string,
now: () => number = Date.now,
) {
let parsed: unknown;
try {
parsed = decodeReviewJSON(Buffer.from(raw, "utf8"));
} catch {
return invalid();
}
const config = fields(parsed, ["version", "keys"]);
if (
config.version !== 1 ||
!Array.isArray(config.keys) ||
config.keys.length < 1 ||
config.keys.length > 64
)
return invalid();
const keys = new Map<string, Key>(),
digests = new Set<string>(),
clients = new Map<string, number>();
for (const entry of config.keys) {
const k = fields(entry, [
"clientId",
"keyId",
"tokenSHA256",
"notBefore",
"notAfter",
"scopes",
]);
if (
typeof k.clientId !== "string" ||
!opaque.test(k.clientId) ||
typeof k.keyId !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(k.keyId) ||
typeof k.tokenSHA256 !== "string" ||
!secret.test(k.tokenSHA256) ||
!Array.isArray(k.scopes) ||
k.scopes.length < 1 ||
k.scopes.length > 2 ||
k.scopes.some(
(scope) => scope !== "completion.exchange" && scope !== "receipt.read",
) ||
new Set(k.scopes).size !== k.scopes.length
)
return invalid();
const from = date(k.notBefore),
until = date(k.notAfter),
id = k.clientId + ":" + k.keyId;
const count = (clients.get(k.clientId) || 0) + 1;
if (
until <= from ||
keys.has(id) ||
digests.has(k.tokenSHA256) ||
count > 16
)
return invalid();
clients.set(k.clientId, count);
if (clients.size > 32) return invalid();
digests.add(k.tokenSHA256);
keys.set(id, {
clientId: k.clientId,
digest: Buffer.from(k.tokenSHA256, "hex"),
from,
until,
scopes: [...k.scopes] as Scope[],
});
}
const dummy = Buffer.alloc(32);
return Object.freeze({
authenticate(
req: Request,
scope: Scope,
): { clientId: string; valid: () => boolean } | undefined {
const client = singleReviewHeader(req, "x-4open-artifact-client-id"),
keyId = singleReviewHeader(req, "x-4open-artifact-service-key-id"),
authorization = singleReviewHeader(req, "authorization");
if (
!client ||
!opaque.test(client) ||
!keyId ||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!authorization ||
!/^Bearer [a-f0-9]{64}$/i.test(authorization) ||
!secret.test(authorization.slice(7))
)
return undefined;
const key = keys.get(client + ":" + keyId);
const matches = timingSafeEqual(
createHash("sha256").update(authorization.slice(7), "ascii").digest(),
key?.digest || dummy,
);
const valid = () => {
const time = now();
return (
!!key &&
matches &&
key.scopes.includes(scope) &&
Number.isFinite(time) &&
time >= key.from &&
time < key.until
);
};
return valid() ? Object.freeze({ clientId: client, valid }) : undefined;
},
});
}