clone

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

release: v0.6.0

Dami committed a59a55b
Add owner-controlled notification pairing windows and harden the public enrollment surface.

Group site lifecycle commands, require explicit notification send, update docs, and remove NOTIFY_PLAN.md.
commit
a59a55bba70b1fbdd66fbcd6c02393ab1a309265
parent
7787b6a8ea647f61008ef13a4f0a35bee1081e0b
author
Dami <dami.etoile@gmail.com>
29 files changed, +498 -1483
DNOTIFY_PLAN.md1276------------------------------
MREADME.md59+-
Mcli/README.md49+-
Mcli/commands/cp.ts2+-
Mcli/commands/notify.ts31+-
Mcli/commands/notify_test.ts12+-
Mcli/commands/push.ts4+-
Acli/commands/site.ts95++
Acli/commands/site_test.ts41+
Mcli/commands/sites.ts50+-
Mcli/commands/where.ts8+-
Mcli/deno.json2+-
Mcli/lib/api.ts5+
Mcli/lib/bundle.ts2+-
Mcli/lib/fmt.ts6+-
Mcli/lib/paths.ts8+-
Mcli/main.ts84+-
Mshared/deno.json2+-
Mshared/types.ts10+-
Mshared/version.ts2+-
Mskills/nzip/SKILL.md57+-
Aworker/migrations/0006_notification_pairing_window.sql4+
Mworker/setup.md12+-
Mworker/src/api.ts7+
Mworker/src/notify_enrollment.ts46+-
Mworker/src/notify_public.ts4+
Mworker/src/notify_ui.ts29+-
Mworker/src/serve.ts2+
Mworker/test/index.runtime.test.ts72++-
D NOTIFY_PLAN.md +0 -1276
@@ -1,1276 +0,0 @@
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?
M README.md +31 -28
@@ -11,7 +11,7 @@ with optional expiration, password protection, and enumeration alerts._
1111 · [args.io/cat/nzip](https://args.io/cat/nzip)1212 1313 ```console14-$ nzip push ./demo work:demo --ttl 30d14+$ nzip site push ./demo work:demo --ttl 30d1515 bundling ./demo … 3 files, 197 B1616 manifest 4a91d75d — 3 new blobs (197 B), 0 deduped1717 ✓ pushed work:demo -> https://share.example.com/12d8 (expires in 30d, push #1)
@@ -50,23 +50,23 @@ Commands accept any of three target forms:
5050 - **Ephemeral by default.** 14-day TTL, with `--ttl 30d` or `--ttl forever` to5151 override. Expired shares answer `410 Gone`; a daily cron sweeps them and5252 garbage-collects unreferenced content.53-- **Revertible.** The last 10 pushes per site are kept. `nzip revert` repoints53+- **Revertible.** The last 10 pushes per site are kept. `nzip site revert` repoints5454 the address at any of them, and the revert itself is recorded as a push.55-- **Password-protectable.** `nzip push ./demo work:demo --password …` publishes55+- **Password-protectable.** `nzip site push ./demo work:demo --password …` publishes5656 the content and password policy atomically, then gates the site behind an5757 unlock form (PBKDF2 hashing, signed per-site cookie, 7 days). Changing the58- password policy revokes existing cookies immediately. Use `nzip site` to58+ password policy revokes existing cookies immediately. Use `nzip site update` to5959 change protection later.6060 - **Single-user by design.** One bearer token, stored as a Worker secret and in6161 `~/.config/nzip/config.json` (mode 0600).6262 - **Owner notifications.** Pair explicitly approved phones from the quiet root6363 PWA, then send Web Push notifications from the authenticated CLI. Clicks may6464 open `/` or a manifest-pinned nzip site; arbitrary external URLs are refused.65-- **Locates itself.** `nzip where <target>` prints the local directory this65+- **Locates itself.** `nzip site where <target>` prints the local directory this6666 machine pushed a site from. A breadcrumb registry6767 (`~/.config/nzip/paths.json`) records the source path on every push and6868 self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls`69- reconciles against the live set. Use it as `cd "$(nzip where personal:plan)"`.69+ reconciles against the live set. Use it as `cd "$(nzip site where personal:plan)"`.7070 - **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json`7171 restricts which vaults this install may target. Targets outside the list and7272 raw hex addresses are refused before any upload, so a home-project agent can't
@@ -83,22 +83,23 @@ nzip
8383 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]8484 │ ├─ ls list vaults8585 │ └─ default <name> set the default vault86-├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]87-├─ cp <target> [dir] [--overwrite] copy a hosted bundle88-├─ site <target> [--ttl …] [--password PW | --no-password]89-├─ ls [vault] list sites90-├─ where <target> print this machine's source directory91-├─ rm <target> [--yes] delete a site86+├─ site87+│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]88+│ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle89+│ ├─ show <target> show site details90+│ ├─ update <target> [--ttl …] [--password PW | --no-password]91+│ ├─ ls [vault] list sites92+│ ├─ where <target> print this machine's source directory93+│ ├─ rm <target> [--yes] delete a site94+│ └─ revert <target> [--to N] [--list] inspect or restore push history9295 ├─ status show server and vault status93-├─ notify94-│ ├─ <body> [--title TEXT] [--open TARGET] [--tag TEXT]95-│ ├─ test send a diagnostic notification96-│ ├─ approve <code> --name NAME [--yes]97-│ ├─ devices [--all] list current notification devices98-│ └─ revoke <device-id> [--yes] revoke a notification device99-└─ revert <target> [--to N] [--list] inspect or restore push history100-101-aliases: list → ls · download → cp · share → site96+└─ notify97+ ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]98+ ├─ test send a diagnostic notification99+ ├─ pair allow device pairing for 10 minutes100+ ├─ approve <code> --name NAME [--yes]101+ ├─ devices [--all] list current notification devices102+ └─ revoke <device-id> [--yes] revoke a notification device102103 ```103104 104105 Vault descriptions are returned by `vault ls --json` so agents can choose an
@@ -115,7 +116,7 @@ pushes skip dotfiles and `node_modules`, and honor a `.nzipignore` (one glob per
115116 line). Single-file sites serve directly at the bare address (`/2a3f`);116117 multi-file bundles redirect to `/2a3f/` so relative asset URLs resolve.117118 118-`nzip cp work:demo ./recovered-demo` recovers the exact current bundle from the119+`nzip site cp work:demo ./recovered-demo` recovers the exact current bundle from the119120 authenticated server when the original local directory is unavailable. It120121 refuses non-empty destinations unless `--overwrite` is passed, and verifies121122 every downloaded file against the hosted manifest. It can only restore uploaded
@@ -124,16 +125,18 @@ a push.
124125 125126 ### Owner notifications126127 127-Open the deployment root on a phone, tap `pair`, and approve the displayed code128-from an authenticated terminal:128+Open a 10-minute pairing window from an authenticated terminal, then open the129+deployment root on a phone. The `pair` action is visible only during that130+window. Tap it and approve the displayed code:129131 130132 ```sh133+nzip notify pair131134 nzip notify approve ABCD-1234 --name "Personal phone"132135 ```133136 134137 Wait for `paired` before installing the PWA. In the installed app, tap135138 `notifications off` to request permission and attach the subscription. Send a136-message with `nzip notify "Build finished"`; add `--open work:report` to open an139+message with `nzip notify send "Build finished"`; add `--open work:report` to open an137140 existing same-origin site when the notification is tapped. Use138141 `nzip notify devices` to inspect current delivery health and `nzip notify revoke` to139142 remove a device. Pass `--all` to include disabled, revoked, and expired tombstones.
@@ -361,7 +364,7 @@ deno install -g -A -f -n nzip jsr:@nzip/cli # 1. install the `nzip` command
361364 362365 nzip auth --server https://share.example.com # 2. authenticate (prompts for the token)363366 364-nzip push ./site work:demo # 3. get a URL back367+nzip site push ./site work:demo # 3. get a URL back365368 ```366369 367370 `nzip auth` prompts for anything you omit and saves it to
@@ -372,7 +375,7 @@ Upgrade any time by re-running the install line; try it without installing via
372375 Adding a **second machine**? Only the CLI is per-machine; the Worker, R2, D1,373376 vaults, and token are already provisioned. Install from JSR and run `nzip auth`374377 with the same server and token, and every vault and site is instantly there.375-(The `nzip where` breadcrumb registry is local, so it only knows sites pushed378+(The `nzip site where` breadcrumb registry is local, so it only knows sites pushed376379 from this machine.)377380 378381 `nzip` is self-hosted. The server URL is supplied by the operator; there is no
@@ -404,7 +407,7 @@ nzip auth --server https://share.example.com # use your actual PUBLIC_BASE
404407 ```405408 406409 `routes[0].pattern` and `vars.PUBLIC_BASE` are required for a real deployment.407-`PUBLIC_BASE` controls the URLs returned by `nzip push`, and the CLI410+`PUBLIC_BASE` controls the URLs returned by `nzip site push`, and the CLI408411 authenticates against that same origin.409412 410413 Two gotchas learned the hard way:
M cli/README.md +26 -23
@@ -26,7 +26,7 @@ deno run -A jsr:@nzip/cli --help
2626 ```sh2727 nzip auth --server https://share.example.com # authenticate (prompts for the token)2828 nzip vault add work # register a named vault29-nzip push ./site work:demo --ttl 30d --password secret29+nzip site push ./site work:demo --ttl 30d --password secret3030 # → https://share.example.com/12d83131 ```3232
@@ -44,22 +44,23 @@ nzip
4444 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]4545 │ ├─ ls list vaults4646 │ └─ default <name> set the default vault47-├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]48-├─ cp <target> [dir] [--overwrite] copy a hosted bundle49-├─ site <target> [--ttl …] [--password PW | --no-password]50-├─ ls [vault] list sites51-├─ where <target> print this machine's source directory52-├─ rm <target> [--yes] delete a site47+├─ site48+│ ├─ push <dir|file> [target] [--ttl …] [--password PW | --no-password]49+│ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle50+│ ├─ show <target> show site details51+│ ├─ update <target> [--ttl …] [--password PW | --no-password]52+│ ├─ ls [vault] list sites53+│ ├─ where <target> print this machine's source directory54+│ ├─ rm <target> [--yes] delete a site55+│ └─ revert <target> [--to N] [--list] inspect or restore push history5356 ├─ status show server and vault status54-├─ notify55-│ ├─ <body> [--title TEXT] [--open TARGET] [--tag TEXT]56-│ ├─ test send a diagnostic notification57-│ ├─ approve <code> --name NAME [--yes]58-│ ├─ devices [--all] list current notification devices59-│ └─ revoke <device-id> [--yes] revoke a notification device60-└─ revert <target> [--to N] [--list] inspect or restore push history61-62-aliases: list → ls · download → cp · share → site57+└─ notify58+ ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]59+ ├─ test send a diagnostic notification60+ ├─ pair allow device pairing for 10 minutes61+ ├─ approve <code> --name NAME [--yes]62+ ├─ devices [--all] list current notification devices63+ └─ revoke <device-id> [--yes] revoke a notification device6364 ```6465 6566 Vault descriptions are included in `vault ls --json`. Use
@@ -69,21 +70,23 @@ Password and TTL are committed with the content. On a new site, omitting `--pass
6970 unprotected site; on an existing target, omission preserves its current password. Pass7071 `--no-password` to clear protection explicitly.7172 72-`nzip cp work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty directory.73-It uses the configured bearer token, verifies file hashes, and never exposes source via the public74-page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were never75-stored.73+`nzip site cp work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty74+directory. It uses the configured bearer token, verifies file hashes, and never exposes source via75+the public page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were76+never stored.7677 7778 ## Notifications7879 79-Pairing starts from the deployment root. After the phone shows a code, approve it from the80-authenticated CLI:80+Pairing is closed by default. Open a 10-minute pairing window from the authenticated CLI, then open81+the deployment root on the phone. The `pair` action appears only while that window is open. After82+the phone shows a code, approve it from the CLI:8183 8284 ```sh85+nzip notify pair8386 nzip notify approve ABCD-1234 --name "Personal phone"8487 nzip notify devices8588 nzip notify devices --all # include disabled, revoked, and expired tombstones86-nzip notify "Report ready" --title "nzip" --open work:report89+nzip notify send "Report ready" --title "nzip" --open work:report8790 ```8891 8992 `--open` accepts an existing nzip target and applies the same local vault guardrails as other
M cli/commands/cp.ts +1 -1
M cli/commands/notify.ts +23 -8
@@ -6,17 +6,24 @@ import { ago, bold, cyan, dim, emit, fail, green, table } from "../lib/fmt.ts";
66 export type NotifyInvocation =77 | { kind: "send"; body: string }88 | { kind: "test" }9+ | { kind: "pair" }910 | { kind: "approve"; code: string }1011 | { kind: "devices" }1112 | { kind: "revoke"; deviceId: string };1213 1314 export function parseNotifyInvocation(rest: string[]): NotifyInvocation {1415 const [first, second, ...extra] = rest;15- if (!first) fail("usage: nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]");16+ if (!first) {17+ fail("usage: nzip notify <send|test|pair|approve|devices|revoke> ...");18+ }1619 if (first === "test") {1720 if (second !== undefined) fail("usage: nzip notify test");1821 return { kind: "test" };1922 }23+ if (first === "pair") {24+ if (second !== undefined) fail("usage: nzip notify pair");25+ return { kind: "pair" };26+ }2027 if (first === "approve") {2128 if (!second || extra.length > 0) fail("usage: nzip notify approve <code> --name NAME");2229 return { kind: "approve", code: second };
@@ -29,14 +36,13 @@ export function parseNotifyInvocation(rest: string[]): NotifyInvocation {
2936 if (!second || extra.length > 0) fail("usage: nzip notify revoke <device-id> [--yes]");3037 return { kind: "revoke", deviceId: second };3138 }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 };39+ if (first !== "send") {40+ fail(`unknown notify command: ${first}`);3741 }38- if (second !== undefined) fail("notification body must be one quoted argument");39- return { kind: "send", body: first };42+ if (!second || extra.length > 0) {43+ fail("usage: nzip notify send <body> [--title TEXT] [--open TARGET] [--tag TEXT]");44+ }45+ return { kind: "send", body: second };4046 }4147 4248 function location(preview: NotifyApprovalPreview): string {
@@ -68,6 +74,15 @@ export async function cmdNotify(
6874 const invocation = parseNotifyInvocation(rest);6975 const api = new ApiClient(config);7076 77+ if (invocation.kind === "pair") {78+ const pairing = await api.openNotificationPairing();79+ emit(80+ () => console.log(`${green("✓")} pairing enabled for 10 minutes`),81+ { ok: true, pairing },82+ );83+ return;84+ }85+7186 if (invocation.kind === "devices") {7287 const devices = filterNotificationDevices(await api.notificationDevices(), options.all);7388 emit(() => {
M cli/commands/notify_test.ts +3 -9
M cli/commands/push.ts +2 -2
A cli/commands/site.ts +95 -0
@@ -0,0 +1,95 @@
1+import type { Config } from "../lib/config.ts";2+import { fail } from "../lib/fmt.ts";3+import { cmdCp } from "./cp.ts";4+import { cmdPush } from "./push.ts";5+import { cmdLs, cmdRevert, cmdRm, cmdSiteShow, cmdSiteUpdate } from "./sites.ts";6+import { cmdWhere } from "./where.ts";7+8+export type SiteInvocation =9+ | { kind: "push"; source?: string; target?: string }10+ | { kind: "cp"; target?: string; dir?: string }11+ | { kind: "show"; target?: string }12+ | { kind: "update"; target?: string }13+ | { kind: "ls"; vault?: string }14+ | { kind: "where"; target?: string }15+ | { kind: "rm"; target?: string }16+ | { kind: "revert"; target?: string };17+18+export function parseSiteInvocation(rest: string[]): SiteInvocation {19+ const [action, first, second, ...extra] = rest;20+ if (!action) fail("usage: nzip site <push|cp|show|update|ls|where|rm|revert> ...");21+ if (extra.length > 0) fail(`too many arguments for nzip site ${action}`);22+ switch (action) {23+ case "push":24+ return { kind: "push", source: first, target: second };25+ case "cp":26+ return { kind: "cp", target: first, dir: second };27+ case "show":28+ if (second !== undefined) fail("usage: nzip site show <target>");29+ return { kind: "show", target: first };30+ case "update":31+ if (second !== undefined) fail("usage: nzip site update <target> [options]");32+ return { kind: "update", target: first };33+ case "ls":34+ if (second !== undefined) fail("usage: nzip site ls [vault]");35+ return { kind: "ls", vault: first };36+ case "where":37+ if (second !== undefined) fail("usage: nzip site where <target>");38+ return { kind: "where", target: first };39+ case "rm":40+ if (second !== undefined) fail("usage: nzip site rm <target> [--yes]");41+ return { kind: "rm", target: first };42+ case "revert":43+ if (second !== undefined) fail("usage: nzip site revert <target> [--to N] [--list]");44+ return { kind: "revert", target: first };45+ default:46+ return fail(`unknown site command: ${action}`);47+ }48+}49+50+export async function cmdSiteGroup(51+ config: Config,52+ rest: string[],53+ options: {54+ ttl?: string;55+ password?: string;56+ noPassword: boolean;57+ overwrite: boolean;58+ yes: boolean;59+ toSeq?: number;60+ list: boolean;61+ },62+): Promise<void> {63+ const invocation = parseSiteInvocation(rest);64+ switch (invocation.kind) {65+ case "push":66+ return await cmdPush(67+ config,68+ invocation.source,69+ invocation.target,70+ options.ttl,71+ options.password,72+ options.noPassword,73+ );74+ case "cp":75+ return await cmdCp(config, invocation.target, invocation.dir, options.overwrite);76+ case "show":77+ return await cmdSiteShow(config, invocation.target);78+ case "update":79+ return await cmdSiteUpdate(80+ config,81+ invocation.target,82+ options.ttl,83+ options.password,84+ options.noPassword,85+ );86+ case "ls":87+ return await cmdLs(config, invocation.vault);88+ case "where":89+ return await cmdWhere(config, invocation.target);90+ case "rm":91+ return await cmdRm(config, invocation.target, options.yes);92+ case "revert":93+ return await cmdRevert(config, invocation.target, options.toSeq, options.list);94+ }95+}
A cli/commands/site_test.ts +41 -0
M cli/commands/sites.ts +31 -19
@@ -1,5 +1,6 @@
1-// site, ls, rm, status, revert — the site-management commands.1+// Site lifecycle operations plus the global deployment status command.22 3+import type { SiteDetail } from "@nzip/shared";34 import { ApiClient, resolveCliTarget } from "../lib/api.ts";45 import type { Config } from "../lib/config.ts";56 import { forget, reconcile } from "../lib/paths.ts";
@@ -30,7 +31,28 @@ function targetOrFail(
3031 }3132 }3233 33-export async function cmdSite(34+function emitSiteDetail(site: SiteDetail): void {35+ const name = site.alias ? `${site.vault}:${site.alias}` : site.address;36+ emit(37+ () =>38+ console.log(39+ `${bold(name)} ${cyan(site.url)} ${site.manifestHash.slice(0, 8)} ${40+ site.expiresAt === null ? green("forever") : `expires in ${ttlLeft(site.expiresAt)}`41+ }${site.protected ? ` ${amber("🔒")}` : ""}`,42+ ),43+ { ok: true, ...site },44+ );45+}46+47+export async function cmdSiteShow(48+ config: Config,49+ raw: string | undefined,50+): Promise<void> {51+ const target = targetOrFail(raw, config, "usage: nzip site show <target>");52+ emitSiteDetail(await new ApiClient(config).siteDetail(target));53+}54+55+export async function cmdSiteUpdate(3456 config: Config,3557 raw: string | undefined,3658 ttlRaw: string | undefined,
@@ -40,29 +62,19 @@ export async function cmdSite(
4062 const target = targetOrFail(4163 raw,4264 config,43- "usage: nzip site <target> [--ttl 14d|forever] [--password PW | --no-password]",65+ "usage: nzip site update <target> [--ttl 14d|forever] [--password PW | --no-password]",4466 );4567 if (password !== undefined && noPassword) {4668 fail("choose either --password or --no-password, not both");4769 }48- const api = new ApiClient(config);4970 const patch: { ttl?: number | "forever"; password?: string | null } = {};5071 if (ttlRaw !== undefined) patch.ttl = parseTtl(ttlRaw);5172 if (noPassword) patch.password = null;5273 else if (password !== undefined) patch.password = password;53- const site = Object.keys(patch).length === 054- ? await api.siteDetail(target)55- : await api.patchSite(target, patch);56- const name = site.alias ? `${site.vault}:${site.alias}` : site.address;57- emit(58- () =>59- console.log(60- `${bold(name)} ${cyan(site.url)} ${site.manifestHash.slice(0, 8)} ${61- site.expiresAt === null ? green("forever") : `expires in ${ttlLeft(site.expiresAt)}`62- }${site.protected ? ` ${amber("🔒")}` : ""}`,63- ),64- { ok: true, ...site },65- );74+ if (Object.keys(patch).length === 0) {75+ fail("site update requires --ttl, --password, or --no-password");76+ }77+ emitSiteDetail(await new ApiClient(config).patchSite(target, patch));6678 }6779 6880 export async function cmdLs(
@@ -98,7 +110,7 @@ export async function cmdRm(
98110 raw: string | undefined,99111 yes: boolean,100112 ): Promise<void> {101- const target = targetOrFail(raw, config, "usage: nzip rm <target> [--yes]");113+ const target = targetOrFail(raw, config, "usage: nzip site rm <target> [--yes]");102114 const api = new ApiClient(config);103115 const site = await api.siteDetail(target);104116 const name = site.alias ? `${site.vault}:${site.alias}` : site.address;
@@ -164,7 +176,7 @@ export async function cmdRevert(
164176 const target = targetOrFail(165177 raw,166178 config,167- "usage: nzip revert <target> [--to N] [--list]",179+ "usage: nzip site revert <target> [--to N] [--list]",168180 );169181 const api = new ApiClient(config);170182
M cli/commands/where.ts +4 -4
M cli/deno.json +1 -1
M cli/lib/api.ts +5 -0
M cli/lib/bundle.ts +1 -1
M cli/lib/fmt.ts +3 -3
M cli/lib/paths.ts +4 -4
M cli/main.ts +33 -51
@@ -2,15 +2,14 @@
22 /**33 * `nzip` — push a directory of HTML from the terminal and get a four-character44 * URL back. This is the CLI entrypoint; it parses argv and dispatches to the5- * command handlers (`auth`, `vault`, `push`, `site`, `ls`, `where`, `rm`,6- * `status`, `revert`).5+ * command handlers (`auth`, `status`, `vault`, `site`, and `notify`).76 *87 * Install and run it as a command rather than importing it:98 *109 * ```sh1110 * deno install -g -A -f -n nzip jsr:@nzip/cli1211 * nzip auth --server https://share.example.com13- * nzip push ./site work:demo12+ * nzip site push ./site work:demo1413 * ```1514 *1615 * @module
@@ -19,12 +18,10 @@
1918 2019 import { parseArgs } from "@std/cli/parse-args";2120 import { cmdAuth } from "./commands/auth.ts";22-import { cmdPush } from "./commands/push.ts";2321 import { cmdNotify } from "./commands/notify.ts";24-import { cmdCp } from "./commands/cp.ts";25-import { cmdLs, cmdRevert, cmdRm, cmdSite, cmdStatus } from "./commands/sites.ts";22+import { cmdSiteGroup } from "./commands/site.ts";23+import { cmdStatus } from "./commands/sites.ts";2624 import { cmdVault } from "./commands/vault.ts";27-import { cmdWhere } from "./commands/where.ts";2825 import { requireConfig } from "./lib/config.ts";2926 import { fail, setJsonMode } from "./lib/fmt.ts";3027
@@ -38,22 +35,23 @@ commands:
3835 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]3936 │ ├─ ls list vaults4037 │ └─ default <name> set the default vault41- ├─ push <dir|file> [target] [--ttl ...] [--password PW | --no-password]42- ├─ cp <target> [dir] [--overwrite] copy a hosted bundle43- ├─ site <target> [--ttl ...] [--password PW | --no-password]44- ├─ ls [vault] list sites45- ├─ where <target> print this machine's source directory46- ├─ rm <target> [--yes] delete a site38+ ├─ site39+ │ ├─ push <dir|file> [target] [--ttl ...] [--password PW | --no-password]40+ │ ├─ cp <target> [dir] [--overwrite] copy a hosted bundle41+ │ ├─ show <target> show site details42+ │ ├─ update <target> [--ttl ...] [--password PW | --no-password]43+ │ ├─ ls [vault] list sites44+ │ ├─ where <target> print this machine's source directory45+ │ ├─ rm <target> [--yes] delete a site46+ │ └─ revert <target> [--to N] [--list] inspect or restore push history4747 ├─ status show server and vault status48- ├─ notify49- │ ├─ <body> [--title TEXT] [--open TARGET] [--tag TEXT]50- │ ├─ test queue a diagnostic notification51- │ ├─ approve <code> --name NAME [--yes]52- │ ├─ devices [--all] list current notification devices53- │ └─ revoke <device-id> [--yes] revoke a notification device54- └─ revert <target> [--to N] [--list] inspect or restore push history55-56-aliases: list → ls · download → cp · share → site48+ └─ notify49+ ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]50+ ├─ test queue a diagnostic notification51+ ├─ pair allow device pairing for 10 minutes52+ ├─ approve <code> --name NAME [--yes]53+ ├─ devices [--all] list current notification devices54+ └─ revoke <device-id> [--yes] revoke a notification device5755 5856 targets: 2a3f | work:demo | demo (alias in default vault)5957
@@ -104,39 +102,25 @@ async function main(): Promise<void> {
104102 105103 if (command === "auth") return await cmdAuth(args.server, args.token);106104 105+ if (!["site", "status", "notify", "vault"].includes(command)) {106+ fail(`unknown command: ${command}\n\n${HELP}`);107+ }108+107109 const config = await requireConfig();108110 const slot = args.slot === undefined ? undefined : parseInt(args.slot, 10);109111 const toSeq = args.to === undefined ? undefined : parseInt(args.to, 10);110112 111113 switch (command) {112- case "push":113- return await cmdPush(114- config,115- rest[0],116- rest[1],117- args.ttl,118- args.password,119- args["no-password"],120- );121- case "cp":122- case "download": // compatibility alias for the former command name123- return await cmdCp(config, rest[0], rest[1], args.overwrite);124114 case "site":125- case "share": // compatibility alias for the former site-management command126- return await cmdSite(127- config,128- rest[0],129- args.ttl,130- args.password,131- args["no-password"],132- );133- case "ls":134- case "list":135- return await cmdLs(config, rest[0]);136- case "where":137- return await cmdWhere(config, rest[0]);138- case "rm":139- return await cmdRm(config, rest[0], args.yes);115+ return await cmdSiteGroup(config, rest, {116+ ttl: args.ttl,117+ password: args.password,118+ noPassword: args["no-password"],119+ overwrite: args.overwrite,120+ yes: args.yes,121+ toSeq,122+ list: args.list,123+ });140124 case "status":141125 return await cmdStatus(config);142126 case "notify":
@@ -148,8 +132,6 @@ async function main(): Promise<void> {
148132 yes: args.yes,149133 all: args.all,150134 });151- case "revert":152- return await cmdRevert(config, rest[0], toSeq, args.list);153135 case "vault":154136 return await cmdVault(config, rest[0], rest[1], {155137 slot,
M shared/deno.json +1 -1
M shared/types.ts +9 -1
M shared/version.ts +1 -1
M skills/nzip/SKILL.md +30 -27
@@ -63,7 +63,7 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
6363 3. Publish with an explicit low TTL:6464 6565 ```sh66- nzip push .nzip-<purpose>/ --ttl 1d66+ nzip site push .nzip-<purpose>/ --ttl 1d6767 ```6868 6969 Use `1d` for plans, designs, review pages, and other disposable agent artifacts unless the user
@@ -75,7 +75,7 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
7575 `nzip vault ls --json`:7676 7777 ```sh78- nzip push .nzip-<purpose>/ <vault>:<alias> --ttl 1d78+ nzip site push .nzip-<purpose>/ <vault>:<alias> --ttl 1d7979 ```8080 8181 5. Return the canonical URL and expiry. If the user requested a review boundary or asked for only
@@ -93,25 +93,26 @@ nzip
9393 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]9494 │ ├─ ls9595 │ └─ default <name>96-├─ push <dir|file> [target] [--ttl DAYS] [--password PW | --no-password]97-├─ cp <target> [dir] [--overwrite]98-├─ site <target> [--ttl DAYS] [--password PW | --no-password]99-├─ ls [vault]100-├─ where <target>101-├─ rm <target> [--yes]96+├─ site97+│ ├─ push <dir|file> [target] [--ttl DAYS] [--password PW | --no-password]98+│ ├─ cp <target> [dir] [--overwrite]99+│ ├─ show <target>100+│ ├─ update <target> [--ttl DAYS] [--password PW | --no-password]101+│ ├─ ls [vault]102+│ ├─ where <target>103+│ ├─ rm <target> [--yes]104+│ └─ revert <target> [--to N] [--list]102105 ├─ status103-├─ notify104-│ ├─ <body> [--title TEXT] [--open TARGET] [--tag TEXT]105-│ ├─ test106-│ ├─ approve <code> --name NAME [--yes]107-│ ├─ devices108-│ └─ revoke <device-id> [--yes]109-└─ revert <target> [--to N] [--list]110-111-aliases: list → ls · download → cp · share → site106+└─ notify107+ ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]108+ ├─ test109+ ├─ pair110+ ├─ approve <code> --name NAME [--yes]111+ ├─ devices112+ └─ revoke <device-id> [--yes]112113 ```113114 114-Use password protection when the content or user requires it; `nzip push` accepts the same115+Use password protection when the content or user requires it; `nzip site push` accepts the same115116 `--password <pw> | --no-password` flags to set it at publish time. Vault descriptions appear in116117 `vault ls` and `status`; use them to pick the right destination, and pass `--no-description` to117118 clear one. A vault rename updates the invoking client's default vault, allow-list, and local push
@@ -129,16 +130,18 @@ own; approval still uses the configured owner bearer token.
129130 130131 ## Pair notifications131132 132-1. Open the deployment root on the phone and tap `pair`.133-2. Ask the user for the displayed code and device name, then run133+1. On explicit request, run `nzip notify pair` to open the owner-authenticated 10-minute pairing134+ window. This is an external state change; do not open it while merely diagnosing.135+2. Open the deployment root on the phone while the temporary `pair` action is visible and tap it.136+3. Ask the user for the displayed code and device name, then run134137 `nzip notify approve <code> --name <name>`. Show the approval preview and preserve the135138 interactive confirmation unless the user explicitly requested `--yes`.136-3. Wait for the browser to show `paired` before installing the PWA.137-4. Open the installed app, tap `notifications off`, and accept the system permission request.138-5. Run `nzip notify test` only when the user explicitly requests an end-to-end test.139-6. Send only on explicit request with140- `nzip notify <body> [--title TEXT] [--open TARGET] [--tag TEXT]`.141-7. Inspect current delivery health with `nzip notify devices --json`. Add `--all` only when139+4. Wait for the browser to show `paired` before installing the PWA.140+5. Open the installed app, tap `notifications off`, and accept the system permission request.141+6. Run `nzip notify test` only when the user explicitly requests an end-to-end test.142+7. Send only on explicit request with143+ `nzip notify send <body> [--title TEXT] [--open TARGET] [--tag TEXT]`.144+8. Inspect current delivery health with `nzip notify devices --json`. Add `--all` only when142145 disabled, revoked, or expired tombstones are relevant.143146 144147 If the installed app loses its pairing cookie, instruct the user to remove it, pair again in the
@@ -152,7 +155,7 @@ browser, wait for `paired`, and reinstall it. Revoke a device only on explicit r
152155 - Publish disposable plans and designs with `--ttl 1d`.153156 - Prefer finite TTLs; use `forever` only when explicitly requested.154157 - Prefer descriptive aliases and an explicit vault when identity or audience matters.155-- Preserve the local artifact directory for iteration and `nzip where`; do not commit it.158+- Preserve the local artifact directory for iteration and `nzip site where`; do not commit it.156159 - Use complete, self-contained HTML and relative asset paths.157160 - Use read-only status, list, and inspect operations before mutations when destination state is158161 uncertain.
A worker/migrations/0006_notification_pairing_window.sql +4 -0
M worker/setup.md +9 -3
@@ -112,6 +112,7 @@ npx wrangler d1 execute nzip --remote --file migrations/0002_security_alerts.sql
112112 npx wrangler d1 execute nzip --remote --file migrations/0003_security_notification_outbox.sql113113 npx wrangler d1 execute nzip --remote --file migrations/0004_vault_descriptions.sql114114 npx wrangler d1 execute nzip --remote --file migrations/0005_notifications.sql115+npx wrangler d1 execute nzip --remote --file migrations/0006_notification_pairing_window.sql115116 ```116117 117118 ## Owner notifications
@@ -143,6 +144,9 @@ cd worker
143144 npx wrangler d1 execute nzip --remote \144145 --config wrangler.local.jsonc \145146 --file migrations/0005_notifications.sql147+npx wrangler d1 execute nzip --remote \148+ --config wrangler.local.jsonc \149+ --file migrations/0006_notification_pairing_window.sql146150 npx wrangler deploy --dry-run --config wrangler.local.jsonc147151 npx wrangler deploy --config wrangler.local.jsonc148152 ```
@@ -151,10 +155,12 @@ npx wrangler deploy --config wrangler.local.jsonc
151155 deployment configuration is valid and a real device is ready for the pairing152156 flow.153157 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:158+To pair a phone, first open a 10-minute pairing window from an authenticated159+terminal. Then open the deployment root in its browser, tap the temporary160+`pair` footer action, and approve the displayed code:156161 157162 ```sh163+nzip notify pair158164 nzip notify approve ABCD-1234 --name "Personal phone"159165 ```160166
@@ -194,7 +200,7 @@ deno install -g -f -n nzip \
194200 nzip auth --server https://share.example.com --token <token-from-step-5>195201 nzip vault add personal # slot 0x0196202 nzip vault add work # slot 0x1197-nzip push ./docs personal:plan --ttl forever203+nzip site push ./docs personal:plan --ttl forever198204 ```199205 200206 ## Local development
M worker/src/api.ts +7 -0
M worker/src/notify_enrollment.ts +39 -7
@@ -4,6 +4,7 @@ import { validatePushEndpoint as validateWebPushEndpoint } from "./web_push.ts";
44 55 const CLAIM_COOKIE = "__Host-nzip-notify";66 const PENDING_SECONDS = 10 * 60;7+const PAIRING_WINDOW_SECONDS = 10 * 60;78 const APPROVED_SETUP_SECONDS = 24 * 60 * 60;89 const CLAIM_SECONDS = 365 * 24 * 60 * 60;910 const RENEW_WINDOW_SECONDS = 90 * 24 * 60 * 60;
@@ -209,9 +210,7 @@ function cfMetadata(
209210 const cf = request.cf;210211 return {211212 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,213+ region: typeof cf?.regionCode === "string" ? cf.regionCode.slice(0, 8) : null,215214 asn: typeof cf?.asn === "number" ? cf.asn : null,216215 };217216 }
@@ -278,7 +277,9 @@ export async function createEnrollment(
278277 (id, pairing_code_hash, claim_hash, status, user_agent_summary, device_class,279278 country, region, asn, created_at, pairing_expires_at, claim_expires_at)280279 SELECT ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?281- WHERE (SELECT COUNT(*) FROM notification_devices280+ WHERE EXISTS (SELECT 1 FROM notification_pairing_window281+ WHERE id = 1 AND enabled_until > ?)282+ AND (SELECT COUNT(*) FROM notification_devices282283 WHERE status = 'pending' AND pairing_expires_at > ?) < ?`,283284 ).bind(284285 id,
@@ -293,12 +294,11 @@ export async function createEnrollment(
293294 now + PENDING_SECONDS,294295 now + PENDING_SECONDS,295296 now,297+ now,296298 MAX_PENDING,297299 ).run();298300 if (result.meta.changes !== 1) {299- throw new NotifyHttpError(429, "pairing temporarily unavailable", {300- "retry-after": "60",301- });301+ throw new NotifyHttpError(404, "pairing unavailable");302302 }303303 console.log(304304 JSON.stringify({ event: "notify.enrollment_created", deviceId: id }),
@@ -322,6 +322,38 @@ export async function createEnrollment(
322322 });323323 }324324 325+/** Public state used to reveal the pairing action only during an owner-opened window. */326+export async function getPairingWindow(327+ request: Request,328+ env: NotifyEnv,329+): Promise<Response> {330+ if (!isSameOriginBrowserRequest(request)) {331+ throw new NotifyHttpError(403, "same-origin request required");332+ }333+ await rateLimit(env.RL_NOTIFY_READ, clientKey(request));334+ const now = nowSeconds();335+ const row = await env.DB.prepare(336+ "SELECT enabled_until FROM notification_pairing_window WHERE id = 1 AND enabled_until > ?",337+ ).bind(now).first<{ enabled_until: number }>();338+ return publicJson({339+ enabled: row !== null,340+ expiresAt: row?.enabled_until ?? null,341+ });342+}343+344+/** Open a fixed, short pairing window from the owner-authenticated API. */345+export async function openPairingWindow(env: NotifyEnv): Promise<Response> {346+ const expiresAt = nowSeconds() + PAIRING_WINDOW_SECONDS;347+ await env.DB.prepare(348+ `INSERT INTO notification_pairing_window (id, enabled_until) VALUES (1, ?)349+ ON CONFLICT(id) DO UPDATE SET enabled_until = excluded.enabled_until`,350+ ).bind(expiresAt).run();351+ console.log(JSON.stringify({ event: "notify.pairing_enabled", expiresAt }));352+ return json({ enabled: true, expiresAt }, 200, {353+ "cache-control": "no-store",354+ });355+}356+325357 async function currentDevice(326358 request: Request,327359 env: NotifyEnv,
M worker/src/notify_public.ts +4 -0
M worker/src/notify_ui.ts +23 -6
@@ -80,11 +80,12 @@ self.addEventListener("notificationclick", (event) => {
8080 const APP_SCRIPT = String.raw`(() => {8181 const footer = document.querySelector("footer");8282 const status = document.getElementById("notify-status");83- const pair = document.getElementById("pair");8483 const isStandalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;8584 let state = "unpaired";8685 let polling = 0;86+ let pairingTimer = 0;8787 let operation = false;88+ let pairingEnabled = false;8889 8990 const api = async (path, options = {}) => {9091 const response = await fetch(path, { credentials: "same-origin", ...options });
@@ -105,14 +106,15 @@ const APP_SCRIPT = String.raw`(() => {
105106 value.textContent = text;106107 return value;107108 };108- const render = (next) => {109+ const render = (next, pairing = pairingEnabled) => {109110 state = next.status || state;111+ pairingEnabled = pairing;110112 footer.replaceChildren();111113 const args = document.createElement("a");112114 args.href = "https://args.io/cat/nzip";113115 args.textContent = "args";114116 footer.append(args);115- if (state === "unpaired" || state === "pending") {117+ if (state === "unpaired" && pairingEnabled) {116118 footer.append(" · ", button("pair", "pair"));117119 document.getElementById("pair").addEventListener("click", beginPairing);118120 return;
@@ -129,6 +131,18 @@ const APP_SCRIPT = String.raw`(() => {
129131 }130132 }131133 };134+ const showPairingWindow = (pairing) => {135+ if (pairingTimer) clearTimeout(pairingTimer);136+ pairingTimer = 0;137+ const enabled = pairing.enabled === true && Number.isFinite(pairing.expiresAt);138+ render({ status: "unpaired" }, enabled);139+ if (!enabled) return;140+ const delay = Math.max(0, pairing.expiresAt * 1000 - Date.now());141+ pairingTimer = setTimeout(() => {142+ pairingTimer = 0;143+ if (state === "unpaired") render({ status: "unpaired" }, false);144+ }, delay);145+ };132146 const installAssets = async () => {133147 if (!document.querySelector('link[rel="manifest"]')) {134148 const manifest = document.createElement("link");
@@ -270,8 +284,11 @@ const APP_SCRIPT = String.raw`(() => {
270284 const code = sessionStorage.getItem("nzip-pairing-code");271285 setStatus((code ? code + "\n" : "") + "waiting");272286 startPolling();273- } else render({ status: "unpaired" });274- } catch (_) { render({ status: "unpaired" }); setStatus("offline"); }287+ } else {288+ const pairing = await api("/_notify/pairing");289+ showPairingWindow(pairing);290+ }291+ } catch (_) { render({ status: "unpaired" }, false); setStatus("offline"); }275292 })();276293 })();`;277294
@@ -291,7 +308,7 @@ export function notifyLandingPage(env: NotifyEnv): string {
291308 const host = escapeHtml(new URL(env.PUBLIC_BASE).host);292309 return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"><meta name="theme-color" content="#1e1810"><title>${host}</title>293310 <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>`;311+<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></footer><script>${APP_SCRIPT}</script></body></html>`;295312 }296313 297314 function assetResponse(
M worker/src/serve.ts +2 -0
M worker/test/index.runtime.test.ts +65 -7
@@ -9,15 +9,17 @@ describe("Worker runtime", () => {
99 1010 expect(response.status).toBe(200);1111 expect(response.headers.get("cache-control")).toBe("public, max-age=3600");12+ expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");13+ expect(response.headers.get("x-frame-options")).toBe("DENY");1214 expect(await response.text()).toContain("nzip");1315 });1416 15- it("keeps the root shell claim-independent until pairing is requested", async () => {17+ it("keeps the root shell claim-independent and hides pairing by default", async () => {1618 const response = await SELF.fetch("https://share.example.com/");1719 const html = await response.text();1820 1921 expect(response.headers.get("set-cookie")).toBeNull();20- expect(html).toContain('id="pair"');22+ expect(html).not.toContain('id="pair"');2123 expect(html).toContain("maximum-scale=1, user-scalable=no");2224 expect(html).not.toContain('<link rel="manifest"');2325 const count = await env.DB.prepare(
@@ -26,6 +28,51 @@ describe("Worker runtime", () => {
2628 expect(count?.count).toBe(0);2729 });2830 31+ it("rejects enrollment until the owner opens a pairing window", async () => {32+ const browserHeaders = {33+ origin: "https://share.example.com",34+ "sec-fetch-site": "same-origin",35+ "content-type": "application/json",36+ };37+ const status = await SELF.fetch("https://share.example.com/_notify/pairing", {38+ headers: { "sec-fetch-site": "same-origin" },39+ });40+ expect(status.status).toBe(200);41+ expect(await status.json()).toEqual({ enabled: false, expiresAt: null });42+43+ const enrolled = await SELF.fetch(44+ "https://share.example.com/_notify/enrollments",45+ { method: "POST", headers: browserHeaders, body: "{}" },46+ );47+ expect(enrolled.status).toBe(404);48+ expect(await enrolled.json()).toEqual({ error: "pairing unavailable" });49+50+ const crossOrigin = await SELF.fetch("https://share.example.com/_notify/pairing", {51+ headers: {52+ origin: "https://attacker.example",53+ "sec-fetch-site": "cross-site",54+ },55+ });56+ expect(crossOrigin.status).toBe(403);57+58+ await SELF.fetch("https://share.example.com/api/notify/pairing", {59+ method: "POST",60+ headers: {61+ authorization: "Bearer runtime-test-token",62+ "content-type": "application/json",63+ },64+ body: "{}",65+ });66+ await env.DB.prepare(67+ "UPDATE notification_pairing_window SET enabled_until = 0 WHERE id = 1",68+ ).run();69+ const expired = await SELF.fetch(70+ "https://share.example.com/_notify/enrollments",71+ { method: "POST", headers: browserHeaders, body: "{}" },72+ );73+ expect(expired.status).toBe(404);74+ });75+2976 it("enrolls, previews, approves, and activates while delivery is disabled", async () => {3077 const browserHeaders = {3178 origin: "https://share.example.com",
@@ -33,6 +80,21 @@ describe("Worker runtime", () => {
3380 "content-type": "application/json",3481 "user-agent": "runtime mobile browser",3582 };83+ const ownerHeaders = {84+ authorization: "Bearer runtime-test-token",85+ "content-type": "application/json",86+ };87+ const opened = await SELF.fetch(88+ "https://share.example.com/api/notify/pairing",89+ { method: "POST", headers: ownerHeaders, body: "{}" },90+ );91+ expect(opened.status).toBe(200);92+ expect(await opened.json()).toMatchObject({ enabled: true });93+ const pairing = await SELF.fetch("https://share.example.com/_notify/pairing", {94+ headers: { "sec-fetch-site": "same-origin" },95+ });96+ expect(await pairing.json()).toMatchObject({ enabled: true });97+3698 const enrolled = await SELF.fetch(3799 "https://share.example.com/_notify/enrollments",38100 { method: "POST", headers: browserHeaders, body: "{}" },
@@ -45,10 +107,6 @@ describe("Worker runtime", () => {
45107 expect(cookie).toMatch(/^__Host-nzip-notify=/);46108 expect(enrollment.code).toMatch(/^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/);47109 48- const ownerHeaders = {49- authorization: "Bearer runtime-test-token",50- "content-type": "application/json",51- };52110 const preview = await SELF.fetch(53111 `https://share.example.com/api/notify/approvals/${enrollment.code}`,54112 { headers: ownerHeaders },
@@ -139,7 +197,7 @@ describe("Worker runtime", () => {
139197 });140198 141199 expect(response.status).toBe(200);142- expect((await response.json<{ version: string }>()).version).toBe("0.5.0");200+ expect((await response.json<{ version: string }>()).version).toBe("0.6.0");143201 });144202 145203 it("creates, lists, renames, and redescribes vaults", async () => {