chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -0,0 +1,230 @@
import {
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
webPushFailure,
webPushSuccess,
type WebPushObserver,
type WebPushResult,
} from "../../../contracts/web-push.ts";
import type { PushAssociationFenceStore } from "../push-association-fence-store.ts";
import { decodeNotificationClickData } from "../push-codec.ts";
import type { WebPushNotificationRegistry } from "../notification-registry.ts";
import {
createLinkedAbortController,
nativeFailure,
observeWebPush,
withAbortableDeadline,
type TimeoutScheduler,
} from "../runtime-support.ts";
export type NotificationFacade = Readonly<{
data: unknown;
close(): void;
}>;
export type NotificationClickEventFacade = Readonly<{
notification: NotificationFacade;
waitUntil(task: Promise<void>): void;
}>;
export type WindowClientFacade = Readonly<{
url: string;
focus(): Promise<unknown>;
postMessage(message: unknown): void;
}>;
export type WorkerClientsFacade = Readonly<{
matchControlledWindowClients(): Promise<
readonly WindowClientFacade[]
>;
openWindow(url: string): Promise<WindowClientFacade | null>;
}>;
export type NotificationClickAdapter = Readonly<{
handle(event: NotificationClickEventFacade): Promise<WebPushResult<void>>;
}>;
export function createNotificationClickAdapter(dependencies: Readonly<{
fenceStore: PushAssociationFenceStore;
clients: WorkerClientsFacade;
origin: string;
now?: () => number;
handlerDeadlineMs?: number;
scheduler?: TimeoutScheduler;
signal?: AbortSignal;
observer?: WebPushObserver;
registry: WebPushNotificationRegistry;
}>): NotificationClickAdapter {
const now = dependencies.now ?? Date.now;
const handlerDeadlineMs =
dependencies.handlerDeadlineMs ?? WEB_PUSH_LIMITS.handlerDeadlineMs;
const parsedOrigin = safeOrigin(dependencies.origin);
if (
!parsedOrigin ||
!Number.isSafeInteger(handlerDeadlineMs) ||
handlerDeadlineMs < 1 ||
handlerDeadlineMs > WEB_PUSH_LIMITS.handlerDeadlineMs
) {
throw new TypeError("Web Push click adapter configuration is invalid.");
}
const origin: string = parsedOrigin;
return Object.freeze({
handle(event) {
try {
event.notification.close();
} catch {
// Closing is best effort and never expands click authority.
}
const taskControl = createLinkedAbortController(
dependencies.signal,
);
const processing = withAbortableDeadline(
(signal) => process(event.notification.data, signal),
{
deadlineMs: handlerDeadlineMs,
operation: "NOTIFICATION_CLICK",
signal: taskControl.signal,
scheduler: dependencies.scheduler,
},
).finally(taskControl.dispose);
try {
event.waitUntil(
processing.then((result) => {
observeWebPush(dependencies.observer, {
event: "web_push_click_dispatched",
outcome: result.ok ? "SUCCEEDED" : "FAILED",
...(result.ok ? {} : { reason: result.error.code }),
});
}),
);
} catch {
taskControl.abort();
return Promise.resolve(nativeFailure("NOTIFICATION_CLICK", false));
}
return processing;
},
});
async function process(
data: unknown,
signal: AbortSignal,
): Promise<WebPushResult<void>> {
const decoded = decodeNotificationClickData(data, now());
if (!decoded.ok) return decoded;
const initialFence = await validateActiveFence(
decoded.value.associationEpoch,
decoded.value.releaseEpoch,
signal,
);
if (!initialFence.ok) return initialFence;
const path = dependencies.registry.routePath(decoded.value.routeIntent);
if (!path) {
return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK");
}
const target = safeTarget(origin, path);
if (!target) {
return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK");
}
let clients: readonly WindowClientFacade[];
try {
clients =
await dependencies.clients.matchControlledWindowClients();
} catch {
return nativeFailure("NOTIFICATION_CLICK", true);
}
const boundedClients = clients.slice(
0,
WEB_PUSH_LIMITS.clientHandoffCount,
);
const existing = boundedClients.find(
(client) => safeOrigin(client.url) === origin,
);
const finalFence = await validateActiveFence(
decoded.value.associationEpoch,
decoded.value.releaseEpoch,
signal,
);
if (!finalFence.ok) return finalFence;
const handoff = Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.clickHandoff,
routeIntent: decoded.value.routeIntent,
notificationId: decoded.value.notificationId,
associationEpoch: decoded.value.associationEpoch,
releaseEpoch: decoded.value.releaseEpoch,
expiresAt: decoded.value.expiresAt,
path,
});
try {
if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
if (existing) {
existing.postMessage(handoff);
await existing.focus();
} else {
const opened = await dependencies.clients.openWindow(target);
if (!opened) return nativeFailure("NOTIFICATION_CLICK", true);
}
if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_CLICK");
}
} catch {
return nativeFailure("NOTIFICATION_CLICK", true);
}
return webPushSuccess(undefined);
}
async function validateActiveFence(
associationEpoch: string,
releaseEpoch: string,
signal: AbortSignal,
): Promise<WebPushResult<void>> {
const control = await dependencies.fenceStore.read({ signal });
if (!control.ok) return control;
if (
!control.value ||
control.value.control.association.state !== "ACTIVE" ||
control.value.control.association.associationEpoch !==
associationEpoch
) {
return webPushFailure(
"ASSOCIATION_MISMATCH",
"NOTIFICATION_CLICK",
);
}
return control.value.control.releaseEpoch === releaseEpoch
? webPushSuccess(undefined)
: webPushFailure(
"RELEASE_MISMATCH",
"NOTIFICATION_CLICK",
);
}
}
function safeOrigin(value: string): string | null {
try {
const parsed = new URL(value);
const local =
parsed.protocol === "http:" &&
["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
return parsed.protocol === "https:" || local ? parsed.origin : null;
} catch {
return null;
}
}
function safeTarget(origin: string, path: string): string | null {
try {
const target = new URL(path, origin);
return target.origin === origin &&
target.username === "" &&
target.password === "" &&
target.hash === ""
? target.href
: null;
} catch {
return null;
}
}
@@ -0,0 +1,196 @@
import {
WEB_PUSH_LIMITS,
webPushFailure,
webPushSuccess,
type WebPushObserver,
type WebPushResult,
} from "../../../contracts/web-push.ts";
import type { PushAssociationFenceStore } from "../push-association-fence-store.ts";
import {
clickDataFromHint,
decodeWebPushHint,
} from "../push-codec.ts";
import {
createAssociationNotificationTag,
type SafeNotification,
type WebPushNotificationRegistry,
} from "../notification-registry.ts";
import {
createLinkedAbortController,
failureCode,
nativeFailure,
observeWebPush,
withAbortableDeadline,
type TimeoutScheduler,
} from "../runtime-support.ts";
export type PushMessageDataFacade = Readonly<{
arrayBuffer(): ArrayBuffer;
}>;
export type PushEventFacade = Readonly<{
data: PushMessageDataFacade | null;
waitUntil(task: Promise<void>): void;
}>;
export type WorkerNotificationFacade = Readonly<{
showNotification(
title: string,
options: SafeNotification["options"],
): Promise<void>;
}>;
export type PushEventAdapter = Readonly<{
handle(event: PushEventFacade): Promise<WebPushResult<void>>;
}>;
export function createPushEventAdapter(dependencies: Readonly<{
fenceStore: PushAssociationFenceStore;
notifications: WorkerNotificationFacade;
now?: () => number;
handlerDeadlineMs?: number;
scheduler?: TimeoutScheduler;
signal?: AbortSignal;
observer?: WebPushObserver;
tagDigest?: Parameters<typeof createAssociationNotificationTag>[2];
registry: WebPushNotificationRegistry;
}>): PushEventAdapter {
const now = dependencies.now ?? Date.now;
const handlerDeadlineMs =
dependencies.handlerDeadlineMs ?? WEB_PUSH_LIMITS.handlerDeadlineMs;
if (
!Number.isSafeInteger(handlerDeadlineMs) ||
handlerDeadlineMs < 1 ||
handlerDeadlineMs > WEB_PUSH_LIMITS.handlerDeadlineMs
) {
throw new TypeError("Web Push handler deadline is invalid.");
}
return Object.freeze({
handle(event) {
const taskControl = createLinkedAbortController(
dependencies.signal,
);
const processing = withAbortableDeadline(
(signal) => process(event, signal),
{
deadlineMs: handlerDeadlineMs,
operation: "PUSH_HANDLE",
signal: taskControl.signal,
scheduler: dependencies.scheduler,
},
).finally(taskControl.dispose);
try {
event.waitUntil(
processing.then((result) => {
observeWebPush(dependencies.observer, {
event: "web_push_hint_processed",
outcome: result.ok ? "SUCCEEDED" : "FAILED",
...(result.ok ? {} : { reason: result.error.code }),
});
}),
);
} catch {
taskControl.abort();
return Promise.resolve(nativeFailure("PUSH_HANDLE", false));
}
return processing;
},
});
async function process(
event: PushEventFacade,
signal: AbortSignal,
): Promise<WebPushResult<void>> {
if (!event.data) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
let bytes: ArrayBuffer;
try {
bytes = event.data.arrayBuffer();
} catch {
return nativeFailure("PUSH_DECODE", false);
}
const decoded = decodeWebPushHint(bytes, now());
if (!decoded.ok) return decoded;
const initialFence = await validateActiveFence(
decoded.value.associationEpoch,
decoded.value.releaseEpoch,
signal,
);
if (!initialFence.ok) return initialFence;
const definition = dependencies.registry.resolve(
decoded.value.notificationType,
decoded.value.routeIntent,
);
if (!definition) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_HANDLE");
}
let tag: string;
try {
tag = await createAssociationNotificationTag(
decoded.value.associationEpoch,
decoded.value.notificationType,
dependencies.tagDigest,
);
} catch {
return nativeFailure("PUSH_HANDLE", false);
}
if (signal.aborted) {
return webPushFailure("ABORTED", "PUSH_HANDLE");
}
const finalFence = await validateActiveFence(
decoded.value.associationEpoch,
decoded.value.releaseEpoch,
signal,
);
if (!finalFence.ok) return finalFence;
const clickData = clickDataFromHint(decoded.value);
try {
await dependencies.notifications.showNotification(definition.title, {
body: definition.body,
data: clickData,
requireInteraction: false,
tag,
});
if (signal.aborted) {
return webPushFailure("ABORTED", "NOTIFICATION_SHOW");
}
} catch {
const failed = nativeFailure("NOTIFICATION_SHOW", true);
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: "FAILED",
reason: failureCode(failed),
});
return failed;
}
observeWebPush(dependencies.observer, {
event: "web_push_notification_finished",
outcome: "SUCCEEDED",
});
return webPushSuccess(undefined);
}
async function validateActiveFence(
associationEpoch: string,
releaseEpoch: string,
signal: AbortSignal,
): Promise<WebPushResult<void>> {
const control = await dependencies.fenceStore.read({ signal });
if (!control.ok) return control;
if (
!control.value ||
control.value.control.association.state !== "ACTIVE" ||
control.value.control.association.associationEpoch !==
associationEpoch
) {
return webPushFailure("ASSOCIATION_MISMATCH", "PUSH_HANDLE");
}
return control.value.control.releaseEpoch === releaseEpoch
? webPushSuccess(undefined)
: webPushFailure("RELEASE_MISMATCH", "PUSH_HANDLE");
}
}
+66
View File
@@ -0,0 +1,66 @@
export {
createAssociationNotificationTag,
createWebPushNotificationRegistry,
type SafeNotification,
type WebPushNotificationRegistry,
} from "./notification-registry.ts";
export {
createPushAssociationFenceStore,
type PushAssociationFenceStore,
type PushAssociationFenceStoreDependencies,
type PushControlReceipt,
type PushControlRepository,
} from "./push-association-fence-store.ts";
export {
clickDataFromHint,
decodeNotificationClickData,
decodeNotificationClickDataForCleanup,
decodeWebPushHint,
} from "./push-codec.ts";
export {
createWebPushRegistrationGateway,
WEB_PUSH_REGISTRATION_OPERATIONS,
type NativePushSubscriptionMaterial,
type WebPushReconciliation,
type WebPushRegistrationCommit,
type WebPushRegistrationExecutor,
type WebPushRegistrationGateway,
} from "./push-registration-gateway.ts";
export {
createWebPushSubscriptionAdapter,
type NotificationPermissionFacade,
type OwnedNotificationFacade,
type WindowPushManagerFacade,
type WindowPushSubscriptionFacade,
type WindowServiceWorkerRegistrationFacade,
} from "./push-subscription-adapter.ts";
export {
createWebPushServiceWorkerRuntime,
type ServiceWorkerEventHost,
type WebPushServiceWorkerRuntime,
} from "./service-worker-runtime.ts";
export {
createLinkedAbortController,
failureCode,
nativeFailure,
observeWebPush,
systemTimeoutScheduler,
withAbortableDeadline,
type LinkedAbortController,
type TimeoutScheduler,
} from "./runtime-support.ts";
export {
createNotificationClickAdapter,
type NotificationClickAdapter,
type NotificationClickEventFacade,
type NotificationFacade,
type WindowClientFacade,
type WorkerClientsFacade,
} from "./inbound/notification-click-adapter.ts";
export {
createPushEventAdapter,
type PushEventAdapter,
type PushEventFacade,
type PushMessageDataFacade,
type WorkerNotificationFacade,
} from "./inbound/push-event-adapter.ts";
@@ -0,0 +1,122 @@
import type {
NotificationClickDataV1,
NotificationRouteIntentId,
NotificationTypeId,
} from "../../contracts/web-push.ts";
export type SafeNotification = Readonly<{
title: string;
options: Readonly<{
body: string;
data: NotificationClickDataV1;
requireInteraction: false;
tag: string;
}>;
}>;
export type WebPushNotificationDefinition = Readonly<{
notificationType: NotificationTypeId;
routeIntent: NotificationRouteIntentId;
title: string;
body: string;
path: string;
}>;
export interface WebPushNotificationRegistry {
resolve(
notificationType: NotificationTypeId,
routeIntent: NotificationRouteIntentId,
): WebPushNotificationDefinition | null;
routePath(routeIntent: NotificationRouteIntentId): string | null;
}
const REGISTRY_ID = /^[A-Z][A-Z0-9_]{0,63}$/u;
export function createWebPushNotificationRegistry(
definitions: readonly WebPushNotificationDefinition[],
): WebPushNotificationRegistry {
if (
definitions.length === 0 ||
definitions.length > 32 ||
definitions.some(
(definition) =>
!REGISTRY_ID.test(definition.notificationType) ||
!REGISTRY_ID.test(definition.routeIntent) ||
definition.title.length < 1 ||
definition.title.length > 80 ||
definition.body.length < 1 ||
definition.body.length > 160 ||
!safePath(definition.path),
)
) {
throw new TypeError("Web Push notification registry is invalid.");
}
const byType = new Map<string, WebPushNotificationDefinition>();
const byRoute = new Map<string, string>();
for (const definition of definitions) {
if (
byType.has(definition.notificationType) ||
(byRoute.has(definition.routeIntent) &&
byRoute.get(definition.routeIntent) !== definition.path)
) {
throw new TypeError("Web Push notification registry is ambiguous.");
}
const snapshot = Object.freeze({ ...definition });
byType.set(definition.notificationType, snapshot);
byRoute.set(definition.routeIntent, definition.path);
}
const registry: WebPushNotificationRegistry = {
resolve(notificationType, routeIntent) {
const definition = byType.get(notificationType);
return definition && definition.routeIntent === routeIntent
? definition
: null;
},
routePath(routeIntent) {
return byRoute.get(routeIntent) ?? null;
},
};
return Object.freeze(registry);
}
export type AssociationNotificationTagDigest = (
algorithm: "SHA-256",
data: Uint8Array<ArrayBuffer>,
) => Promise<ArrayBuffer>;
export async function createAssociationNotificationTag(
associationEpoch: string,
notificationType: NotificationTypeId,
digest: AssociationNotificationTagDigest = (algorithm, data) =>
globalThis.crypto.subtle.digest(algorithm, data),
): Promise<string> {
const encoded = new TextEncoder().encode(
`PUSH_ASSOCIATION_TAG_V1\0${associationEpoch}`,
);
const hashed = new Uint8Array(await digest("SHA-256", encoded));
if (hashed.byteLength !== 32) {
throw new TypeError("Web Push notification tag digest is invalid.");
}
const prefix = [...hashed.subarray(0, 12)]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
return `ca-push-v1-${prefix}-${notificationType.toLowerCase()}`;
}
function safePath(path: string): boolean {
if (
!path.startsWith("/") ||
path.startsWith("//") ||
path.includes("\\") ||
path.includes("#") ||
path.length > 512
) {
return false;
}
try {
const parsed = new URL(path, "https://registry.invalid");
return parsed.origin === "https://registry.invalid";
} catch {
return false;
}
}
@@ -0,0 +1,733 @@
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<Value> =
| 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<PushControlStoreResult<void>>;
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<PushControlStoreResult<PushControlWriteReceipt>>;
remove(
input: Readonly<{
key: string;
expectedRevision: number | null;
idempotencyKey: string;
signal?: AbortSignal;
}>,
): Promise<PushControlStoreResult<PushControlWriteReceipt>>;
close(): void;
}>;
export interface PushAssociationFenceStore {
read(input?: Readonly<{
signal?: AbortSignal;
}>): Promise<WebPushResult<PushControlReceipt | null>>;
prepare(input: Readonly<{
authority: PushAuthoritySnapshot;
updatedAt: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<PushControlReceipt>>;
activate(input: Readonly<{
expectedRevision: number;
authority: PushAuthoritySnapshot;
associationEpoch: string;
updatedAt: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<PushControlReceipt>>;
markRevoked(input: Readonly<{
expectedRevision: number;
authority: PushAuthoritySnapshot;
updatedAt: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<PushControlReceipt>>;
rotateAndRevoke(input: Readonly<{
expectedRevision: number;
previousAuthority: PushAuthoritySnapshot;
nextAuthority: PushAuthoritySnapshot;
updatedAt: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<PushControlReceipt>>;
purge(input: Readonly<{
expectedRevision: number;
authority: PushAuthoritySnapshot;
associationEpoch: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<void>>;
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<Value>(
operation: WebPushOperation,
signal: AbortSignal | undefined,
task: (
boundedSignal: AbortSignal,
) => Promise<WebPushResult<Value>>,
): Promise<WebPushResult<Value>> {
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<WebPushResult<PushControlReceipt>> {
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<WebPushResult<PushControlReceipt | null>> {
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<WebPushResult<PushControlReceipt>> {
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)) {
return webPushFailure("CONTROL_CORRUPT", operation);
}
return webPushSuccess(
Object.freeze({
control,
revision: written.value.revision,
}),
);
}
async function removeControl(
expectedRevision: number,
signal: AbortSignal | undefined,
): Promise<WebPushResult<void>> {
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) ||
removed.value.revision !== expectedRevision + 1
) {
return webPushFailure("CONTROL_CORRUPT", "CONTROL_PURGE");
}
return webPushSuccess(undefined);
}
}
async function callRepository<Value>(
call: () => Promise<PushControlStoreResult<Value>>,
operation: WebPushOperation,
): Promise<WebPushResult<Value>> {
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<never> {
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<string, unknown>;
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<string, unknown>;
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<PushControlRepository>;
return (
typeof repository.open === "function" &&
typeof repository.read === "function" &&
typeof repository.compareAndSwap === "function" &&
typeof repository.remove === "function" &&
typeof repository.close === "function"
);
}
function validWriteReceipt(
value: PushControlWriteReceipt,
): value is PushControlWriteReceipt {
return (
Boolean(value) &&
value.key === CONTROL_KEY &&
validRevision(value.revision) &&
typeof value.replayed === "boolean"
);
}
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))
);
}
+308
View File
@@ -0,0 +1,308 @@
import {
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
webPushFailure,
webPushSuccess,
type NotificationClickDataV1,
type WebPushHintV1,
type WebPushResult,
} from "../../contracts/web-push.ts";
const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
const REGISTRY_ID = /^[A-Z][A-Z0-9_]{0,63}$/u;
const ISO_INSTANT =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
const HINT_KEYS = Object.freeze([
"associationEpoch",
"expiresAt",
"issuedAt",
"notificationId",
"notificationType",
"protocol",
"releaseEpoch",
"routeIntent",
] as const);
const CLICK_KEYS = Object.freeze([
"associationEpoch",
"expiresAt",
"notificationId",
"protocol",
"releaseEpoch",
"routeIntent",
] as const);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasExactKeys(
value: Record<string, unknown>,
expected: readonly string[],
): boolean {
const actual = Object.keys(value).sort();
return (
actual.length === expected.length &&
actual.every((key, index) => key === expected[index])
);
}
function validOpaqueId(value: unknown): value is string {
return typeof value === "string" && OPAQUE_ID.test(value);
}
function instant(value: unknown): number | null {
if (typeof value !== "string" || !ISO_INSTANT.test(value)) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function byteView(input: ArrayBuffer | Uint8Array): Uint8Array {
return input instanceof Uint8Array ? input : new Uint8Array(input);
}
export function decodeWebPushHint(
input: ArrayBuffer | Uint8Array,
nowEpochMs: number,
): WebPushResult<WebPushHintV1> {
const bytes = byteView(input);
if (bytes.byteLength === 0 || bytes.byteLength > WEB_PUSH_LIMITS.decodedHintBytes) {
return webPushFailure("LIMIT_EXCEEDED", "PUSH_DECODE");
}
if (
bytes.byteLength >= 3 &&
bytes[0] === 0xef &&
bytes[1] === 0xbb &&
bytes[2] === 0xbf
) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
let decoded: string;
try {
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
let value: unknown;
try {
value = JSON.parse(decoded);
} catch {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
if (hasDuplicateTopLevelJsonKeys(decoded)) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
if (isRecord(value) && Object.hasOwn(value, "web_push")) {
return webPushFailure("DECLARATIVE_PUSH_FORBIDDEN", "PUSH_DECODE");
}
if (!isRecord(value) || !hasExactKeys(value, HINT_KEYS)) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
const issuedAt = instant(value.issuedAt);
const expiresAt = instant(value.expiresAt);
if (
value.protocol !== WEB_PUSH_PROTOCOLS.hint ||
typeof value.notificationType !== "string" ||
!REGISTRY_ID.test(value.notificationType) ||
typeof value.routeIntent !== "string" ||
!REGISTRY_ID.test(value.routeIntent) ||
!validOpaqueId(value.notificationId) ||
!validOpaqueId(value.associationEpoch) ||
!validOpaqueId(value.releaseEpoch) ||
issuedAt === null ||
expiresAt === null ||
!Number.isFinite(nowEpochMs) ||
issuedAt > expiresAt ||
issuedAt > nowEpochMs + WEB_PUSH_LIMITS.hintFutureSkewMs ||
expiresAt - issuedAt > WEB_PUSH_LIMITS.hintMaxLifetimeMs
) {
return webPushFailure("CONTRACT_REJECTED", "PUSH_DECODE");
}
if (expiresAt < nowEpochMs) {
return webPushFailure("EXPIRED", "PUSH_DECODE");
}
return webPushSuccess(
Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.hint,
notificationType: value.notificationType,
notificationId: value.notificationId,
associationEpoch: value.associationEpoch,
releaseEpoch: value.releaseEpoch,
issuedAt: value.issuedAt as string,
expiresAt: value.expiresAt as string,
routeIntent: value.routeIntent,
}),
);
}
export function decodeNotificationClickData(
value: unknown,
nowEpochMs: number,
): WebPushResult<NotificationClickDataV1> {
const decoded = decodeNotificationClickDataForCleanup(value);
if (!decoded.ok) return decoded;
const expiresAt = instant(decoded.value.expiresAt);
if (
expiresAt === null ||
!Number.isFinite(nowEpochMs) ||
expiresAt < nowEpochMs
) {
return webPushFailure("EXPIRED", "NOTIFICATION_CLICK");
}
return decoded;
}
/**
* Strictly recognizes mechanism-owned notification data without using expiry
* as an ownership test. Logout cleanup must still close an expired envelope.
*/
export function decodeNotificationClickDataForCleanup(
value: unknown,
): WebPushResult<NotificationClickDataV1> {
if (!isRecord(value) || !hasExactKeys(value, CLICK_KEYS)) {
return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK");
}
const expiresAt = instant(value.expiresAt);
if (
value.protocol !== WEB_PUSH_PROTOCOLS.click ||
typeof value.routeIntent !== "string" ||
!REGISTRY_ID.test(value.routeIntent) ||
!validOpaqueId(value.notificationId) ||
!validOpaqueId(value.associationEpoch) ||
!validOpaqueId(value.releaseEpoch) ||
expiresAt === null
) {
return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK");
}
let encodedBytes: number;
try {
encodedBytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
} catch {
return webPushFailure("CONTRACT_REJECTED", "NOTIFICATION_CLICK");
}
if (encodedBytes > WEB_PUSH_LIMITS.decodedHintBytes) {
return webPushFailure("LIMIT_EXCEEDED", "NOTIFICATION_CLICK");
}
return webPushSuccess(
Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.click,
notificationId: value.notificationId,
routeIntent: value.routeIntent,
associationEpoch: value.associationEpoch,
releaseEpoch: value.releaseEpoch,
expiresAt: value.expiresAt as string,
}),
);
}
export function clickDataFromHint(
hint: WebPushHintV1,
): NotificationClickDataV1 {
return Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.click,
notificationId: hint.notificationId,
routeIntent: hint.routeIntent,
associationEpoch: hint.associationEpoch,
releaseEpoch: hint.releaseEpoch,
expiresAt: hint.expiresAt,
});
}
function hasDuplicateTopLevelJsonKeys(source: string): boolean {
let cursor = skipWhitespace(source, 0);
if (source[cursor] !== "{") return false;
cursor = skipWhitespace(source, cursor + 1);
const keys = new Set<string>();
while (cursor < source.length && source[cursor] !== "}") {
if (source[cursor] !== "\"") return false;
const keyEnd = jsonStringEnd(source, cursor);
if (keyEnd === null) return false;
let key: unknown;
try {
key = JSON.parse(source.slice(cursor, keyEnd));
} catch {
return false;
}
if (typeof key !== "string") return false;
if (keys.has(key)) return true;
keys.add(key);
cursor = skipWhitespace(source, keyEnd);
if (source[cursor] !== ":") return false;
const valueEnd = jsonValueEnd(source, cursor + 1);
if (valueEnd === null) return false;
cursor = skipWhitespace(source, valueEnd);
if (source[cursor] === ",") {
cursor = skipWhitespace(source, cursor + 1);
continue;
}
if (source[cursor] !== "}") return false;
}
return false;
}
function jsonStringEnd(
source: string,
start: number,
): number | null {
let escaped = false;
for (let cursor = start + 1; cursor < source.length; cursor += 1) {
const character = source[cursor];
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "\"") {
return cursor + 1;
}
}
return null;
}
function jsonValueEnd(
source: string,
start: number,
): number | null {
let cursor = skipWhitespace(source, start);
let depth = 0;
let inString = false;
let escaped = false;
for (; cursor < source.length; cursor += 1) {
const character = source[cursor];
if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === "\"") {
inString = false;
}
continue;
}
if (character === "\"") {
inString = true;
} else if (character === "{" || character === "[") {
depth += 1;
} else if (character === "}" || character === "]") {
if (depth === 0) return cursor;
depth -= 1;
} else if (character === "," && depth === 0) {
return cursor;
}
}
return inString || depth !== 0 ? null : cursor;
}
function skipWhitespace(source: string, start: number): number {
let cursor = start;
while (
cursor < source.length &&
(source[cursor] === " " ||
source[cursor] === "\n" ||
source[cursor] === "\r" ||
source[cursor] === "\t")
) {
cursor += 1;
}
return cursor;
}
@@ -0,0 +1,325 @@
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<WebPushResult<unknown>>;
}>;
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<WebPushResult<WebPushRegistrationCommit>>;
reconcile(input: Readonly<{
material: NativePushSubscriptionMaterial;
authority: PushAuthoritySnapshot;
signal?: AbortSignal;
}>): Promise<WebPushResult<WebPushReconciliation>>;
revoke(input: Readonly<{
associationEpoch: string;
signal?: AbortSignal;
}>): Promise<WebPushResult<Readonly<{
state: "REVOKED" | "ALREADY_GONE";
}>>>;
}
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<WebPushRegistrationExecutor["execute"]>[0],
operation:
| "SUBSCRIPTION_CREATE"
| "SUBSCRIPTION_RECONCILE"
| "SUBSCRIPTION_REVOKE",
): Promise<WebPushResult<unknown>> {
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<string, unknown> {
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;
}
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
import {
webPushFailure,
type WebPushFailureCode,
type WebPushObserver,
type WebPushOperation,
type WebPushResult,
} from "../../contracts/web-push.ts";
export type TimeoutScheduler = Readonly<{
setTimeout(callback: () => void, milliseconds: number): unknown;
clearTimeout(handle: unknown): void;
}>;
export const systemTimeoutScheduler: TimeoutScheduler = Object.freeze({
setTimeout(callback, milliseconds) {
return globalThis.setTimeout(callback, milliseconds);
},
clearTimeout(handle) {
globalThis.clearTimeout(
handle as ReturnType<typeof globalThis.setTimeout>,
);
},
});
export type LinkedAbortController = Readonly<{
signal: AbortSignal;
abort(): void;
dispose(): void;
}>;
export function createLinkedAbortController(
source?: AbortSignal,
): LinkedAbortController {
const controller = new AbortController();
const abort = () => controller.abort();
if (source?.aborted) {
controller.abort();
} else {
source?.addEventListener("abort", abort, { once: true });
if (source?.aborted) abort();
}
return Object.freeze({
signal: controller.signal,
abort,
dispose() {
source?.removeEventListener("abort", abort);
},
});
}
export async function withAbortableDeadline<Value>(
task: (signal: AbortSignal) => Promise<WebPushResult<Value>>,
input: Readonly<{
deadlineMs: number;
operation: WebPushOperation;
signal?: AbortSignal;
scheduler?: TimeoutScheduler;
}>,
): Promise<WebPushResult<Value>> {
if (input.signal?.aborted) {
return webPushFailure("ABORTED", input.operation);
}
const scheduler = input.scheduler ?? systemTimeoutScheduler;
const controller = new AbortController();
let timeoutHandle: unknown;
let settleTerminal:
| ((result: WebPushResult<Value>) => void)
| undefined;
const terminal = new Promise<WebPushResult<Value>>((resolve) => {
settleTerminal = resolve;
});
const abortFromCaller = () => {
controller.abort();
settleTerminal?.(webPushFailure("ABORTED", input.operation));
};
input.signal?.addEventListener("abort", abortFromCaller, {
once: true,
});
if (input.signal?.aborted) abortFromCaller();
try {
timeoutHandle = scheduler.setTimeout(() => {
controller.abort();
settleTerminal?.(
webPushFailure("DEADLINE_EXCEEDED", input.operation),
);
}, input.deadlineMs);
} catch {
controller.abort();
input.signal?.removeEventListener("abort", abortFromCaller);
return webPushFailure(
"NATIVE_FAILURE",
input.operation,
true,
);
}
const execution = Promise.resolve()
.then(() =>
controller.signal.aborted
? webPushFailure("ABORTED", input.operation)
: task(controller.signal),
)
.catch(() => webPushFailure("NATIVE_FAILURE", input.operation, true));
try {
return await Promise.race([execution, terminal]);
} finally {
try {
scheduler.clearTimeout(timeoutHandle);
} catch {
// A host cleanup failure cannot replace the settled closed result.
}
input.signal?.removeEventListener("abort", abortFromCaller);
}
}
export function observeWebPush(
observer: WebPushObserver | undefined,
input: Parameters<WebPushObserver["record"]>[0],
): void {
try {
observer?.record(Object.freeze({ ...input }));
} catch {
// Capability correctness is independent from best-effort observation.
}
}
export function nativeFailure(
operation: WebPushOperation,
retryable = true,
): WebPushResult<never> {
return webPushFailure("NATIVE_FAILURE", operation, retryable);
}
export function failureCode<Value>(
result: WebPushResult<Value>,
): WebPushFailureCode | undefined {
return result.ok ? undefined : result.error.code;
}
@@ -0,0 +1,281 @@
import {
WEB_PUSH_LIMITS,
WEB_PUSH_PROTOCOLS,
webPushFailure,
webPushSuccess,
type WebPushObserver,
} from "../../contracts/web-push.ts";
import type { PushAssociationFenceStore } from "./push-association-fence-store.ts";
import {
createPushEventAdapter,
type PushEventFacade,
} from "./inbound/push-event-adapter.ts";
import {
createNotificationClickAdapter,
type NotificationClickEventFacade,
type WindowClientFacade,
} from "./inbound/notification-click-adapter.ts";
import type {
AssociationNotificationTagDigest,
WebPushNotificationRegistry,
} from "./notification-registry.ts";
import {
createLinkedAbortController,
nativeFailure,
observeWebPush,
withAbortableDeadline,
type TimeoutScheduler,
} from "./runtime-support.ts";
type FunctionalEventFacade = Readonly<{
waitUntil(task: Promise<void>): void;
}>;
type ServiceWorkerRegistrationFacade = Readonly<{
showNotification(
title: string,
options: Readonly<{
body: string;
data: unknown;
requireInteraction: false;
tag: string;
}>,
): Promise<void>;
}>;
type ServiceWorkerClientsFacade = Readonly<{
matchAll(input: Readonly<{
type: "window";
includeUncontrolled: boolean;
}>): Promise<readonly unknown[]>;
openWindow(url: string): Promise<unknown>;
}>;
/**
* Structural worker host used deliberately instead of exposing DOM worker
* globals to application/test compilation. A selected worker entry adapts its
* native scope to this facade; this factory has no registration side effect.
*/
export type ServiceWorkerEventHost = Readonly<{
origin: string;
registration: ServiceWorkerRegistrationFacade;
clients: ServiceWorkerClientsFacade;
addEventListener(type: string, listener: (event: unknown) => void): void;
removeEventListener(type: string, listener: (event: unknown) => void): void;
}>;
export type WebPushServiceWorkerRuntime = Readonly<{
dispose(): void;
}>;
export function createWebPushServiceWorkerRuntime(
dependencies: Readonly<{
host: ServiceWorkerEventHost;
fenceStore: PushAssociationFenceStore;
registry: WebPushNotificationRegistry;
now?: () => number;
scheduler?: TimeoutScheduler;
observer?: WebPushObserver;
tagDigest?: AssociationNotificationTagDigest;
}>,
): WebPushServiceWorkerRuntime {
const lifecycle = new AbortController();
const push = createPushEventAdapter({
fenceStore: dependencies.fenceStore,
registry: dependencies.registry,
notifications: {
showNotification: (title, options) =>
dependencies.host.registration.showNotification(title, options),
},
now: dependencies.now,
scheduler: dependencies.scheduler,
observer: dependencies.observer,
tagDigest: dependencies.tagDigest,
signal: lifecycle.signal,
});
const click = createNotificationClickAdapter({
fenceStore: dependencies.fenceStore,
registry: dependencies.registry,
clients: {
async matchControlledWindowClients() {
const candidates = await dependencies.host.clients.matchAll({
type: "window",
includeUncontrolled: false,
});
return candidates
.map(windowClientFacade)
.filter(
(candidate): candidate is WindowClientFacade =>
candidate !== null,
);
},
async openWindow(url) {
return windowClientFacade(
await dependencies.host.clients.openWindow(url),
);
},
},
origin: dependencies.host.origin,
now: dependencies.now,
scheduler: dependencies.scheduler,
observer: dependencies.observer,
signal: lifecycle.signal,
});
const onPush = (event: unknown) => {
const facade = pushEventFacade(event);
if (facade) void push.handle(facade);
};
const onNotificationClick = (event: unknown) => {
const facade = notificationClickEventFacade(event);
if (facade) void click.handle(facade);
};
const onSubscriptionChange = (event: unknown) => {
const facade = functionalEventFacade(event);
if (!facade) return;
const taskControl = createLinkedAbortController(lifecycle.signal);
const processing = withAbortableDeadline(
async (signal) => {
let clients: readonly unknown[];
try {
clients = await dependencies.host.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
} catch {
return nativeFailure("SUBSCRIPTION_RECONCILE", true);
}
if (signal.aborted) {
return webPushFailure("ABORTED", "SUBSCRIPTION_RECONCILE");
}
try {
for (const candidate of clients.slice(
0,
WEB_PUSH_LIMITS.clientHandoffCount,
)) {
if (signal.aborted) {
return webPushFailure(
"ABORTED",
"SUBSCRIPTION_RECONCILE",
);
}
const client = windowClientFacade(candidate);
client?.postMessage(
Object.freeze({
protocol: WEB_PUSH_PROTOCOLS.reconcileRequired,
}),
);
}
} catch {
return nativeFailure("SUBSCRIPTION_RECONCILE", true);
}
return webPushSuccess(undefined);
},
{
deadlineMs: WEB_PUSH_LIMITS.handlerDeadlineMs,
operation: "SUBSCRIPTION_RECONCILE",
signal: taskControl.signal,
scheduler: dependencies.scheduler,
},
).finally(taskControl.dispose);
const lifetime = processing.then((result) => {
observeWebPush(dependencies.observer, {
event: "web_push_subscription_rotated",
outcome: result.ok ? "SUCCEEDED" : "DEGRADED",
...(result.ok ? {} : { reason: result.error.code }),
});
});
try {
facade.waitUntil(lifetime);
} catch {
taskControl.abort();
void lifetime;
}
};
dependencies.host.addEventListener("push", onPush);
dependencies.host.addEventListener(
"notificationclick",
onNotificationClick,
);
dependencies.host.addEventListener(
"pushsubscriptionchange",
onSubscriptionChange,
);
let disposed = false;
return Object.freeze({
dispose() {
if (disposed) return;
disposed = true;
lifecycle.abort();
dependencies.host.removeEventListener("push", onPush);
dependencies.host.removeEventListener(
"notificationclick",
onNotificationClick,
);
dependencies.host.removeEventListener(
"pushsubscriptionchange",
onSubscriptionChange,
);
dependencies.fenceStore.close();
},
});
}
function functionalEventFacade(
value: unknown,
): FunctionalEventFacade | null {
try {
return isRecord(value) && typeof value.waitUntil === "function"
? (value as FunctionalEventFacade)
: null;
} catch {
return null;
}
}
function pushEventFacade(value: unknown): PushEventFacade | null {
if (
!isRecord(value) ||
typeof value.waitUntil !== "function" ||
!(
value.data === null ||
(isRecord(value.data) && typeof value.data.arrayBuffer === "function")
)
) {
return null;
}
return value as PushEventFacade;
}
function notificationClickEventFacade(
value: unknown,
): NotificationClickEventFacade | null {
if (
!isRecord(value) ||
typeof value.waitUntil !== "function" ||
!isRecord(value.notification) ||
typeof value.notification.close !== "function" ||
!Object.hasOwn(value.notification, "data")
) {
return null;
}
return value as NotificationClickEventFacade;
}
function windowClientFacade(value: unknown): WindowClientFacade | null {
if (
!isRecord(value) ||
typeof value.url !== "string" ||
typeof value.focus !== "function" ||
typeof value.postMessage !== "function"
) {
return null;
}
return value as WindowClientFacade;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,57 @@
import type { ServiceWorkerEventHost } from "./service-worker-runtime.ts";
type NotificationOptions = Parameters<
ServiceWorkerEventHost["registration"]["showNotification"]
>[1];
type MatchAllOptions = Parameters<
ServiceWorkerEventHost["clients"]["matchAll"]
>[0];
/**
* Adapts a native Service Worker global scope onto the structural
* {@link ServiceWorkerEventHost} facade.
*
* The native `showNotification` call lives here because `src/adapters/web-push`
* is the owner of the notification API. The single physical worker entry
* (§17.1) composes the runtime but never touches the native API itself.
*/
export type NativeWorkerScope = Readonly<{
location: Readonly<{ origin: string }>;
registration: Readonly<{
showNotification(title: string, options: unknown): Promise<void>;
}>;
clients: Readonly<{
matchAll(options: unknown): Promise<readonly unknown[]>;
openWindow(url: string): Promise<unknown>;
}>;
// Deliberately loose: a native scope declares a richly overloaded listener
// signature, and this facade only needs to forward the registration.
addEventListener(type: string, listener: never, options?: never): void;
removeEventListener(type: string, listener: never, options?: never): void;
}>;
export function createServiceWorkerScopeHost(
scope: NativeWorkerScope,
): ServiceWorkerEventHost {
return Object.freeze({
origin: scope.location.origin,
registration: Object.freeze({
showNotification: (title: string, options: NotificationOptions) =>
scope.registration.showNotification(title, {
body: options.body,
data: options.data,
requireInteraction: options.requireInteraction,
tag: options.tag,
}),
}),
clients: Object.freeze({
matchAll: (options: MatchAllOptions) => scope.clients.matchAll(options),
openWindow: (url: string) => scope.clients.openWindow(url),
}),
addEventListener: (type, listener) =>
scope.addEventListener(type, listener as never),
removeEventListener: (type, listener) =>
scope.removeEventListener(type, listener as never),
});
}