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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 12:04:58 +09:00
co-authored by Claude Opus 5
parent 002ba3624e
commit 4bff9ca151
142 changed files with 23010 additions and 1544 deletions
@@ -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
) {