Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -60,16 +60,18 @@ 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
path: |
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
15 changes: 15 additions & 0 deletions docs/review-completion-http.md
Original file line number Diff line number Diff line change
@@ -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 <token>`. 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.
262 changes: 262 additions & 0 deletions src/server/service/review-completion-http.ts
Original file line number Diff line number Diff line change
@@ -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--;
}
};
}
63 changes: 4 additions & 59 deletions src/server/service/review-intent-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { decodeReviewJSON } from "./review-json";
import * as https from "https";
import { ClientRequest } from "http";

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading