fix: validate the snapshot that installs, not the object that was shown
Three trust boundaries checked a caller's object and then read it again to use it. Between those two reads an accessor or a Proxy can answer differently, so the value that passed validation and the value that was installed were not the same value. A credential owner's answer was read field by field outside the auth boundary: a throwing `kind` getter escaped into the transport catch and an auth outage reached operators as `NETWORK_FAILURE`. Contract composition validated a contribution and then copied it, so a policy that answered 10,000 to the ceiling check and 999,999 to the copy installed the second value. The cursor runtime validated its profile once and re-read it on every page, so raising `maxPages` after construction widened a cap that had already been checked. `src/contracts/exact-snapshot.ts` is the one descriptor-based decoder they now share: every property is read exactly once, an accessor, a symbol, an inherited or non-enumerable field and a throwing trap all resolve to a typed failure, and validation runs on the owned copy. Separately, the `responseBody: NONE` probe awaited a bare `read()`. The deadline produced a bounded public result while the raw reader kept its lease, so the body stayed locked and the outer compensator could not cancel it. The probe now takes the operation lifetime and owns the cancel and the lock release itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc91fc6ae0
commit
df18349682
@@ -116,6 +116,7 @@ export async function readBoundedBytes(
|
||||
*/
|
||||
export async function probeForbiddenBody(
|
||||
response: Response,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BodyProbeOutcome> {
|
||||
const declared = declaredContentLength(response);
|
||||
if (declared !== null && declared > 0) {
|
||||
@@ -128,7 +129,20 @@ export async function probeForbiddenBody(
|
||||
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
const next = await reader.read();
|
||||
// NS-03. The probe owns the reader it opened, so the operation's lifetime
|
||||
// has to reach it. Awaiting a bare `read()` left a non-cooperative stream
|
||||
// locked after the deadline had already closed the public result, and the
|
||||
// outer compensator could not cancel a body this reader still held.
|
||||
const next = await readOrAbandon(reader, signal);
|
||||
if (next === READ_ABANDONED) {
|
||||
// Never awaited: cancelling a stream whose source ignores its signal can
|
||||
// itself hang, and the caller already owns the terminal result.
|
||||
void reader.cancel().catch(() => {});
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
code: "RESPONSE_STREAM_FAILURE" as const,
|
||||
});
|
||||
}
|
||||
if (next.done || !next.value || next.value.byteLength === 0) {
|
||||
return Object.freeze({ ok: true as const, present: false });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
INSTALLED_REST_AUTH_PROFILES,
|
||||
type InstalledRestAuthProfiles,
|
||||
} from "../../contracts/rest-profiles.ts";
|
||||
import {
|
||||
snapshotExactObject,
|
||||
snapshotOwnDataRecord,
|
||||
} from "../../contracts/exact-snapshot.ts";
|
||||
import {
|
||||
certaintyForAbandonedAttempt,
|
||||
classifyProblemEffect,
|
||||
@@ -604,25 +608,12 @@ export function createContractHttpExecutor(
|
||||
"TIMEOUT",
|
||||
);
|
||||
}
|
||||
const patch = patchResult;
|
||||
if (patch?.kind === "SCOPE_FENCED") {
|
||||
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
||||
}
|
||||
if (patch?.kind === "UNAUTHENTICATED") {
|
||||
// A missing credential never downgrades into an anonymous request.
|
||||
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
||||
}
|
||||
if (patch?.kind === "UNAVAILABLE") {
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_UNAVAILABLE", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
if (
|
||||
patch?.kind !== "READY" ||
|
||||
patch.headers === null ||
|
||||
typeof patch.headers !== "object"
|
||||
) {
|
||||
// NS-01. The whole answer is decoded once, inside the auth boundary, before
|
||||
// any field is used. Reading `kind` and `headers` off the raw object left
|
||||
// the decode outside that boundary: a throwing getter escaped into the
|
||||
// transport catch and an auth outage was classified as a network failure.
|
||||
const patch = decodeCredentialPatch(patchResult);
|
||||
if (patch === null) {
|
||||
// An off-contract answer is a collaborator breach, never a session
|
||||
// verdict the caller may act on.
|
||||
return finish(
|
||||
@@ -630,6 +621,19 @@ export function createContractHttpExecutor(
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
if (patch.kind === "SCOPE_FENCED") {
|
||||
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
|
||||
}
|
||||
if (patch.kind === "UNAUTHENTICATED") {
|
||||
// A missing credential never downgrades into an anonymous request.
|
||||
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
|
||||
}
|
||||
if (patch.kind === "UNAVAILABLE") {
|
||||
return finish(
|
||||
authIntegrationFailure("CREDENTIAL_OWNER_UNAVAILABLE", isCommand),
|
||||
"AUTH_INTEGRATION_FAILURE",
|
||||
);
|
||||
}
|
||||
|
||||
// The idempotency key is contract-owned, so a credential owner supplying it
|
||||
// stays the more specific request-contract violation.
|
||||
@@ -926,6 +930,53 @@ export function createContractHttpExecutor(
|
||||
return Object.freeze({ execute });
|
||||
}
|
||||
|
||||
const CREDENTIAL_HEADER_VALUE_CEILING = 8_192;
|
||||
|
||||
/**
|
||||
* NS-01. Decodes a credential owner's answer into an owned, frozen value. Every
|
||||
* field is read exactly once through its own data descriptor, so an accessor, a
|
||||
* Proxy that answers differently on a second read, an inherited or smuggled
|
||||
* field, or a trap that throws all resolve to `null` — a collaborator breach —
|
||||
* rather than escaping as an exception or being installed unvalidated.
|
||||
*/
|
||||
function decodeCredentialPatch(
|
||||
source: unknown,
|
||||
): CredentialPatchOutcome | null {
|
||||
const outer = snapshotExactObject(source, {
|
||||
allowed: ["kind", "headers"],
|
||||
required: ["kind"],
|
||||
});
|
||||
if (outer === null) return null;
|
||||
const kind = outer["kind"];
|
||||
if (
|
||||
kind === "UNAUTHENTICATED" ||
|
||||
kind === "UNAVAILABLE" ||
|
||||
kind === "SCOPE_FENCED"
|
||||
) {
|
||||
return Object.hasOwn(outer, "headers")
|
||||
? null
|
||||
: Object.freeze({ kind } as const);
|
||||
}
|
||||
if (kind !== "READY") return null;
|
||||
|
||||
// The key set stays open here so the profile's own admission — and the more
|
||||
// specific reserved-header violation — can still report the precise reason.
|
||||
const headers = snapshotOwnDataRecord(outer["headers"]);
|
||||
if (headers === null) return null;
|
||||
for (const value of Object.values(headers)) {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > CREDENTIAL_HEADER_VALUE_CEILING
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers,
|
||||
}) as CredentialPatchOutcome;
|
||||
}
|
||||
|
||||
type AdmissionOutcome<Value, Problem> = Readonly<{
|
||||
result: HttpExecutionOutcome<Value, Problem>;
|
||||
certainty: string;
|
||||
@@ -1013,7 +1064,7 @@ async function admitResponse<Input, WireOutput, Problem>(
|
||||
|
||||
// Success status: body policy first.
|
||||
if (contract.responseBody === "NONE") {
|
||||
const probe = await probeForbiddenBody(response);
|
||||
const probe = await probeForbiddenBody(response, signal);
|
||||
if (!probe.ok) {
|
||||
return settled(
|
||||
transportFailure(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CursorPaginationRuntime,
|
||||
} from "../../contracts/cursor-pagination.ts";
|
||||
import { createFailure } from "../../contracts/errors.ts";
|
||||
import { snapshotExactObject } from "../../contracts/exact-snapshot.ts";
|
||||
|
||||
const ABORTED = Symbol("PAGINATION_ABORTED");
|
||||
|
||||
@@ -53,7 +54,17 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
context: Readonly<{ signal?: AbortSignal }>,
|
||||
): Promise<Result<CursorPage<Value>>>;
|
||||
}>): CursorPaginationRuntime<Value> {
|
||||
validateProfile(dependencies.profile);
|
||||
// NS-07. The caps are captured once, here. Validating the caller's profile and
|
||||
// then reading it again on every page let a `maxPages` of 1 become 3 after
|
||||
// construction, so the request count, item total and byte ceiling that were
|
||||
// checked were not the ones the loop enforced. The collaborators are captured
|
||||
// for the same reason.
|
||||
const profile = snapshotProfile(dependencies.profile);
|
||||
const definitionId = dependencies.definitionId;
|
||||
const loadPage = dependencies.loadPage;
|
||||
if (typeof definitionId !== "string" || typeof loadPage !== "function") {
|
||||
throw new TypeError("Invalid cursor pagination dependencies.");
|
||||
}
|
||||
return Object.freeze({
|
||||
async loadAll(context) {
|
||||
const items: Value[] = [];
|
||||
@@ -62,7 +73,7 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
let snapshot: string | null | undefined;
|
||||
for (
|
||||
let pageIndex = 0;
|
||||
pageIndex < dependencies.profile.maxPages;
|
||||
pageIndex < profile.maxPages;
|
||||
pageIndex += 1
|
||||
) {
|
||||
if (context.signal?.aborted) {
|
||||
@@ -74,7 +85,7 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
// accumulated into a successful result.
|
||||
const raced: Result<CursorPage<Value>> | typeof ABORTED =
|
||||
await raceAbort<Result<CursorPage<Value>>>(
|
||||
dependencies.loadPage(cursor, context),
|
||||
loadPage(cursor, context),
|
||||
context.signal,
|
||||
);
|
||||
if (raced === ABORTED || context.signal?.aborted) {
|
||||
@@ -83,7 +94,7 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
const result: Result<CursorPage<Value>> = raced;
|
||||
if (!result.ok) return result;
|
||||
const page: CursorPage<Value> = result.value;
|
||||
if (!isValidPage(page, dependencies.profile)) {
|
||||
if (!isValidPage(page, profile)) {
|
||||
return failure(
|
||||
"PAGINATION_CONTRACT_VIOLATION",
|
||||
"PAGINATION_PAGE_INVALID",
|
||||
@@ -99,8 +110,8 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
}
|
||||
items.push(...page.items);
|
||||
if (
|
||||
items.length > dependencies.profile.maxTotalItems ||
|
||||
estimatedBytes(items) > dependencies.profile.maxEstimatedBytes
|
||||
items.length > profile.maxTotalItems ||
|
||||
estimatedBytes(items) > profile.maxEstimatedBytes
|
||||
) {
|
||||
return failure(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
@@ -134,13 +145,46 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
|
||||
) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: createFailure(kind, dependencies.definitionId, 0, { code }),
|
||||
error: createFailure(kind, definitionId, 0, { code }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NS-07. Copies the profile into an owned frozen record, reading every field
|
||||
* exactly once, and validates that copy. An accessor, an inherited or extra
|
||||
* field, a symbol key or a Proxy trap fails closed rather than becoming a cap
|
||||
* that can change after it was checked.
|
||||
*/
|
||||
function snapshotProfile(source: unknown): CursorPaginationProfile {
|
||||
const profile = snapshotExactObject(source, {
|
||||
allowed: [
|
||||
"profileId",
|
||||
"maxPages",
|
||||
"maxTotalItems",
|
||||
"maxEstimatedBytes",
|
||||
"maxCursorBytes",
|
||||
"allowSparsePage",
|
||||
],
|
||||
required: [
|
||||
"profileId",
|
||||
"maxPages",
|
||||
"maxTotalItems",
|
||||
"maxEstimatedBytes",
|
||||
"maxCursorBytes",
|
||||
"allowSparsePage",
|
||||
],
|
||||
}) as CursorPaginationProfile | null;
|
||||
if (profile === null || typeof profile.allowSparsePage !== "boolean") {
|
||||
throw new TypeError("Invalid cursor pagination profile.");
|
||||
}
|
||||
validateProfile(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
function validateProfile(profile: CursorPaginationProfile): void {
|
||||
if (
|
||||
typeof profile.profileId !== "string" ||
|
||||
!profile.profileId ||
|
||||
!Number.isSafeInteger(profile.maxPages) ||
|
||||
profile.maxPages < 1 ||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Descriptor-based exact decoding for values that cross a trust boundary.
|
||||
*
|
||||
* Several adapters independently wrote "check the shape, then read it again to
|
||||
* copy it". That order is the bug: between the check and the copy an accessor
|
||||
* or a Proxy can answer differently, so the value that was validated and the
|
||||
* value that was installed are two different things. Every helper here reads a
|
||||
* property exactly once, through its own data descriptor, and hands back an
|
||||
* owned plain object. Validation then runs on the snapshot, never on the source.
|
||||
*
|
||||
* The helpers are total: a hostile `getPrototypeOf`, `ownKeys` or
|
||||
* `getOwnPropertyDescriptor` trap yields `null`, never a thrown exception, so a
|
||||
* caller can keep its own typed failure vocabulary.
|
||||
*/
|
||||
|
||||
const DEFAULT_PROTOTYPES: readonly (object | null)[] = Object.freeze([
|
||||
Object.prototype,
|
||||
null,
|
||||
]);
|
||||
|
||||
export type ExactObjectPolicy = Readonly<{
|
||||
/** Every own key the value may carry. Anything else rejects the snapshot. */
|
||||
allowed: readonly string[];
|
||||
/** Keys that must be present as own data properties. */
|
||||
required?: readonly string[];
|
||||
/**
|
||||
* Prototypes the value may have. Defaults to a plain object or a null
|
||||
* prototype, which is what a decoded wire payload or a literal produces.
|
||||
*/
|
||||
prototypes?: readonly (object | null)[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Reads `source[key]` exactly once through its own data descriptor. An accessor,
|
||||
* an inherited property or a missing key all answer `undefined`, and a trap that
|
||||
* throws answers `undefined` rather than escaping.
|
||||
*/
|
||||
export function ownDataValue(source: unknown, key: string): unknown {
|
||||
if (source === null || typeof source !== "object") return undefined;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
if (!descriptor || !("value" in descriptor)) return undefined;
|
||||
return descriptor.value;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when `key` is present as an own data property. */
|
||||
export function hasOwnDataKey(source: unknown, key: string): boolean {
|
||||
if (source === null || typeof source !== "object") return false;
|
||||
try {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
||||
return Boolean(descriptor) && "value" in (descriptor as PropertyDescriptor);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies `source` into a frozen plain object, reading every property exactly
|
||||
* once. Returns `null` when the value is not an object, carries a symbol or an
|
||||
* unexpected own key, exposes an accessor, has an unapproved prototype, misses a
|
||||
* required key, or makes any reflection operation throw.
|
||||
*/
|
||||
export function snapshotExactObject(
|
||||
source: unknown,
|
||||
policy: ExactObjectPolicy,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
if (source === null || typeof source !== "object") return null;
|
||||
try {
|
||||
const prototypes = policy.prototypes ?? DEFAULT_PROTOTYPES;
|
||||
if (!prototypes.includes(Reflect.getPrototypeOf(source))) return null;
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return null;
|
||||
|
||||
const allowed = new Set(policy.allowed);
|
||||
const names = Object.getOwnPropertyNames(source);
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const name of names) {
|
||||
if (!allowed.has(name)) return null;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, name);
|
||||
// A non-enumerable own property is as much a smuggled field as an
|
||||
// inherited one, and an accessor is a second read waiting to happen.
|
||||
if (
|
||||
!descriptor ||
|
||||
!("value" in descriptor) ||
|
||||
descriptor.enumerable !== true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(snapshot, name, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
for (const name of policy.required ?? []) {
|
||||
if (!Object.hasOwn(snapshot, name)) return null;
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an open-keyed record — a header bag, a query map — into a frozen owned
|
||||
* object, reading every property exactly once. The key set is not constrained
|
||||
* here; admission against an allow-list stays with the policy that owns it, so
|
||||
* the more specific rejection can still be reported. Returns `null` for a
|
||||
* non-object, a symbol key, an accessor, a non-enumerable own key, an
|
||||
* unapproved prototype, more than `maximumKeys` entries, or a throwing trap.
|
||||
*/
|
||||
export function snapshotOwnDataRecord(
|
||||
source: unknown,
|
||||
maximumKeys = 64,
|
||||
): Readonly<Record<string, unknown>> | null {
|
||||
if (source === null || typeof source !== "object") return null;
|
||||
try {
|
||||
if (!DEFAULT_PROTOTYPES.includes(Reflect.getPrototypeOf(source))) {
|
||||
return null;
|
||||
}
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) return null;
|
||||
const names = Object.getOwnPropertyNames(source);
|
||||
if (names.length > maximumKeys) return null;
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const name of names) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, name);
|
||||
if (
|
||||
!descriptor ||
|
||||
!("value" in descriptor) ||
|
||||
descriptor.enumerable !== true
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
Object.defineProperty(snapshot, name, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a genuine array into a frozen owned array, reading each element exactly
|
||||
* once. Returns `null` for a non-array, a hostile length or a trap that throws.
|
||||
*/
|
||||
export function snapshotExactArray(
|
||||
source: unknown,
|
||||
maximumLength = 4_096,
|
||||
): readonly unknown[] | null {
|
||||
try {
|
||||
if (!Array.isArray(source)) return null;
|
||||
const length = source.length;
|
||||
if (!Number.isSafeInteger(length) || length < 0 || length > maximumLength) {
|
||||
return null;
|
||||
}
|
||||
const items: unknown[] = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(source, String(index));
|
||||
if (!descriptor || !("value" in descriptor)) return null;
|
||||
items.push(descriptor.value);
|
||||
}
|
||||
return Object.freeze(items);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -548,15 +548,21 @@ function snapshotHttpContract(
|
||||
label: string,
|
||||
): InstalledHttpContract<unknown, unknown, unknown> {
|
||||
const reject = (detail: string): never => fail(`${label}: ${detail}`);
|
||||
// NS-02. The outer row is snapshotted first, so every nested read below comes
|
||||
// from an owned object rather than from the caller's, which could answer
|
||||
// differently on a second read.
|
||||
const row = exactOwnDataSnapshot<
|
||||
InstalledHttpContract<unknown, unknown, unknown>
|
||||
>(installed, ["contract", "frontend"], ["contract", "frontend"], reject);
|
||||
const frontend = exactOwnDataSnapshot<HttpExecutionPolicy>(
|
||||
installed.frontend,
|
||||
row.frontend,
|
||||
EXECUTION_POLICY_KEYS,
|
||||
EXECUTION_POLICY_KEYS,
|
||||
reject,
|
||||
);
|
||||
const source = exactOwnDataSnapshot<
|
||||
InstalledHttpContract<unknown, unknown, unknown>["contract"]
|
||||
>(installed.contract, HTTP_CONTRACT_KEYS, HTTP_CONTRACT_KEYS, reject);
|
||||
>(row.contract, HTTP_CONTRACT_KEYS, HTTP_CONTRACT_KEYS, reject);
|
||||
const contract = Object.freeze({
|
||||
...source,
|
||||
acceptedStatuses: Object.freeze([...source.acceptedStatuses]),
|
||||
@@ -605,11 +611,22 @@ export function composeContractContributions(
|
||||
>();
|
||||
const packagesById = new Map<string, InstalledContractPackageIdentity>();
|
||||
const contributionIds = new Set<string>();
|
||||
const installedContributions: InstalledContractContribution[] = [];
|
||||
|
||||
for (const contribution of contributions) {
|
||||
if (!contribution || typeof contribution !== "object") {
|
||||
for (const raw of contributions) {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
fail("contribution: object required");
|
||||
}
|
||||
// NS-02. Snapshot first, then validate the snapshot, then install exactly
|
||||
// what was validated. Validating the caller's object and reading it again
|
||||
// to copy it let a stateful answer pass the ceiling check and still install
|
||||
// a different deadline, retry budget or auth profile.
|
||||
const contribution = exactOwnDataSnapshot<InstalledContractContribution>(
|
||||
raw,
|
||||
["contributionId", "featureId", "source", "http", "events"],
|
||||
["contributionId", "featureId", "source", "http", "events"],
|
||||
(detail) => fail(`contribution: ${detail}`),
|
||||
);
|
||||
const contributionId = contribution.contributionId;
|
||||
if (
|
||||
typeof contributionId !== "string" ||
|
||||
@@ -625,59 +642,114 @@ export function composeContractContributions(
|
||||
if (typeof featureId !== "string" || !FEATURE_ID.test(featureId)) {
|
||||
fail(`featureId: ${String(featureId)}`);
|
||||
}
|
||||
const source = contribution.source;
|
||||
if (!source || typeof source !== "object" || !("kind" in source)) {
|
||||
const rawSource = contribution.source;
|
||||
if (!rawSource || typeof rawSource !== "object" || !("kind" in rawSource)) {
|
||||
fail(`${featureId}: source`);
|
||||
}
|
||||
if (!Array.isArray(contribution.http) || !Array.isArray(contribution.events)) {
|
||||
fail(`${featureId}: contribution arrays`);
|
||||
}
|
||||
if (source.kind === "EXTERNAL_PACKAGE") {
|
||||
assertPackageIdentity(source.package, featureId);
|
||||
const existing = packagesById.get(source.package.packageId);
|
||||
const rejectSource = (detail: string): never =>
|
||||
fail(`${featureId}: source ${detail}`);
|
||||
let source: ContractContributionSource;
|
||||
if (rawSource.kind === "EXTERNAL_PACKAGE") {
|
||||
const outer = exactOwnDataSnapshot<
|
||||
Readonly<{ kind: "EXTERNAL_PACKAGE"; package: unknown }>
|
||||
>(rawSource, ["kind", "package"], ["kind", "package"], rejectSource);
|
||||
const identity = exactOwnDataSnapshot<InstalledContractPackageIdentity>(
|
||||
outer.package,
|
||||
[
|
||||
"packageId",
|
||||
"version",
|
||||
"digest",
|
||||
"runtimeProtocolVersion",
|
||||
"sourceRevision",
|
||||
],
|
||||
[
|
||||
"packageId",
|
||||
"version",
|
||||
"digest",
|
||||
"runtimeProtocolVersion",
|
||||
"sourceRevision",
|
||||
],
|
||||
rejectSource,
|
||||
);
|
||||
assertPackageIdentity(identity, featureId);
|
||||
source = Object.freeze({
|
||||
kind: "EXTERNAL_PACKAGE" as const,
|
||||
package: identity,
|
||||
});
|
||||
const existing = packagesById.get(identity.packageId);
|
||||
if (
|
||||
existing &&
|
||||
(existing.version !== source.package.version ||
|
||||
existing.digest !== source.package.digest ||
|
||||
existing.sourceRevision !== source.package.sourceRevision)
|
||||
(existing.version !== identity.version ||
|
||||
existing.digest !== identity.digest ||
|
||||
existing.sourceRevision !== identity.sourceRevision)
|
||||
) {
|
||||
fail(
|
||||
`${featureId}: package ${source.package.packageId} has conflicting identities`,
|
||||
`${featureId}: package ${identity.packageId} has conflicting identities`,
|
||||
);
|
||||
}
|
||||
packagesById.set(source.package.packageId, source.package);
|
||||
} else if (source.kind === "TEMPLATE_FIXTURE") {
|
||||
if (source.fixtureId !== "REFERENCE_FEATURE_V1" || source.revision !== 1) {
|
||||
packagesById.set(identity.packageId, identity);
|
||||
} else if (rawSource.kind === "TEMPLATE_FIXTURE") {
|
||||
const fixture = exactOwnDataSnapshot<
|
||||
Readonly<{
|
||||
kind: "TEMPLATE_FIXTURE";
|
||||
fixtureId: "REFERENCE_FEATURE_V1";
|
||||
revision: 1;
|
||||
}>
|
||||
>(
|
||||
rawSource,
|
||||
["kind", "fixtureId", "revision"],
|
||||
["kind", "fixtureId", "revision"],
|
||||
rejectSource,
|
||||
);
|
||||
if (
|
||||
fixture.fixtureId !== "REFERENCE_FEATURE_V1" ||
|
||||
fixture.revision !== 1
|
||||
) {
|
||||
fail(`${featureId}: template fixture identity`);
|
||||
}
|
||||
if (contribution.events.length !== 0) {
|
||||
fail(`${featureId}: template fixture must not contribute events`);
|
||||
}
|
||||
source = fixture;
|
||||
} else {
|
||||
fail(`${featureId}: unknown contribution source kind`);
|
||||
}
|
||||
|
||||
const installedHttp: InstalledHttpContract<unknown, unknown, unknown>[] = [];
|
||||
for (const installed of contribution.http) {
|
||||
assertHttpContract(installed, featureId);
|
||||
const operationId = installed.contract.operationId;
|
||||
const snapshot = snapshotHttpContract(installed, featureId);
|
||||
assertHttpContract(snapshot, featureId);
|
||||
const operationId = snapshot.contract.operationId;
|
||||
const previous = httpByOperationId.get(operationId);
|
||||
if (previous) fail(`duplicate operation: ${operationId}`);
|
||||
httpByOperationId.set(
|
||||
operationId,
|
||||
snapshotHttpContract(installed, `${featureId}/${operationId}`),
|
||||
);
|
||||
httpByOperationId.set(operationId, snapshot);
|
||||
installedHttp.push(snapshot);
|
||||
}
|
||||
|
||||
const installedEvents: InstalledEventContract<unknown, unknown>[] = [];
|
||||
for (const event of contribution.events) {
|
||||
assertEventContract(event, featureId);
|
||||
if (eventByType.has(event.eventType)) {
|
||||
fail(`duplicate event type: ${event.eventType}`);
|
||||
const snapshot = snapshotEventContract(event, featureId);
|
||||
assertEventContract(snapshot, featureId);
|
||||
if (eventByType.has(snapshot.eventType)) {
|
||||
fail(`duplicate event type: ${snapshot.eventType}`);
|
||||
}
|
||||
eventByType.set(
|
||||
event.eventType,
|
||||
snapshotEventContract(event, `${featureId}/${event.eventType}`),
|
||||
);
|
||||
eventByType.set(snapshot.eventType, snapshot);
|
||||
installedEvents.push(snapshot);
|
||||
}
|
||||
|
||||
// Everything published downstream is the validated snapshot, so no consumer
|
||||
// can be handed the caller's still-live object.
|
||||
installedContributions.push(
|
||||
Object.freeze({
|
||||
contributionId,
|
||||
featureId,
|
||||
source,
|
||||
http: Object.freeze(installedHttp),
|
||||
events: Object.freeze(installedEvents),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const externalPackages = [...packagesById.values()].map((identity) =>
|
||||
@@ -685,7 +757,7 @@ export function composeContractContributions(
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
contributions: Object.freeze([...contributions]),
|
||||
contributions: Object.freeze(installedContributions),
|
||||
httpByOperationId: createReadOnlyRegistry(httpByOperationId),
|
||||
eventByType: createReadOnlyRegistry(eventByType),
|
||||
externalPackages: Object.freeze(externalPackages),
|
||||
|
||||
@@ -63,6 +63,12 @@ export function exactOwnDataSnapshot<Shape extends object>(
|
||||
if (Object.getOwnPropertySymbols(source).length > 0) {
|
||||
onViolation("symbol-keyed field");
|
||||
}
|
||||
// NS-02. A custom prototype carries fields a name sweep never sees, and it
|
||||
// stays live: replacing one after composition changes what the row answers.
|
||||
const prototype = Reflect.getPrototypeOf(source);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
onViolation("unexpected prototype");
|
||||
}
|
||||
const allowed = new Set<string>(allowedKeys);
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of Object.getOwnPropertyNames(source)) {
|
||||
@@ -71,6 +77,9 @@ export function exactOwnDataSnapshot<Shape extends object>(
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
onViolation(`accessor field ${key}`);
|
||||
}
|
||||
if (descriptor.enumerable !== true) {
|
||||
onViolation(`non-enumerable field ${key}`);
|
||||
}
|
||||
snapshot[key] = descriptor.value;
|
||||
}
|
||||
for (const key of requiredKeys) {
|
||||
|
||||
Reference in New Issue
Block a user