import { WEB_PUSH_PROTOCOLS, webPushFailure, webPushSuccess, type PushAuthoritySnapshot, type WebPushResult, } from "../../contracts/web-push.ts"; const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; export const WEB_PUSH_REGISTRATION_OPERATIONS = Object.freeze({ register: "REGISTER_WEB_PUSH_SUBSCRIPTION", reconcile: "RECONCILE_WEB_PUSH_SUBSCRIPTION", revoke: "REVOKE_WEB_PUSH_ASSOCIATION", } as const); export type NativePushSubscriptionMaterial = Readonly<{ endpoint: string; p256dh: string; auth: string; expirationTime: number | null; }>; export type WebPushRegistrationExecutor = Readonly<{ execute(input: Readonly<{ operationId: (typeof WEB_PUSH_REGISTRATION_OPERATIONS)[keyof typeof WEB_PUSH_REGISTRATION_OPERATIONS]; body: unknown; idempotencyKey?: string; signal?: AbortSignal; }>): Promise>; }>; export type WebPushRegistrationCommit = Readonly<{ associationEpoch: string; sessionBindingEpoch: string; }>; export type WebPushReconciliation = | Readonly<{ state: "ABSENT" }> | Readonly<{ state: "ACTIVE"; associationEpoch: string; sessionBindingEpoch: string; }>; export interface WebPushRegistrationGateway { register(input: Readonly<{ material: NativePushSubscriptionMaterial; authority: PushAuthoritySnapshot; idempotencyKey: string; signal?: AbortSignal; }>): Promise>; reconcile(input: Readonly<{ material: NativePushSubscriptionMaterial; authority: PushAuthoritySnapshot; signal?: AbortSignal; }>): Promise>; revoke(input: Readonly<{ associationEpoch: string; signal?: AbortSignal; }>): Promise>>; } export function createWebPushRegistrationGateway( executor: WebPushRegistrationExecutor, ): WebPushRegistrationGateway { const gateway: WebPushRegistrationGateway = { async register(input) { if ( !validMaterial(input.material) || !validAuthority(input.authority) || !validOpaqueId(input.idempotencyKey) ) { return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_CREATE"); } const response = await execute( { operationId: WEB_PUSH_REGISTRATION_OPERATIONS.register, body: Object.freeze({ protocol: "WEB_PUSH_REGISTER_COMMAND_V1", subscription: snapshotMaterial(input.material), fenceGeneration: input.authority.fenceGeneration, sessionBindingEpoch: input.authority.sessionBindingEpoch, releaseEpoch: input.authority.releaseEpoch, }), idempotencyKey: input.idempotencyKey, signal: input.signal, }, "SUBSCRIPTION_CREATE", ); if (!response.ok) return response; const decoded = decodeRegistration(response.value); return decoded ? webPushSuccess(decoded) : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_CREATE"); }, async reconcile(input) { if ( !validMaterial(input.material) || !validAuthority(input.authority) ) { return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_RECONCILE"); } const response = await execute( { operationId: WEB_PUSH_REGISTRATION_OPERATIONS.reconcile, body: Object.freeze({ protocol: "WEB_PUSH_RECONCILE_COMMAND_V1", subscription: snapshotMaterial(input.material), fenceGeneration: input.authority.fenceGeneration, sessionBindingEpoch: input.authority.sessionBindingEpoch, releaseEpoch: input.authority.releaseEpoch, }), signal: input.signal, }, "SUBSCRIPTION_RECONCILE", ); if (!response.ok) return response; const decoded = decodeReconciliation(response.value); return decoded ? webPushSuccess(decoded) : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_RECONCILE"); }, async revoke(input) { if (!validOpaqueId(input.associationEpoch)) { return webPushFailure("INVALID_INPUT", "SUBSCRIPTION_REVOKE"); } const response = await execute( { operationId: WEB_PUSH_REGISTRATION_OPERATIONS.revoke, body: Object.freeze({ protocol: "WEB_PUSH_REVOKE_COMMAND_V1", associationEpoch: input.associationEpoch, }), signal: input.signal, }, "SUBSCRIPTION_REVOKE", ); if (!response.ok) return response; const decoded = decodeRevoke(response.value); return decoded ? webPushSuccess(decoded) : webPushFailure("CONTRACT_REJECTED", "SUBSCRIPTION_REVOKE"); }, }; return Object.freeze(gateway); async function execute( input: Parameters[0], operation: | "SUBSCRIPTION_CREATE" | "SUBSCRIPTION_RECONCILE" | "SUBSCRIPTION_REVOKE", ): Promise> { try { return await executor.execute(input); } catch { return webPushFailure("NATIVE_FAILURE", operation, true); } } } function decodeRegistration( value: unknown, ): WebPushRegistrationCommit | null { if (!exactRecord(value, [ "associationEpoch", "protocol", "sessionBindingEpoch", ])) { return null; } return value.protocol === WEB_PUSH_PROTOCOLS.registration && validOpaqueId(value.associationEpoch) && validOpaqueId(value.sessionBindingEpoch) ? Object.freeze({ associationEpoch: value.associationEpoch, sessionBindingEpoch: value.sessionBindingEpoch, }) : null; } function decodeReconciliation( value: unknown, ): WebPushReconciliation | null { if ( exactRecord(value, ["protocol", "state"]) && value.protocol === WEB_PUSH_PROTOCOLS.reconciliation && value.state === "ABSENT" ) { return Object.freeze({ state: "ABSENT" }); } if ( !exactRecord(value, [ "associationEpoch", "protocol", "sessionBindingEpoch", "state", ]) || value.protocol !== WEB_PUSH_PROTOCOLS.reconciliation || value.state !== "ACTIVE" || !validOpaqueId(value.associationEpoch) || !validOpaqueId(value.sessionBindingEpoch) ) { return null; } return Object.freeze({ state: "ACTIVE", associationEpoch: value.associationEpoch, sessionBindingEpoch: value.sessionBindingEpoch, }); } function decodeRevoke( value: unknown, ): Readonly<{ state: "REVOKED" | "ALREADY_GONE" }> | null { return exactRecord(value, ["protocol", "state"]) && value.protocol === WEB_PUSH_PROTOCOLS.revoke && (value.state === "REVOKED" || value.state === "ALREADY_GONE") ? Object.freeze({ state: value.state }) : null; } function exactRecord( value: unknown, keys: readonly string[], ): value is Record { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const actual = Object.keys(value).sort(); return ( actual.length === keys.length && actual.every((key, index) => key === keys[index]) ); } function validOpaqueId(value: unknown): value is string { return typeof value === "string" && OPAQUE_ID.test(value); } function validAuthority(value: PushAuthoritySnapshot): boolean { return ( validOpaqueId(value.fenceGeneration) && validOpaqueId(value.sessionBindingEpoch) && validOpaqueId(value.releaseEpoch) ); } function validMaterial(value: NativePushSubscriptionMaterial): boolean { const p256dh = base64UrlDecode(value?.p256dh); const auth = base64UrlDecode(value?.auth); if ( !value || typeof value !== "object" || typeof value.endpoint !== "string" || value.endpoint.length > 4_096 || !/^[A-Za-z0-9_-]{87}$/u.test(value.p256dh) || !/^[A-Za-z0-9_-]{22}$/u.test(value.auth) || p256dh?.byteLength !== 65 || p256dh[0] !== 4 || auth?.byteLength !== 16 || (value.expirationTime !== null && (!Number.isFinite(value.expirationTime) || value.expirationTime <= 0)) ) { return false; } try { const endpoint = new URL(value.endpoint); return ( endpoint.protocol === "https:" && !endpoint.username && !endpoint.password && !endpoint.hash ); } catch { return false; } } function snapshotMaterial( value: NativePushSubscriptionMaterial, ): NativePushSubscriptionMaterial { return Object.freeze({ endpoint: value.endpoint, p256dh: value.p256dh, auth: value.auth, expirationTime: value.expirationTime, }); } function base64UrlDecode(value: unknown): Uint8Array | null { if (typeof value !== "string") return null; const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const output = new Uint8Array( new ArrayBuffer(Math.floor((value.length * 6) / 8)), ); let accumulator = 0; let bitCount = 0; let outputIndex = 0; for (const character of value) { const digit = alphabet.indexOf(character); if (digit < 0) return null; accumulator = (accumulator << 6) | digit; bitCount += 6; if (bitCount >= 8) { bitCount -= 8; output[outputIndex] = (accumulator >> bitCount) & 0xff; outputIndex += 1; } } return bitCount < 6 && outputIndex === output.length && (bitCount === 0 || (accumulator & ((1 << bitCount) - 1)) === 0) ? output : null; }