Files
tech-log-frontend/src/contracts/web-push.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
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>
2026-08-15 12:04:58 +09:00

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
);
}