2122 lines
61 KiB
TypeScript
2122 lines
61 KiB
TypeScript
import type {
|
|
ByteCount,
|
|
BrowserFilePolicyProfile,
|
|
BrowserManagedDownloadCapabilityResolver,
|
|
BrowserManagedDownloadCapabilityReceipt,
|
|
DownloadDeliveryPort,
|
|
DownloadOutcome,
|
|
DurableObjectDescriptor,
|
|
DurableObjectId,
|
|
DurableObjectMaintenancePort,
|
|
DurableObjectRead,
|
|
DurableObjectStorePort,
|
|
ExampleQuarantinedUpload,
|
|
ExampleQuarantinedUploadPort,
|
|
ExampleUploadPartReceipt,
|
|
ExampleUploadSession,
|
|
FileBytePattern,
|
|
FileCandidate,
|
|
FileByteSource,
|
|
FileContentPort,
|
|
FileInspection,
|
|
FileInspectionPolicy,
|
|
FilePolicyReference,
|
|
FilePickerPort,
|
|
FileSelectionOutcome,
|
|
FileSelectionLimitReduction,
|
|
FileSelectionPolicy,
|
|
FileVerificationReceipt,
|
|
LocalFileRef,
|
|
MigrationProgress,
|
|
ObjectGeneration,
|
|
ObjectStoreRecoverySummary,
|
|
OfflineCommitReceipt,
|
|
OfflinePage,
|
|
OfflineStoreLifecycleEvent,
|
|
OfflineStoreMaintenancePort,
|
|
OfflineStoreStatus,
|
|
PublicAssetEntry,
|
|
PublicCacheInspection,
|
|
PublicCacheLookup,
|
|
PublicResponseCacheAdmin,
|
|
StorageDurabilityPort,
|
|
StorageEstimate,
|
|
StoredRecord,
|
|
StructuredOfflineMutation,
|
|
StructuredOfflineStore,
|
|
TransientPreviewPort,
|
|
RegisteredDownloadPolicy,
|
|
RegisteredPreviewPolicy,
|
|
} from "./browser-file-storage-contracts.ts";
|
|
import type { CapabilityResult } from "./contracts.ts";
|
|
import { failure, success } from "./fake-adapters.ts";
|
|
|
|
export function asByteCount(value: number): ByteCount {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new TypeError("Byte counts must be non-negative safe integers.");
|
|
}
|
|
return value as ByteCount;
|
|
}
|
|
|
|
export function asLocalFileRef(value: string): LocalFileRef {
|
|
if (value.length === 0) {
|
|
throw new TypeError("Local file references must not be empty.");
|
|
}
|
|
return value as LocalFileRef;
|
|
}
|
|
|
|
export function asFileVerificationReceipt(
|
|
value: string,
|
|
): FileVerificationReceipt {
|
|
if (!/^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value)) {
|
|
throw new TypeError("File verification receipts must be opaque tokens.");
|
|
}
|
|
return value as FileVerificationReceipt;
|
|
}
|
|
|
|
const issuedFilePolicyReferences = new WeakSet<object>();
|
|
|
|
export function browserFilePolicyReference(
|
|
policyKey: string,
|
|
intention: string,
|
|
): FilePolicyReference {
|
|
const token = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
|
|
if (!token.test(policyKey) || !token.test(intention)) {
|
|
throw new TypeError("File policy references must be opaque tokens.");
|
|
}
|
|
const reference = Object.freeze({
|
|
policyKey:
|
|
policyKey as FilePolicyReference["policyKey"],
|
|
intention:
|
|
intention as FilePolicyReference["intention"],
|
|
});
|
|
issuedFilePolicyReferences.add(reference);
|
|
return reference;
|
|
}
|
|
|
|
export function asBrowserManagedDownloadCapabilityReceipt(
|
|
value: string,
|
|
): BrowserManagedDownloadCapabilityReceipt {
|
|
if (!/^[a-z0-9][a-z0-9:_-]{0,127}$/i.test(value)) {
|
|
throw new TypeError(
|
|
"Browser-managed download receipts must be opaque tokens.",
|
|
);
|
|
}
|
|
return value as BrowserManagedDownloadCapabilityReceipt;
|
|
}
|
|
|
|
type MemoryPolicyProfile = Readonly<{
|
|
bindingId: string;
|
|
selection?: FileSelectionPolicy;
|
|
inspection?: FileInspectionPolicy;
|
|
preview?: RegisteredPreviewPolicy;
|
|
download?: RegisteredDownloadPolicy;
|
|
}>;
|
|
|
|
/**
|
|
* Composition-time dataset policy registry used by the deterministic fakes.
|
|
* It deep-snapshots profile data and resolves only the exact registered
|
|
* reference object. Equal key/intention strings are not authority.
|
|
*/
|
|
export class MemoryBrowserFilePolicyRegistry {
|
|
readonly #profiles =
|
|
new Map<FilePolicyReference, MemoryPolicyProfile>();
|
|
|
|
constructor(profiles: ReadonlyArray<BrowserFilePolicyProfile>) {
|
|
if (profiles.length === 0) {
|
|
throw new TypeError("At least one file policy profile is required.");
|
|
}
|
|
const semanticKeys = new Set<string>();
|
|
for (const profile of profiles) {
|
|
if (
|
|
!issuedFilePolicyReferences.has(profile.reference) ||
|
|
(!profile.selection &&
|
|
!profile.inspection &&
|
|
!profile.preview &&
|
|
!profile.download) ||
|
|
(profile.preview && !profile.inspection)
|
|
) {
|
|
throw new TypeError("The file policy profile is invalid.");
|
|
}
|
|
const bindingId = `${profile.reference.policyKey}\u0000${profile.reference.intention}`;
|
|
if (semanticKeys.has(bindingId)) {
|
|
throw new TypeError("The file policy profile is duplicated.");
|
|
}
|
|
semanticKeys.add(bindingId);
|
|
this.#profiles.set(
|
|
profile.reference,
|
|
Object.freeze({
|
|
bindingId,
|
|
...(profile.selection
|
|
? { selection: snapshotSelectionPolicy(profile.selection) }
|
|
: {}),
|
|
...(profile.inspection
|
|
? {
|
|
inspection: snapshotInspectionPolicy(
|
|
profile.inspection,
|
|
),
|
|
}
|
|
: {}),
|
|
...(profile.preview
|
|
? { preview: snapshotPreviewPolicy(profile.preview) }
|
|
: {}),
|
|
...(profile.download
|
|
? {
|
|
download: Object.freeze({
|
|
...profile.download,
|
|
}),
|
|
}
|
|
: {}),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
selection(
|
|
reference: FilePolicyReference,
|
|
reductions?: FileSelectionLimitReduction,
|
|
): CapabilityResult<FileSelectionPolicy> {
|
|
const profile = this.#profiles.get(reference);
|
|
if (!profile?.selection) return rejectedPolicy();
|
|
const maxCount = reduceLimit(
|
|
reductions?.maxCount,
|
|
profile.selection.maxCount,
|
|
);
|
|
const maxFileBytes = reduceLimit(
|
|
reductions?.maxFileBytes,
|
|
profile.selection.maxFileBytes,
|
|
);
|
|
const maxTotalBytes = reduceLimit(
|
|
reductions?.maxTotalBytes,
|
|
profile.selection.maxTotalBytes,
|
|
);
|
|
if (
|
|
maxCount === null ||
|
|
maxFileBytes === null ||
|
|
maxTotalBytes === null
|
|
) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"A caller may only reduce registered file limits.",
|
|
);
|
|
}
|
|
return success(
|
|
Object.freeze({
|
|
...profile.selection,
|
|
maxCount,
|
|
maxFileBytes: asByteCount(maxFileBytes),
|
|
maxTotalBytes: asByteCount(maxTotalBytes),
|
|
}),
|
|
);
|
|
}
|
|
|
|
inspection(
|
|
reference: FilePolicyReference,
|
|
reduction?: ByteCount,
|
|
): CapabilityResult<
|
|
Readonly<{
|
|
policy: FileInspectionPolicy;
|
|
bindingId: string;
|
|
}>
|
|
> {
|
|
const profile = this.#profiles.get(reference);
|
|
if (!profile?.inspection) return rejectedPolicy();
|
|
const maxInspectionBytes = reduceLimit(
|
|
reduction,
|
|
profile.inspection.maxInspectionBytes,
|
|
);
|
|
if (maxInspectionBytes === null) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"A caller may only reduce registered inspection limits.",
|
|
);
|
|
}
|
|
return success(
|
|
Object.freeze({
|
|
bindingId: profile.bindingId,
|
|
policy: Object.freeze({
|
|
...profile.inspection,
|
|
maxInspectionBytes: asByteCount(maxInspectionBytes),
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
|
|
preview(
|
|
reference: FilePolicyReference,
|
|
reduction?: ByteCount,
|
|
): CapabilityResult<
|
|
Readonly<{
|
|
bindingId: string;
|
|
allowedMediaTypes: ReadonlySet<string>;
|
|
maxPreviewBytes: ByteCount;
|
|
}>
|
|
> {
|
|
const profile = this.#profiles.get(reference);
|
|
if (!profile?.preview || !profile.inspection) {
|
|
return rejectedPolicy();
|
|
}
|
|
const maxPreviewBytes = reduceLimit(
|
|
reduction,
|
|
profile.preview.maxPreviewBytes,
|
|
);
|
|
if (maxPreviewBytes === null) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"A caller may only reduce registered preview limits.",
|
|
);
|
|
}
|
|
return success(
|
|
Object.freeze({
|
|
bindingId: profile.bindingId,
|
|
allowedMediaTypes: new Set(
|
|
profile.preview.allowedMediaTypes,
|
|
),
|
|
maxPreviewBytes: asByteCount(maxPreviewBytes),
|
|
}),
|
|
);
|
|
}
|
|
|
|
download(
|
|
reference: FilePolicyReference,
|
|
reductions: Readonly<{
|
|
maxTransferBytes?: ByteCount;
|
|
maxBufferedBytes?: ByteCount;
|
|
}>,
|
|
): CapabilityResult<RegisteredDownloadPolicy> {
|
|
const profile = this.#profiles.get(reference);
|
|
if (!profile?.download) return rejectedPolicy();
|
|
const maxTransferBytes = reduceLimit(
|
|
reductions.maxTransferBytes,
|
|
profile.download.maxTransferBytes,
|
|
);
|
|
const maxBufferedBytes = reduceLimit(
|
|
reductions.maxBufferedBytes,
|
|
Math.min(
|
|
profile.download.maxBufferedBytes,
|
|
maxTransferBytes ?? -1,
|
|
),
|
|
);
|
|
if (
|
|
maxTransferBytes === null ||
|
|
maxBufferedBytes === null
|
|
) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"A caller may only reduce registered download limits.",
|
|
);
|
|
}
|
|
return success(
|
|
Object.freeze({
|
|
...profile.download,
|
|
maxTransferBytes: asByteCount(maxTransferBytes),
|
|
maxBufferedBytes: asByteCount(maxBufferedBytes),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function rejectedPolicy(): CapabilityResult<never> {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The composition-issued file policy is not available.",
|
|
);
|
|
}
|
|
|
|
function reduceLimit(
|
|
reduction: number | undefined,
|
|
ceiling: number,
|
|
): number | null {
|
|
const value = reduction ?? ceiling;
|
|
return Number.isSafeInteger(value) &&
|
|
value > 0 &&
|
|
value <= ceiling
|
|
? value
|
|
: null;
|
|
}
|
|
|
|
function snapshotSelectionPolicy(
|
|
policy: FileSelectionPolicy,
|
|
): FileSelectionPolicy {
|
|
return Object.freeze({
|
|
...policy,
|
|
accept: Object.freeze(
|
|
policy.accept.map((rule) =>
|
|
Object.freeze({
|
|
mediaType: rule.mediaType,
|
|
extensions: Object.freeze([...rule.extensions]),
|
|
}),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
|
|
function snapshotInspectionPolicy(
|
|
policy: FileInspectionPolicy,
|
|
): FileInspectionPolicy {
|
|
return Object.freeze({
|
|
...policy,
|
|
acceptedSignatures: Object.freeze(
|
|
policy.acceptedSignatures.map((rule) =>
|
|
Object.freeze({
|
|
mediaType: rule.mediaType,
|
|
extensions: Object.freeze([...rule.extensions]),
|
|
patterns: Object.freeze(
|
|
rule.patterns.map((pattern) =>
|
|
Object.freeze({
|
|
offset: pattern.offset,
|
|
bytes: Object.freeze([...pattern.bytes]),
|
|
...(pattern.mask
|
|
? { mask: Object.freeze([...pattern.mask]) }
|
|
: {}),
|
|
}),
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
|
|
function snapshotPreviewPolicy(
|
|
policy: RegisteredPreviewPolicy,
|
|
): RegisteredPreviewPolicy {
|
|
return Object.freeze({
|
|
allowedMediaTypes: Object.freeze([
|
|
...policy.allowedMediaTypes,
|
|
]),
|
|
maxPreviewBytes: policy.maxPreviewBytes,
|
|
});
|
|
}
|
|
|
|
export function asDurableObjectId(value: string): DurableObjectId {
|
|
if (value.length === 0) {
|
|
throw new TypeError("Durable object IDs must not be empty.");
|
|
}
|
|
return value as DurableObjectId;
|
|
}
|
|
|
|
export function asObjectGeneration(value: number): ObjectGeneration {
|
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
throw new TypeError("Object generations must be positive safe integers.");
|
|
}
|
|
return value as ObjectGeneration;
|
|
}
|
|
|
|
function cancelled(signal?: AbortSignal): CapabilityResult<never> | null {
|
|
return signal?.aborted
|
|
? failure("ABORTED", false, "The operation was cancelled.")
|
|
: null;
|
|
}
|
|
|
|
function isReadableStatus(status: OfflineStoreStatus): boolean {
|
|
return status.kind === "READY" || status.kind === "READ_ONLY";
|
|
}
|
|
|
|
function isWritableStatus(status: OfflineStoreStatus): boolean {
|
|
return status.kind === "READY";
|
|
}
|
|
|
|
function cloneBytes(bytes: Uint8Array): Uint8Array {
|
|
return bytes.slice();
|
|
}
|
|
|
|
function extensionOf(fileName: string): string | null {
|
|
const normalized = fileName.normalize("NFC");
|
|
const index = normalized.lastIndexOf(".");
|
|
return index > 0 && index < normalized.length - 1
|
|
? normalized.slice(index).toLowerCase()
|
|
: null;
|
|
}
|
|
|
|
function acceptsCandidate(
|
|
policy: FileSelectionPolicy,
|
|
candidate: FileCandidate,
|
|
): boolean {
|
|
if (policy.accept.length === 0) return true;
|
|
const extension = extensionOf(candidate.displayName);
|
|
return policy.accept.some(
|
|
(rule) =>
|
|
(candidate.reportedMediaType !== null &&
|
|
candidate.reportedMediaType.toLowerCase() ===
|
|
rule.mediaType.toLowerCase()) ||
|
|
(extension !== null &&
|
|
rule.extensions.some(
|
|
(accepted) => accepted.toLowerCase() === extension,
|
|
)),
|
|
);
|
|
}
|
|
|
|
function matchesFileBytePattern(
|
|
bytes: Uint8Array,
|
|
pattern: FileBytePattern,
|
|
): boolean {
|
|
if (pattern.offset + pattern.bytes.length > bytes.byteLength) {
|
|
return false;
|
|
}
|
|
for (const [index, expected] of pattern.bytes.entries()) {
|
|
const actual = bytes[pattern.offset + index];
|
|
const mask = pattern.mask?.[index] ?? 0xff;
|
|
if (
|
|
actual === undefined ||
|
|
!Number.isInteger(expected) ||
|
|
expected < 0 ||
|
|
expected > 0xff ||
|
|
(actual & mask) !== (expected & mask)
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export type MemoryFileFixture = Readonly<{
|
|
ref: LocalFileRef;
|
|
displayName: string;
|
|
bytes: Uint8Array;
|
|
reportedMediaType: string | null;
|
|
detectedMediaType: string | null;
|
|
lastModifiedEpochMs?: number | null;
|
|
signature?: FileInspection["signature"];
|
|
source?: FileCandidate["source"];
|
|
}>;
|
|
|
|
/**
|
|
* Deterministic picker and transient file vault. Metadata matching is only a
|
|
* preflight policy simulation. Any separately selected backend transfer
|
|
* workflow must independently revalidate content.
|
|
*/
|
|
export class MemoryFileSelectionAdapter
|
|
implements FilePickerPort, FileContentPort
|
|
{
|
|
readonly support;
|
|
readonly #policies: MemoryBrowserFilePolicyRegistry;
|
|
readonly #files = new Map<LocalFileRef, MemoryFileFixture>();
|
|
readonly #verifications = new Map<
|
|
FileVerificationReceipt,
|
|
Readonly<{
|
|
ref: LocalFileRef;
|
|
policyBindingId: string;
|
|
mediaType: string;
|
|
}>
|
|
>();
|
|
#verificationSequence = 0;
|
|
#nextOutcome: "SELECTED" | "DISMISSED" | "PERMISSION_DENIED" = "SELECTED";
|
|
|
|
constructor(
|
|
files: ReadonlyArray<MemoryFileFixture>,
|
|
policies: MemoryBrowserFilePolicyRegistry,
|
|
support: FilePickerPort["support"] = {
|
|
nativeInput: true,
|
|
systemOpenPicker: false,
|
|
systemSavePicker: false,
|
|
},
|
|
) {
|
|
this.#policies = policies;
|
|
this.support = Object.freeze({ ...support });
|
|
for (const file of files) {
|
|
this.#files.set(file.ref, {
|
|
...file,
|
|
bytes: cloneBytes(file.bytes),
|
|
});
|
|
}
|
|
}
|
|
|
|
setNextOutcome(
|
|
outcome: "SELECTED" | "DISMISSED" | "PERMISSION_DENIED",
|
|
): void {
|
|
this.#nextOutcome = outcome;
|
|
}
|
|
|
|
async select(input: {
|
|
policy: FilePolicyReference;
|
|
limits?: FileSelectionLimitReduction;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<FileSelectionOutcome>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const outcome = this.#nextOutcome;
|
|
this.#nextOutcome = "SELECTED";
|
|
if (outcome === "DISMISSED") {
|
|
return success({ kind: "DISMISSED" });
|
|
}
|
|
if (outcome === "PERMISSION_DENIED") {
|
|
return failure(
|
|
"PERMISSION_DENIED",
|
|
false,
|
|
"File access permission was denied.",
|
|
);
|
|
}
|
|
const resolvedPolicy = this.#policies.selection(
|
|
input.policy,
|
|
input.limits,
|
|
);
|
|
if (!resolvedPolicy.ok) return resolvedPolicy;
|
|
const policy = resolvedPolicy.value;
|
|
|
|
const candidates = Array.from(this.#files.values(), (file) =>
|
|
Object.freeze({
|
|
ref: file.ref,
|
|
displayName: file.displayName,
|
|
sizeBytes: asByteCount(file.bytes.byteLength),
|
|
reportedMediaType: file.reportedMediaType,
|
|
lastModifiedEpochMs: file.lastModifiedEpochMs ?? null,
|
|
source: file.source ?? "NATIVE_INPUT",
|
|
}),
|
|
);
|
|
const total = candidates.reduce(
|
|
(sum, candidate) => sum + candidate.sizeBytes,
|
|
0,
|
|
);
|
|
if (
|
|
candidates.length > policy.maxCount ||
|
|
(!policy.multiple && candidates.length > 1) ||
|
|
candidates.some(
|
|
(candidate) =>
|
|
candidate.sizeBytes > policy.maxFileBytes ||
|
|
(!policy.allowEmpty && candidate.sizeBytes === 0),
|
|
) ||
|
|
total > policy.maxTotalBytes
|
|
) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"The selected files exceed the approved count or byte budget.",
|
|
);
|
|
}
|
|
if (
|
|
candidates.some(
|
|
(candidate) => !acceptsCandidate(policy, candidate),
|
|
)
|
|
) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"A selected file does not match the intake policy.",
|
|
);
|
|
}
|
|
return success({ kind: "SELECTED", files: candidates });
|
|
}
|
|
|
|
async inspect(input: {
|
|
ref: LocalFileRef;
|
|
policy: FilePolicyReference;
|
|
maxInspectionBytes?: ByteCount;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<FileInspection>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const resolvedPolicy = this.#policies.inspection(
|
|
input.policy,
|
|
input.maxInspectionBytes,
|
|
);
|
|
if (!resolvedPolicy.ok) return resolvedPolicy;
|
|
const { policy, bindingId } = resolvedPolicy.value;
|
|
const file = this.#files.get(input.ref);
|
|
if (!file) {
|
|
return failure("NOT_FOUND", false, "The selected file is no longer available.");
|
|
}
|
|
this.#invalidateVerifications(input.ref);
|
|
const inspectedBytes = file.bytes.slice(
|
|
0,
|
|
policy.maxInspectionBytes,
|
|
);
|
|
const matchedRule = policy.acceptedSignatures.find(
|
|
(rule) =>
|
|
rule.mediaType.toLowerCase() ===
|
|
file.detectedMediaType?.toLowerCase() &&
|
|
rule.patterns.some((pattern) =>
|
|
matchesFileBytePattern(inspectedBytes, pattern),
|
|
),
|
|
);
|
|
const detectedMediaType = matchedRule?.mediaType ?? null;
|
|
const signature =
|
|
file.signature === "MISMATCHED"
|
|
? "MISMATCHED"
|
|
: matchedRule
|
|
? "MATCHED"
|
|
: file.signature === "MATCHED"
|
|
? "MISMATCHED"
|
|
: "UNKNOWN";
|
|
const verificationReceipt =
|
|
signature === "MATCHED" && detectedMediaType !== null
|
|
? asFileVerificationReceipt(
|
|
`verification:${++this.#verificationSequence}`,
|
|
)
|
|
: null;
|
|
if (verificationReceipt) {
|
|
this.#verifications.set(
|
|
verificationReceipt,
|
|
Object.freeze({
|
|
ref: input.ref,
|
|
policyBindingId: bindingId,
|
|
mediaType: detectedMediaType!,
|
|
}),
|
|
);
|
|
}
|
|
return success({
|
|
byteLength: asByteCount(file.bytes.byteLength),
|
|
reportedMediaType: file.reportedMediaType,
|
|
detectedMediaType,
|
|
normalizedExtension: extensionOf(file.displayName),
|
|
signature,
|
|
verificationReceipt,
|
|
});
|
|
}
|
|
|
|
async readRange(input: {
|
|
ref: LocalFileRef;
|
|
offset: ByteCount;
|
|
length: ByteCount;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<Uint8Array>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const file = this.#files.get(input.ref);
|
|
if (!file) {
|
|
return failure("NOT_FOUND", false, "The selected file is no longer available.");
|
|
}
|
|
const end = input.offset + input.length;
|
|
if (
|
|
!Number.isSafeInteger(end) ||
|
|
input.offset > file.bytes.byteLength ||
|
|
end > file.bytes.byteLength
|
|
) {
|
|
return failure("INVALID_INPUT", false, "The requested byte range is invalid.");
|
|
}
|
|
return success(file.bytes.slice(input.offset, end));
|
|
}
|
|
|
|
async openSource(input: {
|
|
ref: LocalFileRef;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<FileByteSource>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const file = this.#files.get(input.ref);
|
|
if (!file) {
|
|
return failure(
|
|
"NOT_FOUND",
|
|
false,
|
|
"The selected file is no longer available.",
|
|
);
|
|
}
|
|
const bytes = cloneBytes(file.bytes);
|
|
return success(
|
|
Object.freeze({
|
|
byteLength: asByteCount(bytes.byteLength),
|
|
async *stream(signal: AbortSignal) {
|
|
const streamAbort = cancelled(signal);
|
|
if (streamAbort) {
|
|
yield streamAbort;
|
|
return;
|
|
}
|
|
yield success(cloneBytes(bytes));
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
release(ref: LocalFileRef): void {
|
|
this.#invalidateVerifications(ref);
|
|
this.#files.delete(ref);
|
|
}
|
|
|
|
verification(
|
|
receipt: FileVerificationReceipt,
|
|
): Readonly<{
|
|
ref: LocalFileRef;
|
|
policyBindingId: string;
|
|
mediaType: string;
|
|
byteLength: ByteCount;
|
|
}> | null {
|
|
const verification = this.#verifications.get(receipt);
|
|
if (!verification) return null;
|
|
const file = this.#files.get(verification.ref);
|
|
return file
|
|
? Object.freeze({
|
|
...verification,
|
|
byteLength: asByteCount(file.bytes.byteLength),
|
|
})
|
|
: null;
|
|
}
|
|
|
|
#invalidateVerifications(ref: LocalFileRef): void {
|
|
for (const [receipt, verification] of this.#verifications) {
|
|
if (verification.ref === ref) this.#verifications.delete(receipt);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class MemoryTransientPreviewAdapter implements TransientPreviewPort {
|
|
static readonly #ACTIVE_CONTENT_DENYLIST = new Set([
|
|
"application/pdf",
|
|
"application/xhtml+xml",
|
|
"application/xml",
|
|
"image/svg+xml",
|
|
"text/html",
|
|
"text/xml",
|
|
]);
|
|
|
|
readonly #files: MemoryFileSelectionAdapter;
|
|
readonly #policies: MemoryBrowserFilePolicyRegistry;
|
|
readonly #active = new Set<string>();
|
|
#sequence = 0;
|
|
#disposed = false;
|
|
|
|
constructor(
|
|
files: MemoryFileSelectionAdapter,
|
|
policies: MemoryBrowserFilePolicyRegistry,
|
|
) {
|
|
this.#files = files;
|
|
this.#policies = policies;
|
|
}
|
|
|
|
async create(
|
|
input: Parameters<TransientPreviewPort["create"]>[0],
|
|
): ReturnType<TransientPreviewPort["create"]> {
|
|
if (this.#disposed) {
|
|
return failure(
|
|
"PROVIDER_UNAVAILABLE",
|
|
false,
|
|
"The preview adapter has been disposed.",
|
|
);
|
|
}
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const verification = this.#files.verification(
|
|
input.verificationReceipt,
|
|
);
|
|
const resolvedPolicy = this.#policies.preview(
|
|
input.policy,
|
|
input.maxPreviewBytes,
|
|
);
|
|
if (!resolvedPolicy.ok) return resolvedPolicy;
|
|
const policy = resolvedPolicy.value;
|
|
if (
|
|
!verification ||
|
|
verification.ref !== input.ref ||
|
|
verification.policyBindingId !== policy.bindingId
|
|
) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The preview verification receipt is invalid.",
|
|
);
|
|
}
|
|
if (
|
|
verification.byteLength > policy.maxPreviewBytes
|
|
) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"The preview exceeds its byte budget.",
|
|
);
|
|
}
|
|
if (
|
|
!policy.allowedMediaTypes.has(verification.mediaType) ||
|
|
MemoryTransientPreviewAdapter.#ACTIVE_CONTENT_DENYLIST.has(
|
|
verification.mediaType.toLowerCase(),
|
|
)
|
|
) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The file type is not approved for inline preview.",
|
|
);
|
|
}
|
|
const url = `blob:memory-preview-${++this.#sequence}`;
|
|
this.#active.add(url);
|
|
let released = false;
|
|
return success(
|
|
Object.freeze({
|
|
url,
|
|
mediaType: verification.mediaType,
|
|
release: () => {
|
|
if (released) return;
|
|
released = true;
|
|
this.#active.delete(url);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
get activeLeaseCount(): number {
|
|
return this.#active.size;
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.#disposed) return;
|
|
this.#disposed = true;
|
|
this.#active.clear();
|
|
}
|
|
}
|
|
|
|
type MemoryUploadState = {
|
|
expectedBytes: number;
|
|
parts: Map<
|
|
number,
|
|
Readonly<{ offset: number; receipt: ExampleUploadPartReceipt }>
|
|
>;
|
|
idempotency: Map<
|
|
string,
|
|
Readonly<{
|
|
fingerprint: string;
|
|
receipt: ExampleUploadPartReceipt;
|
|
}>
|
|
>;
|
|
};
|
|
|
|
/**
|
|
* Deterministic example for a separately selected, backend-authorized
|
|
* resumable upload workflow. It is not part of browser-file composition.
|
|
*/
|
|
export class MemoryExampleQuarantinedUploadAdapter
|
|
implements ExampleQuarantinedUploadPort
|
|
{
|
|
readonly #sessions = new Map<string, MemoryUploadState>();
|
|
#sequence = 0;
|
|
|
|
constructor(
|
|
private readonly maxUploadBytes: ByteCount,
|
|
private readonly partSizeBytes: ByteCount,
|
|
private readonly now: () => number = Date.now,
|
|
) {}
|
|
|
|
async create(input: {
|
|
purpose: string;
|
|
byteLength: ByteCount;
|
|
detectedMediaType: string;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<ExampleUploadSession>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (
|
|
input.purpose.length === 0 ||
|
|
input.detectedMediaType.length === 0 ||
|
|
input.byteLength > this.maxUploadBytes
|
|
) {
|
|
return failure("LIMIT_EXCEEDED", false, "The upload policy rejected the file.");
|
|
}
|
|
const sessionId = `upload-${++this.#sequence}`;
|
|
this.#sessions.set(sessionId, {
|
|
expectedBytes: input.byteLength,
|
|
parts: new Map(),
|
|
idempotency: new Map(),
|
|
});
|
|
return success({
|
|
sessionId,
|
|
partSizeBytes: this.partSizeBytes,
|
|
maxConcurrency: 2,
|
|
expiresAt: new Date(this.now() + 15 * 60_000).toISOString(),
|
|
checksumAlgorithm: "SHA-256",
|
|
});
|
|
}
|
|
|
|
async uploadPart(input: {
|
|
sessionId: string;
|
|
partNumber: number;
|
|
offset: ByteCount;
|
|
bytes: Uint8Array;
|
|
checksumSha256: string;
|
|
idempotencyKey: string;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<ExampleUploadPartReceipt>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const session = this.#sessions.get(input.sessionId);
|
|
if (!session) {
|
|
return failure(
|
|
"EXPIRED_RESOURCE",
|
|
false,
|
|
"The upload session is no longer available.",
|
|
);
|
|
}
|
|
const fingerprint = [
|
|
input.partNumber,
|
|
input.offset,
|
|
input.bytes.byteLength,
|
|
input.checksumSha256,
|
|
].join(":");
|
|
const replay = session.idempotency.get(input.idempotencyKey);
|
|
if (replay) {
|
|
return replay.fingerprint === fingerprint
|
|
? success(replay.receipt)
|
|
: failure(
|
|
"CONFLICT",
|
|
false,
|
|
"The idempotency key was reused for a different upload part.",
|
|
);
|
|
}
|
|
if (
|
|
input.partNumber < 1 ||
|
|
input.bytes.byteLength === 0 ||
|
|
input.bytes.byteLength > this.partSizeBytes ||
|
|
input.offset + input.bytes.byteLength > session.expectedBytes ||
|
|
input.checksumSha256.length === 0
|
|
) {
|
|
return failure("INVALID_INPUT", false, "The upload part is invalid.");
|
|
}
|
|
if (session.parts.has(input.partNumber)) {
|
|
return failure("CONFLICT", false, "The upload part already exists.");
|
|
}
|
|
const receipt = Object.freeze({
|
|
partNumber: input.partNumber,
|
|
acceptedBytes: asByteCount(input.bytes.byteLength),
|
|
checksumSha256: input.checksumSha256,
|
|
});
|
|
session.parts.set(input.partNumber, {
|
|
offset: input.offset,
|
|
receipt,
|
|
});
|
|
session.idempotency.set(input.idempotencyKey, {
|
|
fingerprint,
|
|
receipt,
|
|
});
|
|
return success(receipt);
|
|
}
|
|
|
|
async complete(input: {
|
|
sessionId: string;
|
|
parts: ReadonlyArray<ExampleUploadPartReceipt>;
|
|
signal: AbortSignal;
|
|
}): Promise<CapabilityResult<ExampleQuarantinedUpload>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const session = this.#sessions.get(input.sessionId);
|
|
if (!session) {
|
|
return failure(
|
|
"EXPIRED_RESOURCE",
|
|
false,
|
|
"The upload session is no longer available.",
|
|
);
|
|
}
|
|
const partNumbers = new Set(input.parts.map((part) => part.partNumber));
|
|
const acceptedBytes = input.parts.reduce(
|
|
(sum, part) => sum + part.acceptedBytes,
|
|
0,
|
|
);
|
|
const matches = input.parts.every(
|
|
(part) =>
|
|
session.parts.get(part.partNumber)?.receipt.checksumSha256 ===
|
|
part.checksumSha256,
|
|
);
|
|
const storedParts = Array.from(session.parts.values()).sort(
|
|
(left, right) => left.offset - right.offset,
|
|
);
|
|
let nextOffset = 0;
|
|
const contiguous = storedParts.every((part) => {
|
|
if (part.offset !== nextOffset) return false;
|
|
nextOffset += part.receipt.acceptedBytes;
|
|
return true;
|
|
});
|
|
if (
|
|
!matches ||
|
|
partNumbers.size !== input.parts.length ||
|
|
input.parts.length !== session.parts.size ||
|
|
!contiguous ||
|
|
nextOffset !== session.expectedBytes ||
|
|
acceptedBytes !== session.expectedBytes
|
|
) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The uploaded parts did not pass final verification.",
|
|
);
|
|
}
|
|
this.#sessions.delete(input.sessionId);
|
|
return success({
|
|
resourceId: `resource-${input.sessionId}`,
|
|
state: "QUARANTINED",
|
|
});
|
|
}
|
|
|
|
async abort(
|
|
sessionId: string,
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(signal);
|
|
if (aborted) return aborted;
|
|
this.#sessions.delete(sessionId);
|
|
return success(undefined);
|
|
}
|
|
}
|
|
|
|
const windowsDeviceNames = new Set([
|
|
"CON",
|
|
"PRN",
|
|
"AUX",
|
|
"NUL",
|
|
"COM1",
|
|
"COM2",
|
|
"COM3",
|
|
"COM4",
|
|
"COM5",
|
|
"COM6",
|
|
"COM7",
|
|
"COM8",
|
|
"COM9",
|
|
"LPT1",
|
|
"LPT2",
|
|
"LPT3",
|
|
"LPT4",
|
|
"LPT5",
|
|
"LPT6",
|
|
"LPT7",
|
|
"LPT8",
|
|
"LPT9",
|
|
]);
|
|
|
|
function sanitizeFileNameCharacter(character: string): string {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
const control = codePoint <= 31 || codePoint === 127;
|
|
const bidiControl =
|
|
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
(codePoint >= 0x2066 && codePoint <= 0x2069);
|
|
if (control || bidiControl) return "";
|
|
return character === ":" ? "_" : character;
|
|
}
|
|
|
|
export function sanitizeDownloadFileName(
|
|
candidate: string,
|
|
fallback = "download.bin",
|
|
): string {
|
|
const normalized = candidate.normalize("NFC");
|
|
const pathSegments = normalized.split(/[/\\]/);
|
|
const leafName = pathSegments.at(-1) ?? normalized;
|
|
const cleaned = Array.from(leafName)
|
|
.map(sanitizeFileNameCharacter)
|
|
.join("")
|
|
.trim()
|
|
.replace(/^[. ]+|[. ]+$/g, "")
|
|
.replace(/_+/g, "_");
|
|
const bounded = Array.from(cleaned).slice(0, 180).join("");
|
|
const stem = bounded.split(".")[0]?.toUpperCase() ?? "";
|
|
if (
|
|
bounded.length === 0 ||
|
|
bounded === "." ||
|
|
bounded === ".." ||
|
|
windowsDeviceNames.has(stem)
|
|
) {
|
|
return fallback;
|
|
}
|
|
return bounded;
|
|
}
|
|
|
|
export class RecordingDownloadDeliveryAdapter
|
|
implements DownloadDeliveryPort
|
|
{
|
|
readonly receipts: Array<
|
|
Readonly<{ fileName: string; strategy: string; bytesWritten: number }>
|
|
> = [];
|
|
readonly #policies: MemoryBrowserFilePolicyRegistry;
|
|
readonly #resolveBrowserManaged:
|
|
BrowserManagedDownloadCapabilityResolver["resolve"];
|
|
readonly #now: () => number;
|
|
#sequence = 0;
|
|
|
|
constructor(
|
|
policies: MemoryBrowserFilePolicyRegistry,
|
|
browserManagedCapabilities: BrowserManagedDownloadCapabilityResolver,
|
|
now: () => number = Date.now,
|
|
) {
|
|
this.#policies = policies;
|
|
this.#resolveBrowserManaged =
|
|
browserManagedCapabilities.resolve.bind(
|
|
browserManagedCapabilities,
|
|
);
|
|
this.#now = now;
|
|
}
|
|
|
|
async deliver(
|
|
input: Parameters<DownloadDeliveryPort["deliver"]>[0],
|
|
): Promise<CapabilityResult<DownloadOutcome>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const resolvedPolicy = this.#policies.download(input.policy, {
|
|
maxTransferBytes: input.maxTransferBytes,
|
|
maxBufferedBytes: input.maxBufferedBytes,
|
|
});
|
|
if (!resolvedPolicy.ok) return resolvedPolicy;
|
|
const policy = resolvedPolicy.value;
|
|
const sanitized = sanitizeDownloadFileName(input.suggestedFileName);
|
|
const safeExtension = policy.safeExtension.toLowerCase();
|
|
const fileName = sanitized.toLowerCase().endsWith(safeExtension)
|
|
? sanitized
|
|
: `${sanitized.replace(/\.[^.]+$/, "")}${safeExtension}`;
|
|
const transferId = `download-${++this.#sequence}`;
|
|
if (
|
|
(policy.strategy === "BROWSER_MANAGED" &&
|
|
input.source.kind !== "BROWSER_MANAGED_RESOURCE") ||
|
|
(policy.strategy !== "BROWSER_MANAGED" &&
|
|
input.source.kind === "BROWSER_MANAGED_RESOURCE")
|
|
) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The source is incompatible with the registered strategy.",
|
|
);
|
|
}
|
|
if (
|
|
policy.strategy === "BROWSER_MANAGED" &&
|
|
input.source.kind === "BROWSER_MANAGED_RESOURCE"
|
|
) {
|
|
const capability = this.#resolveBrowserManaged({
|
|
resourceId: input.source.resourceId,
|
|
capabilityReceipt: input.source.capabilityReceipt,
|
|
});
|
|
if (!capability.ok) return capability;
|
|
if (
|
|
capability.value.capabilityReceipt !==
|
|
input.source.capabilityReceipt ||
|
|
capability.value.resourceId !== input.source.resourceId ||
|
|
capability.value.mediaType !== policy.mediaType ||
|
|
capability.value.safeExtension !== policy.safeExtension ||
|
|
capability.value.maxBytes > policy.maxTransferBytes ||
|
|
capability.value.expiresAtEpochMs <= this.#now() ||
|
|
(policy.integrity === "REQUIRED" &&
|
|
!capability.value.expectedSha256)
|
|
) {
|
|
return failure(
|
|
capability.value.expiresAtEpochMs <= this.#now()
|
|
? "EXPIRED_RESOURCE"
|
|
: "POLICY_REJECTED",
|
|
false,
|
|
"The server-bound download capability is invalid.",
|
|
);
|
|
}
|
|
this.receipts.push({
|
|
fileName,
|
|
strategy: policy.strategy,
|
|
bytesWritten: 0,
|
|
});
|
|
return success({ kind: "BROWSER_HANDOFF", transferId });
|
|
}
|
|
if (input.source.kind === "AUTHORIZED_STREAM_RESOURCE") {
|
|
return failure(
|
|
"UNSUPPORTED",
|
|
false,
|
|
"This fake cannot stream an authorized server resource.",
|
|
);
|
|
}
|
|
if (input.source.kind !== "GENERATED") {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The download source is incompatible with this fake.",
|
|
);
|
|
}
|
|
const expectedSha256 = input.source.expectedSha256;
|
|
if (policy.integrity === "REQUIRED" && !expectedSha256) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"The registered download policy requires integrity.",
|
|
);
|
|
}
|
|
if (
|
|
policy.strategy === "BOUNDED_OBJECT_URL" &&
|
|
input.source.bytes.byteLength !== null &&
|
|
input.source.bytes.byteLength > policy.maxBufferedBytes
|
|
) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"The generated download exceeds the buffering budget.",
|
|
);
|
|
}
|
|
let bytesWritten = 0;
|
|
const integrityChunks: Uint8Array[] = [];
|
|
for await (const chunkResult of input.source.bytes.stream(
|
|
input.signal,
|
|
)) {
|
|
if (!chunkResult.ok) return chunkResult;
|
|
const chunk = chunkResult.value;
|
|
const duringTransfer = cancelled(input.signal);
|
|
if (duringTransfer) return duringTransfer;
|
|
bytesWritten += chunk.byteLength;
|
|
if (bytesWritten > policy.maxTransferBytes) {
|
|
return failure(
|
|
"LIMIT_EXCEEDED",
|
|
false,
|
|
"The generated download exceeds the transfer budget.",
|
|
);
|
|
}
|
|
if (
|
|
input.source.bytes.byteLength !== null &&
|
|
bytesWritten > input.source.bytes.byteLength
|
|
) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The generated download exceeded its declared size.",
|
|
);
|
|
}
|
|
if (expectedSha256) integrityChunks.push(cloneBytes(chunk));
|
|
input.onProgress({
|
|
phase: "TRANSFERRING",
|
|
transferredBytes: bytesWritten,
|
|
totalBytes: input.source.bytes.byteLength,
|
|
});
|
|
}
|
|
if (
|
|
input.source.bytes.byteLength !== null &&
|
|
bytesWritten !== input.source.bytes.byteLength
|
|
) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The generated download did not match its declared size.",
|
|
);
|
|
}
|
|
if (expectedSha256) {
|
|
const beforeVerification = cancelled(input.signal);
|
|
if (beforeVerification) return beforeVerification;
|
|
input.onProgress({
|
|
phase: "VERIFYING",
|
|
transferredBytes: bytesWritten,
|
|
totalBytes: input.source.bytes.byteLength,
|
|
});
|
|
const actualSha256 = await sha256Hex(concatenateBytes(integrityChunks));
|
|
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The generated download failed integrity verification.",
|
|
);
|
|
}
|
|
}
|
|
this.receipts.push({
|
|
fileName,
|
|
strategy: policy.strategy,
|
|
bytesWritten,
|
|
});
|
|
return success({
|
|
kind: "SAVED",
|
|
transferId,
|
|
bytesWritten: asByteCount(bytesWritten),
|
|
integrity: expectedSha256 ? "VERIFIED" : "NOT_PROVIDED",
|
|
});
|
|
}
|
|
}
|
|
|
|
function concatenateBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
|
|
const totalBytes = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
const combined = new Uint8Array(totalBytes);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
combined.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return combined;
|
|
}
|
|
|
|
type MemoryStructuredOfflineStoreOptions = Readonly<{
|
|
maxBytes: number;
|
|
now?: () => number;
|
|
initialStatus?: OfflineStoreStatus;
|
|
}>;
|
|
|
|
export class MemoryStructuredOfflineStore<T>
|
|
implements StructuredOfflineStore<T>, OfflineStoreMaintenancePort
|
|
{
|
|
readonly #records = new Map<string, StoredRecord<T>>();
|
|
readonly #receipts = new Map<
|
|
string,
|
|
Readonly<{ fingerprint: string; receipt: OfflineCommitReceipt }>
|
|
>();
|
|
readonly #now: () => number;
|
|
readonly #maxBytes: number;
|
|
#status: OfflineStoreStatus;
|
|
#onLifecycle: ((event: OfflineStoreLifecycleEvent) => void) | null = null;
|
|
#commitSequence = 0;
|
|
|
|
constructor(options: MemoryStructuredOfflineStoreOptions) {
|
|
this.#maxBytes = options.maxBytes;
|
|
this.#now = options.now ?? Date.now;
|
|
this.#status =
|
|
options.initialStatus ??
|
|
Object.freeze({
|
|
kind: "CLOSED",
|
|
reason: "DISPOSED",
|
|
});
|
|
}
|
|
|
|
async open(input: {
|
|
partitionKey: string;
|
|
signal?: AbortSignal;
|
|
onLifecycle(event: OfflineStoreLifecycleEvent): void;
|
|
}): Promise<CapabilityResult<OfflineStoreStatus>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (input.partitionKey.length === 0) {
|
|
return failure("INVALID_INPUT", false, "The storage partition is invalid.");
|
|
}
|
|
this.#onLifecycle = input.onLifecycle;
|
|
if (this.#status.kind === "UPGRADE_BLOCKED") {
|
|
input.onLifecycle({ kind: "STATUS_CHANGED", status: this.#status });
|
|
return success(this.#status);
|
|
}
|
|
this.#status = Object.freeze({
|
|
kind: "READY",
|
|
persistence: "BEST_EFFORT",
|
|
});
|
|
input.onLifecycle({ kind: "STATUS_CHANGED", status: this.#status });
|
|
return success(this.#status);
|
|
}
|
|
|
|
async read(
|
|
id: string,
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<StoredRecord<T> | null>> {
|
|
const aborted = cancelled(signal);
|
|
if (aborted) return aborted;
|
|
if (!isReadableStatus(this.#status)) {
|
|
return failure("PROVIDER_UNAVAILABLE", false, "The offline store is not open.");
|
|
}
|
|
const record = this.#records.get(id);
|
|
if (
|
|
record?.expiresAtEpochMs !== null &&
|
|
record?.expiresAtEpochMs !== undefined &&
|
|
record.expiresAtEpochMs <= this.#now()
|
|
) {
|
|
return success(null);
|
|
}
|
|
return success(record ? structuredClone(record) : null);
|
|
}
|
|
|
|
async page(input: {
|
|
cursor?: string;
|
|
limit: number;
|
|
includeExpired?: boolean;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<OfflinePage<T>>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (!isReadableStatus(this.#status)) {
|
|
return failure("PROVIDER_UNAVAILABLE", false, "The offline store is not open.");
|
|
}
|
|
if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 500) {
|
|
return failure("LIMIT_EXCEEDED", false, "The offline page limit is invalid.");
|
|
}
|
|
const offset = input.cursor === undefined ? 0 : Number(input.cursor);
|
|
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
return failure("INVALID_INPUT", false, "The offline cursor is invalid.");
|
|
}
|
|
const now = this.#now();
|
|
const records = Array.from(this.#records.values())
|
|
.filter(
|
|
(record) =>
|
|
input.includeExpired === true ||
|
|
record.expiresAtEpochMs === null ||
|
|
record.expiresAtEpochMs > now,
|
|
)
|
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
const selected = records
|
|
.slice(offset, offset + input.limit)
|
|
.map((record) => structuredClone(record));
|
|
const nextOffset = offset + selected.length;
|
|
return success({
|
|
records: selected,
|
|
nextCursor: nextOffset < records.length ? String(nextOffset) : null,
|
|
});
|
|
}
|
|
|
|
async commit(input: {
|
|
idempotencyKey: string;
|
|
mutations: ReadonlyArray<StructuredOfflineMutation<T>>;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<OfflineCommitReceipt>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (!isWritableStatus(this.#status)) {
|
|
return failure("PROVIDER_UNAVAILABLE", false, "The offline store is read-only.");
|
|
}
|
|
if (input.idempotencyKey.length === 0 || input.mutations.length === 0) {
|
|
return failure("INVALID_INPUT", false, "The offline commit is empty.");
|
|
}
|
|
let fingerprint: string;
|
|
try {
|
|
fingerprint = JSON.stringify(input.mutations);
|
|
} catch {
|
|
return failure(
|
|
"CORRUPT_DATA",
|
|
false,
|
|
"The offline mutation cannot be encoded safely.",
|
|
);
|
|
}
|
|
const replay = this.#receipts.get(input.idempotencyKey);
|
|
if (replay) {
|
|
return replay.fingerprint === fingerprint
|
|
? success({ ...replay.receipt, replayed: true })
|
|
: failure(
|
|
"CONFLICT",
|
|
false,
|
|
"The idempotency key was reused for a different offline commit.",
|
|
);
|
|
}
|
|
|
|
const draft = new Map(
|
|
Array.from(this.#records, ([id, record]) => [
|
|
id,
|
|
structuredClone(record),
|
|
]),
|
|
);
|
|
const revisions: Record<string, number | null> = {};
|
|
const now = this.#now();
|
|
try {
|
|
for (const mutation of input.mutations) {
|
|
const current = draft.get(mutation.id);
|
|
if (!revisionAllows(mutation.revision, current?.revision)) {
|
|
return failure(
|
|
"CONFLICT",
|
|
false,
|
|
"The offline record changed before the commit.",
|
|
);
|
|
}
|
|
if (mutation.kind === "DELETE") {
|
|
draft.delete(mutation.id);
|
|
revisions[mutation.id] = null;
|
|
continue;
|
|
}
|
|
const revision = (current?.revision ?? 0) + 1;
|
|
draft.set(
|
|
mutation.id,
|
|
structuredClone({
|
|
id: mutation.id,
|
|
revision,
|
|
payloadVersion: mutation.payloadVersion,
|
|
createdAtEpochMs: current?.createdAtEpochMs ?? now,
|
|
updatedAtEpochMs: now,
|
|
expiresAtEpochMs: mutation.expiresAtEpochMs,
|
|
value: mutation.value,
|
|
}),
|
|
);
|
|
revisions[mutation.id] = revision;
|
|
}
|
|
} catch {
|
|
return failure(
|
|
"CORRUPT_DATA",
|
|
false,
|
|
"The offline value cannot be persisted safely.",
|
|
);
|
|
}
|
|
|
|
if (estimateRecordBytes(draft) > this.#maxBytes) {
|
|
return failure(
|
|
"QUOTA_EXCEEDED",
|
|
true,
|
|
"The offline storage budget was exceeded.",
|
|
);
|
|
}
|
|
const lastAbortCheck = cancelled(input.signal);
|
|
if (lastAbortCheck) return lastAbortCheck;
|
|
this.#records.clear();
|
|
for (const [id, record] of draft) this.#records.set(id, record);
|
|
const receipt = Object.freeze({
|
|
commitId: `commit-${++this.#commitSequence}`,
|
|
revisions: Object.freeze(revisions),
|
|
replayed: false,
|
|
});
|
|
this.#receipts.set(input.idempotencyKey, { fingerprint, receipt });
|
|
return success(receipt);
|
|
}
|
|
|
|
async clearPartition(input: {
|
|
reason: "LOGOUT" | "ACCOUNT_DELETION" | "USER_REQUEST";
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
this.#records.clear();
|
|
this.#receipts.clear();
|
|
return success(undefined);
|
|
}
|
|
|
|
status(): OfflineStoreStatus {
|
|
return this.#status;
|
|
}
|
|
|
|
close(): void {
|
|
this.#onLifecycle = null;
|
|
this.#status = Object.freeze({ kind: "CLOSED", reason: "DISPOSED" });
|
|
}
|
|
|
|
async resumeDataMigration(input: {
|
|
migrationId: string;
|
|
maxRecords: number;
|
|
timeBudgetMs: number;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<MigrationProgress>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (
|
|
input.migrationId.length === 0 ||
|
|
input.maxRecords < 1 ||
|
|
input.timeBudgetMs < 1
|
|
) {
|
|
return failure("INVALID_INPUT", false, "The migration budget is invalid.");
|
|
}
|
|
const progress = Object.freeze({
|
|
migrationId: input.migrationId,
|
|
state: "COMPLETED" as const,
|
|
processedCount: Math.min(this.#records.size, input.maxRecords),
|
|
remainingEstimate: 0,
|
|
});
|
|
this.#onLifecycle?.({ kind: "MIGRATION_PROGRESS", ...progress });
|
|
return success(progress);
|
|
}
|
|
|
|
async purgeExpired(input: {
|
|
maxRecords: number;
|
|
nowEpochMs: number;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<Readonly<{ purgedCount: number }>>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (input.maxRecords < 1) {
|
|
return failure("INVALID_INPUT", false, "The purge budget is invalid.");
|
|
}
|
|
let purgedCount = 0;
|
|
for (const [id, record] of this.#records) {
|
|
if (purgedCount >= input.maxRecords) break;
|
|
if (
|
|
record.expiresAtEpochMs !== null &&
|
|
record.expiresAtEpochMs <= input.nowEpochMs
|
|
) {
|
|
this.#records.delete(id);
|
|
purgedCount += 1;
|
|
}
|
|
}
|
|
return success({ purgedCount });
|
|
}
|
|
}
|
|
|
|
function revisionAllows(
|
|
guard: StructuredOfflineMutation<unknown>["revision"],
|
|
currentRevision: number | undefined,
|
|
): boolean {
|
|
if (guard.kind === "ANY") return true;
|
|
if (guard.kind === "MUST_NOT_EXIST") return currentRevision === undefined;
|
|
return currentRevision === guard.revision;
|
|
}
|
|
|
|
function estimateRecordBytes<T>(
|
|
records: ReadonlyMap<string, StoredRecord<T>>,
|
|
): number {
|
|
try {
|
|
return new TextEncoder().encode(JSON.stringify(Array.from(records.values())))
|
|
.byteLength;
|
|
} catch {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
}
|
|
|
|
export class MemoryStorageDurabilityAdapter implements StorageDurabilityPort {
|
|
#persisted: boolean;
|
|
|
|
constructor(
|
|
private readonly usageBytes: number | null,
|
|
private readonly quotaBytes: number | null,
|
|
private readonly persistenceDecision: "GRANTED" | "DENIED",
|
|
persisted = false,
|
|
) {
|
|
this.#persisted = persisted;
|
|
}
|
|
|
|
async inspect(
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<StorageEstimate>> {
|
|
return (
|
|
cancelled(signal) ??
|
|
success({
|
|
usageBytes: this.usageBytes,
|
|
quotaBytes: this.quotaBytes,
|
|
persisted: this.#persisted,
|
|
pressure: storagePressure(
|
|
this.usageBytes,
|
|
this.quotaBytes,
|
|
),
|
|
})
|
|
);
|
|
}
|
|
|
|
async requestPersistence(input: {
|
|
reason: "PROTECT_UNSYNCED_USER_DATA";
|
|
userInitiated: true;
|
|
signal?: AbortSignal;
|
|
}): Promise<CapabilityResult<"GRANTED" | "DENIED">> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
this.#persisted = this.persistenceDecision === "GRANTED";
|
|
return success(this.persistenceDecision);
|
|
}
|
|
}
|
|
|
|
function storagePressure(
|
|
usageBytes: number | null,
|
|
quotaBytes: number | null,
|
|
): StorageEstimate["pressure"] {
|
|
if (
|
|
usageBytes === null ||
|
|
quotaBytes === null ||
|
|
!Number.isFinite(usageBytes) ||
|
|
!Number.isFinite(quotaBytes) ||
|
|
usageBytes < 0 ||
|
|
quotaBytes <= 0
|
|
) {
|
|
return "UNKNOWN";
|
|
}
|
|
const ratio = usageBytes / quotaBytes;
|
|
if (ratio >= 0.85) return "CRITICAL";
|
|
if (ratio >= 0.7) return "PRESSURE";
|
|
return "NORMAL";
|
|
}
|
|
|
|
type MemoryObject = Readonly<{
|
|
descriptor: DurableObjectDescriptor;
|
|
chunks: ReadonlyArray<Uint8Array>;
|
|
}>;
|
|
|
|
type PendingObject = Readonly<{
|
|
id: DurableObjectId;
|
|
phase: "PREPARING" | "FILES_READY";
|
|
}>;
|
|
|
|
export class MemoryDurableObjectStore
|
|
implements DurableObjectStorePort, DurableObjectMaintenancePort
|
|
{
|
|
readonly #objects = new Map<DurableObjectId, MemoryObject>();
|
|
readonly #pending = new Map<DurableObjectId, PendingObject>();
|
|
#failurePhase: PendingObject["phase"] | null = null;
|
|
|
|
constructor(
|
|
private readonly maxObjectBytes: ByteCount,
|
|
private readonly backend: "OPFS" | "INDEXEDDB_BLOB" | "NONE" = "OPFS",
|
|
) {}
|
|
|
|
failNextPutAfter(phase: PendingObject["phase"]): void {
|
|
this.#failurePhase = phase;
|
|
}
|
|
|
|
async capabilities(signal?: AbortSignal) {
|
|
return (
|
|
cancelled(signal) ??
|
|
success({
|
|
backend: this.backend,
|
|
persistence: "BEST_EFFORT" as const,
|
|
maxObjectBytes: this.maxObjectBytes,
|
|
})
|
|
);
|
|
}
|
|
|
|
async put(
|
|
input: Parameters<DurableObjectStorePort["put"]>[0],
|
|
): Promise<CapabilityResult<DurableObjectDescriptor>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (this.backend === "NONE") {
|
|
return failure("UNSUPPORTED", false, "Durable object storage is unsupported.");
|
|
}
|
|
if (input.declaredByteLength > this.maxObjectBytes) {
|
|
return failure("LIMIT_EXCEEDED", false, "The object exceeds its byte budget.");
|
|
}
|
|
const current = this.#objects.get(input.id);
|
|
if (
|
|
(input.expectedGeneration === null && current !== undefined) ||
|
|
(input.expectedGeneration !== null &&
|
|
current?.descriptor.generation !== input.expectedGeneration)
|
|
) {
|
|
return failure("CONFLICT", false, "The object generation changed.");
|
|
}
|
|
this.#pending.set(input.id, { id: input.id, phase: "PREPARING" });
|
|
if (this.#failurePhase === "PREPARING") {
|
|
this.#failurePhase = null;
|
|
return failure(
|
|
"PROVIDER_UNAVAILABLE",
|
|
true,
|
|
"The object writer stopped after journal preparation.",
|
|
);
|
|
}
|
|
|
|
const chunks: Uint8Array[] = [];
|
|
let totalBytes = 0;
|
|
try {
|
|
for await (const chunkResult of input.source) {
|
|
if (!chunkResult.ok) return chunkResult;
|
|
const chunk = chunkResult.value;
|
|
const duringWrite = cancelled(input.signal);
|
|
if (duringWrite) return duringWrite;
|
|
if (!(chunk instanceof Uint8Array)) {
|
|
return failure(
|
|
"CORRUPT_DATA",
|
|
false,
|
|
"The object source emitted an invalid byte chunk.",
|
|
);
|
|
}
|
|
totalBytes += chunk.byteLength;
|
|
if (
|
|
totalBytes > input.declaredByteLength ||
|
|
totalBytes > this.maxObjectBytes
|
|
) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The object exceeded its declared size.",
|
|
);
|
|
}
|
|
chunks.push(cloneBytes(chunk));
|
|
input.onProgress({
|
|
phase: "TRANSFERRING",
|
|
transferredBytes: totalBytes,
|
|
totalBytes: input.declaredByteLength,
|
|
});
|
|
}
|
|
} catch {
|
|
return failure(
|
|
"NOT_READABLE",
|
|
true,
|
|
"The object byte source could not be read.",
|
|
);
|
|
}
|
|
if (totalBytes !== input.declaredByteLength) {
|
|
return failure(
|
|
"INTEGRITY_FAILED",
|
|
false,
|
|
"The object did not match its declared size.",
|
|
);
|
|
}
|
|
this.#pending.set(input.id, { id: input.id, phase: "FILES_READY" });
|
|
if (this.#failurePhase === "FILES_READY") {
|
|
this.#failurePhase = null;
|
|
return failure(
|
|
"PROVIDER_UNAVAILABLE",
|
|
true,
|
|
"The object writer stopped before logical commit.",
|
|
);
|
|
}
|
|
|
|
const generation = asObjectGeneration(
|
|
(current?.descriptor.generation ?? 0) + 1,
|
|
);
|
|
const chunkDigests = await Promise.all(chunks.map(sha256Hex));
|
|
const chunkSizeBytes = Math.max(
|
|
0,
|
|
...chunks.map((chunk) => chunk.byteLength),
|
|
);
|
|
const canonicalTree = new TextEncoder().encode(
|
|
[
|
|
totalBytes,
|
|
chunkSizeBytes,
|
|
...chunks.map(
|
|
(chunk, index) =>
|
|
`${chunk.byteLength}:${chunkDigests[index] ?? "missing"}`,
|
|
),
|
|
].join(":"),
|
|
);
|
|
const descriptor: DurableObjectDescriptor = Object.freeze({
|
|
id: input.id,
|
|
generation,
|
|
byteLength: asByteCount(totalBytes),
|
|
mediaType: input.mediaType,
|
|
integrity: Object.freeze({
|
|
algorithm: "SHA-256-TREE-V1",
|
|
rootDigest: await sha256Hex(canonicalTree),
|
|
chunkSizeBytes: asByteCount(chunkSizeBytes),
|
|
}),
|
|
dataClass: input.dataClass,
|
|
retention: input.retention,
|
|
});
|
|
this.#objects.set(input.id, { descriptor, chunks });
|
|
this.#pending.delete(input.id);
|
|
return success(descriptor);
|
|
}
|
|
|
|
async open(
|
|
id: DurableObjectId,
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<DurableObjectRead>> {
|
|
const aborted = cancelled(signal);
|
|
if (aborted) return aborted;
|
|
const object = this.#objects.get(id);
|
|
if (!object) {
|
|
return failure("NOT_FOUND", false, "The durable object does not exist.");
|
|
}
|
|
const chunks = object.chunks.map(cloneBytes);
|
|
return success({
|
|
descriptor: object.descriptor,
|
|
chunks: chunksFrom(chunks),
|
|
});
|
|
}
|
|
|
|
async remove(
|
|
input: Parameters<DurableObjectStorePort["remove"]>[0],
|
|
): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const current = this.#objects.get(input.id);
|
|
if (!current) {
|
|
return failure("NOT_FOUND", false, "The durable object does not exist.");
|
|
}
|
|
if (current.descriptor.generation !== input.expectedGeneration) {
|
|
return failure("CONFLICT", false, "The object generation changed.");
|
|
}
|
|
this.#objects.delete(input.id);
|
|
return success(undefined);
|
|
}
|
|
|
|
async reconcile(
|
|
input: Parameters<DurableObjectMaintenancePort["reconcile"]>[0],
|
|
): Promise<CapabilityResult<ObjectStoreRecoverySummary>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (input.maxEntries < 1 || input.timeBudgetMs < 1) {
|
|
return failure("INVALID_INPUT", false, "The recovery budget is invalid.");
|
|
}
|
|
const pending = Array.from(this.#pending.keys()).slice(0, input.maxEntries);
|
|
for (const id of pending) this.#pending.delete(id);
|
|
return success({
|
|
resumedCount: 0,
|
|
purgedCount: pending.length,
|
|
quarantinedCount: 0,
|
|
nextCursor: this.#pending.size > 0 ? String(pending.length) : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
new Uint8Array(buffer).set(bytes);
|
|
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
|
return Array.from(new Uint8Array(digest), (byte) =>
|
|
byte.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
}
|
|
|
|
async function* chunksFrom(
|
|
chunks: ReadonlyArray<Uint8Array>,
|
|
): AsyncIterable<CapabilityResult<Uint8Array>> {
|
|
for (const chunk of chunks) yield success(cloneBytes(chunk));
|
|
}
|
|
|
|
type MemoryCacheRelease = Readonly<{
|
|
manifestDigest: string;
|
|
entries: ReadonlyMap<string, PublicAssetEntry>;
|
|
}>;
|
|
|
|
type MemoryPublicResponseCacheOptions = Readonly<{
|
|
origin: string;
|
|
maxEntryBytes: number;
|
|
allowedMediaTypes: ReadonlySet<string>;
|
|
forbiddenPaths?: ReadonlyArray<string>;
|
|
}>;
|
|
|
|
export class MemoryPublicResponseCache
|
|
implements PublicResponseCacheAdmin
|
|
{
|
|
readonly #releases = new Map<string, MemoryCacheRelease>();
|
|
readonly #origin: string;
|
|
readonly #maxEntryBytes: number;
|
|
readonly #allowedMediaTypes: ReadonlySet<string>;
|
|
readonly #forbiddenPaths: ReadonlyArray<string>;
|
|
#activeReleaseId: string | null = null;
|
|
#previousReleaseId: string | null = null;
|
|
|
|
constructor(options: MemoryPublicResponseCacheOptions) {
|
|
this.#origin = new URL(options.origin).origin;
|
|
this.#maxEntryBytes = options.maxEntryBytes;
|
|
this.#allowedMediaTypes = options.allowedMediaTypes;
|
|
this.#forbiddenPaths = options.forbiddenPaths ?? [
|
|
"/config.json",
|
|
"/release-manifest.json",
|
|
"/api/",
|
|
"/auth/",
|
|
"/user/",
|
|
"/tenant/",
|
|
];
|
|
}
|
|
|
|
async stageRelease(
|
|
input: Parameters<PublicResponseCacheAdmin["stageRelease"]>[0],
|
|
): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (
|
|
input.releaseId.length === 0 ||
|
|
input.manifestDigest.length === 0 ||
|
|
input.entries.length === 0
|
|
) {
|
|
return failure("INVALID_INPUT", false, "The cache release is invalid.");
|
|
}
|
|
const staged = new Map<string, PublicAssetEntry>();
|
|
for (const entry of input.entries) {
|
|
const key = this.#cacheKey(entry.url);
|
|
if (
|
|
key === null ||
|
|
entry.byteLength > this.#maxEntryBytes ||
|
|
entry.integritySha256.length === 0 ||
|
|
entry.requestCredentials !== "OMIT" ||
|
|
entry.dataClass !== "PUBLIC" ||
|
|
!this.#allowedMediaTypes.has(entry.mediaType) ||
|
|
staged.has(key)
|
|
) {
|
|
return failure(
|
|
"POLICY_REJECTED",
|
|
false,
|
|
"A public cache entry violates the cache policy.",
|
|
);
|
|
}
|
|
staged.set(key, Object.freeze({ ...entry, url: key }));
|
|
}
|
|
this.#releases.set(input.releaseId, {
|
|
manifestDigest: input.manifestDigest,
|
|
entries: staged,
|
|
});
|
|
return success(undefined);
|
|
}
|
|
|
|
async activateRelease(
|
|
input: Parameters<PublicResponseCacheAdmin["activateRelease"]>[0],
|
|
): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
const candidate = this.#releases.get(input.releaseId);
|
|
if (
|
|
!candidate ||
|
|
candidate.manifestDigest !== input.manifestDigest ||
|
|
this.#activeReleaseId !== input.expectedPreviousReleaseId
|
|
) {
|
|
return failure(
|
|
"CONFLICT",
|
|
false,
|
|
"The cache candidate is incomplete or stale.",
|
|
);
|
|
}
|
|
this.#previousReleaseId = this.#activeReleaseId;
|
|
this.#activeReleaseId = input.releaseId;
|
|
return success(undefined);
|
|
}
|
|
|
|
async lookup(
|
|
input: Parameters<PublicResponseCacheAdmin["lookup"]>[0],
|
|
): Promise<CapabilityResult<PublicCacheLookup>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
if (
|
|
input.requestCredentials !== "OMIT" ||
|
|
input.hasAuthorization !== false
|
|
) {
|
|
return success({ kind: "MISS", reason: "POLICY_REJECTED" });
|
|
}
|
|
if (!this.#activeReleaseId) {
|
|
return success({ kind: "MISS", reason: "NO_ACTIVE_RELEASE" });
|
|
}
|
|
const key = this.#cacheKey(input.url);
|
|
if (!key) return success({ kind: "MISS", reason: "POLICY_REJECTED" });
|
|
const entry = this.#releases
|
|
.get(this.#activeReleaseId)
|
|
?.entries.get(key);
|
|
return entry
|
|
? success({
|
|
kind: "HIT",
|
|
releaseId: this.#activeReleaseId,
|
|
entry,
|
|
})
|
|
: success({ kind: "MISS", reason: "NOT_FOUND" });
|
|
}
|
|
|
|
async inspect(
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<PublicCacheInspection>> {
|
|
const aborted = cancelled(signal);
|
|
if (aborted) return aborted;
|
|
const candidateReleaseIds = Array.from(this.#releases.keys()).filter(
|
|
(releaseId) =>
|
|
releaseId !== this.#activeReleaseId &&
|
|
releaseId !== this.#previousReleaseId,
|
|
);
|
|
const ownedBytes = Array.from(this.#releases.values()).reduce(
|
|
(total, release) =>
|
|
total +
|
|
Array.from(release.entries.values()).reduce(
|
|
(releaseTotal, entry) => releaseTotal + entry.byteLength,
|
|
0,
|
|
),
|
|
0,
|
|
);
|
|
return success({
|
|
activeReleaseId: this.#activeReleaseId,
|
|
previousReleaseId: this.#previousReleaseId,
|
|
candidateReleaseIds,
|
|
ownedBytes,
|
|
});
|
|
}
|
|
|
|
async rollback(
|
|
signal?: AbortSignal,
|
|
): Promise<CapabilityResult<void>> {
|
|
const aborted = cancelled(signal);
|
|
if (aborted) return aborted;
|
|
if (!this.#previousReleaseId) {
|
|
return failure("NOT_FOUND", false, "No previous cache release is available.");
|
|
}
|
|
const active = this.#activeReleaseId;
|
|
this.#activeReleaseId = this.#previousReleaseId;
|
|
this.#previousReleaseId = active;
|
|
return success(undefined);
|
|
}
|
|
|
|
async deleteOwned(
|
|
input: Parameters<PublicResponseCacheAdmin["deleteOwned"]>[0],
|
|
): Promise<CapabilityResult<Readonly<{ deletedCount: number }>>> {
|
|
const aborted = cancelled(input.signal);
|
|
if (aborted) return aborted;
|
|
let deletedCount = 0;
|
|
if (input.roles.includes("CANDIDATE")) {
|
|
for (const releaseId of Array.from(this.#releases.keys())) {
|
|
if (
|
|
releaseId !== this.#activeReleaseId &&
|
|
releaseId !== this.#previousReleaseId
|
|
) {
|
|
this.#releases.delete(releaseId);
|
|
deletedCount += 1;
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
input.roles.includes("PREVIOUS") &&
|
|
this.#previousReleaseId !== null
|
|
) {
|
|
this.#releases.delete(this.#previousReleaseId);
|
|
this.#previousReleaseId = null;
|
|
deletedCount += 1;
|
|
}
|
|
return success({ deletedCount });
|
|
}
|
|
|
|
#cacheKey(value: string): string | null {
|
|
try {
|
|
const url = new URL(value, this.#origin);
|
|
if (
|
|
url.origin !== this.#origin ||
|
|
this.#forbiddenPaths.some(
|
|
(path) =>
|
|
url.pathname === path ||
|
|
(path.endsWith("/") && url.pathname.startsWith(path)),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
url.hash = "";
|
|
return url.href;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|