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
@@ -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();
},
});