chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user