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:
DongHyeonka
2026-08-14 02:19:00 +09:00
co-authored by Claude Opus 5
parent 976c8a8da4
commit 000a2581af
13 changed files with 683 additions and 43 deletions
@@ -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 {}