import { WEB_PUSH_LIMITS, WEB_PUSH_PROTOCOLS, samePushAuthority, webPushFailure, webPushSuccess, type PushAuthoritySnapshot, type PushControlAssociationV1, type PushControlV1, type WebPushOperation, type WebPushResult, } from "../../contracts/web-push.ts"; import { withAbortableDeadline, type TimeoutScheduler, } from "./runtime-support.ts"; const CONTROL_KEY = "push-control-v1"; const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u; export type PushControlReceipt = Readonly<{ control: PushControlV1; revision: number; }>; export type PushControlWriteReceipt = Readonly<{ key: string; revision: number; replayed: boolean; }>; /** * The failure surface this adapter consumes. `code` is intentionally a plain * string: the storage taxonomy is owned by whichever runtime backs the store, * and an unrecognised code maps to the safe default in * {@link mapRepositoryFailure} rather than failing to compile. */ export type PushControlStoreFailure = Readonly<{ code: string; retryable: boolean; }>; export type PushControlStoreResult = | Readonly<{ ok: true; value: Value }> | Readonly<{ ok: false; error: PushControlStoreFailure }>; /** * The narrow durable store this capability requires: revisioned * compare-and-swap over a single key. * * It is declared here, structurally, rather than imported from the browser * file/storage port so the two capabilities stay independently removable. The * generic IndexedDB repository satisfies it as-is; the composition root is * where the two are joined, and it owns connection, migration, transaction, * timeout, codec and version-change policy. */ export type PushControlRepository = Readonly<{ open(signal?: AbortSignal): Promise>; read( key: string, signal?: AbortSignal, ): Promise< PushControlStoreResult< Readonly<{ value: PushControlV1; revision: number }> | null > >; compareAndSwap( input: Readonly<{ key: string; value: PushControlV1; expectedRevision: number | null; idempotencyKey: string; signal?: AbortSignal; }>, ): Promise>; remove( input: Readonly<{ key: string; expectedRevision: number | null; idempotencyKey: string; signal?: AbortSignal; }>, ): Promise>; close(): void; }>; export interface PushAssociationFenceStore { read(input?: Readonly<{ signal?: AbortSignal; }>): Promise>; prepare(input: Readonly<{ authority: PushAuthoritySnapshot; updatedAt: string; signal?: AbortSignal; }>): Promise>; activate(input: Readonly<{ expectedRevision: number; authority: PushAuthoritySnapshot; associationEpoch: string; updatedAt: string; signal?: AbortSignal; }>): Promise>; markRevoked(input: Readonly<{ expectedRevision: number; authority: PushAuthoritySnapshot; updatedAt: string; signal?: AbortSignal; }>): Promise>; rotateAndRevoke(input: Readonly<{ expectedRevision: number; previousAuthority: PushAuthoritySnapshot; nextAuthority: PushAuthoritySnapshot; updatedAt: string; signal?: AbortSignal; }>): Promise>; purge(input: Readonly<{ expectedRevision: number; authority: PushAuthoritySnapshot; associationEpoch: string; signal?: AbortSignal; }>): Promise>; close(): void; } export type PushAssociationFenceStoreDependencies = Readonly<{ repository: PushControlRepository; idempotencyKeyFactory?: () => string; operationDeadlineMs?: number; scheduler?: TimeoutScheduler; }>; export function createPushAssociationFenceStore( dependencies: PushAssociationFenceStoreDependencies, ): PushAssociationFenceStore { if ( !dependencies || typeof dependencies !== "object" || !validRepository(dependencies.repository) ) { throw new TypeError("Web Push fence store configuration is invalid."); } const repository = dependencies.repository; const operationDeadlineMs = dependencies.operationDeadlineMs ?? WEB_PUSH_LIMITS.fenceOperationDeadlineMs; if ( !Number.isSafeInteger(operationDeadlineMs) || operationDeadlineMs < 1 || operationDeadlineMs > WEB_PUSH_LIMITS.fenceOperationDeadlineMs ) { throw new TypeError("Web Push fence deadline is invalid."); } const idempotencyKeyFactory = dependencies.idempotencyKeyFactory ?? (() => `push-control-${globalThis.crypto.randomUUID()}`); let closed = false; const store: PushAssociationFenceStore = { async read(input = {}) { return await bounded( "CONTROL_READ", input.signal, (signal) => readReceipt("CONTROL_READ", signal), ); }, async prepare(input) { if ( !validAuthority(input.authority) || !validInstant(input.updatedAt) ) { return webPushFailure("INVALID_INPUT", "CONTROL_PREPARE"); } return await bounded( "CONTROL_PREPARE", input.signal, async (signal) => { const current = await readReceipt( "CONTROL_PREPARE", signal, ); if (!current.ok) return current; if (current.value) { return samePushAuthority( current.value.control, input.authority, ) ? webPushSuccess(current.value) : webPushFailure( "STALE_AUTHORITY", "CONTROL_PREPARE", ); } return await compareAndSwap( "CONTROL_PREPARE", null, controlSnapshot({ protocol: WEB_PUSH_PROTOCOLS.control, ...input.authority, updatedAt: input.updatedAt, association: Object.freeze({ state: "UNASSOCIATED", }), }), signal, ); }, ); }, async activate(input) { if ( !validRevision(input.expectedRevision) || !validAuthority(input.authority) || !validOpaqueId(input.associationEpoch) || !validInstant(input.updatedAt) ) { return webPushFailure("INVALID_INPUT", "CONTROL_ACTIVATE"); } return await bounded( "CONTROL_ACTIVATE", input.signal, async (signal) => { const current = await currentForMutation( "CONTROL_ACTIVATE", input.expectedRevision, input.authority, signal, ); if (!current.ok) return current; if ( current.value.control.association.state === "ACTIVE" && current.value.control.association.associationEpoch === input.associationEpoch ) { return current; } if ( current.value.control.association.state === "REVOKED" && current.value.control.association.associationEpoch === input.associationEpoch ) { return webPushFailure( "TOMBSTONE_CONFLICT", "CONTROL_ACTIVATE", ); } return await compareAndSwap( "CONTROL_ACTIVATE", input.expectedRevision, controlSnapshot({ ...current.value.control, updatedAt: input.updatedAt, association: Object.freeze({ state: "ACTIVE", associationEpoch: input.associationEpoch, }), }), signal, ); }, ); }, async markRevoked(input) { if ( !validRevision(input.expectedRevision) || !validAuthority(input.authority) || !validInstant(input.updatedAt) ) { return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE"); } return await bounded( "CONTROL_REVOKE", input.signal, async (signal) => { const current = await currentForMutation( "CONTROL_REVOKE", input.expectedRevision, input.authority, signal, ); if (!current.ok) return current; if ( current.value.control.association.state === "UNASSOCIATED" ) { return current; } return await compareAndSwap( "CONTROL_REVOKE", input.expectedRevision, controlSnapshot({ ...current.value.control, updatedAt: input.updatedAt, association: Object.freeze({ state: "REVOKED", associationEpoch: current.value.control.association .associationEpoch, }), }), signal, ); }, ); }, async rotateAndRevoke(input) { if ( !validRevision(input.expectedRevision) || !validAuthority(input.previousAuthority) || !validAuthority(input.nextAuthority) || input.previousAuthority.fenceGeneration === input.nextAuthority.fenceGeneration || !validInstant(input.updatedAt) ) { return webPushFailure("INVALID_INPUT", "CONTROL_REVOKE"); } return await bounded( "CONTROL_REVOKE", input.signal, async (signal) => { const current = await currentForMutation( "CONTROL_REVOKE", input.expectedRevision, input.previousAuthority, signal, ); if (!current.ok) return current; const association: PushControlAssociationV1 = current.value.control.association.state === "UNASSOCIATED" ? Object.freeze({ state: "UNASSOCIATED" }) : Object.freeze({ state: "REVOKED", associationEpoch: current.value.control.association .associationEpoch, }); return await compareAndSwap( "CONTROL_REVOKE", input.expectedRevision, controlSnapshot({ protocol: WEB_PUSH_PROTOCOLS.control, ...input.nextAuthority, updatedAt: input.updatedAt, association, }), signal, ); }, ); }, async purge(input) { if ( !validRevision(input.expectedRevision) || !validAuthority(input.authority) || !validOpaqueId(input.associationEpoch) ) { return webPushFailure("INVALID_INPUT", "CONTROL_PURGE"); } return await bounded( "CONTROL_PURGE", input.signal, async (signal) => { const current = await currentForMutation( "CONTROL_PURGE", input.expectedRevision, input.authority, signal, ); if (!current.ok) return current; if ( current.value.control.association.state !== "REVOKED" || current.value.control.association.associationEpoch !== input.associationEpoch ) { return webPushFailure( "ASSOCIATION_MISMATCH", "CONTROL_PURGE", ); } return await removeControl(input.expectedRevision, signal); }, ); }, close() { if (closed) return; closed = true; try { repository.close(); } catch { // The local authority is terminal even if host cleanup throws. } }, }; return Object.freeze(store); async function bounded( operation: WebPushOperation, signal: AbortSignal | undefined, task: ( boundedSignal: AbortSignal, ) => Promise>, ): Promise> { return await withAbortableDeadline(task, { deadlineMs: operationDeadlineMs, operation, signal, scheduler: dependencies.scheduler, }); } async function currentForMutation( operation: | "CONTROL_ACTIVATE" | "CONTROL_REVOKE" | "CONTROL_PURGE", expectedRevision: number, authority: PushAuthoritySnapshot, signal: AbortSignal | undefined, ): Promise> { const current = await readReceipt(operation, signal); if (!current.ok) return current; if (!current.value || current.value.revision !== expectedRevision) { return webPushFailure("STALE_REVISION", operation); } if (!samePushAuthority(current.value.control, authority)) { return webPushFailure("STALE_AUTHORITY", operation); } return webPushSuccess(current.value); } async function readReceipt( operation: WebPushOperation, signal: AbortSignal | undefined, ): Promise> { if (closed) return webPushFailure("NATIVE_FAILURE", operation); if (signal?.aborted) return webPushFailure("ABORTED", operation); const opened = await callRepository( () => repository.open(signal), operation, ); if (!opened.ok) return opened; const read = await callRepository( () => repository.read(CONTROL_KEY, signal), operation, ); if (!read.ok) return read; if (!read.value) return webPushSuccess(null); const control = decodeControl(read.value.value); if (!control || !validRevision(read.value.revision)) { return webPushFailure("CONTROL_CORRUPT", operation); } return webPushSuccess( Object.freeze({ control, revision: read.value.revision, }), ); } async function compareAndSwap( operation: | "CONTROL_PREPARE" | "CONTROL_ACTIVATE" | "CONTROL_REVOKE", expectedRevision: number | null, control: PushControlV1, signal: AbortSignal | undefined, ): Promise> { let idempotencyKey: string; try { idempotencyKey = idempotencyKeyFactory(); } catch { return webPushFailure("NATIVE_FAILURE", operation); } if (!validIdempotencyKey(idempotencyKey)) { return webPushFailure("INVALID_INPUT", operation); } const written = await callRepository( () => repository.compareAndSwap({ key: CONTROL_KEY, value: control, expectedRevision, idempotencyKey, ...(signal ? { signal } : {}), }), operation, ); if (!written.ok) return written; if (!validWriteReceipt(written.value, expectedRevision)) { return webPushFailure("CONTROL_CORRUPT", operation); } return webPushSuccess( Object.freeze({ control, revision: written.value.revision, }), ); } async function removeControl( expectedRevision: number, signal: AbortSignal | undefined, ): Promise> { let idempotencyKey: string; try { idempotencyKey = idempotencyKeyFactory(); } catch { return webPushFailure("NATIVE_FAILURE", "CONTROL_PURGE"); } if (!validIdempotencyKey(idempotencyKey)) { return webPushFailure("INVALID_INPUT", "CONTROL_PURGE"); } const removed = await callRepository( () => repository.remove({ key: CONTROL_KEY, expectedRevision, idempotencyKey, ...(signal ? { signal } : {}), }), "CONTROL_PURGE", ); if (!removed.ok) return removed; if (!validWriteReceipt(removed.value, expectedRevision)) { return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE"); } return webPushSuccess(undefined); } } async function callRepository( call: () => Promise>, operation: WebPushOperation, ): Promise> { try { const result = await call(); return result.ok ? webPushSuccess(result.value) : mapRepositoryFailure(result.error, operation); } catch { return webPushFailure("NATIVE_FAILURE", operation, true); } } function mapRepositoryFailure( failure: PushControlStoreFailure, operation: WebPushOperation, ): WebPushResult { switch (failure.code) { case "ABORTED": return webPushFailure("ABORTED", operation); case "BLOCKED": return webPushFailure("BLOCKED", operation, failure.retryable); case "CONFLICT": case "STALE_RESULT": return webPushFailure( "STALE_REVISION", operation, failure.retryable, ); case "CORRUPT_DATA": case "EXPIRED_RESOURCE": case "INTEGRITY_FAILED": case "MIGRATION_FAILED": return webPushFailure( "CONTROL_CORRUPT", operation, failure.retryable, ); case "INVALID_INPUT": return webPushFailure("INVALID_INPUT", operation); case "LIMIT_EXCEEDED": return webPushFailure( "LIMIT_EXCEEDED", operation, failure.retryable, ); case "UNSUPPORTED": return webPushFailure("UNSUPPORTED", operation); default: return webPushFailure( "NATIVE_FAILURE", operation, failure.retryable, ); } } function controlSnapshot(value: PushControlV1): PushControlV1 { const association: PushControlAssociationV1 = value.association.state === "UNASSOCIATED" ? Object.freeze({ state: "UNASSOCIATED" }) : Object.freeze({ state: value.association.state, associationEpoch: value.association.associationEpoch, }); return Object.freeze({ protocol: WEB_PUSH_PROTOCOLS.control, fenceGeneration: value.fenceGeneration, sessionBindingEpoch: value.sessionBindingEpoch, releaseEpoch: value.releaseEpoch, updatedAt: value.updatedAt, association, }); } function decodeControl(value: unknown): PushControlV1 | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; const keys = Object.keys(record).sort(); if ( keys.length !== 6 || keys.join("|") !== "association|fenceGeneration|protocol|releaseEpoch|sessionBindingEpoch|updatedAt" || record.protocol !== WEB_PUSH_PROTOCOLS.control || !validOpaqueId(record.fenceGeneration) || !validOpaqueId(record.sessionBindingEpoch) || !validOpaqueId(record.releaseEpoch) || !validInstant(record.updatedAt) || !validAssociation(record.association) ) { return null; } return controlSnapshot({ protocol: WEB_PUSH_PROTOCOLS.control, fenceGeneration: record.fenceGeneration, sessionBindingEpoch: record.sessionBindingEpoch, releaseEpoch: record.releaseEpoch, updatedAt: record.updatedAt, association: record.association, }); } function validAssociation( value: unknown, ): value is PushControlAssociationV1 { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const record = value as Record; const keys = Object.keys(record).sort(); if (record.state === "UNASSOCIATED") { return keys.length === 1 && keys[0] === "state"; } return ( (record.state === "ACTIVE" || record.state === "REVOKED") && keys.length === 2 && keys[0] === "associationEpoch" && keys[1] === "state" && validOpaqueId(record.associationEpoch) ); } function validAuthority( value: PushAuthoritySnapshot, ): value is PushAuthoritySnapshot { return ( Boolean(value) && validOpaqueId(value.fenceGeneration) && validOpaqueId(value.sessionBindingEpoch) && validOpaqueId(value.releaseEpoch) ); } function validRepository( value: unknown, ): value is PushControlRepository { if (!value || typeof value !== "object") return false; const repository = value as Partial; return ( typeof repository.open === "function" && typeof repository.read === "function" && typeof repository.compareAndSwap === "function" && typeof repository.remove === "function" && typeof repository.close === "function" ); } /** * WP-01. One validator for both write and remove. * * A CAS receipt is only evidence when it names the expected key and the exact * next revision. Accepting any well-typed revision let a stale or arbitrary * repository receipt be packaged as a confirmed control, after which the whole * CAS authority is wrong. A replayed receipt must still carry that exact * revision, since replay means "this command already produced this revision". */ function validWriteReceipt( value: PushControlWriteReceipt, expectedRevision: number | null, ): value is PushControlWriteReceipt { return ( Boolean(value) && value.key === CONTROL_KEY && validRevision(value.revision) && typeof value.replayed === "boolean" && value.revision === (expectedRevision ?? 0) + 1 ); } function validIdempotencyKey(value: unknown): value is string { return ( typeof value === "string" && value.length >= 1 && value.length <= 200 ); } function validOpaqueId(value: unknown): value is string { return typeof value === "string" && OPAQUE_ID.test(value); } function validRevision(value: unknown): value is number { return ( typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ); } function validInstant(value: unknown): value is string { return ( typeof value === "string" && ISO_INSTANT.test(value) && Number.isFinite(Date.parse(value)) ); }