diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41254ea..ebe7354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,7 @@ jobs: - run: npx tsc --noEmit - name: Start disposable MongoDB replica set run: | - docker run -d --name review-consent-test --network host --memory=512m --cpus=1 --tmpfs /data/db:rw,noexec,nosuid,size=256m mongo:7.0@sha256:0e145625e78b94224d16222ff2609c4621ff6e2c390300e4e6bf698305596792 mongod --bind_ip 127.0.0.1 --port 28743 --replSet review-consent-test --oplogSize 16 + docker run -d --name review-consent-test --network host --memory=1g --cpus=1 --tmpfs /data/db:rw,noexec,nosuid,size=512m mongo:7.0@sha256:0e145625e78b94224d16222ff2609c4621ff6e2c390300e4e6bf698305596792 mongod --bind_ip 127.0.0.1 --port 28743 --replSet review-consent-test --oplogSize 16 for attempt in $(seq 1 40); do if docker exec review-consent-test mongosh --quiet --port 28743 --eval 'db.adminCommand({ping:1})' > /dev/null 2>&1; then break; fi sleep 1 @@ -60,9 +60,10 @@ jobs: env: TEST_REVIEW_CONSENT_MONGO: mongodb://127.0.0.1:28743/?replicaSet=review-consent-test TEST_REVIEW_CONSENT_PERF_REPORT: review-consent-perf.json + TEST_REVIEW_COMPLETION_HTTP_PERF: review-completion-http-perf.json TEST_REVIEW_COMPLETION_PERF_REPORT: review-completion-perf.json TEST_REVIEW_CONSENT_HTTP_PERF: review-consent-http-perf.json - run: npx mocha test/review-owner-consent.test.js test/review-consent-http.test.js + run: npx mocha test/review-owner-consent.test.js test/review-consent-http.test.js test/review-completion-http.test.js - uses: actions/upload-artifact@v4 with: name: review-consent-performance @@ -70,6 +71,7 @@ jobs: review-consent-perf.json review-consent-http-perf.json review-completion-perf.json + review-completion-http-perf.json - name: Remove disposable database if: always() run: docker rm -f review-consent-test || true diff --git a/docs/review-completion-http.md b/docs/review-completion-http.md new file mode 100644 index 0000000..c6c108b --- /dev/null +++ b/docs/review-completion-http.md @@ -0,0 +1,15 @@ +# Completion service transport + +`createReviewCompletionService` serves the exact `POST /service/v1/completions/exchange` and `GET /service/v1/intents/{intentId}/receipt` routes when explicitly mounted before body parsing and browser authentication. Startup does not mount it. An absent scoped registry returns typed 404 responses without touching the backend. + +The private registry has exactly `version: 1` and `keys`. Each key contains `clientId`, `keyId`, `tokenSHA256`, `notBefore`, `notAfter` and `scopes`. Identifiers, ASCII-token hashing and whole-second validity windows follow the capabilities registry. Each key must explicitly authorize `completion.exchange`, `receipt.read`, or both. The existing capabilities-only configuration is rejected because it has no operation scopes. Limits are 64 KiB, 64 keys, 32 clients and 16 keys per client. Duplicate fields, key identities, digests and scope entries are rejected. + +Requests use `X-4open-Artifact-Client-Id`, `X-4open-Artifact-Service-Key-Id` and `Authorization: Bearer `. TLS is required, directly or through explicitly trusted proxy configuration. Browser cookies, origin/referrer/fetch metadata, duplicate headers, encoded bodies, query strings and route aliases are rejected. Authorization precedes body reads. Constant-time digest comparison and the key window are checked, with validity rechecked before backend execution and before returning a result. + +The transport accepts at most 64 KiB of strict JSON. The shared decoder rejects invalid UTF-8, duplicate decoded keys and excessive nesting. Commands have exactly the v1 exchange fields. A successful receipt must contain exactly the expected client/intent scope and v1 receipt fields. Errors contain a fixed protocol code and fresh random correlation ID, never provider or database diagnostics. Responses disable caching, referrers and CORS. Rejections close the connection. + +A client is limited to 60 requests per minute across its rotation keys. At most four operations run concurrently. Each request has a 15-second deadline. A disconnected or timed-out caller does not release an in-progress backend slot until the backend settles. A write may commit after response loss; callers must retain and explicitly retry their original request. No automatic retry occurs here. + +Tests use real loopback HTTP, synthetic trusted-proxy TLS metadata and a simulated backend. They do not measure incoming TLS, MongoDB or provider latency. Set `TEST_REVIEW_COMPLETION_HTTP_PERF` to capture 50 authenticated receipt reads. The opt-in review-repository interoperability test also passes against this actual TypeScript handler over certificate-verified HTTPS. It covers explicit replay, changed-request conflicts, post-expiry receipt reads, policy mismatch, scope separation and untrusted certificates. Its backend remains synthetic. Credentials, startup wiring, browser callback handoff and activation remain unfinished; capabilities still advertise no available integration features. + +The dedicated CI MongoDB fixture uses one CPU, a 1 GiB memory cap and a 512 MiB temporary data mount. The earlier 256 MiB mount failed during replica-set initialization with a no-space error; the increased fixture is still isolated and removed after testing. This does not change production storage or resource limits. diff --git a/src/server/service/review-completion-http.ts b/src/server/service/review-completion-http.ts new file mode 100644 index 0000000..b33d751 --- /dev/null +++ b/src/server/service/review-completion-http.ts @@ -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 { + 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, + "exchange" | "receipt" + >, + now: () => number = Date.now, +): RequestHandler { + const auth = + raw === undefined ? undefined : createReviewServiceAuth(raw, now); + const rates = new Map(); + 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((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[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--; + } + }; +} diff --git a/src/server/service/review-intent-client.ts b/src/server/service/review-intent-client.ts index 4e918a2..17dcd5f 100644 --- a/src/server/service/review-intent-client.ts +++ b/src/server/service/review-intent-client.ts @@ -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(); - 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, diff --git a/src/server/service/review-json.ts b/src/server/service/review-json.ts new file mode 100644 index 0000000..abc6cd0 --- /dev/null +++ b/src/server/service/review-json.ts @@ -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(); + 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; +} diff --git a/src/server/service/review-service-auth.ts b/src/server/service/review-service-auth.ts new file mode 100644 index 0000000..d6bba33 --- /dev/null +++ b/src/server/service/review-service-auth.ts @@ -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 { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).sort().join(",") !== names.sort().join(",") + ) + return invalid(); + return value as Record; +} +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(), + digests = new Set(), + clients = new Map(); + 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; + }, + }); +} diff --git a/test/review-completion-http.test.js b/test/review-completion-http.test.js new file mode 100644 index 0000000..6e1d02f --- /dev/null +++ b/test/review-completion-http.test.js @@ -0,0 +1,348 @@ +require("ts-node/register/transpile-only"); +const { expect } = require("chai"); +const express = require("express"); +const http = require("http"); +const { createHash } = require("crypto"); +const { setTimeout: delay } = require("timers/promises"); +const { + createReviewCompletionService, +} = require("../src/server/service/review-completion-http"); +const { + ReviewConsentError, +} = require("../src/server/service/review-owner-consent"); +const client = "1".repeat(32), + intent = "2".repeat(32), + token = "3".repeat(64); +const exchangePath = "/service/v1/completions/exchange"; +const readPath = `/service/v1/intents/${intent}/receipt`; +const command = () => ({ + contract: "4open.artifacts/1", + clientId: client, + intentId: intent, + code: "4".repeat(64), + requestId: "5".repeat(32), +}); +const receipt = () => ({ + contract: "4open.artifacts/1", + clientId: client, + intentId: intent, + bindingId: "6".repeat(32), + submissionRef: "7".repeat(32), + entitlementId: "8".repeat(32), + policy: { + version: 1, + access: "restricted-review", + retainUntil: "2199-01-01T00:00:00Z", + }, +}); +const config = (scopes = ["completion.exchange", "receipt.read"]) => + JSON.stringify({ + version: 1, + keys: [ + { + clientId: client, + keyId: "current", + tokenSHA256: createHash("sha256").update(token).digest("hex"), + notBefore: "2026-01-01T00:00:00Z", + notAfter: "2027-01-01T00:00:00Z", + scopes, + }, + ], + }); +describe("review completion service HTTP transport", function () { + this.timeout(20000); + let server, backend, calls, clock; + async function start(raw = config(), beforeParser = true) { + const app = express(); + app.set("trust proxy", "loopback"); + if (!beforeParser) app.use(express.json()); + app.use( + createReviewCompletionService( + raw === null ? undefined : raw, + backend, + () => clock, + ), + ); + server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + } + beforeEach(async () => { + clock = Date.parse("2026-09-27T12:00:00Z"); + calls = []; + backend = { + exchange: async (id, body) => { + calls.push({ id, body }); + return receipt(); + }, + receipt: async (id) => { + calls.push({ id }); + return receipt(); + }, + }; + }); + afterEach(async () => { + if (server) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + server = undefined; + } + }); + function request(path = exchangePath, options = {}) { + const body = + options.raw === undefined + ? path === exchangePath && options.body !== false + ? JSON.stringify(options.body || command()) + : undefined + : options.raw; + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: server.address().port, + path, + method: options.method || (body === undefined ? "GET" : "POST"), + headers: { + "X-Forwarded-Proto": "https", + "X-4open-Artifact-Client-ID": client, + "X-4open-Artifact-Service-Key-ID": "current", + Authorization: "Bearer " + token, + ...(body === undefined + ? {} + : { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }), + ...options.headers, + }, + }, + (res) => { + let text = ""; + res.on("data", (chunk) => { + text += chunk; + }); + res.on("end", () => + resolve({ + status: res.statusCode, + headers: res.headers, + body: JSON.parse(text), + }), + ); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); + } + it("requires scoped service credentials and returns the exact client receipt", async () => { + await start(); + const result = await request(); + expect(result.status).equal(200); + expect(result.body).deep.equal(receipt()); + expect(result.headers["cache-control"]).equal("no-store"); + expect(result.headers["referrer-policy"]).equal("no-referrer"); + expect(result.headers["set-cookie"]).equal(undefined); + expect(result.headers["access-control-allow-origin"]).equal(undefined); + expect((await request(readPath)).status).equal(200); + expect(calls).length(2); + }); + it("stays disabled without a dedicated registry", async () => { + await start(null); + expect((await request()).status).equal(404); + expect(calls).length(0); + }); + it("does not permit a read credential to exchange a completion", async () => { + await start(config(["receipt.read"])); + expect((await request()).status).equal(401); + expect((await request(readPath)).status).equal(200); + expect(calls).length(1); + }); + it("rejects capabilities-only configuration and malformed or duplicate registry fields", () => { + const old = JSON.parse(config()); + delete old.keys[0].scopes; + for (const raw of [ + JSON.stringify(old), + config().replace('"version":1', '"version":1,"version":1'), + config().replace('"receipt.read"', '"unknown"'), + "{", + ]) + expect(() => createReviewCompletionService(raw, backend)).to.throw( + "Invalid review exchange credentials", + ); + }); + it("rejects browser context, cleartext, encodings and duplicate authentication headers", async () => { + await start(); + for (const headers of [ + { Cookie: "session=untrusted" }, + { Origin: "https://example.test" }, + { Referer: "https://example.test" }, + { "Sec-Fetch-Site": "none" }, + { "X-Forwarded-Proto": "http" }, + { "Content-Encoding": "gzip" }, + { Authorization: ["Bearer " + token, "Bearer " + token] }, + ]) { + const result = await request(exchangePath, { headers }); + expect(result.status).oneOf([400, 401, 403]); + expect(result.headers.connection).equal("close"); + } + expect(calls).length(0); + }); + it("rejects noncanonical routes, queries, read bodies and wrong methods", async () => { + await start(); + for (const path of [ + exchangePath + "/", + exchangePath + "?x=1", + readPath + "?x=1", + "/service/v1/intents/" + intent.toUpperCase() + "F/receipt", + ]) + expect((await request(path, { method: "POST", raw: "{}" })).status).equal( + 404, + ); + expect( + (await request(exchangePath, { method: "GET", body: false })).status, + ).equal(405); + expect( + (await request(readPath, { method: "GET", raw: "{}" })).status, + ).equal(400); + expect(calls).length(0); + }); + it("rejects duplicate decoded JSON keys, unknown fields, malformed UTF-8 and excessive bodies", async () => { + await start(); + const valid = JSON.stringify(command()); + for (const raw of [ + valid.replace('"code":', '"code":"' + "0".repeat(64) + '","co\\u0064e":'), + JSON.stringify({ ...command(), extra: true }), + valid + "{}", + "[]", + Buffer.from([123, 34, 120, 34, 58, 34, 255, 34, 125]), + " ".repeat(65537), + ]) + expect((await request(exchangePath, { raw })).status).equal(400); + expect(calls).length(0); + }); + it("rejects wrong client scope and unsupported wire versions", async () => { + await start(); + expect( + ( + await request(exchangePath, { + body: { ...command(), clientId: "a".repeat(32) }, + }) + ).status, + ).equal(403); + expect( + ( + await request(exchangePath, { + body: { ...command(), contract: "4open.artifacts/2" }, + }) + ).status, + ).equal(422); + expect(calls).length(0); + }); + it("fails closed if mounted after a JSON parser", async () => { + await start(config(), false); + expect((await request()).status).equal(503); + expect(calls).length(0); + }); + it("rechecks credential expiry after backend work and filters invalid receipts", async () => { + backend.exchange = async () => { + clock = Date.parse("2027-01-01T00:00:00Z"); + return receipt(); + }; + await start(); + expect((await request()).status).equal(401); + clock = Date.parse("2026-09-27T12:00:00Z"); + backend.exchange = async () => ({ ...receipt(), owner: "private-owner" }); + const result = await request(); + expect(result.status).equal(503); + expect(JSON.stringify(result.body)).not.include("private-owner"); + }); + it("uses fixed errors without exposing backend details", async () => { + await start(); + for (const [kind, status] of [ + ["invalid", 400], + ["forbidden", 403], + ["expired", 410], + ["conflict", 409], + ["unavailable", 503], + ]) { + backend.exchange = async () => { + throw new ReviewConsentError(kind); + }; + const result = await request(); + expect(result.status).equal(status); + expect(result.body.requestId).match(/^[a-f0-9]{32}$/); + } + backend.exchange = async () => { + throw new Error("secret database diagnostics"); + }; + const result = await request(); + expect(result.status).equal(503); + expect(JSON.stringify(result.body)).not.include("diagnostics"); + }); + it("limits concurrent operations until the backend settles", async () => { + const pending = []; + backend.exchange = () => new Promise((resolve) => pending.push(resolve)); + await start(); + const requests = Array.from({ length: 4 }, () => request()); + for (let i = 0; i < 100 && pending.length < 4; i++) await delay(5); + expect(pending).length(4); + expect((await request()).status).equal(503); + pending.forEach((resolve) => resolve(receipt())); + expect((await Promise.all(requests)).every((r) => r.status === 200)).equal( + true, + ); + backend.exchange = async () => receipt(); + expect((await request()).status).equal(200); + }); + it("limits client requests and restores capacity in the next minute", async () => { + await start(); + for (let i = 0; i < 60; i++) + expect((await request(readPath)).status).equal(200); + expect((await request(readPath)).status).equal(429); + clock += 60000; + expect((await request(readPath)).status).equal(200); + }); + it("closes unauthorized incomplete uploads before reading their bodies", async () => { + await start(); + const startTime = Date.now(); + const result = await request(exchangePath, { + raw: "{", + headers: { + Authorization: "Bearer " + "0".repeat(64), + "Content-Length": "60000", + }, + }); + expect(result.status).equal(401); + expect(Date.now() - startTime).lessThan(2000); + expect(calls).length(0); + }); + it("bounds authenticated incomplete uploads without invoking the backend", async () => { + await start(); + const result = await request(exchangePath, { + raw: "{", + headers: { "Content-Length": "60000" }, + }); + expect(result.status).equal(503); + expect(calls).length(0); + }); + it('supports overlapping rotation keys with independent validity windows', async () => { + const settings=JSON.parse(config()), nextToken='b'.repeat(64); + settings.keys[0].notAfter='2026-09-27T12:00:01Z'; + settings.keys.push({...settings.keys[0],keyId:'next',tokenSHA256:createHash('sha256').update(nextToken).digest('hex'),notBefore:'2026-09-27T12:00:00Z',notAfter:'2027-01-01T00:00:00Z'}); + await start(JSON.stringify(settings)); + const headers={'X-4open-Artifact-Service-Key-ID':'next',Authorization:'Bearer '+nextToken}; + expect((await request(readPath)).status).equal(200); + expect((await request(readPath,{headers})).status).equal(200); + clock+=1000; expect((await request(readPath)).status).equal(401); + expect((await request(readPath,{headers})).status).equal(200); + clock-=2000; expect((await request(readPath,{headers})).status).equal(401); + }); + it('measures 50 authenticated receipt reads with a synthetic backend', async () => { + await start(); const times=[]; const process=require('process'); + for(let i=0;i<50;i++) { const startTime=process.hrtime.bigint(); expect((await request(readPath)).status).equal(200); times.push(Number(process.hrtime.bigint()-startTime)/1e6); } + times.sort((a,b)=>a-b); + if(process.env.TEST_REVIEW_COMPLETION_HTTP_PERF) require('fs').writeFileSync(process.env.TEST_REVIEW_COMPLETION_HTTP_PERF,JSON.stringify({calls:50,workload:'loopback HTTP with trusted-proxy TLS metadata and synthetic backend, no provider or database',medianMs:times[24],p95Ms:times[47]},null,2)+'\n'); + }); + +});