The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
6.0 KiB
TypeScript
232 lines
6.0 KiB
TypeScript
export const WEB_PUSH_LIMITS = Object.freeze({
|
|
decodedHintBytes: 3 * 1024,
|
|
hintFutureSkewMs: 5 * 60 * 1_000,
|
|
hintMaxLifetimeMs: 24 * 60 * 60 * 1_000,
|
|
handlerDeadlineMs: 10_000,
|
|
fenceOperationDeadlineMs: 2_000,
|
|
nativeOperationDeadlineMs: 30_000,
|
|
backendOperationDeadlineMs: 15_000,
|
|
notificationCleanupCount: 64,
|
|
notificationCleanupDeadlineMs: 2_000,
|
|
clientHandoffCount: 32,
|
|
} as const);
|
|
|
|
export const WEB_PUSH_PROTOCOLS = Object.freeze({
|
|
control: "PUSH_CONTROL_V1",
|
|
hint: "WEB_PUSH_HINT_V1",
|
|
click: "NOTIFICATION_CLICK_DATA_V1",
|
|
registration: "WEB_PUSH_REGISTRATION_V1",
|
|
reconciliation: "WEB_PUSH_RECONCILIATION_V1",
|
|
revoke: "WEB_PUSH_REVOKE_V1",
|
|
clickHandoff: "WEB_PUSH_CLICK_HANDOFF_V1",
|
|
reconcileRequired: "WEB_PUSH_RECONCILE_REQUIRED_V1",
|
|
} as const);
|
|
|
|
/**
|
|
* Product IDs stay opaque to the mechanism. The selected composition must
|
|
* provide a closed registry that resolves these syntactically validated IDs.
|
|
*/
|
|
export type NotificationTypeId = string;
|
|
export type NotificationRouteIntentId = string;
|
|
|
|
export type PushAuthoritySnapshot = Readonly<{
|
|
fenceGeneration: string;
|
|
sessionBindingEpoch: string;
|
|
releaseEpoch: string;
|
|
}>;
|
|
|
|
export type PushControlAssociationV1 =
|
|
| Readonly<{ state: "UNASSOCIATED" }>
|
|
| Readonly<{
|
|
state: "ACTIVE" | "REVOKED";
|
|
associationEpoch: string;
|
|
}>;
|
|
|
|
/**
|
|
* One origin-scoped durable authority record shared by the window and worker.
|
|
* It intentionally contains no account identifier or native subscription
|
|
* material. `updatedAt` is diagnostic metadata, never an ordering authority.
|
|
*/
|
|
export type PushControlV1 = Readonly<{
|
|
protocol: typeof WEB_PUSH_PROTOCOLS.control;
|
|
fenceGeneration: string;
|
|
sessionBindingEpoch: string;
|
|
releaseEpoch: string;
|
|
updatedAt: string;
|
|
association: PushControlAssociationV1;
|
|
}>;
|
|
|
|
export type WebPushHintV1 = Readonly<{
|
|
protocol: typeof WEB_PUSH_PROTOCOLS.hint;
|
|
notificationType: NotificationTypeId;
|
|
notificationId: string;
|
|
associationEpoch: string;
|
|
releaseEpoch: string;
|
|
issuedAt: string;
|
|
expiresAt: string;
|
|
routeIntent: NotificationRouteIntentId;
|
|
}>;
|
|
|
|
export type NotificationClickDataV1 = Readonly<{
|
|
protocol: typeof WEB_PUSH_PROTOCOLS.click;
|
|
notificationId: string;
|
|
routeIntent: NotificationRouteIntentId;
|
|
associationEpoch: string;
|
|
releaseEpoch: string;
|
|
expiresAt: string;
|
|
}>;
|
|
|
|
export type WebPushReadinessState =
|
|
| "PUSH_READY"
|
|
| "PUSH_PERMISSION_REQUIRED"
|
|
| "PUSH_DENIED"
|
|
| "PUSH_UNSUPPORTED"
|
|
| "PUSH_UNAVAILABLE";
|
|
|
|
export type WebPushUnavailableReason =
|
|
| "ABORTED"
|
|
| "BACKEND_ASSOCIATION_MISSING"
|
|
| "BACKEND_REVOKE_AMBIGUOUS"
|
|
| "BUSY"
|
|
| "CLOSED"
|
|
| "LOCAL_FENCE_UNSAFE"
|
|
| "NATIVE_UNSUBSCRIBE_AMBIGUOUS"
|
|
| "NATIVE_SUBSCRIPTION_MISSING"
|
|
| "PERMISSION_DISMISSED"
|
|
| "REGISTRATION_NOT_ACTIVE"
|
|
| "REVOKED"
|
|
| "SESSION_AUTHORITY_CHANGED"
|
|
| "SUBSCRIPTION_KEY_MISMATCH"
|
|
| "WORKER_UNAVAILABLE";
|
|
|
|
export type WebPushReadiness = Readonly<{
|
|
state: WebPushReadinessState;
|
|
reason?: WebPushUnavailableReason;
|
|
}>;
|
|
|
|
export type WebPushOperation =
|
|
| "CONTROL_OPEN"
|
|
| "CONTROL_READ"
|
|
| "CONTROL_PREPARE"
|
|
| "CONTROL_ACTIVATE"
|
|
| "CONTROL_REVOKE"
|
|
| "CONTROL_PURGE"
|
|
| "PERMISSION_REQUEST"
|
|
| "SUBSCRIPTION_INSPECT"
|
|
| "SUBSCRIPTION_CREATE"
|
|
| "SUBSCRIPTION_RECONCILE"
|
|
| "SUBSCRIPTION_REVOKE"
|
|
| "PUSH_DECODE"
|
|
| "PUSH_HANDLE"
|
|
| "NOTIFICATION_SHOW"
|
|
| "NOTIFICATION_CLICK"
|
|
| "NOTIFICATION_CLEANUP";
|
|
|
|
export type WebPushFailureCode =
|
|
| "ABORTED"
|
|
| "ASSOCIATION_MISMATCH"
|
|
| "BLOCKED"
|
|
| "CONTRACT_REJECTED"
|
|
| "CONTROL_CORRUPT"
|
|
| "DEADLINE_EXCEEDED"
|
|
| "DECLARATIVE_PUSH_FORBIDDEN"
|
|
| "EXPIRED"
|
|
| "INVALID_INPUT"
|
|
| "LIMIT_EXCEEDED"
|
|
| "NATIVE_FAILURE"
|
|
| "PERMISSION_DENIED"
|
|
| "PROVIDER_UNAVAILABLE"
|
|
| "RELEASE_MISMATCH"
|
|
| "STALE_AUTHORITY"
|
|
| "STALE_REVISION"
|
|
| "TOMBSTONE_CONFLICT"
|
|
| "UNSUPPORTED";
|
|
|
|
export type WebPushFailure = Readonly<{
|
|
code: WebPushFailureCode;
|
|
operation: WebPushOperation;
|
|
retryable: boolean;
|
|
}>;
|
|
|
|
export type WebPushResult<Value> =
|
|
| Readonly<{ ok: true; value: Value }>
|
|
| Readonly<{ ok: false; error: WebPushFailure }>;
|
|
|
|
export type WebPushObservationEvent =
|
|
| "web_push_permission_finished"
|
|
| "web_push_registration_finished"
|
|
| "web_push_subscription_rotated"
|
|
| "web_push_hint_processed"
|
|
| "web_push_notification_finished"
|
|
| "web_push_click_dispatched"
|
|
| "web_push_association_revoked";
|
|
|
|
/**
|
|
* WP-06. Bounded fan-out is a deliberate policy, but reporting a truncated pass
|
|
* as plain success hid the fact that only part of the set was handled.
|
|
*/
|
|
export type WebPushCountBucket =
|
|
| "0"
|
|
| "1_8"
|
|
| "9_32"
|
|
| "33_64"
|
|
| "GT_64";
|
|
|
|
export function webPushCountBucket(count: number): WebPushCountBucket {
|
|
if (!Number.isFinite(count) || count <= 0) return "0";
|
|
if (count <= 8) return "1_8";
|
|
if (count <= 32) return "9_32";
|
|
if (count <= 64) return "33_64";
|
|
return "GT_64";
|
|
}
|
|
|
|
/**
|
|
* WP-07. Certainty of a user-visible native effect. It is evidence only and
|
|
* never authorizes a retry.
|
|
*/
|
|
export type WebPushNativeEffectCertainty =
|
|
| "CONFIRMED"
|
|
| "NOT_APPLIED"
|
|
| "MAYBE_APPLIED";
|
|
|
|
export type WebPushObservation = Readonly<{
|
|
event: WebPushObservationEvent;
|
|
outcome: "SUCCEEDED" | "FAILED" | "DEGRADED";
|
|
reason?: WebPushFailureCode | WebPushUnavailableReason;
|
|
countBucket?: WebPushCountBucket;
|
|
truncated?: boolean;
|
|
nativeEffect?: WebPushNativeEffectCertainty;
|
|
}>;
|
|
|
|
export interface WebPushObserver {
|
|
record(observation: WebPushObservation): void;
|
|
}
|
|
|
|
export function webPushSuccess<Value>(
|
|
value: Value,
|
|
): WebPushResult<Value> {
|
|
return Object.freeze({ ok: true, value });
|
|
}
|
|
|
|
export function webPushFailure(
|
|
code: WebPushFailureCode,
|
|
operation: WebPushOperation,
|
|
retryable = false,
|
|
): WebPushResult<never> {
|
|
return Object.freeze({
|
|
ok: false,
|
|
error: Object.freeze({ code, operation, retryable }),
|
|
});
|
|
}
|
|
|
|
export function samePushAuthority(
|
|
left: PushAuthoritySnapshot,
|
|
right: PushAuthoritySnapshot,
|
|
): boolean {
|
|
return (
|
|
left.fenceGeneration === right.fenceGeneration &&
|
|
left.sessionBindingEpoch === right.sessionBindingEpoch &&
|
|
left.releaseEpoch === right.releaseEpoch
|
|
);
|
|
}
|