clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat: add owner web push notifications

Dami committed 84ef7ee
commit
84ef7ee176bb57920afffd0d8df7609c0bae2bb6
parent
528b3429da60594bb02b3e444184fda04386bc1c
author
Dami <dami.etoile@gmail.com>
24 files changed, +3903 -24
ANOTIFY_PLAN.md1276++++++++++++++++++++++++++++++
Acli/commands/notify.ts157++++
Acli/commands/notify_test.ts34+
Mcli/lib/api.ts37+
Mcli/main.ts29+-
Mshared/types.ts93++
Mworker/.dev.vars.example1+
Aworker/migrations/0005_notifications.sql59+
Mworker/package-lock.json188++++
Mworker/package.json4+
Mworker/schema.sql60+
Mworker/setup.md62+-
Mworker/src/api.ts37+
Mworker/src/index.ts8+
Aworker/src/notify.ts336++++++++
Aworker/src/notify_enrollment.ts730+++++++++++++++++
Aworker/src/notify_public.ts77++
Aworker/src/notify_ui.ts365+++++++++
Mworker/src/serve.ts18+-
Aworker/src/web_push.ts116+++
Mworker/test/index.runtime.test.ts105++
Aworker/test/notify.test.mjs106++
Mworker/worker-configuration.d.ts11+-
Mworker/wrangler.jsonc18+-
A NOTIFY_PLAN.md +1276 -0
@@ -0,0 +1,1276 @@
1+# Notify feature plan2+3+Status: implemented and deployed in staged mode. Pairing and subscription setup are available;4+delivery remains disabled until the owner completes the explicit real-device go/no-go checks.5+6+This document is written for adversarial review. It tries to make the trust boundaries, implicit7+assumptions, failure semantics, and uncomfortable product questions explicit. A reviewer should8+challenge those claims before approving implementation.9+10+## Proposed outcome11+12+`nzip notify` sends a user-visible Web Push notification from the authenticated CLI, through the13+nzip Worker, to one or more mobile devices that the deployment owner explicitly approved.14+15+The root URL is the installation and notification origin. It does not become a general account or16+device-management dashboard. The centered nzip wordmark remains the page's dominant element. Setup17+is exposed as quiet footer text beside the existing `args` link, with no cards, forms, large18+buttons, or persistent status panel.19+20+The desired sequence is:21+22+```text23+browser enrollment24+ -> owner CLI approval25+ -> browser installation nudge26+ -> installed app notification permission27+ -> PushSubscription attachment28+ -> active notification device29+```30+31+Approval always precedes installation promotion and subscription attachment. Manual installation32+cannot be prevented on every browser, but an unapproved installation must never be able to attach a33+subscription or receive a push.34+35+## Review targets36+37+The most important claims to attack are:38+39+1. WebKit copies an HTTP-only claim cookie into an installed web app and sends a `SameSite=Lax`40+ cookie on the Home Screen launch navigation. Both details are slice-0 go/no-go checks, not41+ settled assumptions.42+2. Owner approval before subscription attachment actually prevents unauthorized notification43+ recipients.44+3. A public browser-supplied push endpoint can be validated tightly enough to avoid turning the45+ Worker into an arbitrary outbound request primitive.46+4. D1 plus `waitUntil()` and scheduled retries is durable enough for the intended personal-device47+ scale without Cloudflare Queues.48+5. The existing deployment-wide bearer token is the right authority to send arbitrary49+ lock-screen-visible text.50+6. Same-origin notification click targets are sufficient; arbitrary URLs are intentionally excluded.51+52+## Adversarial review resolutions53+54+The first adversarial review found six blockers and nine secondary gaps. This revision resolves or55+turns each into an explicit slice-0 gate:56+57+| Finding | Resolution in this revision |58+| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |59+| Approve-by-code contradiction | Preview and approve are code-addressed; the server hashes the presented code and pairing-code hashes are unique. |60+| Partially verified iOS cookie handoff | Use `SameSite=Lax`; test HTTP-only copy and Home Screen cookie sending as separate go/no-go checks. |61+| Claim rotation race | Do not rotate the secret; use an idempotent activation POST that extends the same cookie only after approval. |62+| Undefined renewal and recovery | Renew near expiry on validated installed-app use; test push-handler renewal; document remove-and-reinstall recovery. |63+| Concurrent double-send | Add conditional D1 delivery leases shared by immediate and cron drains. |64+| Incomplete Worker SSRF model | Make an operator-configured provider hostname allowlist the real control; fail closed and forbid redirects/own-zone endpoints. |65+| Approval lacks context | Preview bounded creation, device, user-agent, country/region, and ASN metadata before confirmation. |66+| Root caching risk | Keep `/` a claim-independent static shell; make every claim-sensitive response `no-store`. |67+| Endpoint uniqueness ambiguity | Same-row attach is idempotent; a new approved row atomically steals the endpoint and disables the old row. |68+| HTTP-only UI assumption | Resolve authority through a credentialed state request; disable controls until online validation. |69+| Silent subscription death | Revalidate and re-attach on every standalone launch; expose last success in the CLI. |70+| VAPID and D1-secret policy | Document rotation as subscription-breaking; skip application-layer D1 encryption in v1 while treating fields as secrets. |71+| Android install assumption | State install-first as a product choice and test browser-tab push separately. |72+| Reusable address click risk | Pin site notifications to the sent manifest and validate before opening. |73+| Smaller operational gaps | Set a 32-row pending cap, polling cadence, due index, enable flag, consistent routes, ordinary-path test event, and slice-0 iOS wording check. |74+75+## Goals76+77+- Allow a bearer-authenticated CLI to submit a small notification event.78+- Deliver that event to every active owner-approved device.79+- Keep the nzip bearer token out of all browser and PWA storage.80+- Use the browser or operating system installation surface wherever it is programmatically81+ available.82+- Delay all installation promotion until the device enrollment is approved.83+- Require a separate user gesture for notification permission.84+- Route notification clicks to `/` or to an existing same-origin nzip site.85+- Persist enough delivery state to retry transient failures and diagnose loss.86+- Remain appropriate for a small, single-user, free-tier-oriented deployment.87+88+## Non-goals89+90+- User accounts, teams, or multiple owners.91+- Public visitor subscriptions.92+- Per-vault recipients or notification groups.93+- Arbitrary external notification click URLs.94+- Silent pushes or background data synchronization.95+- Exactly-once delivery.96+- Guaranteed real-time delivery while a device is offline or suppressing alerts.97+- Replacing the existing security-alert email pipeline.98+- A full notification inbox synchronized across devices.99+- iOS or iPadOS versions older than 17.2.100+101+## Existing constraints102+103+- nzip is single-user by design and uses one deployment-wide bearer token.104+- Every `/api/*` request currently passes through the bearer check before API routing.105+- `/` is public and currently serves a static landing page.106+- The Worker already uses D1 outbox state plus immediate and scheduled delivery attempts for107+ security email.108+- CLI and shared-code changes must remain Deno-first.109+- Worker changes use the existing TypeScript, Wrangler, and Vitest setup.110+111+## Visual contract112+113+The setup surface extends the current landing page instead of replacing its composition:114+115+```text116+ nzip117+118+119+ args · pair120+```121+122+Visual rules:123+124+- Preserve the current warm background, centered wordmark, coral `z`, monospace type, and muted125+ footer treatment.126+- Put pairing state immediately beside `args` in the footer.127+- Use exactly `pair` before approval and `paired` after approval.128+- `pair` is a text action. `paired` is state, not a device-management link.129+- Do not show a pairing code, device name, notification status, or installation instruction until it130+ is relevant.131+- Do not add a custom installation button. The browser or operating system owns installation UI.132+- In the installed app, expose one additional text action: `notifications off` or133+ `notifications on`.134+- The notification text is the only notification control. There are no separate enable, disable,135+ test, settings, or retry buttons on `/`.136+- Keep operational device naming, listing, testing, and revocation in the CLI.137+- Use color and underlining sparingly. Actions may use the existing footer-link treatment; completed138+ state should be quieter than an action.139+- Avoid icons, switches, checkboxes, badges, illustrations, modal dialogs, and platform logos.140+- Implement `pair` and the notification toggle as semantic `<button>` elements visually reset to141+ match the existing `args` link. Keep `paired` as a non-interactive `<span>`.142+- Preserve visible keyboard focus even though the controls are visually quiet.143+- Put transient code, instruction, and error text in one centered `aria-live="polite"` region so a144+ state change does not rearrange the wordmark or footer.145+- Use the existing 12px muted footer type for controls and status. The pairing code may be slightly146+ brighter with modest letter spacing, but it must remain secondary to the wordmark.147+148+### Visual states149+150+Unpaired, collapsed:151+152+```text153+ nzip154+155+156+ args · pair157+```158+159+`pair` is the only setup action. The server does not create an enrollment merely because somebody160+visited `/`.161+162+Pairing, expanded after tapping `pair`:163+164+```text165+ nzip166+167+ ABCD-1234168+ waiting169+170+ args · pair171+```172+173+The code and `waiting` label are small, muted, and centered beneath the wordmark. They exist only174+after the visitor deliberately taps `pair`. A refresh with a live claim may restore this expanded175+state; a new visitor sees the collapsed state.176+177+Approved in a regular Android browser:178+179+```text180+ nzip181+182+183+ args · paired184+```185+186+Attaching the manifest and service worker after approval lets the browser expose its native ambient187+install affordance. The page does not add an install control.188+189+Approved in an iPhone browser:190+191+```text192+ nzip193+194+ share → add to home screen195+196+ args · paired197+```198+199+The instruction is muted and disappears when running in standalone mode. Whether iOS 26 needs an200+additional `Open as Web App` hint is a slice-0 observation; do not ship unverified wording.201+202+Installed, notifications inactive:203+204+```text205+ nzip206+207+208+ args · paired · notifications off209+```210+211+Because pairing authority lives in an HTTP-only cookie, an installed launch begins with `args · …`212+while the credentialed state request is pending. The actionable footer appears only after online213+validation. Offline last-known state may be rendered muted, but never as an enabled control.214+215+Installed, notifications active:216+217+```text218+ nzip219+220+221+ args · paired · notifications on222+```223+224+`notifications off` and `notifications on` are the same text control in two states. The labels225+describe current state; tapping them requests the opposite state.226+227+During an operation, replace the state briefly with `notifications …` and disable repeat taps. On a228+recoverable error, restore the previous state and show one muted line beneath the wordmark. If the229+browser permission is denied, show non-actionable `notifications blocked`; the page cannot revoke or230+open operating-system permission settings on the user's behalf.231+232+## User journey233+234+### 1. Begin enrollment235+236+The owner opens `/` on the intended mobile device. The page initially shows only the wordmark and237+the footer `args · pair`. No enrollment exists yet.238+239+The owner taps `pair`. That deliberate action expands the pairing state and calls:240+241+```http242+POST /_notify/enrollments243+```244+245+The Worker creates a pending device record and returns a display-safe pairing code. Pairing-code246+hashes are unique. Creation generates a new code and retries on a uniqueness conflict, so approval247+can never match more than one pending row. It also sets a high-entropy claim cookie:248+249+```text250+HttpOnly251+Secure252+SameSite=Lax253+Path=/254+Max-Age=600255+```256+257+The pending and approved states use the same high-entropy claim value. The server changes the row's258+authority, not its secret, so two tabs cannot invalidate one another by racing a credential259+rotation.260+261+The page adds only the code and a quiet waiting label beneath the wordmark:262+263+```text264+ABCD-1234265+waiting266+```267+268+The page polls its own state using the claim cookie. It polls every three seconds for the first269+minute, then every ten seconds with jitter. The setup read limiter allows at least 30 reads per270+claim per minute, so legitimate polling stays below its threshold. The pairing code is not an271+authentication credential; possession of the owner bearer token is required to approve it.272+273+### 2. Approve from the CLI274+275+The owner runs:276+277+```text278+nzip notify approve ABCD-1234 --name "Personal iPhone"279+```280+281+The CLI first requests an authenticated approval preview by code:282+283+```http284+GET /api/notify/approvals/ABCD-1234285+Authorization: Bearer <owner token>286+```287+288+It displays the enrollment creation time, bounded user-agent summary, device class, country/region,289+and ASN before asking the owner to confirm. Raw IP addresses are neither stored nor displayed. After290+confirmation, the CLI calls the code-addressed approval endpoint:291+292+```http293+POST /api/notify/approvals294+Content-Type: application/json295+Authorization: Bearer <owner token>296+297+{"code":"ABCD-1234","name":"Personal iPhone"}298+```299+300+The server normalizes and hashes the presented code, then changes the one matching, unexpired device301+from `pending` to `approved`. A missing, expired, already-used, or otherwise unmatched code returns302+the same generic error. Approval does not yet create an active notification recipient because no303+push subscription exists.304+305+### 3. Promote installation only after approval306+307+The waiting page observes the approved state, then explicitly calls:308+309+```http310+POST /_notify/enrollments/activate311+Cookie: <pending claim>312+```313+314+Activation is idempotent: it validates the same claim verifier, confirms that the row is approved,315+and reissues the same cookie value with a one-year lifetime. Only after that response succeeds does316+the page remove the pairing code, change `pair` to `paired`, attach the web app manifest, and317+register the service worker. The UI tells the user to remain on this page until `paired` appears.318+Installing before activation may snapshot the ten-minute pending cookie and require the app to be319+removed and installed again if that cookie expires.320+321+Android Chromium behavior:322+323+- Rely on the browser's ambient install affordance after the site becomes installable.324+- Do not capture `beforeinstallprompt` in order to build a custom install button.325+- Do not attempt to call `prompt()` automatically; the approval poll is not a user gesture.326+- If the browser provides no ambient affordance, show one muted fallback line such as327+ `add to home screen from the browser menu`.328+329+iPhone and iPad behavior:330+331+- There is no web API that opens the Add to Home Screen flow.332+- After approval, show the minimal instruction: `Share -> Add to Home Screen`.333+- Add any iOS 26 `Open as Web App` wording only if slice 0 confirms the actual installation UI.334+- Do not present these instructions before approval.335+336+Desktop and unsupported browsers may show equivalent platform guidance, but mobile Android and337+iPhone are the v1 acceptance targets.338+339+### 4. Carry approval into the installed app340+341+The claim is stored in an HTTP-only cookie, not local storage. WebKit documents a one-time copy of342+browser cookies into a newly installed Home Screen web app from any iOS browser, while other local343+storage is not copied. The documentation does not explicitly guarantee that the copy includes344+HTTP-only cookies or that the resulting Home Screen launch sends a `SameSite=Lax` cookie. Slice 0345+must prove both. The manifest keeps a stable `id: "/"` and does not put a device identifier or claim346+secret into `start_url`.347+348+The minimum supported Apple mobile release is iOS/iPadOS 17.2. There is no older-version fallback,349+continuation-code flow, or identifier-bearing `start_url`. When an older release can be identified,350+the setup surface shows one muted `requires iOS 17.2+` line and does not create an enrollment.351+Embedding a unique device claim in `start_url` remains rejected because it is a persistent352+identifier and the Web App Manifest specification calls out that privacy risk.353+354+### 5. Attach a push subscription355+356+The installed app cannot read the HTTP-only cookie. On every standalone launch it performs a357+credentialed `GET /_notify/enrollments/current`; only the server response can authorize the paired358+footer and subscription controls. Until that request resolves, the footer shows `args · …`. If the359+app is offline, it may show last-known local presentation state, but the notification control stays360+disabled until the server confirms the claim. Once confirmed, the footer becomes:361+362+```text363+args · paired · notifications off364+```365+366+Tapping `notifications off` supplies the required user gesture. The app requests notification367+permission, obtains a `PushSubscription` using the deployment VAPID public key, and calls:368+369+```http370+POST /_notify/subscriptions371+Content-Type: application/json372+Cookie: <approved device claim>373+374+{375+ "endpoint": "https://push-service.example/...",376+ "expirationTime": null,377+ "keys": {378+ "p256dh": "...",379+ "auth": "..."380+ }381+}382+```383+384+The server validates the endpoint and keys, attaches them to the already-approved device, and385+changes its state to `active`. The footer becomes `args · paired · notifications on`.386+387+On every later standalone launch, the app reads `pushManager.getSubscription()` and re-attaches it388+through the same endpoint. Identical endpoint and key material is an idempotent no-op that updates389+`last_seen_at` without consuming a full subscription write; changed material replaces the previous390+subscription. Do not depend on `pushsubscriptionchange`, which may be absent or unreliable.391+392+Tapping `notifications on` unsubscribes the browser, removes the subscription capability from the393+server, and returns the device to approved-but-inactive state. It does not revoke owner approval and394+does not attempt to revoke the browser's notification permission. The owner can turn notifications395+back on without pairing again while the approved claim remains valid.396+397+The service worker must display a user-visible notification for every push. It must not use pushes398+for silent work.399+400+### 6. Send a notification401+402+Primary CLI shape:403+404+```text405+nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]406+```407+408+Examples:409+410+```text411+nzip notify "Your report is ready"412+nzip notify "Your report is ready" --title Codex --open work:report413+nzip notify "Build completed" --tag build-main414+```415+416+If `--open` is present, the CLI resolves the existing nzip target, applies the local vault guard,417+and sends only the resolved same-origin path such as `/12d8`. The API never accepts an arbitrary418+absolute click URL.419+420+The CLI calls:421+422+```http423+POST /api/notify424+Content-Type: application/json425+Authorization: Bearer <owner token>426+427+{428+ "title": "Codex",429+ "body": "Your report is ready",430+ "path": "/12d8",431+ "tag": "report-ready"432+}433+```434+435+The response reports acceptance, not delivery:436+437+```json438+{439+ "eventId": "01J...",440+ "queuedDevices": 2,441+ "inactiveDevices": 1442+}443+```444+445+Human output:446+447+```text448+notification queued for 2 devices (event 01J...)449+```450+451+## Device state machine452+453+```text454+ owner approval455+pending --------------------------------> approved456+ | |457+ | pairing expiry | subscription attached458+ v v459+expired active460+ | |461+ user toggles notifications| |push endpoint 404/410462+ off | +--------------------> disabled463+ |464+ +----------------------> approved465+466+Any non-revoked state ---------------- owner revoke ----------------> revoked467+```468+469+Proposed rules:470+471+- `pending`: has a claim and pairing code; cannot be installed through nzip's promoted flow and472+ cannot attach a subscription.473+- `approved`: owner-authorized claim; may see installation UI and attach one subscription.474+- `active`: approved and has a currently usable subscription.475+- An active device returns to `approved` when the user toggles notifications off. Approval and the476+ claim remain; subscription capability material is deleted.477+- `disabled`: endpoint is known to be expired or rejected; key material is removed and re-enrollment478+ is required.479+- `revoked`: owner explicitly removed access; key material and claim verifier are removed480+ immediately.481+- `expired`: unapproved or incomplete setup exceeded its allowed lifetime.482+483+Proposed lifetimes:484+485+- Pending approval: 10 minutes.486+- Approved, never activated setup: 24 hours to complete the initial installation and subscription.487+- Approved, previously active but toggled off: remains paired until owner revocation or approved488+ claim expiry.489+- Active: no fixed expiry; browser endpoint responses and owner action control its lifetime.490+- Revoked and expired tombstones: retain only if needed for a short audit window.491+492+Enrollment storage is bounded to 32 unexpired pending rows deployment-wide. Before creating a row,493+the Worker prunes expired pending rows. If 32 remain, it rejects the new enrollment with a bounded494+`Retry-After`; it never evicts an unexpired enrollment, because attacker-driven eviction would make495+the cap another denial-of-service primitive. Enrollment creation also has a stricter per-IP limiter496+than claim-authenticated polling.497+498+An active PWA may refresh changed subscription material without new owner approval while presenting499+the same approved claim. This decision is closed: revalidation on every standalone launch is the500+primary defense against silent subscription death.501+502+Approved claims use a one-year sliding expiry. A credentialed `POST /_notify/enrollments/renew`503+extends the server expiry and cookie only when fewer than 90 days remain, avoiding a D1 write on504+every launch. The installed app calls it after a successful launch validation. Slice 0 must also505+test whether a service worker can renew from a credentialed fetch while handling a visible push; use506+that as an additional renewal path only if verified.507+508+If the installed app loses or expires its claim, re-pairing in the browser cannot copy new state509+into an already-installed app. Recovery is explicitly destructive: remove the Home Screen app, pair510+in the browser again, wait for `paired`, and install it again.511+512+## HTTP surface513+514+Public but claim-cookie authenticated:515+516+```text517+POST /_notify/enrollments create a pending enrollment518+GET /_notify/enrollments/current poll or validate the caller's enrollment519+POST /_notify/enrollments/activate extend an approved pending claim before installation520+POST /_notify/enrollments/renew renew a validated installed-app claim near expiry521+POST /_notify/subscriptions attach or refresh a subscription after approval522+DELETE /_notify/subscriptions/current toggle notifications off while preserving approval523+POST /_notify/click-target validate an event target before navigation524+GET /_notify/vapid-public-key retrieve the application-server public key525+```526+527+Owner bearer authenticated:528+529+```text530+POST /api/notify enqueue an event531+GET /api/notify/devices list pending and active devices532+GET /api/notify/approvals/{code} preview a pending enrollment by code533+POST /api/notify/approvals approve by hashing the presented code534+DELETE /api/notify/devices/{id} revoke a device535+```536+537+The route-prefix decision is closed: keep the claim-authenticated surface under `/_notify/*`. It538+cannot collide with four-hex site addresses and avoids weakening the global bearer boundary on539+`/api/*`.540+541+There is no special `/api/notify/test` route. `nzip notify test` submits a recognizable diagnostic542+event through the ordinary `POST /api/notify` contract so the smoke test exercises the real path.543+544+All public mutation endpoints require explicit content types, bounded bodies, strict schemas, no545+CORS, same-origin fetch metadata where available, and rate limits distinct from address enumeration546+and unlock limits.547+548+### Root caching boundary549+550+Keep `/` as a claim-independent static shell so it may retain public caching. It must not render551+`pair` versus `paired` on the server, inspect cookies, set cookies, or embed pairing state. Client552+JavaScript obtains state only from `/_notify/enrollments/current` and mutates the footer locally.553+554+Every `/_notify/*` response is `Cache-Control: no-store`; claim-sensitive reads also send555+`Vary: Cookie`. No cacheable response may contain `Set-Cookie`. This prevents a paired footer or556+claim cookie from being replayed to another visitor while preserving the cheap static root.557+558+## Event contract559+560+Proposed shared types:561+562+```ts563+export interface NotifyRequest {564+ title?: string;565+ body: string;566+ path?: string;567+ tag?: string;568+}569+570+export interface NotifyResponse {571+ eventId: string;572+ queuedDevices: number;573+ inactiveDevices: number;574+}575+```576+577+Validation:578+579+- `title`: defaults to `nzip`; 1-80 Unicode scalar values.580+- `body`: required; 1-240 Unicode scalar values.581+- `path`: omitted or a normalized same-origin absolute path beginning with `/`.582+- `path`: reject schemes, protocol-relative values, backslashes, credentials, control characters,583+ and encoded forms that normalize outside the origin.584+- `tag`: optional; 1-64 characters from a conservative ASCII set.585+- Entire serialized notification payload: capped below the smallest supported browser push payload586+ after encryption overhead. The exact cap must be set by the transport spike rather than assumed587+ here.588+589+Notification content is visible on lock screens. CLI help and documentation must explicitly say not590+to put passwords, tokens, private URLs, or sensitive personal data in title or body.591+592+## Persistence model593+594+Keep owner notifications separate from security email notifications.595+596+Illustrative schema, not final migration SQL:597+598+```sql599+CREATE TABLE notification_devices (600+ id TEXT PRIMARY KEY,601+ pairing_code_hash TEXT UNIQUE,602+ claim_hash TEXT NOT NULL,603+ name TEXT,604+ status TEXT NOT NULL,605+ user_agent_summary TEXT,606+ device_class TEXT,607+ country TEXT,608+ region TEXT,609+ asn INTEGER,610+ endpoint TEXT,611+ endpoint_hash TEXT,612+ p256dh TEXT,613+ auth TEXT,614+ created_at INTEGER NOT NULL,615+ pairing_expires_at INTEGER,616+ claim_expires_at INTEGER,617+ approved_at INTEGER,618+ active_at INTEGER,619+ last_attached_at INTEGER,620+ last_seen_at INTEGER,621+ last_success_at INTEGER,622+ last_error TEXT,623+ UNIQUE(endpoint_hash)624+);625+626+CREATE TABLE notification_events (627+ id TEXT PRIMARY KEY,628+ title TEXT NOT NULL,629+ body TEXT NOT NULL,630+ path TEXT,631+ expected_manifest_hash TEXT,632+ tag TEXT,633+ created_at INTEGER NOT NULL634+);635+636+CREATE TABLE notification_deliveries (637+ event_id TEXT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,638+ device_id TEXT NOT NULL REFERENCES notification_devices(id) ON DELETE CASCADE,639+ status TEXT NOT NULL,640+ attempts INTEGER NOT NULL DEFAULT 0,641+ next_attempt_at INTEGER,642+ lease_owner TEXT,643+ lease_expires_at INTEGER,644+ sent_at INTEGER,645+ last_error TEXT,646+ PRIMARY KEY (event_id, device_id)647+);648+649+CREATE INDEX idx_notification_deliveries_due650+ ON notification_deliveries(status, next_attempt_at);651+```652+653+Do not store the raw claim cookie. Store a keyed digest or cryptographic hash and compare a digest654+of the presented value. Pairing codes are short-lived and must also be stored as non-reversible655+verifiers.656+657+Endpoint, `p256dh`, and `auth` are capability secrets and must be protected from logs and API658+responses. The application-layer-encryption decision is closed for v1: do not add it. In this659+single-tenant Worker, the same deployment would hold the decryption key, so encryption mainly660+reduces D1-snapshot-only disclosure while adding secret rotation and recovery failure modes. Revisit661+if the storage or tenancy model changes.662+663+Endpoint uniqueness uses steal-and-disable semantics. Attaching an endpoint already owned by the664+same row is idempotent. Attaching it from a different approved row atomically disables the old row,665+clears its capability material, and assigns the endpoint to the new row. A constraint conflict must666+never surface as a generic 500.667+668+## Architectural reference: Pushy669+670+[Pushy](https://codakuma.com/pushy/) is a useful small-scale, Cloudflare-free-tier-focused671+reference. Its author describes an approximately 200-line Worker that stores native iOS device672+tokens in KV, registers and deregisters tokens through two endpoints, signs APNs requests with673+Worker crypto, sends directly to APNs, and deletes tokens when APNs returns `410`. The native client674+avoids redundant registration writes so steady-state KV usage stays low.675+676+Transferable lessons for nzip:677+678+- Direct delivery from a Worker to the platform push service is viable for a small personal service;679+ an intermediary such as OneSignal is not inherently required.680+- Registration and opt-out should be symmetric. Nzip's `notifications off` action should delete681+ subscription capability rather than leave an inactive endpoint stored indefinitely.682+- Provider `410` responses should automatically remove stale subscription capability.683+- Clients should compare the current subscription with the last attached subscription and avoid a D1684+ write when nothing changed.685+- The system should be intentionally small enough that its full delivery path can be audited.686+- Free-tier cost discipline belongs in the design: initial enrollment bursts, redundant writes, and687+ fan-out reads deserve explicit tests and metrics.688+689+Important differences:690+691+- Pushy targets native APNs. Nzip targets standards-based Web Push across browser vendors, so it692+ needs VAPID plus encrypted payload construction using the subscription's `endpoint`, `p256dh`, and693+ `auth` values. Pushy's APNs JWT transport cannot be reused directly.694+- Pushy trusts an application key for token registration. Nzip requires a pending browser claim and695+ explicit owner approval before a subscription may be attached.696+- Pushy uses KV and reads the token set for a send. Nzip already uses D1 and needs relational device697+ state, atomic event/delivery creation, retry scheduling, and revocation checks; D1 remains the698+ proposed store.699+- Pushy's admin HTML sends messages. Nzip deliberately keeps composition in the authenticated CLI700+ and keeps `/` visually limited to pairing and notification state.701+- The article describes the implementation but does not provide source code to audit. Treat it as702+ architectural evidence and a simplicity benchmark, not as a dependency or security reference.703+704+The transport spike should use Pushy's approximate scale as a challenge: explain every additional705+nzip component and remove it if owner approval, cross-browser Web Push, or durable retry semantics706+do not require it.707+708+## Push transport709+710+The transport must implement standard Web Push payload encryption and VAPID authentication.711+Cloudflare Workers exposes the required ECDH, ECDSA, HKDF, and AES-GCM Web Crypto operations and712+supports outbound `fetch()`.713+714+Implementation policy:715+716+- Put protocol work behind a narrow `WebPushTransport` interface.717+- Prefer a small, maintained, standards-conformant library that demonstrably executes in Workers.718+- Do not copy a hand-rolled cryptographic implementation directly into route code.719+- If no suitable library works, isolate the minimal RFC implementation and test it against720+ independent fixtures and real browser endpoints.721+- Generate one VAPID key pair per deployment.722+- Store the VAPID private key as a Worker secret.723+- Expose only the VAPID public key to the root app.724+- Use a stable `sub` contact claim configured by the deployment operator.725+- Treat VAPID key rotation as a subscription-breaking operation. Rotation marks every active device726+ `disabled` and requires each still-approved installed app to recreate its subscription through the727+ notification toggle or launch revalidation. Launch revalidation compares728+ `subscription.options.applicationServerKey` with the current public key; on mismatch it729+ unsubscribes and creates a new subscription. A valid approved claim avoids owner re-pairing.730+- Do not treat VAPID application-server-key binding as a substitute for protecting endpoint,731+ `p256dh`, and `auth` capability material.732+733+The transport spike must prove delivery to, at minimum:734+735+- Current Chrome on Android.736+- A Home Screen web app on current iOS Safari/WebKit.737+- A closed app with the browser not foregrounded.738+- A notification containing Unicode and a same-origin click path.739+740+## Delivery and retry semantics741+742+When `POST /api/notify` succeeds:743+744+1. Validate the event.745+2. Read active devices.746+3. Insert one event and one pending delivery per active device in a D1 batch.747+4. Start an immediate drain with `ctx.waitUntil()`.748+5. Return acceptance without waiting for device delivery.749+750+Immediate and scheduled drains share the same lease protocol. A drain first reads due candidates,751+then conditionally claims each row:752+753+```sql754+UPDATE notification_deliveries755+SET status = 'sending', lease_owner = ?, lease_expires_at = ?756+WHERE event_id = ? AND device_id = ?757+ AND (758+ (status IN ('pending', 'retry') AND COALESCE(next_attempt_at, 0) <= ?)759+ OR (status = 'sending' AND lease_expires_at < ?)760+ );761+```762+763+It sends only when the update reports exactly one changed row. The lease owner is a random drain ID;764+the lease is long enough for the provider timeout and short enough for crash recovery. Terminal or765+retry updates include `WHERE status = 'sending' AND lease_owner = ?` so an expired worker cannot766+overwrite a newer claimant. This conditional-update lease is required because D1 has no767+`SELECT FOR UPDATE` and the immediate drain can overlap the five-minute cron.768+769+Delivery outcomes:770+771+- `2xx`: mark sent.772+- `404` or `410`: disable device, erase subscription key material, do not retry.773+- `429`: honor bounded `Retry-After`, then retry.774+- Other `4xx`: record a permanent failure unless the transport specification identifies a retryable775+ case.776+- Network error or `5xx`: schedule bounded exponential retry.777+778+The existing five-minute scheduled handler may invoke the new notification drainer. The security779+email outbox is precedent for durable payload state, but notify's lease-aware general drainer is new780+machinery rather than reuse of the current email drain.781+782+Proposed retry policy:783+784+- Immediate attempt.785+- Then approximately 5, 15, 60, and 360 minutes, quantized to the five-minute cron.786+- Maximum five attempts over about six hours.787+- Add jitter so multiple devices do not retry in lockstep.788+789+The system provides at-least-once transport attempts, not exactly-once visible notifications.790+Browser behavior and timeouts can make a send result ambiguous. The notification `tag` should be791+used when replacement/deduplication semantics matter to the caller.792+793+The Cloudflare Queues decision is closed for v1: use the D1 outbox with conditional leases for the794+expected handful of owner devices. Add Queues only if measured fan-out, retry volume, or latency795+outgrows the scheduled outbox.796+797+## Notification click routing798+799+Four-character addresses are reusable after site deletion or expiry. An old notification must not800+blindly open a newly allocated site at the same path. For `--open` events, the CLI records both the801+resolved path and the site's current manifest hash. The root-scoped service worker handles802+`notificationclick`:803+804+1. Close the notification.805+2. For a generic event, focus or open `/`.806+3. For a site event, send the event ID to credentialed `POST /_notify/click-target`.807+4. The Worker loads the retained event and current site. It returns the path only when the current808+ manifest still equals `expected_manifest_hash`.809+5. Focus an existing matching client or call `clients.openWindow(path)` only after validation.810+6. If the event expired, the site disappeared, the address was reused, or the manifest changed, open811+ `/` with a transient `link expired` state instead of opening potentially unrelated content.812+813+The service worker remains the notification router. No event token appears in the browser URL. This814+v1 contract intentionally pins a notification to the manifest that existed when it was sent; a later815+re-push makes the old click target expire rather than silently opening changed content.816+817+## Platform-specific installation behavior818+819+### Android820+821+The `beforeinstallprompt` API is Chromium-specific and not guaranteed to fire. This design does not822+turn it into a custom `Install` control. Approval makes the site installable and lets the browser823+present its own ambient installation surface. Therefore approval can unlock native install824+promotion, but it cannot force installation or guarantee that a prompt appears.825+826+Install-first behavior on Android is a product consistency requirement, not an assumed platform827+requirement. Chrome may support Web Push without Home Screen installation. Slice 0 must test that828+capability, but v1 still withholds the notification toggle outside standalone mode so Android and829+iPhone share one owner setup model unless the product decision is revisited.830+831+Adversarial cases:832+833+- Approval arrives before the browser reevaluates installability.834+- Browser heuristics suppress the event.835+- The app is already installed.836+- The user dismisses or ignores the browser's affordance.837+- A non-Chromium Android browser provides only manual installation.838+839+The page must handle all of these without treating missing install telemetry as an authorization840+failure.841+842+### iPhone and iPad843+844+Websites cannot programmatically open Add to Home Screen. The approved browser page can only explain845+the native action. Web Push remains tied to a Home Screen web app and notification permission846+requires a user gesture.847+848+Cookies are copied into a new Home Screen web app on iOS/iPadOS 17.2 and later; other local storage849+is not copied. That is why the approved claim uses a cookie.850+851+Adversarial cases:852+853+- The user adds the site before approval.854+- The user disables `Open as Web App` on iOS 26.855+- The browser does not copy the cookie due to version or privacy behavior.856+- The user opens the browser tab rather than the Home Screen app.857+- The user installs multiple copies of the same manifest identity.858+- The user clears browser or app data after approval.859+860+Only an approved claim may attach a subscription. One approved device row owns one current endpoint.861+If multiple installed copies share the copied claim, the most recent successful attachment wins and862+the previous endpoint is removed; the older installation silently stops receiving. Owners who need863+two independently active installations must pair them as two devices. Slice 0 must verify how iOS864+and Android actually represent multiple installs and whether this limitation is acceptable.865+866+### Notification toggle semantics867+868+The footer toggle controls nzip subscription state, not operating-system permission:869+870+- `notifications off` -> request permission if needed, subscribe, attach, become active.871+- `notifications on` -> call `unsubscribe()`, remove endpoint and keys, return to approved.872+- `notifications blocked` -> permission is denied; render as non-actionable state.873+- `notifications …` -> an operation is in flight; ignore repeat activation.874+875+The toggle is visible only in standalone display mode with an approved claim. A normal browser tab876+never exposes it, even after approval. Device testing and revocation remain CLI actions.877+878+## Threat model879+880+| Threat | Proposed control | Residual concern |881+| ------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |882+| Attacker enrolls many pending devices | Dedicated IP rate limit, ten-minute expiry, hard cap of 32 live pending rows | Distributed abuse can deny pairing for up to the expiry window |883+| Attacker guesses a pairing code | Pairing code cannot approve itself; bearer is still required | Public status lookup must also require claim cookie |884+| Stolen pairing URL or cookie before approval | Short lifetime, Lax HTTP-only cookie, owner preview of bounded device/network metadata | Browser compromise and social approval attacks remain possible |885+| Unapproved PWA calls subscription endpoint | Server requires an approved claim verifier | Manual installation is allowed but inert |886+| Forged subscription endpoint causes SSRF | Operator-configured push-service hostname allowlist; HTTPS and URL parser hygiene | Push-service origins evolve; configuration maintenance is required |887+| Push endpoint leaks through logs | Never log raw endpoint, keys, claim, or payload body | Provider hostname may still be useful telemetry |888+| Bearer holder sends abusive text | Existing bearer is owner authority; no misleading local-only security guard | No server-side sender scopes exist |889+| Notification opens phishing URL | Relative same-origin paths only | Hosted content itself can still be misleading |890+| Revoked endpoint remains deliverable | Delete key material and exclude non-active status atomically | An in-flight request may already have left the Worker |891+| Duplicate visible notifications | Conditional D1 delivery lease plus optional notification tag | Provider timeout can still leave delivery outcome ambiguous |892+| D1 write succeeds but immediate send never starts | Scheduled outbox drain | Retry latency can be up to the cron interval |893+| D1 compromise reveals subscriptions | Minimize retention and treat fields as secrets; no application-layer encryption in v1 | D1-snapshot-only disclosure is accepted |894+895+### Push endpoint validation896+897+This is a release-blocking security decision.898+899+A browser subscription endpoint is supplied by an untrusted client. Approval of the browser session900+does not cryptographically prove that the submitted URL is a real push-service endpoint. Blindly901+fetching it would create an owner-approved but attacker-controlled outbound request primitive.902+903+At minimum:904+905+- Require HTTPS.906+- Reject credentials, fragments, non-default parsing oddities, IP literals, and syntactically907+ private or link-local address forms as parser hygiene.908+- Treat an explicit push-service hostname allowlist as the actual request-forgery control. Workers909+ cannot perform a trustworthy resolve-then-fetch DNS check, and hostname tricks cannot be made safe910+ by private-range checks alone.911+- Store the allowlist in operator-updatable `WEB_PUSH_ORIGINS` configuration, parsed and validated912+ at startup. A provider change must not require a source patch, but invalid or empty production913+ configuration must fail closed.914+- Validate before persistence and again before every fetch.915+- Add unit tests for alternate IP encodings, redirects, DNS-shaped hostnames, Unicode hostnames, and916+ URL parser differentials.917+- Do not follow redirects in v1.918+- Never allow the deployment's own hostname; fetching it can re-enter the Worker and burn919+ subrequests or create loops.920+921+Slice 0 must validate a starting policy against actual subscriptions. Candidate hosts from the922+adversarial review are `fcm.googleapis.com`, `web.push.apple.com`,923+`updates.push.services.mozilla.com`, and the required subdomains of `notify.windows.com`. Do not924+merge delivery code with an `allow any HTTPS URL` fallback. The practical threat is request forgery,925+third-party quota burn, and Worker re-entry rather than access to a conventional VPS private926+network.927+928+## Sender authority and local guardrails929+930+In the current model, possession of the deployment bearer token authorizes an owner-wide931+notification. There is no server-side distinction between a human CLI, an unrestricted agent, and an932+agent whose local config limits vaults.933+934+`--open TARGET` must apply the existing `allowVaults` checks before resolution. That prevents a935+restricted CLI from constructing a click-through into a vault it cannot otherwise target.936+937+The sender-guard decision is closed: do not add `allowNotify` in v1. The deployment bearer already938+represents full owner authority, and a local flag is not a security boundary against code that can939+read that token. Real sender restriction requires scoped server credentials, which is a separate940+authentication feature. Document this authority plainly.941+942+## Privacy and retention943+944+- Notification bodies are stored long enough for delivery retries and diagnosis.945+- Proposed event and delivery retention: seven days after terminal state.946+- Pending enrollments expire after ten minutes.947+- Approved enrollments that have never completed an initial subscription expire after 24 hours.948+- Revocation immediately removes endpoint, `p256dh`, `auth`, and claim verifier.949+- Never log notification body, endpoint, push keys, claim cookie, or pairing-code verifier.950+- Structured logs may include event ID, device ID, provider class, attempt count, result class,951+ duration, and retry time.952+- Device names are owner-provided and may contain personal information; avoid emitting them in953+ routine Worker logs.954+955+## Observability956+957+Suggested structured events:958+959+```text960+notify.enrollment_created961+notify.device_approved962+notify.subscription_attached963+notify.event_accepted964+notify.delivery_sent965+notify.delivery_retry966+notify.delivery_disabled967+notify.device_revoked968+notify.setup_expired969+```970+971+Every delivery log should include stable IDs but no content or subscription secrets. Metrics worth972+exposing through logs or D1 queries:973+974+- Pending, approved, active, disabled device counts.975+- Events accepted.976+- Deliveries sent, retried, permanently failed, or disabled.977+- Time from event acceptance to successful provider response.978+- Setup abandonment between approval and subscription attachment.979+- Provider response classes.980+981+`nzip notify devices` must show status, creation/approval times, `last_seen_at`, `last_attached_at`,982+`last_success_at`, and the most recent bounded error class. This is the owner's primary way to983+notice a subscription that silently stopped receiving before a provider returns `404` or `410`.984+985+## Failure and recovery behavior986+987+| Failure | User-visible behavior | Recovery |988+| ---------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |989+| Approval polling fails | Page remains waiting and offers retry | Resume using claim cookie |990+| Approval expires | Page returns to unpaired state | Start a new enrollment |991+| Native install prompt unavailable | Show platform fallback instruction | User installs manually |992+| User installs before `paired` | App may receive only the short-lived pending cookie | Remove app, return to browser, re-pair, wait, reinstall |993+| Cookie not present or expired in installed app | App cannot validate pairing or toggle notifications | Remove app and repeat browser pairing and installation |994+| Notification permission denied | Device remains approved but inactive | Explain how to change OS settings or re-enroll |995+| Subscription attachment rejected | Show setup error without exposing endpoint details | Retry after validation or re-enroll |996+| Provider temporarily fails | CLI already reported queued | D1 retry schedule |997+| Provider returns 404/410 | Device becomes disabled | Owner re-enrolls device |998+| Click target changed or expired | Root shows muted `link expired` | Open the desired site independently |999+| Owner revokes during retry | Pending rows stop being sent | Delivery query rechecks active status |1000+1001+## Implementation slices1002+1003+### Slice 0: transport and platform spike1004+1005+- Generate test VAPID keys outside production.1006+- Select or prototype the Worker-compatible Web Push transport.1007+- Deliver to Android Chrome and iOS Home Screen web apps.1008+- Record actual subscription endpoint origins and response behavior, including the candidate FCM,1009+ Apple, Mozilla, and Windows host patterns.1010+- Verify payload size, Unicode, closed-app delivery, and click handling.1011+- Verify that iOS installation copies an HTTP-only cookie from every supported source browser.1012+- Verify that Home Screen launch sends the copied `SameSite=Lax` cookie.1013+- Verify the required ordering: approve, activation response sets long-lived cookie, footer renders1014+ `paired`, then install.1015+- Verify whether a service-worker push handler can renew the cookie through credentialed fetch.1016+- Verify Android browser-tab Web Push capability, while keeping install-first as the v1 product1017+ default unless explicitly changed.1018+- Verify multi-install endpoint behavior.1019+- Produce a written go/no-go result before schema implementation.1020+1021+### Slice 1: shared contracts and schema1022+1023+- Add request/response types to `shared/types.ts`.1024+- Add a new numbered D1 migration and update `worker/schema.sql`.1025+- Add device state transition helpers in a dedicated notification module.1026+- Keep notification persistence out of the security email tables.1027+1028+### Slice 2: enrollment and owner approval1029+1030+- Add public enrollment routes outside the global `/api/*` bearer gate.1031+- Add claim-cookie generation and verification.1032+- Add owner approval preview, code-addressed approval, device list, and revoke routes.1033+- Store bounded enrollment user-agent and Cloudflare country/region/ASN metadata for approval1034+ preview; do not store raw IP.1035+- Add dedicated rate limiter configuration.1036+- Add expiry cleanup to scheduled work.1037+1038+### Slice 3: approved installation surface1039+1040+- Replace the root landing response with a small HTML shell using the existing nzip styling.1041+- Extend the footer from `args` to `args · pair` or `args · paired`.1042+- Keep pairing code markup collapsed until `pair` is activated.1043+- Attach manifest and service-worker registration only after approval.1044+- Rely on Android's ambient native install affordance and add only a muted manual fallback.1045+- Implement approved-only iPhone instructions.1046+- Implement standalone detection and the single `notifications off/on` text toggle.1047+- Keep device naming and management out of the public page.1048+1049+### Slice 4: event and delivery path1050+1051+- Add `POST /api/notify` and the D1 delivery outbox.1052+- Add transport adapter and endpoint validation.1053+- Drain immediately with `waitUntil()` and later through scheduled work using one conditional lease1054+ protocol.1055+- Implement terminal response handling and bounded retries.1056+- Add service-worker push and click handlers.1057+1058+### Slice 5: CLI and agent surface1059+1060+- Add Deno CLI commands for send, list, approve, revoke, and test. Approval previews metadata and1061+ requires confirmation before posting the code.1062+- Preserve `--json` one-line output behavior.1063+- Apply target and vault guardrails to `--open`.1064+- After the CLI contract stabilizes, expose a semantic `notify` operation through the existing agent1065+ interface.1066+1067+### Slice 6: documentation and rollout1068+1069+- Add operator setup for VAPID secrets and any new rate-limit binding.1070+- Add mobile pairing and permission instructions.1071+- Document lock-screen visibility and sender-authority limitations.1072+- Deploy schema before Worker code that reads it.1073+- Gate sending with `NOTIFY_ENABLED`, defaulting false. Enabling requires valid VAPID and provider1074+ allowlist configuration plus at least one real-device setup.1075+- Use `nzip notify test` as the final production smoke test.1076+1077+## Verification plan1078+1079+### Shared and CLI tests1080+1081+- Request validation, including Unicode and length boundaries.1082+- CLI parsing for send, approve, revoke, list, and test.1083+- Approval preview and confirmation, including generic errors for unknown, expired, and used codes.1084+- JSON output stability.1085+- `--open` target resolution and `allowVaults` refusal.1086+- No absolute or cross-origin click target accepted.1087+1088+Run with the repository's Deno tasks:1089+1090+```text1091+deno task check1092+deno task fmt:check1093+deno task lint1094+deno task test1095+```1096+1097+### Worker unit tests1098+1099+- Claim generation, hashing, expiry, and cookie attributes.1100+- Pairing-code uniqueness retry and exact-one-match approval behavior.1101+- Two tabs activating one approved claim concurrently, including a dropped first response; both1102+ retain the same valid claim.1103+- Every legal and illegal device state transition.1104+- Approval cannot activate a device.1105+- Unapproved claims cannot attach subscriptions.1106+- Revoked and expired claims cannot attach or refresh subscriptions.1107+- Endpoint parser and SSRF cases.1108+- Operator allowlist parsing, provider match rules, own-zone rejection, and redirect rejection.1109+- Endpoint-hash collision steals from and disables the old row atomically.1110+- Identical launch re-attachment is a no-op; changed subscription material replaces the old1111+ endpoint.1112+- Event validation and relative-path normalization.1113+- Click-target validation refuses a deleted, reallocated, or re-pushed address.1114+- D1 event/delivery insertion is atomic.1115+- Retry schedule and response classification.1116+- Concurrent immediate and cron drains race for one row and issue exactly one outbound request.1117+- Expired delivery leases recover; stale lease owners cannot overwrite the new result.1118+- Revocation prevents later outbox drains.1119+- VAPID rotation disables active subscriptions without revoking device approval.1120+- Pending enrollment capacity prunes expired rows, rejects at 32 live rows, and never evicts a live1121+ enrollment.1122+- Raw secrets and payload bodies do not appear in logs.1123+1124+### Worker runtime tests1125+1126+- Root enrollment and polling routes.1127+- Approval activation is explicit, idempotent, and must finish before install promotion appears.1128+- Existing `/api/*` bearer behavior remains unchanged.1129+- Public enrollment endpoints reject cross-origin and oversized requests.1130+- Scheduled retries resume a delivery left pending after the request finishes.1131+- Service-worker, manifest, and icon responses have correct content types and cache policies.1132+- `/` contains no cookie-derived state or `Set-Cookie`; every `/_notify/*` response is `no-store`.1133+- Root visits do not create enrollment until `pair` is activated.1134+- Pairing code is absent from the collapsed DOM and appears only after activation.1135+- Normal browser mode never renders the notification toggle.1136+- Standalone mode renders exactly one notification action.1137+- Offline launch does not present an enabled toggle before server claim validation.1138+- Poll cadence stays below the claim-read limiter.1139+1140+Run:1141+1142+```text1143+cd worker1144+npm run check1145+npm test1146+```1147+1148+### Manual device matrix1149+1150+| Platform | Browser/context | Required result |1151+| --------------- | ----------------------- | ------------------------------------------------------------------- |1152+| Current Android | Chrome browser | No install promotion before approval |1153+| Current Android | Chrome browser | Native ambient install affordance or muted fallback after approval |1154+| Current Android | Installed PWA | Permission, subscription, receive, tap route, revoke |1155+| Current iPhone | Safari/browser | No Add to Home Screen instruction before approval |1156+| Current iPhone | Safari/browser | Approved-only Share -> Add to Home Screen instruction |1157+| Current iPhone | Home Screen app | Claim survives install, permission works, receive while closed |1158+| Current iPhone | Home Screen app | Notification tap opens `/` or the unchanged manifest-pinned site |1159+| Both | OS notifications denied | Clear inactive state and recovery instruction |1160+| Both | Endpoint invalidated | Device becomes disabled after provider response |1161+| Both | Installed PWA | Footer toggle turns subscription off and back on without re-pairing |1162+1163+Also test a device that manually installs before approval. It must remain unable to attach a1164+subscription until the owner approves its claim.1165+1166+## Acceptance criteria1167+1168+- A pending device cannot see nzip's install promotion.1169+- A fresh root visit shows only the wordmark and `args · pair`; it creates no enrollment.1170+- The pairing code appears only after `pair` is activated.1171+- `POST /api/notify/approvals` hashes the presented code and is the only owner action that1172+ authorizes installation promotion.1173+- Approval preview shows bounded enrollment metadata and requires owner confirmation.1174+- `POST /_notify/enrollments/activate` must succeed before the page shows `paired` or installation1175+ guidance.1176+- Approval alone does not create a recipient.1177+- An unapproved or expired claim cannot attach or refresh a subscription.1178+- No browser ever receives the nzip owner bearer token.1179+- Android relies on native ambient install UI when the browser exposes it; nzip adds no install1180+ button.1181+- iPhone uses approved-only Add to Home Screen instructions because no native prompt API is1182+ available to the page.1183+- The installed app requires a user gesture before notification permission.1184+- The installed footer reads `args · paired · notifications off/on` and has no other setup control.1185+- Toggling off removes subscription capability but preserves approval, allowing a later toggle on1186+ without re-pairing.1187+- A queued event is persisted before the API reports success.1188+- Immediate and cron drains cannot concurrently send the same delivery row while a lease is valid.1189+- Transient provider failures retry; expired subscriptions are disabled.1190+- Notification clicks open a site path only if its current manifest matches the event; reused or1191+ changed addresses resolve to `link expired` at `/`.1192+- Revocation removes subscription capability and prevents future retries.1193+- Current Android and current iPhone receive a test notification while the app is closed.1194+- Existing push, serve, password, security alert, and GC behavior remains intact.1195+1196+## Rejected alternatives1197+1198+### Subscribe before owner approval1199+1200+Rejected. It creates browser capability material before owner authorization and does not work as a1201+uniform flow on iPhone, where Web Push belongs to an installed Home Screen app.1202+1203+### Put the owner bearer token in the PWA1204+1205+Rejected. Browser compromise would grant full site-management authority, and the token is1206+unnecessary for a device-specific claim.1207+1208+### Promote installation to every root visitor1209+1210+Rejected. It creates a public install surface unrelated to owner authorization and conflicts with1211+the requested owner-only experience.1212+1213+### Put a device identifier in manifest `start_url`1214+1215+Rejected. It creates a persistent per-install identifier and depends on browser startup behavior for1216+an authorization handoff.1217+1218+### Accept arbitrary notification click URLs1219+1220+Rejected. It turns owner notifications into a general link-push and phishing surface. Existing nzip1221+targets cover the intended routing use case.1222+1223+### Treat `waitUntil()` as the only delivery mechanism1224+1225+Rejected. The event must survive an interrupted or failed immediate drain, so D1 outbox state and1226+scheduled retries are required.1227+1228+### Reuse `security_notifications`1229+1230+Rejected. Security email payloads, retention, recipients, and retry semantics are different from1231+owner Web Push events.1232+1233+## References to verify during implementation1234+1235+- Web Push for iOS/iPadOS Home Screen web apps:1236+ https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/1237+- iOS/iPadOS 17.2 browser-cookie handoff to installed web apps:1238+ https://webkit.org/blog/14787/webkit-features-in-safari-17-2/1239+- Android/Chromium `beforeinstallprompt` behavior:1240+ https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeinstallprompt_event1241+- Web App Manifest `start_url` privacy considerations: https://www.w3.org/TR/appmanifest/1242+- Push subscription fields and encryption keys: https://www.w3.org/TR/push-api/1243+- Cloudflare Workers Web Crypto support:1244+ https://developers.cloudflare.com/workers/runtime-apis/web-crypto/1245+- Cloudflare outbound API requests:1246+ https://developers.cloudflare.com/workers/configuration/integrations/apis/1247+- Cloudflare Queues retry behavior if the outbox design is reconsidered:1248+ https://developers.cloudflare.com/queues/configuration/batching-retries/1249+- Pushy, a minimal native APNs service on Cloudflare Workers: https://codakuma.com/pushy/1250+1251+These references are time-sensitive. Re-check them when implementation begins; do not treat this1252+plan as a substitute for current browser and Cloudflare tests.1253+1254+## Reviewer checklist1255+1256+Before approving implementation, answer each item explicitly:1257+1258+- [ ] Is owner-first approval the correct product boundary?1259+- [ ] Is a public pending-enrollment endpoint acceptable?1260+- [ ] Does slice 0 prove that iOS copies the HTTP-only cookie and sends it on Home Screen launch?1261+- [ ] Is the requirement to wait for `paired` before installation clear enough?1262+- [ ] Does `WEB_PUSH_ORIGINS` cover every observed provider without an unsafe wildcard?1263+- [ ] Is the D1 lease duration long enough for provider timeouts and short enough for recovery?1264+- [ ] Are retry duration and event retention acceptable?1265+- [ ] Is deployment-wide bearer authority sufficient for sending notifications?1266+- [ ] Are title/body limits and lock-screen privacy guidance sufficient?1267+- [ ] Is manifest-pinned click routing preferable to opening the latest content after a re-push?1268+- [ ] Is destructive reinstall acceptable recovery for a lost installed-app claim?1269+- [ ] Are the code-addressed approval and `/_notify/*` route names consistent with nzip?1270+- [ ] Is `args · pair(ed) · notifications off/on` sufficiently clear without added UI chrome?1271+- [ ] Is relying on Android's ambient installation affordance acceptable when it may not appear?1272+- [ ] Should Android remain install-first even if slice 0 confirms browser-tab push works?1273+- [ ] Is the muted iPhone Add to Home Screen instruction sufficient?1274+- [ ] Is last-attachment-wins acceptable when multiple installs share one approved claim?1275+- [ ] Does the manual device matrix cover the actual supported platforms?1276+- [ ] Are any implementation slices too broad to review safely?
A cli/commands/notify.ts +157 -0
@@ -0,0 +1,157 @@
1+import type { NotifyApprovalPreview, NotifyRequest } from "@nzip/shared";2+import { ApiClient, resolveCliTarget } from "../lib/api.ts";3+import type { Config } from "../lib/config.ts";4+import { ago, bold, cyan, dim, emit, fail, green, table } from "../lib/fmt.ts";5+6+export type NotifyInvocation =7+ | { kind: "send"; body: string }8+ | { kind: "test" }9+ | { kind: "approve"; code: string }10+ | { kind: "devices" }11+ | { kind: "revoke"; deviceId: string };12+13+export function parseNotifyInvocation(rest: string[]): NotifyInvocation {14+ const [first, second, ...extra] = rest;15+ if (!first) fail("usage: nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]");16+ if (first === "test") {17+ if (second !== undefined) fail("usage: nzip notify test");18+ return { kind: "test" };19+ }20+ if (first === "approve") {21+ if (!second || extra.length > 0) fail("usage: nzip notify approve <code> --name NAME");22+ return { kind: "approve", code: second };23+ }24+ if (first === "devices" || first === "ls") {25+ if (second !== undefined) fail("usage: nzip notify devices");26+ return { kind: "devices" };27+ }28+ if (first === "revoke") {29+ if (!second || extra.length > 0) fail("usage: nzip notify revoke <device-id> [--yes]");30+ return { kind: "revoke", deviceId: second };31+ }32+ if (first === "send") {33+ if (!second || extra.length > 0) {34+ fail("usage: nzip notify send <body> [--title TEXT] [--open TARGET] [--tag TEXT]");35+ }36+ return { kind: "send", body: second };37+ }38+ if (second !== undefined) fail("notification body must be one quoted argument");39+ return { kind: "send", body: first };40+}41+42+function location(preview: NotifyApprovalPreview): string {43+ return [preview.country, preview.region].filter(Boolean).join("/") || "unknown";44+}45+46+export async function cmdNotify(47+ config: Config,48+ rest: string[],49+ options: {50+ title?: string;51+ open?: string;52+ tag?: string;53+ name?: string;54+ yes: boolean;55+ },56+): Promise<void> {57+ const invocation = parseNotifyInvocation(rest);58+ const api = new ApiClient(config);59+60+ if (invocation.kind === "devices") {61+ const devices = await api.notificationDevices();62+ emit(() => {63+ if (devices.length === 0) return console.log(dim("no notification devices"));64+ console.log(table(65+ [66+ "ID",67+ "NAME",68+ "STATUS",69+ "CREATED",70+ "APPROVED",71+ "SEEN",72+ "ATTACHED",73+ "DELIVERED",74+ "ERROR",75+ ],76+ devices.map((device) => [77+ cyan(device.id),78+ device.name ?? dim("—"),79+ device.status,80+ ago(device.createdAt),81+ device.approvedAt ? ago(device.approvedAt) : dim("never"),82+ device.lastSeenAt ? ago(device.lastSeenAt) : dim("never"),83+ device.lastAttachedAt ? ago(device.lastAttachedAt) : dim("never"),84+ device.lastSuccessAt ? ago(device.lastSuccessAt) : dim("never"),85+ device.lastError ?? "",86+ ]),87+ ));88+ }, { ok: true, devices });89+ return;90+ }91+92+ if (invocation.kind === "approve") {93+ if (!options.name?.trim()) fail("--name is required when approving a notification device");94+ const preview = await api.notificationApprovalPreview(invocation.code);95+ if (!options.yes) {96+ emit(() => {97+ console.log(`approve notification device ${bold(options.name!.trim())}?`);98+ console.log(99+ ` created ${ago(preview.createdAt)} · ${preview.deviceClass ?? "unknown device"}`,100+ );101+ console.log(` ${preview.userAgentSummary ?? "unknown browser"}`);102+ console.log(` ${location(preview)} · ASN ${preview.asn ?? "unknown"}`);103+ });104+ const answer = prompt("approve this device? [y/N]");105+ if (answer?.trim().toLowerCase() !== "y") {106+ emit(() => console.log(dim("aborted")), { ok: false, error: "aborted" });107+ return;108+ }109+ }110+ const device = await api.approveNotificationDevice({111+ code: invocation.code,112+ name: options.name.trim(),113+ });114+ emit(115+ () => console.log(`${green("✓")} approved ${bold(device.name ?? device.id)}`),116+ { ok: true, device },117+ );118+ return;119+ }120+121+ if (invocation.kind === "revoke") {122+ if (!options.yes) {123+ const answer = prompt(`revoke notification device ${invocation.deviceId}? [y/N]`);124+ if (answer?.trim().toLowerCase() !== "y") {125+ emit(() => console.log(dim("aborted")), { ok: false, error: "aborted" });126+ return;127+ }128+ }129+ await api.revokeNotificationDevice(invocation.deviceId);130+ emit(131+ () => console.log(`${green("✓")} revoked ${bold(invocation.deviceId)}`),132+ { ok: true, revoked: invocation.deviceId },133+ );134+ return;135+ }136+137+ const event: NotifyRequest = invocation.kind === "test"138+ ? { title: "nzip", body: "nzip notifications are working", tag: "nzip-test" }139+ : { body: invocation.body };140+ if (invocation.kind === "send") {141+ if (options.title !== undefined) event.title = options.title;142+ if (options.tag !== undefined) event.tag = options.tag;143+ if (options.open !== undefined) {144+ const target = resolveCliTarget(options.open, config);145+ const site = await api.siteDetail(target);146+ event.path = `/${site.address}`;147+ }148+ }149+ const response = await api.notify(event);150+ emit(151+ () =>152+ console.log(153+ `notification queued for ${response.queuedDevices} devices (event ${response.eventId})`,154+ ),155+ { ok: true, ...response },156+ );157+}
A cli/commands/notify_test.ts +34 -0
M cli/lib/api.ts +37 -0
M cli/main.ts +28 -1
@@ -20,6 +20,7 @@
2020 import { parseArgs } from "@std/cli/parse-args";2121 import { cmdAuth } from "./commands/auth.ts";2222 import { cmdPush } from "./commands/push.ts";23+import { cmdNotify } from "./commands/notify.ts";2324 import { cmdCp } from "./commands/cp.ts";2425 import { cmdLs, cmdRevert, cmdRm, cmdSite, cmdStatus } from "./commands/sites.ts";2526 import { cmdVault } from "./commands/vault.ts";
@@ -46,6 +47,12 @@ usage:
4647 nzip where <target> print the local dir this machine pushed from4748 nzip rm <target> [--yes] delete a site4849 nzip status server + vault overview50+ nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]51+ queue a notification for active devices52+ nzip notify test queue a diagnostic notification53+ nzip notify approve <code> --name NAME [--yes]54+ nzip notify devices list notification devices55+ nzip notify revoke <device-id> [--yes] revoke a notification device4956 nzip revert <target> [--to N] [--list] repoint to a previous push5057 5158 targets: 2a3f | work:demo | demo (alias in default vault)
@@ -59,7 +66,19 @@ to named vaults — pushes/aliases outside the list are refused (agent guardrail
5966 6067 async function main(): Promise<void> {6168 const args = parseArgs(Deno.args, {62- string: ["ttl", "to", "slot", "server", "token", "password", "name", "description"],69+ string: [70+ "ttl",71+ "to",72+ "slot",73+ "server",74+ "token",75+ "password",76+ "name",77+ "description",78+ "title",79+ "open",80+ "tag",81+ ],6382 boolean: ["yes", "list", "help", "no-password", "overwrite", "json", "no-description"],6483 alias: { h: "help", y: "yes" },6584 });
@@ -107,6 +126,14 @@ async function main(): Promise<void> {
107126 return await cmdRm(config, rest[0], args.yes);108127 case "status":109128 return await cmdStatus(config);129+ case "notify":130+ return await cmdNotify(config, rest, {131+ title: args.title,132+ open: args.open,133+ tag: args.tag,134+ name: args.name,135+ yes: args.yes,136+ });110137 case "revert":111138 return await cmdRevert(config, rest[0], toSeq, args.list);112139 case "vault":
M shared/types.ts +93 -0
@@ -171,3 +171,96 @@ export interface ApiError {
171171 /** Human-readable error message safe to show to the authenticated client. */172172 error: string;173173 }174+175+/** Body of `POST /api/notify`: a small, user-visible Web Push event. */176+export interface NotifyRequest {177+ /** Notification title. The Worker defaults this to `nzip` when omitted. */178+ title?: string;179+ /** Required notification body. */180+ body: string;181+ /** Optional normalized, same-origin path opened when the notification is tapped. */182+ path?: string;183+ /** Optional provider-independent replacement/deduplication tag. */184+ tag?: string;185+}186+187+/** Reply from `POST /api/notify`; acceptance does not imply delivery. */188+export interface NotifyResponse {189+ /** Stable identifier for the persisted notification event. */190+ eventId: string;191+ /** Number of active device deliveries placed in the outbox. */192+ queuedDevices: number;193+ /** Number of approved but inactive devices excluded from delivery. */194+ inactiveDevices: number;195+}196+197+/** Server-side lifecycle state for an owner-approved notification device. */198+export type NotifyDeviceStatus =199+ | "pending"200+ | "approved"201+ | "active"202+ | "disabled"203+ | "revoked"204+ | "expired";205+206+/** Owner-visible notification device metadata. Subscription secrets are never returned. */207+export interface NotifyDeviceInfo {208+ /** Opaque stable identifier used for revocation. */209+ id: string;210+ /** Owner-assigned display name, or `null` while pairing is pending. */211+ name: string | null;212+ /** Current enrollment and subscription lifecycle state. */213+ status: NotifyDeviceStatus;214+ /** Bounded browser and operating-system summary captured at enrollment. */215+ userAgentSummary: string | null;216+ /** Coarse device class such as phone, tablet, or desktop. */217+ deviceClass: string | null;218+ /** Two-letter country code reported by the edge, when available. */219+ country: string | null;220+ /** Bounded region code reported by the edge, when available. */221+ region: string | null;222+ /** Network autonomous-system number reported by the edge, when available. */223+ asn: number | null;224+ /** Unix timestamp, in seconds, when enrollment began. */225+ createdAt: number;226+ /** Unix expiry for an unapproved pairing code, or `null` once inapplicable. */227+ pairingExpiresAt: number | null;228+ /** Unix expiry for the approved claim, or `null` when unavailable. */229+ claimExpiresAt: number | null;230+ /** Unix timestamp of owner approval, or `null` before approval. */231+ approvedAt: number | null;232+ /** Unix timestamp of first active subscription attachment, or `null`. */233+ activeAt: number | null;234+ /** Unix timestamp of the most recent subscription attachment, or `null`. */235+ lastAttachedAt: number | null;236+ /** Unix timestamp of the most recent validated device request, or `null`. */237+ lastSeenAt: number | null;238+ /** Unix timestamp of the most recent successful Web Push delivery, or `null`. */239+ lastSuccessAt: number | null;240+ /** Bounded error class only; provider responses and subscription material are excluded. */241+ lastError: string | null;242+}243+244+/** Bounded context shown before the owner approves a pairing code. */245+export interface NotifyApprovalPreview {246+ /** Unix timestamp, in seconds, when enrollment began. */247+ createdAt: number;248+ /** Bounded browser and operating-system summary captured at enrollment. */249+ userAgentSummary: string | null;250+ /** Coarse device class such as phone, tablet, or desktop. */251+ deviceClass: string | null;252+ /** Two-letter country code reported by the edge, when available. */253+ country: string | null;254+ /** Bounded region code reported by the edge, when available. */255+ region: string | null;256+ /** Network autonomous-system number reported by the edge, when available. */257+ asn: number | null;258+}259+260+/** Body of `POST /api/notify/approvals`. */261+export interface NotifyApprovalRequest {262+ /** Display-safe pairing code shown on the enrolling device. */263+ code: string;264+ /** Owner-assigned device name. */265+ name: string;266+}
M worker/.dev.vars.example +1 -0
A worker/migrations/0005_notifications.sql +59 -0
@@ -0,0 +1,59 @@
1+CREATE TABLE IF NOT EXISTS notification_devices (2+ id TEXT PRIMARY KEY,3+ pairing_code_hash TEXT UNIQUE,4+ claim_hash TEXT UNIQUE,5+ name TEXT,6+ status TEXT NOT NULL CHECK (7+ status IN ('pending', 'approved', 'active', 'disabled', 'revoked', 'expired')8+ ),9+ user_agent_summary TEXT,10+ device_class TEXT,11+ country TEXT,12+ region TEXT,13+ asn INTEGER,14+ endpoint TEXT,15+ endpoint_hash TEXT UNIQUE,16+ p256dh TEXT,17+ auth TEXT,18+ created_at INTEGER NOT NULL,19+ pairing_expires_at INTEGER,20+ claim_expires_at INTEGER,21+ approved_at INTEGER,22+ active_at INTEGER,23+ last_attached_at INTEGER,24+ last_seen_at INTEGER,25+ last_success_at INTEGER,26+ last_error TEXT27+);28+29+CREATE INDEX IF NOT EXISTS idx_notification_devices_status30+ ON notification_devices(status);31+32+CREATE TABLE IF NOT EXISTS notification_events (33+ id TEXT PRIMARY KEY,34+ title TEXT NOT NULL,35+ body TEXT NOT NULL,36+ path TEXT,37+ expected_manifest_hash TEXT,38+ tag TEXT,39+ created_at INTEGER NOT NULL40+);41+42+CREATE INDEX IF NOT EXISTS idx_notification_events_created43+ ON notification_events(created_at);44+45+CREATE TABLE IF NOT EXISTS notification_deliveries (46+ event_id TEXT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,47+ device_id TEXT NOT NULL REFERENCES notification_devices(id) ON DELETE CASCADE,48+ status TEXT NOT NULL CHECK (status IN ('pending', 'sending', 'retry', 'sent', 'failed')),49+ attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),50+ next_attempt_at INTEGER,51+ lease_owner TEXT,52+ lease_expires_at INTEGER,53+ sent_at INTEGER,54+ last_error TEXT,55+ PRIMARY KEY (event_id, device_id)56+);57+58+CREATE INDEX IF NOT EXISTS idx_notification_deliveries_due59+ ON notification_deliveries(status, next_attempt_at);
M worker/package-lock.json +188 -0
@@ -5,10 +5,14 @@
55 "packages": {66 "": {77 "name": "nzip-worker",8+ "dependencies": {9+ "web-push": "^3.6.7"10+ },811 "devDependencies": {912 "@cloudflare/vitest-pool-workers": "^0.18.4",1013 "@cloudflare/workers-types": "^5.20260712.1",1114 "@types/node": "^26.1.1",15+ "@types/web-push": "^3.6.4",1216 "typescript": "^5.9.3",1317 "vitest": "^4.1.10",1418 "wrangler": "^4.110.0"
@@ -1564,6 +1568,16 @@
15641568 "undici-types": "~8.3.0"15651569 }15661570 },1571+ "node_modules/@types/web-push": {1572+ "version": "3.6.4",1573+ "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",1574+ "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",1575+ "dev": true,1576+ "license": "MIT",1577+ "dependencies": {1578+ "@types/node": "*"1579+ }1580+ },15671581 "node_modules/@vitest/expect": {15681582 "version": "4.1.10",15691583 "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
@@ -1677,6 +1691,27 @@
16771691 "url": "https://opencollective.com/vitest"16781692 }16791693 },1694+ "node_modules/agent-base": {1695+ "version": "7.1.4",1696+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",1697+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",1698+ "license": "MIT",1699+ "engines": {1700+ "node": ">= 14"1701+ }1702+ },1703+ "node_modules/asn1.js": {1704+ "version": "5.4.1",1705+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",1706+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",1707+ "license": "MIT",1708+ "dependencies": {1709+ "bn.js": "^4.0.0",1710+ "inherits": "^2.0.1",1711+ "minimalistic-assert": "^1.0.0",1712+ "safer-buffer": "^2.1.0"1713+ }1714+ },16801715 "node_modules/assertion-error": {16811716 "version": "2.0.1",16821717 "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1694,6 +1729,18 @@
16941729 "dev": true,16951730 "license": "MIT"16961731 },1732+ "node_modules/bn.js": {1733+ "version": "4.12.5",1734+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",1735+ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",1736+ "license": "MIT"1737+ },1738+ "node_modules/buffer-equal-constant-time": {1739+ "version": "1.0.1",1740+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",1741+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",1742+ "license": "BSD-3-Clause"1743+ },16971744 "node_modules/chai": {16981745 "version": "6.2.2",16991746 "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1732,6 +1779,23 @@
17321779 "url": "https://opencollective.com/express"17331780 }17341781 },1782+ "node_modules/debug": {1783+ "version": "4.4.3",1784+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",1785+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",1786+ "license": "MIT",1787+ "dependencies": {1788+ "ms": "^2.1.3"1789+ },1790+ "engines": {1791+ "node": ">=6.0"1792+ },1793+ "peerDependenciesMeta": {1794+ "supports-color": {1795+ "optional": true1796+ }1797+ }1798+ },17351799 "node_modules/detect-libc": {17361800 "version": "2.1.2",17371801 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1742,6 +1806,15 @@
17421806 "node": ">=8"17431807 }17441808 },1809+ "node_modules/ecdsa-sig-formatter": {1810+ "version": "1.0.11",1811+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",1812+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",1813+ "license": "Apache-2.0",1814+ "dependencies": {1815+ "safe-buffer": "^5.0.1"1816+ }1817+ },17451818 "node_modules/error-stack-parser-es": {17461819 "version": "1.0.5",17471820 "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
@@ -1854,6 +1927,55 @@
18541927 "node": "^8.16.0 || ^10.6.0 || >=11.0.0"18551928 }18561929 },1930+ "node_modules/http_ece": {1931+ "version": "1.2.0",1932+ "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",1933+ "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",1934+ "license": "MIT",1935+ "engines": {1936+ "node": ">=16"1937+ }1938+ },1939+ "node_modules/https-proxy-agent": {1940+ "version": "7.0.6",1941+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",1942+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",1943+ "license": "MIT",1944+ "dependencies": {1945+ "agent-base": "^7.1.2",1946+ "debug": "4"1947+ },1948+ "engines": {1949+ "node": ">= 14"1950+ }1951+ },1952+ "node_modules/inherits": {1953+ "version": "2.0.4",1954+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",1955+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",1956+ "license": "ISC"1957+ },1958+ "node_modules/jwa": {1959+ "version": "2.0.1",1960+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",1961+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",1962+ "license": "MIT",1963+ "dependencies": {1964+ "buffer-equal-constant-time": "^1.0.1",1965+ "ecdsa-sig-formatter": "1.0.11",1966+ "safe-buffer": "^5.0.1"1967+ }1968+ },1969+ "node_modules/jws": {1970+ "version": "4.0.1",1971+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",1972+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",1973+ "license": "MIT",1974+ "dependencies": {1975+ "jwa": "^2.0.1",1976+ "safe-buffer": "^5.0.1"1977+ }1978+ },18571979 "node_modules/kleur": {18581980 "version": "4.1.5",18591981 "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -2156,6 +2278,27 @@
21562278 "node": ">=22.0.0"21572279 }21582280 },2281+ "node_modules/minimalistic-assert": {2282+ "version": "1.0.1",2283+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",2284+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",2285+ "license": "ISC"2286+ },2287+ "node_modules/minimist": {2288+ "version": "1.2.8",2289+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",2290+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",2291+ "license": "MIT",2292+ "funding": {2293+ "url": "https://github.com/sponsors/ljharb"2294+ }2295+ },2296+ "node_modules/ms": {2297+ "version": "2.1.3",2298+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",2299+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",2300+ "license": "MIT"2301+ },21592302 "node_modules/nanoid": {21602303 "version": "3.3.15",21612304 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -2286,6 +2429,32 @@
22862429 "@rolldown/binding-win32-x64-msvc": "1.1.5"22872430 }22882431 },2432+ "node_modules/safe-buffer": {2433+ "version": "5.2.1",2434+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",2435+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",2436+ "funding": [2437+ {2438+ "type": "github",2439+ "url": "https://github.com/sponsors/feross"2440+ },2441+ {2442+ "type": "patreon",2443+ "url": "https://www.patreon.com/feross"2444+ },2445+ {2446+ "type": "consulting",2447+ "url": "https://feross.org/support"2448+ }2449+ ],2450+ "license": "MIT"2451+ },2452+ "node_modules/safer-buffer": {2453+ "version": "2.1.2",2454+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",2455+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",2456+ "license": "MIT"2457+ },22892458 "node_modules/semver": {22902459 "version": "7.8.5",22912460 "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
@@ -2649,6 +2818,25 @@
26492818 }26502819 }26512820 },2821+ "node_modules/web-push": {2822+ "version": "3.6.7",2823+ "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",2824+ "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",2825+ "license": "MPL-2.0",2826+ "dependencies": {2827+ "asn1.js": "^5.3.0",2828+ "http_ece": "1.2.0",2829+ "https-proxy-agent": "^7.0.0",2830+ "jws": "^4.0.0",2831+ "minimist": "^1.2.5"2832+ },2833+ "bin": {2834+ "web-push": "src/cli.js"2835+ },2836+ "engines": {2837+ "node": ">= 16"2838+ }2839+ },26522840 "node_modules/why-is-node-running": {26532841 "version": "2.3.0",26542842 "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
M worker/package.json +4 -0
M worker/schema.sql +60 -0
@@ -81,3 +81,63 @@ CREATE TABLE IF NOT EXISTS security_notifications (
8181 CREATE INDEX IF NOT EXISTS idx_security_notifications_pending8282 ON security_notifications(created_at)8383 WHERE sent_at IS NULL;84+85+CREATE TABLE IF NOT EXISTS notification_devices (86+ id TEXT PRIMARY KEY,87+ pairing_code_hash TEXT UNIQUE,88+ claim_hash TEXT UNIQUE,89+ name TEXT,90+ status TEXT NOT NULL CHECK (91+ status IN ('pending', 'approved', 'active', 'disabled', 'revoked', 'expired')92+ ),93+ user_agent_summary TEXT,94+ device_class TEXT,95+ country TEXT,96+ region TEXT,97+ asn INTEGER,98+ endpoint TEXT,99+ endpoint_hash TEXT UNIQUE,100+ p256dh TEXT,101+ auth TEXT,102+ created_at INTEGER NOT NULL,103+ pairing_expires_at INTEGER,104+ claim_expires_at INTEGER,105+ approved_at INTEGER,106+ active_at INTEGER,107+ last_attached_at INTEGER,108+ last_seen_at INTEGER,109+ last_success_at INTEGER,110+ last_error TEXT111+);112+113+CREATE INDEX IF NOT EXISTS idx_notification_devices_status114+ ON notification_devices(status);115+116+CREATE TABLE IF NOT EXISTS notification_events (117+ id TEXT PRIMARY KEY,118+ title TEXT NOT NULL,119+ body TEXT NOT NULL,120+ path TEXT,121+ expected_manifest_hash TEXT,122+ tag TEXT,123+ created_at INTEGER NOT NULL124+);125+126+CREATE INDEX IF NOT EXISTS idx_notification_events_created127+ ON notification_events(created_at);128+129+CREATE TABLE IF NOT EXISTS notification_deliveries (130+ event_id TEXT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,131+ device_id TEXT NOT NULL REFERENCES notification_devices(id) ON DELETE CASCADE,132+ status TEXT NOT NULL CHECK (status IN ('pending', 'sending', 'retry', 'sent', 'failed')),133+ attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),134+ next_attempt_at INTEGER,135+ lease_owner TEXT,136+ lease_expires_at INTEGER,137+ sent_at INTEGER,138+ last_error TEXT,139+ PRIMARY KEY (event_id, device_id)140+);141+142+CREATE INDEX IF NOT EXISTS idx_notification_deliveries_due143+ ON notification_deliveries(status, next_attempt_at);
M worker/setup.md +59 -3
@@ -64,9 +64,10 @@ Click the verification link Cloudflare sends, then configure:
6464 }6565 ```6666 67-Apply `migrations/0002_security_alerts.sql` and68-`migrations/0003_security_notification_outbox.sql`, then69-`migrations/0004_vault_descriptions.sql` before deploying an upgraded Worker.67+Apply `migrations/0002_security_alerts.sql`,68+`migrations/0003_security_notification_outbox.sql`,69+`migrations/0004_vault_descriptions.sql`, and70+`migrations/0005_notifications.sql` before deploying an upgraded Worker.7071 After deployment, send a delivery test through the owner-authenticated endpoint:7172 7273 ```sh
@@ -110,8 +111,63 @@ npx wrangler d1 execute nzip --remote --file migrations/0001_auth_version.sql
110111 npx wrangler d1 execute nzip --remote --file migrations/0002_security_alerts.sql111112 npx wrangler d1 execute nzip --remote --file migrations/0003_security_notification_outbox.sql112113 npx wrangler d1 execute nzip --remote --file migrations/0004_vault_descriptions.sql114+npx wrangler d1 execute nzip --remote --file migrations/0005_notifications.sql113115 ```114116 117+## Owner notifications118+119+Notifications are fail-closed by default. Keep `NOTIFY_ENABLED` set to the120+string `"false"` while provisioning and applying the schema. Before deploying,121+copy the two notification rate-limit bindings from `wrangler.jsonc` into your122+ignored `wrangler.local.jsonc`, then configure one stable VAPID key pair:123+124+```sh125+# Generates a public/private P-256 VAPID key pair. Keep the private key private.126+npx web-push generate-vapid-keys127+128+# Enter the generated private key only at Wrangler's interactive prompt.129+npx wrangler secret put VAPID_PRIVATE_KEY --config wrangler.local.jsonc130+```131+132+Set `VAPID_PUBLIC_KEY` to the generated public key and `VAPID_SUBJECT` to a133+stable `mailto:` or `https:` contact value in `wrangler.local.jsonc`. Populate134+`WEB_PUSH_ORIGINS` with a comma-separated list of exact HTTPS origins observed135+from the browsers you intend to pair. Do not add wildcards or the deployment's136+own origin. An empty or invalid allowlist rejects subscription attachment and137+delivery.138+139+Apply the notification migration before deploying code that reads its tables:140+141+```sh142+cd worker143+npx wrangler d1 execute nzip --remote \144+ --config wrangler.local.jsonc \145+ --file migrations/0005_notifications.sql146+npx wrangler deploy --dry-run --config wrangler.local.jsonc147+npx wrangler deploy --config wrangler.local.jsonc148+```149+150+`NOTIFY_ENABLED` is the delivery kill switch. Leave it `"false"` until the151+deployment configuration is valid and a real device is ready for the pairing152+flow.153+154+To pair a phone, open the deployment root in its browser, tap the quiet `pair`155+footer action, and approve the displayed code from an authenticated terminal:156+157+```sh158+nzip notify approve ABCD-1234 --name "Personal phone"159+```160+161+Wait for the browser footer to say `paired` before adding the site to the Home162+Screen. Open the installed app, tap `notifications off`, and accept the system163+permission request. If the installed app cannot recover the approved claim,164+remove it, repeat pairing in the browser, wait for `paired`, and install again.165+166+After pairing succeeds, set `NOTIFY_ENABLED` to `"true"`, redeploy, and use167+`nzip notify test` for the final end-to-end smoke test. Notification titles and168+bodies may appear on a lock screen; never put passwords, tokens, private URLs,169+or other sensitive data in them.170+115171 > **Cron gotcha:** deploying the `triggers` block fails with a 403 (API error116172 > `10063`) until the account has a workers.dev subdomain registered — even if117173 > the Worker only serves a custom domain. Open the Workers dashboard once to
M worker/src/api.ts +37 -0
@@ -6,6 +6,15 @@ import { handleSite, handleSiteList } from "./api/sites.ts";
66 import { handleStatus } from "./api/status.ts";77 import { handleVault, handleVaults } from "./api/vaults.ts";88 import { sendAlertTest } from "./security_alerts.ts";9+import { handleNotifySend } from "./notify.ts";10+import {11+ approveEnrollment,12+ listNotificationDevices,13+ notifyErrorResponse,14+ NotifyHttpError,15+ previewApproval,16+ revokeNotificationDevice,17+} from "./notify_enrollment.ts";918 1019 /** Authenticated management API router. Route handlers own validation and storage concerns. */1120 export async function api(
@@ -18,6 +27,33 @@ export async function api(
1827 const method = request.method;1928 2029 try {30+ if (parts[1] === "notify" && parts.length === 2 && method === "POST") {31+ return await handleNotifySend(request, env, ctx);32+ }33+ if (34+ parts[1] === "notify" && parts[2] === "devices" && parts.length === 3 &&35+ method === "GET"36+ ) {37+ return await listNotificationDevices(env);38+ }39+ if (40+ parts[1] === "notify" && parts[2] === "approvals" &&41+ parts.length === 4 && method === "GET"42+ ) {43+ return await previewApproval(decodeURIComponent(parts[3]), env);44+ }45+ if (46+ parts[1] === "notify" && parts[2] === "approvals" &&47+ parts.length === 3 && method === "POST"48+ ) {49+ return await approveEnrollment(request, env);50+ }51+ if (52+ parts[1] === "notify" && parts[2] === "devices" &&53+ parts.length === 4 && method === "DELETE"54+ ) {55+ return await revokeNotificationDevice(decodeURIComponent(parts[3]), env);56+ }2157 if (parts[1] === "push" && parts[2] === "prepare" && method === "POST") {2258 return await handlePrepare(request, env);2359 }
@@ -53,6 +89,7 @@ export async function api(
5389 }5490 throw new ApiError(404, "not found");5591 } catch (error) {92+ if (error instanceof NotifyHttpError) return notifyErrorResponse(error);5693 return apiErrorResponse(error, request, url);5794 }5895 }
M worker/src/index.ts +8 -0
A worker/src/notify.ts +336 -0
@@ -0,0 +1,336 @@
1+import type { NotifyRequest, NotifyResponse } from "../../shared/mod.ts";2+import { ApiError } from "./api/errors.ts";3+import type { Env } from "./env.ts";4+import { json } from "./env.ts";5+import { sendWebPush } from "./web_push.ts";6+7+interface DeviceSubscriptionRow {8+ id: string;9+ endpoint: string;10+ p256dh: string;11+ auth: string;12+}13+14+interface DeliveryRow extends DeviceSubscriptionRow {15+ event_id: string;16+ title: string;17+ body: string;18+ path: string | null;19+ tag: string | null;20+ attempts: number;21+}22+23+const RETRY_DELAYS = [300, 900, 3600, 21600] as const;24+25+function scalarLength(value: string): number {26+ return [...value].length;27+}28+29+function boundedString(30+ value: unknown,31+ name: string,32+ min: number,33+ max: number,34+): string {35+ if (typeof value !== "string") {36+ throw new ApiError(400, `${name} must be a string`);37+ }38+ const length = scalarLength(value);39+ if (length < min || length > max) {40+ throw new ApiError(400, `${name} must be ${min}-${max} characters`);41+ }42+ return value;43+}44+45+function normalizePath(value: unknown): string | undefined {46+ if (value === undefined) return undefined;47+ const path = boundedString(value, "path", 1, 128);48+ if (49+ !path.startsWith("/") || path.startsWith("//") || path.includes("\\") ||50+ /[\u0000-\u001f\u007f]/.test(path)51+ ) throw new ApiError(400, "path must be a same-origin absolute path");52+ let decoded: string;53+ try {54+ decoded = decodeURIComponent(path);55+ } catch {56+ throw new ApiError(400, "path has invalid encoding");57+ }58+ if (decoded !== path || !/^\/(?:[0-9a-f]{4})?$/.test(path)) {59+ throw new ApiError(400, "path must be / or an existing nzip site path");60+ }61+ return path;62+}63+64+export function validateNotifyRequest(value: unknown): NotifyRequest {65+ if (!value || typeof value !== "object" || Array.isArray(value)) {66+ throw new ApiError(400, "request body must be an object");67+ }68+ const record = value as Record<string, unknown>;69+ const allowed = new Set(["title", "body", "path", "tag"]);70+ if (Object.keys(record).some((key) => !allowed.has(key))) {71+ throw new ApiError(400, "request contains unknown fields");72+ }73+ const body = boundedString(record.body, "body", 1, 240);74+ const title = record.title === undefined75+ ? undefined76+ : boundedString(record.title, "title", 1, 80);77+ const path = normalizePath(record.path);78+ let tag: string | undefined;79+ if (record.tag !== undefined) {80+ tag = boundedString(record.tag, "tag", 1, 64);81+ if (!/^[A-Za-z0-9._:-]+$/.test(tag)) {82+ throw new ApiError(400, "tag is invalid");83+ }84+ }85+ const request = {86+ body,87+ ...(title ? { title } : {}),88+ ...(path ? { path } : {}),89+ ...(tag ? { tag } : {}),90+ };91+ if (new TextEncoder().encode(JSON.stringify(request)).byteLength > 2048) {92+ throw new ApiError(400, "notification payload is too large");93+ }94+ return request;95+}96+97+async function readJson(request: Request): Promise<unknown> {98+ if (99+ request.headers.get("content-type")?.split(";", 1)[0] !== "application/json"100+ ) {101+ throw new ApiError(415, "content-type must be application/json");102+ }103+ const length = Number(request.headers.get("content-length") ?? "0");104+ if (Number.isFinite(length) && length > 4096) {105+ throw new ApiError(413, "request too large");106+ }107+ const text = await request.text();108+ if (new TextEncoder().encode(text).byteLength > 4096) {109+ throw new ApiError(413, "request too large");110+ }111+ try {112+ return JSON.parse(text);113+ } catch {114+ throw new ApiError(400, "invalid JSON");115+ }116+}117+118+export async function handleNotifySend(119+ request: Request,120+ env: Env,121+ ctx: ExecutionContext,122+): Promise<Response> {123+ if (env.NOTIFY_ENABLED !== "true") {124+ throw new ApiError(503, "notifications are disabled");125+ }126+ const input = validateNotifyRequest(await readJson(request));127+ const now = Math.floor(Date.now() / 1000);128+ let manifestHash: string | null = null;129+ if (input.path && input.path !== "/") {130+ const address = Number.parseInt(input.path.slice(1), 16);131+ const site = await env.DB.prepare(132+ "SELECT current_manifest FROM sites WHERE address = ?",133+ ).bind(address).first<{ current_manifest: string }>();134+ if (!site) throw new ApiError(400, "notification target site not found");135+ manifestHash = site.current_manifest;136+ }137+ const devices = await env.DB.prepare(138+ `SELECT id, endpoint, p256dh, auth FROM notification_devices139+ WHERE status = 'active' AND endpoint IS NOT NULL`,140+ ).all<DeviceSubscriptionRow>();141+ const inactive = await env.DB.prepare(142+ "SELECT COUNT(*) AS count FROM notification_devices WHERE status <> 'active'",143+ ).first<{ count: number }>();144+ const eventId = crypto.randomUUID();145+ const statements = [146+ env.DB.prepare(147+ `INSERT INTO notification_events148+ (id, title, body, path, expected_manifest_hash, tag, created_at)149+ VALUES (?, ?, ?, ?, ?, ?, ?)`,150+ ).bind(151+ eventId,152+ input.title ?? "nzip",153+ input.body,154+ input.path ?? null,155+ manifestHash,156+ input.tag ?? null,157+ now,158+ ),159+ ...devices.results.map((device) =>160+ env.DB.prepare(161+ `INSERT INTO notification_deliveries162+ (event_id, device_id, status, attempts, next_attempt_at)163+ VALUES (?, ?, 'pending', 0, ?)`,164+ ).bind(eventId, device.id, now)165+ ),166+ ];167+ await env.DB.batch(statements);168+ ctx.waitUntil(drainNotifications(env, now));169+ const response: NotifyResponse = {170+ eventId,171+ queuedDevices: devices.results.length,172+ inactiveDevices: inactive?.count ?? 0,173+ };174+ console.log({175+ event: "notify.event_accepted",176+ eventId,177+ queuedDevices: response.queuedDevices,178+ });179+ return json(response, 202);180+}181+182+function classify(status: number): "sent" | "disabled" | "retry" | "failed" {183+ if (status >= 200 && status < 300) return "sent";184+ if (status === 404 || status === 410) return "disabled";185+ if (status === 429 || status >= 500) return "retry";186+ return "failed";187+}188+189+export async function drainNotifications(190+ env: Env,191+ now = Math.floor(Date.now() / 1000),192+): Promise<void> {193+ if (env.NOTIFY_ENABLED !== "true") return;194+ const candidates = await env.DB.prepare(195+ `SELECT d.event_id, d.device_id AS id, d.attempts, e.title, e.body, e.path, e.tag,196+ v.endpoint, v.p256dh, v.auth197+ FROM notification_deliveries d198+ JOIN notification_events e ON e.id = d.event_id199+ JOIN notification_devices v ON v.id = d.device_id AND v.status = 'active'200+ WHERE ((d.status IN ('pending','retry') AND COALESCE(d.next_attempt_at,0) <= ?)201+ OR (d.status = 'sending' AND d.lease_expires_at < ?))202+ ORDER BY COALESCE(d.next_attempt_at,0) LIMIT 25`,203+ ).bind(now, now).all<DeliveryRow>();204+ const owner = crypto.randomUUID();205+ for (const row of candidates.results) {206+ const attemptStartedAt = Math.floor(Date.now() / 1000);207+ const claim = await env.DB.prepare(208+ `UPDATE notification_deliveries SET status='sending', lease_owner=?, lease_expires_at=?209+ WHERE event_id=? AND device_id=? AND210+ ((status IN ('pending','retry') AND COALESCE(next_attempt_at,0) <= ?)211+ OR (status='sending' AND lease_expires_at < ?))`,212+ ).bind(213+ owner,214+ attemptStartedAt + 60,215+ row.event_id,216+ row.id,217+ attemptStartedAt,218+ attemptStartedAt,219+ ).run();220+ if (claim.meta.changes !== 1) continue;221+ let outcome: "sent" | "disabled" | "retry" | "failed" = "retry";222+ let errorClass = "network";223+ let retryAfter: number | null = null;224+ try {225+ const result = await sendWebPush(env, {226+ endpoint: row.endpoint,227+ expirationTime: null,228+ keys: { p256dh: row.p256dh, auth: row.auth },229+ }, {230+ eventId: row.event_id,231+ title: row.title,232+ body: row.body,233+ ...(row.path ? { path: row.path } : {}),234+ ...(row.tag ? { tag: row.tag } : {}),235+ });236+ outcome = classify(result.status);237+ errorClass = `http_${result.status}`;238+ retryAfter = result.retryAfter;239+ } catch {240+ outcome = "retry";241+ }242+ const attempts = row.attempts + 1;243+ const completedAt = Math.floor(Date.now() / 1000);244+ if (outcome === "disabled") {245+ const terminal = await env.DB.prepare(246+ `UPDATE notification_deliveries SET status='failed', attempts=?, last_error=?, lease_owner=NULL,247+ lease_expires_at=NULL WHERE event_id=? AND device_id=? AND status='sending' AND lease_owner=?`,248+ ).bind(attempts, errorClass, row.event_id, row.id, owner).run();249+ if (terminal.meta.changes === 1) {250+ await env.DB.prepare(251+ `UPDATE notification_devices SET status='disabled', endpoint=NULL, endpoint_hash=NULL,252+ p256dh=NULL, auth=NULL, last_error=? WHERE id=? AND status='active'`,253+ ).bind(errorClass, row.id).run();254+ }255+ } else if (outcome === "sent") {256+ const terminal = await env.DB.prepare(257+ `UPDATE notification_deliveries SET status='sent', attempts=?, sent_at=?, last_error=NULL,258+ lease_owner=NULL, lease_expires_at=NULL WHERE event_id=? AND device_id=? AND status='sending' AND lease_owner=?`,259+ ).bind(attempts, completedAt, row.event_id, row.id, owner).run();260+ if (terminal.meta.changes === 1) {261+ await env.DB.prepare(262+ "UPDATE notification_devices SET last_success_at=?, last_error=NULL WHERE id=? AND status='active'",263+ ).bind(completedAt, row.id).run();264+ }265+ } else if (outcome === "retry" && attempts < 5) {266+ const delay = retryAfter ??267+ RETRY_DELAYS[Math.min(attempts - 1, RETRY_DELAYS.length - 1)];268+ await env.DB.prepare(269+ `UPDATE notification_deliveries SET status='retry', attempts=?, next_attempt_at=?, last_error=?,270+ lease_owner=NULL, lease_expires_at=NULL WHERE event_id=? AND device_id=? AND status='sending' AND lease_owner=?`,271+ ).bind(272+ attempts,273+ completedAt + delay,274+ errorClass,275+ row.event_id,276+ row.id,277+ owner,278+ )279+ .run();280+ } else {281+ await env.DB.prepare(282+ `UPDATE notification_deliveries SET status='failed', attempts=?, last_error=?, lease_owner=NULL,283+ lease_expires_at=NULL WHERE event_id=? AND device_id=? AND status='sending' AND lease_owner=?`,284+ ).bind(attempts, errorClass, row.event_id, row.id, owner).run();285+ }286+ console.log({287+ event: `notify.delivery_${outcome}`,288+ eventId: row.event_id,289+ deviceId: row.id,290+ attempts,291+ });292+ }293+}294+295+export async function validateClickTarget(296+ env: Env,297+ eventId: string,298+): Promise<string | null> {299+ const event = await env.DB.prepare(300+ `SELECT path, expected_manifest_hash FROM notification_events301+ WHERE id=? AND created_at >= ?`,302+ ).bind(eventId, Math.floor(Date.now() / 1000) - 7 * 86400).first<{303+ path: string | null;304+ expected_manifest_hash: string | null;305+ }>();306+ if (!event?.path || event.path === "/") return event?.path ?? null;307+ const site = await env.DB.prepare(308+ "SELECT current_manifest FROM sites WHERE address=?",309+ )310+ .bind(Number.parseInt(event.path.slice(1), 16)).first<311+ { current_manifest: string }312+ >();313+ return site?.current_manifest === event.expected_manifest_hash314+ ? event.path315+ : null;316+}317+318+export async function pruneNotifications(319+ env: Env,320+ now = Math.floor(Date.now() / 1000),321+): Promise<void> {322+ await env.DB.batch([323+ env.DB.prepare("DELETE FROM notification_events WHERE created_at < ?").bind(324+ now - 7 * 86400,325+ ),326+ env.DB.prepare(327+ "UPDATE notification_devices SET status='expired', claim_hash=NULL, pairing_code_hash=NULL, last_seen_at=? WHERE status='pending' AND pairing_expires_at < ?",328+ ).bind(now, now),329+ env.DB.prepare(330+ "UPDATE notification_devices SET status='expired', claim_hash=NULL, pairing_code_hash=NULL, last_seen_at=? WHERE status='approved' AND active_at IS NULL AND claim_expires_at < ?",331+ ).bind(now, now),332+ env.DB.prepare(333+ "DELETE FROM notification_devices WHERE status IN ('revoked','expired') AND last_seen_at < ?",334+ ).bind(now - 7 * 86400),335+ ]);336+}
A worker/src/notify_enrollment.ts +730 -0
@@ -0,0 +1,730 @@
1+import type { Env } from "./env.ts";2+import { json } from "./env.ts";3+import { validatePushEndpoint as validateWebPushEndpoint } from "./web_push.ts";4+5+const CLAIM_COOKIE = "__Host-nzip-notify";6+const PENDING_SECONDS = 10 * 60;7+const APPROVED_SETUP_SECONDS = 24 * 60 * 60;8+const CLAIM_SECONDS = 365 * 24 * 60 * 60;9+const RENEW_WINDOW_SECONDS = 90 * 24 * 60 * 60;10+const MAX_PENDING = 32;11+const MAX_JSON_BYTES = 8 * 1024;12+const CODE_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";13+const CODE_RE = /^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/;14+15+export type NotifyEnv = Env & {16+ NOTIFY_ENABLED?: string;17+ VAPID_PUBLIC_KEY?: string;18+ WEB_PUSH_ORIGINS?: string;19+ RL_NOTIFY_ENROLL?: RateLimit;20+ RL_NOTIFY_READ?: RateLimit;21+};22+23+interface DeviceRow {24+ id: string;25+ status: string;26+ name: string | null;27+ user_agent_summary: string | null;28+ device_class: string | null;29+ country: string | null;30+ region: string | null;31+ asn: number | null;32+ created_at: number;33+ pairing_expires_at: number | null;34+ claim_expires_at: number | null;35+ approved_at: number | null;36+ active_at: number | null;37+ last_attached_at: number | null;38+ last_seen_at: number | null;39+ last_success_at: number | null;40+ last_error: string | null;41+ endpoint_hash?: string | null;42+ p256dh?: string | null;43+ auth?: string | null;44+}45+46+export class NotifyHttpError extends Error {47+ status: number;48+ headers: HeadersInit;49+50+ constructor(51+ status: number,52+ message: string,53+ headers: HeadersInit = {},54+ ) {55+ super(message);56+ this.status = status;57+ this.headers = headers;58+ }59+}60+61+function nowSeconds(): number {62+ return Math.floor(Date.now() / 1000);63+}64+65+function randomBase64Url(bytes: number): string {66+ const value = crypto.getRandomValues(new Uint8Array(bytes));67+ let binary = "";68+ for (const byte of value) binary += String.fromCharCode(byte);69+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(70+ /=+$/,71+ "",72+ );73+}74+75+export function generatePairingCode(): string {76+ const bytes = crypto.getRandomValues(new Uint8Array(8));77+ let code = "";78+ for (const byte of bytes) code += CODE_ALPHABET[byte % CODE_ALPHABET.length];79+ return `${code.slice(0, 4)}-${code.slice(4)}`;80+}81+82+export function normalizePairingCode(value: string): string | null {83+ const compact = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");84+ if (compact.length !== 8) return null;85+ const normalized = `${compact.slice(0, 4)}-${compact.slice(4)}`;86+ return CODE_RE.test(normalized) ? normalized : null;87+}88+89+async function hmacHex(90+ secret: string,91+ purpose: string,92+ value: string,93+): Promise<string> {94+ const encoder = new TextEncoder();95+ const key = await crypto.subtle.importKey(96+ "raw",97+ encoder.encode(secret),98+ { name: "HMAC", hash: "SHA-256" },99+ false,100+ ["sign"],101+ );102+ const digest = new Uint8Array(103+ await crypto.subtle.sign(104+ "HMAC",105+ key,106+ encoder.encode(`${purpose}\0${value}`),107+ ),108+ );109+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");110+}111+112+function signingSecret(env: NotifyEnv): string {113+ if (!env.NZIP_TOKEN) {114+ throw new NotifyHttpError(503, "notifications unavailable");115+ }116+ return env.NZIP_TOKEN;117+}118+119+export function claimCookie(value: string, maxAge: number): string {120+ return `${CLAIM_COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;121+}122+123+function clearClaimCookie(): string {124+ return `${CLAIM_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;125+}126+127+export function readClaimCookie(request: Request): string | null {128+ const cookie = request.headers.get("cookie");129+ if (!cookie) return null;130+ for (const entry of cookie.split(";")) {131+ const [name, ...parts] = entry.trim().split("=");132+ if (name === CLAIM_COOKIE) {133+ const value = parts.join("=");134+ return /^[A-Za-z0-9_-]{40,64}$/.test(value) ? value : null;135+ }136+ }137+ return null;138+}139+140+export function isSameOriginBrowserRequest(request: Request): boolean {141+ const url = new URL(request.url);142+ const origin = request.headers.get("origin");143+ if (origin !== null && origin !== url.origin) return false;144+ const site = request.headers.get("sec-fetch-site");145+ if (site !== null && site !== "same-origin" && site !== "none") return false;146+ return origin !== null || site !== null;147+}148+149+function boundedText(value: unknown, max: number): string | null {150+ if (typeof value !== "string") return null;151+ const normalized = value.trim();152+ if (153+ !normalized || [...normalized].length > max ||154+ /[\u0000-\u001f\u007f]/.test(normalized)155+ ) {156+ return null;157+ }158+ return normalized;159+}160+161+export async function readNotifyJsonObject(162+ request: Request,163+): Promise<Record<string, unknown>> {164+ const type = request.headers.get("content-type")?.split(";", 1)[0].trim()165+ .toLowerCase();166+ if (type !== "application/json") {167+ throw new NotifyHttpError(415, "application/json required");168+ }169+ const declared = request.headers.get("content-length");170+ if (171+ declared !== null &&172+ (!/^\d+$/.test(declared) || Number(declared) > MAX_JSON_BYTES)173+ ) {174+ throw new NotifyHttpError(413, "request too large");175+ }176+ const bytes = new Uint8Array(await request.arrayBuffer());177+ if (bytes.byteLength > MAX_JSON_BYTES) {178+ throw new NotifyHttpError(413, "request too large");179+ }180+ try {181+ const value: unknown = JSON.parse(new TextDecoder().decode(bytes));182+ if (typeof value !== "object" || value === null || Array.isArray(value)) {183+ throw new Error("not object");184+ }185+ return value as Record<string, unknown>;186+ } catch {187+ throw new NotifyHttpError(400, "invalid json");188+ }189+}190+191+function userAgentSummary(request: Request): string {192+ return (request.headers.get("user-agent") ?? "unknown").replace(193+ /[\u0000-\u001f\u007f]/g,194+ " ",195+ )196+ .slice(0, 200);197+}198+199+function deviceClass(userAgent: string): string {200+ if (/iPhone|iPad|iPod/i.test(userAgent)) return "apple-mobile";201+ if (/Android/i.test(userAgent)) return "android";202+ if (/Mobile/i.test(userAgent)) return "mobile";203+ return "desktop";204+}205+206+function cfMetadata(207+ request: Request,208+): { country: string | null; region: string | null; asn: number | null } {209+ const cf = request.cf;210+ return {211+ country: typeof cf?.country === "string" ? cf.country.slice(0, 2) : null,212+ region: typeof cf?.regionCode === "string"213+ ? cf.regionCode.slice(0, 8)214+ : null,215+ asn: typeof cf?.asn === "number" ? cf.asn : null,216+ };217+}218+219+function publicHeaders(extra: HeadersInit = {}): HeadersInit {220+ return {221+ "cache-control": "no-store",222+ "vary": "Cookie",223+ ...extra,224+ };225+}226+227+function publicJson(228+ body: unknown,229+ status = 200,230+ headers: HeadersInit = {},231+): Response {232+ return json(body, status, publicHeaders(headers));233+}234+235+async function rateLimit(236+ binding: RateLimit | undefined,237+ key: string,238+): Promise<void> {239+ if (!binding) return;240+ const result = await binding.limit({ key });241+ if (!result.success) {242+ throw new NotifyHttpError(429, "rate limited", { "retry-after": "60" });243+ }244+}245+246+function clientKey(request: Request): string {247+ return request.headers.get("cf-connecting-ip") ?? "local";248+}249+250+export async function createEnrollment(251+ request: Request,252+ env: NotifyEnv,253+): Promise<Response> {254+ if (!isSameOriginBrowserRequest(request)) {255+ throw new NotifyHttpError(403, "same-origin request required");256+ }257+ await rateLimit(env.RL_NOTIFY_ENROLL, clientKey(request));258+ await readNotifyJsonObject(request);259+260+ const now = nowSeconds();261+ await env.DB.prepare(262+ "UPDATE notification_devices SET status = 'expired', pairing_code_hash = NULL, claim_hash = NULL WHERE status = 'pending' AND pairing_expires_at <= ?",263+ ).bind(now).run();264+265+ const secret = signingSecret(env);266+ const claim = randomBase64Url(32);267+ const claimHash = await hmacHex(secret, "notify-claim", claim);268+ const userAgent = userAgentSummary(request);269+ const metadata = cfMetadata(request);270+271+ for (let attempt = 0; attempt < 8; attempt++) {272+ const id = crypto.randomUUID();273+ const code = generatePairingCode();274+ const codeHash = await hmacHex(secret, "notify-code", code);275+ try {276+ const result = await env.DB.prepare(277+ `INSERT INTO notification_devices278+ (id, pairing_code_hash, claim_hash, status, user_agent_summary, device_class,279+ country, region, asn, created_at, pairing_expires_at, claim_expires_at)280+ SELECT ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?281+ WHERE (SELECT COUNT(*) FROM notification_devices282+ WHERE status = 'pending' AND pairing_expires_at > ?) < ?`,283+ ).bind(284+ id,285+ codeHash,286+ claimHash,287+ userAgent,288+ deviceClass(userAgent),289+ metadata.country,290+ metadata.region,291+ metadata.asn,292+ now,293+ now + PENDING_SECONDS,294+ now + PENDING_SECONDS,295+ now,296+ MAX_PENDING,297+ ).run();298+ if (result.meta.changes !== 1) {299+ throw new NotifyHttpError(429, "pairing temporarily unavailable", {300+ "retry-after": "60",301+ });302+ }303+ console.log(304+ JSON.stringify({ event: "notify.enrollment_created", deviceId: id }),305+ );306+ return publicJson(307+ { code, status: "pending", expiresAt: now + PENDING_SECONDS },308+ 201,309+ { "set-cookie": claimCookie(claim, PENDING_SECONDS) },310+ );311+ } catch (error) {312+ if (error instanceof NotifyHttpError) throw error;313+ if (314+ !/unique|constraint/i.test(315+ error instanceof Error ? error.message : String(error),316+ )317+ ) throw error;318+ }319+ }320+ throw new NotifyHttpError(503, "pairing temporarily unavailable", {321+ "retry-after": "60",322+ });323+}324+325+async function currentDevice(326+ request: Request,327+ env: NotifyEnv,328+): Promise<{ row: DeviceRow; claim: string } | null> {329+ const claim = readClaimCookie(request);330+ if (!claim) return null;331+ const claimHash = await hmacHex(signingSecret(env), "notify-claim", claim);332+ const row = await env.DB.prepare(333+ `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,334+ created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,335+ last_attached_at, last_seen_at, last_success_at, last_error,336+ endpoint_hash, p256dh, auth337+ FROM notification_devices WHERE claim_hash = ?`,338+ ).bind(claimHash).first<DeviceRow>();339+ return row ? { row, claim } : null;340+}341+342+export async function authorizeNotificationClaim(343+ request: Request,344+ env: NotifyEnv,345+): Promise<boolean> {346+ const current = await currentDevice(request, env);347+ const now = nowSeconds();348+ return current !== null &&349+ ["approved", "active"].includes(current.row.status) &&350+ (current.row.claim_expires_at ?? 0) > now;351+}352+353+function publicState(row: DeviceRow, now: number): Record<string, unknown> {354+ if (row.status === "pending" && (row.pairing_expires_at ?? 0) <= now) {355+ return { status: "expired" };356+ }357+ if (["revoked", "expired", "disabled"].includes(row.status)) {358+ return { status: row.status };359+ }360+ return {361+ status: row.status,362+ paired: row.status === "approved" || row.status === "active",363+ notifications: row.status === "active" ? "on" : "off",364+ claimExpiresAt: row.claim_expires_at,365+ };366+}367+368+export async function getCurrentEnrollment(369+ request: Request,370+ env: NotifyEnv,371+): Promise<Response> {372+ if (!isSameOriginBrowserRequest(request)) {373+ throw new NotifyHttpError(403, "same-origin request required");374+ }375+ const current = await currentDevice(request, env);376+ if (!current) return publicJson({ status: "unpaired" });377+ await rateLimit(env.RL_NOTIFY_READ, current.row.id);378+ const now = nowSeconds();379+ if (380+ (current.row.claim_expires_at ?? 0) <= now &&381+ current.row.status !== "pending"382+ ) {383+ return publicJson({ status: "expired" }, 200, {384+ "set-cookie": clearClaimCookie(),385+ });386+ }387+ return publicJson(publicState(current.row, now));388+}389+390+export async function activateEnrollment(391+ request: Request,392+ env: NotifyEnv,393+): Promise<Response> {394+ if (!isSameOriginBrowserRequest(request)) {395+ throw new NotifyHttpError(403, "same-origin request required");396+ }397+ await readNotifyJsonObject(request);398+ const current = await currentDevice(request, env);399+ const now = nowSeconds();400+ if (401+ !current || current.row.status !== "approved" ||402+ (current.row.claim_expires_at ?? 0) <= now403+ ) {404+ throw new NotifyHttpError(404, "pairing unavailable");405+ }406+ const expiry = now + CLAIM_SECONDS;407+ const result = await env.DB.prepare(408+ "UPDATE notification_devices SET claim_expires_at = ?, last_seen_at = ? WHERE id = ? AND status = 'approved'",409+ ).bind(expiry, now, current.row.id).run();410+ if (result.meta.changes !== 1) {411+ throw new NotifyHttpError(404, "pairing unavailable");412+ }413+ return publicJson(414+ {415+ status: "approved",416+ paired: true,417+ notifications: "off",418+ claimExpiresAt: expiry,419+ },420+ 200,421+ { "set-cookie": claimCookie(current.claim, CLAIM_SECONDS) },422+ );423+}424+425+export async function renewEnrollment(426+ request: Request,427+ env: NotifyEnv,428+): Promise<Response> {429+ if (!isSameOriginBrowserRequest(request)) {430+ throw new NotifyHttpError(403, "same-origin request required");431+ }432+ await readNotifyJsonObject(request);433+ const current = await currentDevice(request, env);434+ const now = nowSeconds();435+ if (436+ !current || !["approved", "active"].includes(current.row.status) ||437+ (current.row.claim_expires_at ?? 0) <= now438+ ) {439+ throw new NotifyHttpError(404, "pairing unavailable");440+ }441+ const remaining = (current.row.claim_expires_at ?? 0) - now;442+ if (remaining >= RENEW_WINDOW_SECONDS) {443+ return publicJson(publicState(current.row, now));444+ }445+ const expiry = now + CLAIM_SECONDS;446+ await env.DB.prepare(447+ "UPDATE notification_devices SET claim_expires_at = ?, last_seen_at = ? WHERE id = ? AND status IN ('approved', 'active')",448+ ).bind(expiry, now, current.row.id).run();449+ return publicJson(450+ { ...publicState(current.row, now), claimExpiresAt: expiry },451+ 200,452+ { "set-cookie": claimCookie(current.claim, CLAIM_SECONDS) },453+ );454+}455+456+interface SubscriptionBody {457+ endpoint: string;458+ expirationTime: number | null;459+ keys: { p256dh: string; auth: string };460+}461+462+export function validateSubscriptionEndpoint(463+ endpoint: string,464+ env: NotifyEnv,465+): URL {466+ try {467+ return validateWebPushEndpoint(env, endpoint);468+ } catch (_error) {469+ throw new NotifyHttpError(400, "invalid subscription");470+ }471+}472+473+function validSubscription(474+ value: Record<string, unknown>,475+): SubscriptionBody | null {476+ const endpoint = boundedText(value.endpoint, 2048);477+ const keys = value.keys;478+ const expirationTime = value.expirationTime;479+ if (480+ !endpoint || typeof keys !== "object" || keys === null ||481+ Array.isArray(keys) ||482+ !(expirationTime === null ||483+ (typeof expirationTime === "number" &&484+ Number.isSafeInteger(expirationTime)))485+ ) {486+ return null;487+ }488+ const typedKeys = keys as Record<string, unknown>;489+ const p256dh = boundedText(typedKeys.p256dh, 256);490+ const auth = boundedText(typedKeys.auth, 128);491+ if (492+ !p256dh || !auth || !/^[A-Za-z0-9_-]+$/.test(p256dh) ||493+ !/^[A-Za-z0-9_-]+$/.test(auth)494+ ) {495+ return null;496+ }497+ return { endpoint, expirationTime, keys: { p256dh, auth } };498+}499+500+export async function attachSubscription(501+ request: Request,502+ env: NotifyEnv,503+): Promise<Response> {504+ if (!isSameOriginBrowserRequest(request)) {505+ throw new NotifyHttpError(403, "same-origin request required");506+ }507+ const body = validSubscription(await readNotifyJsonObject(request));508+ if (!body) throw new NotifyHttpError(400, "invalid subscription");509+ const endpoint = validateSubscriptionEndpoint(body.endpoint, env).toString();510+ const current = await currentDevice(request, env);511+ const now = nowSeconds();512+ if (513+ !current || !["approved", "active"].includes(current.row.status) ||514+ (current.row.claim_expires_at ?? 0) <= now515+ ) {516+ throw new NotifyHttpError(404, "pairing unavailable");517+ }518+ const endpointHash = await hmacHex(519+ signingSecret(env),520+ "notify-endpoint",521+ endpoint,522+ );523+ if (524+ current.row.endpoint_hash === endpointHash &&525+ current.row.p256dh === body.keys.p256dh &&526+ current.row.auth === body.keys.auth527+ ) {528+ await env.DB.prepare(529+ "UPDATE notification_devices SET last_seen_at = ? WHERE id = ?",530+ ).bind(531+ now,532+ current.row.id,533+ ).run();534+ } else {535+ await env.DB.batch([536+ env.DB.prepare(537+ `UPDATE notification_devices SET status = 'disabled', endpoint = NULL,538+ endpoint_hash = NULL, p256dh = NULL, auth = NULL, last_error = 'endpoint_reassigned'539+ WHERE endpoint_hash = ? AND id <> ?`,540+ ).bind(endpointHash, current.row.id),541+ env.DB.prepare(542+ `UPDATE notification_devices SET status = 'active', endpoint = ?, endpoint_hash = ?,543+ p256dh = ?, auth = ?, active_at = COALESCE(active_at, ?), last_attached_at = ?,544+ last_seen_at = ?, last_error = NULL545+ WHERE id = ? AND status IN ('approved', 'active')`,546+ ).bind(547+ endpoint,548+ endpointHash,549+ body.keys.p256dh,550+ body.keys.auth,551+ now,552+ now,553+ now,554+ current.row.id,555+ ),556+ ]);557+ }558+ console.log(559+ JSON.stringify({560+ event: "notify.subscription_attached",561+ deviceId: current.row.id,562+ }),563+ );564+ return publicJson({ status: "active", paired: true, notifications: "on" });565+}566+567+export async function detachSubscription(568+ request: Request,569+ env: NotifyEnv,570+): Promise<Response> {571+ if (!isSameOriginBrowserRequest(request)) {572+ throw new NotifyHttpError(403, "same-origin request required");573+ }574+ const current = await currentDevice(request, env);575+ const now = nowSeconds();576+ if (577+ !current || !["approved", "active"].includes(current.row.status) ||578+ (current.row.claim_expires_at ?? 0) <= now579+ ) {580+ throw new NotifyHttpError(404, "pairing unavailable");581+ }582+ await env.DB.prepare(583+ `UPDATE notification_devices SET status = 'approved', endpoint = NULL,584+ endpoint_hash = NULL, p256dh = NULL, auth = NULL, last_seen_at = ?, last_error = NULL585+ WHERE id = ? AND status IN ('approved', 'active')`,586+ ).bind(now, current.row.id).run();587+ return publicJson({ status: "approved", paired: true, notifications: "off" });588+}589+590+async function codeHash(env: NotifyEnv, raw: string): Promise<string | null> {591+ const code = normalizePairingCode(raw);592+ return code ? await hmacHex(signingSecret(env), "notify-code", code) : null;593+}594+595+function genericApprovalError(): never {596+ throw new NotifyHttpError(404, "pairing code unavailable");597+}598+599+function ownerDevice(row: DeviceRow): Record<string, unknown> {600+ return {601+ id: row.id,602+ name: row.name,603+ status: row.status,604+ createdAt: row.created_at,605+ pairingExpiresAt: row.pairing_expires_at,606+ claimExpiresAt: row.claim_expires_at,607+ approvedAt: row.approved_at,608+ deviceClass: row.device_class,609+ userAgentSummary: row.user_agent_summary,610+ country: row.country,611+ region: row.region,612+ asn: row.asn,613+ activeAt: row.active_at,614+ lastSeenAt: row.last_seen_at,615+ lastAttachedAt: row.last_attached_at,616+ lastSuccessAt: row.last_success_at,617+ lastError: row.last_error,618+ };619+}620+621+export async function previewApproval(622+ rawCode: string,623+ env: NotifyEnv,624+): Promise<Response> {625+ const hash = await codeHash(env, rawCode);626+ if (!hash) return genericApprovalError();627+ const now = nowSeconds();628+ const row = await env.DB.prepare(629+ `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,630+ created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,631+ last_attached_at, last_seen_at, last_success_at, last_error632+ FROM notification_devices633+ WHERE pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?`,634+ ).bind(hash, now).first<DeviceRow>();635+ if (!row) return genericApprovalError();636+ return json(637+ {638+ createdAt: row.created_at,639+ userAgentSummary: row.user_agent_summary,640+ deviceClass: row.device_class,641+ country: row.country,642+ region: row.region,643+ asn: row.asn,644+ },645+ 200,646+ { "cache-control": "no-store" },647+ );648+}649+650+export async function approveEnrollment(651+ request: Request,652+ env: NotifyEnv,653+): Promise<Response> {654+ const body = await readNotifyJsonObject(request);655+ const rawCode = boundedText(body.code, 32);656+ const name = boundedText(body.name, 80);657+ if (!rawCode || !name) {658+ throw new NotifyHttpError(400, "code and name required");659+ }660+ const hash = await codeHash(env, rawCode);661+ if (!hash) return genericApprovalError();662+ const now = nowSeconds();663+ const pending = await env.DB.prepare(664+ "SELECT id FROM notification_devices WHERE pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?",665+ ).bind(hash, now).first<{ id: string }>();666+ if (!pending) return genericApprovalError();667+ const result = await env.DB.prepare(668+ `UPDATE notification_devices SET status = 'approved', name = ?, pairing_code_hash = NULL,669+ approved_at = ?, claim_expires_at = ?670+ WHERE id = ? AND pairing_code_hash = ? AND status = 'pending' AND pairing_expires_at > ?`,671+ ).bind(name, now, now + APPROVED_SETUP_SECONDS, pending.id, hash, now).run();672+ if (result.meta.changes !== 1) return genericApprovalError();673+ const row = await env.DB.prepare(674+ `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,675+ created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,676+ last_attached_at, last_seen_at, last_success_at, last_error677+ FROM notification_devices WHERE id = ?`,678+ ).bind(pending.id).first<DeviceRow>();679+ if (!row) throw new NotifyHttpError(500, "internal error");680+ console.log(JSON.stringify({ event: "notify.device_approved" }));681+ return json(ownerDevice(row), 200, { "cache-control": "no-store" });682+}683+684+export async function listNotificationDevices(685+ env: NotifyEnv,686+): Promise<Response> {687+ const rows = await env.DB.prepare(688+ `SELECT id, status, name, user_agent_summary, device_class, country, region, asn,689+ created_at, pairing_expires_at, claim_expires_at, approved_at, active_at,690+ last_attached_at, last_seen_at, last_success_at, last_error691+ FROM notification_devices ORDER BY created_at DESC LIMIT 100`,692+ ).all<DeviceRow>();693+ return json(rows.results.map(ownerDevice), 200, {694+ "cache-control": "no-store",695+ });696+}697+698+export async function revokeNotificationDevice(699+ id: string,700+ env: NotifyEnv,701+): Promise<Response> {702+ if (!/^[0-9a-f-]{36}$/i.test(id)) {703+ throw new NotifyHttpError(404, "device not found");704+ }705+ const result = await env.DB.prepare(706+ `UPDATE notification_devices SET status = 'revoked', pairing_code_hash = NULL,707+ claim_hash = NULL, endpoint = NULL, endpoint_hash = NULL, p256dh = NULL, auth = NULL,708+ last_error = NULL, last_seen_at = ? WHERE id = ? AND status <> 'revoked'`,709+ ).bind(Math.floor(Date.now() / 1000), id).run();710+ if (result.meta.changes !== 1) {711+ throw new NotifyHttpError(404, "device not found");712+ }713+ console.log(JSON.stringify({ event: "notify.device_revoked", deviceId: id }));714+ return json({ ok: true, deviceId: id }, 200, { "cache-control": "no-store" });715+}716+717+export function notifyErrorResponse(error: unknown): Response {718+ if (error instanceof NotifyHttpError) {719+ return json({ error: error.message }, error.status, {720+ "cache-control": "no-store",721+ ...error.headers,722+ });723+ }724+ console.error(725+ JSON.stringify({ event: "notify.request_failed", error: "internal" }),726+ );727+ return json({ error: "internal error" }, 500, {728+ "cache-control": "no-store",729+ });730+}
A worker/src/notify_public.ts +77 -0
@@ -0,0 +1,77 @@
1+import {2+ activateEnrollment,3+ attachSubscription,4+ authorizeNotificationClaim,5+ createEnrollment,6+ detachSubscription,7+ getCurrentEnrollment,8+ type NotifyEnv,9+ notifyErrorResponse,10+ readNotifyJsonObject,11+ renewEnrollment,12+} from "./notify_enrollment.ts";13+import { json } from "./env.ts";14+import { validateClickTarget } from "./notify.ts";15+import { notifyAssetResponse } from "./notify_ui.ts";16+17+/**18+ * Claim-cookie authenticated notification router. This must be called before19+ * the ordinary public GET/HEAD gate, and must never sit under `/api/*`.20+ */21+export async function handleNotifyPublic(22+ request: Request,23+ env: NotifyEnv,24+ url = new URL(request.url),25+): Promise<Response> {26+ try {27+ const method = request.method;28+ const path = url.pathname;29+30+ if (method === "GET" || method === "HEAD") {31+ const asset = notifyAssetResponse(request, env, url);32+ if (asset) return asset;33+ }34+ if (path === "/_notify/enrollments" && method === "POST") {35+ return await createEnrollment(request, env);36+ }37+ if (path === "/_notify/enrollments/current" && method === "GET") {38+ return await getCurrentEnrollment(request, env);39+ }40+ if (path === "/_notify/enrollments/activate" && method === "POST") {41+ return await activateEnrollment(request, env);42+ }43+ if (path === "/_notify/enrollments/renew" && method === "POST") {44+ return await renewEnrollment(request, env);45+ }46+ if (path === "/_notify/subscriptions" && method === "POST") {47+ return await attachSubscription(request, env);48+ }49+ if (path === "/_notify/subscriptions/current" && method === "DELETE") {50+ return await detachSubscription(request, env);51+ }52+ if (path === "/_notify/click-target" && method === "POST") {53+ if (!await authorizeNotificationClaim(request, env)) {54+ return json({ error: "pairing unavailable" }, 404, {55+ "cache-control": "no-store",56+ });57+ }58+ const body = await readNotifyJsonObject(request);59+ if (60+ typeof body.eventId !== "string" ||61+ !/^[0-9a-f-]{36}$/i.test(body.eventId)62+ ) {63+ return json({ error: "invalid event" }, 400, {64+ "cache-control": "no-store",65+ });66+ }67+ const target = await validateClickTarget(env, body.eventId);68+ return json(target ? { path: target } : { expired: true }, 200, {69+ "cache-control": "no-store",70+ "vary": "Cookie",71+ });72+ }73+ return json({ error: "not found" }, 404, { "cache-control": "no-store" });74+ } catch (error) {75+ return notifyErrorResponse(error);76+ }77+}
A worker/src/notify_ui.ts +365 -0
@@ -0,0 +1,365 @@
1+import type { NotifyEnv } from "./notify_enrollment.ts";2+3+const APP_MANIFEST = JSON.stringify({4+ id: "/",5+ name: "nzip",6+ short_name: "nzip",7+ description: "Personal nzip notifications",8+ start_url: "/",9+ scope: "/",10+ display: "standalone",11+ background_color: "#1e1810",12+ theme_color: "#1e1810",13+ icons: [{14+ src: "/_notify/icon.svg",15+ sizes: "any",16+ type: "image/svg+xml",17+ purpose: "any",18+ }],19+});20+21+const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">22+<rect width="512" height="512" rx="96" fill="#1e1810"/>23+<text x="256" y="310" text-anchor="middle" font-family="ui-monospace,monospace" font-size="190" font-weight="700" fill="#f2e8d4">n<tspan fill="#ff6b4a">z</tspan>ip</text>24+</svg>`;25+26+const SERVICE_WORKER = String.raw`self.addEventListener("push", (event) => {27+ let payload = {};28+ try { payload = event.data ? event.data.json() : {}; } catch (_) {}29+ const title = typeof payload.title === "string" ? payload.title : "nzip";30+ const body = typeof payload.body === "string" ? payload.body : "Notification received";31+ const options = {32+ body,33+ tag: typeof payload.tag === "string" ? payload.tag : undefined,34+ icon: "/_notify/icon.svg",35+ badge: "/_notify/icon.svg",36+ data: {37+ eventId: typeof payload.eventId === "string" ? payload.eventId : null,38+ hasPath: typeof payload.path === "string"39+ }40+ };41+ const visible = self.registration.showNotification(title, options);42+ const renew = fetch("/_notify/enrollments/renew", {43+ method: "POST",44+ credentials: "include",45+ headers: { "content-type": "application/json" },46+ body: "{}"47+ }).catch(() => undefined);48+ event.waitUntil(Promise.all([visible, renew]));49+});50+51+self.addEventListener("notificationclick", (event) => {52+ event.notification.close();53+ event.waitUntil((async () => {54+ let path = "/";55+ const data = event.notification.data || {};56+ if (data.hasPath && data.eventId) {57+ try {58+ const response = await fetch("/_notify/click-target", {59+ method: "POST",60+ credentials: "include",61+ headers: { "content-type": "application/json" },62+ body: JSON.stringify({ eventId: data.eventId })63+ });64+ if (response.ok) {65+ const result = await response.json();66+ if (typeof result.path === "string" && result.path.startsWith("/")) path = result.path;67+ else path = "/?notify=link-expired";68+ } else path = "/?notify=link-expired";69+ } catch (_) { path = "/?notify=link-expired"; }70+ }71+ const absolute = new URL(path, self.location.origin).href;72+ const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });73+ for (const client of windows) {74+ if (client.url === absolute && "focus" in client) return client.focus();75+ }76+ return clients.openWindow(path);77+ })());78+});`;79+80+const APP_SCRIPT = String.raw`(() => {81+ const footer = document.querySelector("footer");82+ const status = document.getElementById("notify-status");83+ const pair = document.getElementById("pair");84+ const isStandalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;85+ let state = "unpaired";86+ let polling = 0;87+ let operation = false;88+89+ const api = async (path, options = {}) => {90+ const response = await fetch(path, { credentials: "same-origin", ...options });91+ const data = await response.json().catch(() => ({}));92+ if (!response.ok) throw new Error(data.error || "request failed");93+ return data;94+ };95+ const post = (path, body = {}) => api(path, {96+ method: "POST",97+ headers: { "content-type": "application/json" },98+ body: JSON.stringify(body)99+ });100+ const setStatus = (text) => { status.textContent = text || ""; };101+ const button = (id, text) => {102+ const value = document.createElement("button");103+ value.id = id;104+ value.type = "button";105+ value.textContent = text;106+ return value;107+ };108+ const render = (next) => {109+ state = next.status || state;110+ footer.replaceChildren();111+ const args = document.createElement("a");112+ args.href = "https://args.io/cat/nzip";113+ args.textContent = "args";114+ footer.append(args);115+ if (state === "unpaired" || state === "pending") {116+ footer.append(" · ", button("pair", "pair"));117+ document.getElementById("pair").addEventListener("click", beginPairing);118+ return;119+ }120+ if (state === "approved" || state === "active") {121+ const paired = document.createElement("span");122+ paired.textContent = "paired";123+ footer.append(" · ", paired);124+ if (isStandalone) {125+ const on = state === "active" || next.notifications === "on";126+ const toggle = button("notify-toggle", on ? "notifications on" : "notifications off");127+ toggle.addEventListener("click", () => toggleNotifications(on));128+ footer.append(" · ", toggle);129+ }130+ }131+ };132+ const installAssets = async () => {133+ if (!document.querySelector('link[rel="manifest"]')) {134+ const manifest = document.createElement("link");135+ manifest.rel = "manifest";136+ manifest.href = "/_notify/app.webmanifest";137+ document.head.append(manifest);138+ }139+ if ("serviceWorker" in navigator) await navigator.serviceWorker.register("/_notify/sw.js", { scope: "/" });140+ };141+ const platformInstruction = () => {142+ if (isStandalone) return setStatus("");143+ if (/iPhone|iPad|iPod/.test(navigator.userAgent)) setStatus("share → add to home screen");144+ else setStatus("install from your browser menu");145+ };146+ async function beginPairing() {147+ if (operation) return;148+ operation = true;149+ try {150+ const result = await post("/_notify/enrollments");151+ sessionStorage.setItem("nzip-pairing-code", result.code);152+ setStatus(result.code + "\nwaiting");153+ render({ status: "pending" });154+ startPolling();155+ } catch (error) { setStatus(error.message); }156+ finally { operation = false; }157+ }158+ function startPolling() {159+ if (polling) return;160+ const started = Date.now();161+ const poll = async () => {162+ try {163+ const current = await api("/_notify/enrollments/current");164+ if (current.status === "approved") {165+ const active = await post("/_notify/enrollments/activate");166+ sessionStorage.removeItem("nzip-pairing-code");167+ await installAssets();168+ render(active);169+ platformInstruction();170+ polling = 0;171+ return;172+ }173+ if (current.status === "expired" || current.status === "unpaired") {174+ sessionStorage.removeItem("nzip-pairing-code");175+ setStatus(current.status === "expired" ? "pairing expired" : "");176+ render({ status: "unpaired" });177+ polling = 0;178+ return;179+ }180+ } catch (_) { setStatus("waiting · retrying"); }181+ const elapsed = Date.now() - started;182+ const base = elapsed < 60000 ? 3000 : 10000;183+ polling = setTimeout(poll, base + Math.floor(Math.random() * 1000));184+ };185+ polling = setTimeout(poll, 3000);186+ }187+ const vapidKey = (value) => {188+ const padding = "=".repeat((4 - value.length % 4) % 4);189+ const raw = atob((value + padding).replaceAll("-", "+").replaceAll("_", "/"));190+ return Uint8Array.from(raw, (char) => char.charCodeAt(0));191+ };192+ const sameKey = (left, right) => {193+ if (!left) return false;194+ const bytes = new Uint8Array(left);195+ return bytes.length === right.length && bytes.every((value, index) => value === right[index]);196+ };197+ const reconcileSubscription = async (current) => {198+ const registration = await navigator.serviceWorker.ready;199+ const key = await api("/_notify/vapid-public-key");200+ const applicationServerKey = vapidKey(key.publicKey);201+ let subscription = await registration.pushManager.getSubscription();202+ if (subscription && !sameKey(subscription.options.applicationServerKey, applicationServerKey)) {203+ await subscription.unsubscribe();204+ subscription = null;205+ }206+ if (!subscription && current.status === "active" && Notification.permission === "granted") {207+ subscription = await registration.pushManager.subscribe({208+ userVisibleOnly: true,209+ applicationServerKey210+ });211+ }212+ if (subscription) return post("/_notify/subscriptions", subscription.toJSON());213+ if (current.status === "active") {214+ return api("/_notify/subscriptions/current", { method: "DELETE" });215+ }216+ return current;217+ };218+ async function toggleNotifications(on) {219+ if (operation) return;220+ operation = true;221+ const prior = on ? "notifications on" : "notifications off";222+ const control = document.getElementById("notify-toggle");223+ if (control) { control.disabled = true; control.textContent = "notifications …"; }224+ try {225+ const registration = await navigator.serviceWorker.ready;226+ if (on) {227+ const subscription = await registration.pushManager.getSubscription();228+ if (subscription) await subscription.unsubscribe();229+ const next = await api("/_notify/subscriptions/current", { method: "DELETE" });230+ render(next);231+ } else {232+ const permission = await Notification.requestPermission();233+ if (permission !== "granted") {234+ render({ status: "approved", notifications: "off" });235+ const blocked = document.getElementById("notify-toggle");236+ if (blocked) { blocked.textContent = "notifications blocked"; blocked.disabled = true; }237+ return;238+ }239+ const key = await api("/_notify/vapid-public-key");240+ const existing = await registration.pushManager.getSubscription();241+ const subscription = existing || await registration.pushManager.subscribe({242+ userVisibleOnly: true,243+ applicationServerKey: vapidKey(key.publicKey)244+ });245+ const next = await post("/_notify/subscriptions", subscription.toJSON());246+ render(next);247+ }248+ } catch (error) {249+ setStatus(error.message);250+ const restored = document.getElementById("notify-toggle");251+ if (restored) { restored.disabled = false; restored.textContent = prior; }252+ } finally { operation = false; }253+ }254+ (async () => {255+ if (new URL(location.href).searchParams.get("notify") === "link-expired") setStatus("link expired");256+ try {257+ const current = await api("/_notify/enrollments/current");258+ if (current.status === "approved" || current.status === "active") {259+ await installAssets();260+ if (isStandalone) {261+ const reconciled = await reconcileSubscription(current);262+ render(reconciled);263+ post("/_notify/enrollments/renew").catch(() => undefined);264+ } else {265+ render(current);266+ platformInstruction();267+ }268+ } else if (current.status === "pending") {269+ render(current);270+ const code = sessionStorage.getItem("nzip-pairing-code");271+ setStatus((code ? code + "\n" : "") + "waiting");272+ startPolling();273+ } else render({ status: "unpaired" });274+ } catch (_) { render({ status: "unpaired" }); setStatus("offline"); }275+ })();276+})();`;277+278+function escapeHtml(value: string): string {279+ return value.replace(/[&<>"']/g, (character) =>280+ ({281+ "&": "&amp;",282+ "<": "&lt;",283+ ">": "&gt;",284+ '"': "&quot;",285+ "'": "&#39;",286+ })[character]!);287+}288+289+/** Claim-independent shell. Safe to cache publicly; all state is fetched client-side. */290+export function notifyLandingPage(env: NotifyEnv): string {291+ const host = escapeHtml(new URL(env.PUBLIC_BASE).host);292+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#1e1810"><title>${host}</title>293+<style>*{box-sizing:border-box}body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;padding:24px;text-align:center}.landing{display:grid;gap:24px;justify-items:center}.wordmark{color:#f2e8d4;font-size:clamp(40px,10vw,72px);font-weight:700;letter-spacing:-.18em;line-height:.8}.wordmark .z{color:#ff6b4a}#notify-status{color:#8a8172;font-size:12px;line-height:1.6;white-space:pre-line;min-height:3.2em;letter-spacing:.02em}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom));color:#6b6355;font-size:12px}a,button{color:#6b6355;font:inherit;text-decoration:underline}button{appearance:none;background:none;border:0;padding:0;cursor:pointer}a:hover,button:hover{color:#d99a5b}button:focus-visible,a:focus-visible{outline:2px solid #ffb347;outline-offset:4px}button:disabled{cursor:default;text-decoration:none}</style></head>294+<body><main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div><div id="notify-status" aria-live="polite"></div></div></main><footer><a href="https://args.io/cat/nzip">args</a> · <button id="pair" type="button">pair</button></footer><script>${APP_SCRIPT}</script></body></html>`;295+}296+297+function assetResponse(298+ request: Request,299+ body: string,300+ contentType: string,301+ headers: HeadersInit = {},302+): Response {303+ return new Response(request.method === "HEAD" ? null : body, {304+ headers: {305+ "content-type": contentType,306+ "cache-control": "public, max-age=3600",307+ "x-content-type-options": "nosniff",308+ ...headers,309+ },310+ });311+}312+313+export function notifyAssetResponse(314+ request: Request,315+ env: NotifyEnv,316+ url: URL,317+): Response | null {318+ if (url.pathname === "/_notify/vapid-public-key") {319+ if (!env.VAPID_PUBLIC_KEY) {320+ return new Response(JSON.stringify({ error: "not found" }), {321+ status: 404,322+ headers: {323+ "content-type": "application/json",324+ "cache-control": "no-store",325+ },326+ });327+ }328+ return new Response(329+ request.method === "HEAD"330+ ? null331+ : JSON.stringify({ publicKey: env.VAPID_PUBLIC_KEY }),332+ {333+ headers: {334+ "content-type": "application/json; charset=utf-8",335+ "cache-control": "public, max-age=3600",336+ "x-content-type-options": "nosniff",337+ },338+ },339+ );340+ }341+ if (url.pathname === "/_notify/app.webmanifest") {342+ return assetResponse(343+ request,344+ APP_MANIFEST,345+ "application/manifest+json; charset=utf-8",346+ );347+ }348+ if (url.pathname === "/_notify/sw.js") {349+ return assetResponse(350+ request,351+ SERVICE_WORKER,352+ "text/javascript; charset=utf-8",353+ {354+ "cache-control": "no-cache",355+ "service-worker-allowed": "/",356+ },357+ );358+ }359+ if (url.pathname === "/_notify/icon.svg") {360+ return assetResponse(request, ICON, "image/svg+xml; charset=utf-8", {361+ "cache-control": "public, max-age=86400",362+ });363+ }364+ return null;365+}
M worker/src/serve.ts +2 -16
@@ -8,6 +8,7 @@ import {
88 makeUnlockCookie,99 verifyPassword,1010 } from "./password.ts";11+import { notifyLandingPage } from "./notify_ui.ts";1112 1213 const GONE_PAGE =1314 `<!doctype html><meta charset="utf-8"><title>expired — nzip</title>
@@ -22,21 +23,6 @@ const NOT_FOUND_PAGE =
2223 const MAX_UNLOCK_BODY_BYTES = 4096;2324 const MAX_PASSWORD_LENGTH = 256;2425 25-const escapeHtml = (s: string): string =>26- s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]!));27-28-// The landing shows the deployment's own hostname, derived from PUBLIC_BASE, so29-// the committed source carries no operator-specific branding. It pairs that30-// hostname with the nzip wordmark; the coral z is the product's small visual mark.31-function landingPage(env: Env): string {32- const host = new URL(env.PUBLIC_BASE).host;33- return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${34- escapeHtml(host)35- }</title>36-<style>body{background:#1e1810;color:#e7dbc5;font-family:ui-monospace,monospace;margin:0;min-height:100dvh;display:flex;flex-direction:column}main{flex:1;display:grid;place-items:center;padding:24px;text-align:center}.landing{display:grid;gap:24px;justify-items:center}.wordmark{color:#f2e8d4;font-size:clamp(40px,10vw,72px);font-weight:700;letter-spacing:-.18em;line-height:.8}.wordmark .z{color:#ff6b4a}.host{font-size:clamp(14px,2vw,18px);letter-spacing:-.06em}.host b{color:#d99a5b}footer{text-align:center;padding:14px 16px calc(14px + env(safe-area-inset-bottom))}a{color:#6b6355;font-size:12px;text-decoration:underline}a:hover{color:#d99a5b}</style>37-<main><div class="landing"><div class="wordmark" aria-label="nzip">n<span class="z">z</span>ip</div></div></main><footer><a href="https://args.io/cat/nzip">args</a></footer>`;38-}39-4026 function htmlResponse(4127 body: string,4228 status: number,
@@ -134,7 +120,7 @@ export async function serve(
134120 const path = url.pathname;135121 136122 if (path === "/") {137- return htmlResponse(landingPage(env), 200, {123+ return htmlResponse(notifyLandingPage(env), 200, {138124 "cache-control": "public, max-age=3600",139125 });140126 }
A worker/src/web_push.ts +116 -0
@@ -0,0 +1,116 @@
1+import webPush, { type PushSubscription } from "web-push";2+import type { Env } from "./env.ts";3+4+export interface WebPushMessage {5+ eventId: string;6+ title: string;7+ body: string;8+ path?: string;9+ tag?: string;10+}11+12+export interface WebPushResult {13+ status: number;14+ retryAfter: number | null;15+}16+17+export interface WebPushRequest {18+ endpoint: URL;19+ headers: Record<string, string>;20+ body: Uint8Array;21+}22+23+function configuredOrigins(env: Env): URL[] {24+ const raw = env.WEB_PUSH_ORIGINS?.trim();25+ if (!raw) throw new Error("web push origins are not configured");26+ return raw.split(",").map((value) => {27+ const url = new URL(value.trim());28+ if (29+ url.protocol !== "https:" || url.username || url.password || url.hash ||30+ url.pathname !== "/" || url.search || url.port31+ ) {32+ throw new Error("invalid web push origin configuration");33+ }34+ return url;35+ });36+}37+38+function isIpLiteral(hostname: string): boolean {39+ return /^\[.*\]$/.test(hostname) || /^\d+(?:\.\d+){3}$/.test(hostname) ||40+ /^0x/i.test(hostname) || /^\d+$/.test(hostname);41+}42+43+export function validatePushEndpoint(env: Env, endpoint: string): URL {44+ let url: URL;45+ try {46+ url = new URL(endpoint);47+ } catch {48+ throw new Error("invalid push subscription endpoint");49+ }50+ if (51+ url.protocol !== "https:" || url.username || url.password || url.hash ||52+ url.port || isIpLiteral(url.hostname)53+ ) {54+ throw new Error("invalid push subscription endpoint");55+ }56+ const publicBase = new URL(env.PUBLIC_BASE);57+ if (url.hostname === publicBase.hostname) {58+ throw new Error("push subscription endpoint cannot target this deployment");59+ }60+ const allowed = configuredOrigins(env).some((origin) =>61+ origin.origin === url.origin62+ );63+ if (!allowed) throw new Error("push subscription provider is not allowed");64+ return url;65+}66+67+export function buildWebPushRequest(68+ env: Env,69+ subscription: PushSubscription,70+ message: WebPushMessage,71+): WebPushRequest {72+ const endpoint = validatePushEndpoint(env, subscription.endpoint);73+ if (!env.VAPID_PUBLIC_KEY || !env.VAPID_PRIVATE_KEY || !env.VAPID_SUBJECT) {74+ throw new Error("VAPID is not configured");75+ }76+ const details = webPush.generateRequestDetails(77+ subscription,78+ JSON.stringify(message),79+ {80+ TTL: 6 * 60 * 60,81+ urgency: "normal",82+ contentEncoding: "aes128gcm",83+ vapidDetails: {84+ subject: env.VAPID_SUBJECT,85+ publicKey: env.VAPID_PUBLIC_KEY,86+ privateKey: env.VAPID_PRIVATE_KEY,87+ },88+ },89+ );90+ if (!details.body) throw new Error("web push payload was not generated");91+ return {92+ endpoint,93+ headers: details.headers,94+ body: new Uint8Array(details.body),95+ };96+}97+98+export async function sendWebPush(99+ env: Env,100+ subscription: PushSubscription,101+ message: WebPushMessage,102+): Promise<WebPushResult> {103+ const request = buildWebPushRequest(env, subscription, message);104+ const response = await fetch(request.endpoint, {105+ method: "POST",106+ headers: request.headers,107+ body: request.body,108+ redirect: "manual",109+ signal: AbortSignal.timeout(20_000),110+ });111+ const retryAfterHeader = response.headers.get("retry-after");112+ const retryAfter = retryAfterHeader && /^\d+$/.test(retryAfterHeader)113+ ? Math.min(Number(retryAfterHeader), 6 * 60 * 60)114+ : null;115+ return { status: response.status, retryAfter };116+}
M worker/test/index.runtime.test.ts +105 -0
@@ -12,6 +12,111 @@ describe("Worker runtime", () => {
1212 expect(await response.text()).toContain("nzip");1313 });1414 15+ it("keeps the root shell claim-independent until pairing is requested", async () => {16+ const response = await SELF.fetch("https://share.example.com/");17+ const html = await response.text();18+19+ expect(response.headers.get("set-cookie")).toBeNull();20+ expect(html).toContain('id="pair"');21+ expect(html).not.toContain('<link rel="manifest"');22+ const count = await env.DB.prepare(23+ "SELECT COUNT(*) AS count FROM notification_devices",24+ ).first<{ count: number }>();25+ expect(count?.count).toBe(0);26+ });27+28+ it("enrolls, previews, approves, and activates while delivery is disabled", async () => {29+ const browserHeaders = {30+ origin: "https://share.example.com",31+ "sec-fetch-site": "same-origin",32+ "content-type": "application/json",33+ "user-agent": "runtime mobile browser",34+ };35+ const enrolled = await SELF.fetch(36+ "https://share.example.com/_notify/enrollments",37+ { method: "POST", headers: browserHeaders, body: "{}" },38+ );39+ expect(enrolled.status).toBe(201);40+ expect(enrolled.headers.get("cache-control")).toBe("no-store");41+ expect(enrolled.headers.get("vary")).toBe("Cookie");42+ const cookie = enrolled.headers.get("set-cookie")?.split(";", 1)[0];43+ const enrollment = await enrolled.json<{ code: string }>();44+ expect(cookie).toMatch(/^__Host-nzip-notify=/);45+ expect(enrollment.code).toMatch(/^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/);46+47+ const ownerHeaders = {48+ authorization: "Bearer runtime-test-token",49+ "content-type": "application/json",50+ };51+ const preview = await SELF.fetch(52+ `https://share.example.com/api/notify/approvals/${enrollment.code}`,53+ { headers: ownerHeaders },54+ );55+ expect(preview.status).toBe(200);56+ expect(await preview.json()).toMatchObject({57+ userAgentSummary: "runtime mobile browser",58+ deviceClass: "mobile",59+ });60+61+ const approved = await SELF.fetch(62+ "https://share.example.com/api/notify/approvals",63+ {64+ method: "POST",65+ headers: ownerHeaders,66+ body: JSON.stringify({ code: enrollment.code, name: "runtime device" }),67+ },68+ );69+ expect(approved.status).toBe(200);70+ expect(await approved.json()).toMatchObject({71+ name: "runtime device",72+ status: "approved",73+ });74+75+ const activated = await SELF.fetch(76+ "https://share.example.com/_notify/enrollments/activate",77+ {78+ method: "POST",79+ headers: { ...browserHeaders, cookie: cookie! },80+ body: "{}",81+ },82+ );83+ expect(activated.status).toBe(200);84+ expect(await activated.json()).toMatchObject({85+ paired: true,86+ notifications: "off",87+ });88+89+ const disabledSend = await SELF.fetch(90+ "https://share.example.com/api/notify",91+ {92+ method: "POST",93+ headers: ownerHeaders,94+ body: JSON.stringify({ body: "runtime test" }),95+ },96+ );97+ expect(disabledSend.status).toBe(503);98+ });99+100+ it("serves install assets with explicit content and cache policies", async () => {101+ const manifest = await SELF.fetch(102+ "https://share.example.com/_notify/app.webmanifest",103+ );104+ const serviceWorker = await SELF.fetch(105+ "https://share.example.com/_notify/sw.js",106+ );107+ const icon = await SELF.fetch("https://share.example.com/_notify/icon.svg");108+109+ expect(manifest.headers.get("content-type")).toContain(110+ "application/manifest+json",111+ );112+ expect(serviceWorker.headers.get("content-type")).toContain(113+ "text/javascript",114+ );115+ expect(serviceWorker.headers.get("cache-control")).toBe("no-cache");116+ expect(serviceWorker.headers.get("service-worker-allowed")).toBe("/");117+ expect(icon.headers.get("content-type")).toContain("image/svg+xml");118+ });119+15120 it("rejects unauthenticated API requests before touching storage", async () => {16121 const response = await SELF.fetch("https://share.example.com/api/status");17122
A worker/test/notify.test.mjs +106 -0
@@ -0,0 +1,106 @@
1+import assert from "node:assert/strict";2+import { createECDH, randomBytes } from "node:crypto";3+import { describe, it } from "node:test";4+import webPush from "web-push";5+import { validateNotifyRequest } from "../src/notify.ts";6+import { buildWebPushRequest, validatePushEndpoint } from "../src/web_push.ts";7+8+const env = {9+ PUBLIC_BASE: "https://share.example.com",10+ WEB_PUSH_ORIGINS:11+ "https://fcm.googleapis.com,https://web.push.apple.com,https://updates.push.services.mozilla.com",12+};13+14+describe("notification validation", () => {15+ it("accepts bounded Unicode notification content", () => {16+ assert.deepEqual(17+ validateNotifyRequest({18+ title: "Build ✓",19+ body: "Ready 🚀",20+ path: "/12af",21+ tag: "build-main",22+ }),23+ {24+ title: "Build ✓",25+ body: "Ready 🚀",26+ path: "/12af",27+ tag: "build-main",28+ },29+ );30+ });31+32+ it("rejects cross-origin, encoded, and unknown notification fields", () => {33+ for (34+ const value of [35+ { body: "x", path: "https://example.com/" },36+ { body: "x", path: "//example.com" },37+ { body: "x", path: "/%31%32af" },38+ { body: "x", extra: true },39+ ]40+ ) {41+ assert.throws(() => validateNotifyRequest(value));42+ }43+ });44+45+ it("counts Unicode scalars instead of UTF-16 code units", () => {46+ assert.equal(47+ validateNotifyRequest({ body: "🚀".repeat(240) }).body.length,48+ 480,49+ );50+ assert.throws(() => validateNotifyRequest({ body: "🚀".repeat(241) }));51+ });52+});53+54+describe("push endpoint policy", () => {55+ it("accepts only configured HTTPS provider origins", () => {56+ assert.equal(57+ validatePushEndpoint(env, "https://fcm.googleapis.com/fcm/send/abc")58+ .hostname,59+ "fcm.googleapis.com",60+ );61+ assert.throws(() => validatePushEndpoint(env, "https://example.com/push"));62+ assert.throws(() =>63+ validatePushEndpoint(env, "http://fcm.googleapis.com/push")64+ );65+ });66+67+ it("builds an RFC 8291 aes128gcm request", () => {68+ const vapid = webPush.generateVAPIDKeys();69+ const client = createECDH("prime256v1");70+ client.generateKeys();71+ const request = buildWebPushRequest({72+ ...env,73+ VAPID_PUBLIC_KEY: vapid.publicKey,74+ VAPID_PRIVATE_KEY: vapid.privateKey,75+ VAPID_SUBJECT: "https://share.example.com",76+ }, {77+ endpoint: "https://fcm.googleapis.com/fcm/send/test",78+ keys: {79+ p256dh: client.getPublicKey().toString("base64url"),80+ auth: randomBytes(16).toString("base64url"),81+ },82+ }, {83+ eventId: "00000000-0000-4000-8000-000000000000",84+ title: "nzip",85+ body: "transport fixture ✓",86+ });87+88+ assert.equal(request.headers["Content-Encoding"], "aes128gcm");89+ assert.match(request.headers.Authorization, /^vapid /);90+ assert.ok(request.body.byteLength > 0);91+ });92+93+ it("rejects own-zone, credentialed, IP-literal, and alternate-port endpoints", () => {94+ for (95+ const endpoint of [96+ "https://share.example.com/_notify/reenter",97+ "https://user:pass@fcm.googleapis.com/push",98+ "https://127.0.0.1/push",99+ "https://2130706433/push",100+ "https://fcm.googleapis.com:8443/push",101+ ]102+ ) {103+ assert.throws(() => validatePushEndpoint(env, endpoint));104+ }105+ });106+});
M worker/worker-configuration.d.ts +9 -2
@@ -1,5 +1,5 @@
11 /* eslint-disable */2-// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --strict-vars=false` (hash: 3bd3218662632ec1c405b5eb1a134078)2+// Generated by Wrangler by running `wrangler types --env-file=.dev.vars.example --include-runtime=false --strict-vars=false` (hash: 74af392e91803a5e7b5145df95107baa)33 interface __BaseEnv_Env {44 CONTENT: R2Bucket;55 DB: D1Database;
@@ -8,10 +8,17 @@ interface __BaseEnv_Env {
88 RL_UNLOCK: RateLimit;99 RL_OBSERVE: RateLimit;1010 RL_SIGNAL: RateLimit;11+ RL_NOTIFY_ENROLL: RateLimit;12+ RL_NOTIFY_READ: RateLimit;1113 PUBLIC_BASE: string;1214 ALERT_EMAIL_TO: string;1315 ALERT_EMAIL_FROM: string;16+ NOTIFY_ENABLED: string;17+ WEB_PUSH_ORIGINS: string;18+ VAPID_PUBLIC_KEY: string;19+ VAPID_SUBJECT: string;1420 NZIP_TOKEN: string;21+ VAPID_PRIVATE_KEY: string;1522 }1623 declare namespace Cloudflare {1724 interface GlobalProps {
@@ -24,5 +31,5 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
2431 [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;2532 };2633 declare namespace NodeJS {27- interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "PUBLIC_BASE" | "ALERT_EMAIL_TO" | "ALERT_EMAIL_FROM" | "NZIP_TOKEN">> {}34+ interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "PUBLIC_BASE" | "ALERT_EMAIL_TO" | "ALERT_EMAIL_FROM" | "NOTIFY_ENABLED" | "WEB_PUSH_ORIGINS" | "VAPID_PUBLIC_KEY" | "VAPID_SUBJECT" | "NZIP_TOKEN" | "VAPID_PRIVATE_KEY">> {}2835 }
M worker/wrangler.jsonc +16 -2