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:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -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
@@ -7,7 +7,9 @@ export {
|
||||
type BrowserRpcRuntimeDependencies,
|
||||
} from "./browser-rpc-runtime.ts";
|
||||
export {
|
||||
decodeServerStreamLease,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcServerStreamLease,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
type BrowserRpcTransportCall,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user