chore: sync the frontend template from 4dc033c to 8157ad4

The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 12:04:58 +09:00
co-authored by Claude Opus 5
parent 002ba3624e
commit 4bff9ca151
142 changed files with 23010 additions and 1544 deletions
+26 -8
View File
@@ -1,24 +1,26 @@
import type {
AuthSessionPort,
CredentialOperationContext,
CredentialPatch,
CredentialRequestBinding,
SessionState,
} from "../../application/ports/auth-session-port.ts";
import { CREDENTIAL_HEADER_NAMES } from "../../contracts/rest-profiles.ts";
export type ExternalSessionOwner = Readonly<{
readState(): SessionState;
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
attachCredential(binding: CredentialRequestBinding): Promise<CredentialPatch>;
attachCredential(
binding: CredentialRequestBinding,
context?: CredentialOperationContext,
): Promise<CredentialPatch>;
recoverSession(): Promise<"restored" | "no-session">;
notifyUnauthenticated(): void;
}>;
const ALLOWED_CREDENTIAL_HEADERS = new Set([
"authorization",
"x-csrf-token",
]);
const ALLOWED_CREDENTIAL_HEADERS = new Set<string>(CREDENTIAL_HEADER_NAMES);
const MAX_HEADER_VALUE_BYTES = 8_192;
export function validateCredentialPatch(value: unknown): CredentialPatch {
@@ -54,8 +56,10 @@ export function createExternalAuthSessionAdapter(
subscribe: (listener) => owner.subscribe(listener),
beginSignIn: (returnTo) => owner.beginSignIn(returnTo),
signOut: () => owner.signOut(),
async credentialPatch(binding) {
return validateCredentialPatch(await owner.attachCredential(binding));
async credentialPatch(binding, context) {
return validateCredentialPatch(
await owner.attachCredential(binding, context),
);
},
async recover() {
const result = await owner.recoverSession();
@@ -85,9 +89,23 @@ export function createAnonymousSessionAdapter(): AuthSessionPort {
export type DemoSessionAdapter = AuthSessionPort &
Readonly<{ setState(next: SessionState): void }>;
/**
* §7.7. `AUTH_MODE=demo` still runs against the strict
* `REFERENCE_EXTERNAL_BEARER` profile, so the demo owner must supply a real
* proof header. This marker is a fixed, non-secret placeholder: it exists so
* the demo path satisfies the bearer contract instead of weakening it.
*/
export const DEMO_AUTHORIZATION_MARKER = "Bearer demo-session-not-a-secret";
const DEMO_PATCH = Object.freeze({
headers: Object.freeze({ authorization: DEMO_AUTHORIZATION_MARKER }),
});
export function createDemoSessionAdapter(
initialState: SessionState = "unauthenticated",
demoPatch: CredentialPatch = DEMO_PATCH,
): DemoSessionAdapter {
const patch = validateCredentialPatch(demoPatch);
let state = initialState;
const listeners = new Set<() => void>();
const setState = (next: SessionState) => {
@@ -106,7 +124,7 @@ export function createDemoSessionAdapter(
async signOut() {
setState("unauthenticated");
},
credentialPatch: async () => EMPTY_PATCH,
credentialPatch: async () => patch,
async recover() {
if (state === "recovery-pending") {
setState("authenticated");
@@ -443,20 +443,23 @@ function browserManagedHandoff(context: Readonly<{
context.options.observer,
);
}
const href = capability.value.href;
if (
!safeBrowserManagedTarget(href, context.baseOrigin, {
const target = resolveBrowserManagedTarget(
capability.value.href,
context.baseOrigin,
{
allowCrossOrigin:
context.options.allowCrossOriginBrowserHandoff ?? false,
allowQuery: context.options.allowBrowserManagedQuery ?? false,
})
) {
return observeResult(
browserDataFailure("POLICY_REJECTED", "DOWNLOAD"),
context.options.observer,
);
},
);
if (!target.ok) {
return observeResult(target, context.options.observer);
}
context.options.host.handoff(href, context.suggestedFileName);
// The host receives the parsed canonical URL, never the raw string.
context.options.host.handoff(
target.value.absoluteHref,
context.suggestedFileName,
);
return observeResult(
browserDataSuccess(
Object.freeze({
@@ -566,11 +569,13 @@ async function promptAndStream(context: Readonly<{
);
}
const sourceHolder = createSourceHolder();
try {
const sourceResult = await resolveByteSource(
context.input,
context.input.signal,
context.options,
sourceHolder,
);
if (!sourceResult.ok) {
return observeResult(sourceResult, context.options.observer);
@@ -699,6 +704,10 @@ async function promptAndStream(context: Readonly<{
mapDownloadException(error),
context.options.observer,
);
} finally {
// TR-RR-04. Exactly once, on every path: success, validation failure,
// writer failure and abort.
closeHeldSource(sourceHolder);
}
}
@@ -721,12 +730,15 @@ async function boundedObjectUrlHandoff(context: Readonly<{
context.options.observer,
);
}
const sourceHolder = createSourceHolder();
const sourceResult = await resolveByteSource(
context.input,
context.input.signal,
context.options,
sourceHolder,
);
if (!sourceResult.ok) {
closeHeldSource(sourceHolder);
return observeResult(sourceResult, context.options.observer);
}
const source = sourceResult.value;
@@ -735,6 +747,7 @@ async function boundedObjectUrlHandoff(context: Readonly<{
(source.byteLength > context.input.maxBufferedBytes ||
source.byteLength > context.input.maxTransferBytes)
) {
closeHeldSource(sourceHolder);
return observeResult(
browserDataFailure("LIMIT_EXCEEDED", "DOWNLOAD"),
context.options.observer,
@@ -835,6 +848,9 @@ async function boundedObjectUrlHandoff(context: Readonly<{
context.options.observer,
transferred,
);
} finally {
// TR-RR-04. Exactly once, on every path.
closeHeldSource(sourceHolder);
}
}
@@ -994,10 +1010,63 @@ function validateDownloadInput(
return browserDataSuccess(true);
}
/**
* TR-RR-04. A presigned byte source owns a fetch reader and a capability lease,
* and its port requires `close()`. The delivery consumer never called it, so
* every success, validation failure, writer failure and abort leaked both. The
* closeable subtype is lost in the `FileByteSource` projection, so the holder
* keeps it and the outermost boundary closes it exactly once.
*/
type CloseableSourceHolder = { source: FileByteSource | null; closed: boolean };
function createSourceHolder(): CloseableSourceHolder {
return { source: null, closed: false };
}
/**
* TR-02. Closes a lease that fulfilled after the delivery already ended. The
* holder's own `closed` latch is the single close-once authority, so a lease
* the holder did adopt is never closed twice and a lease it never saw is still
* closed exactly once. A late rejection is observed and discarded.
*/
function compensateLateSource(
pending: Promise<BrowserDataResult<FileByteSource>>,
holder: CloseableSourceHolder | undefined,
): void {
if (!holder) return;
void pending.then(
(result) => {
if (!result.ok || !holder.closed) return;
// The holder was already closed, so this lease was never adopted.
if (!isVerifiedPresignedSource(result.value)) return;
try {
result.value.close();
} catch {
// Compensation is best effort and never changes the outcome.
}
},
() => undefined,
);
}
function closeHeldSource(holder: CloseableSourceHolder): void {
if (holder.closed) return;
holder.closed = true;
const source = holder.source;
holder.source = null;
if (!source || !isVerifiedPresignedSource(source)) return;
try {
source.close();
} catch {
// Closing is best effort and never changes the delivery outcome.
}
}
async function resolveByteSource(
input: DeliveryInput,
signal: AbortSignal,
options: DownloadDeliveryAdapterOptions,
holder?: CloseableSourceHolder,
): Promise<BrowserDataResult<FileByteSource>> {
const source = input.source;
if (signal.aborted) {
@@ -1013,20 +1082,26 @@ async function resolveByteSource(
}
const open = options.openAuthorizedSource;
if (!open) return browserDataFailure("UNSUPPORTED", "DOWNLOAD");
const result = await awaitWithSignal(
open({
resourceId: source.resourceId,
capability: source.capability,
signal,
}),
const pendingOpen = open({
resourceId: source.resourceId,
capability: source.capability,
signal,
);
});
// TR-02. A lease that arrives after the abort already ended the delivery
// never reaches the holder, so nothing would ever close it: the fetch reader
// and the capability lease outlived the terminal result. The compensator and
// the holder share one close-once latch, so exactly one of them closes it.
compensateLateSource(pendingOpen, holder);
const result = await awaitWithSignal(pendingOpen, signal);
if (!result.ok) {
return browserDataFailure(result.error.code, "DOWNLOAD", {
retryable: result.error.retryable,
recovery: result.error.recovery,
});
}
// Held from the moment the lease exists, so a validation failure below still
// closes it.
if (holder) holder.source = result.value;
return validPresignedByteSource(
result.value,
source.capability,
@@ -1245,28 +1320,45 @@ function validateBrowserManagedCapability(
);
}
function safeBrowserManagedTarget(
type ResolvedBrowserManagedTarget = Readonly<{ absoluteHref: string }>;
/**
* STO-02. Parse once, canonicalize, then execute the canonical value.
*
* Returning a boolean and handing the raw href to the host let the browser
* re-resolve a relative target against `document.baseURI`, so a hostile
* `<base>` could send the navigation to a different origin than the one this
* policy just approved.
*/
function resolveBrowserManagedTarget(
href: string,
baseOrigin: string,
policy: Readonly<{
allowCrossOrigin: boolean;
allowQuery: boolean;
}>,
): boolean {
): BrowserDataResult<ResolvedBrowserManagedTarget> {
let base: URL;
let target: URL;
try {
const base = new URL(baseOrigin);
const target = new URL(href, base);
return (
["http:", "https:"].includes(target.protocol) &&
target.username.length === 0 &&
target.password.length === 0 &&
(policy.allowCrossOrigin || target.origin === base.origin) &&
(policy.allowQuery || target.search.length === 0) &&
target.hash.length === 0
);
base = new URL(baseOrigin);
target = new URL(href, base);
} catch {
return false;
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
}
if (
!["http:", "https:"].includes(target.protocol) ||
target.username.length > 0 ||
target.password.length > 0 ||
(!policy.allowCrossOrigin && target.origin !== base.origin) ||
(!policy.allowQuery && target.search.length > 0) ||
target.hash.length > 0
) {
return browserDataFailure("POLICY_REJECTED", "DOWNLOAD");
}
return browserDataSuccess(
Object.freeze({ absoluteHref: target.href }),
);
}
function safeOpaqueId(value: unknown): value is string {
File diff suppressed because it is too large Load Diff
+2
View File
@@ -7,7 +7,9 @@ export {
type BrowserRpcRuntimeDependencies,
} from "./browser-rpc-runtime.ts";
export {
decodeServerStreamLease,
defineBrowserRpcTransport,
type BrowserRpcServerStreamLease,
type BrowserRpcStreamFrame,
type BrowserRpcTransport,
type BrowserRpcTransportCall,
+93 -1
View File
@@ -50,6 +50,29 @@ export type BrowserRpcStreamFrame =
failure: BrowserRpcTransportFailure;
}>;
/**
* RPC-RR-01. A server stream is a physical resource, not just a sequence.
*
* A bare `AsyncIterable` gives the runtime no way to cancel the underlying
* stream or to learn when it actually closed: `iterator.return()` is a request
* a non-cooperative implementation may ignore. The runtime could then time out,
* report the call finished, and admit a second stream for the same operation
* while the first was still running against the server.
*
* The lease separates the three concerns the runtime needs:
*
* - `frames` is the sequence,
* - `cancel(reason)` is a synchronous request to stop the physical stream,
* - `waitClosed()` settles only once that stream is really closed,
* - `streamId` names the physical stream so two leases are never confused.
*/
export type BrowserRpcServerStreamLease = Readonly<{
streamId: string;
frames: AsyncIterable<BrowserRpcStreamFrame>;
cancel(reason: string): void;
waitClosed(): Promise<void>;
}>;
export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
Readonly<{
invokeUnary?(
@@ -57,9 +80,78 @@ export type BrowserRpcTransport = BrowserRpcRuntimeBindingIdentity &
): Promise<BrowserRpcUnaryTransportResult>;
openServerStream?(
call: BrowserRpcTransportCall,
): AsyncIterable<BrowserRpcStreamFrame>;
): BrowserRpcServerStreamLease;
}>;
const STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
/**
* RPC-RR-01. Decodes a lease from own data descriptors before the runtime
* registers it, so an accessor cannot hand the registry one object and the
* cancellation path another.
*/
export function decodeServerStreamLease(
value: unknown,
): BrowserRpcServerStreamLease | null {
if (value === null || typeof value !== "object") return null;
let streamId: unknown;
let frames: unknown;
let cancel: unknown;
let waitClosed: unknown;
try {
if (Object.getOwnPropertySymbols(value).length > 0) return null;
const names = Object.getOwnPropertyNames(value).sort();
const expected = ["cancel", "frames", "streamId", "waitClosed"];
if (
names.length !== expected.length ||
names.some((name, index) => name !== expected[index])
) {
return null;
}
for (const name of names) {
const descriptor = Object.getOwnPropertyDescriptor(value, name);
if (!descriptor || !("value" in descriptor)) return null;
}
streamId = Object.getOwnPropertyDescriptor(value, "streamId")?.value;
frames = Object.getOwnPropertyDescriptor(value, "frames")?.value;
cancel = Object.getOwnPropertyDescriptor(value, "cancel")?.value;
waitClosed = Object.getOwnPropertyDescriptor(value, "waitClosed")?.value;
} catch {
return null;
}
// RPC-02. The async-iterator lookup is a read of foreign state like any
// other, so it happens inside the decoder's own boundary. Performing it after
// the `try` let a throwing `Symbol.asyncIterator` getter escape this
// function as a native `TypeError`, breaking the decoder's totality.
let openFrames: unknown;
try {
if (
typeof streamId !== "string" ||
!STREAM_ID.test(streamId) ||
frames === null ||
typeof frames !== "object" ||
typeof cancel !== "function" ||
typeof waitClosed !== "function"
) {
return null;
}
openFrames = (frames as AsyncIterable<unknown>)[Symbol.asyncIterator];
if (typeof openFrames !== "function") return null;
} catch {
return null;
}
const iterate = (openFrames as () => AsyncIterator<BrowserRpcStreamFrame>)
.bind(frames);
return Object.freeze({
streamId,
frames: Object.freeze({
[Symbol.asyncIterator]: iterate,
}) as AsyncIterable<BrowserRpcStreamFrame>,
cancel: (cancel as (reason: string) => void).bind(value),
waitClosed: (waitClosed as () => Promise<void>).bind(value),
});
}
export function defineBrowserRpcTransport(
transport: BrowserRpcTransport,
): BrowserRpcTransport {
@@ -31,11 +31,22 @@ export function createUnavailableBrowserRpcTransport(input: Readonly<{
}
return defineBrowserRpcTransport({
...input,
async *openServerStream() {
yield Object.freeze({
kind: "TERMINAL",
ok: false,
failure: Object.freeze({ code: "UNAVAILABLE" }),
// RPC-RR-01. Even a stream that never opens hands back a lease, so the
// runtime's registry and cancellation path have one shape to work with.
openServerStream() {
return Object.freeze({
streamId: `unavailable-${input.runtimeProfileId}`,
frames: Object.freeze({
async *[Symbol.asyncIterator]() {
yield Object.freeze({
kind: "TERMINAL" as const,
ok: false as const,
failure: Object.freeze({ code: "UNAVAILABLE" as const }),
});
},
}),
cancel() {},
async waitClosed() {},
});
},
});
@@ -6,6 +6,11 @@ import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
createAbortableOperation,
snapshotAbortTimers,
type AbortTimerSnapshot,
} from "../../platform/abortable-operation.ts";
import { parseStaticImageHeaderMetadata } from "./image-header-metadata.ts";
export type DecodedImageFacade = Readonly<{
@@ -47,7 +52,7 @@ export function createBrowserImageProbe(
? async (image: Blob) => createImageBitmap(image)
: undefined);
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const scheduler = snapshotScheduler(
const timers = snapshotAbortTimers(
dependencies.scheduler ?? defaultScheduler(),
);
if (
@@ -98,10 +103,15 @@ export function createBrowserImageProbe(
const scope = createProbeAbortScope(
request.signal,
timeoutMs,
scheduler,
timers,
);
let response: Response | undefined;
try {
if (scope.signal.aborted) {
// Nothing physical has started yet: an already aborted caller or a
// deadline that could not be installed ends the probe before fetch.
return signalFailure(request.signal);
}
try {
const fetchTask = Promise.resolve(
fetcher(url.href, {
@@ -114,15 +124,11 @@ export function createBrowserImageProbe(
signal: scope.signal,
}),
);
response = await awaitWithAbort(
fetchTask,
scope.signal,
(lateResponse) => {
cancelResponseBody(lateResponse);
},
);
response = await scope.await(fetchTask, (lateResponse) => {
cancelResponseBody(lateResponse);
});
} catch {
return signalFailure(request.signal, scope);
return signalFailure(request.signal);
}
if (
response.status !== 200 ||
@@ -150,7 +156,7 @@ export function createBrowserImageProbe(
);
} catch (error) {
if (request.signal.aborted || scope.timedOut()) {
return signalFailure(request.signal, scope);
return signalFailure(request.signal);
}
return error instanceof EncodedBodyLimitError
? browserDataFailure(
@@ -221,11 +227,7 @@ export function createBrowserImageProbe(
type: request.expectedMediaType,
}),
);
bitmap = await awaitWithAbort(
decodeTask,
scope.signal,
closeBitmap,
);
bitmap = await scope.await(decodeTask, closeBitmap);
if (
!positiveSafeInteger(bitmap.width) ||
!positiveSafeInteger(bitmap.height) ||
@@ -254,7 +256,7 @@ export function createBrowserImageProbe(
);
} catch {
return request.signal.aborted || scope.timedOut()
? signalFailure(request.signal, scope)
? signalFailure(request.signal)
: browserDataFailure(
"INTEGRITY_FAILED",
"IMAGE_RESOLVE",
@@ -310,9 +312,13 @@ function validResponseHeaders(
);
if (!directives) return false;
if (request.delivery === "PRIVATE_SIGNED") {
return (
directives.get("no-store") === true &&
!directives.has("public")
// TR-RR-09. The recorded BT-IMG-02 contract is a fail-closed matrix, not a
// pair of checks: a private response must carry `no-store` and nothing else
// that describes cacheability. Only a syntactically valid unknown extension
// is ignored, so a contradictory pairing can never read as acceptable.
if (directives.get("no-store") !== true) return false;
return !PRIVATE_FORBIDDEN_DIRECTIVES.some((name) =>
directives.has(name),
);
}
const maxAge = directives.get("max-age");
@@ -336,6 +342,92 @@ function validResponseHeaders(
);
}
/**
* TR-RR-09. Every cacheability directive a `PRIVATE_SIGNED` response may not
* carry alongside `no-store`.
*/
const PRIVATE_FORBIDDEN_DIRECTIVES: readonly string[] = Object.freeze([
"public",
"private",
"immutable",
"max-age",
"s-maxage",
"no-cache",
"must-revalidate",
"proxy-revalidate",
]);
/**
* BT-IMG-02. Quote- and escape-aware Cache-Control tokenizer.
*
* A naive comma split plus `replace(/^"|"$/g, "")` accepted `max-age="60` and
* `max-age=60"` as the number 60, so a malformed policy could be approved as an
* immutable public response. A comma inside a quoted extension value is also
* not a directive boundary.
*/
function splitCacheControlDirectives(value: string): string[] | null {
const parts: string[] = [];
let current = "";
let inQuotes = false;
let escaped = false;
for (const character of value) {
if (escaped) {
const code = character.codePointAt(0) ?? 0;
// quoted-pair may not carry a bare control character.
if (code <= 0x1f || code === 0x7f) return null;
current += character;
escaped = false;
continue;
}
if (inQuotes && character === "\\") {
escaped = true;
current += character;
continue;
}
if (character === '"') {
inQuotes = !inQuotes;
current += character;
continue;
}
if (character === "," && !inQuotes) {
parts.push(current);
current = "";
continue;
}
current += character;
}
// An unterminated quoted-string or a dangling escape is malformed.
if (inQuotes || escaped) return null;
parts.push(current);
return parts;
}
function unquoteCacheControlValue(rawValue: string): string | null {
if (!rawValue.startsWith('"')) {
// A bare value may not contain a quote at all.
return rawValue.includes('"') ? null : rawValue;
}
if (rawValue.length < 2 || !rawValue.endsWith('"')) return null;
const inner = rawValue.slice(1, -1);
let unquoted = "";
let escaped = false;
for (const character of inner) {
if (escaped) {
unquoted += character;
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
// An unescaped quote inside the string means the quoting is unbalanced.
if (character === '"') return null;
unquoted += character;
}
return escaped ? null : unquoted;
}
function parseCacheControl(
value: string | null,
): ReadonlyMap<string, string | true> | null {
@@ -348,7 +440,10 @@ function parseCacheControl(
"public",
]);
const directives = new Map<string, string | true>();
for (const part of value?.split(",") ?? []) {
if (value === null) return directives;
const parts = splitCacheControlDirectives(value);
if (!parts) return null;
for (const part of parts) {
const trimmedPart = part.trim();
const separator = trimmedPart.indexOf("=");
const name = (
@@ -367,7 +462,9 @@ function parseCacheControl(
if (flagDirectives.has(name)) return null;
const rawValue = trimmedPart.slice(separator + 1).trim();
if (rawValue === "") return null;
directives.set(name, rawValue.replace(/^"|"$/gu, ""));
const unquoted = unquoteCacheControlValue(rawValue);
if (unquoted === null) return null;
directives.set(name, unquoted);
}
return directives;
}
@@ -388,11 +485,7 @@ async function readBoundedBody(
try {
while (true) {
if (signal.aborted) throw abortException();
const next = await awaitWithAbort(
reader.read(),
signal,
() => undefined,
);
const next = await readOrAbort(reader.read(), signal);
if (next.done) break;
if (!(next.value instanceof Uint8Array)) {
throw new TypeError("Image response chunk is invalid.");
@@ -424,97 +517,99 @@ async function readBoundedBody(
type ProbeAbortScope = Readonly<{
signal: AbortSignal;
/** True once a deadline, or a deadline that could not be installed, ended it. */
timedOut(): boolean;
/**
* Awaits `task` under this scope's ownership. A late value is compensated
* exactly once; a terminal owner raises the scope's abort exception.
*/
await<Value>(
task: Promise<Value>,
onLateValue: (value: Value) => void,
): Promise<Value>;
release(): void;
}>;
/**
* X-AUDIT-02 / TR-RR-05. The probe scope is the shared abort primitive with
* this subsystem's vocabulary on top. Owning a private copy meant the caller
* listener was attached before the timer was installed, so a scheduler that
* threw rejected the public `probe()` promise and left the listener behind.
*/
function createProbeAbortScope(
externalSignal: AbortSignal,
timeoutMs: number,
scheduler: ImageProbeScheduler,
timers: AbortTimerSnapshot,
): ProbeAbortScope {
const controller = new AbortController();
let timeoutReached = false;
let released = false;
const onExternalAbort = () => {
controller.abort(externalSignal.reason);
};
externalSignal.addEventListener("abort", onExternalAbort, {
once: true,
const operation = createAbortableOperation({
signal: externalSignal,
timeoutMs,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
if (externalSignal.aborted) onExternalAbort();
const timeoutHandle = scheduler.setTimeout(() => {
if (released) return;
timeoutReached = true;
controller.abort(abortException());
}, timeoutMs);
return Object.freeze({
signal: controller.signal,
timedOut: () => timeoutReached,
release() {
if (released) return;
released = true;
try {
scheduler.clearTimeout(timeoutHandle);
} catch {
// A broken optional scheduler cannot change a terminal probe result.
}
externalSignal.removeEventListener("abort", onExternalAbort);
signal: operation.signal,
// `CLOSED` here means the deadline could never be installed, so the probe
// was never bounded: operationally the same unanswered host as a deadline.
timedOut: () =>
operation.terminal() !== "CALLER_ABORT" && operation.terminal() !== null,
async await<Value>(
task: Promise<Value>,
onLateValue: (value: Value) => void,
): Promise<Value> {
const raced = await operation.race(task, onLateValue);
if (raced.kind === "VALUE") return raced.value;
if (raced.kind === "REJECTED") throw raced.reason;
throw abortException();
},
release: () => operation.close(),
});
}
function awaitWithAbort<Value>(
/**
* A single read raced against the probe's ownership. A late chunk is dropped:
* the bytes are only ever accumulated by the caller below.
*/
function readOrAbort<Value>(
task: Promise<Value>,
signal: AbortSignal,
onLateValue: (value: Value) => void,
): Promise<Value> {
return new Promise<Value>((resolve, reject) => {
let settled = false;
const onAbort = () => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortException());
};
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) onAbort();
void task.then(
(value) => {
if (settled) {
onLateValue(value);
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
if (!settled) {
settled = true;
resolve(value);
}
},
(error: unknown) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
if (!settled) {
settled = true;
reject(error);
}
},
);
});
}
function signalFailure(
externalSignal: AbortSignal,
scope: ProbeAbortScope,
) {
function signalFailure(externalSignal: AbortSignal) {
// A caller abort is the caller's own verdict; every other owner — a deadline
// or a deadline that could never be installed — is an unanswered host.
return externalSignal.aborted
? browserDataFailure("ABORTED", "IMAGE_RESOLVE")
: scope.timedOut()
? browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
retryable: true,
recovery: "RETRY",
})
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
retryable: true,
recovery: "RETRY",
});
: browserDataFailure("UNAVAILABLE", "IMAGE_RESOLVE", {
retryable: true,
recovery: "RETRY",
});
}
function cancelResponseBody(response: Response): void {
@@ -564,22 +659,6 @@ function withinDecodeBudget(
);
}
function snapshotScheduler(
scheduler: ImageProbeScheduler,
): ImageProbeScheduler {
if (
!scheduler ||
typeof scheduler.setTimeout !== "function" ||
typeof scheduler.clearTimeout !== "function"
) {
throw new TypeError("Image probe scheduler is invalid.");
}
return Object.freeze({
setTimeout: scheduler.setTimeout.bind(scheduler),
clearTimeout: scheduler.clearTimeout.bind(scheduler),
});
}
function defaultScheduler(): ImageProbeScheduler {
return Object.freeze({
setTimeout(callback: () => void, milliseconds: number) {
@@ -323,6 +323,11 @@ export function createImageCdnRuntime(
);
}
activeCapabilityVerifications += 1;
// TR-RR-07. The slot belongs to the raw verifier, not to this wrapper.
// Releasing it when the wrapper's deadline expired let an abandoned
// verification keep running while a new one was admitted, so repeated
// timeouts produced more physical work than the configured cap allows.
const rawVerificationTasks: Promise<unknown>[] = [];
try {
const canonicalPayload =
canonicalImageCapabilityPayload(snapshot);
@@ -345,8 +350,10 @@ export function createImageCdnRuntime(
if (deadline.signal.aborted) {
throw capabilityVerificationAbortException();
}
const digestTask = sha256Hex(digest, canonicalPayload);
rawVerificationTasks.push(digestTask);
bindingDigest = await awaitImageRuntimeAbort(
sha256Hex(digest, canonicalPayload),
digestTask,
deadline.signal,
);
if (
@@ -363,14 +370,15 @@ export function createImageCdnRuntime(
if (deadline.signal.aborted) {
throw capabilityVerificationAbortException();
}
const verifyTask = verifyCapability({
algorithm: snapshot.signature.algorithm,
keyId: snapshot.signature.keyId,
canonicalPayload: Uint8Array.from(canonicalPayload),
signatureBase64Url: snapshot.signature.valueBase64Url,
});
rawVerificationTasks.push(verifyTask);
verified = await awaitImageRuntimeAbort(
verifyCapability({
algorithm: snapshot.signature.algorithm,
keyId: snapshot.signature.keyId,
canonicalPayload: Uint8Array.from(canonicalPayload),
signatureBase64Url:
snapshot.signature.valueBase64Url,
}),
verifyTask,
deadline.signal,
);
} catch {
@@ -427,7 +435,10 @@ export function createImageCdnRuntime(
);
return browserDataSuccess(reference);
} finally {
activeCapabilityVerifications -= 1;
// Released only once the physical work this slot admitted has settled.
void Promise.allSettled(rawVerificationTasks).then(() => {
activeCapabilityVerifications -= 1;
});
}
};
@@ -1,3 +1,6 @@
import {
PRESIGNED_TRANSFER_PROTOCOL,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import type {
PresignedDownloadCapability,
PresignedTransferBinding,
@@ -7,6 +10,11 @@ import type {
PresignedUploadPartCapability,
PresignedUploadPartCapabilityProvider,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import {
createAbortableOperation,
snapshotAbortTimers,
type AbortTimerSnapshot,
} from "../../platform/abortable-operation.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataFailureCode,
@@ -141,16 +149,20 @@ export function createPresignedCapabilityHttpProvider(
);
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
const now = options.now ?? Date.now;
const scheduler =
// X-AUDIT-02. The timer callables are captured once, bound to their
// receiver, so replacing a scheduler method after composition cannot change
// how work already in flight is bounded.
const timers = snapshotAbortTimers(
options.scheduler ??
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler);
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler),
);
const credentials = options.controlPlaneCredentials ?? "same-origin";
const observer = options.observer;
@@ -183,9 +195,10 @@ export function createPresignedCapabilityHttpProvider(
if (signal.aborted) {
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
}
const scope = createAbortScope(signal, timeoutMs, scheduler);
const scope = createAbortScope(signal, timeoutMs, timers);
try {
const response = await fetcher(endpoint, {
const raced = await scope.race(
fetcher(endpoint, {
method: "POST",
credentials,
redirect: "error",
@@ -196,6 +209,10 @@ export function createPresignedCapabilityHttpProvider(
"Content-Type": "application/json",
},
body: JSON.stringify({
// BT-PRE-02. The server negotiates by request shape: a legacy request
// gets a legacy response and a V1 request gets a V1 response. Fields
// are never dual-emitted into a strict decoder.
protocol: PRESIGNED_TRANSFER_PROTOCOL,
method: expected.method,
binding: expected.binding,
...(expected.mediaType !== undefined
@@ -208,8 +225,13 @@ export function createPresignedCapabilityHttpProvider(
? { expectedSha256: expected.expectedSha256 }
: {}),
}),
signal: scope.signal,
});
signal: scope.signal,
}),
);
if (raced === SCOPE_ENDED) {
return transferFailure(signal, scope.timedOut());
}
const response = raced;
if (
response.redirected ||
response.type === "opaqueredirect" ||
@@ -440,6 +462,7 @@ function validateCapabilityPayload(
try {
const payload = strictRecord(value, [
"allowedQueryParameters",
"protocol",
"binding",
"byteLength",
"capabilityReceipt",
@@ -460,6 +483,11 @@ function validateCapabilityPayload(
"requiredResponseHeaders",
"singleUse",
]);
if (payload.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
// Missing, V0 and V2 all close the same way: this envelope is not one we
// can interpret. No new failure code is introduced.
throw new TypeError("Capability transfer protocol is unsupported.");
}
if (payload.singleUse !== true || payload.method !== context.expected.method) {
throw new TypeError("Capability method or replay policy is invalid.");
}
@@ -618,6 +646,9 @@ function validateCapabilityPayload(
return browserDataSuccess(
Object.freeze({
// TR-RR-03. The registration carries the negotiated protocol version so
// the vault validates the same shape the executor later reads.
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt,
method: context.expected.method,
binding,
@@ -854,30 +885,104 @@ function statusFailure(
});
}
/** BT-PRE-03. The scope ended before the task settled. */
const SCOPE_ENDED = Symbol("presigned-capability-scope-ended");
/**
* TR-RR-01 / TR-RR-05. The abort scope is a thin projection of the shared
* `createAbortableOperation` primitive into this subsystem's vocabulary.
*
* Two things changed with the migration. `abort()` exists, so `close()` on a
* download can actually stop a pending fetch instead of only dropping
* listeners; and additional ownership signals — a consumer's stream signal —
* are composed into the same operation *before* any I/O starts, so an
* already-aborted consumer never causes a fetch.
*/
function createAbortScope(
external: AbortSignal,
timeoutMs: number,
scheduler: Scheduler,
timers: AbortTimerSnapshot,
additionalSignals: readonly AbortSignal[] = [],
) {
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(external.reason);
external.addEventListener("abort", onAbort, { once: true });
if (external.aborted) onAbort();
const timer = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
}, timeoutMs);
const operation = createAbortableOperation({
signal: external,
timeoutMs,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
const releases: (() => void)[] = [];
for (const extra of additionalSignals) {
if (!extra) continue;
if (extra.aborted) {
operation.close();
continue;
}
const onAbort = () => operation.close();
try {
extra.addEventListener("abort", onAbort, { once: true });
releases.push(() => {
try {
extra.removeEventListener("abort", onAbort);
} catch {
// A hostile signal facade cannot block the rest of cleanup.
}
});
} catch {
// A signal that refuses a listener cannot bound this operation, so the
// operation closes rather than running unbounded.
operation.close();
}
}
const releaseExtras = () => {
for (const release of releases.splice(0)) release();
};
return Object.freeze({
signal: controller.signal,
timedOut: () => timedOut,
signal: operation.signal,
timedOut: () => operation.terminal() === "DEADLINE",
/** Bounds a fetch that ignores its signal. */
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
const outcome = await operation.race(
task,
(value) => compensateLateResponseValue(value),
);
if (outcome.kind === "VALUE") return outcome.value;
// A collaborator's own rejection stays a rejection: the caller's existing
// catch classifies it, and it is never forged into a cancellation.
if (outcome.kind === "REJECTED") throw outcome.reason;
return SCOPE_ENDED;
},
/** TR-RR-01. Ends the physical work, not just the bookkeeping. */
abort() {
operation.close();
releaseExtras();
},
release() {
scheduler.clearTimeout(timer);
external.removeEventListener("abort", onAbort);
operation.close();
releaseExtras();
},
});
}
function compensateLateResponseValue(value: unknown): void {
const body = (value as { body?: { cancel(): Promise<void> } | null } | null)
?.body;
void body?.cancel().catch(() => undefined);
}
function transferFailure(
signal: AbortSignal,
timedOut: boolean,
): BrowserDataResult<never> {
if (signal.aborted) {
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
}
return browserDataFailure(
timedOut ? "UNAVAILABLE" : "NOT_READABLE",
"PRESIGNED_TRANSFER",
{ retryable: true, recovery: "REISSUE_CAPABILITY" },
);
}
function isAbortSignal(value: unknown): value is AbortSignal {
try {
if (!value || typeof value !== "object") return false;
@@ -1083,18 +1188,64 @@ function validatePathPrefix(value: string): string {
return path;
}
/**
* BT-PRE-05. Object stores and CDNs may percent-decode a path one more time
* than this client does, so rejecting only literal `.`, `..` and backslash is
* not enough: `%2f`, `%5c` and `%252e%252e` can still become separators or dot
* segments downstream.
*
* Each raw segment is strictly percent-decoded once. The decoded value may not
* contain a separator, NUL, a dot segment or a further percent-escape, and
* re-encoding it canonically must reproduce the raw segment exactly. That
* closes double encoding and mixed-case variants while still allowing any valid
* opaque UTF-8 segment.
*/
function validateExactPath(value: unknown): string {
const path = requiredString(value, 2_048);
if (
!path.startsWith("/") ||
path.includes("\\") ||
/[\0\r\n]/.test(path) ||
path.split("/").some((segment) => segment === "." || segment === "..")
/[\0\r\n]/.test(path)
) {
throw new TypeError("Path is invalid.");
}
for (const segment of path.split("/")) {
if (segment === "") continue;
if (segment === "." || segment === "..") {
throw new TypeError("Path is invalid.");
}
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
// Malformed or non-UTF-8 percent-escapes are rejected outright.
throw new TypeError("Path is invalid.");
}
if (
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\") ||
decoded.includes("\0") ||
// A decoded value that still carries a percent-escape would decode again.
/%[0-9A-Fa-f]{2}/u.test(decoded)
) {
throw new TypeError("Path is invalid.");
}
if (canonicalPathSegment(decoded) !== segment) {
throw new TypeError("Path is invalid.");
}
}
return path;
}
/** Uppercase percent-hex canonical form, matching the provider fixtures. */
function canonicalPathSegment(decoded: string): string {
return encodeURIComponent(decoded).replace(
/%[0-9a-f]{2}/gu,
(escape) => escape.toUpperCase(),
);
}
class ResponseLimitError extends Error {}
class ResponseIntegrityError extends Error {}
@@ -1,22 +1,73 @@
import type {
PresignedTransferBinding,
PresignedTransferCapability,
PresignedTransferCapabilityReceipt,
PresignedTransferMethod,
PresignedTransferReplayGuard,
import {
PRESIGNED_TRANSFER_PROTOCOL,
type PresignedTransferBinding,
type PresignedTransferCapability,
type PresignedTransferCapabilityReceipt,
type PresignedTransferMethod,
type PresignedTransferProtocol,
type PresignedTransferReplayGuard,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
ownDataValue,
snapshotExactArray,
snapshotExactObject,
} from "../../../contracts/exact-snapshot.ts";
export type PresignedHeaderBinding = Readonly<{
name: string;
value: string;
}>;
/**
* TR-RR-03. A registration is a versioned exact union. Without the version an
* issuer from another release could register a shape this vault validates under
* different rules than the executor later applies to it.
*/
/** TR-RR-03. The exact own-data key set a registration may carry. */
const REGISTRATION_KEYS: readonly string[] = Object.freeze(
[
"allowedQueryParameters",
"binding",
"byteLength",
"capabilityReceipt",
"digestRequestHeader",
"digestResponseHeader",
"expectedResponseByteLength",
"expectedSha256",
"expectedStatus",
"expiresAtEpochMs",
"href",
"maxBytes",
"mediaType",
"method",
"origin",
"path",
"protocol",
"receiptResponseHeader",
"requestHeaders",
"requiredResponseHeaders",
].sort(),
);
/**
* TR-RR-03. Ambient credential and cookie headers are forbidden on a presigned
* capability: the signature is the authorization.
*/
const FORBIDDEN_CAPABILITY_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"cookie",
"cookie2",
"proxy-authorization",
"set-cookie",
]);
export type PresignedCapabilityRegistration = Readonly<{
protocol: PresignedTransferProtocol;
capabilityReceipt: PresignedTransferCapabilityReceipt;
method: PresignedTransferMethod;
binding: PresignedTransferBinding;
@@ -109,6 +160,96 @@ export function createPresignedCapabilityVault(options: Readonly<{
}
}
/**
* BT-PRE-04 / TR-RR-03. Runtime invariants every registration must satisfy,
* regardless of which issuer produced it.
*/
function validatePresignedCapabilityRegistration(
registration: PresignedCapabilityRegistration,
): BrowserDataResult<never> | null {
const invalid = () =>
browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
recovery: "REISSUE_CAPABILITY",
});
if (!registration || typeof registration !== "object") return invalid();
if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
return invalid();
}
if (
typeof registration.capabilityReceipt !== "string" ||
registration.capabilityReceipt.length === 0 ||
(registration.method !== "GET" && registration.method !== "PUT")
) {
return invalid();
}
let target: URL;
let origin: URL;
try {
target = new URL(registration.href);
origin = new URL(registration.origin);
} catch {
return invalid();
}
if (
// TR-RR-03. A presigned capability travels the network as a bearer of its
// own authority, so plaintext is never acceptable.
target.protocol !== "https:" ||
origin.protocol !== "https:" ||
target.origin !== origin.origin ||
origin.href.replace(/\/$/u, "") !== registration.origin.replace(/\/$/u, "") ||
target.pathname !== registration.path ||
target.username.length > 0 ||
target.password.length > 0 ||
target.hash.length > 0
) {
return invalid();
}
// TR-RR-03. A presigned URL already carries its authorization. An ambient
// credential header alongside it would send the user's session to an
// origin the capability alone was meant to reach.
for (const header of [
...registration.requestHeaders,
...registration.requiredResponseHeaders,
]) {
if (
FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase())
) {
return invalid();
}
}
if (
registration.allowedQueryParameters.some(
(parameter) => typeof parameter !== "string" || parameter.length === 0,
)
) {
return invalid();
}
if (
!Number.isSafeInteger(registration.expectedStatus) ||
registration.expectedStatus < 200 ||
registration.expectedStatus > 299 ||
(registration.expectedResponseByteLength !== null &&
(!Number.isSafeInteger(registration.expectedResponseByteLength) ||
registration.expectedResponseByteLength < 0))
) {
return invalid();
}
if (
!Number.isSafeInteger(registration.byteLength) ||
registration.byteLength < 0 ||
!Number.isSafeInteger(registration.maxBytes) ||
registration.maxBytes < registration.byteLength ||
!Number.isSafeInteger(registration.expiresAtEpochMs) ||
registration.expiresAtEpochMs <= 0 ||
!/^[a-f0-9]{64}$/u.test(registration.expectedSha256) ||
typeof registration.mediaType !== "string" ||
registration.mediaType.length === 0
) {
return invalid();
}
return null;
}
return Object.freeze({
register(
registration: PresignedCapabilityRegistration,
@@ -116,6 +257,23 @@ export function createPresignedCapabilityVault(options: Readonly<{
if (disposed) {
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
}
// TR-01. One owned snapshot first, then validate and store only that
// snapshot. Validating the issuer's own object and reading it again to
// copy it let a stateful answer show an allowed header set to the
// forbidden-header check and hand `Authorization` to the copy, so the
// vault stored a capability no rule had ever seen.
const snapshot = snapshotRegistration(registration);
if (!snapshot) {
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
recovery: "REISSUE_CAPABILITY",
});
}
// BT-PRE-04. The vault owns its own registration invariants so a second
// issuer adapter, a test seam or composition code cannot register a
// weaker capability of the same type. The HTTP decoder still owns the
// wire shape; this only re-checks runtime invariants.
const invalid = validatePresignedCapabilityRegistration(snapshot);
if (invalid) return invalid;
pruneExpired();
if (
byReceipt.has(registration.capabilityReceipt) ||
@@ -133,36 +291,37 @@ export function createPresignedCapabilityVault(options: Readonly<{
}
const capability = Object.freeze({
capabilityReceipt: registration.capabilityReceipt,
method: registration.method,
binding: freezeBinding(registration.binding),
mediaType: registration.mediaType,
byteLength: registration.byteLength,
maxBytes: registration.maxBytes,
expectedSha256: registration.expectedSha256,
expiresAtEpochMs: registration.expiresAtEpochMs,
capabilityReceipt: snapshot.capabilityReceipt,
method: snapshot.method,
binding: freezeBinding(snapshot.binding),
mediaType: snapshot.mediaType,
byteLength: snapshot.byteLength,
maxBytes: snapshot.maxBytes,
expectedSha256: snapshot.expectedSha256,
expiresAtEpochMs: snapshot.expiresAtEpochMs,
}) as PresignedTransferCapability;
const binding: PresignedCapabilityBinding = Object.freeze({
capability,
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt: capability.capabilityReceipt,
method: capability.method,
binding: capability.binding,
href: registration.href,
origin: registration.origin,
path: registration.path,
href: snapshot.href,
origin: snapshot.origin,
path: snapshot.path,
allowedQueryParameters: Object.freeze([
...registration.allowedQueryParameters,
...snapshot.allowedQueryParameters,
]),
requestHeaders: freezeHeaders(registration.requestHeaders),
requestHeaders: freezeHeaders(snapshot.requestHeaders),
requiredResponseHeaders: freezeHeaders(
registration.requiredResponseHeaders,
snapshot.requiredResponseHeaders,
),
digestRequestHeader: registration.digestRequestHeader,
digestResponseHeader: registration.digestResponseHeader,
receiptResponseHeader: registration.receiptResponseHeader,
expectedStatus: registration.expectedStatus,
digestRequestHeader: snapshot.digestRequestHeader,
digestResponseHeader: snapshot.digestResponseHeader,
receiptResponseHeader: snapshot.receiptResponseHeader,
expectedStatus: snapshot.expectedStatus,
expectedResponseByteLength:
registration.expectedResponseByteLength,
snapshot.expectedResponseByteLength,
mediaType: capability.mediaType,
byteLength: capability.byteLength,
maxBytes: capability.maxBytes,
@@ -242,6 +401,95 @@ export function createSingleUsePresignedReplayGuard():
});
}
const DOWNLOAD_BINDING_KEYS = Object.freeze(["kind", "resourceId"]);
const UPLOAD_BINDING_KEYS = Object.freeze([
"kind",
"protocol",
"sessionId",
"requestBindingSha256",
"uploadBindingSha256",
"partNumber",
"offset",
"idempotencyKey",
]);
/**
* TR-01. Copies a registration and everything nested inside it into owned,
* frozen values, reading each property exactly once. Only this snapshot is
* validated and stored, so a stateful issuer cannot show one value to the
* forbidden-header and HTTPS checks and hand another to the vault. A hostile
* trap, an accessor, an inherited or extra field and a non-iterable header
* array all resolve to `null` — a typed `POLICY_REJECTED` — rather than
* escaping as a native exception.
*/
function snapshotRegistration(
source: unknown,
): PresignedCapabilityRegistration | null {
const outer = snapshotExactObject(source, {
allowed: REGISTRATION_KEYS,
required: REGISTRATION_KEYS,
});
if (outer === null) return null;
const binding = snapshotBinding(outer["binding"]);
if (binding === null) return null;
const allowedQueryParameters = snapshotExactArray(
outer["allowedQueryParameters"],
);
if (allowedQueryParameters === null) return null;
const requestHeaders = snapshotHeaderBindings(outer["requestHeaders"]);
const requiredResponseHeaders = snapshotHeaderBindings(
outer["requiredResponseHeaders"],
);
if (requestHeaders === null || requiredResponseHeaders === null) return null;
return Object.freeze({
...outer,
binding,
allowedQueryParameters: Object.freeze([...allowedQueryParameters]),
requestHeaders,
requiredResponseHeaders,
}) as PresignedCapabilityRegistration;
}
function snapshotBinding(source: unknown): PresignedTransferBinding | null {
const kind = ownDataValue(source, "kind");
if (kind !== "DOWNLOAD" && kind !== "UPLOAD_PART") return null;
const keys =
kind === "DOWNLOAD" ? DOWNLOAD_BINDING_KEYS : UPLOAD_BINDING_KEYS;
const binding = snapshotExactObject(source, {
allowed: keys,
required: keys,
});
return binding === null
? null
: (binding as unknown as PresignedTransferBinding);
}
function snapshotHeaderBindings(
source: unknown,
): readonly PresignedHeaderBinding[] | null {
const rows = snapshotExactArray(source);
if (rows === null) return null;
const headers: PresignedHeaderBinding[] = [];
for (const row of rows) {
const header = snapshotExactObject(row, {
allowed: ["name", "value"],
required: ["name", "value"],
});
if (
header === null ||
typeof header["name"] !== "string" ||
header["name"].length === 0 ||
typeof header["value"] !== "string"
) {
return null;
}
headers.push(header as unknown as PresignedHeaderBinding);
}
return Object.freeze(headers);
}
function freezeBinding(
binding: PresignedTransferBinding,
): PresignedTransferBinding {
@@ -8,6 +8,11 @@ import type {
PresignedUploadPartOutcome,
PresignedUploadPartPort,
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
import {
createAbortableOperation,
snapshotAbortTimers,
type AbortTimerSnapshot,
} from "../../platform/abortable-operation.ts";
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type {
BrowserDataFailureCode,
@@ -91,16 +96,20 @@ export function createPresignedTransferExecutor(
const timeoutMs = positiveSafeInteger(options.timeoutMs);
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
const now = options.now ?? Date.now;
const scheduler =
// X-AUDIT-02. The timer callables are captured once, bound to their
// receiver, so replacing a scheduler method after composition cannot change
// how work already in flight is bounded.
const timers = snapshotAbortTimers(
options.scheduler ??
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler);
({
setTimeout: (callback, milliseconds) =>
globalThis.setTimeout(callback, milliseconds),
clearTimeout: (handle) =>
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
),
} satisfies Scheduler),
);
const createVerifier =
options.createStreamingVerifier ?? createStreamingSha256Verifier;
if (options.digestBytes === undefined && !globalThis.crypto?.subtle) {
@@ -154,41 +163,40 @@ export function createPresignedTransferExecutor(
const consumed = consume(capability);
if (!consumed.ok) return consumed;
const scope = createAbortScope(signal, timeoutMs, scheduler);
try {
const response = await fetcher(binding.href, {
method: "GET",
headers: headersFor(binding),
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
});
const validated = validateDownloadResponse(
response,
binding,
);
if (!validated.ok) {
cancelBody(response);
scope.release();
return validated;
}
const source = createDownloadSource({
response,
binding,
capability,
externalSignal: signal,
scope,
hardMaxChunkBytes,
createVerifier,
observer,
});
return browserDataSuccess(source);
} catch {
scope.release();
return transferFailure(signal, scope.timedOut());
}
// BT-PRE-01. The lease is lazy and single-start: `open()` performs no
// network I/O, so the transfer deadline begins at first consumption and an
// unused source can be discarded through `close()` without leaking a body,
// a timer or a listener.
const source = createDownloadSource({
start: async (scope) =>
await fetcher(binding.href, {
method: "GET",
headers: headersFor(binding),
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
}),
validateResponse: (response) =>
validateDownloadResponse(response, binding),
createScope: (consumerSignal?: AbortSignal) =>
createAbortScope(
signal,
timeoutMs,
timers,
consumerSignal ? [consumerSignal] : [],
),
recheckExpiry: () =>
validateExpiry(capability, minimumRemainingLifetimeMs, now()),
binding,
capability,
externalSignal: signal,
hardMaxChunkBytes,
createVerifier,
observer,
});
return browserDataSuccess(source);
}
async function putUploadPart(
@@ -273,47 +281,67 @@ export function createPresignedTransferExecutor(
) {
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
}
let actualDigest: string;
// TR-RR-02. The abort scope is created first, so the digest — which can be
// a long or non-settling computation over a large buffer — is owned by the
// caller signal and the deadline like every other step. Computing it before
// the scope existed meant an abort or a deadline could not reach it, and a
// hash that never settled held the whole `put` open.
const scope = createAbortScope(request.signal, timeoutMs, timers);
try {
actualDigest = normalizedSha256(await digestBytes(bytes));
} catch {
return browserDataFailure(
"INTEGRITY_FAILED",
"PRESIGNED_TRANSFER",
const digested = await scope.race(
Promise.resolve().then(async () => await digestBytes(bytes)),
);
}
if (actualDigest !== capability.expectedSha256) {
return browserDataFailure(
"INTEGRITY_FAILED",
"PRESIGNED_TRANSFER",
if (digested === SCOPE_ENDED) {
return transferFailure(request.signal, scope.timedOut());
}
let actualDigest: string;
try {
actualDigest = normalizedSha256(digested);
} catch {
return browserDataFailure(
"INTEGRITY_FAILED",
"PRESIGNED_TRANSFER",
);
}
if (actualDigest !== capability.expectedSha256) {
return browserDataFailure(
"INTEGRITY_FAILED",
"PRESIGNED_TRANSFER",
);
}
// The owner is re-checked after the wait: a claim and a network call may
// only follow a digest that finished while this operation still held the
// execution.
if (scope.signal.aborted || request.signal.aborted) {
return transferFailure(request.signal, scope.timedOut());
}
const active = validateExpiry(
capability,
minimumRemainingLifetimeMs,
now(),
);
}
if (request.signal.aborted) {
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
}
const active = validateExpiry(
capability,
minimumRemainingLifetimeMs,
now(),
);
if (!active.ok) return active;
const claimed = claim(capability);
if (!claimed.ok) return claimed;
const consumed = consume(capability);
if (!consumed.ok) return consumed;
if (!active.ok) return active;
const claimed = claim(capability);
if (!claimed.ok) return claimed;
const consumed = consume(capability);
if (!consumed.ok) return consumed;
const scope = createAbortScope(request.signal, timeoutMs, scheduler);
try {
const response = await fetcher(binding.href, {
method: "PUT",
headers: headersFor(binding),
body: bytes.buffer,
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
});
const raced = await scope.race(
fetcher(binding.href, {
method: "PUT",
headers: headersFor(binding),
body: bytes.buffer,
credentials: "omit",
redirect: "error",
referrerPolicy: "no-referrer",
cache: "no-store",
signal: scope.signal,
}),
);
if (raced === SCOPE_ENDED) {
return transferFailure(request.signal, scope.timedOut());
}
const response = raced;
const validated = validateUploadResponse(
response,
binding,
@@ -406,29 +434,61 @@ export function createPresignedTransferExecutor(
}
function createDownloadSource(input: Readonly<{
response: Response;
start: (
scope: ReturnType<typeof createAbortScope>,
) => Promise<Response>;
validateResponse: (response: Response) => BrowserDataResult<unknown>;
createScope: (
consumerSignal?: AbortSignal,
) => ReturnType<typeof createAbortScope>;
recheckExpiry: () => BrowserDataResult<unknown>;
binding: PresignedCapabilityBinding;
capability: PresignedDownloadCapability;
externalSignal: AbortSignal;
scope: ReturnType<typeof createAbortScope>;
hardMaxChunkBytes: number;
createVerifier: (
expectedSha256: string,
) => StreamingSha256Verifier;
observer: BrowserDataObserver | undefined;
}>): PresignedDownloadByteSource {
let started = false;
/** BT-PRE-01. One state machine shared by `stream()` and `close()`. */
let state: "READY" | "STREAMING" | "CLOSED" = "READY";
let activeScope: ReturnType<typeof createAbortScope> | undefined;
let activeResponse: Response | undefined;
// TR-RR-01. Releasing the scope only dropped listeners and the timer, so a
// fetch or read already in flight kept running after `close()`. The scope is
// aborted here, which is what actually ends the physical I/O.
const releaseActive = () => {
if (activeScope) {
activeScope.abort();
}
if (activeResponse) {
cancelBody(activeResponse);
activeResponse = undefined;
}
if (activeScope) {
activeScope.release();
activeScope = undefined;
}
};
return Object.freeze({
byteLength: input.capability.byteLength,
capability: input.capability,
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
close() {
// READY -> CLOSED performs no I/O; STREAMING -> CLOSED cancels once.
if (state === "CLOSED") return;
state = "CLOSED";
releaseActive();
},
async *stream(
consumerSignal: AbortSignal,
): AsyncIterable<BrowserDataResult<Uint8Array>> {
if (!isAbortSignal(consumerSignal)) {
started = true;
cancelBody(input.response);
input.scope.release();
state = "CLOSED";
releaseActive();
const failure = browserDataFailure(
"INVALID_INPUT",
"PRESIGNED_TRANSFER",
@@ -437,7 +497,7 @@ function createDownloadSource(input: Readonly<{
yield failure;
return;
}
if (started) {
if (state !== "READY") {
const failure = browserDataFailure(
"CONFLICT",
"PRESIGNED_TRANSFER",
@@ -449,10 +509,72 @@ function createDownloadSource(input: Readonly<{
yield failure;
return;
}
started = true;
let combined:
| ReturnType<typeof combineConsumerAbort>
| undefined;
state = "STREAMING";
// The capability may have expired while the lease sat unused.
const stillActive = input.recheckExpiry();
if (!stillActive.ok) {
state = "CLOSED";
observeTransferResult(
input.observer,
"DOWNLOAD",
stillActive,
0,
);
yield stillActive as BrowserDataResult<never>;
return;
}
// TR-RR-01. The consumer's stream signal is part of this operation's
// ownership from the start. Composing it only after the fetch had begun
// meant an already-aborted consumer still caused one network request.
const scope = input.createScope(consumerSignal);
activeScope = scope;
if (scope.signal.aborted) {
state = "CLOSED";
releaseActive();
const failure = transferFailure(
input.externalSignal,
scope.timedOut(),
);
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
yield failure;
return;
}
let response: Response;
try {
// BT-PRE-03. A fetch that ignores its signal cannot outlive the scope.
const started = await scope.race(input.start(scope));
if (started === SCOPE_ENDED) {
state = "CLOSED";
releaseActive();
const failure = transferFailure(
input.externalSignal,
scope.timedOut(),
);
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
yield failure;
return;
}
response = started;
} catch {
state = "CLOSED";
releaseActive();
const failure = transferFailure(
input.externalSignal,
scope.timedOut(),
);
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
yield failure;
return;
}
activeResponse = response;
const validated = input.validateResponse(response);
if (!validated.ok) {
state = "CLOSED";
releaseActive();
observeTransferResult(input.observer, "DOWNLOAD", validated, 0);
yield validated as BrowserDataResult<never>;
return;
}
let reader:
| ReadableStreamDefaultReader<Uint8Array>
| undefined;
@@ -470,14 +592,10 @@ function createDownloadSource(input: Readonly<{
return browserDataFailure(code, "PRESIGNED_TRANSFER", options);
};
try {
combined = combineConsumerAbort(
input.scope,
consumerSignal,
);
const verifier = input.createVerifier(
input.capability.expectedSha256,
);
if (!input.response.body) {
if (!response.body) {
let verified = false;
try {
verified =
@@ -492,7 +610,7 @@ function createDownloadSource(input: Readonly<{
yield fail("INTEGRITY_FAILED");
return;
}
reader = input.response.body.getReader();
reader = response.body.getReader();
while (true) {
if (
input.externalSignal.aborted ||
@@ -501,7 +619,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED");
return;
}
if (input.scope.timedOut()) {
if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -510,7 +628,7 @@ function createDownloadSource(input: Readonly<{
}
const result = await readWithSignal(
reader,
input.scope.signal,
scope.signal,
);
if (result.done) break;
const chunk = result.value;
@@ -544,7 +662,7 @@ function createDownloadSource(input: Readonly<{
yield fail("ABORTED");
return;
}
if (input.scope.timedOut()) {
if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -590,7 +708,7 @@ function createDownloadSource(input: Readonly<{
consumerSignal.aborted
) {
yield fail("ABORTED");
} else if (input.scope.timedOut()) {
} else if (scope.timedOut()) {
yield fail("UNAVAILABLE", {
retryable: true,
recovery: "REISSUE_CAPABILITY",
@@ -602,17 +720,20 @@ function createDownloadSource(input: Readonly<{
});
}
} finally {
combined?.release();
if (!completed) {
if (reader) cancelReader(reader);
else cancelBody(input.response);
else cancelBody(response);
}
try {
reader?.releaseLock();
} catch {
// Reader cleanup cannot change stream success or failure.
}
input.scope.release();
// The lease is terminal once its single stream ends; cleanup runs once.
state = "CLOSED";
activeResponse = undefined;
activeScope = undefined;
scope.release();
observeBrowserData(input.observer, {
operation: "DOWNLOAD",
outcome: completed
@@ -925,50 +1046,85 @@ function transferFailure(
);
}
/** BT-PRE-03. The scope ended before the task settled. */
const SCOPE_ENDED = Symbol("presigned-scope-ended");
function compensateLateResponse(task: Promise<unknown>): void {
void task
.then(async (value) => {
const body = (value as { body?: { cancel(): Promise<void> } | null })
?.body;
await body?.cancel();
})
.catch(() => undefined);
}
/**
* TR-RR-01 / TR-RR-05. A projection of the shared `createAbortableOperation`
* primitive into this subsystem's vocabulary, replacing a second hand-written
* copy of the same mechanics.
*
* `additionalSignals` lets a consumer's stream signal join the operation's
* ownership before any I/O begins, and `abort()` ends the physical work rather
* than only releasing bookkeeping.
*/
function createAbortScope(
external: AbortSignal,
timeoutMs: number,
scheduler: Scheduler,
timers: AbortTimerSnapshot,
additionalSignals: readonly AbortSignal[] = [],
) {
const controller = new AbortController();
let timedOut = false;
let released = false;
const onAbort = () => controller.abort(external.reason);
const releaseListener = () => {
external.removeEventListener("abort", onAbort);
};
external.addEventListener("abort", onAbort, { once: true });
if (external.aborted) onAbort();
const timer = scheduler.setTimeout(() => {
timedOut = true;
controller.abort("timeout");
releaseListener();
}, timeoutMs);
return Object.freeze({
signal: controller.signal,
timedOut: () => timedOut,
abort(reason?: unknown) {
controller.abort(reason);
},
release() {
if (released) return;
released = true;
scheduler.clearTimeout(timer);
releaseListener();
},
const operation = createAbortableOperation({
signal: external,
timeoutMs,
setTimer: timers.setTimer,
clearTimer: timers.clearTimer,
});
}
function combineConsumerAbort(
scope: ReturnType<typeof createAbortScope>,
consumer: AbortSignal,
) {
const onAbort = () => scope.abort(consumer.reason);
consumer.addEventListener("abort", onAbort, { once: true });
if (consumer.aborted) onAbort();
const releases: (() => void)[] = [];
for (const extra of additionalSignals) {
if (!extra) continue;
if (extra.aborted) {
operation.close();
continue;
}
const onAbort = () => operation.close();
try {
extra.addEventListener("abort", onAbort, { once: true });
releases.push(() => {
try {
extra.removeEventListener("abort", onAbort);
} catch {
// A hostile signal facade cannot block the rest of cleanup.
}
});
} catch {
operation.close();
}
}
const releaseExtras = () => {
for (const release of releases.splice(0)) release();
};
return Object.freeze({
signal: operation.signal,
timedOut: () => operation.terminal() === "DEADLINE",
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
const outcome = await operation.race(task, (value) => {
const body = (
value as { body?: { cancel(): Promise<void> } | null } | null
)?.body;
void body?.cancel().catch(() => undefined);
});
if (outcome.kind === "VALUE") return outcome.value;
if (outcome.kind === "REJECTED") throw outcome.reason;
return SCOPE_ENDED;
},
abort() {
operation.close();
releaseExtras();
},
release() {
consumer.removeEventListener("abort", onAbort);
operation.close();
releaseExtras();
},
});
}
@@ -10,6 +10,11 @@ import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
createAbortableOperation,
type AbortRace,
type AbortTerminalReason,
} from "../../platform/abortable-operation.ts";
import type {
ResumableUploadControlOperation,
ResumableUploadJsonTransport,
@@ -32,6 +37,13 @@ export type ResumableUploadFetchTransportDependencies = Readonly<{
expectedSuccessStatuses?: Partial<
Readonly<Record<ResumableUploadControlOperation, number>>
>;
/** BT-UP-02. Injected epoch clock; defaults to `Date.now`. */
nowEpochMs?: () => number;
/** BT-UP-02. Injected scheduler for the request timeout. */
scheduler?: Readonly<{
setTimeout(callback: () => void, delayMs: number): unknown;
clearTimeout(handle: unknown): void;
}>;
}>;
const DEFAULT_SUCCESS_STATUSES: Readonly<
@@ -68,6 +80,37 @@ export function createResumableUploadFetchJsonTransport(
throw new TypeError("Upload fetch transport dependency is invalid.");
}
const headers = snapshotHeaders(input.requestHeaders ?? []);
// BT-UP-02. Snapshot and validate the clock and scheduler once.
const nowEpochMs = input.nowEpochMs ?? (() => Date.now());
/** A broken clock must not produce a negative or NaN retry delay. */
const safeNowEpochMs = (): number => {
try {
const value = nowEpochMs();
return Number.isSafeInteger(value) && value >= 0 ? value : Number.NaN;
} catch {
return Number.NaN;
}
};
const scheduler = input.scheduler ?? {
setTimeout: (callback: () => void, delayMs: number) =>
setTimeout(callback, delayMs),
clearTimeout: (handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
},
};
if (
typeof nowEpochMs !== "function" ||
typeof scheduler.setTimeout !== "function" ||
typeof scheduler.clearTimeout !== "function"
) {
throw new TypeError("Upload fetch transport dependency is invalid.");
}
// X-AUDIT-02. Reading `scheduler.setTimeout` again at request time made the
// validated dependency and the executed one two different things: replacing
// the method after composition changed how an attempt was bounded. The
// callables are bound to their receiver once, here.
const setTimer = scheduler.setTimeout.bind(scheduler);
const clearTimer = scheduler.clearTimeout.bind(scheduler);
const timeoutMs = boundedPositiveInteger(
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1,
@@ -139,8 +182,18 @@ export function createResumableUploadFetchJsonTransport(
"NONE",
);
}
const attempt = createFetchAttempt(signal, timeoutMs);
const attempt = createFetchAttempt(
signal,
timeoutMs,
setTimer,
clearTimer,
);
try {
if (attempt.terminalKind() !== null) {
// The attempt was closed before it could be bounded, so no request is
// ever put on the wire.
return attemptFailure(attempt, operation);
}
const fetchPromise = fetcher(endpoint, {
method: "POST",
headers: headersFor(headers),
@@ -154,19 +207,13 @@ export function createResumableUploadFetchJsonTransport(
? "same-origin"
: "cors",
});
const raced = await Promise.race([
fetchPromise.then(
(value) => {
if (attempt.terminalKind()) {
cancelResponseBody(value);
}
return { kind: "RESPONSE" as const, value };
},
() => ({ kind: "FAILED" as const }),
),
attempt.terminal,
]);
if (raced.kind !== "RESPONSE") {
const raced = await attempt.race(
Promise.resolve(fetchPromise),
(late) => {
cancelResponseBody(late);
},
);
if (raced.kind !== "VALUE") {
return attemptFailure(attempt, operation);
}
const response = raced.value;
@@ -188,6 +235,7 @@ export function createResumableUploadFetchJsonTransport(
response,
operation,
maxRetryAfterMs,
safeNowEpochMs(),
);
cancelResponseBody(response);
return failed;
@@ -390,18 +438,8 @@ async function readBoundedJson(
let total = 0;
try {
while (true) {
const raced = await Promise.race([
reader.read().then(
(value) => ({ kind: "READ" as const, value }),
() => ({ kind: "FAILED" as const }),
),
attempt.terminal,
]);
if (raced.kind === "ABORT" || raced.kind === "TIMEOUT") {
cancelReader(reader);
return attemptFailure(attempt, operation);
}
if (raced.kind === "FAILED") {
const raced = await attempt.race(reader.read());
if (raced.kind !== "VALUE") {
cancelReader(reader);
return attemptFailure(attempt, operation);
}
@@ -473,6 +511,7 @@ function statusFailure(
response: Response,
operation: ResumableUploadControlOperation,
maxRetryAfterMs: number,
nowEpochMs: number,
): UploadProviderResult<never> {
if (response.status === 400 || response.status === 422) {
return failure("INVALID_INPUT", operation, false, "NONE");
@@ -495,6 +534,7 @@ function statusFailure(
if (response.status === 429) {
const retryAfterMs = parseRetryAfter(
response.headers.get("retry-after"),
nowEpochMs,
);
return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs
? failure(
@@ -534,50 +574,54 @@ function failure(
return Object.freeze({ ok: false, error });
}
type FetchAttemptTerminal =
| Readonly<{ kind: "ABORT" }>
| Readonly<{ kind: "TIMEOUT" }>;
type FetchAttemptTerminalKind = "ABORT" | "TIMEOUT" | "CLOSED";
type FetchAttempt = Readonly<{
signal: AbortSignal;
terminal: Promise<FetchAttemptTerminal>;
terminalKind(): FetchAttemptTerminal["kind"] | null;
terminalKind(): FetchAttemptTerminalKind | null;
race<Value>(
task: Promise<Value>,
compensate?: (value: Value) => void,
): Promise<AbortRace<Value>>;
release(): void;
}>;
/**
* X-AUDIT-02 / TR-RR-05. The attempt is the shared abort primitive with this
* subsystem's vocabulary on top. Owning a private copy meant the listener was
* attached before the timer was installed, so a scheduler that threw rejected
* the public `execute()` promise and left the listener on the caller's signal.
*/
function createFetchAttempt(
parent: AbortSignal,
timeoutMs: number,
setTimer: (callback: () => void, delayMs: number) => unknown,
clearTimer: (handle: unknown) => void,
): FetchAttempt {
const controller = new AbortController();
let terminalKind: FetchAttemptTerminal["kind"] | null = null;
let resolveTerminal:
| ((value: FetchAttemptTerminal) => void)
| undefined;
const terminal = new Promise<FetchAttemptTerminal>(
(resolve) => {
resolveTerminal = resolve;
},
);
const finish = (kind: FetchAttemptTerminal["kind"]) => {
if (terminalKind) return;
terminalKind = kind;
controller.abort();
resolveTerminal?.(Object.freeze({ kind }));
const operation = createAbortableOperation({
signal: parent,
timeoutMs,
setTimer,
clearTimer,
});
const kindOf = (
reason: AbortTerminalReason | null,
): FetchAttemptTerminalKind | null => {
if (reason === null) return null;
if (reason === "CALLER_ABORT") return "ABORT";
return reason === "DEADLINE" ? "TIMEOUT" : "CLOSED";
};
const abort = () => finish("ABORT");
parent.addEventListener("abort", abort, { once: true });
if (parent.aborted) abort();
const timer = setTimeout(() => {
finish("TIMEOUT");
}, timeoutMs);
return Object.freeze({
signal: controller.signal,
terminal,
terminalKind: () => terminalKind,
signal: operation.signal,
terminalKind: () => kindOf(operation.terminal()),
race: <Value,>(
task: Promise<Value>,
compensate?: (value: Value) => void,
) => operation.race(task, compensate),
release() {
clearTimeout(timer);
parent.removeEventListener("abort", abort);
// BT-UP-01. Cleanup is best effort and must never replace the already
// classified terminal result with a rejection.
operation.close();
},
});
}
@@ -623,7 +667,15 @@ function releaseReader(
}
}
function parseRetryAfter(value: string | null): number | null {
/**
* BT-UP-02. Both the delta-seconds and the HTTP-date branch resolve against the
* same captured `now`, so a fake clock makes boundary, rollback and invalid-date
* behaviour deterministic instead of depending on the global clock.
*/
function parseRetryAfter(
value: string | null,
nowEpochMs: number,
): number | null {
if (!value) return null;
if (/^(0|[1-9][0-9]*)$/u.test(value)) {
const seconds = Number(value);
@@ -631,9 +683,11 @@ function parseRetryAfter(value: string | null): number | null {
return Number.isSafeInteger(milliseconds) ? milliseconds : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
if (!Number.isFinite(timestamp) || !Number.isSafeInteger(nowEpochMs)) {
return null;
}
// A clock that moved backwards yields zero, never a negative delay.
return Math.max(0, timestamp - nowEpochMs);
}
function jsonContentType(value: string | null): boolean {
@@ -667,12 +721,18 @@ function boundedPositiveInteger(
return value;
}
/**
* BT-UP-01. The structural guard must cover every method cleanup will call.
* Admitting a signal without `removeEventListener` turned a `finally` into a
* Promise rejection instead of the typed terminal result.
*/
function isAbortSignal(value: unknown): value is AbortSignal {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as AbortSignal).aborted === "boolean" &&
typeof (value as AbortSignal).addEventListener === "function",
typeof (value as AbortSignal).addEventListener === "function" &&
typeof (value as AbortSignal).removeEventListener === "function",
);
}
@@ -15,6 +15,11 @@ import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import {
ownDataValue,
snapshotExactArray,
snapshotExactObject,
} from "../../../contracts/exact-snapshot.ts";
import {
isSafeUploadReceiptToken,
isUploadFileFingerprint,
@@ -104,7 +109,7 @@ export function createResumableUploadHttpControlPlane(
"UPLOAD_SESSION",
);
if (!response.ok) return response;
const session = decodeSession(response.value);
const session = decodeSafely(decodeSession, response.value);
return session
? browserDataSuccess(session)
: browserDataFailure(
@@ -140,7 +145,7 @@ export function createResumableUploadHttpControlPlane(
"UPLOAD_RECONCILE",
);
if (!response.ok) return response;
const status = decodeStatus(response.value);
const status = decodeSafely(decodeStatus, response.value);
return status
? browserDataSuccess(status)
: browserDataFailure(
@@ -158,7 +163,7 @@ export function createResumableUploadHttpControlPlane(
!SHA256_HEX.test(input.uploadBindingSha256) ||
!isUploadFileFingerprint(input.fingerprint) ||
!MEDIA_TYPE.test(input.mediaType) ||
!isUploadPartReceiptShape(input.part) ||
decodeUploadPartShape(input.part) === null ||
!safeIdempotencyKey(input.idempotencyKey)
) {
return browserDataFailure(
@@ -270,7 +275,7 @@ export function createResumableUploadHttpControlPlane(
"UPLOAD_COMPLETE",
);
if (!response.ok) return response;
const completed = decodeCompletion(response.value);
const completed = decodeSafely(decodeCompletion, response.value);
return completed
? browserDataSuccess(completed)
: browserDataFailure(
@@ -303,15 +308,16 @@ export function createResumableUploadHttpControlPlane(
"UPLOAD_ABORT",
);
if (!response.ok) return response;
const abortState = exactSnapshot(response.value, ["state"]);
if (
!exactKeys(response.value, ["state"]) ||
typeof response.value.state !== "string" ||
!abortState ||
typeof abortState["state"] !== "string" ||
![
"ABORTED",
"NOT_FOUND",
"EXPIRED",
"ALREADY_COMPLETED",
].includes(response.value.state)
].includes(abortState["state"])
) {
return browserDataFailure(
"CORRUPT_DATA",
@@ -321,7 +327,7 @@ export function createResumableUploadHttpControlPlane(
}
return browserDataSuccess(
Object.freeze({
state: response.value.state as
state: abortState["state"] as
| "ABORTED"
| "NOT_FOUND"
| "EXPIRED"
@@ -367,66 +373,75 @@ async function invokeJsonTransport(
}
function decodeSession(value: unknown): UploadSession | null {
const record = exactSnapshot(value, [
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"partSizeBytes",
"partCount",
"maxConcurrency",
"expiresAtEpochMs",
]);
if (!record) return null;
const fingerprint = snapshotFingerprint(record["fingerprint"]);
const sessionId = record["sessionId"];
const requestBindingSha256 = record["requestBindingSha256"];
const partSizeBytes = record["partSizeBytes"];
const partCount = record["partCount"];
const maxConcurrency = record["maxConcurrency"];
const expiresAtEpochMs = record["expiresAtEpochMs"];
if (
!exactKeys(value, [
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"partSizeBytes",
"partCount",
"maxConcurrency",
"expiresAtEpochMs",
]) ||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof value.sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
typeof value.requestBindingSha256 !== "string" ||
!SHA256_HEX.test(value.requestBindingSha256) ||
!isUploadFileFingerprint(value.fingerprint) ||
!positiveSafeInteger(value.partSizeBytes) ||
value.partSizeBytes !== value.fingerprint.partSizeBytes ||
!positiveSafeInteger(value.partCount) ||
value.partCount !== value.fingerprint.partCount ||
value.partCount > MAX_PART_COUNT ||
!positiveSafeInteger(value.maxConcurrency) ||
!positiveSafeInteger(value.expiresAtEpochMs)
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(sessionId) ||
typeof requestBindingSha256 !== "string" ||
!SHA256_HEX.test(requestBindingSha256) ||
fingerprint === null ||
!positiveSafeInteger(partSizeBytes) ||
partSizeBytes !== fingerprint.partSizeBytes ||
!positiveSafeInteger(partCount) ||
partCount !== fingerprint.partCount ||
partCount > MAX_PART_COUNT ||
!positiveSafeInteger(maxConcurrency) ||
!positiveSafeInteger(expiresAtEpochMs)
) {
return null;
}
return Object.freeze({
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: value.sessionId,
requestBindingSha256: value.requestBindingSha256,
fingerprint: snapshotFingerprint(value.fingerprint),
partSizeBytes: value.partSizeBytes,
partCount: value.partCount,
maxConcurrency: value.maxConcurrency,
expiresAtEpochMs: value.expiresAtEpochMs,
sessionId,
requestBindingSha256,
fingerprint,
partSizeBytes,
partCount,
maxConcurrency,
expiresAtEpochMs,
});
}
function decodeStatus(value: unknown): UploadSessionStatus | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
if (
record.state === "ACTIVE" &&
exactKeys(record, ["state", "session", "acceptedParts"])
) {
const session = decodeSession(record.session);
if (
!session ||
!Array.isArray(record.acceptedParts) ||
record.acceptedParts.length > MAX_RECEIPT_COUNT ||
!record.acceptedParts.every(isUploadPartReceipt)
) {
// The discriminator is read from the same snapshot the payload comes from.
const state = ownDataValue(value, "state");
if (state === "ACTIVE") {
const record = exactSnapshot(value, [
"state",
"session",
"acceptedParts",
]);
if (!record) return null;
const session = decodeSession(record["session"]);
const rows = snapshotExactArray(record["acceptedParts"]);
if (!session || rows === null || rows.length > MAX_RECEIPT_COUNT) {
return null;
}
const parts = Object.freeze(
record.acceptedParts.map(snapshotReceipt),
);
const receipts: UploadPartReceipt[] = [];
for (const row of rows) {
const receipt = snapshotReceipt(row);
if (receipt === null) return null;
receipts.push(receipt);
}
const parts = Object.freeze(receipts);
return orderedReceipts(parts, session.fingerprint, false)
? Object.freeze({
state: "ACTIVE",
@@ -435,41 +450,49 @@ function decodeStatus(value: unknown): UploadSessionStatus | null {
})
: null;
}
if (
record.state === "QUARANTINED" &&
exactKeys(record, ["state", "session", "resourceId"])
) {
const session = decodeSession(record.session);
if (state === "QUARANTINED") {
const record = exactSnapshot(value, ["state", "session", "resourceId"]);
if (!record) return null;
const session = decodeSession(record["session"]);
const resourceId = record["resourceId"];
return session &&
typeof record.resourceId === "string" &&
SAFE_OPAQUE_ID.test(record.resourceId)
typeof resourceId === "string" &&
SAFE_OPAQUE_ID.test(resourceId)
? Object.freeze({
state: "QUARANTINED",
session,
resourceId: record.resourceId,
resourceId,
})
: null;
}
if (
typeof record.state === "string" &&
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(record.state) &&
exactKeys(record, [
typeof state === "string" &&
["ABORTED", "EXPIRED", "NOT_FOUND"].includes(state)
) {
const record = exactSnapshot(value, [
"state",
"protocol",
"sessionId",
"requestBindingSha256",
]) &&
record.protocol === RESUMABLE_UPLOAD_PROTOCOL &&
typeof record.sessionId === "string" &&
SAFE_OPAQUE_ID.test(record.sessionId) &&
typeof record.requestBindingSha256 === "string" &&
SHA256_HEX.test(record.requestBindingSha256)
) {
]);
const sessionId = record?.["sessionId"];
const requestBindingSha256 = record?.["requestBindingSha256"];
if (
!record ||
record["state"] !== state ||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(sessionId) ||
typeof requestBindingSha256 !== "string" ||
!SHA256_HEX.test(requestBindingSha256)
) {
return null;
}
return Object.freeze({
state: record.state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
state: state as "ABORTED" | "EXPIRED" | "NOT_FOUND",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: record.sessionId,
requestBindingSha256: record.requestBindingSha256,
sessionId,
requestBindingSha256,
});
}
return null;
@@ -484,34 +507,39 @@ function decodeCompletion(
> extends UploadProviderResult<infer Outcome>
? Outcome | null
: never {
const record = exactSnapshot(value, [
"state",
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"resourceId",
]);
const sessionId = record?.["sessionId"];
const requestBindingSha256 = record?.["requestBindingSha256"];
const resourceId = record?.["resourceId"];
const fingerprint = record ? snapshotFingerprint(record["fingerprint"]) : null;
if (
!exactKeys(value, [
"state",
"protocol",
"sessionId",
"requestBindingSha256",
"fingerprint",
"resourceId",
]) ||
value.state !== "QUARANTINED" ||
value.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof value.sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(value.sessionId) ||
typeof value.requestBindingSha256 !== "string" ||
!SHA256_HEX.test(value.requestBindingSha256) ||
!isUploadFileFingerprint(value.fingerprint) ||
typeof value.resourceId !== "string" ||
!SAFE_OPAQUE_ID.test(value.resourceId)
!record ||
record["state"] !== "QUARANTINED" ||
record["protocol"] !== RESUMABLE_UPLOAD_PROTOCOL ||
typeof sessionId !== "string" ||
!SAFE_OPAQUE_ID.test(sessionId) ||
typeof requestBindingSha256 !== "string" ||
!SHA256_HEX.test(requestBindingSha256) ||
fingerprint === null ||
typeof resourceId !== "string" ||
!SAFE_OPAQUE_ID.test(resourceId)
) {
return null;
}
return Object.freeze({
state: "QUARANTINED",
protocol: RESUMABLE_UPLOAD_PROTOCOL,
sessionId: value.sessionId,
requestBindingSha256: value.requestBindingSha256,
fingerprint: snapshotFingerprint(value.fingerprint),
resourceId: value.resourceId,
sessionId,
requestBindingSha256,
fingerprint,
resourceId,
});
}
@@ -546,52 +574,101 @@ function orderedReceipts(
});
}
function isUploadPartReceiptShape(
function decodeUploadPartShape(
value: unknown,
): value is Readonly<{
): Readonly<{
partNumber: number;
offset: number;
byteLength: number;
checksumSha256: string;
}> {
return (
exactKeys(value, [
"partNumber",
"offset",
"byteLength",
"checksumSha256",
]) &&
positiveSafeInteger(value.partNumber) &&
nonNegativeSafeInteger(value.offset) &&
positiveSafeInteger(value.byteLength) &&
typeof value.checksumSha256 === "string" &&
SHA256_HEX.test(value.checksumSha256)
);
}> | null {
const record = exactSnapshot(value, [
"partNumber",
"offset",
"byteLength",
"checksumSha256",
]);
if (!record) return null;
const partNumber = record["partNumber"];
const offset = record["offset"];
const byteLength = record["byteLength"];
const checksumSha256 = record["checksumSha256"];
return positiveSafeInteger(partNumber) &&
nonNegativeSafeInteger(offset) &&
positiveSafeInteger(byteLength) &&
typeof checksumSha256 === "string" &&
SHA256_HEX.test(checksumSha256)
? Object.freeze({ partNumber, offset, byteLength, checksumSha256 })
: null;
}
function snapshotFingerprint(
value: UploadFileFingerprint,
): UploadFileFingerprint {
return Object.freeze({ ...value });
/**
* TR-05. Copies the nested value first, then validates the copy, so the
* fingerprint the session is checked against is the one it carries.
*/
function snapshotFingerprint(value: unknown): UploadFileFingerprint | null {
const record = exactSnapshot(value, [
"algorithm",
"digestHex",
"byteLength",
"partSizeBytes",
"partCount",
]);
return record && isUploadFileFingerprint(record)
? (record as unknown as UploadFileFingerprint)
: null;
}
function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt {
return Object.freeze({ ...value });
function snapshotReceipt(value: unknown): UploadPartReceipt | null {
const record = exactSnapshot(value, [
"partNumber",
"offset",
"byteLength",
"checksumSha256",
"receiptToken",
]);
return record && isUploadPartReceipt(record)
? (record as unknown as UploadPartReceipt)
: null;
}
function exactKeys(
/**
* TR-RR-08 / TR-05. Copies the value into an owned frozen record, reading every
* property exactly once, and returns `null` for anything that is not an exact
* own-data shape.
*
* `Object.keys` saw only enumerable own string keys, so a symbol or
* non-enumerable extra field passed unseen and a later property read invoked
* whatever accessor the sender installed. Worse, checking the sender's object
* and then reading it again to build the result let a stateful answer show a
* safe `sessionId` to the regex and hand an unvalidated one to the receipt, so
* the value that was checked and the value that was returned differed.
*/
function exactSnapshot(
value: unknown,
keys: readonly string[],
): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
): Record<string, unknown> | null {
if (Array.isArray(value)) return null;
return snapshotExactObject(value, {
allowed: keys,
required: keys,
}) as Record<string, unknown> | null;
}
/**
* TR-RR-08. Runs a decoder inside the adapter's failure boundary. A hostile
* object that still throws from a trap becomes a typed `CORRUPT_DATA` result
* rather than a native rejection out of a public method.
*/
function decodeSafely<Value>(
decode: (value: unknown) => Value | null,
value: unknown,
): Value | null {
try {
return decode(value);
} catch {
return null;
}
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function safeIdempotencyKey(value: string): boolean {
@@ -2,6 +2,7 @@ import type {
ResumableUploadCheckpointAdmin,
ResumableUploadCheckpoint,
ResumableUploadCheckpointStore,
PartitionDeleteOutcome,
} from "../../../application/ports/browser-transfer/resumable-upload.ts";
import type { BrowserDataResult } from "../../../application/ports/browser-file-storage/shared.ts";
import {
@@ -21,6 +22,24 @@ const GOVERNANCE_STORE = "governance";
const GOVERNANCE_KEY = "scope-binding";
const DEFAULT_BLOCKED_TIMEOUT_MS = 5_000;
/**
* BT-UP-03. Per-realm registry keyed by `(IDBFactory identity, databaseName)`.
* It prevents this realm from recreating a store whose deletion is still in
* flight. It deliberately claims nothing about other realms, which are handled
* by native blocked ordering and explicit recovery.
*/
const PENDING_DELETIONS = new WeakMap<object, Set<string>>();
function pendingDeletionsFor(factory: unknown): Set<string> {
const key = (factory ?? PENDING_DELETIONS) as object;
let pending = PENDING_DELETIONS.get(key);
if (!pending) {
pending = new Set<string>();
PENDING_DELETIONS.set(key, pending);
}
return pending;
}
export type IndexedDbUploadCheckpointScope = Readonly<{
authorityToken: string;
namespaceToken: string;
@@ -93,6 +112,14 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
? factory.deleteDatabase.bind(factory)
: undefined;
const databaseName = uploadCheckpointDatabaseName(scope);
const pendingDeletions = pendingDeletionsFor(factory);
if (pendingDeletions.has(databaseName)) {
// BT-UP-03. A deletion dispatched by this realm has not settled, so a new
// store over the same database would race an unknown native effect.
throw new TypeError(
"Upload checkpoint partition has an unresolved pending deletion.",
);
}
const expectedBinding: ScopeBinding = Object.freeze({
key: GOVERNANCE_KEY,
schemaVersion: 1,
@@ -376,7 +403,7 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
async deletePartition(
signal?: AbortSignal,
): Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
BrowserDataResult<PartitionDeleteOutcome>
> {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "UPLOAD_RECONCILE");
@@ -397,46 +424,64 @@ export function createIndexedDbResumableUploadCheckpointRuntime(
} catch (error) {
return mapBrowserDataException(error, "UPLOAD_RECONCILE");
}
return await new Promise<
BrowserDataResult<Readonly<{ state: "DELETED" }>>
>((resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<Readonly<{ state: "DELETED" }>>,
) => {
if (settled) return;
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal is
// intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
// BT-UP-03. Once dispatched the deletion may still commit after this
// call returns, so the pending registration is installed before the
// promise settles and is only released by the real native completion.
pendingDeletions.add(databaseName);
return await new Promise<BrowserDataResult<PartitionDeleteOutcome>>(
(resolve) => {
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
const finish = (
result: BrowserDataResult<PartitionDeleteOutcome>,
) => {
if (settled) return;
settled = true;
if (blockedTimer) clearTimeout(blockedTimer);
resolve(result);
};
const releasePending = () => {
pendingDeletions.delete(databaseName);
};
// IDB deleteDatabase cannot be cancelled after dispatch. AbortSignal
// is intentionally observed only before dispatch so the adapter never
// reports ABORTED while deletion may still commit.
request.onblocked = () => {
blockedTimer = setTimeout(() => {
// Not NOT_APPLIED: the request is still live in the browser.
finish(
browserDataSuccess(
Object.freeze({
state: "PENDING" as const,
effect: "UNKNOWN" as const,
reason: "BLOCKED_DEADLINE" as const,
}),
),
);
}, blockedTimeoutMs);
};
request.onerror = () => {
releasePending();
finish(
browserDataFailure("BLOCKED", "UPLOAD_RECONCILE", {
retryable: true,
recovery: "RELOAD_OTHER_CONTEXTS",
}),
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
}, blockedTimeoutMs);
};
request.onerror = () =>
finish(
mapBrowserDataException(
request.error,
"UPLOAD_RECONCILE",
),
);
request.onsuccess = () =>
finish(
browserDataSuccess(
Object.freeze({ state: "DELETED" as const }),
),
);
});
};
request.onsuccess = () => {
releasePending();
finish(
browserDataSuccess(
Object.freeze({
state: "DELETED" as const,
effect: "APPLIED" as const,
}),
),
);
};
},
);
},
};
const admin = Object.freeze(adminValue);
@@ -43,6 +43,15 @@ export function createPresignedUploadPartExecutor(
recovery: "RESUME",
});
}
// BT-UP-04. A NaN or negative clock silently bypasses every expiry
// comparison, and an infinite one misreports a dependency failure as a
// capability policy failure. Both are dependency failures.
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0) {
return browserDataFailure("UNAVAILABLE", "UPLOAD_PART", {
retryable: true,
recovery: "RESUME",
});
}
if (
!capability ||
capability.method !== "PUT" ||
@@ -66,7 +66,24 @@ import type { UploadMutationLock } from "./upload-mutation-lock.ts";
export type ResumableUploadRuntime = ResumableUploadPort &
Readonly<{
/**
* BT-UP-06. Compatibility facade: closes admission and starts the same
* single-flight drain that `dispose()` awaits.
*/
close(): void;
/**
* BT-UP-06. Awaitable teardown for a future composition owner. It shares
* one drain promise, aborts the active operation registry and only then
* closes the checkpoint store and cancellation channel, so success actually
* means quiescent. No current bootstrap consumer is assumed.
*/
/**
* TR-RR-06. Closes admission and returns the *bounded* drain result. A
* failure means the runtime is still `CLOSING`: physical work the caller
* must not treat as finished is still in flight.
*/
dispose(): Promise<BrowserDataResult<void>>;
lifecycle(): "OPEN" | "CLOSING" | "CLOSED";
}>;
export type ResumableUploadRuntimeDependencies<Capability> = Readonly<{
@@ -108,6 +125,13 @@ type RuntimeDependencies<Capability> = Readonly<{
random(): number;
sleep(delayMs: number, signal: AbortSignal): Promise<void>;
observer?: BrowserDataObserver;
/**
* TR-04. Every raw provider promise, from the moment the collaborator is
* called until it actually settles. The wrapper that bounds the attempt can
* settle long before the provider does, so the wrapper registry alone could
* report an empty set while physical work was still running.
*/
physicalTasks: Set<Promise<unknown>>;
}>;
type ActiveResolution =
@@ -159,10 +183,16 @@ const RECOVERIES: ReadonlySet<string> = new Set([
export function createResumableUploadRuntime<Capability>(
inputDependencies: ResumableUploadRuntimeDependencies<Capability>,
): ResumableUploadRuntime {
const dependencies = snapshotDependencies(inputDependencies);
/** TR-04. Raw provider work, tracked independently of its bounded wrapper. */
const physicalTasks = new Set<Promise<unknown>>();
const dependencies = snapshotDependencies(inputDependencies, physicalTasks);
const lifetime = new AbortController();
const localUploads = new Map<string, Set<AbortController>>();
/** BT-UP-06. Terminal settlement of every admitted operation. */
const activeOperations = new Set<Promise<unknown>>();
let closed = false;
let lifecycle: "OPEN" | "CLOSING" | "CLOSED" = "OPEN";
let drain: Promise<BrowserDataResult<void>> | null = null;
const cancelLocalUploads = (uploadKey: string): void => {
if (!SAFE_UPLOAD_KEY.test(uploadKey)) return;
for (const controller of localUploads.get(uploadKey) ?? []) {
@@ -211,19 +241,24 @@ export function createResumableUploadRuntime<Capability>(
}
return browserDataFailure("ABORTED", "UPLOAD_SESSION");
}
const operation = dependencies.mutationLock.run(
request.uploadKey,
operationScope.signal,
async () =>
await executeUpload(
dependencies,
Object.freeze({
...request,
signal: operationScope.signal,
}),
),
);
// Tracked until terminal settlement so dispose() can prove quiescence.
const tracked = Promise.resolve(operation).catch(() => undefined);
activeOperations.add(tracked);
void tracked.finally(() => activeOperations.delete(tracked));
try {
return await dependencies.mutationLock.run(
request.uploadKey,
operationScope.signal,
async () =>
await executeUpload(
dependencies,
Object.freeze({
...request,
signal: operationScope.signal,
}),
),
);
return await operation;
} catch (error) {
return mapLockFailure(error, "UPLOAD_SESSION");
} finally {
@@ -259,17 +294,19 @@ export function createResumableUploadRuntime<Capability>(
dependencies.crossContextCancellation?.publish(uploadKey);
}
const combined = combineAbortSignals(input.signal, lifetime.signal);
const operation = dependencies.mutationLock.run(
uploadKey,
combined.signal,
async () =>
await executeAbort(dependencies, uploadKey, combined.signal),
);
// TR-RR-06. An abort is admitted physical work like an upload, so it is
// tracked from admission and `dispose()` cannot step over it.
const tracked = Promise.resolve(operation).catch(() => undefined);
activeOperations.add(tracked);
void tracked.finally(() => activeOperations.delete(tracked));
try {
return await dependencies.mutationLock.run(
uploadKey,
combined.signal,
async () =>
await executeAbort(
dependencies,
uploadKey,
combined.signal,
),
);
return await operation;
} catch (error) {
return mapLockFailure(error, "UPLOAD_ABORT");
} finally {
@@ -278,14 +315,65 @@ export function createResumableUploadRuntime<Capability>(
},
close() {
if (closed) return;
// BT-UP-06. Admission closes synchronously; the drain runs behind the
// same single-flight promise dispose() returns.
void startDrain();
},
dispose(): Promise<BrowserDataResult<void>> {
return startDrain();
},
lifecycle: () => lifecycle,
});
function startDrain(): Promise<BrowserDataResult<void>> {
drain ??= (async () => {
closed = true;
lifecycle = "CLOSING";
releaseCrossContextCancellation?.();
dependencies.crossContextCancellation?.close();
// Abort every admitted operation, then wait for their real settlement.
lifetime.abort();
for (const controllers of localUploads.values()) {
for (const controller of controllers) controller.abort();
}
// TR-RR-06. Bounded. A non-cooperative mutation lock or provider must not
// make teardown unbounded, and an unproved drain is reported as such
// rather than closed over.
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<"EXPIRED">((resolve) => {
timer = setTimeout(
() => resolve("EXPIRED"),
dependencies.policy.cleanupDeadlineMs,
);
});
// TR-04. Quiescence means both registries: the bounded wrappers and the
// raw provider work they may have outlived. A settling wrapper can still
// register more physical work, so the drain repeats until both are empty
// or the cleanup deadline expires.
const quiescent = (async () => {
while (activeOperations.size > 0 || physicalTasks.size > 0) {
await Promise.allSettled([...activeOperations, ...physicalTasks]);
}
return "DRAINED" as const;
})();
const drained = await Promise.race([quiescent, expired]);
if (timer !== undefined) clearTimeout(timer);
if (drained === "EXPIRED") {
// The store stays open: something can still write a checkpoint.
return browserDataFailure("UNAVAILABLE", "UPLOAD_ABORT", {
retryable: true,
recovery: "RESUME",
});
}
// The store closes only after nothing can still write a checkpoint.
dependencies.checkpoints.close();
},
});
lifecycle = "CLOSED";
return browserDataSuccess(undefined);
})();
return drain;
}
return runtime;
}
@@ -1238,8 +1326,20 @@ async function invokeProviderAttempt<Capability, Value>(
}, dependencies.policy.providerAttemptTimeoutMs);
});
try {
// TR-04. The raw promise enters the physical registry the moment the
// provider is called and stays there until it truly settles. Racing it
// against a deadline let the bounded wrapper settle first and leave the
// set empty, so `dispose()` reported a drained runtime while the provider
// was still running.
const raw = action(controller.signal);
const tracked = Promise.resolve(raw).then(
() => undefined,
() => undefined,
);
dependencies.physicalTasks.add(tracked);
void tracked.finally(() => dependencies.physicalTasks.delete(tracked));
return await Promise.race([
invokeProvider(operation, () => action(controller.signal)),
invokeProvider(operation, () => raw),
deadline,
]);
} finally {
@@ -1671,6 +1771,7 @@ function snapshotRequest(
function snapshotDependencies<Capability>(
input: ResumableUploadRuntimeDependencies<Capability>,
physicalTasks: Set<Promise<unknown>>,
): RuntimeDependencies<Capability> {
const policy = resolveResumableUploadRuntimePolicy(input.policy);
const controlPlane = snapshotControlPlane(input.controlPlane);
@@ -1693,6 +1794,7 @@ function snapshotDependencies<Capability>(
throw new TypeError("Upload runtime dependency is invalid.");
}
return Object.freeze({
physicalTasks,
controlPlane,
partExecutor,
checkpoints,
@@ -13,6 +13,12 @@ export type ResumableUploadRuntimePolicy = Readonly<{
capabilityRefreshSkewMs: number;
maxSessionLifetimeMs: number;
providerAttemptTimeoutMs: number;
/**
* TR-RR-06. The bound `dispose()` applies to its drain. A non-cooperative
* mutation lock or provider would otherwise make teardown unbounded, so a
* caller could never learn whether the runtime was quiescent.
*/
cleanupDeadlineMs: number;
}>;
const MIB = 1024 * 1024;
@@ -49,6 +55,7 @@ const DEFAULT_POLICY: ResumableUploadRuntimePolicy = Object.freeze({
capabilityRefreshSkewMs: 5_000,
maxSessionLifetimeMs: 24 * 60 * 60_000,
providerAttemptTimeoutMs: 30_000,
cleanupDeadlineMs: 10_000,
});
export function resolveResumableUploadRuntimePolicy(
@@ -95,6 +102,9 @@ export function resolveResumableUploadRuntimePolicy(
!positiveSafeInteger(policy.providerAttemptTimeoutMs) ||
policy.providerAttemptTimeoutMs >
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs ||
!positiveSafeInteger(policy.cleanupDeadlineMs) ||
policy.cleanupDeadlineMs >
ABSOLUTE_LIMITS.maxProviderAttemptTimeoutMs * 2 ||
Math.ceil(policy.maxFileBytes / policy.partSizeBytes) >
policy.maxPartCount
) {
@@ -132,6 +132,11 @@ export function assertPublicCachePolicy(
policy.allowedVaryHeaderNames.some(
(name) => !policy.allowedRequestHeaderNames.includes(name),
) ||
// STO-03. Enabling variants while stripping `vary` from stored responses
// makes every variant collide on the same cache key, so the combination is
// rejected at composition instead of producing an unusable candidate.
(policy.allowedVaryHeaderNames.length > 0 &&
!policy.allowedResponseHeaderNames.includes("vary")) ||
policy.forbiddenQueryParameterNames.some((name) => name.length === 0) ||
policy.allowedQueryParameterNames.some((name) =>
policy.forbiddenQueryParameterNames.includes(name),
@@ -21,6 +21,7 @@ import {
browserDataSuccess,
mapBrowserDataException,
} from "../browser-file-storage/result.ts";
import { createAbortableOperation } from "../platform/abortable-operation.ts";
import {
cacheByteBucket,
cacheEntryBucket,
@@ -241,7 +242,7 @@ export function createPublicResponseCacheAdapter(
const aborted = abortedResult(signal, "CACHE_LOOKUP");
if (aborted) return aborted;
if (!dependencies.cacheStorage) {
return unsupported("CACHE_LOOKUP");
return unsupported("CACHE_LOOKUP", "RETRY");
}
let assetRequest: NormalizedAsset;
try {
@@ -389,10 +390,7 @@ export function createPublicResponseCacheAdapter(
const signal = options.signal;
const aborted = abortedResult(signal, "CACHE_STAGE");
if (aborted) return aborted;
const availability = mutationAvailability(
dependencies,
"CACHE_STAGE",
);
const availability = stageAvailability(dependencies);
if (availability) return availability;
let normalized: NormalizedReleaseManifest;
@@ -412,23 +410,59 @@ export function createPublicResponseCacheAdapter(
entryBucket: cacheEntryBucket(normalized.assets.length),
});
// NS-08. One owner covers the whole staging body. Handing the signal to
// each `Request` only asked a cooperative fetch to stop: a stream or a
// digest that ignored it kept the mutation lock and, worse, could finish
// after the abort had already ended the operation and still write both the
// asset and the marker, publishing a release nobody was waiting for.
const owner = createAbortableOperation({ signal });
const ownedStep = async <Value>(
task: Promise<Value>,
compensate?: (value: Value) => void,
): Promise<Value> => {
const raced = await owner.race(task, compensate);
if (raced.kind === "REJECTED") throw raced.reason;
if (raced.kind !== "VALUE") {
throw new CacheValidationFailure("ABORTED");
}
return raced.value;
};
const assertOwned = (): void => {
if (owner.terminal() !== null) {
throw new CacheValidationFailure("ABORTED");
}
};
try {
const result = await dependencies.mutationLock!.run(
signal,
async () => {
assertOwned();
const cacheName = releaseCacheName(
policy,
normalized.releaseRegistryId,
normalized.manifestDigestHex,
);
const existingNames = await dependencies.cacheStorage!.keys();
if (existingNames.includes(cacheName)) {
const existingNames = await ownedStep(
Promise.resolve(dependencies.cacheStorage!.keys()),
);
// STO-RR-05. A candidate this call did not create may be the one
// currently serving traffic, so nothing about it is deleted before
// a replacement has been fetched and verified.
const preExistingCandidate = existingNames.includes(cacheName);
if (preExistingCandidate) {
const existing = await dependencies.cacheStorage!.open(cacheName);
const marker = await readMarker(
existing,
policy,
dependencies.crypto,
);
if (!marker.ok && marker.error.code !== "CORRUPT_DATA") {
// STO-RR-04. A marker that could not be read is unknown, not
// damaged. Treating a transient storage error as proof of
// corruption would delete a healthy active release.
return rebaseFailure(marker.error, "CACHE_STAGE");
}
if (
marker.ok &&
marker.value &&
@@ -438,47 +472,78 @@ export function createPublicResponseCacheAdapter(
normalized.manifestDigestHex &&
marker.value.entryCount === normalized.assets.length
) {
return browserDataSuccess(summaryFromMarker(marker.value));
}
await dependencies.cacheStorage!.delete(cacheName);
}
const cache = await dependencies.cacheStorage!.open(cacheName);
try {
let totalBytes = 0;
for (const asset of normalized.assets) {
if (signal?.aborted) {
throw new CacheValidationFailure("ABORTED");
}
const cacheRequest = createNativeRequest(asset);
const networkRequest = createNativeRequest(
asset,
signal,
);
const response =
await dependencies.fetcher!(networkRequest);
const read = await readAndValidateResponse(
response,
asset,
// STO-04. The marker is a claim that staging completed, not
// evidence that every entry still exists and matches. Browser
// eviction, manual deletion and partial corruption all leave the
// marker intact, so the candidate is re-verified before reuse.
const verified = await verifyReleaseCandidate(
existing,
marker.value.assets,
policy,
dependencies.crypto,
signal,
);
if (verified.kind === "VERIFIED") {
return browserDataSuccess(summaryFromMarker(marker.value));
}
if (verified.kind === "UNKNOWN") {
// Abort or an unreadable candidate is never stage success and
// never silently deletes an owned candidate.
return verified.failure;
}
}
}
const cache = await ownedStep(
Promise.resolve(dependencies.cacheStorage!.open(cacheName)),
);
try {
let totalBytes = 0;
for (const asset of normalized.assets) {
assertOwned();
const cacheRequest = createNativeRequest(asset);
const networkRequest = createNativeRequest(
asset,
owner.signal,
);
const response = await ownedStep(
Promise.resolve(dependencies.fetcher!(networkRequest)),
// A response that arrives after the owner ended is released
// rather than read.
(late) => {
void late.body?.cancel().catch(() => undefined);
},
);
const read = await ownedStep(
readAndValidateResponse(
response,
asset,
policy,
dependencies.crypto,
owner.signal,
),
);
if (!read.ok) throw new CacheValidationFailure(read.error.code);
assertOwned();
totalBytes += read.value.bytes.byteLength;
if (totalBytes > policy.maxReleaseBytes) {
throw new CacheValidationFailure("LIMIT_EXCEEDED");
}
await cache.put(
cacheRequest,
new Response(Uint8Array.from(read.value.bytes), {
status: 200,
headers: read.value.headers.map(
([name, value]) => [name, value],
await ownedStep(
Promise.resolve(
cache.put(
cacheRequest,
new Response(Uint8Array.from(read.value.bytes), {
status: 200,
headers: read.value.headers.map(
([name, value]) => [name, value],
),
}),
),
}),
),
);
}
assertOwned();
const marker: ReleaseMarker = Object.freeze({
schemaVersion: 1,
@@ -490,13 +555,23 @@ export function createPublicResponseCacheAdapter(
stagedAtEpochMs: now(),
assets: normalized.assets,
});
await cache.put(
markerRequest(policy),
jsonResponse(marker),
await ownedStep(
Promise.resolve(
cache.put(markerRequest(policy), jsonResponse(marker)),
),
);
// The marker is the activation record, so it is only a success
// once this call still owns the operation that wrote it.
assertOwned();
return browserDataSuccess(summaryFromMarker(marker));
} catch (error) {
await dependencies.cacheStorage!.delete(cacheName);
// STO-RR-05. Only a candidate this call created is removed. A
// repair that failed part-way leaves every entry it did replace
// and every entry it never touched in place, so the release that
// was serving traffic before still is.
if (!preExistingCandidate) {
await dependencies.cacheStorage!.delete(cacheName);
}
throw error;
}
},
@@ -522,6 +597,8 @@ export function createPublicResponseCacheAdapter(
dependencies.observer,
normalized.releaseRegistryId,
);
} finally {
owner.close();
}
},
@@ -535,7 +612,7 @@ export function createPublicResponseCacheAdapter(
const signal = options.signal;
const aborted = abortedResult(signal, "CACHE_ACTIVATE");
if (aborted) return aborted;
const availability = mutationAvailability(
const availability = localMutationAvailability(
dependencies,
"CACHE_ACTIVATE",
);
@@ -589,31 +666,25 @@ export function createPublicResponseCacheAdapter(
);
}
for (const asset of marker.assets) {
if (signal?.aborted) {
return browserDataFailure("ABORTED", "CACHE_ACTIVATE");
}
const cached = await cache.match(createNativeRequest(asset));
if (!cached) {
return browserDataFailure(
"INTEGRITY_FAILED",
"CACHE_ACTIVATE",
{ recovery: "REHYDRATE" },
);
}
const verified = await readAndValidateResponse(
cached,
asset,
policy,
dependencies.crypto,
signal,
const candidate = await verifyReleaseCandidate(
cache,
marker.assets,
policy,
dependencies.crypto,
signal,
);
if (candidate.kind === "UNKNOWN") {
return rebaseFailure(
candidate.failure.error,
"CACHE_ACTIVATE",
);
}
if (candidate.kind === "REPAIRABLE") {
return browserDataFailure(
"INTEGRITY_FAILED",
"CACHE_ACTIVATE",
{ recovery: "REHYDRATE" },
);
if (!verified.ok) {
return rebaseFailure(
verified.error,
"CACHE_ACTIVATE",
);
}
}
const previousPointer = await readActivePointer(
@@ -692,7 +763,7 @@ export function createPublicResponseCacheAdapter(
const signal = request.signal;
const aborted = abortedResult(signal, "CACHE_DELETE");
if (aborted) return aborted;
const availability = mutationAvailability(
const availability = localMutationAvailability(
dependencies,
"CACHE_DELETE",
);
@@ -771,7 +842,7 @@ export function createPublicResponseCacheAdapter(
async inspect() {
if (!dependencies.cacheStorage) {
return unsupported("CACHE_LOOKUP");
return unsupported("CACHE_LOOKUP", "RETRY");
}
try {
const names = await dependencies.cacheStorage.keys();
@@ -1640,24 +1711,104 @@ function summaryFromMarker(
});
}
function mutationAvailability(
type ReleaseCandidateVerdict =
| Readonly<{ kind: "VERIFIED" }>
/** An exact, owned entry is missing or no longer matches the manifest. */
| Readonly<{ kind: "REPAIRABLE" }>
/** Abort or an unreadable candidate: never success, never a silent delete. */
| Readonly<{ kind: "UNKNOWN"; failure: BrowserFailureResult }>;
/**
* STO-04. The single verification authority shared by the stage fast path and
* activation, so "the marker says it is staged" can never stand in for "every
* entry is present and matches".
*/
async function verifyReleaseCandidate(
cache: Readonly<{ match(request: Request): Promise<Response | undefined> }>,
assets: readonly NormalizedAsset[],
policy: PublicCacheRuntimePolicy,
crypto: Readonly<{
digestSha256(bytes: Uint8Array): Promise<ArrayBuffer>;
}>,
signal: AbortSignal | undefined,
): Promise<ReleaseCandidateVerdict> {
for (const asset of assets) {
if (signal?.aborted) {
return Object.freeze({
kind: "UNKNOWN" as const,
failure: asFailure(
browserDataFailure("ABORTED", "CACHE_ACTIVATE"),
),
});
}
let cached: Response | undefined;
try {
cached = await cache.match(createNativeRequest(asset));
} catch {
return Object.freeze({
kind: "UNKNOWN" as const,
failure: asFailure(
browserDataFailure("UNAVAILABLE", "CACHE_ACTIVATE", {
retryable: true,
recovery: "RETRY",
}),
),
});
}
if (!cached) return Object.freeze({ kind: "REPAIRABLE" as const });
const verified = await readAndValidateResponse(
cached,
asset,
policy,
crypto,
signal,
);
if (!verified.ok) {
return verified.error.code === "ABORTED"
? Object.freeze({
kind: "UNKNOWN" as const,
failure: asFailure(verified),
})
: Object.freeze({ kind: "REPAIRABLE" as const });
}
}
return Object.freeze({ kind: "VERIFIED" as const });
}
/**
* STO-05. Staging is the only operation that reaches the network, so it is the
* only one that requires a fetcher.
*/
function stageAvailability(
dependencies: PublicResponseCacheDependencySnapshot,
operation: BrowserDataOperation,
): BrowserFailureResult | null {
return dependencies.cacheStorage &&
dependencies.fetcher &&
dependencies.mutationLock
? null
: unsupported(operation);
: unsupported("CACHE_STAGE", "ONLINE_ONLY");
}
/**
* Activation, rollback and cleanup are local Cache Storage mutations. Requiring
* a fetcher would block an offline rollback or a quota-recovery cleanup that
* needs no network at all.
*/
function localMutationAvailability(
dependencies: PublicResponseCacheDependencySnapshot,
operation: "CACHE_ACTIVATE" | "CACHE_DELETE",
): BrowserFailureResult | null {
return dependencies.cacheStorage && dependencies.mutationLock
? null
: unsupported(operation, "RETRY");
}
function unsupported(
operation: BrowserDataOperation,
recovery: "ONLINE_ONLY" | "RETRY",
): BrowserFailureResult {
return asFailure(
browserDataFailure("UNSUPPORTED", operation, {
recovery: "ONLINE_ONLY",
}),
browserDataFailure("UNSUPPORTED", operation, { recovery }),
);
}
@@ -2,6 +2,7 @@ import {
isCacheInvalidationOpaqueIdentifier,
type CacheInvalidationTopicDefinition,
} from "../../contracts/cache-invalidation.ts";
import { STORAGE_REGISTRY } from "../../contracts/storage-keys.ts";
import {
createBrowserCrossContextInvalidation,
type BroadcastChannelFacade,
@@ -31,8 +32,9 @@ type NativeBroadcastChannel = Readonly<{
}>;
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
// N-09. The registry owns the physical-key policy for the pulse.
const STORAGE_PULSE_KEY =
"ca-frontend:cache-invalidation:v1:pulse";
STORAGE_REGISTRY.CACHE_INVALIDATION_PULSE.physicalKey;
/**
* Captures native capabilities without allowing a SecurityError getter or a
@@ -59,6 +61,8 @@ export function createBrowserCrossContextInvalidationFromHost(
const sourceEpoch = createOpaqueId("page");
if (!sourceId || !sourceEpoch) return undefined;
const capturedLocalStorage = captureLocalStorage(host);
return createBrowserCrossContextInvalidation({
channelName: CHANNEL_NAME,
storagePulseKey: STORAGE_PULSE_KEY,
@@ -72,8 +76,13 @@ export function createBrowserCrossContextInvalidationFromHost(
return eventId;
},
createBroadcastChannel: broadcastFactory(host),
storage: storageFacade(host),
storageEvents: storageEventTarget(host),
// N-09. One capture, one identity: the write facade and the event
// validator must agree about which Storage object they trust. Reading the
// getter twice would let a hostile host return a different object.
storage: capturedLocalStorage
? storageFacade(capturedLocalStorage)
: undefined,
storageEvents: storageEventTarget(host, capturedLocalStorage),
observe: dependencies.observe,
});
}
@@ -182,11 +191,16 @@ function isNativeBroadcastChannel(
);
}
function storageFacade(
function captureLocalStorage(
host: Record<string, unknown>,
): StoragePulseFacade | undefined {
): object | undefined {
const candidate = safeGet(host, "localStorage");
if (!candidate || typeof candidate !== "object") return undefined;
return candidate && typeof candidate === "object" ? candidate : undefined;
}
function storageFacade(
candidate: object,
): StoragePulseFacade | undefined {
const record = candidate as Record<string, unknown>;
const setItem = safeGet(record, "setItem");
const removeItem = safeGet(record, "removeItem");
@@ -205,6 +219,7 @@ function storageFacade(
function storageEventTarget(
host: Record<string, unknown>,
expectedLocalStorage: object | undefined,
): StorageEventTargetFacade | undefined {
const addEventListener = safeGet(host, "addEventListener");
const removeEventListener = safeGet(host, "removeEventListener");
@@ -222,15 +237,27 @@ function storageEventTarget(
addEventListener(_type: "storage", listener: StoragePulseListener) {
const bound = (event: unknown) => {
if (!event || typeof event !== "object") {
listener({ key: null, newValue: null });
listener({
key: null,
newValue: null,
storageArea: "OTHER_OR_UNKNOWN",
});
return;
}
const record = event as Record<string, unknown>;
const key = safeGet(record, "key");
const newValue = safeGet(record, "newValue");
const storageArea = safeGet(record, "storageArea");
listener({
key: typeof key === "string" ? key : null,
newValue: typeof newValue === "string" ? newValue : null,
// Compared by object identity against the captured area, never by
// shape or by re-reading `host.localStorage`.
storageArea:
expectedLocalStorage !== undefined &&
storageArea === expectedLocalStorage
? "EXPECTED_LOCAL_STORAGE"
: "OTHER_OR_UNKNOWN",
});
};
bindings.set(listener, bound);
@@ -98,6 +98,13 @@ export type StoragePulseFacade = Readonly<{
export type StoragePulseEvent = Readonly<{
key: string | null;
newValue: string | null;
/**
* N-09. A `storage` event fires for every `Storage` area in the context.
* Matching only key and value cannot prove the write came from the
* localStorage this runtime actually captured, so the host classifies the
* native `storageArea` by object identity and the core admits one value.
*/
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN";
}>;
export type StoragePulseListener = (event: StoragePulseEvent) => void;
@@ -179,6 +186,7 @@ export function createBrowserCrossContextInvalidation(
const receiveStorage: StoragePulseListener = (event) => {
if (
closed ||
event.storageArea !== "EXPECTED_LOCAL_STORAGE" ||
event.key !== dependencies.storagePulseKey ||
typeof event.newValue !== "string"
) {
@@ -6,11 +6,15 @@ import {
type DiagnosticRecordInput,
} from "../../contracts/diagnostics.ts";
import { projectTelemetryEvent } from "../../contracts/telemetry.ts";
import { assertBoundedCapacity } from "../telemetry/best-effort-telemetry.ts";
export const noOpDiagnostics: DiagnosticsPort = Object.freeze({
record() {},
});
/** N-11. Documented absolute ceiling for bounded in-memory evidence. */
export const MAX_DIAGNOSTIC_ENTRIES = 10_000;
export function createDiagnosticsAdapter(
options: Readonly<{
maxEntries?: number;
@@ -18,7 +22,11 @@ export function createDiagnosticsAdapter(
sink?: (record: DiagnosticRecord) => void;
}> = {},
) {
const maxEntries = Math.max(1, options.maxEntries ?? 100);
const maxEntries = assertBoundedCapacity(
options.maxEntries ?? 100,
MAX_DIAGNOSTIC_ENTRIES,
"diagnostics maxEntries",
);
const entries: DiagnosticRecord[] = [];
const droppedReasons = new Map<string, number>();
+49 -2
View File
@@ -28,9 +28,36 @@ export function declaredContentLength(response: Response): number | null {
return Number.isFinite(value) && value >= 0 ? value : null;
}
/**
* LIVE-04. A `read()` that never settles is a physical wait, so the reader
* accepts the operation's lifetime signal. A cooperative stream stops here; a
* non-cooperative one is abandoned with its reader cancelled, and the caller's
* own race against the same signal still bounds the public result.
*/
const READ_ABANDONED = Symbol("bounded-read-abandoned");
async function readOrAbandon(
reader: ReadableStreamDefaultReader<Uint8Array>,
signal: AbortSignal | undefined,
): Promise<ReadableStreamReadResult<Uint8Array> | typeof READ_ABANDONED> {
if (!signal) return reader.read();
if (signal.aborted) return READ_ABANDONED;
let onAbort: (() => void) | undefined;
const abandoned = new Promise<typeof READ_ABANDONED>((resolve) => {
onAbort = () => resolve(READ_ABANDONED);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([reader.read(), abandoned]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
export async function readBoundedBytes(
response: Response,
maximumBytes: number,
signal?: AbortSignal,
): Promise<BoundedBytesOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > maximumBytes) {
@@ -46,7 +73,13 @@ export async function readBoundedBytes(
let total = 0;
try {
for (;;) {
const next = await reader.read();
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 failure("RESPONSE_STREAM_FAILURE");
}
if (next.done) break;
if (!next.value) continue;
total += next.value.byteLength;
@@ -83,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) {
@@ -95,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 -40
View File
@@ -1,50 +1,39 @@
import { decodeJsonBytes, readBoundedBytes } from "./bounded-body-reader.ts";
export type BoundedJsonResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; code: "RESPONSE_BODY_LIMIT" | "MALFORMED_JSON" }>;
/**
* N-08. The V2 compatibility reader delegates to the common bounded reader
* instead of maintaining a second stream-reading strategy.
*
* `bounded-body-reader` already isolates `cancel()` and `releaseLock()` throws
* so a cleanup failure cannot escape the closed result. Only the legacy failure
* codes are preserved here:
*
* - `RESPONSE_TOO_LARGE` → `RESPONSE_BODY_LIMIT`
* - `RESPONSE_STREAM_FAILURE` / `UTF8_INVALID` / `JSON_INVALID` →
* `MALFORMED_JSON`
*/
export async function readBoundedJson(
response: Response,
maxBytes: number,
): Promise<BoundedJsonResult> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
if (!response.body) return { ok: false, code: "MALFORMED_JSON" };
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
total += next.value.byteLength;
if (total > maxBytes) {
await reader.cancel();
return { ok: false, code: "RESPONSE_BODY_LIMIT" };
}
chunks.push(next.value);
}
} catch {
return { ok: false, code: "MALFORMED_JSON" };
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
try {
return {
ok: true,
value: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
};
} catch {
return { ok: false, code: "MALFORMED_JSON" };
const bytes = await readBoundedBytes(response, maxBytes);
if (!bytes.ok) {
return Object.freeze({
ok: false as const,
code:
bytes.code === "RESPONSE_TOO_LARGE"
? ("RESPONSE_BODY_LIMIT" as const)
: ("MALFORMED_JSON" as const),
});
}
// An absent body decodes to zero bytes, which is not valid JSON. The legacy
// contract reported that as MALFORMED_JSON, and that is preserved.
const decoded = decodeJsonBytes(bytes.bytes);
return decoded.ok
? Object.freeze({ ok: true as const, value: decoded.value })
: Object.freeze({ ok: false as const, code: "MALFORMED_JSON" as const });
}
+200 -37
View File
@@ -18,7 +18,24 @@ import {
durationBucket,
statusGroup,
} from "../../contracts/diagnostics.ts";
import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts";
import type {
AuthSessionPort,
CredentialOperationContext,
} from "../../application/ports/auth-session-port.ts";
import { isValidIdempotencyKey } from "../../contracts/mutation-intent.ts";
/** Sentinel for a credential wait ended by the attempt lifetime. */
const ATTEMPT_ABORTED = Symbol("ATTEMPT_ABORTED");
/**
* LEG-02. The pre-V2 operations have no installed auth profile, so this is the
* legacy allowance the shared validator applies to them. It requires nothing,
* which preserves their existing behaviour exactly.
*/
const LEGACY_ALLOWED_CREDENTIAL_HEADERS = Object.freeze([
"authorization",
"x-csrf-token",
] as const);
import type { ClockPort } from "../../application/ports/clock-port.ts";
import type { DiagnosticsPort } from "../../application/ports/diagnostics-port.ts";
import type { TelemetryPort } from "../../application/ports/telemetry-port.ts";
@@ -26,6 +43,7 @@ import type { ApiOperation } from "../../contracts/api-operations.ts";
import type { ApiFailure } from "../../contracts/errors.ts";
import type { OperationRequestInput } from "./request-builder.ts";
import { readBoundedJson } from "./bounded-json.ts";
import { admitCredentialHeaders } from "./http-contract-bridge.ts";
import type { MappingResult } from "../../contracts/boundary-mapper.ts";
import {
createRestProviderProfile,
@@ -238,22 +256,52 @@ export function createHttpClient(
}
return outcome;
}
// N-06. A caller-supplied key is validated before credentials, timers or
// fetch. An invalid value is rejected outright rather than trimmed or
// replaced, so a keyed command can never replay with no key at all.
let logicalIdempotencyKey: string | undefined;
try {
logicalIdempotencyKey =
operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory()
: undefined;
} catch {
return finalize(
{
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
}),
},
"failed",
);
if (operation.idempotency === "keyed") {
if (input.idempotencyKey !== undefined) {
if (!isValidIdempotencyKey(input.idempotencyKey)) {
return finalize(
{
ok: false,
error: failure("VALIDATION_REJECTED", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_INVALID",
}),
},
"failed",
);
}
logicalIdempotencyKey = input.idempotencyKey;
} else {
let generated: string;
try {
generated = idempotencyKeyFactory();
} catch {
return finalize(
{
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
}),
},
"failed",
);
}
if (!isValidIdempotencyKey(generated)) {
return finalize(
{
ok: false,
error: failure("UNKNOWN_CLIENT_FAILURE", input.operationId, 0, {
code: "IDEMPOTENCY_KEY_CREATION_FAILED",
}),
},
"failed",
);
}
logicalIdempotencyKey = generated;
}
}
let retryCount = 0;
let recoveryUsed = false;
@@ -294,14 +342,29 @@ export function createHttpClient(
authSession.onUnauthenticated();
return finalize(outcome, "failed");
}
let recovered: Awaited<ReturnType<typeof recoverSession>>;
let recovered: SessionRecoveryOutcome;
// LEG-01. The recovery collaborator receives the request lifetime, and
// the transport races the same signal so a non-cooperative owner cannot
// hold the request open.
const recoveryLifetime = new AbortController();
try {
recovered = await withinLogicalDeadline(
recoverSession(authSession, operation, outcome.error),
recoverSession(
authSession,
operation,
outcome.error,
Object.freeze({
signal: recoveryLifetime.signal,
deadlineAtMonotonicMs: deadlineAt,
}),
),
deadlineAt,
input.signal,
);
} catch (error) {
// The request is over. Whatever the recovery answers next is observed
// by its own owner, never adopted here.
recoveryLifetime.abort();
return finalize(
{
ok: false,
@@ -322,7 +385,14 @@ export function createHttpClient(
error instanceof LogicalDeadlineError ? "failed" : "aborted",
);
}
if (!recovered.ok) return finalize(recovered, "failed");
if (!recovered.ok) {
// The result is adopted here, so the notification happens here.
if (recovered.notifyUnauthenticated) authSession.onUnauthenticated();
return finalize(
{ ok: false, error: recovered.error },
"failed",
);
}
if (operation.idempotency === "none") {
return finalize(
{
@@ -568,22 +638,72 @@ export function createHttpClient(
};
}
try {
const patch = await authSession.credentialPatch({
origin: target.url.origin,
method: operation.method,
operationId: operation.operationId,
});
for (const [name, value] of Object.entries(patch.headers)) {
const normalized = name.toLowerCase();
const allowedHeaders =
// N-07. The credential owner is bounded by the attempt lifetime that
// already carries the total deadline and the caller signal, so a
// non-cooperative owner cannot hold the request open and no extra
// timer is introduced. A late completion is observed and discarded.
const raced = await raceAttemptSignal(
Promise.resolve(
authSession.credentialPatch(
{
origin: target.url.origin,
method: operation.method,
operationId: operation.operationId,
},
Object.freeze({
signal: controller.signal,
deadlineAtMonotonicMs: deadlineAt,
}),
),
),
controller.signal,
);
if (raced === ATTEMPT_ABORTED) {
return {
ok: false,
error: timedOut
? failure(
"REQUEST_TIMEOUT",
operation.operationId,
attempt,
{ code: "OPERATION_DEADLINE_EXCEEDED" },
)
: failure(
"REQUEST_ABORTED",
operation.operationId,
attempt,
{ code: "REQUEST_ABORTED" },
),
};
}
const patch = raced;
// LEG-02. The same admission validator V3 uses. Checking only the
// allowed set let a bearer profile dispatch with no Authorization at
// all, which is precisely the anonymous downgrade the required set
// exists to prevent.
const admission = admitCredentialHeaders(patch.headers, {
allowedCredentialHeaders:
security?.auth.allowedCredentialHeaders ??
(["authorization", "x-csrf-token"] as const);
if (!allowedHeaders.includes(normalized as never)) {
throw new TypeError("Credential patch contains a forbidden header");
}
headers.set(normalized, value);
LEGACY_ALLOWED_CREDENTIAL_HEADERS,
requiredCredentialHeaders:
security?.auth.requiredCredentialHeaders ?? [],
});
if (!admission.ok) {
return {
ok: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
attempt,
{ code: "AUTH_ATTACH_FAILED" },
),
};
}
for (const [name, value] of Object.entries(admission.headers)) {
headers.set(name, value);
}
} catch {
// An ordinary owner rejection stays an integration failure.
return {
ok: false,
error: failure("AUTH_INTEGRATION_FAILURE", operation.operationId, attempt, {
@@ -663,6 +783,28 @@ export function createHttpClient(
return Object.freeze({ execute });
function raceAttemptSignal<Value>(
operation: Promise<Value>,
signal: AbortSignal,
): Promise<Value | typeof ATTEMPT_ABORTED> {
operation.catch(() => {});
if (signal.aborted) return Promise.resolve(ATTEMPT_ABORTED);
return new Promise<Value | typeof ATTEMPT_ABORTED>((resolve, reject) => {
const onAbort = () => resolve(ATTEMPT_ABORTED);
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort);
reject(error instanceof Error ? error : new Error("rejected"));
},
);
});
}
function withinLogicalDeadline<Value>(
promise: Promise<Value>,
deadlineAt: number,
@@ -714,6 +856,10 @@ async function parseResponse(
const contentType = mediaType(response.headers.get("content-type"));
const acceptedMedia = operation.responseMediaTypes ?? ["application/json"];
if (!contentType || !acceptedMedia.includes(contentType)) {
// N-08. A rejected response still owns an open body stream. Cancellation is
// best-effort cleanup, so it is started but not awaited: the closed result
// must not depend on stream teardown settling.
void response.body?.cancel().catch(() => {});
return {
ok: false,
error: failure("CONTENT_TYPE_MISMATCH", operation.operationId, attempt, {
@@ -844,20 +990,36 @@ async function parseResponse(
};
}
/**
* LEG-01. Recovery returns data only.
*
* The sign-out notification is a user-visible side effect, so it belongs to
* whoever adopts this result — not to the raw recovery call. A recovery that
* loses the race against the deadline or a caller abort still settles, and
* signing the user out then would attribute a request nobody is waiting on to
* an expired session.
*/
type SessionRecoveryOutcome =
| Readonly<{ ok: true }>
| Readonly<{
ok: false;
error: HttpFailure;
notifyUnauthenticated: boolean;
}>;
async function recoverSession(
authSession: HttpAuthSession,
operation: ApiOperation,
originalFailure: HttpFailure,
): Promise<
Readonly<{ ok: true }> | Readonly<{ ok: false; error: HttpFailure }>
> {
context: CredentialOperationContext,
): Promise<SessionRecoveryOutcome> {
try {
const result = await authSession.recover();
const result = await authSession.recover(context);
if (result === "restored") return { ok: true };
if (result === "no-session") {
authSession.onUnauthenticated();
return {
ok: false,
notifyUnauthenticated: true,
error: failure("AUTH_REQUIRED", operation.operationId, originalFailure.attemptCount - 1, {
code: "AUTH_REQUIRED",
httpStatus: 401,
@@ -870,6 +1032,7 @@ async function recoverSession(
return {
ok: false,
notifyUnauthenticated: false,
error: failure(
"AUTH_INTEGRATION_FAILURE",
operation.operationId,
+112 -11
View File
@@ -2,6 +2,11 @@ import {
HTTP_EXECUTION_CEILINGS,
type InstalledHttpContract,
} from "../../contracts/external-contract-runtime.ts";
import {
CREDENTIAL_HEADER_NAMES,
type CredentialHeaderName,
type RestAuthProfile,
} from "../../contracts/rest-profiles.ts";
/**
* §7.4–§7.7. Descriptor-driven request projection.
@@ -11,21 +16,99 @@ import {
* bounds and re-verifies them.
*/
/**
* §7.7 / VD-23. A credential owner contributes proof headers only. Fetch
* `credentials` belongs to the installed auth profile, so it is deliberately
* absent from this outcome.
*/
export type CredentialPatchOutcome =
| Readonly<{
kind: "READY";
headers: Readonly<Record<string, string>>;
credentials: RequestCredentials;
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
}>
| Readonly<{ kind: "UNAUTHENTICATED" }>
| Readonly<{ kind: "UNAVAILABLE" }>
| Readonly<{ kind: "SCOPE_FENCED" }>;
/** §7.7. The complete set of headers a credential bridge may contribute. */
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"x-csrf-token",
"x-tenant-context",
export const ALLOWED_CREDENTIAL_HEADERS: ReadonlySet<string> = new Set(
CREDENTIAL_HEADER_NAMES,
);
export type CredentialAdmissionFailure =
| "TRANSPORT_OWNED_HEADER"
| "CREDENTIAL_HEADER_NOT_ALLOWED"
| "CREDENTIAL_HEADER_VALUE_INVALID"
| "MISSING_REQUIRED_CREDENTIAL_HEADER";
export type CredentialAdmissionOutcome =
| Readonly<{
ok: true;
headers: Readonly<Partial<Record<CredentialHeaderName, string>>>;
}>
| Readonly<{ ok: false; failure: CredentialAdmissionFailure }>;
const MAX_CREDENTIAL_HEADER_VALUE_BYTES = 8_192;
/**
* §7.7. Admits a credential patch against the resolved profile before any
* header object is built. A rejection here guarantees `fetch()` is not called:
* a credential owner cannot widen the profile, replace a transport-owned
* header, or turn an authenticated profile into an anonymous request.
*/
export function admitCredentialHeaders(
patchHeaders: Readonly<Record<string, unknown>>,
profile: Readonly<{
allowedCredentialHeaders: readonly CredentialHeaderName[];
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>,
): CredentialAdmissionOutcome {
const admitted: Partial<Record<CredentialHeaderName, string>> = {};
const seen = new Set<CredentialHeaderName>();
for (const [name, value] of Object.entries(patchHeaders)) {
const lower = name.toLowerCase();
if (TRANSPORT_OWNED_HEADERS.has(lower) || FORBIDDEN_REQUEST_HEADERS.has(lower)) {
return frozenAdmissionFailure("TRANSPORT_OWNED_HEADER");
}
if (
!ALLOWED_CREDENTIAL_HEADERS.has(lower) ||
!profile.allowedCredentialHeaders.includes(lower as CredentialHeaderName)
) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
}
const credentialName = lower as CredentialHeaderName;
if (seen.has(credentialName)) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_NOT_ALLOWED");
}
if (
typeof value !== "string" ||
value.length === 0 ||
/[\r\n]/.test(value) ||
encoder.encode(value).byteLength > MAX_CREDENTIAL_HEADER_VALUE_BYTES
) {
return frozenAdmissionFailure("CREDENTIAL_HEADER_VALUE_INVALID");
}
seen.add(credentialName);
admitted[credentialName] = value;
}
for (const required of profile.requiredCredentialHeaders) {
if (!seen.has(required)) {
return frozenAdmissionFailure("MISSING_REQUIRED_CREDENTIAL_HEADER");
}
}
return Object.freeze({ ok: true as const, headers: Object.freeze(admitted) });
}
function frozenAdmissionFailure(
failureKind: CredentialAdmissionFailure,
): CredentialAdmissionOutcome {
return Object.freeze({ ok: false as const, failure: failureKind });
}
const TRANSPORT_OWNED_HEADERS: ReadonlySet<string> = new Set([
"accept",
"content-type",
"idempotency-key",
]);
const FORBIDDEN_REQUEST_HEADERS: ReadonlySet<string> = new Set([
@@ -217,6 +300,8 @@ export type FinalInvariantInput = Readonly<{
requestByteLimit: number;
deadlineRemainingMs: number;
scopeIsCurrent: boolean;
/** The resolved installed profile this dispatch must match exactly. */
authProfile: RestAuthProfile;
}>;
export type FinalInvariantFailure =
@@ -224,7 +309,10 @@ export type FinalInvariantFailure =
| "URL_NOT_ALLOWED"
| "REDIRECT_MODE_INVALID"
| "CREDENTIALS_MODE_INVALID"
| "CREDENTIALS_MODE_MISMATCH"
| "HEADER_NOT_ALLOWED"
| "CREDENTIAL_HEADER_NOT_ALLOWED"
| "MISSING_REQUIRED_CREDENTIAL_HEADER"
| "FORBIDDEN_HEADER"
| "REQUEST_BODY_TOO_LARGE"
| "DEADLINE_EXPIRED"
@@ -258,17 +346,30 @@ export function checkFinalInvariants(
) {
return "CREDENTIALS_MODE_INVALID";
}
// The profile is the transport authority: a credential collaborator cannot
// move the request onto a different Fetch credentials mode.
if (input.init.credentials !== input.authProfile.credentials) {
return "CREDENTIALS_MODE_MISMATCH";
}
const presentCredentialHeaders = new Set<string>();
for (const name of Object.keys(input.headers)) {
const lower = name.toLowerCase();
if (FORBIDDEN_REQUEST_HEADERS.has(lower)) return "FORBIDDEN_HEADER";
if (TRANSPORT_OWNED_HEADERS.has(lower)) continue;
if (!ALLOWED_CREDENTIAL_HEADERS.has(lower)) return "HEADER_NOT_ALLOWED";
if (
lower !== "accept" &&
lower !== "content-type" &&
lower !== "idempotency-key" &&
!ALLOWED_CREDENTIAL_HEADERS.has(lower)
!input.authProfile.allowedCredentialHeaders.includes(
lower as CredentialHeaderName,
)
) {
return "HEADER_NOT_ALLOWED";
return "CREDENTIAL_HEADER_NOT_ALLOWED";
}
presentCredentialHeaders.add(lower);
}
for (const required of input.authProfile.requiredCredentialHeaders) {
if (!presentCredentialHeaders.has(required)) {
return "MISSING_REQUIRED_CREDENTIAL_HEADER";
}
}
@@ -48,6 +48,30 @@ export function certaintyForAbandonedAttempt(
}
}
/**
* §8.7. The conservative certainty lattice for one logical execution.
*
* `PhysicalAttemptState` describes only the attempt in flight. A new retry that
* has not been sent yet must never lower what an earlier attempt already
* established, so the executor joins observations into a monotonic accumulator.
*/
const CERTAINTY_RANK: Readonly<Record<MutationEffectCertainty, number>> =
Object.freeze({
NOT_STARTED: 0,
NOT_APPLIED: 1,
MAYBE_APPLIED: 2,
APPLIED_CONFIRMED: 3,
});
export function joinMutationEffectCertainty(
current: MutationEffectCertainty,
observed: MutationEffectCertainty,
): MutationEffectCertainty {
return CERTAINTY_RANK[observed] > CERTAINTY_RANK[current]
? observed
: current;
}
export type ProblemEffectInput<Problem> = Readonly<{
status: number;
problem: Problem;
+480 -123
View File
@@ -6,7 +6,9 @@ import {
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
import {
defineMutationIntent,
MUTATION_INTENT_BOUNDS,
// OPT-NET-02. One shared key authority, so the intent factory and this
// admission site cannot drift apart.
isValidIdempotencyKey,
type MutationIntent,
} from "../../contracts/mutation-intent.ts";
import {
@@ -17,13 +19,25 @@ import {
readBoundedBytes,
} from "./bounded-body-reader.ts";
import {
admitCredentialHeaders,
checkFinalInvariants,
projectRequest,
type CredentialAdmissionFailure,
type CredentialPatchOutcome,
} from "./http-contract-bridge.ts";
import {
INSTALLED_REST_AUTH_PROFILES,
type InstalledRestAuthProfiles,
} from "../../contracts/rest-profiles.ts";
import {
snapshotExactObject,
snapshotOwnDataRecord,
} from "../../contracts/exact-snapshot.ts";
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
joinMutationEffectCertainty,
type MutationEffectCertainty,
type PhysicalAttemptState,
} from "./http-effect-certainty.ts";
import { parseRetryAfter } from "./retry-policy.ts";
@@ -124,8 +138,40 @@ export type HttpExecutionOutcome<Value, Problem> =
| Readonly<{
kind: "CANCELLED";
effect: "NOT_STARTED" | "MAYBE_APPLIED";
}>
| Readonly<{
kind: "AUTH_INTEGRATION_FAILURE";
reason: AuthIntegrationFailureReason;
effect: "NOT_APPLICABLE" | "NOT_STARTED";
}>;
/**
* §7.7 / VD-23. A configuration or collaborator contract breach, never a user
* session state. `UNAUTHENTICATED` stays reserved for the latter.
*/
export type AuthIntegrationFailureReason =
| "UNKNOWN_AUTH_PROFILE"
/**
* LIVE-01. The collaborator answered that the auth system itself cannot serve
* this request. That is an outage of the integration, not a statement about
* the user's session, so it must never reach the composition root's logout
* path.
*/
| "CREDENTIAL_OWNER_UNAVAILABLE"
/** LIVE-01. The collaborator threw, rejected, or answered off-contract. */
| "CREDENTIAL_OWNER_FAILED"
| CredentialAdmissionFailure;
/**
* §8.5. Credential collaborators receive the operation lifetime so a
* cooperative owner can abandon its own work; a non-cooperative one is still
* bounded by the executor's race against the same signal.
*/
export type AuthOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
export type CancellationOwner =
| "CALLER"
| "ROUTE_TRANSITION"
@@ -134,6 +180,12 @@ export type CancellationOwner =
| "DEADLINE";
export interface HttpExecutionContext {
/**
* §7.4. The low-cardinality route identity that owns this logical execution.
* It is required at the installed operation-executor boundary so a terminal
* outcome can always be attributed without reconstructing it from a URL.
*/
readonly routeId: string;
readonly signal?: AbortSignal;
readonly scope: CacheScopeSnapshot;
readonly intent?: MutationIntent;
@@ -147,23 +199,85 @@ export interface ContractHttpExecutor {
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
}
/**
* §7.4 / VD-07. One typed internal record per logical execution. It is not an
* arbitrary context map: the composition root owns the projection into the
* closed diagnostics and telemetry buckets, and raw attempt count, duration and
* status never leave that projection.
*/
export type HttpExecutionObservation = Readonly<{
routeId: string;
operationId: string;
diagnosticsOperation: string;
outcome: string;
attempts: number;
certainty: string;
outcome: HttpExecutionOutcome<unknown, unknown>["kind"];
errorKind: string;
status?: number;
attemptCount: number;
durationMs: number;
effect: HttpEffectCertainty;
cancellationOwner?: CancellationOwner;
/**
* The internal terminal-reason label recorded by the execution site. It is
* evidence for the HTTP scenario catalog only; the composition-root
* projection never forwards it to diagnostics or telemetry.
*/
terminalReason: string;
}>;
/**
* The outcome is the single authority for the observed error kind. The terminal
* reason only distinguishes an internal runtime failure from a transport
* failure, because both surface as the same public outcome.
*/
function observationErrorKind(
outcome: HttpExecutionOutcome<unknown, unknown>,
terminalReason: string,
): string {
switch (outcome.kind) {
case "SUCCESS":
return "NONE";
case "PROBLEM":
return "PROBLEM";
case "UNAUTHENTICATED":
return "UNAUTHENTICATED";
case "FORBIDDEN":
return "FORBIDDEN";
case "RATE_LIMITED":
return "RATE_LIMITED";
case "CONTRACT_VIOLATION":
return outcome.violation.kind;
case "TRANSPORT_FAILURE":
return terminalReason === "RUNTIME_FAILURE"
? "RUNTIME_FAILURE"
: outcome.failure.kind;
case "CANCELLED":
return "REQUEST_ABORTED";
case "AUTH_INTEGRATION_FAILURE":
return outcome.reason;
}
}
function observationStatus(
outcome: HttpExecutionOutcome<unknown, unknown>,
): number | undefined {
return outcome.kind === "SUCCESS" || outcome.kind === "PROBLEM"
? outcome.metadata.status
: undefined;
}
export type ContractHttpExecutorDependencies = Readonly<{
baseUrl: string;
/** §8.2. `MAX_RETRY_ATTEMPTS` from Runtime Config; the ceiling is still 2. */
maxRetryAttempts: number;
/** The installed profile registry; the executor never invents a profile. */
authProfiles?: InstalledRestAuthProfiles;
attachCredentials(
operation: Readonly<{
operationId: string;
authProfileId: string;
method: string;
}>,
context: AuthOperationContext,
): Promise<CredentialPatchOutcome> | CredentialPatchOutcome;
fetcher?: typeof fetch;
/** Adapter seam for the common bounded response reader. */
@@ -234,7 +348,7 @@ function validateMutationIntent(
}
const key = validated.idempotencyKey;
if (requiresKey && !validIdempotencyKey(key)) {
if (requiresKey && !isValidIdempotencyKey(key)) {
return Object.freeze({
ok: false,
violation: "MISSING_IDEMPOTENCY_KEY",
@@ -249,35 +363,12 @@ function validateMutationIntent(
return Object.freeze({ ok: true, intent: validated });
}
const UTF8 = new TextEncoder();
function validIdempotencyKey(value: unknown): value is string {
return (
typeof value === "string" &&
value.trim().length > 0 &&
UTF8.encode(value).byteLength <=
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes &&
!hasControlCharacter(value)
);
}
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (
codePoint <= 0x1f ||
(codePoint >= 0x7f && codePoint <= 0x9f)
) {
return true;
}
}
return false;
}
export function createContractHttpExecutor(
dependencies: ContractHttpExecutorDependencies,
): ContractHttpExecutor {
const fetcher = dependencies.fetcher ?? fetch;
const authProfiles =
dependencies.authProfiles ?? INSTALLED_REST_AUTH_PROFILES;
const readResponseBytes =
dependencies.readBoundedResponseBytes ?? readBoundedBytes;
const now = dependencies.monotonicNow ?? (() => performance.now());
@@ -308,11 +399,31 @@ export function createContractHttpExecutor(
// §8.5. One monotonic deadline covers credential resolution, encoding,
// backoff, every physical attempt, body read and validation.
const deadlineAt = now() + policy.totalDeadlineMs;
const startedAt = now();
const deadlineAt = startedAt + policy.totalDeadlineMs;
const remaining = () => deadlineAt - now();
let attemptState: PhysicalAttemptState = "PREPARING";
let attempts = 0;
/**
* §8.7 / D-01. Per-attempt state stays local to the attempt; this monotonic
* accumulator is the logical execution history. A retry that has not been
* dispatched can never lower what an earlier attempt already established.
*/
let logicalCertainty: MutationEffectCertainty = "NOT_STARTED";
const observeCertainty = (observed: MutationEffectCertainty) => {
logicalCertainty = joinMutationEffectCertainty(logicalCertainty, observed);
return logicalCertainty;
};
/** Pre-dispatch failures read the accumulator, never a fresh attempt. */
const logicalPreDispatchEffect = (): HttpEffectCertainty =>
isCommand ? logicalCertainty : "NOT_APPLICABLE";
const abandonedCertainty = (): MutationEffectCertainty =>
observeCertainty(certaintyForAbandonedAttempt(attemptState, isCommand));
const abandonedTransportFailure = (
kind: HttpTransportFailure["kind"],
): HttpExecutionOutcome<WireOutput, Problem> =>
transportFailure(kind, false, abandonedCertainty());
let terminalCancellation: CancellationOwner | null = null;
const lifetimeController = new AbortController();
const forwardCallerToLifetime = () => {
@@ -353,16 +464,28 @@ export function createContractHttpExecutor(
const finish = (
outcome: HttpExecutionOutcome<WireOutput, Problem>,
certainty: string,
terminalReason: string,
): HttpExecutionOutcome<WireOutput, Problem> => {
disposeLifetime();
try {
dependencies.observe?.({
diagnosticsOperation: policy.diagnosticsOperation,
outcome: outcome.kind,
attempts,
certainty,
});
const status = observationStatus(outcome);
dependencies.observe?.(
Object.freeze({
routeId: context.routeId,
operationId: contract.operationId,
diagnosticsOperation: policy.diagnosticsOperation,
outcome: outcome.kind,
errorKind: observationErrorKind(outcome, terminalReason),
...(status === undefined ? {} : { status }),
attemptCount: attempts,
durationMs: Math.max(0, now() - startedAt),
effect: outcome.effect,
terminalReason,
...(terminalCancellation === null
? {}
: { cancellationOwner: terminalCancellation }),
}),
);
} catch {
// Observation is outside the execution authority.
}
@@ -371,6 +494,16 @@ export function createContractHttpExecutor(
try {
// §7.7. The installed registry is the only source of a profile. Composition
// already rejects unknown identities; this is the runtime fail-close.
const authProfile = authProfiles.get(policy.authProfileId);
if (!authProfile) {
return finish(
authIntegrationFailure("UNKNOWN_AUTH_PROFILE", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// §7.4 step 1-2: capture the scope and verify it is still current.
if (!context.scope.isCurrent()) {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
@@ -424,54 +557,86 @@ export function createContractHttpExecutor(
// §7.7 / §8.4. Credentials are resolved before send. A response 401 is
// terminal; there is no hidden refresh-and-replay.
//
// LIVE-01. A synchronous throw and an asynchronous rejection are the same
// event seen from two call sites, so one classifier owns both. Neither is
// evidence about the user's session.
let patchOperation: Promise<CredentialPatchOutcome>;
try {
patchOperation = Promise.resolve(
dependencies.attachCredentials({
operationId: contract.operationId,
authProfileId: policy.authProfileId,
method: contract.method,
}),
dependencies.attachCredentials(
{
operationId: contract.operationId,
authProfileId: policy.authProfileId,
method: contract.method,
},
Object.freeze({
signal: lifetimeController.signal,
deadlineAtMonotonicMs: deadlineAt,
}),
),
);
} catch {
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
return finish(
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
let patchResult: CredentialPatchOutcome | typeof ABORTED;
try {
patchResult = await awaitWithAbort(
patchOperation,
lifetimeController.signal,
);
} catch {
return finish(
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
const patchResult = await awaitWithAbort(
patchOperation,
lifetimeController.signal,
);
if (patchResult === ABORTED) {
if (terminalCancellation === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
return terminalCancellation === "CALLER"
? finish(cancelled("NOT_STARTED"), "CANCELLED")
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
const patch = patchResult;
// 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(
authIntegrationFailure("CREDENTIAL_OWNER_FAILED", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
if (patch.kind === "SCOPE_FENCED") {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
}
if (patch.kind !== "READY") {
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.
if (
Object.keys(patch.headers).some(
(name) => name.toLowerCase() === "idempotency-key",
@@ -483,9 +648,21 @@ export function createContractHttpExecutor(
);
}
// §7.7. The profile, not the patch, decides what may travel. Rejection here
// means zero fetch calls.
const admission = admitCredentialHeaders(patch.headers, authProfile);
if (!admission.ok) {
return finish(
authIntegrationFailure(admission.failure, isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// Transport-owned headers are written last so no credential entry can
// shadow Accept or Content-Type through key ordering.
const headers: Record<string, string> = {
...admission.headers,
Accept: "application/json",
...patch.headers,
};
if (contract.requestBody === "JSON") {
headers["Content-Type"] = "application/json";
@@ -504,14 +681,14 @@ export function createContractHttpExecutor(
if (callerSignal?.aborted) {
terminalCancellation ??= "CALLER";
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (!context.scope.isCurrent()) {
terminalCancellation ??= "SCOPE_FENCE";
return finish(
scopeFenced(contractViolationEffect(attemptState, isCommand)),
scopeFenced(abandonedCertainty()),
"SCOPE_FENCED",
);
}
@@ -520,7 +697,7 @@ export function createContractHttpExecutor(
const budget = remaining();
if (budget <= 0) {
return finish(
transportFailure("TIMEOUT", false, attemptState, isCommand),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -543,7 +720,7 @@ export function createContractHttpExecutor(
headers,
redirect: "error",
referrerPolicy: "no-referrer",
credentials: patch.credentials,
credentials: authProfile.credentials,
cache: "no-store",
signal: controller.signal,
...(projected.request.bodyBytes
@@ -560,17 +737,18 @@ export function createContractHttpExecutor(
requestByteLimit: policy.requestByteLimit,
deadlineRemainingMs: remaining(),
scopeIsCurrent: context.scope.isCurrent(),
authProfile,
});
if (invariantFailure) {
clearTimeout(deadlineTimer);
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
return finish(
invariantFailure === "SCOPE_FENCED"
? scopeFenced(preDispatchEffect(isCommand))
? scopeFenced(logicalPreDispatchEffect())
: violation(
"FINAL_REQUEST_INVARIANT_FAILED",
"REQUEST",
preDispatchEffect(isCommand),
logicalPreDispatchEffect(),
),
"NOT_STARTED",
);
@@ -578,30 +756,42 @@ export function createContractHttpExecutor(
let response: Response;
attemptState = "READY_TO_SEND";
// LIVE-04. The dispatch wait is raced against the attempt signal, which
// already carries the caller, the scope fence and the total deadline. A
// `fetch` that ignores its own `signal` therefore still cannot outlive
// the operation, and a response that lands late is drained, not admitted.
let dispatch: BoundedRace<Response>;
try {
attempts += 1;
const pending = fetcher(projected.request.url, init);
attemptState = "DISPATCHED";
response = await pending;
attemptState = "RESPONSE_HEADERS";
// D-01. Dispatch is the point of no return for the logical execution.
// No later retry may claim the command never started.
observeCertainty(certaintyForAbandonedAttempt("DISPATCHED", isCommand));
dispatch = await raceTerminal(
pending,
controller.signal,
cancelResponseBody,
);
} catch {
dispatch = REJECTED_RACE;
}
if (dispatch.kind === "VALUE") {
response = dispatch.value;
attemptState = "RESPONSE_HEADERS";
} else {
clearTimeout(deadlineTimer);
lifetimeController.signal.removeEventListener("abort", forwardLifetime);
const owner = terminalCancellation;
if (owner === "CALLER") {
return finish(
cancelled(certaintyForAbandonedAttempt(attemptState, isCommand)),
cancelled(abandonedCertainty()),
"CANCELLED",
);
}
if (owner === "SCOPE_FENCE") {
return finish(
transportFailure(
"ABORTED_BY_SCOPE",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("ABORTED_BY_SCOPE"),
"SCOPE_FENCED",
);
}
@@ -621,18 +811,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -640,20 +823,64 @@ export function createContractHttpExecutor(
}
}
return finish(
transportFailure(kind, false, attemptState, isCommand),
abandonedTransportFailure(kind),
kind,
);
}
try {
const outcome = await admitResponse(
operation,
response,
context,
attemptState,
readResponseBytes,
// LIVE-04. Response admission reads a body, so it is a physical wait
// too. It is bounded by the same signal, the reader is handed that
// signal so a cooperative stream stops early, and an admission that
// completes after the terminal owner fired is discarded.
const admission = await raceTerminal(
admitResponse(
operation,
response,
context,
attemptState,
readResponseBytes,
controller.signal,
),
controller.signal,
() => cancelResponseBody(response),
);
// LIVE-04. Once response headers are in hand the request demonstrably
// reached the server, so a terminal owner that lands during admission
// keeps the dispatched classification: a stale generation stays the
// `SCOPE_FENCED` contract violation it has always been, and only the
// deadline and the caller reclassify the outcome.
const abandonAdmission = ():
| HttpExecutionOutcome<WireOutput, Problem>
| null => {
switch (terminalCancellation) {
case "DEADLINE":
return finish(abandonedTransportFailure("TIMEOUT"), "TIMEOUT");
case "CALLER":
return finish(cancelled(abandonedCertainty()), "CANCELLED");
case "SCOPE_FENCE":
return finish(
scopeFenced(abandonedCertainty()),
"SCOPE_FENCED",
);
default:
return null;
}
};
if (admission.kind !== "VALUE") {
cancelResponseBody(response);
return (
abandonAdmission() ??
finish(
abandonedTransportFailure("NETWORK_FAILURE"),
"NETWORK_FAILURE",
)
);
}
const outcome = admission.value;
attemptState = "SETTLED";
const abandoned = abandonAdmission();
if (abandoned) return abandoned;
if (
outcome.retryHint &&
retryIndex < retryCeiling &&
@@ -673,18 +900,11 @@ export function createContractHttpExecutor(
if (slept === ABORTED) {
return terminalCancellation === "CALLER"
? finish(
cancelled(
certaintyForAbandonedAttempt(attemptState, isCommand),
),
cancelled(abandonedCertainty()),
"CANCELLED",
)
: finish(
transportFailure(
"TIMEOUT",
false,
attemptState,
isCommand,
),
abandonedTransportFailure("TIMEOUT"),
"TIMEOUT",
);
}
@@ -699,7 +919,7 @@ export function createContractHttpExecutor(
}
} catch {
return finish(
transportFailure("NETWORK_FAILURE", false, attemptState, isCommand),
abandonedTransportFailure("NETWORK_FAILURE"),
"RUNTIME_FAILURE",
);
} finally {
@@ -710,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;
@@ -728,6 +995,7 @@ async function admitResponse<Input, WireOutput, Problem>(
context: HttpExecutionContext,
attemptState: PhysicalAttemptState,
readResponseBytes: typeof readBoundedBytes,
signal: AbortSignal,
): Promise<AdmissionOutcome<WireOutput, Problem>> {
const contract = operation.contract;
const policy = operation.frontend;
@@ -790,19 +1058,19 @@ async function admitResponse<Input, WireOutput, Problem>(
metadata,
attemptState,
readResponseBytes,
signal,
);
}
// 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(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
probe.code,
);
@@ -834,7 +1102,11 @@ async function admitResponse<Input, WireOutput, Problem>(
const emptyAllowed = contract.emptyBodyStatuses.includes(status);
const mediaOk = isJsonMediaType(response.headers.get("content-type"));
const bytes = await readResponseBytes(response, policy.responseByteLimit);
const bytes = await readResponseBytes(
response,
policy.responseByteLimit,
signal,
);
if (!bytes.ok) {
return settled(
bytes.code === "RESPONSE_TOO_LARGE"
@@ -846,8 +1118,7 @@ async function admitResponse<Input, WireOutput, Problem>(
: transportFailure(
"RESPONSE_STREAM_FAILURE",
false,
attemptState,
isCommand,
certaintyForAbandonedAttempt(attemptState, isCommand),
),
bytes.code,
);
@@ -946,6 +1217,87 @@ async function admitResponse<Input, WireOutput, Problem>(
);
}
/**
* LIVE-04. The outcome of a physical wait that the operation's terminal signal
* bounds.
*
* `REJECTED` is kept distinct from `TERMINAL` on purpose: a collaborator's own
* rejection is evidence about the request, and forging it into a cancellation
* state would erase the reason the attempt actually failed.
*/
type BoundedRace<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "REJECTED" }>
| Readonly<{ kind: "TERMINAL" }>;
const TERMINAL_RACE: BoundedRace<never> = Object.freeze({
kind: "TERMINAL" as const,
});
const REJECTED_RACE: BoundedRace<never> = Object.freeze({
kind: "REJECTED" as const,
});
/**
* LIVE-04. Races a physical operation against the terminal signal so a
* non-cooperative `fetch` or reader cannot hold the port result open past the
* total deadline.
*
* Two properties matter beyond the race itself. A value that arrives while the
* terminal owner has already fired is *late*, so it is compensated rather than
* admitted. And the abandoned operation is still observed exactly once, so a
* late native rejection never surfaces as an unhandled rejection.
*/
async function raceTerminal<Value>(
operation: Promise<Value>,
signal: AbortSignal,
compensate: (value: Value) => void,
): Promise<BoundedRace<Value>> {
let landed: BoundedRace<Value> | null = null;
const settled: Promise<BoundedRace<Value>> = operation.then(
(value) => (landed = Object.freeze({ kind: "VALUE" as const, value })),
() => (landed = REJECTED_RACE),
);
const observeLate = () => {
void settled.then((outcome) => {
if (outcome.kind !== "VALUE") return;
try {
compensate(outcome.value);
} catch {
// Compensation is outside the execution authority.
}
});
};
let onAbort: (() => void) | undefined;
const terminal = new Promise<BoundedRace<Value>>((resolve) => {
if (signal.aborted) {
resolve(TERMINAL_RACE);
return;
}
onAbort = () => resolve(TERMINAL_RACE);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
const winner = await Promise.race([settled, terminal]);
if (winner !== TERMINAL_RACE) return winner;
// The terminal owner reached the await first. Drain the microtask queue
// once so an operation that had *already* settled can still hand over its
// value: a microtask turn cannot be extended by a collaborator that has
// not settled, so a non-cooperative operation is still abandoned here.
for (let turn = 0; turn < 4 && landed === null; turn += 1) {
await Promise.resolve();
}
if (landed !== null) return landed;
observeLate();
return TERMINAL_RACE;
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
function cancelResponseBody(response: Response): void {
void response.body?.cancel().catch(() => {});
}
const ABORTED = Symbol("http-operation-aborted");
async function awaitWithAbort<Value>(
@@ -972,6 +1324,7 @@ async function admitProblem<Input, WireOutput, Problem>(
metadata: SafeResponseMetadata,
attemptState: PhysicalAttemptState,
readResponseBytes: typeof readBoundedBytes,
signal: AbortSignal,
): Promise<AdmissionOutcome<WireOutput, Problem>> {
const contract = operation.contract;
const isCommand = contract.commandEffect !== null;
@@ -980,6 +1333,7 @@ async function admitProblem<Input, WireOutput, Problem>(
const bytes = await readResponseBytes(
response,
HTTP_EXECUTION_CEILINGS.problemResponseBytes,
signal,
);
if (!bytes.ok || isEffectivelyEmpty(bytes.bytes)) {
// An unclassifiable failure stays uncertain for a command.
@@ -1156,6 +1510,21 @@ function scopeFenced<Value, Problem>(
return violation("SCOPE_FENCED", "RESPONSE", effect);
}
/**
* §7.7. A credential collaborator or profile-binding breach. It always resolves
* before dispatch, so the command effect is `NOT_STARTED` and fetch count zero.
*/
function authIntegrationFailure<Value, Problem>(
reason: AuthIntegrationFailureReason,
isCommand: boolean,
): HttpExecutionOutcome<Value, Problem> {
return Object.freeze({
kind: "AUTH_INTEGRATION_FAILURE" as const,
reason,
effect: isCommand ? ("NOT_STARTED" as const) : ("NOT_APPLICABLE" as const),
});
}
function preDispatchEffect(isCommand: boolean): HttpEffectCertainty {
return isCommand ? "NOT_STARTED" : "NOT_APPLICABLE";
}
@@ -1164,16 +1533,6 @@ function postDispatchEffect(isCommand: boolean): HttpEffectCertainty {
return isCommand ? "MAYBE_APPLIED" : "NOT_APPLICABLE";
}
function contractViolationEffect(
attemptState: PhysicalAttemptState,
isCommand: boolean,
): HttpEffectCertainty {
if (!isCommand) return "NOT_APPLICABLE";
return attemptState === "PREPARING" || attemptState === "READY_TO_SEND"
? "NOT_STARTED"
: "MAYBE_APPLIED";
}
function unauthenticated<Value, Problem>(
effect: string,
isCommand: boolean,
@@ -1206,10 +1565,8 @@ function cancelled<Value, Problem>(
function transportFailure<Value, Problem>(
kind: HttpTransportFailure["kind"],
retryable: boolean,
attemptState: PhysicalAttemptState,
isCommand: boolean,
effect: MutationEffectCertainty,
): HttpExecutionOutcome<Value, Problem> {
const effect = certaintyForAbandonedAttempt(attemptState, isCommand);
return Object.freeze({
kind: "TRANSPORT_FAILURE" as const,
failure: Object.freeze({ kind, retryable }),
@@ -0,0 +1,269 @@
/**
* BT-X-01. Shared abort and deadline mechanics.
*
* Several adapters independently reimplemented "race a promise against a
* caller signal and a deadline, then clean up listeners and timers". Only the
* mechanics are shared here; every subsystem keeps its own result taxonomy and
* recovery vocabulary, so this module deliberately imports none of them and is
* not a generic middleware layer.
*/
export type AbortTerminalReason = "CALLER_ABORT" | "DEADLINE" | "CLOSED";
/**
* TR-RR-05. A collaborator's own rejection is evidence about the work, so it is
* a distinct outcome. Forging it into `TERMINAL` erased the reason the
* operation actually failed and made `race()` disagree with `terminal()`, which
* still reported no owner.
*/
export type AbortRace<Value> =
| Readonly<{ kind: "VALUE"; value: Value }>
| Readonly<{ kind: "REJECTED"; reason: unknown }>
| Readonly<{ kind: "TERMINAL"; terminal: AbortTerminalReason }>;
export type AbortableOperation = Readonly<{
/** The composed signal: caller abort, deadline and close all feed it. */
readonly signal: AbortSignal;
/**
* The first terminal owner, or `null` while the operation is still live.
* This is a live accessor, not a snapshot.
*/
terminal(): AbortTerminalReason | null;
/**
* Resolves with the operation's value, its own rejection, or the first
* terminal owner. A terminal race never returns a bare value, and the
* `terminal` it reports is always the same owner `terminal()` reports.
*
* `compensate` is invoked at most once, and only for a value that arrived
* after the operation already ended.
*/
race<Value>(
operation: Promise<Value>,
compensate?: (value: Value) => void,
): Promise<AbortRace<Value>>;
/**
* Idempotent. Removes listeners, clears the deadline timer and marks the
* operation `CLOSED` if nothing terminal happened first.
*/
close(): void;
}>;
export type AbortableOperationInput = Readonly<{
signal?: AbortSignal;
timeoutMs?: number;
/** Scheduler seam; a throwing scheduler must not leak a listener. */
setTimer?: (callback: () => void, delayMs: number) => unknown;
clearTimer?: (handle: unknown) => void;
}>;
export type AbortTimerSnapshot = Readonly<{
setTimer: (callback: () => void, delayMs: number) => unknown;
clearTimer: (handle: unknown) => void;
}>;
/**
* X-AUDIT-02. Captures a scheduler's timer callables once, bound to their
* receiver. Consumers that kept the scheduler object and re-read `setTimeout`
* per request validated one function and executed another, so replacing a
* method after composition silently changed how work was bounded.
*/
export function snapshotAbortTimers<Handle>(
scheduler: Readonly<{
setTimeout(callback: () => void, milliseconds: number): Handle;
clearTimeout(handle: Handle): void;
}>,
): AbortTimerSnapshot {
const set = scheduler?.setTimeout;
const clear = scheduler?.clearTimeout;
if (typeof set !== "function" || typeof clear !== "function") {
throw new TypeError("Timer scheduler is invalid.");
}
return Object.freeze({
setTimer: set.bind(scheduler) as (
callback: () => void,
delayMs: number,
) => unknown,
clearTimer: clear.bind(scheduler) as (handle: unknown) => void,
});
}
export function createAbortableOperation(
input: AbortableOperationInput = {},
): AbortableOperation {
const controller = new AbortController();
// TR-RR-05. The scheduler and the caller signal are captured once, so
// replacing a method on the input object after construction cannot change how
// an operation already in flight is bounded or cleaned up.
const callerSignal = input.signal;
const setTimer =
input.setTimer ??
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
const clearTimer =
input.clearTimer ??
((handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
});
let terminalReason: AbortTerminalReason | null = null;
let disposed = false;
let timer: unknown;
/** First terminal owner wins; later owners never overwrite it. */
const settle = (reason: AbortTerminalReason) => {
terminalReason ??= reason;
if (!controller.signal.aborted) controller.abort();
};
const onCallerAbort = () => {
settle("CALLER_ABORT");
dispose();
};
function dispose(): void {
if (disposed) return;
disposed = true;
try {
callerSignal?.removeEventListener("abort", onCallerAbort);
} catch {
// A hostile signal facade cannot block cleanup of the rest.
}
if (timer !== undefined) {
try {
clearTimer(timer);
} catch {
// A throwing scheduler cannot leave the operation un-disposed.
}
timer = undefined;
}
}
if (callerSignal?.aborted) {
settle("CALLER_ABORT");
disposed = true;
} else if (callerSignal) {
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
}
if (
terminalReason === null &&
input.timeoutMs !== undefined &&
Number.isFinite(input.timeoutMs) &&
input.timeoutMs >= 0
) {
try {
timer = setTimer(() => {
settle("DEADLINE");
dispose();
}, input.timeoutMs);
} catch {
// TR-RR-05. A scheduler that cannot install the deadline leaves the
// operation unbounded. Removing the caller listener and leaving no
// terminal owner made a later abort invisible, so installation failure is
// itself terminal: the operation closes atomically with its resources.
timer = undefined;
settle("CLOSED");
dispose();
}
}
return Object.freeze({
signal: controller.signal,
terminal: () => terminalReason,
race<Value>(
operation: Promise<Value>,
compensate?: (value: Value) => void,
): Promise<AbortRace<Value>> {
let compensated = false;
const compensateOnce = (value: Value) => {
if (compensated || !compensate) return;
compensated = true;
try {
compensate(value);
} catch {
// Compensation is best effort and never changes the outcome.
}
};
const terminalRace = (): AbortRace<Value> =>
Object.freeze({
kind: "TERMINAL" as const,
// The owner reported here is always the owner `terminal()` reports.
terminal: terminalReason ?? ("CLOSED" as const),
});
if (terminalReason !== null) {
// Already owned before the task was ever raced: nothing it produces can
// be admitted, so a value is compensated and a rejection absorbed.
void operation.then(
(value) => compensateOnce(value),
() => undefined,
);
return Promise.resolve(terminalRace());
}
// X-AUDIT-01. Task settlement and the terminal event share one settle-once
// state machine, so the outcome is whichever callback actually ran first.
// Draining a fixed number of microtasks to guess whether a promise "had
// already settled" made the answer depend on scheduling rather than on
// observation, and let a rejection overwrite an owner that was fixed
// synchronously before it.
return new Promise<AbortRace<Value>>((resolve) => {
let claimed = false;
let onAbort: (() => void) | undefined;
const release = () => {
if (!onAbort) return;
controller.signal.removeEventListener("abort", onAbort);
onAbort = undefined;
};
const claim = (outcome: AbortRace<Value>): boolean => {
if (claimed) return false;
claimed = true;
release();
resolve(outcome);
return true;
};
operation.then(
(value) => {
if (!claim(Object.freeze({ kind: "VALUE" as const, value }))) {
// The work landed after the operation already ended.
compensateOnce(value);
}
},
(reason: unknown) => {
// A rejection that loses the claim is absorbed here, so it can never
// surface as an unhandled rejection.
claim(Object.freeze({ kind: "REJECTED" as const, reason }));
},
);
if (controller.signal.aborted) {
claim(terminalRace());
return;
}
onAbort = () => {
claim(terminalRace());
};
controller.signal.addEventListener("abort", onAbort, { once: true });
});
},
close() {
settle("CLOSED");
dispose();
},
});
}
/**
* Compensates a native handle that arrives after the operation ended. The
* compensation itself is best effort and can never change the already selected
* outcome.
*/
export function compensateLateHandle(
handle: Promise<Readonly<{ body?: { cancel(): Promise<void> } | null }> | null>,
): void {
void handle
.then(async (value) => {
await value?.body?.cancel();
})
.catch(() => undefined);
}
@@ -32,6 +32,26 @@ type ValidatorRow = {
generation: number;
};
type ConditionalValidatorKeyTuple = readonly [
scopeFingerprint: string,
definitionId: string,
identityToken: string,
representationVersion: number,
];
/** The store is a trust boundary, so key components are validated and bounded. */
const MAX_KEY_COMPONENT_BYTES = 512;
const KEY_COMPONENT_ENCODER = new TextEncoder();
function isBoundedKeyComponent(value: unknown): value is string {
return (
typeof value === "string" &&
value.length > 0 &&
KEY_COMPONENT_ENCODER.encode(value).byteLength <=
MAX_KEY_COMPONENT_BYTES
);
}
export function createConditionalValidatorStore(
maxEntries = 1_024,
): ConditionalValidatorStore {
@@ -40,22 +60,31 @@ export function createConditionalValidatorStore(
}
const rows = new Map<string, ValidatorRow>();
/**
* N-05. A delimiter join is not injective here: `definitionId`,
* `identityToken` and the scope fingerprint may all contain the delimiter, so
* two distinct valid bindings could encode to the same key and one
* definition's ETag could be sent for another. The key is a validated fixed
* tuple encoded with `JSON.stringify`, which escapes the separators.
*/
function key(binding: ConditionalValidatorBinding): string | null {
if (
!binding.scope.isCurrent() ||
!binding.definitionId ||
!isBoundedKeyComponent(binding.scope.fingerprint) ||
!isBoundedKeyComponent(binding.definitionId) ||
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
!Number.isSafeInteger(binding.representationVersion) ||
binding.representationVersion < 1
) {
return null;
}
return [
const tuple: ConditionalValidatorKeyTuple = [
binding.scope.fingerprint,
binding.definitionId,
binding.identityToken,
binding.representationVersion,
].join(":");
];
return JSON.stringify(tuple);
}
return Object.freeze({
@@ -5,6 +5,46 @@ 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");
/**
* Resolves as soon as the operation settles or the signal aborts, whichever
* comes first. A late operation result is observed and discarded, never thrown
* as an unhandled rejection.
*
* OPT-NET-01. A loader rejection is *not* an abort. The presence of a signal
* says nothing about why the loader failed, so a rejection is re-thrown exactly
* as it would be with no signal at all; only a signal that has actually
* aborted classifies the outcome as cancellation.
*/
async function raceAbort<Value>(
operation: Promise<Value>,
signal: AbortSignal | undefined,
): Promise<Value | typeof ABORTED> {
operation.catch(() => {});
if (!signal) return await operation;
if (signal.aborted) return ABORTED;
return await new Promise<Value | typeof ABORTED>((resolve, reject) => {
const onAbort = () => resolve(ABORTED);
signal.addEventListener("abort", onAbort, { once: true });
operation.then(
(value) => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
(reason: unknown) => {
signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
resolve(ABORTED);
return;
}
reject(reason);
},
);
});
}
export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
definitionId: string;
@@ -14,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[] = [];
@@ -23,16 +73,28 @@ 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) {
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
}
const result = await dependencies.loadPage(cursor, context);
// N-10. A non-cooperative loader may never settle, or may settle after
// abort. Race the signal so `loadAll` is bounded, and re-check before
// observing the page so a late completion is ignored rather than
// accumulated into a successful result.
const raced: Result<CursorPage<Value>> | typeof ABORTED =
await raceAbort<Result<CursorPage<Value>>>(
loadPage(cursor, context),
context.signal,
);
if (raced === ABORTED || context.signal?.aborted) {
return failure("REQUEST_ABORTED", "PAGINATION_ABORTED");
}
const result: Result<CursorPage<Value>> = raced;
if (!result.ok) return result;
const page = result.value;
if (!isValidPage(page, dependencies.profile)) {
const page: CursorPage<Value> = result.value;
if (!isValidPage(page, profile)) {
return failure(
"PAGINATION_CONTRACT_VIOLATION",
"PAGINATION_PAGE_INVALID",
@@ -48,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",
@@ -57,7 +119,7 @@ export function createCursorPaginationRuntime<Value>(dependencies: Readonly<{
);
}
if (!page.hasMore) return { ok: true, value: Object.freeze(items) };
const nextCursor = page.nextCursor;
const nextCursor: string | null = page.nextCursor;
if (!nextCursor || cursors.has(nextCursor)) {
return failure(
"PAGINATION_CONTRACT_VIOLATION",
@@ -83,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 ||
@@ -168,6 +168,28 @@ export function createLivePollHandoffCoordinator<Value>(
let quiescing: InternalWriterLease<Value> | null = null;
let transitionCandidate: InternalWriterLease<Value> | null = null;
let closePromise: Promise<RealtimeResult<void>> | null = null;
/**
* R-03. Writers whose lease was fail-closed but whose tail may still be
* running. Membership keeps `close()` honest about quiescence.
*/
const retiredWriters = new Set<InternalWriterLease<Value>>();
/**
* RT-RR-04. Checkpoint work is an external authority call like a writer tail,
* so it belongs in a physical-task registry from invocation to settlement.
* Racing it against a timeout bounded the public wait but left `close()` free
* to report success while the checkpoint was still running.
*/
const checkpointTasks = new Set<Promise<unknown>>();
/** RT-RR-03. Prunes a settled writer without needing another `close()`. */
function trackRetiredWriter(lease: InternalWriterLease<Value>): void {
retiredWriters.add(lease);
void lease.tail
.catch(() => undefined)
.finally(() => {
retiredWriters.delete(lease);
});
}
active = createWriterLease(dependencies.initial.writer);
@@ -568,6 +590,12 @@ export function createLivePollHandoffCoordinator<Value>(
(value) => ({ kind: "VALUE" as const, value }),
() => ({ kind: "REJECTED" as const }),
);
// RT-RR-04. Registered at the moment the authority is called, and removed
// only when it settles, so `close()` cannot report quiescence over it.
checkpointTasks.add(operation);
void operation.finally(() => {
checkpointTasks.delete(operation);
});
const timeout = Promise.resolve()
.then(async () => {
await clock.sleep(
@@ -638,6 +666,33 @@ export function createLivePollHandoffCoordinator<Value>(
return realtimeSuccess(undefined);
}
/**
* RT-RR-04. The writer-tail quiescence bound applied to any retained physical
* task, so checkpoint work is proved settled on the same terms.
*/
async function awaitTaskQuiescence(
task: Promise<unknown>,
): Promise<QuiescenceOutcome> {
const timer = new AbortController();
const settled = task.then(
() => "QUIESCED" as const,
() => "QUIESCED" as const,
);
const timeout = Promise.resolve()
.then(async () => {
await clock.sleep(limits.quiescenceTimeoutMs, timer.signal);
return "TIMED_OUT" as const;
})
.catch(() =>
timer.signal.aborted
? ("QUIESCED" as const)
: ("TIMER_FAILED" as const),
);
const outcome = await Promise.race([settled, timeout]);
timer.abort();
return outcome;
}
async function awaitQuiescence(
lease: InternalWriterLease<Value>,
): Promise<QuiescenceOutcome> {
@@ -665,7 +720,13 @@ export function createLivePollHandoffCoordinator<Value>(
}
function close(): Promise<RealtimeResult<void>> {
closePromise ??= performClose();
// RT-RR-03. Only a close that is still running is shared. Caching the first
// timeout forever meant a writer that later settled could never be proved
// quiescent: every subsequent close replayed the stale failure and the
// retained registry could never be pruned.
closePromise ??= performClose().finally(() => {
closePromise = null;
});
return closePromise;
}
@@ -673,21 +734,35 @@ export function createLivePollHandoffCoordinator<Value>(
lifecycleGeneration += 1;
state = "CLOSED";
transitioning = true;
// R-03. Current and previously retired writers are waited on together and
// deduplicated, so a writer dropped by an overflow fail-close is still
// proved quiescent before close reports success.
const writers = uniqueLeases([
active,
probe?.lease ?? null,
quiescing,
transitionCandidate,
...retiredWriters,
]);
active = null;
const selectedProbe = probe;
probe = null;
selectedProbe?.buffer.splice(0);
if (selectedProbe) selectedProbe.bufferedBytes = 0;
for (const writer of writers) writer.controller.abort();
const outcomes = await Promise.all(
writers.map(async (writer) => await awaitQuiescence(writer)),
);
for (const writer of writers) {
// RT-RR-03. Every writer this close fences is retained until its tail
// actually settles, so a later close still sees a writer that has not
// finished — and stops seeing it the moment it does.
trackRetiredWriter(writer);
writer.controller.abort();
}
const outcomes = await Promise.all([
...writers.map(async (writer) => await awaitQuiescence(writer)),
// RT-RR-04. Checkpoint work is drained on the same terms as a writer tail.
...[...checkpointTasks].map(
async (task) => await awaitTaskQuiescence(task),
),
]);
quiescing = null;
transitionCandidate = null;
transitioning = false;
@@ -771,20 +846,33 @@ export function createLivePollHandoffCoordinator<Value>(
);
}
/**
* R-03. A fail-close aborts every lease, but the underlying writers may be
* non-cooperative and still running. They move into the retired set before
* their references are cleared, so a later `close()` cannot report success
* while an abandoned writer is still executing.
*/
function failClosed(): void {
lifecycleGeneration += 1;
state = "CLOSED";
transitioning = false;
active?.controller.abort();
probe?.lease.controller.abort();
quiescing?.controller.abort();
transitionCandidate?.controller.abort();
for (const writer of uniqueLeases([
active,
probe?.lease ?? null,
quiescing,
transitionCandidate,
])) {
writer.controller.abort();
trackRetiredWriter(writer);
}
active = null;
if (probe) {
probe.buffer.length = 0;
probe.bufferedBytes = 0;
}
probe = null;
quiescing = null;
transitionCandidate = null;
}
return Object.freeze({
+282 -23
View File
@@ -41,6 +41,27 @@ import {
type RealtimeDataSnapshot,
} from "./result.ts";
/**
* R-02. Lifecycle is orthogonal to freshness. `DRAINING` means the coordinator
* has revoked commit capability and stopped admitting work, but a
* non-cooperative task it started is still running and is deliberately retained
* until it actually settles.
*/
export type RealtimeStreamLifecycle = "OPEN" | "DRAINING" | "CLOSED";
export type RealtimeStreamTaskLimits = Readonly<{
effectTimeoutMs: number;
recoveryTimeoutMs: number;
drainTimeoutMs: number;
}>;
export const DEFAULT_REALTIME_STREAM_TASK_LIMITS: RealtimeStreamTaskLimits =
Object.freeze({
effectTimeoutMs: 5_000,
recoveryTimeoutMs: 10_000,
drainTimeoutMs: 5_000,
});
export type RealtimeStreamCoordinatorDependencies = Readonly<{
registry: RealtimePolicyRegistry;
mappers: Readonly<Record<string, InstalledBoundaryMapper>>;
@@ -48,6 +69,10 @@ export type RealtimeStreamCoordinatorDependencies = Readonly<{
scope: RealtimeScopeSnapshot;
now?: () => number;
observe?: RealtimeEventObservationSink;
taskLimits?: Partial<RealtimeStreamTaskLimits>;
/** Test seam for the bounded task deadline. */
scheduleTimeout?: (callback: () => void, delayMs: number) => unknown;
clearScheduledTimeout?: (handle: unknown) => void;
}>;
export type RealtimeStreamCoordinator = Readonly<{
@@ -66,7 +91,13 @@ export type RealtimeStreamCoordinator = Readonly<{
): RealtimeResult<void>;
getResumeState(streamId: StreamRegistrationId): RealtimeResumeState | null;
inspect(streamId: StreamRegistrationId): RealtimeStreamInspection;
close(): void;
lifecycle(streamId: StreamRegistrationId): RealtimeStreamLifecycle;
/**
* R-02. Bounded quiescence. Success means every tracked task actually
* settled; `IDLE_TIMEOUT` means the coordinator is still `DRAINING` and the
* caller must not assume a clean teardown.
*/
close(): Promise<RealtimeResult<void>>;
}>;
type DedupeEntry = Readonly<{
@@ -96,6 +127,25 @@ type StreamState = {
awaitingTransportBarrier: boolean;
barrierCheckpoint: RealtimeRecoveryCheckpoint | null;
closed: boolean;
lifecycle: RealtimeStreamLifecycle;
/**
* RT-RR-01. Every physical task this coordinator has handed to an external
* authority, from the moment of the call until it settles. Registering only
* after a timeout meant a `close()` that arrived first saw an empty set and
* reported quiescence while the raw task was still running.
*/
retainedTasks: Set<Promise<unknown>>;
/**
* The subset of `retainedTasks` whose public wait already ended. These are
* what keep the stream `DRAINING` and block new admission.
*/
timedOutTasks: Set<Promise<unknown>>;
/**
* RT-RR-02. Set when a task was abandoned at its deadline. The resume token
* is discarded with it, so the next admitted event cannot skip authoritative
* recovery on the strength of state a timed-out effect may have invalidated.
*/
recoveryRequired: boolean;
};
const SNAPSHOT_CHECKPOINT_KEYS = Object.freeze([
@@ -120,9 +170,94 @@ export function createRealtimeStreamCoordinator(
): RealtimeStreamCoordinator {
assertDependencies(dependencies);
const now = dependencies.now ?? Date.now;
const limits: RealtimeStreamTaskLimits = Object.freeze({
...DEFAULT_REALTIME_STREAM_TASK_LIMITS,
...dependencies.taskLimits,
});
const scheduleTimeout =
dependencies.scheduleTimeout ??
((callback: () => void, delayMs: number) => setTimeout(callback, delayMs));
const clearScheduledTimeout =
dependencies.clearScheduledTimeout ??
((handle: unknown) => {
clearTimeout(handle as ReturnType<typeof setTimeout>);
});
const states = new Map<StreamRegistrationId, StreamState>();
let closed = false;
/** RT-02. Releasing a timer is best effort and never a public failure. */
const clearTimerSafely = (handle: unknown): void => {
try {
clearScheduledTimeout(handle);
} catch {
// A broken scheduler cannot change an already classified outcome.
}
};
const TASK_TIMED_OUT = Symbol("REALTIME_TASK_TIMED_OUT");
/**
* R-02. Bounds the public wait without discarding the task. A task that
* outlives its deadline is retained so `close()` can report honestly whether
* the stream is actually quiescent.
*/
async function awaitTaskWithinDeadline<Value>(
state: StreamState,
invoke: () => Promise<Value> | Value,
timeoutMs: number,
revokeAndAbort: () => void,
): Promise<Value | typeof TASK_TIMED_OUT> {
// RT-01. The collaborator is invoked on the next microtask, after the task
// is already in the registry. Calling it first left a window in which an
// authority that re-entered `close()` from inside its own invocation saw an
// empty registry, so `close()` reported quiescence while its effect was
// still running.
const task = Promise.resolve().then(invoke);
task.catch(() => {});
// RT-RR-01. The task is a physical effect the moment it is created, so it
// is registered here rather than when its public wait happens to expire.
state.retainedTasks.add(task);
void task
.catch(() => {})
.finally(() => {
state.retainedTasks.delete(task);
state.timedOutTasks.delete(task);
if (state.timedOutTasks.size === 0 && state.lifecycle === "DRAINING") {
state.lifecycle = state.closed ? "CLOSED" : "OPEN";
if (!state.closed) state.freshness = "STALE";
}
});
// RT-02. A scheduler that cannot install the deadline leaves the wait
// unbounded. Letting the exception escape turned a typed realtime result
// into a native rejection and — through the caller's own catch — started a
// recovery that overlapped the effect still running, so an install failure
// fails closed as an expired deadline instead.
let handle: unknown;
let installed = false;
const timeout = new Promise<typeof TASK_TIMED_OUT>((resolve) => {
try {
handle = scheduleTimeout(() => resolve(TASK_TIMED_OUT), timeoutMs);
installed = true;
} catch {
resolve(TASK_TIMED_OUT);
}
});
const outcome = await Promise.race([task, timeout]);
if (installed) clearTimerSafely(handle);
if (outcome !== TASK_TIMED_OUT) return outcome;
// The commit capability is revoked immediately; the work itself is not.
revokeAndAbort();
state.lifecycle = "DRAINING";
state.timedOutTasks.add(task);
// RT-RR-02. An abandoned task may have applied part of its effect, so the
// resume token it was based on is no longer authoritative evidence.
state.recoveryRequired = true;
state.resumeState = null;
state.freshness = "UNKNOWN";
return TASK_TIMED_OUT;
}
for (const registration of dependencies.registry.listStreams()) {
states.set(registration.id, {
registration,
@@ -143,6 +278,10 @@ export function createRealtimeStreamCoordinator(
awaitingTransportBarrier: false,
barrierCheckpoint: null,
closed: false,
lifecycle: "OPEN",
retainedTasks: new Set(),
timedOutTasks: new Set(),
recoveryRequired: false,
});
}
@@ -158,6 +297,11 @@ export function createRealtimeStreamCoordinator(
realtimeFailure("MALFORMED_EVENT", "RECEIVE"),
);
}
const draining = states.get(event.envelope.streamId);
if (draining && draining.lifecycle === "DRAINING") {
// R-02. New work is refused while a retained task is still running.
return Promise.resolve(realtimeFailure("CLOSED", "RECEIVE"));
}
const state = states.get(event.envelope.streamId);
if (!state) {
return Promise.resolve(
@@ -258,6 +402,10 @@ export function createRealtimeStreamCoordinator(
return realtimeFailure("ABORTED", "RECEIVE");
}
if (closed || state.closed) return dropped("CLOSED");
// RT-RR-02. Admission happened when this event was queued; execution is a
// second decision. A queue entry admitted before the stream entered
// DRAINING must not start running inside it.
if (state.lifecycle === "DRAINING") return dropped("CLOSED");
if (expectedGeneration !== state.processingGeneration) {
return dropped("SCOPE_FENCED");
}
@@ -273,7 +421,11 @@ export function createRealtimeStreamCoordinator(
signal,
);
}
if (state.freshness === "UNKNOWN" || !state.resumeState) {
if (
state.recoveryRequired ||
state.freshness === "UNKNOWN" ||
!state.resumeState
) {
return recoverForAccept(state, "INITIALIZE", true, signal);
}
if (event.envelope.streamEpoch !== state.resumeState.streamEpoch) {
@@ -384,19 +536,36 @@ export function createRealtimeStreamCoordinator(
!effectAbort.signal.aborted &&
scopeIsCurrent(dependencies.scope);
let effect: unknown;
let effectTimedOut = false;
try {
effect = await dependencies.authority.effects.apply(
eventType.effectProfileId,
mapped.value,
Object.freeze({
streamId: state.registration.id,
eventType: eventType.id,
occurredAt: event.envelope.occurredAt,
scopeGeneration: dependencies.scope.generation,
isCurrent: effectIsCurrent,
}),
effectAbort.signal,
const applied = await awaitTaskWithinDeadline(
state,
() =>
dependencies.authority.effects.apply(
eventType.effectProfileId,
mapped.value,
Object.freeze({
streamId: state.registration.id,
eventType: eventType.id,
occurredAt: event.envelope.occurredAt,
scopeGeneration: dependencies.scope.generation,
isCurrent: effectIsCurrent,
}),
effectAbort.signal,
),
limits.effectTimeoutMs,
() => {
// Commit capability is revoked permanently for this attempt.
effectLeaseActive = false;
effectAbort.abort();
},
);
if (applied === TASK_TIMED_OUT) {
effectTimedOut = true;
effect = null;
} else {
effect = applied;
}
} catch {
effect = null;
} finally {
@@ -406,6 +575,17 @@ export function createRealtimeStreamCoordinator(
state.activeEffectAbort = null;
}
}
if (effectTimedOut) {
// R-02. Bounded for the caller; the underlying task stays tracked.
observe({
operation: "APPLY",
outcome: "FAILED",
streamId: state.registration.id,
eventType: event.envelope.eventType,
reason: "IDLE_TIMEOUT",
});
return realtimeFailure("IDLE_TIMEOUT", "APPLY", false);
}
if (closed || state.closed) {
return dropped("CLOSED");
@@ -483,7 +663,7 @@ export function createRealtimeStreamCoordinator(
calledFromCurrentJob: boolean,
signal?: AbortSignal,
): Promise<RealtimeResult<RealtimeRecoveryCheckpoint>> {
if (closed || state.closed) {
if (closed || state.closed || state.lifecycle === "DRAINING") {
return Promise.resolve(realtimeFailure("CLOSED", "RECOVER"));
}
if (!scopeIsCurrent(dependencies.scope)) {
@@ -542,22 +722,49 @@ export function createRealtimeStreamCoordinator(
let recovered: unknown;
let recoveryThrew = false;
let recoveryTimedOut = false;
try {
recovered = await dependencies.authority.recovery.recover(
Object.freeze({
streamId: state.registration.id,
reason,
scopeGeneration: dependencies.scope.generation,
signal: recoveryAbort.signal,
isCurrent: recoveryIsCurrent,
}),
const outcome = await awaitTaskWithinDeadline(
state,
() =>
dependencies.authority.recovery.recover(
Object.freeze({
streamId: state.registration.id,
reason,
scopeGeneration: dependencies.scope.generation,
signal: recoveryAbort.signal,
isCurrent: recoveryIsCurrent,
}),
),
limits.recoveryTimeoutMs,
() => {
recoveryLeaseActive = false;
recoveryAbort.abort();
},
);
if (outcome === TASK_TIMED_OUT) {
recoveryTimedOut = true;
recovered = null;
} else {
recovered = outcome;
}
} catch {
recovered = null;
recoveryThrew = true;
} finally {
recoveryLeaseActive = false;
}
if (recoveryTimedOut) {
// R-02. A late checkpoint from this attempt can never commit.
state.freshness = "UNKNOWN";
observe({
operation: "RECOVER",
outcome: "FAILED",
streamId: state.registration.id,
reason: "IDLE_TIMEOUT",
});
return realtimeFailure("IDLE_TIMEOUT", "RECOVER", false);
}
if (closed || state.closed) {
state.freshness = "UNKNOWN";
return realtimeFailure("CLOSED", "RECOVER");
@@ -631,6 +838,9 @@ export function createRealtimeStreamCoordinator(
validatedResumeState as RealtimeRecoveryCheckpoint;
state.resumeState = resumeState;
// RT-RR-02. Authoritative recovery is the only thing that clears the
// requirement a timed-out task imposed.
state.recoveryRequired = false;
state.awaitingTransportBarrier =
recoveryRequiresTransportBarrier(state.registration);
state.barrierCheckpoint = state.awaitingTransportBarrier
@@ -806,7 +1016,53 @@ export function createRealtimeStreamCoordinator(
});
}
function close(): void {
function lifecycleOf(streamId: StreamRegistrationId): RealtimeStreamLifecycle {
const state = states.get(streamId);
if (!state) return "CLOSED";
return state.lifecycle;
}
/**
* R-02. `close()` fences immediately but reports honestly: success only when
* every retained task actually settled within the drain bound.
*/
async function close(): Promise<RealtimeResult<void>> {
fenceAllStates();
const retained = [...states.values()].flatMap((state) => [
...state.retainedTasks,
]);
if (retained.length === 0) {
for (const state of states.values()) state.lifecycle = "CLOSED";
return realtimeSuccess(undefined);
}
let handle: unknown;
let installed = false;
const drained = await Promise.race([
Promise.allSettled(retained).then(() => true),
new Promise<false>((resolve) => {
try {
handle = scheduleTimeout(
() => resolve(false),
limits.drainTimeoutMs,
);
installed = true;
} catch {
// RT-02. Without a drain bound this call cannot prove quiescence, so
// it reports the honest failure rather than rejecting natively.
resolve(false);
}
}),
]);
if (installed) clearTimerSafely(handle);
if (!drained) {
// Still DRAINING: the caller must not treat this as quiescence.
return realtimeFailure("IDLE_TIMEOUT", "CLOSE", false);
}
for (const state of states.values()) state.lifecycle = "CLOSED";
return realtimeSuccess(undefined);
}
function fenceAllStates(): void {
if (closed) return;
closed = true;
for (const state of states.values()) {
@@ -826,6 +1082,8 @@ export function createRealtimeStreamCoordinator(
state.eventIds.clear();
state.sequences.clear();
state.dedupeBytes = 0;
state.lifecycle =
state.retainedTasks.size > 0 ? "DRAINING" : "CLOSED";
}
observe({
operation: "CLOSE",
@@ -911,6 +1169,7 @@ export function createRealtimeStreamCoordinator(
confirmTransportBarrier,
getResumeState,
inspect,
lifecycle: lifecycleOf,
close,
});
}
@@ -222,10 +222,11 @@ export function decodeWebSocketServerFrame(
if (!isPositiveInteger(maxFrameBytes)) {
return protocolFailure("FRAME_TOO_LARGE");
}
const byteLength = utf8ByteLength(input);
if (byteLength > maxFrameBytes) {
if (exceedsUtf8ByteLimit(input, maxFrameBytes)) {
return protocolFailure("FRAME_TOO_LARGE");
}
// Only an admitted frame pays for the exact length.
const byteLength = utf8ByteLength(input);
if (
hasDuplicateJsonMembers(input, {
maxDepth: MAX_FRAME_STRUCTURE_DEPTH,
@@ -294,10 +295,12 @@ export function encodeWebSocketClientFrame(
} catch {
return protocolFailure("MALFORMED_FRAME");
}
const byteLength = utf8ByteLength(value);
if (byteLength > maxFrameBytes) {
// Reject before allocating the encoded copy; the exact length is only
// computed for a frame that is going to be sent.
if (exceedsUtf8ByteLimit(value, maxFrameBytes)) {
return protocolFailure("FRAME_TOO_LARGE");
}
const byteLength = utf8ByteLength(value);
return Object.freeze({ ok: true, value, byteLength });
}
@@ -466,10 +469,45 @@ function isUnsignedSequence(input: unknown): input is string {
}
}
/**
* R-05. Admission before allocation.
*
* UTF-8 needs at least one byte per UTF-16 code unit, so a string longer than
* the cap is already over it and is rejected without touching an encoder. The
* remainder is counted incrementally with an early exit, so a hostile frame
* never causes a second full-size buffer. A valid surrogate pair counts as four
* bytes and a lone surrogate as the three-byte replacement sequence, exactly
* like `TextEncoder`.
*/
function utf8ByteLength(input: string): number {
return new TextEncoder().encode(input).byteLength;
}
function exceedsUtf8ByteLimit(input: string, maxBytes: number): boolean {
if (input.length > maxBytes) return true;
let bytes = 0;
for (let index = 0; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff) {
const next = index + 1 < input.length ? input.charCodeAt(index + 1) : 0;
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
index += 1;
} else {
// Lone high surrogate: TextEncoder emits U+FFFD.
bytes += 3;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
// Lone low surrogate: TextEncoder emits U+FFFD.
bytes += 3;
} else bytes += 3;
if (bytes > maxBytes) return true;
}
return false;
}
function protocolFailure(
code: WebSocketProtocolFailure["code"],
): WebSocketProtocolResult<never> {
@@ -40,7 +40,8 @@ const scope: WorkerScopeLike = {
open: (name) => caches.open(name),
keys: () => caches.keys(),
delete: (name) => caches.delete(name),
match: (request) => caches.match(request),
// SW-01. No CacheStorage-wide match: only the current release cache may
// answer a verified static request.
},
clients: {
matchAll: (options) =>
@@ -33,7 +33,6 @@ export type WorkerScopeLike = Readonly<{
open(cacheName: string): Promise<Cache>;
keys(): Promise<readonly string[]>;
delete(cacheName: string): Promise<boolean>;
match(request: string): Promise<Response | undefined>;
}>;
clients: Readonly<{
matchAll(
@@ -59,6 +58,34 @@ export type WorkerRuntimeConfig = Readonly<{
releaseManifestUrl: string;
}>;
/**
* SW-URL-01. Root-relative generated URLs become absolute same-origin URLs
* exactly once. Anything that escapes the scope origin is dropped rather than
* silently classified.
*/
function canonicalManifestUrls(
assets: readonly Readonly<{ url: string }>[],
scopeHref: string,
): readonly string[] {
let base: URL;
try {
base = new URL(scopeHref);
} catch {
return [];
}
const canonical: string[] = [];
for (const asset of assets) {
try {
const absolute = new URL(asset.url, base);
if (absolute.origin !== base.origin) continue;
canonical.push(absolute.href);
} catch {
// A manifest URL that cannot be canonicalized is never classified.
}
}
return canonical;
}
const ACTIVATION_MARKER_URL =
"https://clean-architecture.invalid/__service-worker-activation-v1__";
const ACTIVATION_MARKER_MAX_BYTES = 256;
@@ -73,8 +100,22 @@ export function createServiceWorkerRuntime(
config: WorkerRuntimeConfig,
) {
const staticEnabled = config.handlers.includes("PWA_STATIC_ASSETS");
const manifestUrls = new Set(
staticEnabled ? (config.manifest?.assets ?? []).map((asset) => asset.url) : [],
/**
* SW-URL-01. The generated manifest stores root-relative URLs while `Request`
* exposes absolute ones, so comparing the two directly classified every
* verified asset as a network fallback. Canonicalize once against the
* registration scope, re-check same-origin, and share that identity across
* install cache keys, fetch classification and cache lookup or delete.
*/
const manifestUrls: ReadonlySet<string> = Object.freeze(
new Set(
staticEnabled
? canonicalManifestUrls(
config.manifest?.assets ?? [],
scope.registrationScope,
)
: [],
),
);
const consumedNonces = new Set<string>();
type PendingActivation = Readonly<{
@@ -176,17 +217,30 @@ export function createServiceWorkerRuntime(
});
if (classification !== "VERIFIED_CACHE_FIRST") return null;
const cached = await scope.caches.match(request.url);
// SW-01. Only the current release cache may answer. A CacheStorage-wide
// match could return a previous release's response for the same URL, and
// the subsequent delete would then target a cache that was never read.
const currentCacheName = config.manifest
? staticCacheName(config.manifest.setDigest)
: null;
if (!currentCacheName) return null;
// SW-RR-04. Both `open` and `match` are storage calls that can throw
// synchronously or reject. Either one escaping here rejects `respondWith`
// itself, so the entry never reaches its network fallback and the page
// gets a network error instead of the live response.
let cached: Response | undefined;
let currentCache: Cache;
try {
currentCache = await scope.caches.open(currentCacheName);
cached = await currentCache.match(request.url);
} catch {
return null;
}
if (!cached) return null;
if (cached.status !== 200 || cached.type === "opaque") {
// §18.6. An invalid hit is deleted and treated as a release mismatch.
const current = config.manifest
? staticCacheName(config.manifest.setDigest)
: null;
if (current) {
const cache = await scope.caches.open(current);
await cache.delete(request.url).catch(() => false);
}
// §18.6. An invalid hit is deleted from the cache it was read from and
// treated as a release mismatch.
await currentCache.delete(request.url).catch(() => false);
return null;
}
return cached;
@@ -228,49 +282,64 @@ export function createServiceWorkerRuntime(
parsed.message.sourceBuildId,
);
if (!drained) {
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_REJECTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
return "REJECTED";
}
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATE_ACCEPTED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
}
await scope.skipWaiting();
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "ACTIVATED_RELOAD_REQUIRED",
sourceBuildId: config.identity.buildId,
targetBuildId: parsed.message.sourceBuildId,
nonce,
}),
);
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
// any client is told the activation was accepted, and its failure is a
// rejection rather than an accepted-then-failed activation.
try {
await scope.skipWaiting();
} catch {
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
return "REJECTED";
}
// Post-commit notifications are per-client best effort.
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
notifyClients(
clients,
"ACTIVATED_RELOAD_REQUIRED",
parsed.message.sourceBuildId,
nonce,
);
return "ACCEPTED";
}
/**
* SW-08. One client's `postMessage()` throwing must not break the whole
* activation event; delivery is isolated per client.
*/
function notifyClients(
clients: readonly WorkerClientLike[],
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
targetBuildId: string,
nonce: string,
): void {
for (const client of clients) {
try {
client.postMessage(
createServiceWorkerMessage({
kind,
sourceBuildId: config.identity.buildId,
targetBuildId,
nonce,
}),
);
} catch {
// A dead client cannot change the already committed activation.
}
}
}
async function drainClients(
clients: readonly WorkerClientLike[],
nonce: string,
requesterBuildId: string,
): Promise<boolean> {
if (clients.length === 0) return false;
// SW-07. No in-scope client means nothing dirty to drain, so the set is
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
if (clients.length === 0) return true;
const drained = new Promise<boolean>((resolve) => {
const timer = setTimeout(() => {
pendingActivations.delete(nonce);
@@ -287,15 +356,24 @@ export function createServiceWorkerRuntime(
}),
);
});
// SW-08. A client that cannot receive the drain request can never
// acknowledge it, so it fails immediately instead of holding the pending
// state until the timeout.
for (const client of clients) {
client.postMessage(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: config.identity.buildId,
targetBuildId: requesterBuildId,
nonce,
}),
);
try {
client.postMessage(
createServiceWorkerMessage({
kind: "CLIENT_DRAIN_REQUEST",
sourceBuildId: config.identity.buildId,
targetBuildId: requesterBuildId,
nonce,
}),
);
} catch {
const pending = pendingActivations.get(nonce);
if (pending) settlePendingActivation(nonce, pending, false);
return await drained;
}
}
return drained;
}
@@ -347,7 +425,9 @@ export function createServiceWorkerRuntime(
let cachesDeleted = 0;
const names = await scope.caches.keys();
for (const name of names) {
if (!name.startsWith("ca-static-v1-")) continue;
// SW-02. Exact ownership only: a prefix match would also delete
// `ca-static-v1-not-owned` and any longer-suffixed foreign cache.
if (!isOwnedStaticCacheName(name)) continue;
try {
if (await scope.caches.delete(name)) cachesDeleted += 1;
} catch {
@@ -399,6 +479,141 @@ function isClientWithinRegistrationScope(
}
}
/**
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
* reader is also raced against a deadline so a stream that never produces a
* chunk cannot hold `activate` open.
*/
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
async function readBoundedMarkerText(
response: Response,
): Promise<string | null> {
if (!response.body) {
try {
const text = await response.text();
return new TextEncoder().encode(text).byteLength >
ACTIVATION_MARKER_MAX_BYTES
? null
: text;
} catch {
return null;
}
}
// SW-01. The allowance is `limit + 1` bytes: one byte past the ceiling is
// enough to prove the marker is oversized, and nothing beyond it is ever
// retained. A BYOB reader asks the source for exactly the remaining
// allowance, so a corrupt body cannot answer a 1 MiB chunk to a 257-byte
// request. Without BYOB the first oversized chunk is refused outright rather
// than copied and then measured.
const allowance = ACTIVATION_MARKER_MAX_BYTES + 1;
const reader = byobReader(response.body) ?? response.body.getReader();
const byob = "read" in reader && isByobReader(reader);
const bytes = new Uint8Array(allowance);
let total = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
const deadline = new Promise<"DEADLINE">((resolve) => {
try {
timer = setTimeout(
() => resolve("DEADLINE"),
ACTIVATION_MARKER_READ_DEADLINE_MS,
);
} catch {
// An unschedulable deadline leaves the read unbounded, so it ends now.
resolve("DEADLINE");
}
});
const cancelOnce = (): void => {
if (cancelled) return;
cancelled = true;
// Never awaited: cancelling a stream whose source ignores cancellation can
// itself hang, and the marker read already has its answer.
try {
void reader.cancel().catch(() => {});
} catch {
// A hostile reader cannot block the release below.
}
};
try {
for (;;) {
const remaining = allowance - total;
if (remaining <= 0) {
// More than the ceiling has already arrived.
return null;
}
const pending = byob
? (reader as ReadableStreamBYOBReader).read(
new Uint8Array(remaining),
)
: (reader as ReadableStreamDefaultReader<Uint8Array>).read();
const next = await Promise.race([pending, deadline]);
if (next === "DEADLINE") {
cancelOnce();
return null;
}
if (next.done) break;
const chunk = next.value;
if (!chunk) continue;
if (chunk.byteLength > remaining) {
// Refused before it is retained: the oversized chunk is not copied.
cancelOnce();
return null;
}
bytes.set(chunk, total);
total += chunk.byteLength;
}
} catch {
cancelOnce();
return null;
} finally {
if (timer !== undefined) {
try {
clearTimeout(timer);
} catch {
// Releasing the timer is best effort.
}
}
cancelOnce();
try {
reader.releaseLock();
} catch {
// A cancelled reader has already released its lock.
}
}
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
try {
return new TextDecoder("utf-8", { fatal: true }).decode(
bytes.subarray(0, total),
);
} catch {
return null;
}
}
/** A byte stream can hand out a BYOB reader; a regular one cannot. */
function byobReader(
body: ReadableStream<Uint8Array>,
): ReadableStreamBYOBReader | null {
try {
return (
body as ReadableStream<Uint8Array> & {
getReader(options: { mode: "byob" }): ReadableStreamBYOBReader;
}
).getReader({ mode: "byob" });
} catch {
return null;
}
}
function isByobReader(reader: unknown): reader is ReadableStreamBYOBReader {
return (
typeof ReadableStreamBYOBReader === "function" &&
reader instanceof ReadableStreamBYOBReader
);
}
async function readActivationMarker(
cache: Cache,
expectedCacheName: string,
@@ -412,12 +627,21 @@ async function readActivationMarker(
(!/^\d+$/u.test(declaredLength) ||
Number(declaredLength) > ACTIVATION_MARKER_MAX_BYTES)
) {
// SW-01. A declared oversize ends the read, but the body it declared is
// still an open stream: returning without cancelling it left the source
// holding the connection for the rest of the worker's life.
try {
void response.body?.cancel().catch(() => {});
} catch {
// Cancelling is best effort and cannot change the closed outcome.
}
return null;
}
const text = await response.text();
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
return null;
}
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
// previous `response.text()` read the whole body, so a large or
// non-terminating stream could consume the activation step indefinitely.
const text = await readBoundedMarkerText(response);
if (text === null) return null;
const value: unknown = JSON.parse(text);
if (
value === null ||
@@ -2,6 +2,7 @@ import {
SERVICE_WORKER_BOUNDS,
type InstalledServiceWorkerSelection,
type ServiceWorkerActivationOutcome,
type ServiceWorkerRemovalOutcome,
type ServiceWorkerResetOutcome,
type ServiceWorkerRuntimeHost,
type ServiceWorkerStartOutcome,
@@ -57,6 +58,9 @@ export function createServiceWorkerPageController(
let registration: ServiceWorkerRegistration | null = null;
let messageListener: ((event: MessageEvent) => void) | null = null;
let updateTimer: ReturnType<typeof setInterval> | null = null;
/** SW-06. Single-flight command state. */
let activationInFlight: Promise<ServiceWorkerActivationOutcome> | null = null;
let resetInFlight: Promise<ServiceWorkerResetOutcome> | null = null;
let stopped = false;
const pendingStops = new Set<() => void>();
@@ -75,6 +79,29 @@ export function createServiceWorkerPageController(
return false;
}
/**
* SW-04. Staged removal reports what actually happened.
*
* Returning DISABLED for every outcome let a later release delete the worker
* source and handlers while a registration or an owned cache was still
* present, or while the registration belonged to someone else.
*/
function removalStartOutcome(
outcome: ServiceWorkerRemovalOutcome,
failureReason: string,
): ServiceWorkerStartOutcome {
switch (outcome.kind) {
case "ABSENT":
case "UNREGISTERED":
case "PURGED":
return Object.freeze({ kind: "DISABLED" as const });
case "OWNERSHIP_MISMATCH":
return Object.freeze({ kind: "INCOMPATIBLE" as const });
case "FAILED":
return failed(failureReason);
}
}
async function start(): Promise<ServiceWorkerStartOutcome> {
if (stopped) return failed("STOPPED");
const container = dependencies.container;
@@ -90,8 +117,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("disable_cleanup", outcome.kind);
if (outcome.kind === "FAILED") return failed("DISABLE_CLEANUP_FAILED");
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "DISABLE_CLEANUP_FAILED");
}
const selection = dependencies.selection;
@@ -108,7 +134,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("remove_registration", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "REMOVE_FAILED");
}
if (selection.mode === "PURGE_OWNED_RESOURCES") {
const outcome = await purgeOwnedResources({
@@ -118,7 +144,7 @@ export function createServiceWorkerPageController(
origin: dependencies.origin,
});
observe("purge_owned_resources", outcome.kind);
return Object.freeze({ kind: "DISABLED" as const });
return removalStartOutcome(outcome, "PURGE_FAILED");
}
// §17.5. StrictMode's repeated effect returns the same in-flight promise
@@ -194,6 +220,13 @@ export function createServiceWorkerPageController(
observe("client_drain", "MALFORMED");
return;
}
// SW-06. An arbitrary same-origin source must not be able to close this
// page's admission. The request has to come from the worker we are
// actually waiting on or the one currently controlling us.
if (!isExpectedWorkerSource(source)) {
observe("client_drain", "SOURCE_MISMATCH");
return;
}
const rejected = isBlocked();
source.postMessage(
createServiceWorkerMessage({
@@ -211,6 +244,23 @@ export function createServiceWorkerPageController(
container.addEventListener("message", messageListener);
}
/**
* SW-06. Source identity is checked by object identity against the
* registration's waiting/installing/active worker and the container's
* controller. An empty `event.origin` is never used as a trust signal.
*/
function isExpectedWorkerSource(source: unknown): boolean {
const expected = [
registration?.waiting,
registration?.installing,
registration?.active,
dependencies.container?.controller,
];
return expected.some(
(candidate) => candidate != null && candidate === source,
);
}
function scheduleUpdateChecks(): void {
// §17.14. At most one check per 6 hours, and none while the page is hidden.
if (updateTimer) return;
@@ -228,7 +278,16 @@ export function createServiceWorkerPageController(
* §17.11. Activation is a handshake: every controlled client must close new
* admission and acknowledge within 30s. One missing client rejects it.
*/
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
// SW-06. Concurrent callers share one command: a second call must not issue
// a second nonce, a second listener or a second postMessage.
activationInFlight ??= runActivation().finally(() => {
activationInFlight = null;
});
return activationInFlight;
}
async function runActivation(): Promise<ServiceWorkerActivationOutcome> {
const waiting = registration?.waiting;
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
if (isBlocked()) {
@@ -264,6 +323,18 @@ export function createServiceWorkerPageController(
) {
return;
}
// SW-06 / SW-RR-02. The reply must come from the exact worker this
// request was sent to. A matching nonce is not identity: `null`
// source means the sender cannot be established, so it is refused
// like any other mismatch rather than accepted as this worker.
if (event.source !== waiting) {
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
return;
}
if (registration?.waiting !== waiting) {
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
return;
}
if (
parsed.message.kind === "ACTIVATE_REJECTED" &&
parsed.message.nonce === nonce
@@ -306,11 +377,21 @@ export function createServiceWorkerPageController(
}
/** §18.10. Static caches only; the registration itself is left in place. */
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
// SW-06. Single-flight, like activation.
resetInFlight ??= runReset().finally(() => {
resetInFlight = null;
});
return resetInFlight;
}
async function runReset(): Promise<ServiceWorkerResetOutcome> {
const container = dependencies.container;
if (!container?.controller) {
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
}
// SW-06. The reply must come from the controller this request was sent to.
const requestedController = container.controller;
const nonce = nonces.issue();
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
let settled = false;
@@ -334,6 +415,20 @@ export function createServiceWorkerPageController(
) {
return;
}
// SW-RR-02. An unattributable reset result is never proof this
// controller performed the reset.
if (
event.source !== requestedController ||
container.controller !== requestedController
) {
finish(
Object.freeze({
kind: "FAILED" as const,
code: "PROTOCOL_MISMATCH",
}),
);
return;
}
finish(
Object.freeze({
kind: "RESET" as const,
@@ -109,14 +109,24 @@ export async function removeOwnedRegistration(
) {
return Object.freeze({ kind: "OWNERSHIP_MISMATCH" as const });
}
let unregistered: boolean;
try {
await registration.unregister();
unregistered = await registration.unregister();
} catch {
return Object.freeze({
kind: "FAILED" as const,
operation: "UNREGISTER" as const,
});
}
// SW-03. `unregister()` resolving is not success: `false` means the
// registration is still installed, so reporting UNREGISTERED would let a
// later release delete the worker source while it is still controlling.
if (!unregistered) {
return Object.freeze({
kind: "FAILED" as const,
operation: "UNREGISTER" as const,
});
}
return Object.freeze({ kind: "UNREGISTERED" as const });
}
@@ -149,6 +149,19 @@ export async function installStaticAssets(
if (outcome.kind === "REJECTED") {
await dependencies.caches.delete(cacheName).catch(() => false);
// SW-09. A non-cooperative fetch, digest or `cache.put` started before the
// deadline cannot be cancelled, so it may recreate the candidate cache
// after that delete. The public result already closed at the deadline; a
// second exact delete is registered once the abandoned work settles. It is
// deliberately not awaited, so the public bound is not extended.
if (deadlineExceeded) {
void installation
.catch(() => undefined)
.then(async () => {
await dependencies.caches.delete(cacheName).catch(() => false);
})
.catch(() => undefined);
}
}
return outcome;
}
@@ -173,6 +186,11 @@ async function installCandidate(
const worker = async (): Promise<void> => {
for (;;) {
if (failure) return;
// SW-09. Once fenced, no new candidate work is started.
if (signal.aborted) {
failure ??= rejected("INSTALL_DEADLINE_EXCEEDED");
return;
}
const asset = queue.shift();
if (!asset) return;
const outcome = await storeAsset(asset, cache, dependencies, signal);
@@ -205,15 +223,22 @@ async function storeAsset(
if (signal.aborted) return rejected("FETCH_FAILED");
let response: Response;
try {
const fetched = await abortable(
dependencies.fetcher(asset.url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal,
}),
// SW-09. A non-cooperative fetch that ignores the signal still settles
// later; its body is compensated so an abandoned response is not left open.
const pending = dependencies.fetcher(asset.url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal,
);
});
const fetched = await abortable(pending, signal);
if (fetched === ABORTED) {
void pending
.then(async (late) => {
await late.body?.cancel();
})
.catch(() => undefined);
}
if (fetched === ABORTED) return rejected("FETCH_FAILED");
response = fetched;
} catch {
@@ -234,7 +259,17 @@ async function storeAsset(
if (!body.ok) return rejected(body.code);
const bytes = body.bytes;
const digest = await abortable(dependencies.digest(bytes), signal);
// SW-09. A digest dependency that throws becomes a closed typed outcome
// rather than an escaping rejection.
let digest: string | typeof ABORTED;
try {
digest = await abortable(
Promise.resolve(dependencies.digest(bytes)),
signal,
);
} catch {
return rejected("INTEGRITY_MISMATCH");
}
if (digest === ABORTED) return rejected("FETCH_FAILED");
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
@@ -972,6 +972,7 @@ export function createIndexedDbMaintenance<WireValue>(
prepared: readonly PreparedRecord<WireValue>[],
scan: ScanBatch,
budgetExhausted: boolean,
deadline: number,
signal: AbortSignal | undefined,
): Promise<
BrowserDataResult<IndexedDbMaintenanceBatchReceipt>
@@ -1048,6 +1049,22 @@ export function createIndexedDbMaintenance<WireValue>(
finishWithCheckpoint();
return;
}
// STO-06. The commit chain runs entirely inside IndexedDB callbacks,
// so without this check up to `maxRows` records keep executing past
// the caller's invocation deadline. The budget is only ever checked
// before a record's first write, so a started record still finishes
// atomically and the checkpoint stays exactly at the last safe key.
const currentTime = clock();
if (!currentTime.ok) {
// A broken clock aborts rather than committing an unbounded batch.
context.fail(currentTime);
return;
}
if (currentTime.value >= deadline) {
budgetExhausted = true;
finishWithCheckpoint();
return;
}
let request: IDBRequest<unknown>;
try {
request = records.get(preparedRecord.source.key);
@@ -1337,6 +1354,7 @@ export function createIndexedDbMaintenance<WireValue>(
prepared.value.records,
scan.value,
prepared.value.budgetExhausted,
deadline,
input.signal,
);
return observeResult(
@@ -1650,12 +1650,26 @@ function isChunkReference(
);
}
/**
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
* window; v2 additionally carries a transaction-unique physical fencing token.
*/
function isSupportedPhysicalSchema(value: object): boolean {
const record = value as Record<string, unknown>;
if (record.physicalSchemaVersion === 1) return true;
return (
record.physicalSchemaVersion === 2 &&
typeof record.physicalGenerationId === "string" &&
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
);
}
function isPreparedObject(value: unknown): value is OpfsPreparedObject {
if (
!value ||
typeof value !== "object" ||
!("physicalSchemaVersion" in value) ||
value.physicalSchemaVersion !== 1 ||
!isSupportedPhysicalSchema(value) ||
!("descriptor" in value) ||
!value.descriptor ||
typeof value.descriptor !== "object" ||
@@ -10,6 +10,7 @@ import type {
OpfsReconciliationReport,
OpfsStorageScope,
PutDurableObjectRequest,
OpfsPhysicalGenerationId,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import {
type BrowserDataFailure,
@@ -89,6 +90,12 @@ export type OpfsByteStoreDependencies = Readonly<{
storagePolicy: BrowserStoragePolicy;
policy?: Partial<OpfsRuntimePolicy>;
createTransactionId?: () => string;
createPhysicalGenerationId?: () => OpfsPhysicalGenerationId;
/**
* Composition-owned bounded signal for compensating cleanup. It is
* deliberately separate from any caller signal.
*/
compensationSignal?: AbortSignal;
now?: () => number;
observer?: OpfsSafeObserver;
/**
@@ -130,6 +137,20 @@ export function createOpfsByteStoreAdapter(
const createTransactionId =
dependencies.createTransactionId ??
(() => globalThis.crypto.randomUUID());
const createPhysicalGenerationId =
dependencies.createPhysicalGenerationId ??
(() => {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
let hex = "";
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
return hex as OpfsPhysicalGenerationId;
});
/**
* STO-01. Compensation must not inherit the caller's already aborted signal;
* a cleanup that never starts cannot justify releasing the journal row.
*/
const compensationSignal = dependencies.compensationSignal;
const now = dependencies.now ?? Date.now;
const objects: DurableObjectStorePort = Object.freeze({
@@ -182,6 +203,10 @@ export function createOpfsByteStoreAdapter(
}
const targetGeneration = (current?.descriptor.generation ?? 0) + 1;
const transactionId = createTransactionId();
// STO-01. The logical generation is reused across transactions; this
// token makes the physical target unique so a late compensation can never
// delete a newer transaction's directory.
const physicalGenerationId = createPhysicalGenerationId();
notifyProgress(request, "PREPARING", 0);
const begun = await dependencies.journal.begin({
transactionId,
@@ -214,13 +239,24 @@ export function createOpfsByteStoreAdapter(
});
const prepared = await dependencies.worker.preparePut({
transactionId,
physicalGenerationId,
descriptor,
source: request.source,
signal: request.signal,
onProgress: request.onProgress,
});
if (!prepared.ok) {
await rollbackBestEffort(begun.value, request.signal);
const compensated = await compensatePreparedPut(
begun.value,
physicalGenerationId,
);
if (!compensated.ok) {
return observeFailure(
compensated,
dependencies.observer,
request.source.byteLength!,
);
}
return observeFailure(
prepared,
dependencies.observer,
@@ -234,7 +270,17 @@ export function createOpfsByteStoreAdapter(
prepared.value,
);
if (!filesReady.ok) {
await rollbackBestEffort(begun.value, request.signal);
const compensated = await compensatePreparedPut(
begun.value,
physicalGenerationId,
);
if (!compensated.ok) {
return observeFailure(
compensated,
dependencies.observer,
request.source.byteLength!,
);
}
return observeFailure(
rebaseFailure(filesReady.error, "OBJECT_WRITE"),
dependencies.observer,
@@ -266,12 +312,45 @@ export function createOpfsByteStoreAdapter(
prepared.value,
request.signal,
);
if (finalized.ok) {
await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
if (!finalized.ok) {
// STO-RR-01. The commit fence already passed, so the payload is durable
// and the journal keeps its COMMITTED record for reconciliation to
// settle. What did not happen is finalization: the previous generation
// and the staging directory are still present. Reporting a plain
// success here would claim a settled state nobody observed, so the
// worker's own failure is surfaced and the record is left recoverable.
return observeFailure(
rebaseFailure(finalized.error, "OBJECT_WRITE"),
dependencies.observer,
request.source.byteLength!,
);
}
const completed = await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
);
if (!completed.ok) {
// NS-04. The payload is durable and finalized, so nothing is rolled
// back and the `COMMITTED` row stays the reconciler's authority. What
// did not happen is settling the transaction, and reporting a plain
// success for it claimed a state nobody observed while the reconcile
// backlog and its quota pressure grew unseen.
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_WRITE",
outcome: "DEGRADED",
failureCode: completed.error.code,
byteBucket: byteBucket(prepared.value.descriptor.byteLength),
});
return {
ok: false as const,
error: Object.freeze({
...completed.error,
operation: "OBJECT_WRITE" as const,
retryable: true,
recovery: "RECONCILE" as const,
}),
};
}
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_WRITE",
outcome: "SUCCEEDED",
@@ -438,17 +517,27 @@ export function createOpfsByteStoreAdapter(
request.expectedGeneration,
request.signal,
);
if (removed.ok) {
await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
);
}
const completed = removed.ok
? await dependencies.journal.complete(
transactionId,
begun.value.fencingToken,
)
: null;
// Logical deletion is already committed. Physical cleanup is retryable
// maintenance and must not make the caller repeat a non-idempotent delete.
// NS-04. It is still not a settled state: an unfinished physical removal
// or an unsettled journal row is maintenance debt the reconciler owns, so
// it is observed as such instead of as a clean success.
const settled = removed.ok && completed !== null && completed.ok;
const debtCode = removed.ok
? completed !== null && !completed.ok
? completed.error.code
: undefined
: removed.error.code;
observeOpfsSafely(dependencies.observer, {
operation: "OBJECT_DELETE",
outcome: "SUCCEEDED",
outcome: settled ? "SUCCEEDED" : "DEGRADED",
...(debtCode ? { failureCode: debtCode } : {}),
});
return browserDataSuccess(undefined);
},
@@ -830,19 +919,38 @@ export function createOpfsByteStoreAdapter(
return Object.freeze({ objects, maintenance });
async function rollbackBestEffort(
/**
* STO-01. The compensating half of the put saga.
*
* The journal row and its budget reservation are the only durable evidence
* that a physical staging generation may still exist, so they are released
* exactly when the physical effect is confirmed `CLEANED` or
* `ALREADY_CLEAN`. A timeout, crash, malformed response or `EFFECT_UNKNOWN`
* keeps `PREPARING`/`FILES_READY` in place and asks for reconciliation.
*/
async function compensatePreparedPut(
transaction: OpfsJournalTransaction,
signal: AbortSignal | undefined,
): Promise<void> {
await dependencies.worker.cleanupTransaction(
transaction.scope,
transaction.transactionId,
signal,
);
await dependencies.journal.rollback(
physicalGenerationId: OpfsPhysicalGenerationId,
): Promise<BrowserDataResult<void>> {
const cleanup = await dependencies.worker.abortPreparedPut({
scope: transaction.scope,
transactionId: transaction.transactionId,
physicalGenerationId,
...(compensationSignal ? { signal: compensationSignal } : {}),
});
if (!cleanup.ok || cleanup.value.kind === "EFFECT_UNKNOWN") {
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
recovery: "RETRY",
});
}
const rolledBack = await dependencies.journal.rollback(
transaction.transactionId,
transaction.fencingToken,
);
if (!rolledBack.ok) {
return rebaseFailure(rolledBack.error, "OBJECT_RECONCILE");
}
return browserDataSuccess(undefined);
}
async function reconcileTransaction(
@@ -860,6 +968,11 @@ export function createOpfsByteStoreAdapter(
signal,
);
if (!cleaned.ok) return cleaned;
if (cleaned.value.kind === "EFFECT_UNKNOWN") {
return browserDataFailure("CONFLICT", "OBJECT_RECONCILE", {
recovery: "RETRY",
});
}
const rolledBack = await dependencies.journal.rollback(
transaction.transactionId,
transaction.fencingToken,
@@ -1292,6 +1405,7 @@ function snapshotOpfsWorker(
openObject,
removeObject,
cleanupTransaction,
abortPreparedPut,
finalizePut,
listOrphanCandidates,
deleteOrphanChunk,
@@ -1305,6 +1419,7 @@ function snapshotOpfsWorker(
openObject,
removeObject,
cleanupTransaction,
abortPreparedPut,
finalizePut,
listOrphanCandidates,
deleteOrphanChunk,
@@ -1320,6 +1435,7 @@ function snapshotOpfsWorker(
openObject: openObject.bind(source),
removeObject: removeObject.bind(source),
cleanupTransaction: cleanupTransaction.bind(source),
abortPreparedPut: abortPreparedPut.bind(source),
finalizePut: finalizePut.bind(source),
listOrphanCandidates: listOrphanCandidates.bind(source),
deleteOrphanChunk: deleteOrphanChunk.bind(source),
+5 -1
View File
@@ -26,7 +26,11 @@ export type OpfsRuntimePolicy = Readonly<{
export type OpfsSafeObservation = Readonly<{
operation: BrowserDataOperation;
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
/**
* NS-04. `DEGRADED` is a committed effect whose bookkeeping is unsettled:
* the payload is durable but a reconciler still owns the transaction.
*/
outcome: "STARTED" | "SUCCEEDED" | "FAILED" | "DEGRADED";
failureCode?: BrowserDataFailureCode;
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "16MiB_256MiB" | "GT_256MiB";
transactionBucket?: "0" | "1_10" | "11_100" | "GT_100";
+245 -59
View File
@@ -1,19 +1,25 @@
import type {
OpfsCapabilities,
OpfsCleanupEffect,
OpfsPreparedObject,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
import type {
BrowserDataFailureCode,
BrowserDataOperation,
BrowserDataResult,
ByteSource,
import {
isBrowserDataFailureCode,
type BrowserDataFailureCode,
type BrowserDataOperation,
type BrowserDataResult,
type ByteSource,
} from "../../../application/ports/browser-file-storage/shared.ts";
import {
browserDataFailure,
browserDataSuccess,
} from "../../browser-file-storage/result.ts";
import type { OpfsRuntimePolicy } from "./opfs-policy.ts";
import {
OPFS_WORKER_PROTOCOL_VERSION,
} from "./opfs-worker-protocol.ts";
import type {
AbortPreparedPutRequest,
OpfsWorkerGateway,
OpfsOrphanCandidateBatch,
OpfsOrphanDeleteResult,
@@ -49,6 +55,7 @@ export type OwnedOpfsWorkerClient = Readonly<{
}>;
type PendingRequest = Readonly<{
expectedKind: OpfsWorkerRequest["kind"];
resolve: (response: OpfsWorkerResponse) => void;
reject: (error: OpfsRpcError) => void;
timeout: ReturnType<typeof setTimeout>;
@@ -74,24 +81,49 @@ export function createOpfsWorkerGateway(
const pending = new Map<string, PendingRequest>();
let disposed = false;
const rejectAllPending = (): void => {
const rejectAllPending = (
code: "UNAVAILABLE" | "UNSUPPORTED" = "UNAVAILABLE",
): void => {
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.removeAbortListener();
request.reject(new OpfsRpcError("UNAVAILABLE"));
request.reject(new OpfsRpcError(code));
}
pending.clear();
};
const onMessage = (event: MessageEvent<unknown>): void => {
if (disposed) return;
if (!isWorkerResponse(event.data)) return;
const request = pending.get(event.data.requestId);
const correlation = readCorrelation(event.data);
if (correlation === IGNORE_MESSAGE) return;
if (correlation === UNREADABLE_CORRELATION) {
// NS-06. A reply whose correlation cannot even be read is a protocol
// breach on the only channel this client has. Ignoring it left every
// in-flight request to expire on the RPC timer, so the whole channel
// fails closed promptly instead.
rejectAllPending("UNSUPPORTED");
return;
}
const request = pending.get(correlation);
if (!request) return;
pending.delete(event.data.requestId);
// STO-07 / STO-RR-03. A reply is decoded, never adopted. A different
// operation, an unknown kind, an unknown failure code, an inherited or
// extra field and a hostile accessor are all protocol breaches, and each
// closes the request rather than leaving it to time out.
// UNSUPPORTED is the closed-taxonomy code for "this runtime cannot serve
// this"; no new failure code is invented.
// NS-06. The decode happens before the pending row and its timer are
// released: releasing them first meant a trap that threw inside the decoder
// left the public promise pending with nothing left to time it out.
const decoded = decodeWorkerResponse(event.data, request.expectedKind);
pending.delete(correlation);
clearTimeout(request.timeout);
request.removeAbortListener();
request.resolve(event.data);
if (decoded === null) {
request.reject(new OpfsRpcError("UNSUPPORTED"));
return;
}
request.resolve(decoded);
};
const onWorkerFailure = (): void => {
if (disposed) return;
@@ -119,7 +151,11 @@ export function createOpfsWorkerGateway(
) {
throw new OpfsRpcError("UNAVAILABLE");
}
const message = { ...request, requestId } as OpfsWorkerRequest;
const message = {
...request,
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
} as OpfsWorkerRequest;
return await new Promise<OpfsWorkerResponse>((resolve, reject) => {
const abort = (): void => {
@@ -139,6 +175,9 @@ export function createOpfsWorkerGateway(
reject(new OpfsRpcError("UNAVAILABLE"));
}, dependencies.policy.rpcTimeoutMs);
pending.set(requestId, {
// STO-07. The expected kind is stored so a reply for a different
// operation can never satisfy this request.
expectedKind: request.kind,
resolve,
reject,
timeout,
@@ -187,17 +226,6 @@ export function createOpfsWorkerGateway(
}
}
async function abortAndCleanup(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
): Promise<void> {
try {
await rpc({ kind: "ABORT_PUT", scope, transactionId });
} catch {
// Journal reconciliation repeats cleanup after a crash or timeout.
}
}
return Object.freeze({
async capabilities() {
return await invoke(
@@ -215,6 +243,7 @@ export function createOpfsWorkerGateway(
{
kind: "BEGIN_PUT",
transactionId: request.transactionId,
physicalGenerationId: request.physicalGenerationId,
scope: request.descriptor.scope,
objectId: request.descriptor.objectId,
generation: request.descriptor.generation,
@@ -227,10 +256,8 @@ export function createOpfsWorkerGateway(
request.signal,
);
if (!begin.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
// STO-01. Compensation belongs to the coordinator: it owns the journal
// row this cleanup would otherwise invalidate.
return begin;
}
@@ -257,22 +284,12 @@ export function createOpfsWorkerGateway(
request.signal,
[chunk],
);
if (!append.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return append;
}
if (!append.ok) return append;
sequence += 1;
transferredBytes += chunkByteLength;
notifyProgress(request, "TRANSFERRING", transferredBytes);
}
if (transferredBytes !== request.descriptor.byteLength) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return browserDataFailure("INTEGRITY_FAILED", "OBJECT_WRITE", {
recovery: "RESELECT",
});
@@ -289,18 +306,8 @@ export function createOpfsWorkerGateway(
[],
parsePreparedObject,
);
if (!finished.ok) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
}
return finished;
} catch (error) {
await abortAndCleanup(
request.descriptor.scope,
request.transactionId,
);
return failureResult(
error instanceof OpfsRpcError ? error.code : "NOT_READABLE",
"OBJECT_WRITE",
@@ -387,10 +394,32 @@ export function createOpfsWorkerGateway(
transactionId: string,
signal?: AbortSignal,
) {
return await invoke<void>(
return await invoke(
"OBJECT_RECONCILE",
{ kind: "CLEANUP_TRANSACTION", scope, transactionId },
signal,
[],
parseCleanupEffect,
);
},
/**
* STO-01. The coordinator's single compensation entry point. An RPC that
* times out or fails leaves the physical effect unknown, which is never a
* success and must not release journal or budget state.
*/
async abortPreparedPut(request: AbortPreparedPutRequest) {
return await invoke(
"OBJECT_RECONCILE",
{
kind: "ABORT_PUT",
scope: request.scope,
transactionId: request.transactionId,
physicalGenerationId: request.physicalGenerationId,
},
request.signal,
[],
parseCleanupEffect,
);
},
@@ -610,12 +639,34 @@ function parseCapabilities(value: unknown): OpfsCapabilities | null {
return value as OpfsCapabilities;
}
/**
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
* window; v2 additionally carries a transaction-unique physical fencing token.
*/
function isSupportedPhysicalSchema(value: object): boolean {
const record = value as Record<string, unknown>;
if (record.physicalSchemaVersion === 1) return true;
return (
record.physicalSchemaVersion === 2 &&
typeof record.physicalGenerationId === "string" &&
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
);
}
function parseCleanupEffect(value: unknown): OpfsCleanupEffect | null {
if (!value || typeof value !== "object") return null;
const kind = (value as Record<string, unknown>).kind;
return kind === "CLEANED" || kind === "ALREADY_CLEAN"
? Object.freeze({ kind })
: null;
}
function parsePreparedObject(value: unknown): OpfsPreparedObject | null {
if (
!value ||
typeof value !== "object" ||
!("physicalSchemaVersion" in value) ||
value.physicalSchemaVersion !== 1 ||
!isSupportedPhysicalSchema(value) ||
!("descriptor" in value) ||
!("chunks" in value) ||
!Array.isArray(value.chunks)
@@ -663,13 +714,148 @@ function parseOrphanDeleteResult(
return value as OpfsOrphanDeleteResult;
}
function isWorkerResponse(value: unknown): value is OpfsWorkerResponse {
return Boolean(
value &&
typeof value === "object" &&
"requestId" in value &&
typeof value.requestId === "string" &&
"ok" in value &&
typeof value.ok === "boolean",
);
/**
* STO-07 / STO-RR-03. A response is admitted only when every field survives an
* exact own-data decode: the negotiated protocol version, the exact request
* kind this call is waiting for and, on failure, a code inside the closed
* `BrowserDataFailure` taxonomy with a boolean `retryable`.
*
* The decoder returns a fresh frozen value, so a worker that mutates its own
* message object after posting it cannot change what the caller already read.
*/
const WORKER_RESPONSE_KEYS: ReadonlySet<string> = new Set([
"requestId",
"protocolVersion",
"kind",
"ok",
"value",
"failure",
]);
const WORKER_FAILURE_KEYS: ReadonlySet<string> = new Set(["code", "retryable"]);
/** Reads one own data property, treating an accessor or a trap as absent. */
function ownField(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;
}
}
function ownStringField(source: unknown, key: string): string | null {
const value = ownField(source, key);
return typeof value === "string" && value.length > 0 ? value : null;
}
/** Not a reply at all: nothing on this channel is waiting for it. */
const IGNORE_MESSAGE = Symbol("opfs-ignore-message");
/** A reply whose correlation could not be read without running foreign code. */
const UNREADABLE_CORRELATION = Symbol("opfs-unreadable-correlation");
function readCorrelation(
source: unknown,
): string | typeof IGNORE_MESSAGE | typeof UNREADABLE_CORRELATION {
if (source === null || typeof source !== "object") return IGNORE_MESSAGE;
let descriptor: PropertyDescriptor | undefined;
try {
descriptor = Object.getOwnPropertyDescriptor(source, "requestId");
} catch {
return UNREADABLE_CORRELATION;
}
if (!descriptor) return IGNORE_MESSAGE;
// An accessor would have to be invoked to be read, and invoking foreign code
// to find out who a message belongs to is exactly what must not happen.
if (!("value" in descriptor)) return UNREADABLE_CORRELATION;
const value = descriptor.value;
return typeof value === "string" && value.length > 0 && value.length <= 128
? value
: UNREADABLE_CORRELATION;
}
function hasOnlyOwnDataKeys(
source: object,
allowed: ReadonlySet<string>,
): boolean {
try {
if (Object.getOwnPropertySymbols(source).length > 0) return false;
for (const key of Object.getOwnPropertyNames(source)) {
if (!allowed.has(key)) return false;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) return false;
}
return true;
} catch {
return false;
}
}
function decodeWorkerResponse(
value: unknown,
expectedKind: OpfsWorkerRequest["kind"],
): OpfsWorkerResponse | null {
// NS-06. Total by construction: every reflection operation below can be
// trapped, and a decoder that throws would strand the request it was
// decoding rather than closing it.
try {
return decodeWorkerResponseUnguarded(value, expectedKind);
} catch {
return null;
}
}
function decodeWorkerResponseUnguarded(
value: unknown,
expectedKind: OpfsWorkerRequest["kind"],
): OpfsWorkerResponse | null {
if (value === null || typeof value !== "object") return null;
if (!hasOnlyOwnDataKeys(value, WORKER_RESPONSE_KEYS)) return null;
const requestId = ownStringField(value, "requestId");
if (requestId === null || requestId.length > 128) return null;
if (ownField(value, "protocolVersion") !== OPFS_WORKER_PROTOCOL_VERSION) {
return null;
}
if (ownField(value, "kind") !== expectedKind) return null;
const ok = ownField(value, "ok");
if (typeof ok !== "boolean") return null;
if (ok) {
if (Object.hasOwn(value, "failure")) return null;
return Object.freeze(
Object.hasOwn(value, "value")
? {
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: expectedKind,
ok: true,
value: ownField(value, "value"),
}
: {
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: expectedKind,
ok: true,
},
) as OpfsWorkerResponse;
}
if (Object.hasOwn(value, "value")) return null;
const failure = ownField(value, "failure");
if (failure === null || typeof failure !== "object") return null;
if (!hasOnlyOwnDataKeys(failure, WORKER_FAILURE_KEYS)) return null;
const code = ownField(failure, "code");
const retryable = ownField(failure, "retryable");
if (!isBrowserDataFailureCode(code) || typeof retryable !== "boolean") {
return null;
}
return Object.freeze({
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: expectedKind,
ok: false,
failure: Object.freeze({ code, retryable }),
}) as OpfsWorkerResponse;
}
@@ -1,6 +1,8 @@
import type {
DurableObjectDescriptor,
OpfsCapabilities,
OpfsCleanupEffect,
OpfsPhysicalGenerationId,
OpfsPreparedObject,
OpfsStorageScope,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
@@ -12,18 +14,34 @@ import type {
TransferProgress,
} from "../../../application/ports/browser-file-storage/shared.ts";
/**
* STO-07. Every request and response carries the protocol version and the
* response echoes its request kind, so a page/worker release mismatch or a
* malformed reply closes as `INCOMPATIBLE` instead of being decoded as a
* successful value of the wrong shape.
*/
export const OPFS_WORKER_PROTOCOL_VERSION = 2 as const;
export type OpfsWorkerRequestEnvelope = Readonly<{
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
}>;
export type OpfsWorkerRequest =
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "CAPABILITIES";
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "BEGIN_PUT";
transactionId: string;
scope: OpfsStorageScope;
objectId: string;
generation: number;
/** STO-01. Transaction-unique physical fencing token for new writes. */
physicalGenerationId: OpfsPhysicalGenerationId;
declaredByteLength: number;
mediaType: string;
createdAtEpochMs: number;
@@ -32,6 +50,7 @@ export type OpfsWorkerRequest =
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "APPEND_CHUNK";
scope: OpfsStorageScope;
transactionId: string;
@@ -40,29 +59,35 @@ export type OpfsWorkerRequest =
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "FINISH_PUT";
scope: OpfsStorageScope;
transactionId: string;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "ABORT_PUT";
scope: OpfsStorageScope;
transactionId: string;
physicalGenerationId?: OpfsPhysicalGenerationId;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "VERIFY_OBJECT";
preparedObject: OpfsPreparedObject;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "READ_CHUNK";
preparedObject: OpfsPreparedObject;
sequence: number;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "REMOVE_OBJECT";
scope: OpfsStorageScope;
objectId: string;
@@ -70,18 +95,27 @@ export type OpfsWorkerRequest =
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "CLEANUP_TRANSACTION";
scope: OpfsStorageScope;
transactionId: string;
/**
* STO-01. When present, cleanup deletes only this exact physical
* generation and can never touch a newer transaction that reused the same
* logical generation.
*/
physicalGenerationId?: OpfsPhysicalGenerationId;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "FINALIZE_PUT";
transactionId: string;
preparedObject: OpfsPreparedObject;
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "LIST_ORPHAN_CANDIDATES";
scope: OpfsStorageScope;
olderThanEpochMs: number;
@@ -89,6 +123,7 @@ export type OpfsWorkerRequest =
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: "DELETE_ORPHAN_CHUNK";
scope: OpfsStorageScope;
digestHex: string;
@@ -98,7 +133,7 @@ export type OpfsWorkerRequest =
export type OpfsWorkerRequestBody =
OpfsWorkerRequest extends infer Request
? Request extends OpfsWorkerRequest
? Omit<Request, "requestId">
? Omit<Request, "requestId" | "protocolVersion">
: never
: never;
@@ -121,9 +156,12 @@ export type OpfsOrphanDeleteResult = Readonly<{
export type OpfsWorkerResponse =
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: OpfsWorkerRequest["kind"];
ok: true;
value?:
| OpfsCapabilities
| OpfsCleanupEffect
| OpfsPreparedObject
| ArrayBuffer
| boolean
@@ -132,18 +170,33 @@ export type OpfsWorkerResponse =
}>
| Readonly<{
requestId: string;
protocolVersion: typeof OPFS_WORKER_PROTOCOL_VERSION;
kind: OpfsWorkerRequest["kind"];
ok: false;
failure: OpfsWorkerFailure;
}>;
export type PreparePhysicalObjectRequest = Readonly<{
transactionId: string;
physicalGenerationId: OpfsPhysicalGenerationId;
descriptor: Omit<DurableObjectDescriptor, "integrity">;
source: ByteSource;
signal?: AbortSignal;
onProgress?: (progress: TransferProgress) => void;
}>;
/**
* STO-01. The single compensation entry point. The coordinator owns it and
* passes a composition-owned bounded signal, never the already aborted caller
* signal.
*/
export type AbortPreparedPutRequest = Readonly<{
scope: OpfsStorageScope;
transactionId: string;
physicalGenerationId: OpfsPhysicalGenerationId;
signal?: AbortSignal;
}>;
/**
* The coordinator depends on this technology-neutral worker gateway. The
* browser implementation below the boundary owns Worker, MessageEvent and
@@ -172,7 +225,10 @@ export interface OpfsWorkerGateway {
scope: OpfsStorageScope,
transactionId: string,
signal?: AbortSignal,
): Promise<BrowserDataResult<void>>;
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
abortPreparedPut(
request: AbortPreparedPutRequest,
): Promise<BrowserDataResult<OpfsCleanupEffect>>;
finalizePut(
transactionId: string,
preparedObject: OpfsPreparedObject,
+339 -71
View File
@@ -1,6 +1,8 @@
import type {
OpfsCapabilities,
OpfsChunkReference,
OpfsCleanupEffect,
OpfsPhysicalGenerationId,
OpfsPreparedObject,
OpfsStorageScope,
} from "../../../application/ports/browser-file-storage/opfs-ports.ts";
@@ -13,6 +15,9 @@ import {
isValidOpfsStorageScope,
type OpfsRuntimePolicy,
} from "./opfs-policy.ts";
import {
OPFS_WORKER_PROTOCOL_VERSION,
} from "./opfs-worker-protocol.ts";
import type {
OpfsOrphanCandidateBatch,
OpfsOrphanDeleteResult,
@@ -54,6 +59,7 @@ type ActivePut = {
readonly scope: OpfsPreparedObject["descriptor"]["scope"];
readonly objectId: string;
readonly generation: number;
readonly physicalGenerationId: OpfsPhysicalGenerationId;
readonly declaredByteLength: number;
readonly mediaType: string;
readonly createdAtEpochMs: number;
@@ -151,12 +157,21 @@ export async function startBrowserOpfsDedicatedWorker(
host.addEventListener("message", (event) => {
if (!hasRequestId(event.data)) return;
const requestData = event.data;
// NS-05. The envelope's correlation is captured once, up front. Answering a
// bootstrap failure with a default `CAPABILITIES` kind made the client see
// an expected-kind mismatch and overwrite the real cause — a `BLOCKED` or
// `QUOTA_EXCEEDED` outage was reported to operators as `UNSUPPORTED`.
const correlation = requestCorrelation(requestData);
void runtimePromise
.then((runtime) => runtime.handleRequest(requestData))
.then((response) => postWorkerResponse(host, response))
.catch((error: unknown) => {
host.postMessage(
failure(requestData.requestId, mapRuntimeFailure(error)),
failure(
correlation.requestId,
mapRuntimeFailure(error),
correlation.kind,
),
);
});
});
@@ -193,32 +208,50 @@ export function createOpfsWorkerRuntime(
return Object.freeze({
async handleRequest(request: unknown) {
if (!hasRequestId(request)) return null;
if (!isWorkerRequest(request)) {
// STO-RR-02. Only an envelope this runtime could not read produces a
// protocol-level failure. Everything below answers its own request.
return failure(
request.requestId,
mapRuntimeFailure(new OpfsRuntimeFailure("INVALID_INPUT")),
);
}
try {
if (!isWorkerRequest(request)) {
throw new OpfsRuntimeFailure("INVALID_INPUT");
}
switch (request.kind) {
case "CAPABILITIES":
return success(request.requestId, capabilities);
return success(request.requestId, request.kind, capabilities);
case "BEGIN_PUT":
await beginPut(request);
return success(request.requestId);
return success(request.requestId, request.kind);
case "APPEND_CHUNK":
await appendChunk(request);
return success(request.requestId);
return success(request.requestId, request.kind);
case "FINISH_PUT":
return success(request.requestId, await finishPut(request));
return success(
request.requestId,
request.kind,
await finishPut(request),
);
case "ABORT_PUT":
await abortPut(request.scope, request.transactionId);
return success(request.requestId);
return success(
request.requestId,
request.kind,
await abortPut(
request.scope,
request.transactionId,
request.physicalGenerationId,
),
);
case "VERIFY_OBJECT":
return success(
request.requestId,
request.kind,
await verifyObject(request.preparedObject),
);
case "READ_CHUNK":
return success(
request.requestId,
request.kind,
await readVerifiedChunk(
request.preparedObject,
request.sequence,
@@ -230,22 +263,28 @@ export function createOpfsWorkerRuntime(
request.objectId,
request.generation,
);
return success(request.requestId);
return success(request.requestId, request.kind);
case "CLEANUP_TRANSACTION":
await cleanupTransaction(
request.scope,
request.transactionId,
return success(
request.requestId,
request.kind,
await cleanupTransaction(
request.scope,
request.transactionId,
true,
request.physicalGenerationId,
),
);
return success(request.requestId);
case "FINALIZE_PUT":
await finalizePut(
request.transactionId,
request.preparedObject,
);
return success(request.requestId);
return success(request.requestId, request.kind);
case "LIST_ORPHAN_CANDIDATES":
return success(
request.requestId,
request.kind,
await listOrphanCandidates(
request.scope,
request.olderThanEpochMs,
@@ -255,6 +294,7 @@ export function createOpfsWorkerRuntime(
case "DELETE_ORPHAN_CHUNK":
return success(
request.requestId,
request.kind,
await deleteOrphanChunk(
request.scope,
request.digestHex,
@@ -263,7 +303,14 @@ export function createOpfsWorkerRuntime(
);
}
} catch (error) {
return failure(request.requestId, mapRuntimeFailure(error));
// STO-RR-02. The kind travels with the failure so the client's
// expected-kind check cannot mistake a quota, integrity or abort
// failure for a protocol breach.
return failure(
request.requestId,
mapRuntimeFailure(error),
request.kind,
);
}
},
});
@@ -278,6 +325,7 @@ export function createOpfsWorkerRuntime(
!dependencies.policy.isObjectIdAllowed(request.objectId) ||
!Number.isSafeInteger(request.generation) ||
request.generation < 1 ||
!isPhysicalGenerationId(request.physicalGenerationId) ||
!Number.isSafeInteger(request.declaredByteLength) ||
request.declaredByteLength < 0 ||
request.declaredByteLength > dependencies.policy.maxObjectBytes ||
@@ -327,6 +375,7 @@ export function createOpfsWorkerRuntime(
scope: request.scope,
objectId: request.objectId,
generation: request.generation,
physicalGenerationId: request.physicalGenerationId,
declaredByteLength: request.declaredByteLength,
mediaType: request.mediaType,
createdAtEpochMs: request.createdAtEpochMs,
@@ -457,7 +506,8 @@ export function createOpfsWorkerRuntime(
);
assertActivePut(transactionKey, put, true);
const prepared: OpfsPreparedObject = Object.freeze({
physicalSchemaVersion: 1,
physicalSchemaVersion: 2,
physicalGenerationId: put.physicalGenerationId,
descriptor: Object.freeze({
objectId: put.objectId,
scope: put.scope,
@@ -501,10 +551,13 @@ export function createOpfsWorkerRuntime(
async function abortPut(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
): Promise<void> {
physicalGenerationId?: OpfsPhysicalGenerationId,
): Promise<OpfsCleanupEffect> {
if (
!isValidOpfsStorageScope(scope) ||
!SAFE_TRANSACTION_ID.test(transactionId)
!SAFE_TRANSACTION_ID.test(transactionId) ||
(physicalGenerationId !== undefined &&
!isPhysicalGenerationId(physicalGenerationId))
) {
throw new OpfsRuntimeFailure("INVALID_INPUT");
}
@@ -518,15 +571,33 @@ export function createOpfsWorkerRuntime(
await active.operationTail;
if (activePuts.get(transactionKey) === active) {
activePuts.delete(transactionKey);
}
try {
// STO-01. The mutation lease is held through the physical delete and
// the staging cleanup; releasing it earlier would let a new transaction
// race this compensation.
await removePhysicalGeneration(
active.scope,
active.objectId,
active.generation,
active.physicalGenerationId,
);
return await cleanupTransactionLocked(
scope,
transactionId,
true,
physicalGenerationId ?? active.physicalGenerationId,
);
} finally {
active.lease.release();
}
await removePhysicalGeneration(
active.scope,
active.objectId,
active.generation,
);
}
await cleanupTransaction(scope, transactionId);
return await cleanupTransaction(
scope,
transactionId,
true,
physicalGenerationId,
);
}
async function runActivePutOperation<Value>(
@@ -571,20 +642,30 @@ export function createOpfsWorkerRuntime(
put.abortController.abort();
if (activePuts.get(transactionKey) === put) {
activePuts.delete(transactionKey);
}
try {
await removePhysicalGeneration(
put.scope,
put.objectId,
put.generation,
put.physicalGenerationId,
);
await cleanupTransactionLocked(
put.scope,
put.transactionId,
true,
put.physicalGenerationId,
);
} finally {
put.lease.release();
}
await removePhysicalGeneration(
put.scope,
put.objectId,
put.generation,
);
await cleanupTransaction(put.scope, put.transactionId);
}
async function removePhysicalGeneration(
scope: OpfsStorageScope,
objectId: string,
generation: number,
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
): Promise<void> {
try {
const objectDirectory = await getDirectory(
@@ -599,7 +680,7 @@ export function createOpfsWorkerRuntime(
);
await removeEntryIfPresent(
objectDirectory,
String(generation),
generationSegmentFor(generation, physicalGenerationId),
true,
);
} catch (error) {
@@ -714,17 +795,68 @@ export function createOpfsWorkerRuntime(
}
}
/**
* STO-01. Cleanup deletes exactly one transaction's physical generation while
* holding the origin mutation lease, and reports whether the effect actually
* happened. `ALREADY_CLEAN` means there was nothing left to delete.
*/
async function cleanupTransaction(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
removePreparedGeneration = true,
): Promise<void> {
physicalGenerationId?: OpfsPhysicalGenerationId,
): Promise<OpfsCleanupEffect> {
if (
!isValidOpfsStorageScope(scope) ||
!SAFE_TRANSACTION_ID.test(transactionId)
) {
throw new OpfsRuntimeFailure("INVALID_INPUT");
}
// Probe before locking. A transaction that never reached staging has
// nothing to delete, and waiting for the mutation lease here would deadlock
// against the very BEGIN this compensation is cancelling.
try {
await getDirectory(
dependencies.root,
[...scopeRootPath(scope), "staging", transactionId],
false,
);
} catch (error) {
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
throw error;
}
// Every destructive step below runs while the lease is held.
const lease = await dependencies.leaseManager!.acquire();
try {
return await cleanupTransactionLocked(
scope,
transactionId,
removePreparedGeneration,
physicalGenerationId,
);
} finally {
lease.release();
}
}
/**
* Callers that already hold the origin mutation lease use this directly, so
* an abort never releases the lease between fencing and physical deletion.
*/
async function cleanupTransactionLocked(
scope: OpfsPreparedObject["descriptor"]["scope"],
transactionId: string,
removePreparedGeneration = true,
physicalGenerationId?: OpfsPhysicalGenerationId,
): Promise<OpfsCleanupEffect> {
if (
!isValidOpfsStorageScope(scope) ||
!SAFE_TRANSACTION_ID.test(transactionId) ||
(physicalGenerationId !== undefined &&
!isPhysicalGenerationId(physicalGenerationId))
) {
throw new OpfsRuntimeFailure("INVALID_INPUT");
}
let staging: FileSystemDirectoryHandle;
try {
staging = await getDirectory(
@@ -733,32 +865,38 @@ export function createOpfsWorkerRuntime(
false,
);
} catch (error) {
if (isNotFound(error)) return;
if (isNotFound(error)) return CLEANUP_ALREADY_CLEAN;
throw error;
}
if (removePreparedGeneration) {
let receipt: unknown;
try {
receipt = await readJson(receiptPath(scope, transactionId));
} catch (error) {
if (isNotFound(error)) {
await removeEntryIfPresent(staging, transactionId, true);
return;
{
if (removePreparedGeneration) {
let receipt: unknown;
try {
receipt = await readJson(receiptPath(scope, transactionId));
} catch (error) {
if (isNotFound(error)) {
await removeEntryIfPresent(staging, transactionId, true);
return CLEANUP_ALREADY_CLEAN;
}
// Keep unreadable staging in place so orphan GC fails closed.
throw error;
}
// Keep unreadable staging in place so orphan GC fails closed.
throw error;
const target = extractReceiptPhysicalTarget(receipt, scope);
if (!target) {
throw new OpfsRuntimeFailure("CORRUPT_DATA");
}
// A caller-supplied token wins: a stale compensation must not widen its
// target to whatever the receipt now says.
await removePhysicalGeneration(
scope,
target.objectId,
target.generation,
physicalGenerationId ?? target.physicalGenerationId,
);
}
const target = extractReceiptPhysicalTarget(receipt, scope);
if (!target) {
throw new OpfsRuntimeFailure("CORRUPT_DATA");
}
await removePhysicalGeneration(
scope,
target.objectId,
target.generation,
);
await removeEntryIfPresent(staging, transactionId, true);
return CLEANUP_CLEANED;
}
await removeEntryIfPresent(staging, transactionId, true);
}
async function finalizePut(
@@ -788,16 +926,19 @@ export function createOpfsWorkerRuntime(
for await (const [name, handle] of objectDirectory.entries()) {
if (
handle.kind === "directory" &&
/^\d+$/u.test(name) &&
name !== String(descriptor.generation)
isGenerationSegment(name) &&
name !== preparedGenerationSegment(preparedObject)
) {
await objectDirectory.removeEntry(name, { recursive: true });
}
}
await cleanupTransaction(descriptor.scope, transactionId, false);
// STO-RR-01. This path already holds the origin mutation lease, and a
// Web Lock is not reentrant: asking for it again here never returns, so
// an ordinary PUT would stop for good at FINALIZE.
await cleanupTransactionLocked(descriptor.scope, transactionId, false);
} catch (error) {
if (!isNotFound(error)) throw error;
await cleanupTransaction(
await cleanupTransactionLocked(
preparedObject.descriptor.scope,
transactionId,
false,
@@ -980,7 +1121,11 @@ export function createOpfsWorkerRuntime(
function extractReceiptPhysicalTarget(
receipt: unknown,
scope: OpfsStorageScope,
): Readonly<{ objectId: string; generation: number }> | null {
): Readonly<{
objectId: string;
generation: number;
physicalGenerationId: OpfsPhysicalGenerationId | undefined;
}> | null {
if (!receiptBelongsToScope(receipt, scope)) return null;
const record = receipt as Record<string, unknown>;
if (record.phase === "PREPARING") {
@@ -989,7 +1134,15 @@ export function createOpfsWorkerRuntime(
typeof record.generation === "number" &&
Number.isSafeInteger(record.generation) &&
record.generation > 0
? { objectId: record.objectId, generation: record.generation }
? {
objectId: record.objectId,
generation: record.generation,
physicalGenerationId: isPhysicalGenerationId(
record.physicalGenerationId,
)
? record.physicalGenerationId
: undefined,
}
: null;
}
if (
@@ -997,9 +1150,14 @@ export function createOpfsWorkerRuntime(
isPreparedObjectSafe(record.preparedObject, dependencies.policy) &&
sameScope(record.preparedObject.descriptor.scope, scope)
) {
const prepared = record.preparedObject;
return {
objectId: record.preparedObject.descriptor.objectId,
generation: record.preparedObject.descriptor.generation,
objectId: prepared.descriptor.objectId,
generation: prepared.descriptor.generation,
physicalGenerationId:
prepared.physicalSchemaVersion === 2
? prepared.physicalGenerationId
: undefined,
};
}
return null;
@@ -1019,11 +1177,12 @@ export function createOpfsWorkerRuntime(
async function writeReceipt(put: ActivePut): Promise<void> {
await writeJsonAtomic(receiptPath(put.scope, put.transactionId), {
schemaVersion: 1,
schemaVersion: 2,
phase: "PREPARING",
scope: put.scope,
objectId: put.objectId,
generation: put.generation,
physicalGenerationId: put.physicalGenerationId,
declaredByteLength: put.declaredByteLength,
chunks: put.chunks,
});
@@ -1296,6 +1455,52 @@ async function removeEntryIfPresent(
}
}
const CLEANUP_CLEANED: OpfsCleanupEffect = Object.freeze({ kind: "CLEANED" });
const CLEANUP_ALREADY_CLEAN: OpfsCleanupEffect = Object.freeze({
kind: "ALREADY_CLEAN",
});
const PHYSICAL_GENERATION_ID = /^[0-9a-f]{32}$/u;
const V1_GENERATION_SEGMENT = /^\d+$/u;
const V2_GENERATION_SEGMENT = /^g\d+-[0-9a-f]{32}$/u;
export function isPhysicalGenerationId(
value: unknown,
): value is OpfsPhysicalGenerationId {
return typeof value === "string" && PHYSICAL_GENERATION_ID.test(value);
}
/**
* STO-01. v1 wrote `objects/<prefix>/<id>/<generation>/`, which two
* transactions can legitimately share. v2 writes
* `objects/<prefix>/<id>/g<generation>-<token>/` so a late compensation can
* only ever delete its own transaction's directory. v1 segments stay readable
* through the rollback window.
*/
function generationSegmentFor(
generation: number,
physicalGenerationId: OpfsPhysicalGenerationId | undefined,
): string {
return physicalGenerationId === undefined
? String(generation)
: `g${generation}-${physicalGenerationId}`;
}
function preparedGenerationSegment(
preparedObject: OpfsPreparedObject,
): string {
return generationSegmentFor(
preparedObject.descriptor.generation,
preparedObject.physicalSchemaVersion === 2
? preparedObject.physicalGenerationId
: undefined,
);
}
function isGenerationSegment(name: string): boolean {
return V1_GENERATION_SEGMENT.test(name) || V2_GENERATION_SEGMENT.test(name);
}
function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
const descriptor = preparedObject.descriptor;
return [
@@ -1303,7 +1508,7 @@ function manifestPath(preparedObject: OpfsPreparedObject): readonly string[] {
"objects",
descriptor.objectId.slice(0, 2),
descriptor.objectId,
String(descriptor.generation),
preparedGenerationSegment(preparedObject),
"manifest.json",
];
}
@@ -1373,7 +1578,8 @@ function receiptBelongsToScope(
if (!receipt || typeof receipt !== "object") return false;
const record = receipt as Record<string, unknown>;
if (
record.schemaVersion !== 1 ||
// v1 receipts stay readable through the rollback window.
(record.schemaVersion !== 1 && record.schemaVersion !== 2) ||
(record.phase !== "PREPARING" && record.phase !== "FILES_READY")
) {
return false;
@@ -1464,6 +1670,20 @@ function stableJson(value: unknown): string {
throw new OpfsRuntimeFailure("CORRUPT_DATA");
}
/**
* STO-01 expand phase. v1 prepared objects stay readable through the rollback
* window; v2 additionally carries a transaction-unique physical fencing token.
*/
function isSupportedPhysicalSchema(value: object): boolean {
const record = value as Record<string, unknown>;
if (record.physicalSchemaVersion === 1) return true;
return (
record.physicalSchemaVersion === 2 &&
typeof record.physicalGenerationId === "string" &&
/^[0-9a-f]{32}$/u.test(record.physicalGenerationId)
);
}
function isPreparedObjectSafe(
value: unknown,
policy: OpfsRuntimePolicy,
@@ -1472,7 +1692,7 @@ function isPreparedObjectSafe(
!value ||
typeof value !== "object" ||
!("physicalSchemaVersion" in value) ||
value.physicalSchemaVersion !== 1 ||
!isSupportedPhysicalSchema(value) ||
!("descriptor" in value) ||
!value.descriptor ||
typeof value.descriptor !== "object" ||
@@ -1564,9 +1784,11 @@ function isPreparedObjectSafe(
function success(
requestId: string,
kind: OpfsWorkerRequest["kind"],
value?: OpfsWorkerResponse extends infer _Response
?
| OpfsCapabilities
| OpfsCleanupEffect
| OpfsPreparedObject
| ArrayBuffer
| boolean
@@ -1575,15 +1797,33 @@ function success(
: never,
): OpfsWorkerResponse {
return value === undefined
? { requestId, ok: true }
: { requestId, ok: true, value };
? {
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind,
ok: true,
}
: {
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind,
ok: true,
value,
};
}
function failure(
requestId: string,
workerFailure: OpfsWorkerFailure,
kind: OpfsWorkerRequest["kind"] = "CAPABILITIES",
): OpfsWorkerResponse {
return { requestId, ok: false, failure: workerFailure };
return {
requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind,
ok: false,
failure: workerFailure,
};
}
function mapRuntimeFailure(error: unknown): OpfsWorkerFailure {
@@ -1640,12 +1880,40 @@ function hasRequestId(
);
}
/**
* NS-05. Reads a request envelope's correlation exactly once, through its own
* data descriptors, so a reply can name the request it answers even when the
* runtime that would have handled it never came up. An envelope whose kind
* cannot be read stays a protocol-level failure rather than borrowing an
* unrelated kind.
*/
function requestCorrelation(
value: Readonly<{ requestId: string }>,
): Readonly<{ requestId: string; kind: OpfsWorkerRequest["kind"] }> {
let kind: unknown;
try {
kind = Object.getOwnPropertyDescriptor(value, "kind")?.value;
} catch {
kind = undefined;
}
return {
requestId: value.requestId,
kind:
typeof kind === "string" && WORKER_REQUEST_KINDS.has(kind)
? (kind as OpfsWorkerRequest["kind"])
: "CAPABILITIES",
};
}
function isWorkerRequest(value: unknown): value is OpfsWorkerRequest {
return Boolean(
hasRequestId(value) &&
"kind" in value &&
typeof value.kind === "string" &&
WORKER_REQUEST_KINDS.has(value.kind),
WORKER_REQUEST_KINDS.has(value.kind) &&
// STO-07. A page from another release must not be served.
"protocolVersion" in value &&
value.protocolVersion === OPFS_WORKER_PROTOCOL_VERSION,
);
}
+89 -22
View File
@@ -37,6 +37,31 @@ export const noOpTelemetry: TelemetryAdapter = Object.freeze({
dispose: () => {},
});
/**
* N-04. Disposal is terminal: there is no durable queue and no resurrection.
*/
type TelemetryLifecycle = "ACTIVE" | "DISPOSED";
/** N-11. Documented absolute ceiling for the in-memory best-effort queue. */
export const MAX_TELEMETRY_QUEUE = 10_000;
/**
* N-11. A non-finite or fractional capacity silently disables eviction, so it is
* a construction-time configuration error rather than a runtime drop.
*/
export function assertBoundedCapacity(
value: number,
ceiling: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < 1 || value > ceiling) {
throw new TypeError(
`${label} must be a safe integer between 1 and ${ceiling}`,
);
}
return value;
}
export function createTelemetryAdapter(
options: TelemetryAdapterOptions,
): TelemetryAdapter {
@@ -46,11 +71,19 @@ export function createTelemetryAdapter(
const endpoint = options.endpoint;
const fetcher = options.fetcher ?? fetch;
const maxQueue = Math.max(1, options.maxQueue ?? 100);
const maxQueue = assertBoundedCapacity(
options.maxQueue ?? 100,
MAX_TELEMETRY_QUEUE,
"telemetry maxQueue",
);
const schedule = options.schedule ?? queueMicrotask;
const queue: TelemetryEvent[] = [];
let lifecycleState: TelemetryLifecycle = "ACTIVE";
/** Scheduled callbacks captured before disposal must not run afterwards. */
let scheduleGeneration = 0;
let scheduled = false;
let flushing = false;
let activeFlush: Promise<void> | null = null;
let activeSink: AbortController | null = null;
let dropped = 0;
const dropReasons = new Map<string, number>();
let lastDeliveryEvidence: TelemetryEvent | null = null;
@@ -93,9 +126,12 @@ export function createTelemetryAdapter(
}
function scheduleFlush(): void {
if (scheduled) return;
if (scheduled || lifecycleState === "DISPOSED") return;
scheduled = true;
const generation = scheduleGeneration;
schedule(() => {
// A callback captured before disposal belongs to a dead generation.
if (generation !== scheduleGeneration) return;
scheduled = false;
void flush();
});
@@ -105,6 +141,7 @@ export function createTelemetryAdapter(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void {
if (lifecycleState === "DISPOSED") return;
const projected = projectTelemetryEvent(
eventName,
attributes,
@@ -124,26 +161,44 @@ export function createTelemetryAdapter(
scheduleFlush();
}
async function flush(): Promise<void> {
if (flushing || queue.length === 0) return;
flushing = true;
const batch = queue.splice(0, queue.length);
try {
const response = await fetcher(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: batch }),
keepalive: true,
});
if (!response.ok) recordDrop("sink-failure", batch.length);
} catch {
recordDrop("sink-failure", batch.length);
} finally {
flushing = false;
if (queue.length > 0) {
scheduleFlush();
}
/**
* `flush()` joins the active delivery instead of resolving immediately, so an
* awaited flush really means "the in-flight batch has settled".
*/
function flush(): Promise<void> {
if (activeFlush) return activeFlush;
if (lifecycleState === "DISPOSED" || queue.length === 0) {
return Promise.resolve();
}
const generation = scheduleGeneration;
const run = async () => {
const batch = queue.splice(0, queue.length);
const controller = new AbortController();
activeSink = controller;
try {
const response = await fetcher(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: batch }),
keepalive: true,
signal: controller.signal,
});
// A sink that ignored the abort must not update post-dispose state.
if (generation !== scheduleGeneration) return;
if (!response.ok) recordDrop("sink-failure", batch.length);
} catch {
if (generation !== scheduleGeneration) return;
recordDrop("sink-failure", batch.length);
} finally {
if (activeSink === controller) activeSink = null;
activeFlush = null;
if (generation === scheduleGeneration && queue.length > 0) {
scheduleFlush();
}
}
};
activeFlush = run();
return activeFlush;
}
const flushBeforePageExit = () => {
@@ -151,8 +206,20 @@ export function createTelemetryAdapter(
};
lifecycle?.addEventListener("pagehide", flushBeforePageExit);
/**
* N-04. Terminal disposal: one state transition, no further admission, no
* further scheduling, and no recursive drop telemetry while shutting down.
*/
function dispose(): void {
if (lifecycleState === "DISPOSED") return;
lifecycleState = "DISPOSED";
scheduleGeneration += 1;
scheduled = false;
lifecycle?.removeEventListener("pagehide", flushBeforePageExit);
queue.length = 0;
activeSink?.abort();
activeSink = null;
activeFlush = null;
}
return Object.freeze({
@@ -3,6 +3,7 @@ import {
WEB_PUSH_PROTOCOLS,
webPushFailure,
webPushSuccess,
type WebPushNativeEffectCertainty,
type WebPushObserver,
type WebPushResult,
} from "../../../contracts/web-push.ts";
@@ -44,6 +45,41 @@ export type NotificationClickAdapter = Readonly<{
handle(event: NotificationClickEventFacade): Promise<WebPushResult<void>>;
}>;
/**
* WP-RR-01. Bounds a native effect by the handler lifetime while keeping the
* abandoned promise observable exactly once.
*/
const ABORT_OWNED = Symbol("web-push-click-aborted");
/**
* WP-01. Per-click observation state: the current native-effect certainty and
* the bounded tail tasks that observe an effect landing after the terminal
* result. `waitUntil` owns the tails so the worker cannot be terminated before
* the evidence lands, and the certainty is monotone from `NOT_APPLIED` through
* `MAYBE_APPLIED` to `CONFIRMED`.
*/
type ClickEffectState = {
certainty: WebPushNativeEffectCertainty;
tails: Promise<unknown>[];
};
async function raceAbort<Value>(
operation: Promise<Value>,
signal: AbortSignal,
): Promise<Value | typeof ABORT_OWNED> {
if (signal.aborted) return ABORT_OWNED;
let onAbort: (() => void) | undefined;
const aborted = new Promise<typeof ABORT_OWNED>((resolve) => {
onAbort = () => resolve(ABORT_OWNED);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
export function createNotificationClickAdapter(dependencies: Readonly<{
fenceStore: PushAssociationFenceStore;
clients: WorkerClientsFacade;
@@ -79,8 +115,17 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
const taskControl = createLinkedAbortController(
dependencies.signal,
);
// WP-01. One observation authority per click. The terminal record was
// emitted both inside `process` and again here, so an ordinary click was
// counted twice, and the native-effect evidence was detached from
// `waitUntil` entirely — a worker that shut down after the terminal
// result simply lost it.
const effect: ClickEffectState = {
certainty: "NOT_APPLIED",
tails: [],
};
const processing = withAbortableDeadline(
(signal) => process(event.notification.data, signal),
(signal) => process(event.notification.data, signal, effect),
{
deadlineMs: handlerDeadlineMs,
operation: "NOTIFICATION_CLICK",
@@ -90,12 +135,16 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
).finally(taskControl.dispose);
try {
event.waitUntil(
processing.then((result) => {
processing.then(async (result) => {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: result.ok ? "SUCCEEDED" : "FAILED",
...(result.ok ? {} : { reason: result.error.code }),
nativeEffect: effect.certainty,
});
// The late-effect observation is this handler's own work, so the
// worker stays alive for it without extending the public deadline.
await Promise.allSettled(effect.tails);
}),
);
} catch {
@@ -109,6 +158,7 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
async function process(
data: unknown,
signal: AbortSignal,
effect: ClickEffectState,
): Promise<WebPushResult<void>> {
const decoded = decodeNotificationClickData(data, now());
if (!decoded.ok) return decoded;
@@ -156,21 +206,86 @@ export function createNotificationClickAdapter(dependencies: Readonly<{
expiresAt: decoded.value.expiresAt,
path,
});
// WP-RR-01. `focus` and `openWindow` are user-visible native effects, so
// they carry the same certainty phase `showNotification` already does:
// NOT_APPLIED before the call, MAYBE_APPLIED while the promise is pending,
// CONFIRMED on fulfilment. An effect that lands after this handler's
// deadline is still observed exactly once — as evidence only, never as
// authorization to retry.
const observeLateEffect = (
pending: Promise<unknown>,
appliedWhen: (value: unknown) => boolean,
): void => {
let observed = false;
// WP-01. The tail is tracked so `waitUntil` owns it. Certainty is
// monotone: once the native call has been made the effect can only be
// confirmed or stay uncertain. A rejection says the call did not report
// success, not that it never happened, so downgrading it to NOT_APPLIED
// told operators the click had definitely not been applied.
effect.tails.push(
pending.then(
(value) => {
if (observed) return;
observed = true;
const applied = appliedWhen(value);
effect.certainty = applied ? "CONFIRMED" : "NOT_APPLIED";
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: applied ? "DEGRADED" : "FAILED",
reason: "ABORTED",
nativeEffect: effect.certainty,
});
},
() => {
if (observed) return;
observed = true;
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect: "MAYBE_APPLIED",
});
},
),
);
};
try {
if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
if (existing) {
existing.postMessage(handoff);
await existing.focus();
const focused = Promise.resolve(existing.focus());
effect.certainty = "MAYBE_APPLIED";
const raced = await raceAbort(focused, signal);
if (raced === ABORT_OWNED) {
observeLateEffect(focused, () => true);
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
effect.certainty = "CONFIRMED";
} else {
const opened = await dependencies.clients.openWindow(target);
if (!opened) return nativeFailure("NOTIFICATION_CLICK", true);
const opening = Promise.resolve(
dependencies.clients.openWindow(target),
);
effect.certainty = "MAYBE_APPLIED";
const raced = await raceAbort(opening, signal);
if (raced === ABORT_OWNED) {
observeLateEffect(opening, (value) => value !== null);
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
if (!raced) {
// An explicit null is the one answer that confirms no window opened.
effect.certainty = "NOT_APPLIED";
return nativeFailure("NOTIFICATION_CLICK", true);
}
effect.certainty = "CONFIRMED";
}
if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
} catch {
// The native call threw, so it never reported success; whether it took
// effect is unknown rather than settled.
return nativeFailure("NOTIFICATION_CLICK", true);
}
return webPushSuccess(undefined);
@@ -1,4 +1,5 @@
import {
type WebPushNativeEffectCertainty,
WEB_PUSH_LIMITS,
webPushFailure,
webPushSuccess,
@@ -148,14 +149,30 @@ export function createPushEventAdapter(dependencies: Readonly<{
);
if (!finalFence.ok) return finalFence;
const clickData = clickDataFromHint(decoded.value);
// WP-07. The user-visible effect has its own certainty phase: NOT_APPLIED
// before the native call, MAYBE_APPLIED while the promise is pending and
// CONFIRMED on fulfilment. It is evidence only and never authorizes retry.
let nativeEffect: WebPushNativeEffectCertainty = "NOT_APPLIED";
try {
await dependencies.notifications.showNotification(definition.title, {
body: definition.body,
data: clickData,
requireInteraction: false,
tag,
});
const shown = dependencies.notifications.showNotification(
definition.title,
{
body: definition.body,
data: clickData,
requireInteraction: false,
tag,
},
);
nativeEffect = "MAYBE_APPLIED";
await shown;
nativeEffect = "CONFIRMED";
if (signal.aborted) {
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: "DEGRADED",
reason: "ABORTED",
nativeEffect,
});
return webPushFailure("ABORTED", "NOTIFICATION_SHOW");
}
} catch {
@@ -164,12 +181,14 @@ export function createPushEventAdapter(dependencies: Readonly<{
event: "web_push_notification_finished",
outcome: "FAILED",
reason: failureCode(failed),
nativeEffect,
});
return failed;
}
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: "SUCCEEDED",
nativeEffect,
});
return webPushSuccess(undefined);
}
@@ -502,7 +502,7 @@ export function createPushAssociationFenceStore(
operation,
);
if (!written.ok) return written;
if (!validWriteReceipt(written.value)) {
if (!validWriteReceipt(written.value, expectedRevision)) {
return webPushFailure("CONTROL_CORRUPT", operation);
}
return webPushSuccess(
@@ -537,10 +537,7 @@ export function createPushAssociationFenceStore(
"CONTROL_PURGE",
);
if (!removed.ok) return removed;
if (
!validWriteReceipt(removed.value) ||
removed.value.revision !== expectedRevision + 1
) {
if (!validWriteReceipt(removed.value, expectedRevision)) {
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
}
return webPushSuccess(undefined);
@@ -693,14 +690,25 @@ function validRepository(
);
}
/**
* WP-01. One validator for both write and remove.
*
* A CAS receipt is only evidence when it names the expected key and the exact
* next revision. Accepting any well-typed revision let a stale or arbitrary
* repository receipt be packaged as a confirmed control, after which the whole
* CAS authority is wrong. A replayed receipt must still carry that exact
* revision, since replay means "this command already produced this revision".
*/
function validWriteReceipt(
value: PushControlWriteReceipt,
expectedRevision: number | null,
): value is PushControlWriteReceipt {
return (
Boolean(value) &&
value.key === CONTROL_KEY &&
validRevision(value.revision) &&
typeof value.replayed === "boolean"
typeof value.replayed === "boolean" &&
value.revision === (expectedRevision ?? 0) + 1
);
}
@@ -1,5 +1,6 @@
import type { WebPushControlPort } from "../../application/ports/out/web-push-control.ts";
import {
webPushCountBucket,
WEB_PUSH_LIMITS,
samePushAuthority,
webPushFailure,
@@ -474,7 +475,9 @@ export function createWebPushSubscriptionAdapter(
if (closed) return webPushSuccess(unavailable("CLOSED"));
if (busy) return webPushSuccess(unavailable("BUSY"));
if (signal?.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_INSPECT");
// WP-05. A pre-aborted command is recorded as the operation the caller
// actually requested, not always as an inspection.
return webPushFailure("ABORTED", failureOperation);
}
busy = true;
const generation = lifecycleGeneration;
@@ -879,7 +882,9 @@ export function createWebPushSubscriptionAdapter(
await Promise.allSettled([
unsubscribe,
associationEpoch === null
? Promise.resolve(webPushSuccess(undefined))
? Promise.resolve(
webPushSuccess(Object.freeze({ complete: true })),
)
: closeOwnedNotifications(
associationEpoch,
signal,
@@ -891,7 +896,8 @@ export function createWebPushSubscriptionAdapter(
nativeResult.value;
const notificationsClean =
notificationResult.status === "fulfilled" &&
notificationResult.value.ok;
notificationResult.value.ok &&
notificationResult.value.value.complete;
return webPushSuccess(
nativeClean && notificationsClean,
);
@@ -905,11 +911,16 @@ export function createWebPushSubscriptionAdapter(
return cleanup.ok && cleanup.value;
}
/**
* WP-06. Notification cleanup is bounded best effort and is reported
* separately from revoke authority: an incomplete pass returns
* `{ complete: false }` and is observed as DEGRADED rather than success.
*/
async function closeOwnedNotifications(
associationEpoch: string,
signal: AbortSignal | undefined,
generation: number,
): Promise<WebPushResult<void>> {
): Promise<WebPushResult<Readonly<{ complete: boolean }>>> {
let notifications: readonly OwnedNotificationFacade[];
try {
notifications = await dependencies.registration.getNotifications();
@@ -923,6 +934,15 @@ export function createWebPushSubscriptionAdapter(
if (stale(signal, generation)) {
return webPushFailure("ABORTED", "NOTIFICATION_CLEANUP");
}
const truncated =
notifications.length > WEB_PUSH_LIMITS.notificationCleanupCount;
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: truncated ? "DEGRADED" : "SUCCEEDED",
...(truncated ? { reason: "LIMIT_EXCEEDED" as const } : {}),
countBucket: webPushCountBucket(notifications.length),
truncated,
});
for (const notification of notifications.slice(
0,
WEB_PUSH_LIMITS.notificationCleanupCount,
@@ -941,7 +961,7 @@ export function createWebPushSubscriptionAdapter(
}
}
}
return webPushSuccess(undefined);
return webPushSuccess(Object.freeze({ complete: !truncated }));
}
function stale(
@@ -1,4 +1,5 @@
import {
webPushCountBucket,
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
webPushFailure,
@@ -134,6 +135,10 @@ export function createWebPushServiceWorkerRuntime(
const facade = functionalEventFacade(event);
if (!facade) return;
const taskControl = createLinkedAbortController(lifecycle.signal);
// WP-06. Bounded fan-out is policy, but the operator must be able to see
// that only part of the client set was notified.
let observedClientCount = 0;
let truncatedClients = false;
const processing = withAbortableDeadline(
async (signal) => {
let clients: readonly unknown[];
@@ -148,6 +153,9 @@ export function createWebPushServiceWorkerRuntime(
if (signal.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE");
}
observedClientCount = clients.length;
truncatedClients =
clients.length > WEB_PUSH_LIMITS.clientHandoffCount;
try {
for (const candidate of clients.slice(
0,
@@ -181,8 +189,15 @@ export function createWebPushServiceWorkerRuntime(
const lifetime = processing.then((result) => {
observeWebPush(dependencies.observer, {
event: "web_push_subscription_rotated",
outcome: result.ok ? "SUCCEEDED" : "DEGRADED",
...(result.ok ? {} : { reason: result.error.code }),
outcome:
result.ok && !truncatedClients ? "SUCCEEDED" : "DEGRADED",
...(result.ok
? truncatedClients
? { reason: "LIMIT_EXCEEDED" as const }
: {}
: { reason: result.error.code }),
countBucket: webPushCountBucket(observedClientCount),
truncated: truncatedClients,
});
});
try {
+24 -2
View File
@@ -9,7 +9,16 @@ export type SessionGateway = Readonly<{
subscribe(listener: () => void): () => void;
beginSignIn(returnTo?: string): Promise<void>;
signOut(): Promise<void>;
recover(): Promise<"restored" | "no-session">;
/**
* LEG-01. Recovery is part of a request's lifetime, so it receives the same
* context a credential attach does. The context is optional for one release
* to keep existing owners working; the transport races the signal either way,
* and a recovery that answers after the request already ended is observed but
* never turned into a user-visible sign-out.
*/
recover(
context?: CredentialOperationContext,
): Promise<"restored" | "no-session">;
}>;
export type CredentialRequestBinding = Readonly<{
@@ -22,8 +31,21 @@ export type CredentialPatch = Readonly<{
headers: Readonly<Record<string, string>>;
}>;
/**
* §8.5. The transport lifetime handed to a credential owner. A cooperative
* owner abandons its own work on abort; a non-cooperative one is still bounded
* because the transport races the same signal.
*/
export type CredentialOperationContext = Readonly<{
signal: AbortSignal;
deadlineAtMonotonicMs: number;
}>;
export type CredentialAttacher = Readonly<{
credentialPatch(binding: CredentialRequestBinding): Promise<CredentialPatch>;
credentialPatch(
binding: CredentialRequestBinding,
context?: CredentialOperationContext,
): Promise<CredentialPatch>;
onUnauthenticated(): void;
}>;
@@ -169,12 +169,46 @@ export type OpfsChunkReference = Readonly<{
digestHex: string;
}>;
export type OpfsPreparedObject = Readonly<{
/**
* STO-01. A transaction-unique physical fencing token.
*
* The logical `generation` is reused across transactions by design, so a late
* compensation from an abandoned transaction could otherwise delete the
* physical directory a newer transaction just created under the same logical
* generation. Physical paths are keyed by this token instead.
*/
declare const opfsPhysicalGenerationBrand: unique symbol;
export type OpfsPhysicalGenerationId = string & {
readonly [opfsPhysicalGenerationBrand]: "OpfsPhysicalGenerationId";
};
export type OpfsPreparedObjectV1 = Readonly<{
descriptor: DurableObjectDescriptor;
chunks: readonly OpfsChunkReference[];
physicalSchemaVersion: 1;
}>;
export type OpfsPreparedObjectV2 = Readonly<{
descriptor: DurableObjectDescriptor;
chunks: readonly OpfsChunkReference[];
physicalSchemaVersion: 2;
physicalGenerationId: OpfsPhysicalGenerationId;
}>;
/**
* Expand phase: v1 readers stay for the rollback window while every new write
* emits v2.
*/
export type OpfsPreparedObject = OpfsPreparedObjectV1 | OpfsPreparedObjectV2;
/**
* STO-01. Compensation is only allowed to release journal and budget state
* after the physical effect is confirmed. `EFFECT_UNKNOWN` is never a success.
*/
export type OpfsCleanupEffect =
| Readonly<{ kind: "CLEANED" | "ALREADY_CLEAN" }>
| Readonly<{ kind: "EFFECT_UNKNOWN" }>;
export type OpfsJournalMutation = "PUT" | "DELETE";
export type OpfsJournalPhase =
| "PREPARING"
@@ -1,24 +1,42 @@
import type { Result } from "../../result.ts";
/**
* STO-RR-03. The runtime membership set behind the closed failure taxonomy. A
* boundary decoder needs to test a value against it, and a type alone cannot
* stop an arbitrary string from reaching application code.
*/
export const BROWSER_DATA_FAILURE_CODES = Object.freeze([
"ABORTED",
"BLOCKED",
"CONFLICT",
"CORRUPT_DATA",
"EXPIRED_RESOURCE",
"INTEGRITY_FAILED",
"INVALID_INPUT",
"LIMIT_EXCEEDED",
"MIGRATION_FAILED",
"NOT_FOUND",
"NOT_READABLE",
"PERMISSION_DENIED",
"POLICY_REJECTED",
"QUOTA_EXCEEDED",
"STALE_RESULT",
"STORAGE_EVICTED",
"UNAVAILABLE",
"UNSUPPORTED",
] as const);
export function isBrowserDataFailureCode(
value: unknown,
): value is BrowserDataFailureCode {
return (
typeof value === "string" &&
(BROWSER_DATA_FAILURE_CODES as readonly string[]).includes(value)
);
}
export type BrowserDataFailureCode =
| "ABORTED"
| "BLOCKED"
| "CONFLICT"
| "CORRUPT_DATA"
| "EXPIRED_RESOURCE"
| "INTEGRITY_FAILED"
| "INVALID_INPUT"
| "LIMIT_EXCEEDED"
| "MIGRATION_FAILED"
| "NOT_FOUND"
| "NOT_READABLE"
| "PERMISSION_DENIED"
| "POLICY_REJECTED"
| "QUOTA_EXCEEDED"
| "STALE_RESULT"
| "STORAGE_EVICTED"
| "UNAVAILABLE"
| "UNSUPPORTED";
(typeof BROWSER_DATA_FAILURE_CODES)[number];
export type BrowserDataOperation =
| "CACHE_ACTIVATE"
@@ -165,10 +165,18 @@ export type ImagePresentationDescriptor = Readonly<{
}>;
export interface ImageCdnPresentationPort {
/**
* BT-IMG-01. The lifetime signal is required.
*
* It used to be optional, so the `PRIMARY_REQUIRED` preset expressed a
* missing signal as a runtime `UNSUPPORTED` result - a hidden preset
* precondition. Requiring it at the type level removes that hidden rule
* instead of discovering it at runtime.
*/
resolve(request: Readonly<{
asset: ImageAssetReference;
preset: ImagePresetReference;
signal?: AbortSignal;
signal: AbortSignal;
}>): Promise<BrowserDataResult<ImagePresentationDescriptor>>;
}
@@ -108,11 +108,30 @@ export interface PresignedTransferReplayGuard {
* of the closed-Result stream. Consumers must not commit a destination until
* the iterable finishes without a failure result.
*/
/**
* BT-PRE-02. Top-level wire protocol for the capability envelope.
*
* Without it, a server that adds or reinterprets a field leaves old and new
* clients decoding the same shape with different meaning, and the resulting
* outage is not classified as a version mismatch. `PRESIGNED_MULTIPART_V1`
* stays as the nested multipart binding protocol.
*/
export const PRESIGNED_TRANSFER_PROTOCOL = "PRESIGNED_TRANSFER_V1" as const;
export type PresignedTransferProtocol = typeof PRESIGNED_TRANSFER_PROTOCOL;
export type PresignedDownloadByteSource = FileByteSource &
Readonly<{
byteLength: number;
capability: PresignedDownloadCapability;
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION";
/**
* BT-PRE-01. Discards the lease. Before the first `stream()` this performs
* no network I/O at all; during streaming it cancels the body and releases
* the timer and listeners exactly once. Every consumer must call it in a
* `finally`, including on a pre-stream failure.
*/
close(): void;
}>;
export interface PresignedDownloadSourcePort {
@@ -40,6 +40,14 @@ export type UploadPartReceipt = UploadPartDescriptor &
receiptToken: string;
}>;
export type PartitionDeleteOutcome =
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
| Readonly<{
state: "PENDING";
effect: "UNKNOWN";
reason: "BLOCKED_DEADLINE";
}>;
export interface UploadRangeReader {
readonly byteLength: number;
readRange(input: Readonly<{
@@ -273,7 +281,13 @@ export interface ResumableUploadCheckpointAdmin {
* Account/logout lifecycle operation for this already-bound opaque partition.
* The adapter closes its connection before deletion and bounds blocked waits.
*/
/**
* BT-UP-03. An IndexedDB `deleteDatabase()` request cannot be cancelled once
* dispatched, so a blocked deadline is not evidence that nothing happened.
* `PENDING` reports the effect honestly as `UNKNOWN`; only pre-dispatch
* problems are ordinary failures.
*/
deletePartition(
signal?: AbortSignal,
): Promise<BrowserDataResult<Readonly<{ state: "DELETED" }>>>;
): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
}
+118 -27
View File
@@ -6,7 +6,17 @@ import {
} from "../adapters/auth/external-session-adapter.ts";
import { createDiagnosticsAdapter } from "../adapters/diagnostics/bounded-diagnostics.ts";
import { createHttpClient } from "../adapters/http/client.ts";
import { createContractHttpExecutor } from "../adapters/http/http-execution-v3.ts";
import {
createContractHttpExecutor,
type HttpExecutionObservation,
} from "../adapters/http/http-execution-v3.ts";
import {
attemptBucket,
durationBucket,
statusGroup,
type DiagnosticRecordInput,
} from "../contracts/diagnostics.ts";
import type { TelemetryEventName } from "../contracts/telemetry.ts";
import { createBrowserCrossContextInvalidationFromHost } from "../adapters/cross-context-invalidation/index.ts";
import {
createTanStackCacheCoordinator,
@@ -19,7 +29,10 @@ import { createBrowserMutationIntentFactory } from "../adapters/platform/browser
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
import { createRestProviderProfile } from "../contracts/rest-profiles.ts";
import {
createRestProviderProfile,
INSTALLED_REST_AUTH_PROFILES,
} from "../contracts/rest-profiles.ts";
import type { ClockPort } from "../application/ports/clock-port.ts";
import type { MutationIntent } from "../contracts/mutation-intent.ts";
import { createInstalledFeatureInputs } from "../features/installed-feature-adapters.ts";
@@ -162,6 +175,89 @@ export function createRuntimeHttpClient(
});
}
/**
* VD-07. Exactly one diagnostic per logical V3 execution and exactly one
* `api.request.failed` telemetry event per terminal non-abort failure.
*
* The projection is closed: only registered context keys and bucketed values
* reach the sinks, and neither sink can change the HTTP outcome, because the
* caller invokes this inside the executor's isolated observation boundary.
*/
export function createHttpObservationProjector(
sinks: Readonly<{
diagnostics: Readonly<{ record(input: DiagnosticRecordInput): void }>;
telemetry: Readonly<{
emit(
eventName: TelemetryEventName,
attributes: Record<string, unknown>,
): void;
}>;
}>,
): (observation: HttpExecutionObservation) => void {
return (observation) => {
const safeAttributes = {
route_id: observation.routeId,
operation_id: observation.operationId,
error_kind: observation.errorKind,
http_status_group: statusGroup(observation.status),
attempt_count_bucket: attemptBucket(observation.attemptCount),
duration_bucket: durationBucket(observation.durationMs),
};
try {
sinks.diagnostics.record({
level: observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
...safeAttributes,
operation: observation.diagnosticsOperation,
outcome: observation.outcome,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
if (!isTerminalNonAbortFailure(observation)) return;
try {
sinks.telemetry.emit("api.request.failed", { ...safeAttributes });
} catch {
// Telemetry cannot change a contract execution outcome.
}
};
}
/**
* LIVE-05. Cancellation and scope fencing are caller- or generation-owned
* decisions, not API failures: they produce a diagnostic once and never
* `api.request.failed`.
*
* A `DEADLINE` owner is the opposite case. Nobody asked for it the API did
* not answer inside the contract's own budget so excluding it would hide
* exactly the outage this event exists to report.
*/
const CALLER_OWNED_CANCELLATION: ReadonlySet<string> = new Set([
"CALLER",
"ROUTE_TRANSITION",
"SCOPE_FENCE",
"APPLICATION_SHUTDOWN",
]);
function isTerminalNonAbortFailure(
observation: HttpExecutionObservation,
): boolean {
if (observation.outcome === "SUCCESS") return false;
if (observation.outcome === "CANCELLED") return false;
if (
observation.cancellationOwner !== undefined &&
CALLER_OWNED_CANCELLATION.has(observation.cancellationOwner)
) {
return false;
}
return !(
observation.outcome === "CONTRACT_VIOLATION" &&
observation.errorKind === "SCOPE_FENCED"
);
}
export async function createRuntimeAdapters(
context: RuntimeAdaptersContext,
) {
@@ -299,7 +395,10 @@ export async function createRuntimeAdapters(
baseUrl: config.API_BASE_URL,
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
fetcher: context.fetcher,
async attachCredentials(operation) {
// §7.7. The installed registry owns Fetch credentials and the exact
// credential-header sets; this collaborator only supplies proof headers.
authProfiles: INSTALLED_REST_AUTH_PROFILES,
async attachCredentials(operation, authContext) {
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
@@ -311,49 +410,36 @@ export async function createRuntimeAdapters(
return Object.freeze({ kind: "UNAUTHENTICATED" as const });
}
try {
const patch = await authSession.credentialPatch({
origin: new URL(config.API_BASE_URL).origin,
method: operation.method,
operationId: operation.operationId,
});
const patch = await authSession.credentialPatch(
{
origin: new URL(config.API_BASE_URL).origin,
method: operation.method,
operationId: operation.operationId,
},
authContext,
);
if (serverStateScope.getPhase() !== "READY") {
return Object.freeze({ kind: "SCOPE_FENCED" as const });
}
return Object.freeze({
kind: "READY" as const,
headers: patch.headers,
credentials: "omit" as const,
});
} catch {
return Object.freeze({ kind: "UNAVAILABLE" as const });
}
},
observe(observation) {
try {
diagnostics.record({
level:
observation.outcome === "SUCCESS" ? "info" : "warn",
eventId: "http.request.completed",
context: {
operation_id: observation.diagnosticsOperation,
outcome: observation.outcome,
attempts: observation.attempts,
certainty: observation.certainty,
},
});
} catch {
// Diagnostics cannot change a contract execution outcome.
}
},
observe: createHttpObservationProjector({ diagnostics, telemetry }),
});
const contractOperations = Object.freeze({
async execute(
operationId: string,
input: unknown,
executionContext: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}> = {},
}>,
) {
const operation =
COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId);
@@ -368,6 +454,7 @@ export async function createRuntimeAdapters(
});
}
const outcome = await contractHttp.execute(operation, input, {
routeId: executionContext.routeId,
scope: serverStateScope.getSnapshot(),
...(executionContext.signal === undefined
? {}
@@ -410,6 +497,10 @@ export async function createRuntimeAdapters(
crossContextInvalidationStatus: () =>
serverStateGeneration.getSnapshot().crossContextStatus(),
dispose() {
// N-04. Telemetry is torn down first: it must stop scheduling and
// delivering before the diagnostics and state dependencies it observes
// are destroyed.
telemetry.dispose();
conditionalValidators.clear();
serverStateScope.dispose();
serverStateGeneration.dispose();
+207
View File
@@ -1,3 +1,7 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
import type { InstalledBoundaryMapper } from "./boundary-mapper.ts";
import type { RuntimeSchemaCodec } from "./schema-registry.ts";
@@ -327,6 +331,209 @@ export function composeBrowserRpcRequestEncoderRegistry(
);
}
/**
* RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves
* `set`, `delete` and `clear` working, so an installed registry could still be
* emptied or re-pointed after the snapshot was validated.
*/
export type InstalledBrowserRpcContractBindings = Readonly<{
operations: ReadOnlyRegistry<string, BrowserRpcOperationV3>;
profiles: ReadOnlyRegistry<string, BrowserRpcProviderProfile>;
schemaCodecs: ReadOnlyRegistry<string, RuntimeSchemaCodec>;
mappers: ReadOnlyRegistry<string, InstalledBoundaryMapper>;
requestEncoders: ReadOnlyRegistry<string, BrowserRpcRequestEncoder>;
runtimeBindings: ReadOnlyRegistry<string, BrowserRpcRuntimeBindingIdentity>;
}>;
/**
* R-04. Parse validate install.
*
* `Readonly` is a TypeScript annotation, not a runtime guarantee, and a source
* registry can be mutated after validation so replay policy, deadlines, byte
* ceilings or transport selection differ from what was checked. Every row is
* therefore copied once into a frozen null-prototype snapshot built from exact
* own data properties. A getter, an extra or symbol key, a malformed descriptor
* or a revoked proxy is a composition-time `TypeError`, and the runtime reads
* only the snapshot afterwards.
*/
function installRegistrySnapshot<Value extends object>(
source: Readonly<Record<string, Value>>,
label: string,
allowedKeys: readonly string[],
): ReadOnlyRegistry<string, Value> {
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
// RPC-03. Own *names*, not just enumerable keys: a non-enumerable own entry
// is as much a smuggled row as an inherited one, and `Object.keys` never
// saw either.
ownKeys = Object.getOwnPropertyNames(source);
symbols = Object.getOwnPropertySymbols(source);
prototype = Reflect.getPrototypeOf(source);
} catch {
throw new TypeError(`Browser RPC ${label} registry is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} registry has symbol keys.`);
}
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} registry has a custom prototype.`);
}
const installed = new Map<string, Value>();
for (const key of ownKeys) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} registry entry is not a data property: ${key}`,
);
}
installed.set(
key,
installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys),
);
}
return createReadOnlyRegistry(installed);
}
function installRowSnapshot<Value extends object>(
row: Value,
label: string,
allowedKeys: readonly string[],
): Value {
if (!row || typeof row !== "object") {
throw new TypeError(`Browser RPC ${label} row is not an object.`);
}
let ownKeys: string[];
let symbols: readonly symbol[];
let prototype: object | null;
try {
ownKeys = Object.getOwnPropertyNames(row);
symbols = Object.getOwnPropertySymbols(row);
prototype = Reflect.getPrototypeOf(row);
} catch {
throw new TypeError(`Browser RPC ${label} row is unreadable.`);
}
if (symbols.length > 0) {
throw new TypeError(`Browser RPC ${label} row has symbol keys.`);
}
// RPC-03. A custom prototype carries fields the name sweep never sees and
// stays live after installation, so the installed row would not be the row
// that was checked.
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`Browser RPC ${label} row has a custom prototype.`);
}
const snapshot = Object.create(null) as Record<string, unknown>;
for (const key of ownKeys) {
if (!allowedKeys.includes(key)) {
throw new TypeError(
`Browser RPC ${label} row has an unexpected key: ${key}`,
);
}
const descriptor = Object.getOwnPropertyDescriptor(row, key);
// Reading an accessor would invoke a getter; refuse without calling it.
if (!descriptor || !("value" in descriptor)) {
throw new TypeError(
`Browser RPC ${label} row key is not a data property: ${key}`,
);
}
const value = descriptor.value as unknown;
snapshot[key] = Array.isArray(value)
? Object.freeze([...value])
: value;
}
return Object.freeze(snapshot) as Value;
}
const OPERATION_KEYS = Object.freeze([
"contractVersion", "operationId", "owner", "protocol", "semantics",
"replayPolicy", "idempotencyKeyPolicy", "idempotencyLevel",
"dataClassification", "runtimeProfileId", "providerId",
"fullyQualifiedService", "method", "rpcKind", "requestMessageId",
"responseMessageId", "descriptorArtifactId", "descriptorDigest",
"requestSchemaId", "responseSchemaId", "requestEncoderId", "mapperId",
"authProfileId", "csrfProfileId", "errorProfileId", "deadlineProfileId",
"retryProfileId", "serverStateProfileId", "maxRequestMessageBytes",
"maxResponseMessageBytes", "maxResponseMessages", "maxTotalResponseBytes",
"maxBufferedBytes", "idleDeadlineMs", "totalDeadlineMs",
] as const);
const PROFILE_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "fixedBaseUrl", "runtimeId",
"runtimeVersion", "runtimeDigest", "protocol", "runtimeKind",
"clientApiKind", "rpcKind", "messageEncoding", "framing", "requestMethod",
"descriptorArtifactId", "descriptorDigest", "allowedProcedures",
"authProfileId", "csrfProfileId", "corsProfileId", "errorProfileId",
"deadlineProfileId", "retryProfileId", "retryOwner", "maxAttempts",
"backoffMs", "retryableFailures", "maxRetryAfterMs", "deadlineDialect",
"cancelDialect", "rawByteCeilingOwner", "streamMessageCompression",
] as const);
const SCHEMA_KEYS = Object.freeze(["schemaId", "parse"] as const);
const MAPPER_KEYS = Object.freeze([
"mapperId", "mapperVersion", "inputSchemaId", "outputContractId", "owner",
"maxOutputItems", "map",
] as const);
const ENCODER_KEYS = Object.freeze([
"encoderId", "operationId", "encode",
] as const);
const RUNTIME_BINDING_KEYS = Object.freeze([
"runtimeProfileId", "providerId", "protocol", "rpcKind",
] as const);
export function installBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): InstalledBrowserRpcContractBindings {
// Parse first. Snapshotting from own data descriptors rejects accessors
// without ever invoking them, so a hostile getter cannot observe validation
// or return a different value to it than to the runtime.
const operations = installRegistrySnapshot(
bindings.operations,
"operation",
OPERATION_KEYS,
);
const profiles = installRegistrySnapshot(
bindings.profiles,
"profile",
PROFILE_KEYS,
);
const schemaCodecs = installRegistrySnapshot(
bindings.schemaCodecs,
"schema",
SCHEMA_KEYS,
);
const mappers = installRegistrySnapshot(
bindings.mappers,
"mapper",
MAPPER_KEYS,
);
const requestEncoders = installRegistrySnapshot(
bindings.requestEncoders,
"encoder",
ENCODER_KEYS,
);
const runtimeBindings = installRegistrySnapshot(
bindings.runtimeBindings ?? {},
"runtime",
RUNTIME_BINDING_KEYS,
);
// Then validate the snapshot, so what was checked is exactly what installs.
validateBrowserRpcContractBindings({
operations: Object.fromEntries(operations),
profiles: Object.fromEntries(profiles),
schemaCodecs: Object.fromEntries(schemaCodecs),
mappers: Object.fromEntries(mappers),
requestEncoders: Object.fromEntries(requestEncoders),
runtimeBindings: Object.fromEntries(runtimeBindings),
});
return Object.freeze({
operations,
profiles,
schemaCodecs,
mappers,
requestEncoders,
runtimeBindings,
});
}
export function validateBrowserRpcContractBindings(
bindings: BrowserRpcContractBindings,
): true {
+174
View File
@@ -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;
}
}
+210 -27
View File
@@ -7,6 +7,13 @@
* applies before a contribution may be composed.
*/
import { INSTALLED_REST_AUTH_PROFILES } from "./rest-profiles.ts";
import {
createReadOnlyRegistry,
exactOwnDataSnapshot,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
/** §7.3 hard ceilings. A contribution may lower these, never raise them. */
export const HTTP_EXECUTION_CEILINGS = Object.freeze({
defaultRequestBytes: 262_144,
@@ -313,6 +320,11 @@ function assertExecutionPolicy(
) {
fail(`${label}: frontend execution policy identity`);
}
// §7.7 / VD-23. A declared profile that the installed registry does not own
// is a composition failure; the executor must never resolve it at runtime.
if (!INSTALLED_REST_AUTH_PROFILES.has(policy.authProfileId)) {
fail(`${label}: unknown authProfileId ${policy.authProfileId}`);
}
if (
!Number.isSafeInteger(policy.requestByteLimit) ||
policy.requestByteLimit < 0 ||
@@ -473,14 +485,113 @@ function assertEventContract(
export type ComposedContractContributions = Readonly<{
contributions: readonly InstalledContractContribution[];
httpByOperationId: ReadonlyMap<
/**
* LIVE-03. Read facades over private stores. The executor resolves an
* operation on every request, so an exported `Map` would let any holder of
* the composed singleton delete or replace a validated row after boot.
*/
httpByOperationId: ReadOnlyRegistry<
string,
InstalledHttpContract<unknown, unknown, unknown>
>;
eventByType: ReadonlyMap<string, InstalledEventContract<unknown, unknown>>;
eventByType: ReadOnlyRegistry<
string,
InstalledEventContract<unknown, unknown>
>;
externalPackages: readonly InstalledContractPackageIdentity[];
}>;
const EXECUTION_POLICY_KEYS = [
"policyId",
"requestByteLimit",
"responseByteLimit",
"totalDeadlineMs",
"retryBudget",
"authProfileId",
"diagnosticsOperation",
] as const;
const HTTP_CONTRACT_KEYS = [
"operationId",
"method",
"pathTemplate",
"inputValidator",
"outputValidator",
"problemValidator",
"acceptedStatuses",
"emptyBodyStatuses",
"retrySemantics",
"requestBody",
"responseBody",
"commandRecovery",
"commandEffect",
"projectRequest",
] as const;
const COMMAND_RECOVERY_KEYS = [
"mode",
"operationIdentityField",
"inspectOperationId",
] as const;
/**
* LIVE-03. Composition is the last point at which a contribution row is
* trusted, so the registry keeps an exact own-data copy rather than the
* caller's object. A later mutation of the source including one that swaps a
* deadline or a credential policy cannot reach what the executor reads.
*
* Validators and the descriptor-owned `projectRequest` stay by reference: they
* are behaviour the contribution owns, not data this repository re-derives.
*/
function snapshotHttpContract(
installed: InstalledHttpContract<unknown, unknown, unknown>,
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>(
row.frontend,
EXECUTION_POLICY_KEYS,
EXECUTION_POLICY_KEYS,
reject,
);
const source = exactOwnDataSnapshot<
InstalledHttpContract<unknown, unknown, unknown>["contract"]
>(row.contract, HTTP_CONTRACT_KEYS, HTTP_CONTRACT_KEYS, reject);
const contract = Object.freeze({
...source,
acceptedStatuses: Object.freeze([...source.acceptedStatuses]),
emptyBodyStatuses: Object.freeze([...source.emptyBodyStatuses]),
commandRecovery:
source.commandRecovery === null
? null
: exactOwnDataSnapshot<CommandRecoveryDescriptor>(
source.commandRecovery,
COMMAND_RECOVERY_KEYS,
["mode", "operationIdentityField"],
reject,
),
});
return Object.freeze({ contract, frontend });
}
function snapshotEventContract(
event: InstalledEventContract<unknown, unknown>,
label: string,
): InstalledEventContract<unknown, unknown> {
return exactOwnDataSnapshot<InstalledEventContract<unknown, unknown>>(
event,
["eventType", "envelopeValidator", "payloadValidator"],
["eventType", "envelopeValidator", "payloadValidator"],
(detail) => fail(`${label}: ${detail}`),
);
}
/**
* §4.8§4.9. The only place installed contributions become a runtime registry.
* Every bound is checked before composition; a violation stops the boot rather
@@ -500,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" ||
@@ -520,53 +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, installed);
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, event);
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) =>
@@ -574,9 +757,9 @@ export function composeContractContributions(
);
return Object.freeze({
contributions: Object.freeze([...contributions]),
httpByOperationId,
eventByType,
contributions: Object.freeze(installedContributions),
httpByOperationId: createReadOnlyRegistry(httpByOperationId),
eventByType: createReadOnlyRegistry(eventByType),
externalPackages: Object.freeze(externalPackages),
});
}
+37 -4
View File
@@ -23,6 +23,39 @@ function validBoundedString(value: unknown, maxBytes: number): value is string {
);
}
/**
* N-06. The single idempotency-key authority shared by the V2 compatibility
* client and the V3 executor.
*
* A caller-supplied value is never trimmed, regenerated or silently dropped:
* an invalid key is a contract violation, because replaying a keyed command
* without its key is exactly the unsafe behaviour the key exists to prevent.
*/
export function isValidIdempotencyKey(value: unknown): value is string {
if (
!validBoundedString(
value,
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
)
) {
return false;
}
for (const character of value) {
const codePoint = character.codePointAt(0) ?? 0;
if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return false;
}
}
return true;
}
export function defineIdempotencyKey(value: unknown): string {
if (!isValidIdempotencyKey(value)) {
throw new TypeError("Idempotency key is invalid.");
}
return value;
}
export function defineMutationIntent(intent: MutationIntent): MutationIntent {
if (
!validBoundedString(
@@ -37,11 +70,11 @@ export function defineMutationIntent(intent: MutationIntent): MutationIntent {
intent.canonicalInputIdentity,
MUTATION_INTENT_BOUNDS.canonicalInputIdentityMaxBytes,
) ||
// OPT-NET-02. Intent definition and executor admission share one key
// authority; a second, looser rule here is how a control character reaches
// an `Idempotency-Key` header.
(intent.idempotencyKey !== undefined &&
!validBoundedString(
intent.idempotencyKey,
MUTATION_INTENT_BOUNDS.idempotencyKeyMaxBytes,
)) ||
!isValidIdempotencyKey(intent.idempotencyKey)) ||
!Number.isFinite(intent.createdAtMonotonicMs) ||
intent.createdAtMonotonicMs < 0
) {
+89
View File
@@ -0,0 +1,89 @@
/**
* LIVE-02 / LIVE-03. A composed registry is authority, not data.
*
* `Object.freeze(new Map(...))` only freezes the wrapper object: `set`,
* `delete` and `clear` still reach the backing store, so anything holding the
* exported singleton can empty a validated registry after composition and
* silently change what every later request resolves. The fix is structural
* the store stays private in a closure and only read operations are exported.
*
* The facade is deliberately *not* a `Map` instance, so borrowing a mutator
* (`Map.prototype.clear.call(facade)`) fails on the missing internal slot
* rather than succeeding.
*/
export type ReadOnlyRegistry<Key, Value> = Readonly<{
get(key: Key): Value | undefined;
has(key: Key): boolean;
keys(): IterableIterator<Key>;
values(): IterableIterator<Value>;
entries(): IterableIterator<readonly [Key, Value]>;
forEach(visit: (value: Value, key: Key) => void): void;
readonly size: number;
[Symbol.iterator](): IterableIterator<readonly [Key, Value]>;
}>;
export function createReadOnlyRegistry<Key, Value>(
entries: Iterable<readonly [Key, Value]>,
): ReadOnlyRegistry<Key, Value> {
const store = new Map<Key, Value>(entries as Iterable<[Key, Value]>);
const facade = {
get: (key: Key) => store.get(key),
has: (key: Key) => store.has(key),
keys: () => store.keys(),
values: () => store.values(),
entries: () => store.entries(),
forEach: (visit: (value: Value, key: Key) => void) => {
for (const [key, value] of store) visit(value, key);
},
get size() {
return store.size;
},
[Symbol.iterator]: () => store.entries(),
};
return Object.freeze(facade) as ReadOnlyRegistry<Key, Value>;
}
/**
* Rejects anything that is not an exact own-data record over `allowedKeys`.
*
* A validated row must survive the validation: an accessor re-runs on every
* later read, an inherited field can be replaced through the prototype, and a
* symbol-keyed field escapes a name-based sweep entirely. Only own data
* descriptors are copied, and the result is frozen.
*/
export function exactOwnDataSnapshot<Shape extends object>(
source: unknown,
allowedKeys: readonly (keyof Shape & string)[],
requiredKeys: readonly (keyof Shape & string)[],
onViolation: (detail: string) => never,
): Readonly<Shape> {
if (source === null || typeof source !== "object") {
onViolation("object required");
}
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)) {
if (!allowed.has(key)) onViolation(`unexpected field ${key}`);
const descriptor = Object.getOwnPropertyDescriptor(source, key);
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) {
if (!Object.hasOwn(snapshot, key)) onViolation(`missing field ${key}`);
}
return Object.freeze(snapshot) as Readonly<Shape>;
}
+151 -1
View File
@@ -1,3 +1,8 @@
import {
createReadOnlyRegistry,
type ReadOnlyRegistry,
} from "./read-only-registry.ts";
export type FetchCredentialsMode = "omit" | "same-origin" | "include";
export type RestProviderProfile = Readonly<{
@@ -8,13 +13,42 @@ export type RestProviderProfile = Readonly<{
referrerPolicy: "no-referrer";
}>;
/**
* VD-23. The complete closed set of headers a credential owner may contribute.
* Transport-owned headers (`accept`, `content-type`, `idempotency-key`) and
* every forbidden request header are deliberately absent.
*/
export const CREDENTIAL_HEADER_NAMES = Object.freeze([
"authorization",
"x-csrf-token",
"x-tenant-context",
] as const);
export type CredentialHeaderName = (typeof CREDENTIAL_HEADER_NAMES)[number];
export type RestAuthProfile = Readonly<{
authProfileId: string;
transport: "ANONYMOUS" | "BEARER_HEADER" | "SAME_ORIGIN_COOKIE";
credentials: FetchCredentialsMode;
allowedCredentialHeaders: readonly ("authorization" | "x-csrf-token")[];
allowedCredentialHeaders: readonly CredentialHeaderName[];
/**
* Proof headers the transport must observe before dispatch. A missing entry
* fails closed with zero `fetch()` calls rather than sending an anonymous
* request under an authenticated profile.
*/
requiredCredentialHeaders: readonly CredentialHeaderName[];
}>;
/**
* LIVE-02. A read facade over a private store, never a `Map`. The executor
* resolves a profile on every request, so a post-installation `clear()` would
* otherwise turn every authenticated call into `UNKNOWN_AUTH_PROFILE`.
*/
export type InstalledRestAuthProfiles = ReadOnlyRegistry<
string,
RestAuthProfile
>;
export type RestCsrfProfile = Readonly<{
csrfProfileId: string;
mode: "NONE" | "HEADER";
@@ -27,15 +61,131 @@ export const REST_AUTH_PROFILES = Object.freeze({
transport: "BEARER_HEADER",
credentials: "omit",
allowedCredentialHeaders: Object.freeze(["authorization"] as const),
requiredCredentialHeaders: Object.freeze(["authorization"] as const),
}),
ANONYMOUS: Object.freeze({
authProfileId: "ANONYMOUS",
transport: "ANONYMOUS",
credentials: "omit",
allowedCredentialHeaders: Object.freeze([]),
requiredCredentialHeaders: Object.freeze([]),
}),
} satisfies Readonly<Record<string, RestAuthProfile>>);
function isCredentialHeaderName(value: unknown): value is CredentialHeaderName {
return (
typeof value === "string" &&
(CREDENTIAL_HEADER_NAMES as readonly string[]).includes(value)
);
}
function exactHeaderSet(
names: unknown,
label: string,
): readonly CredentialHeaderName[] {
if (!Array.isArray(names)) {
throw new TypeError(`REST auth profile ${label} must be an array.`);
}
const seen = new Set<string>();
for (const name of names) {
if (!isCredentialHeaderName(name) || seen.has(name)) {
throw new TypeError(`REST auth profile ${label} is not an exact set.`);
}
seen.add(name);
}
return Object.freeze([...(names as readonly CredentialHeaderName[])]);
}
/**
* §7.7 / VD-23. Installs the composition-owned auth profile registry once.
*
* The registry not a credential collaborator owns Fetch `credentials` and
* the exact allowed/required credential-header sets. An incoherent profile is a
* composition failure, never a runtime downgrade.
*/
export function installRestAuthProfileRegistry(
profiles: Readonly<Record<string, RestAuthProfile>> = REST_AUTH_PROFILES,
): InstalledRestAuthProfiles {
const installed = new Map<string, RestAuthProfile>();
for (const [key, candidate] of Object.entries(profiles)) {
if (!candidate || typeof candidate !== "object") {
throw new TypeError(`REST auth profile ${key} is not an object.`);
}
const authProfileId = candidate.authProfileId;
if (
typeof authProfileId !== "string" ||
authProfileId.length === 0 ||
authProfileId !== key
) {
throw new TypeError(`REST auth profile ${key} has a mismatched identity.`);
}
const allowed = exactHeaderSet(
candidate.allowedCredentialHeaders,
"allowedCredentialHeaders",
);
const required = exactHeaderSet(
candidate.requiredCredentialHeaders,
"requiredCredentialHeaders",
);
if (!required.every((name) => allowed.includes(name))) {
throw new TypeError(
`REST auth profile ${key} requires a header it does not allow.`,
);
}
const credentials = candidate.credentials;
if (!["omit", "same-origin", "include"].includes(credentials)) {
throw new TypeError(`REST auth profile ${key} has invalid credentials.`);
}
switch (candidate.transport) {
case "ANONYMOUS":
if (
credentials !== "omit" ||
allowed.length > 0 ||
required.length > 0
) {
throw new TypeError(
`Anonymous REST auth profile ${key} cannot carry credentials.`,
);
}
break;
case "BEARER_HEADER":
if (credentials !== "omit" || !required.includes("authorization")) {
throw new TypeError(
`Bearer REST auth profile ${key} must require authorization with omitted credentials.`,
);
}
break;
case "SAME_ORIGIN_COOKIE":
if (credentials === "omit" || allowed.includes("authorization")) {
throw new TypeError(
`Cookie REST auth profile ${key} must send ambient credentials without a bearer header.`,
);
}
break;
default:
throw new TypeError(`REST auth profile ${key} has unknown transport.`);
}
installed.set(
authProfileId,
Object.freeze({
authProfileId,
transport: candidate.transport,
credentials,
allowedCredentialHeaders: allowed,
requiredCredentialHeaders: required,
}),
);
}
if (installed.size === 0) {
throw new TypeError("REST auth profile registry cannot be empty.");
}
return createReadOnlyRegistry(installed);
}
/** The single installed registry every composition root shares. */
export const INSTALLED_REST_AUTH_PROFILES: InstalledRestAuthProfiles =
installRestAuthProfileRegistry();
export const REST_CSRF_PROFILES = Object.freeze({
NO_CSRF_BEARER: Object.freeze({
csrfProfileId: "NO_CSRF_BEARER",
@@ -0,0 +1,243 @@
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
/**
* SW-05. Runtime-neutral static manifest codec.
*
* The generator, the Node build gate and the Service Worker all need the same
* answer to "is this manifest exactly the one that was generated?". This module
* owns the exact row keys, the content-type and extension allowlist, the
* root-relative URL rule and the length-prefixed canonical byte serialization.
*
* It deliberately contains no digest implementation: the generator and build
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
* the algorithm is never written twice.
*/
export type StaticAssetRow = Readonly<{
url: string;
sha256: string;
bytes: number;
contentType: string;
}>;
export type StaticAssetManifest = Readonly<{
schemaVersion: 1;
buildId: string;
releaseId: string;
setDigest: string;
assets: readonly StaticAssetRow[];
}>;
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
/**
* SW-RR-03. The single authoritative extension content type table.
*
* The build generator and this decoder must agree exactly: an extension the
* generator emits but the decoder refuses turns a correct build into a runtime
* contract failure, and the reverse admits an asset kind no build produces.
* `.json` is deliberately absent every JSON file in a build output is a
* control document (runtime config, release manifest, schema), not a cacheable
* static asset, and the generator excludes them by name.
*/
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
Record<string, string>
> = Object.freeze({
".css": "text/css",
".js": "text/javascript",
".mjs": "text/javascript",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff2": "font/woff2",
});
const MANIFEST_KEYS = Object.freeze([
"assets",
"buildId",
"releaseId",
"schemaVersion",
"setDigest",
] as const);
const ASSET_ROW_KEYS = Object.freeze([
"bytes",
"contentType",
"sha256",
"url",
] as const);
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
/** Root-relative, hashed, no dot segments, no query and no fragment. */
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
/**
* SW-02. The one canonical asset-path predicate, shared by the build generator
* and this decoder. Sharing only the extension table left the two with
* different path grammars: the generator emitted a URL for a directory
* containing a space, an `@` or a percent-escape, and the decoder then refused
* the manifest it had just produced, failing the release build.
*/
export function isCanonicalStaticAssetUrl(url: string): boolean {
return (
typeof url === "string" &&
ASSET_URL.test(url) &&
!url.includes("/../") &&
!url.includes("/./")
);
}
export type StaticManifestDecodeFailure = Readonly<{
reason: string;
}>;
export type StaticManifestDecodeResult =
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
function exactKeys(
value: unknown,
allowed: readonly string[],
): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (Object.getOwnPropertySymbols(record).length > 0) return null;
const keys = Object.keys(record).sort();
return keys.length === allowed.length &&
keys.every((key, index) => key === allowed[index])
? record
: null;
}
function extensionOf(url: string): string {
const lastSlash = url.lastIndexOf("/");
const base = url.slice(lastSlash + 1);
const dot = base.lastIndexOf(".");
return dot < 0 ? "" : base.slice(dot).toLowerCase();
}
/**
* Decodes a generated manifest with every row rule applied. It does not verify
* `setDigest`; callers pair it with their own digest implementation over
* `canonicalStaticManifestBytes`.
*/
export function decodeStaticAssetManifest(
value: unknown,
): StaticManifestDecodeResult {
const record = exactKeys(value, MANIFEST_KEYS);
if (!record) return failure("manifest keys are not exact");
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
if (
typeof record.buildId !== "string" ||
!IDENTITY.test(record.buildId) ||
typeof record.releaseId !== "string" ||
!IDENTITY.test(record.releaseId)
) {
return failure("buildId or releaseId is invalid");
}
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
return failure("setDigest is not a lower-hex sha256");
}
if (!Array.isArray(record.assets)) return failure("assets must be an array");
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
return failure("asset count exceeds its bound");
}
const rows: StaticAssetRow[] = [];
const seen = new Set<string>();
let totalBytes = 0;
let previousUrl: string | null = null;
for (const candidate of record.assets) {
const row = exactKeys(candidate, ASSET_ROW_KEYS);
if (!row) return failure("asset row keys are not exact");
const { url, sha256, bytes, contentType } = row;
if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) {
return failure("asset url must be root-relative without dot segments");
}
if (seen.has(url)) return failure("asset urls must be unique");
// A sorted set makes the canonical bytes independent of directory order.
if (previousUrl !== null && url <= previousUrl) {
return failure("asset urls must be sorted");
}
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
return failure("asset sha256 is not a lower-hex sha256");
}
if (
typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
) {
return failure("asset byte length is invalid");
}
if (typeof contentType !== "string") {
return failure("asset content type is invalid");
}
const expectedContentType =
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
if (!expectedContentType || expectedContentType !== contentType) {
return failure("asset extension and content type do not match");
}
totalBytes += bytes;
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
return failure("asset set exceeds its byte bound");
}
seen.add(url);
previousUrl = url;
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
}
return Object.freeze({
ok: true as const,
manifest: Object.freeze({
schemaVersion: 1 as const,
buildId: record.buildId,
releaseId: record.releaseId,
setDigest: record.setDigest,
assets: Object.freeze(rows),
}),
});
}
/**
* The exact bytes both the Node generator and the worker hash. A reordered
* directory listing, a renamed field or a changed byte length all change these
* bytes; nothing else does.
*/
export function canonicalStaticManifestBytes(
assets: readonly StaticAssetRow[],
): Uint8Array {
const encoder = new TextEncoder();
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
for (const asset of assets) {
parts.push(lengthPrefixed(encoder, asset.url));
parts.push(lengthPrefixed(encoder, asset.sha256));
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
parts.push(lengthPrefixed(encoder, asset.contentType));
}
let total = 0;
for (const part of parts) total += part.byteLength;
const bytes = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.byteLength;
}
return bytes;
}
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
const encoded = encoder.encode(value);
const prefix = encoder.encode(`${encoded.byteLength}:`);
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
combined.set(prefix, 0);
combined.set(encoded, prefix.byteLength);
return combined;
}
function failure(reason: string): StaticManifestDecodeResult {
return Object.freeze({
ok: false as const,
error: Object.freeze({ reason }),
});
}
+12
View File
@@ -69,6 +69,18 @@ export const STORAGE_REGISTRY = Object.freeze({
migration: "discard",
quotaFallback: "feature-disable",
}),
CACHE_INVALIDATION_PULSE: defineStorageKey({
logicalName: "CACHE_INVALIDATION_PULSE",
scope: "cache-invalidation",
name: "pulse",
backend: "localStorage",
classification: "opaque-cache",
schemaVersion: 1,
valueCodec: "opaque-string-v1",
ttl: null,
migration: "discard",
quotaFallback: "no-persist",
}),
AUTH_TOKEN: defineStorageKey({
logicalName: "AUTH_TOKEN",
scope: "auth",
+31
View File
@@ -161,10 +161,41 @@ export type WebPushObservationEvent =
| "web_push_click_dispatched"
| "web_push_association_revoked";
/**
* WP-06. Bounded fan-out is a deliberate policy, but reporting a truncated pass
* as plain success hid the fact that only part of the set was handled.
*/
export type WebPushCountBucket =
| "0"
| "1_8"
| "9_32"
| "33_64"
| "GT_64";
export function webPushCountBucket(count: number): WebPushCountBucket {
if (!Number.isFinite(count) || count <= 0) return "0";
if (count <= 8) return "1_8";
if (count <= 32) return "9_32";
if (count <= 64) return "33_64";
return "GT_64";
}
/**
* WP-07. Certainty of a user-visible native effect. It is evidence only and
* never authorizes a retry.
*/
export type WebPushNativeEffectCertainty =
| "CONFIRMED"
| "NOT_APPLIED"
| "MAYBE_APPLIED";
export type WebPushObservation = Readonly<{
event: WebPushObservationEvent;
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
reason?: WebPushFailureCode | WebPushUnavailableReason;
countBucket?: WebPushCountBucket;
truncated?: boolean;
nativeEffect?: WebPushNativeEffectCertainty;
}>;
export interface WebPushObserver {
@@ -23,7 +23,8 @@ export type InstalledContractOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context?: Readonly<{
context: Readonly<{
routeId: string;
signal?: AbortSignal;
intent?: MutationIntent;
}>,
@@ -48,6 +49,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
operationId,
input,
{
// §7.4. The gateway owns the low-cardinality route identity; losing
// it here is what made every V3 diagnostic unattributable.
routeId: request.routeId,
...(signal === undefined ? {} : { signal }),
...(intent === undefined ? {} : { intent }),
},
@@ -117,6 +121,15 @@ function projectExecutionOutcome(
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "AUTH_INTEGRATION_FAILURE":
// §7.7. A configuration or collaborator breach, not a session state, so
// it must not drive the re-authentication surface.
return failure(
"AUTH_INTEGRATION_FAILURE",
operationId,
outcome.reason,
{ effect: outcome.effect },
);
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"