fix: complete presigned capability and upload transport contracts
BT-PRE-02: add the top-level PRESIGNED_TRANSFER_V1 protocol literal to the capability request and response. A missing, V0 or V2 envelope is closed as POLICY_REJECTED before the vault registers anything. The server negotiates by request shape; fields are never dual-emitted into a strict decoder, and the nested PRESIGNED_MULTIPART_V1 binding protocol is unchanged. BT-PRE-03: aborting a controller does not settle a fetch that ignores its signal, so both presigned scopes now race the task, cancel a late response body and survive a throwing scheduler without leaking the external abort listener. BT-PRE-04: the vault owns its registration invariants, re-checking method, href/origin/path agreement, embedded credentials, byte bounds, digest shape and expiry, so a second issuer cannot register a weaker capability of the same type. BT-PRE-05: decode each path segment once and require it to round-trip through the canonical uppercase percent encoder, closing %2f, %5c, %252e%252e, mixed-case escapes and encoded NUL while still admitting valid opaque UTF-8 segments. BT-UP-02: inject and snapshot the upload transport clock and scheduler, so Retry-After delta-seconds and HTTP-date resolve against the same captured now and a clock rollback clamps to zero instead of producing a negative delay. BT-IMG-01: make the image resolve() lifetime signal required, replacing the hidden PRIMARY_REQUIRED preset precondition with a type-level one, and add the negative typecheck fixture and gate that prove it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
976c8a8da4
commit
000a2581af
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
PRESIGNED_TRANSFER_PROTOCOL,
|
||||
} from "../../../application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type {
|
||||
PresignedDownloadCapability,
|
||||
PresignedTransferBinding,
|
||||
@@ -185,7 +188,8 @@ export function createPresignedCapabilityHttpProvider(
|
||||
}
|
||||
const scope = createAbortScope(signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const response = await fetcher(endpoint, {
|
||||
const raced = await scope.race(
|
||||
fetcher(endpoint, {
|
||||
method: "POST",
|
||||
credentials,
|
||||
redirect: "error",
|
||||
@@ -196,6 +200,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 +216,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 +453,7 @@ function validateCapabilityPayload(
|
||||
try {
|
||||
const payload = strictRecord(value, [
|
||||
"allowedQueryParameters",
|
||||
"protocol",
|
||||
"binding",
|
||||
"byteLength",
|
||||
"capabilityReceipt",
|
||||
@@ -460,6 +474,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.");
|
||||
}
|
||||
@@ -854,6 +873,19 @@ function statusFailure(
|
||||
});
|
||||
}
|
||||
|
||||
/** BT-PRE-03. The scope ended before the task settled. */
|
||||
const SCOPE_ENDED = Symbol("presigned-capability-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);
|
||||
}
|
||||
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
@@ -862,22 +894,72 @@ function createAbortScope(
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
try {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, timeoutMs);
|
||||
let timer: unknown;
|
||||
try {
|
||||
timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
}, timeoutMs);
|
||||
} catch {
|
||||
// BT-PRE-03. A throwing scheduler must not leak the external listener.
|
||||
releaseListener();
|
||||
timer = undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
/** BT-PRE-03. Bounds a fetch that ignores its signal. */
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
task.catch(() => {});
|
||||
if (controller.signal.aborted) {
|
||||
compensateLateResponse(task);
|
||||
return SCOPE_ENDED;
|
||||
}
|
||||
const ended = new Promise<typeof SCOPE_ENDED>((resolve) => {
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(SCOPE_ENDED),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const raced = await Promise.race([task, ended]);
|
||||
if (raced === SCOPE_ENDED) compensateLateResponse(task);
|
||||
return raced;
|
||||
},
|
||||
release() {
|
||||
scheduler.clearTimeout(timer);
|
||||
external.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
if (timer !== undefined) scheduler.clearTimeout(timer as never);
|
||||
} catch {
|
||||
// Cleanup failures cannot change the classified result.
|
||||
}
|
||||
releaseListener();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 +1165,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 {}
|
||||
|
||||
@@ -109,6 +109,59 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-04. 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 (
|
||||
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 (
|
||||
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();
|
||||
}
|
||||
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 +169,12 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
if (disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
// 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(registration);
|
||||
if (invalid) return invalid;
|
||||
pruneExpired();
|
||||
if (
|
||||
byReceipt.has(registration.capabilityReceipt) ||
|
||||
|
||||
@@ -297,16 +297,22 @@ export function createPresignedTransferExecutor(
|
||||
|
||||
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,
|
||||
@@ -484,7 +490,20 @@ function createDownloadSource(input: Readonly<{
|
||||
activeScope = scope;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await input.start(scope);
|
||||
// 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();
|
||||
@@ -984,6 +1003,19 @@ 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);
|
||||
}
|
||||
|
||||
function createAbortScope(
|
||||
external: AbortSignal,
|
||||
timeoutMs: number,
|
||||
@@ -994,25 +1026,63 @@ function createAbortScope(
|
||||
let released = false;
|
||||
const onAbort = () => controller.abort(external.reason);
|
||||
const releaseListener = () => {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
external.removeEventListener("abort", onAbort);
|
||||
} catch {
|
||||
// A hostile signal facade cannot block the rest of cleanup.
|
||||
}
|
||||
};
|
||||
external.addEventListener("abort", onAbort, { once: true });
|
||||
if (external.aborted) onAbort();
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
let timer: unknown;
|
||||
try {
|
||||
timer = scheduler.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort("timeout");
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
} catch {
|
||||
// BT-PRE-03. A scheduler that throws must not leave the external listener
|
||||
// attached; the scope stays bounded by the caller signal alone.
|
||||
releaseListener();
|
||||
}, timeoutMs);
|
||||
timer = undefined;
|
||||
}
|
||||
return Object.freeze({
|
||||
signal: controller.signal,
|
||||
timedOut: () => timedOut,
|
||||
/**
|
||||
* BT-PRE-03. Aborting the controller does not settle a fetch that ignores
|
||||
* its signal, so the caller-facing wait is raced against the scope and a
|
||||
* late `Response` has its body cancelled.
|
||||
*/
|
||||
async race<Value>(task: Promise<Value>): Promise<Value | typeof SCOPE_ENDED> {
|
||||
task.catch(() => {});
|
||||
if (controller.signal.aborted) {
|
||||
compensateLateResponse(task);
|
||||
return SCOPE_ENDED;
|
||||
}
|
||||
const ended = new Promise<typeof SCOPE_ENDED>((resolve) => {
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(SCOPE_ENDED),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const raced = await Promise.race([task, ended]);
|
||||
if (raced === SCOPE_ENDED) compensateLateResponse(task);
|
||||
return raced;
|
||||
},
|
||||
abort(reason?: unknown) {
|
||||
controller.abort(reason);
|
||||
},
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
scheduler.clearTimeout(timer);
|
||||
try {
|
||||
if (timer !== undefined) scheduler.clearTimeout(timer as never);
|
||||
} catch {
|
||||
// Cleanup failures cannot change the classified result.
|
||||
}
|
||||
releaseListener();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,6 +32,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 +75,31 @@ 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.");
|
||||
}
|
||||
const timeoutMs = boundedPositiveInteger(
|
||||
input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
1,
|
||||
@@ -139,7 +171,7 @@ export function createResumableUploadFetchJsonTransport(
|
||||
"NONE",
|
||||
);
|
||||
}
|
||||
const attempt = createFetchAttempt(signal, timeoutMs);
|
||||
const attempt = createFetchAttempt(signal, timeoutMs, scheduler);
|
||||
try {
|
||||
const fetchPromise = fetcher(endpoint, {
|
||||
method: "POST",
|
||||
@@ -188,6 +220,7 @@ export function createResumableUploadFetchJsonTransport(
|
||||
response,
|
||||
operation,
|
||||
maxRetryAfterMs,
|
||||
safeNowEpochMs(),
|
||||
);
|
||||
cancelResponseBody(response);
|
||||
return failed;
|
||||
@@ -473,6 +506,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 +529,7 @@ function statusFailure(
|
||||
if (response.status === 429) {
|
||||
const retryAfterMs = parseRetryAfter(
|
||||
response.headers.get("retry-after"),
|
||||
nowEpochMs,
|
||||
);
|
||||
return retryAfterMs !== null && retryAfterMs <= maxRetryAfterMs
|
||||
? failure(
|
||||
@@ -548,6 +583,10 @@ type FetchAttempt = Readonly<{
|
||||
function createFetchAttempt(
|
||||
parent: AbortSignal,
|
||||
timeoutMs: number,
|
||||
scheduler: Readonly<{
|
||||
setTimeout(callback: () => void, delayMs: number): unknown;
|
||||
clearTimeout(handle: unknown): void;
|
||||
}>,
|
||||
): FetchAttempt {
|
||||
const controller = new AbortController();
|
||||
let terminalKind: FetchAttemptTerminal["kind"] | null = null;
|
||||
@@ -568,7 +607,7 @@ function createFetchAttempt(
|
||||
const abort = () => finish("ABORT");
|
||||
parent.addEventListener("abort", abort, { once: true });
|
||||
if (parent.aborted) abort();
|
||||
const timer = setTimeout(() => {
|
||||
const timer = scheduler.setTimeout(() => {
|
||||
finish("TIMEOUT");
|
||||
}, timeoutMs);
|
||||
return Object.freeze({
|
||||
@@ -579,7 +618,7 @@ function createFetchAttempt(
|
||||
// BT-UP-01. Cleanup is best effort and must never replace the already
|
||||
// classified terminal result with a rejection.
|
||||
try {
|
||||
clearTimeout(timer);
|
||||
scheduler.clearTimeout(timer);
|
||||
} catch {
|
||||
// A hostile scheduler cannot block listener release below.
|
||||
}
|
||||
@@ -633,7 +672,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);
|
||||
@@ -641,9 +688,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 {
|
||||
|
||||
@@ -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,6 +108,18 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user