fix: put presigned transfer work inside one owned abort scope

TR-RR-05. The shared abortable-operation primitive now distinguishes VALUE,
REJECTED and TERMINAL, so a collaborator's own rejection is no longer forged
into a cancellation and race() always names the same first owner terminal()
reports. The caller signal and scheduler are captured once, so replacing a
method after construction cannot change how an in-flight operation is bounded.
A scheduler that cannot install the deadline is itself terminal: previously it
released the caller listener and left no owner, which made every later abort
invisible. Late values are compensated exactly once.

Both presigned subsystems, which each carried their own copy of these
mechanics, are now projections of that primitive — giving it real production
importers rather than a shared helper nobody used.

TR-RR-01. close() on an active download aborts the scope instead of only
dropping listeners, so a fetch or read already in flight actually stops. The
consumer's stream signal joins the operation's ownership before any I/O begins,
so an already-aborted consumer no longer causes one network request first.

TR-RR-02. The upload abort scope is created before the digest, and the digest
races the caller and the deadline like every other step. A non-settling hash can
no longer hold put() open, and the vault claim and the network call happen only
after the owner is re-checked.

TR-RR-03. A capability registration is a versioned exact union validated at
registration time: the protocol version, HTTPS only, exact own-data fields, a
2xx expected status, and no ambient credential or cookie header — a presigned
URL carries its own authorization, and a session header alongside it would send
the user's credentials to that origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 17:06:46 +09:00
co-authored by Claude Opus 5
parent c0f53d1855
commit 46e067e555
6 changed files with 547 additions and 244 deletions
@@ -1,9 +1,11 @@
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 {
@@ -16,7 +18,51 @@ export type PresignedHeaderBinding = Readonly<{
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;
@@ -110,8 +156,8 @@ export function createPresignedCapabilityVault(options: Readonly<{
}
/**
* BT-PRE-04. Runtime invariants every registration must satisfy, regardless
* of which issuer produced it.
* BT-PRE-04 / TR-RR-03. Runtime invariants every registration must satisfy,
* regardless of which issuer produced it.
*/
function validatePresignedCapabilityRegistration(
registration: PresignedCapabilityRegistration,
@@ -121,6 +167,31 @@ export function createPresignedCapabilityVault(options: Readonly<{
recovery: "REISSUE_CAPABILITY",
});
if (!registration || typeof registration !== "object") return invalid();
// TR-RR-03. Exact own data only: an accessor re-runs on every later read,
// an inherited field can be replaced through the prototype, and a symbol
// key escapes a name-based sweep. The vault validates what the executor
// will read, so it must read what it validated.
try {
if (Object.getOwnPropertySymbols(registration).length > 0) {
return invalid();
}
const names = Object.getOwnPropertyNames(registration).sort();
if (
names.length !== REGISTRATION_KEYS.length ||
names.some((name, index) => name !== REGISTRATION_KEYS[index])
) {
return invalid();
}
for (const name of names) {
const descriptor = Object.getOwnPropertyDescriptor(registration, name);
if (!descriptor || !("value" in descriptor)) return invalid();
}
} catch {
return invalid();
}
if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
return invalid();
}
if (
typeof registration.capabilityReceipt !== "string" ||
registration.capabilityReceipt.length === 0 ||
@@ -137,6 +208,10 @@ export function createPresignedCapabilityVault(options: Readonly<{
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 ||
@@ -146,6 +221,32 @@ export function createPresignedCapabilityVault(options: Readonly<{
) {
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 (
!header ||
typeof header.name !== "string" ||
typeof header.value !== "string" ||
FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase())
) {
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 ||
@@ -203,6 +304,7 @@ export function createPresignedCapabilityVault(options: Readonly<{
}) as PresignedTransferCapability;
const binding: PresignedCapabilityBinding = Object.freeze({
capability,
protocol: PRESIGNED_TRANSFER_PROTOCOL,
capabilityReceipt: capability.capabilityReceipt,
method: capability.method,
binding: capability.binding,