The capability vault checked an issuer's registration and then read it again to store it, including its nested header rows. A stateful issuer could show an allowed header set to the forbidden-header check and hand `Authorization` to the copy, so the vault stored — and the executor sent — a credential no rule had ever seen. The registration and everything nested in it is now snapshotted once, and only that snapshot is validated, frozen and stored. The upload control plane had the same shape one level down: a `sessionId` that answered `session_01` to the regex and `../../unsafe` to the result snapshot reached a success receipt. Two lifetimes were also unowned. A download source lease that resolved after the caller's abort never reached the holder, so nothing closed it and its fetch reader and capability lease outlived the terminal result; a compensator sharing the holder's close-once latch now closes it exactly once. And `dispose()` proved quiescence from the wrapper registry alone, so a provider that ignored its attempt deadline let teardown report a drained runtime and close the checkpoint store while the provider was still running. Raw provider promises are now their own registry and the drain must prove both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1345 lines
40 KiB
TypeScript
1345 lines
40 KiB
TypeScript
import type {
|
|
PresignedDownloadCapability,
|
|
PresignedDownloadByteSource,
|
|
PresignedDownloadSourcePort,
|
|
PresignedTransferCapability,
|
|
PresignedTransferReplayGuard,
|
|
PresignedUploadPartCapability,
|
|
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,
|
|
BrowserDataObserver,
|
|
BrowserDataOperation,
|
|
BrowserDataRecovery,
|
|
BrowserDataResult,
|
|
} from "../../../application/ports/browser-file-storage/shared.ts";
|
|
import {
|
|
browserDataFailure,
|
|
browserDataSuccess,
|
|
observeBrowserData,
|
|
} from "../../browser-file-storage/result.ts";
|
|
import {
|
|
createStreamingSha256Verifier,
|
|
type StreamingSha256Verifier,
|
|
} from "./incremental-sha256.ts";
|
|
import type {
|
|
PresignedCapabilityBinding,
|
|
PresignedCapabilityVault,
|
|
} from "./presigned-capability-vault.ts";
|
|
|
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
const RECEIPT_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~:+/=-]{0,511}$/;
|
|
|
|
type Scheduler = Readonly<{
|
|
setTimeout(callback: () => void, milliseconds: number): unknown;
|
|
clearTimeout(handle: unknown): void;
|
|
}>;
|
|
|
|
export type PresignedTransferExecutorOptions = Readonly<{
|
|
vault: PresignedCapabilityVault;
|
|
replayGuard: PresignedTransferReplayGuard;
|
|
hardMaxTransferBytes: number;
|
|
hardMaxChunkBytes: number;
|
|
hardMaxUploadResponseBytes: number;
|
|
minimumRemainingLifetimeMs: number;
|
|
timeoutMs: number;
|
|
fetcher?: typeof fetch;
|
|
now?: () => number;
|
|
scheduler?: Scheduler;
|
|
createStreamingVerifier?: (
|
|
expectedSha256: string,
|
|
) => StreamingSha256Verifier;
|
|
digestBytes?: (
|
|
bytes: Uint8Array,
|
|
) => Promise<string>;
|
|
observer?: BrowserDataObserver;
|
|
}>;
|
|
|
|
export type PresignedTransferExecutor = Readonly<{
|
|
downloadSources: PresignedDownloadSourcePort;
|
|
uploadParts: PresignedUploadPartPort;
|
|
}>;
|
|
|
|
/**
|
|
* Consumes only exact handles issued into the supplied identity vault. Raw
|
|
* href/query/header values never enter either public executor method.
|
|
*/
|
|
export function createPresignedTransferExecutor(
|
|
options: PresignedTransferExecutorOptions,
|
|
): PresignedTransferExecutor {
|
|
const resolve = options.vault.resolve.bind(options.vault);
|
|
const consume = options.vault.consume.bind(options.vault);
|
|
const claim = options.replayGuard.claim.bind(options.replayGuard);
|
|
const hardMaxTransferBytes = positiveSafeInteger(
|
|
options.hardMaxTransferBytes,
|
|
);
|
|
const hardMaxChunkBytes = positiveSafeInteger(
|
|
options.hardMaxChunkBytes,
|
|
);
|
|
if (hardMaxChunkBytes > hardMaxTransferBytes) {
|
|
throw new TypeError("Presigned chunk limit exceeds transfer limit.");
|
|
}
|
|
const hardMaxUploadResponseBytes = positiveSafeInteger(
|
|
options.hardMaxUploadResponseBytes,
|
|
);
|
|
const minimumRemainingLifetimeMs = positiveSafeInteger(
|
|
options.minimumRemainingLifetimeMs,
|
|
);
|
|
const timeoutMs = positiveSafeInteger(options.timeoutMs);
|
|
const fetcher = (options.fetcher ?? fetch).bind(globalThis);
|
|
const now = options.now ?? Date.now;
|
|
// 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),
|
|
);
|
|
const createVerifier =
|
|
options.createStreamingVerifier ?? createStreamingSha256Verifier;
|
|
if (options.digestBytes === undefined && !globalThis.crypto?.subtle) {
|
|
throw new TypeError(
|
|
"WebCrypto SHA-256 is required for presigned uploads.",
|
|
);
|
|
}
|
|
const digestBytes = options.digestBytes ?? digestSha256WithWebCrypto;
|
|
const observer = options.observer;
|
|
|
|
async function openDownload(
|
|
input: Parameters<PresignedDownloadSourcePort["open"]>[0],
|
|
): Promise<BrowserDataResult<PresignedDownloadByteSource>> {
|
|
let resourceId: string;
|
|
let capability: PresignedDownloadCapability;
|
|
let signal: AbortSignal;
|
|
try {
|
|
resourceId = input.resourceId;
|
|
capability = input.capability;
|
|
signal = input.signal;
|
|
if (!isAbortSignal(signal)) {
|
|
throw new TypeError("Abort signal is invalid.");
|
|
}
|
|
} catch {
|
|
return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (signal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const resolved = resolve(capability);
|
|
if (!resolved.ok) return resolved;
|
|
const binding = resolved.value;
|
|
if (
|
|
binding.method !== "GET" ||
|
|
binding.binding.kind !== "DOWNLOAD" ||
|
|
binding.binding.resourceId !== resourceId ||
|
|
capability.binding.kind !== "DOWNLOAD" ||
|
|
capability.binding.resourceId !== resourceId ||
|
|
!validCommonBinding(binding, capability, hardMaxTransferBytes)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "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;
|
|
|
|
// 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(
|
|
input: Parameters<PresignedUploadPartPort["put"]>[0],
|
|
): Promise<BrowserDataResult<PresignedUploadPartOutcome>> {
|
|
let capability: PresignedUploadPartCapability;
|
|
let bytes: Uint8Array<ArrayBuffer>;
|
|
let request: Readonly<{
|
|
sessionId: string;
|
|
requestBindingSha256: string;
|
|
uploadBindingSha256: string;
|
|
partNumber: number;
|
|
offset: number;
|
|
byteLength: number;
|
|
checksumSha256: string;
|
|
idempotencyKey: string;
|
|
signal: AbortSignal;
|
|
}>;
|
|
try {
|
|
capability = input.capability;
|
|
if (!(input.bytes instanceof Uint8Array)) {
|
|
throw new TypeError("Upload bytes are invalid.");
|
|
}
|
|
if (
|
|
input.bytes.byteLength > hardMaxTransferBytes ||
|
|
input.bytes.byteLength !== input.byteLength
|
|
) {
|
|
return browserDataFailure(
|
|
input.bytes.byteLength > hardMaxTransferBytes
|
|
? "LIMIT_EXCEEDED"
|
|
: "INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (!isAbortSignal(input.signal)) {
|
|
throw new TypeError("Abort signal is invalid.");
|
|
}
|
|
// Snapshot before hashing or network awaits so caller mutation cannot
|
|
// alter the verified bytes after the capability check.
|
|
bytes = input.bytes.slice();
|
|
request = Object.freeze({
|
|
sessionId: opaqueId(input.sessionId),
|
|
requestBindingSha256: normalizedSha256(
|
|
input.requestBindingSha256,
|
|
),
|
|
uploadBindingSha256: normalizedSha256(
|
|
input.uploadBindingSha256,
|
|
),
|
|
partNumber: positiveSafeInteger(input.partNumber),
|
|
offset: nonNegativeSafeInteger(input.offset),
|
|
byteLength: positiveSafeInteger(input.byteLength),
|
|
checksumSha256: normalizedSha256(input.checksumSha256),
|
|
idempotencyKey: opaqueId(input.idempotencyKey),
|
|
signal: input.signal,
|
|
});
|
|
} catch {
|
|
return browserDataFailure("INVALID_INPUT", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (request.signal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const resolved = resolve(capability);
|
|
if (!resolved.ok) return resolved;
|
|
const binding = resolved.value;
|
|
if (
|
|
binding.method !== "PUT" ||
|
|
binding.binding.kind !== "UPLOAD_PART" ||
|
|
capability.binding.kind !== "UPLOAD_PART" ||
|
|
binding.binding.protocol !== RESUMABLE_UPLOAD_PROTOCOL ||
|
|
!validCommonBinding(binding, capability, hardMaxTransferBytes) ||
|
|
binding.binding.sessionId !== request.sessionId ||
|
|
binding.binding.requestBindingSha256 !==
|
|
request.requestBindingSha256 ||
|
|
binding.binding.uploadBindingSha256 !==
|
|
request.uploadBindingSha256 ||
|
|
binding.binding.partNumber !== request.partNumber ||
|
|
binding.binding.offset !== request.offset ||
|
|
binding.binding.idempotencyKey !== request.idempotencyKey ||
|
|
capability.byteLength !== request.byteLength ||
|
|
capability.expectedSha256 !== request.checksumSha256 ||
|
|
bytes.byteLength !== capability.byteLength
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
// 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 {
|
|
const digested = await scope.race(
|
|
Promise.resolve().then(async () => await digestBytes(bytes)),
|
|
);
|
|
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 (!active.ok) return active;
|
|
const claimed = claim(capability);
|
|
if (!claimed.ok) return claimed;
|
|
const consumed = consume(capability);
|
|
if (!consumed.ok) return consumed;
|
|
|
|
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,
|
|
hardMaxUploadResponseBytes,
|
|
);
|
|
if (!validated.ok) {
|
|
cancelBody(response);
|
|
return validated;
|
|
}
|
|
const drained = await drainUploadResponse(
|
|
response,
|
|
validated.value.responseByteLength,
|
|
hardMaxUploadResponseBytes,
|
|
scope.signal,
|
|
);
|
|
if (!drained.ok) return drained;
|
|
return browserDataSuccess(
|
|
Object.freeze({
|
|
bytesWritten: bytes.byteLength,
|
|
checksumSha256: capability.expectedSha256,
|
|
receiptToken: validated.value.receiptToken,
|
|
} satisfies PresignedUploadPartOutcome),
|
|
);
|
|
} catch {
|
|
return transferFailure(request.signal, scope.timedOut());
|
|
} finally {
|
|
scope.release();
|
|
}
|
|
}
|
|
|
|
const downloadSources: PresignedDownloadSourcePort = Object.freeze({
|
|
async open(
|
|
input: Parameters<PresignedDownloadSourcePort["open"]>[0],
|
|
) {
|
|
let result: BrowserDataResult<PresignedDownloadByteSource>;
|
|
try {
|
|
result = await openDownload(input);
|
|
} catch {
|
|
result = browserDataFailure(
|
|
"UNAVAILABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
observeTransferResult(
|
|
observer,
|
|
"PRESIGNED_TRANSFER",
|
|
result,
|
|
result.ok
|
|
? result.value.byteLength
|
|
: safeInputByteLength(input),
|
|
);
|
|
return result;
|
|
},
|
|
});
|
|
|
|
const uploadParts: PresignedUploadPartPort = Object.freeze({
|
|
async put(
|
|
input: Parameters<PresignedUploadPartPort["put"]>[0],
|
|
) {
|
|
let result: BrowserDataResult<PresignedUploadPartOutcome>;
|
|
try {
|
|
result = await putUploadPart(input);
|
|
} catch {
|
|
result = browserDataFailure(
|
|
"UNAVAILABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
observeTransferResult(
|
|
observer,
|
|
"UPLOAD_PART",
|
|
result,
|
|
result.ok
|
|
? result.value.bytesWritten
|
|
: safeInputByteLength(input),
|
|
);
|
|
return result;
|
|
},
|
|
});
|
|
|
|
return Object.freeze({ downloadSources, uploadParts });
|
|
}
|
|
|
|
function createDownloadSource(input: Readonly<{
|
|
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;
|
|
hardMaxChunkBytes: number;
|
|
createVerifier: (
|
|
expectedSha256: string,
|
|
) => StreamingSha256Verifier;
|
|
observer: BrowserDataObserver | undefined;
|
|
}>): PresignedDownloadByteSource {
|
|
/** 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)) {
|
|
state = "CLOSED";
|
|
releaseActive();
|
|
const failure = browserDataFailure(
|
|
"INVALID_INPUT",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
|
yield failure;
|
|
return;
|
|
}
|
|
if (state !== "READY") {
|
|
const failure = browserDataFailure(
|
|
"CONFLICT",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
observeTransferResult(input.observer, "DOWNLOAD", failure, 0);
|
|
yield failure;
|
|
return;
|
|
}
|
|
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;
|
|
let completed = false;
|
|
let transferred = 0;
|
|
let terminalFailureCode: BrowserDataFailureCode | undefined;
|
|
const fail = (
|
|
code: BrowserDataFailureCode,
|
|
options: Readonly<{
|
|
retryable?: boolean;
|
|
recovery?: BrowserDataRecovery;
|
|
}> = {},
|
|
): BrowserDataResult<never> => {
|
|
terminalFailureCode = code;
|
|
return browserDataFailure(code, "PRESIGNED_TRANSFER", options);
|
|
};
|
|
try {
|
|
const verifier = input.createVerifier(
|
|
input.capability.expectedSha256,
|
|
);
|
|
if (!response.body) {
|
|
let verified = false;
|
|
try {
|
|
verified =
|
|
input.capability.byteLength === 0 && verifier.verify();
|
|
} catch {
|
|
verified = false;
|
|
}
|
|
if (verified) {
|
|
completed = true;
|
|
return;
|
|
}
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
reader = response.body.getReader();
|
|
while (true) {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
return;
|
|
}
|
|
if (scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
return;
|
|
}
|
|
const result = await readWithSignal(
|
|
reader,
|
|
scope.signal,
|
|
);
|
|
if (result.done) break;
|
|
const chunk = result.value;
|
|
if (!(chunk instanceof Uint8Array)) {
|
|
yield fail("CORRUPT_DATA");
|
|
return;
|
|
}
|
|
const chunkEnd = transferred + chunk.byteLength;
|
|
if (
|
|
!Number.isSafeInteger(chunkEnd) ||
|
|
chunkEnd > input.capability.maxBytes
|
|
) {
|
|
yield fail("LIMIT_EXCEEDED");
|
|
return;
|
|
}
|
|
if (chunkEnd > input.capability.byteLength) {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
// Browser fetch chunk sizing is implementation-defined. Re-slice into
|
|
// owned bounded chunks instead of rejecting a valid large chunk.
|
|
for (
|
|
let offset = 0;
|
|
offset < chunk.byteLength;
|
|
offset += input.hardMaxChunkBytes
|
|
) {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
return;
|
|
}
|
|
if (scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
return;
|
|
}
|
|
const owned = chunk
|
|
.subarray(
|
|
offset,
|
|
Math.min(
|
|
chunk.byteLength,
|
|
offset + input.hardMaxChunkBytes,
|
|
),
|
|
)
|
|
.slice();
|
|
try {
|
|
verifier.update(owned);
|
|
} catch {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
transferred += owned.byteLength;
|
|
yield browserDataSuccess(owned);
|
|
}
|
|
}
|
|
let verified = false;
|
|
try {
|
|
verified = verifier.verify();
|
|
} catch {
|
|
verified = false;
|
|
}
|
|
if (
|
|
transferred !== input.capability.byteLength ||
|
|
!verified
|
|
) {
|
|
yield fail("INTEGRITY_FAILED");
|
|
return;
|
|
}
|
|
completed = true;
|
|
} catch {
|
|
if (
|
|
input.externalSignal.aborted ||
|
|
consumerSignal.aborted
|
|
) {
|
|
yield fail("ABORTED");
|
|
} else if (scope.timedOut()) {
|
|
yield fail("UNAVAILABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
} else {
|
|
yield fail("NOT_READABLE", {
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
});
|
|
}
|
|
} finally {
|
|
if (!completed) {
|
|
if (reader) cancelReader(reader);
|
|
else cancelBody(response);
|
|
}
|
|
try {
|
|
reader?.releaseLock();
|
|
} catch {
|
|
// Reader cleanup cannot change stream success or failure.
|
|
}
|
|
// 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
|
|
? "SUCCEEDED"
|
|
: terminalFailureCode
|
|
? "FAILED"
|
|
: "DEGRADED",
|
|
...(terminalFailureCode
|
|
? { failureCode: terminalFailureCode }
|
|
: {}),
|
|
byteBucket: byteBucket(transferred),
|
|
});
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function validateDownloadResponse(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
): BrowserDataResult<true> {
|
|
if (
|
|
response.redirected ||
|
|
response.type === "opaqueredirect" ||
|
|
!responseUrlMatches(
|
|
response,
|
|
binding.href,
|
|
)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (response.status !== binding.expectedStatus) {
|
|
return statusFailure(response.status);
|
|
}
|
|
if (!validateRequiredHeaders(response, binding)) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const mediaType = response.headers
|
|
.get("content-type")
|
|
?.trim()
|
|
.toLowerCase();
|
|
const declaredLength = response.headers.get("content-length");
|
|
const digestHeader = binding.digestResponseHeader;
|
|
const contentEncoding = response.headers.get("content-encoding");
|
|
if (
|
|
mediaType !== binding.mediaType ||
|
|
declaredLength === null ||
|
|
Number(declaredLength) !== binding.byteLength ||
|
|
digestHeader === null ||
|
|
response.headers.get(digestHeader)?.trim().toLowerCase() !==
|
|
binding.expectedSha256 ||
|
|
(contentEncoding !== null &&
|
|
contentEncoding.trim().toLowerCase() !== "identity") ||
|
|
response.headers.has("content-range") ||
|
|
(binding.byteLength > 0 && !response.body)
|
|
) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
return browserDataSuccess(true);
|
|
}
|
|
|
|
function validateUploadResponse(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
hardMaxUploadResponseBytes: number,
|
|
): BrowserDataResult<
|
|
Readonly<{
|
|
receiptToken: string;
|
|
responseByteLength: number;
|
|
}>
|
|
> {
|
|
if (
|
|
response.redirected ||
|
|
response.type === "opaqueredirect" ||
|
|
!responseUrlMatches(
|
|
response,
|
|
binding.href,
|
|
)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
if (response.status !== binding.expectedStatus) {
|
|
return statusFailure(response.status);
|
|
}
|
|
if (!validateRequiredHeaders(response, binding)) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const headerName = binding.receiptResponseHeader;
|
|
const expectedResponseByteLength =
|
|
binding.expectedResponseByteLength;
|
|
const declaredLength = normalizedContentLength(
|
|
response.headers.get("content-length"),
|
|
response.status === 204 && expectedResponseByteLength === 0,
|
|
);
|
|
if (
|
|
!headerName ||
|
|
expectedResponseByteLength === null ||
|
|
expectedResponseByteLength > hardMaxUploadResponseBytes ||
|
|
declaredLength === null ||
|
|
declaredLength !== expectedResponseByteLength ||
|
|
(expectedResponseByteLength > 0 && !response.body)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
const receipt = normalizeReceiptToken(
|
|
response.headers.get(headerName),
|
|
);
|
|
return receipt
|
|
? browserDataSuccess(
|
|
Object.freeze({
|
|
receiptToken: receipt,
|
|
responseByteLength: expectedResponseByteLength,
|
|
}),
|
|
)
|
|
: browserDataFailure("INTEGRITY_FAILED", "PRESIGNED_TRANSFER");
|
|
}
|
|
|
|
async function drainUploadResponse(
|
|
response: Response,
|
|
expectedByteLength: number,
|
|
hardMaxBytes: number,
|
|
signal: AbortSignal,
|
|
): Promise<BrowserDataResult<true>> {
|
|
if (!response.body) {
|
|
return expectedByteLength === 0
|
|
? browserDataSuccess(true)
|
|
: browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
const reader = response.body.getReader();
|
|
let completed = false;
|
|
let total = 0;
|
|
try {
|
|
while (true) {
|
|
const result = await readWithSignal(reader, signal);
|
|
if (result.done) break;
|
|
if (!(result.value instanceof Uint8Array)) {
|
|
return browserDataFailure(
|
|
"CORRUPT_DATA",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
total += result.value.byteLength;
|
|
if (!Number.isSafeInteger(total) || total > hardMaxBytes) {
|
|
return browserDataFailure(
|
|
"LIMIT_EXCEEDED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
if (total > expectedByteLength) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
}
|
|
if (total !== expectedByteLength) {
|
|
return browserDataFailure(
|
|
"INTEGRITY_FAILED",
|
|
"PRESIGNED_TRANSFER",
|
|
);
|
|
}
|
|
completed = true;
|
|
return browserDataSuccess(true);
|
|
} finally {
|
|
if (!completed) cancelReader(reader);
|
|
try {
|
|
reader.releaseLock();
|
|
} catch {
|
|
// Reader cleanup cannot alter the already classified result.
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateRequiredHeaders(
|
|
response: Response,
|
|
binding: PresignedCapabilityBinding,
|
|
): boolean {
|
|
return binding.requiredResponseHeaders.every(
|
|
(header) => response.headers.get(header.name) === header.value,
|
|
);
|
|
}
|
|
|
|
function normalizedContentLength(
|
|
value: string | null,
|
|
allowImplicitZero: boolean,
|
|
): number | null {
|
|
if (value === null) return allowImplicitZero ? 0 : null;
|
|
const trimmed = value.trim();
|
|
if (!/^(?:0|[1-9][0-9]{0,15})$/.test(trimmed)) return null;
|
|
const length = Number(trimmed);
|
|
return Number.isSafeInteger(length) ? length : null;
|
|
}
|
|
|
|
function headersFor(binding: PresignedCapabilityBinding): Headers {
|
|
const headers = new Headers();
|
|
for (const header of binding.requestHeaders) {
|
|
headers.set(header.name, header.value);
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function validCommonBinding(
|
|
binding: PresignedCapabilityBinding,
|
|
capability: PresignedTransferCapability,
|
|
hardMaxTransferBytes: number,
|
|
): boolean {
|
|
return (
|
|
binding.capability === capability &&
|
|
binding.capabilityReceipt === capability.capabilityReceipt &&
|
|
binding.method === capability.method &&
|
|
binding.binding === capability.binding &&
|
|
binding.mediaType === capability.mediaType &&
|
|
binding.byteLength === capability.byteLength &&
|
|
binding.maxBytes === capability.maxBytes &&
|
|
binding.expectedSha256 === capability.expectedSha256 &&
|
|
binding.expiresAtEpochMs === capability.expiresAtEpochMs &&
|
|
capability.byteLength >= 0 &&
|
|
capability.byteLength <= capability.maxBytes &&
|
|
capability.maxBytes <= hardMaxTransferBytes &&
|
|
SHA256.test(capability.expectedSha256) &&
|
|
Object.isFrozen(capability) &&
|
|
Object.isFrozen(capability.binding)
|
|
);
|
|
}
|
|
|
|
function validateExpiry(
|
|
capability: PresignedTransferCapability,
|
|
minimumRemainingLifetimeMs: number,
|
|
nowEpochMs: number,
|
|
): BrowserDataResult<true> {
|
|
const remainingLifetimeMs =
|
|
capability.expiresAtEpochMs - nowEpochMs;
|
|
if (
|
|
!Number.isSafeInteger(nowEpochMs) ||
|
|
nowEpochMs < 0 ||
|
|
!Number.isSafeInteger(remainingLifetimeMs)
|
|
) {
|
|
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
return remainingLifetimeMs >= minimumRemainingLifetimeMs
|
|
? browserDataSuccess(true)
|
|
: browserDataFailure(
|
|
"EXPIRED_RESOURCE",
|
|
"PRESIGNED_TRANSFER",
|
|
{ recovery: "REISSUE_CAPABILITY" },
|
|
);
|
|
}
|
|
|
|
function statusFailure(
|
|
status: number,
|
|
): BrowserDataResult<never> {
|
|
let code: BrowserDataFailureCode = "UNAVAILABLE";
|
|
let recovery: BrowserDataRecovery = "REISSUE_CAPABILITY";
|
|
let retryable = status === 429 || status >= 500;
|
|
if (status === 401 || status === 403) {
|
|
code = "PERMISSION_DENIED";
|
|
recovery = "REISSUE_CAPABILITY";
|
|
retryable = false;
|
|
} else if (status === 404) {
|
|
code = "NOT_FOUND";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
} else if (status === 409) {
|
|
code = "CONFLICT";
|
|
retryable = false;
|
|
} else if (status === 410) {
|
|
code = "EXPIRED_RESOURCE";
|
|
retryable = false;
|
|
} else if (status === 413) {
|
|
code = "LIMIT_EXCEEDED";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
} else if (status >= 400 && status < 500 && status !== 429) {
|
|
code = "POLICY_REJECTED";
|
|
recovery = "NONE";
|
|
retryable = false;
|
|
}
|
|
return browserDataFailure(code, "PRESIGNED_TRANSFER", {
|
|
retryable,
|
|
recovery,
|
|
});
|
|
}
|
|
|
|
function transferFailure(
|
|
externalSignal: AbortSignal,
|
|
timedOut: boolean,
|
|
): BrowserDataResult<never> {
|
|
if (externalSignal.aborted) {
|
|
return browserDataFailure("ABORTED", "PRESIGNED_TRANSFER");
|
|
}
|
|
return browserDataFailure(
|
|
timedOut ? "UNAVAILABLE" : "NOT_READABLE",
|
|
"PRESIGNED_TRANSFER",
|
|
{
|
|
retryable: true,
|
|
recovery: "REISSUE_CAPABILITY",
|
|
},
|
|
);
|
|
}
|
|
|
|
/** 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,
|
|
timers: AbortTimerSnapshot,
|
|
additionalSignals: readonly AbortSignal[] = [],
|
|
) {
|
|
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 {
|
|
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() {
|
|
operation.close();
|
|
releaseExtras();
|
|
},
|
|
});
|
|
}
|
|
|
|
function cancelBody(response: Response): void {
|
|
try {
|
|
void response.body?.cancel().catch(() => {
|
|
// Cancellation is best effort after the result is classified.
|
|
});
|
|
} catch {
|
|
// A response already classified as failure/success is not reclassified by
|
|
// best-effort body cancellation.
|
|
}
|
|
}
|
|
|
|
function cancelReader(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
): void {
|
|
try {
|
|
void reader.cancel().catch(() => {
|
|
// Cancellation is best effort after the result is classified.
|
|
});
|
|
} catch {
|
|
// Reader cancellation cannot alter the terminal closed result.
|
|
}
|
|
}
|
|
|
|
function readWithSignal(
|
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
signal: AbortSignal,
|
|
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
if (signal.aborted) {
|
|
return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => {
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
reader.read().then(
|
|
(result) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve(result);
|
|
},
|
|
(error: unknown) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(error);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function normalizeReceiptToken(value: string | null): string | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
const unquoted =
|
|
trimmed.length >= 2 &&
|
|
trimmed.startsWith("\"") &&
|
|
trimmed.endsWith("\"")
|
|
? trimmed.slice(1, -1)
|
|
: trimmed;
|
|
return RECEIPT_TOKEN.test(unquoted) && !unquoted.includes("://")
|
|
? unquoted
|
|
: null;
|
|
}
|
|
|
|
function responseUrlMatches(
|
|
response: Response,
|
|
expectedHref: string,
|
|
): boolean {
|
|
if (response.url.length === 0) return false;
|
|
try {
|
|
return new URL(response.url).href === new URL(expectedHref).href;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizedSha256(value: unknown): string {
|
|
if (typeof value !== "string") throw new TypeError("SHA-256 is invalid.");
|
|
const normalized = value.toLowerCase();
|
|
if (!SHA256.test(normalized)) throw new TypeError("SHA-256 is invalid.");
|
|
return normalized;
|
|
}
|
|
|
|
function opaqueId(value: unknown): string {
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length === 0 ||
|
|
value.length > 256 ||
|
|
!/^[a-z0-9][a-z0-9._:-]*$/i.test(value)
|
|
) {
|
|
throw new TypeError("Opaque identifier is invalid.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function positiveSafeInteger(value: unknown): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
|
|
throw new TypeError("Expected a positive safe integer.");
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function nonNegativeSafeInteger(value: unknown): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
throw new TypeError("Expected a non-negative safe integer.");
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
async function digestSha256WithWebCrypto(
|
|
bytes: Uint8Array,
|
|
): Promise<string> {
|
|
if (!(bytes instanceof Uint8Array) || !globalThis.crypto?.subtle) {
|
|
throw new TypeError("WebCrypto SHA-256 is unavailable.");
|
|
}
|
|
const digest = await globalThis.crypto.subtle.digest(
|
|
"SHA-256",
|
|
bytes.slice(),
|
|
);
|
|
return Array.from(new Uint8Array(digest), (value) =>
|
|
value.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
function isAbortSignal(value: unknown): value is AbortSignal {
|
|
try {
|
|
if (!value || typeof value !== "object") return false;
|
|
const abortedGetter = Object.getOwnPropertyDescriptor(
|
|
AbortSignal.prototype,
|
|
"aborted",
|
|
)?.get;
|
|
return Boolean(
|
|
abortedGetter &&
|
|
typeof abortedGetter.call(value) === "boolean" &&
|
|
typeof (value as AbortSignal).addEventListener === "function" &&
|
|
typeof (value as AbortSignal).removeEventListener === "function",
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function safeInputByteLength(value: unknown): number | undefined {
|
|
try {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const input = value as Readonly<{
|
|
byteLength?: unknown;
|
|
bytes?: unknown;
|
|
capability?: unknown;
|
|
}>;
|
|
if (
|
|
Number.isSafeInteger(input.byteLength) &&
|
|
(input.byteLength as number) >= 0
|
|
) {
|
|
return input.byteLength as number;
|
|
}
|
|
if (input.bytes instanceof Uint8Array) {
|
|
return input.bytes.byteLength;
|
|
}
|
|
if (
|
|
input.capability &&
|
|
typeof input.capability === "object" &&
|
|
Number.isSafeInteger(
|
|
(input.capability as Readonly<{ byteLength?: unknown }>)
|
|
.byteLength,
|
|
)
|
|
) {
|
|
const byteLength = (
|
|
input.capability as Readonly<{ byteLength: number }>
|
|
).byteLength;
|
|
return byteLength >= 0 ? byteLength : undefined;
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function observeTransferResult(
|
|
observer: BrowserDataObserver | undefined,
|
|
operation: BrowserDataOperation,
|
|
result: BrowserDataResult<unknown>,
|
|
byteLength?: number,
|
|
): void {
|
|
const bucket = byteBucket(byteLength);
|
|
observeBrowserData(observer, {
|
|
operation,
|
|
outcome: result.ok ? "SUCCEEDED" : "FAILED",
|
|
...(!result.ok ? { failureCode: result.error.code } : {}),
|
|
...(bucket !== undefined ? { byteBucket: bucket } : {}),
|
|
});
|
|
}
|
|
|
|
function byteBucket(
|
|
byteLength: number | undefined,
|
|
):
|
|
| "ZERO"
|
|
| "LT1MIB"
|
|
| "1_TO_9MIB"
|
|
| "10_TO_99MIB"
|
|
| "GTE100MIB"
|
|
| undefined {
|
|
if (
|
|
byteLength === undefined ||
|
|
!Number.isSafeInteger(byteLength) ||
|
|
byteLength < 0
|
|
) {
|
|
return undefined;
|
|
}
|
|
if (byteLength === 0) return "ZERO";
|
|
if (byteLength < 1_048_576) return "LT1MIB";
|
|
if (byteLength < 10_485_760) return "1_TO_9MIB";
|
|
if (byteLength < 104_857_600) return "10_TO_99MIB";
|
|
return "GTE100MIB";
|
|
}
|