1# One-time Cloudflare setup23Everything here happens once. Day-to-day content pushes never touch wrangler.45The default architecture targets the Workers Free plan. It requires no paid6Email Sending subscription: alerts use Email Routing to one verified7destination. Free-tier limits are account-wide, so review the8[budget notes](../ARCHITECTURE.md#free-tier-design-target) before exposing a9busy or multi-tenant deployment.1011```sh12cd worker1314# 1. auth15npx wrangler login1617# 2. private Wrangler config18cp wrangler.jsonc wrangler.local.jsonc19# Edit wrangler.local.jsonc now:20# - routes[0].pattern: your control hostname, such as share.demo.dev21# - routes[1]: wildcard route for *.demo.dev/* with zone_name demo.dev22# - vars.PUBLIC_BASE: https://<control hostname>23# - vars.SITE_DOMAIN: the parent used by <address>.<site domain>, such as demo.dev24# - d1_databases[0].database_id: filled in after D1 creation2526# 3. storage27npx wrangler r2 bucket create nzip-content28npx wrangler d1 create nzip29# -> paste the printed database_id into wrangler.local.jsonc3031# 4. schema32npx wrangler d1 execute nzip --remote --file schema.sql3334# 5. owner token35openssl rand -hex 32 # keep this — it's your CLI token36npx wrangler secret put NZIP_TOKEN --config wrangler.local.jsonc3738# 6. alert destination (click the verification link before deploying)39npx wrangler email routing enable example.com40npx wrangler email routing addresses create operator@example.com41# Edit send_email and ALERT_EMAIL_* in wrangler.local.jsonc as shown below.4243# 7. wildcard DNS (Cloudflare DNS dashboard)44# Create a proxied `*` CNAME pointing to the control hostname.4546# 8. first deploy47npx wrangler deploy --config wrangler.local.jsonc48```4950## Hostname model5152nzip uses two public surfaces:5354- `PUBLIC_BASE`, such as `https://share.demo.dev`, is the exact control origin55 for owner APIs, notification enrollment, and legacy `/<address>` links.56- `SITE_DOMAIN`, such as `demo.dev`, produces isolated artifact origins such as57 `https://2a3f.demo.dev/`.5859The control path permanently redirects to the artifact hostname; it never serves60artifact bytes. This separation gives every hosted site its own browser storage,61service workers, host-only nzip unlock cookie, and default passkey relying-party62ID. Artifact responses also send `Origin-Agent-Cluster: ?1` so browsers keep63each site in an origin-keyed agent cluster. Do not replace it with the obsolete64`Permissions-Policy: document-domain=()` directive.6566New site builds should use `/` as their deployment base; the address is no67longer part of the asset path and no reservation/rebuild/repush cycle is needed.68The Worker includes a compatibility fallback for existing artifacts built with69`/<address>/`: when a prefixed file does not actually exist, it serves the70corresponding root-relative file on the same isolated hostname.7172For a Free-plan deployment, use a dedicated Cloudflare zone and keep artifact73hosts one label below the zone apex. Universal SSL covers the apex and74first-level wildcard. The required wildcard Worker route catches every75otherwise-unmatched subdomain in that zone, so do not point it at a zone whose76other subdomains must be served elsewhere unless you also add more-specific77routes.7879The relevant config shape is:8081```jsonc82"routes": [83 { "pattern": "share.demo.dev", "custom_domain": true },84 { "pattern": "*.demo.dev/*", "zone_name": "demo.dev" }85],86"vars": {87 "PUBLIC_BASE": "https://share.demo.dev",88 "SITE_DOMAIN": "demo.dev"89}90```9192Custom Domains do not accept wildcard hostnames. Create a proxied wildcard DNS93record separately, then use a wildcard Worker route. In Cloudflare DNS, add `*`94as a proxied CNAME to the control hostname (or another proxied target in the95zone). Wait for Universal SSL to report an active certificate covering96`*.demo.dev` before treating the deployment as ready.9798### Free-plan security rule99100The Worker validates hostnames before dispatching any API or artifact request.101As defense in depth, a single Free-plan WAF custom rule can reject malformed102wildcard hosts before they consume a Worker request. For `n.zip`, create a103custom rule named `nzip: reject invalid site hosts`, choose `Block`, and use104this expression:105106```text107ends_with(lower(http.host), ".n.zip") and (108 len(http.host) ne 10 or109 not (substring(lower(http.host), 0, 1) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or110 not (substring(lower(http.host), 1, 2) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or111 not (substring(lower(http.host), 2, 3) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or112 not (substring(lower(http.host), 3, 4) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"})113)114```115116This avoids the paid `matches` regular-expression operator. Adapt both the117suffix and expected hostname length for other zones. Keep control-plane security118rules explicitly scoped to `http.host eq "n.zip"`; rules intended for all public119traffic should include both the apex and valid site hostnames. The Worker's own120rate-limit bindings remain authoritative for address enumeration and121`POST /__unlock`, so the optional edge rule does not consume the Free plan's122single rate-limit rule.123124### Free-plan RUM exclusion125126Cloudflare can automatically inject its Real User Monitoring beacon into HTML127on Free-plan zones. Hosted artifacts should remain byte-for-byte static and128should not produce `/cdn-cgi/rum` requests during PWA reloads. Add a129Configuration Rule named `nzip: disable RUM on artifact hosts` with this130expression:131132```text133ends_with(lower(http.host), ".n.zip")134```135136Set its action to **Disable Real User Monitoring (RUM)**. Adapt the suffix for137other zones. This keeps RUM available on the control origin while disabling it138for every isolated artifact hostname. Use a Configuration Rule for this on the139Free plan; per-host Web Analytics rules are not included there.140141Enable `Always Use HTTPS` for the entire zone. Once every wildcard hostname has142a valid certificate, enable HSTS with a real duration; begin without `preload`,143verify the deployment, and only then consider a one-year duration plus preload.144`includeSubDomains` now intentionally covers all isolated site origins.145146For passkey applications, use the exact artifact hostname as the RP ID—normally147`location.hostname`. Subdomains can deliberately select their registrable148parent as a WebAuthn RP ID or set cookies for that parent domain, so per-site149origins alone do not isolate those two credential mechanisms. Applications150should use `__Host-` or otherwise host-only cookies and exact-host RP IDs. Before151hosting mutually untrusted applications, register `SITE_DOMAIN` in the152[Public Suffix List](https://publicsuffix.org/submit/) and wait for the change to153reach the browsers in scope. Until then, do not use the parent `SITE_DOMAIN` for154application cookies or passkeys.155156## Free security alert email157158Enumeration alerts use the Email Routing Worker binding, not the paid Email159Sending product. Email Routing can send to a verified destination on the free160plan. Enable it for the Worker domain, add and verify the operator address, then161set these values in `wrangler.local.jsonc`:162163```sh164npx wrangler email routing enable example.com165npx wrangler email routing addresses create operator@example.com166```167168Click the verification link Cloudflare sends, then configure:169170```jsonc171"send_email": [{ "name": "EMAIL", "destination_address": "operator@example.com" }],172"vars": {173 "ALERT_EMAIL_TO": "operator@example.com",174 "ALERT_EMAIL_FROM": "alerts@share.demo.dev"175}176```177178Apply `migrations/0002_security_alerts.sql`,179`migrations/0003_security_notification_outbox.sql`,180`migrations/0004_vault_descriptions.sql`, `migrations/0005_notifications.sql`,181`migrations/0006_notification_pairing_window.sql`, and182`migrations/0007_vault_defaults_and_app_origins.sql` before deploying an upgraded Worker. After183deployment, send a delivery test through the owner-authenticated endpoint:184185```sh186curl -X POST -H "Authorization: Bearer $NZIP_TOKEN" \187 https://share.demo.dev/api/security/test-alert188```189190The Worker opens an incident when one scanner tries 20 distinct addresses in191five minutes, or a distributed sweep reaches 128 addresses from 10 scanners with192at least 90% misses. A rate-limit hit or a suspicious live-address hit confirms193the incident. Duplicate email is suppressed unless severity increases or volume194doubles, active incidents summarize hourly, and three quiet windows (15 minutes)195resolve the incident. Probe rows are deduplicated by scanner/address, capped at19630 per scanner per minute in each Cloudflare location; 429 confirmations have a197separate one-per-minute persistence cap. Alert payloads are written to a D1198outbox before delivery and retried by later cron runs with a stable notification199ID. Telemetry contains no raw IP and is pruned after seven days. A daily200activity digest is sent only when probes occurred in the preceding 24 hours.201202### Operational checks after deployment203204- **Workers Metrics:** request volume and errors. Shared-edge caching is205 intentionally disabled because exact and wildcard hostnames must never share206 an artifact response; budget for each public request to execute the Worker.207- **Workers Observability:** filter `event = "security.request"`; Free retains208 Workers Logs for three days.209- **D1 Metrics → Row Metrics:** rows written is the main enumeration-telemetry210 budget; Free currently includes 100,000 written rows per day.211- **Email Routing:** the destination must remain verified. A successful test212 endpoint response means Cloudflare accepted the message for delivery.213214Both routes, `vars.PUBLIC_BASE`, and `vars.SITE_DOMAIN` are required215user-provided values. `PUBLIC_BASE` is the server URL passed to `nzip auth`;216commit responses print the isolated site URL derived from `SITE_DOMAIN`.217218When upgrading an existing deployment created before `auth_version` was added,219apply its migration before deploying the new Worker:220221```sh222cd worker223npx wrangler d1 execute nzip --remote --file migrations/0001_auth_version.sql224npx wrangler d1 execute nzip --remote --file migrations/0002_security_alerts.sql225npx wrangler d1 execute nzip --remote --file migrations/0003_security_notification_outbox.sql226npx wrangler d1 execute nzip --remote --file migrations/0004_vault_descriptions.sql227npx wrangler d1 execute nzip --remote --file migrations/0005_notifications.sql228npx wrangler d1 execute nzip --remote --file migrations/0006_notification_pairing_window.sql229npx wrangler d1 execute nzip --remote --file migrations/0007_vault_defaults_and_app_origins.sql230```231232## Owner notifications233234Notifications are fail-closed by default. Keep `NOTIFY_ENABLED` set to the235string `"false"` while provisioning and applying the schema. Before deploying,236copy the two notification rate-limit bindings from `wrangler.jsonc` into your237ignored `wrangler.local.jsonc`, then configure one stable VAPID key pair:238239```sh240# Generates a public/private P-256 VAPID key pair. Keep the private key private.241npx web-push generate-vapid-keys242243# Enter the generated private key only at Wrangler's interactive prompt.244npx wrangler secret put VAPID_PRIVATE_KEY --config wrangler.local.jsonc245```246247Set `VAPID_PUBLIC_KEY` to the generated public key and `VAPID_SUBJECT` to a248stable `mailto:` or `https:` contact value in `wrangler.local.jsonc`. Populate249`WEB_PUSH_ORIGINS` with a comma-separated list of exact HTTPS origins observed250from the browsers you intend to pair. Do not add wildcards or the deployment's251own origin. An empty or invalid allowlist rejects subscription attachment and252delivery.253254Apply the notification migration before deploying code that reads its tables:255256```sh257cd worker258npx wrangler d1 execute nzip --remote \259 --config wrangler.local.jsonc \260 --file migrations/0005_notifications.sql261npx wrangler d1 execute nzip --remote \262 --config wrangler.local.jsonc \263 --file migrations/0006_notification_pairing_window.sql264npx wrangler d1 execute nzip --remote \265 --config wrangler.local.jsonc \266 --file migrations/0007_vault_defaults_and_app_origins.sql267npx wrangler deploy --dry-run --config wrangler.local.jsonc268npx wrangler deploy --config wrangler.local.jsonc269```270271`NOTIFY_ENABLED` is the delivery kill switch. Leave it `"false"` until the272deployment configuration is valid and a real device is ready for the pairing273flow.274275To pair a phone, first open a 10-minute pairing window from an authenticated276terminal. Then open the deployment root in its browser, tap the temporary `pair`277footer action, and approve the displayed code:278279```sh280nzip notify pair281nzip notify approve ABCD-1234 --name "Personal phone"282```283284Wait for the browser footer to say `paired` before adding the site to the Home285Screen. Open the installed app, tap `notifications off`, and accept the system286permission request. If the installed app cannot recover the approved claim,287remove it, repeat pairing in the browser, wait for `paired`, and install again.288289After pairing succeeds, set `NOTIFY_ENABLED` to `"true"`, redeploy, and use290`nzip notify test` for the final end-to-end smoke test. Notification titles and291bodies may appear on a lock screen; never put passwords, tokens, private URLs,292or other sensitive data in them.293294> **Cron gotcha:** deploying the `triggers` block fails with a 403 (API error295> `10063`) until the account has a workers.dev subdomain registered — even if296> the Worker only serves a custom domain. Open the Workers dashboard once to297> auto-create it, or `PUT /accounts/{id}/workers/subdomain` with298> `{"subdomain":"<name>"}`.299300## Required domain and wildcard route3013021. Add a dedicated zone to your Cloudflare account (registrar -> Cloudflare303 nameservers).3042. In DNS, create a proxied wildcard record for `*`; do not leave the wildcard305 DNS-only.3063. In `wrangler.local.jsonc`, set the exact control Custom Domain, wildcard307 Worker route, `PUBLIC_BASE`, `SITE_DOMAIN`, and your D1 `database_id`.3084. Run `npx wrangler deploy --dry-run --config wrangler.local.jsonc`, then309 deploy. The exact Custom Domain provisions its own DNS and certificate; the310 wildcard route uses the wildcard DNS record from step 2.3115. Verify that `/<address>` returns `308` to `https://<address>.<site-domain>/`,312 an unknown wildcard hostname returns `404`, and `/api/status` is unavailable313 on artifact hostnames.314315## CLI316317```sh318# from the repo root319deno install -g -A -f --config deno.json -n nzip cli/main.ts320321nzip auth --server https://share.demo.dev --token <token-from-step-5>322nzip vault add personal # slot 0x0323nzip vault add work # slot 0x1324nzip site push ./docs personal:plan --ttl forever325```326327Local development and test commands live in328[`CONTRIBUTING.md`](../CONTRIBUTING.md).329330331## Site feedback migration332333Migration `0009_site_feedback.sql` adds the opt-in feedback columns to `sites` and the334`site_feedback` table; `0010_feedback_callback.sql` adds the one-shot submit callback column. Apply it to existing deployments before running a Worker that serves335`/__feedback`; fresh installations get the same schema from `schema.sql`. The Worker also needs the336`RL_FEEDBACK` rate-limit binding from `wrangler.jsonc` in any local deployment config.337338```sh339npx wrangler d1 migrations apply nzip --remote --config wrangler.local.jsonc340```341342Existing sites keep feedback off. Nothing is exposed until an owner enables it on a343password-protected site.344345## Vault purpose and policy346347Vaults partition sites by purpose, audience, retention, and protection. Give each vault a clear348`description` so an AI client can select the right destination for a task. Names such as `public`349are labels, not security guarantees.350351This branch adds migration `0008_vault_policies.sql`. Existing deployments must apply it before352running the updated Worker. Inspect/backup the existing database and use the normal migration353workflow; do not reapply the fresh schema to an existing database. Fresh installations use the354updated `schema.sql` instead. Do not run `0008` again against a freshly initialized schema.355356```sh357# Existing deployment, after reviewing/backing up the database:358npx wrangler d1 migrations apply nzip --remote --config wrangler.local.jsonc359```360361The migration adds nullable/default-off policy fields and does not change existing sites or vault362behavior. The updated API/CLI supports these policies:363364| Field | Behavior |365| --- | --- |366| `description` | Purpose/audience metadata used by agents to choose the destination |367| `defaultTtl` | Inherited retention for new sites; null inherits the global fallback |368| `maxTtl` | Enforced maximum remaining lifetime in days; null means no cap |369| `requirePassword` | Every site must remain password-protected |370| `defaultPassword` | Write-only default visitor password, stored as a PBKDF2 verifier |371| `hasDefaultPassword` | Read-only indication that new sites can inherit protection |372373Example configuration (create the password file privately; never commit it):374375```sh376nzip vault add reviews --description "Private personal review pages and temporary plans" --default-ttl 7d --max-ttl 14d --require-password --default-password-file /secure/path/review-password377nzip vault add demos --description "Non-sensitive demos intended for public viewing" --default-ttl 30d --max-ttl 90d378```379380Agents can choose `reviews` for a private plan and omit TTL/password: publication inherits seven381days and the configured protection. They do not need the shared visitor password. Sharing that382password with intended readers remains an owner action. Explicit TTL/password changes cannot383bypass `maxTtl` or `requirePassword`; enforcement exists in the API and D1 triggers, including384revision, policy-patch, and revert paths.385386Omitting policy fields on a revision preserves the site's current expiry and password. Changing387or removing a vault's default password affects **new sites only**; it does not rotate existing388site passwords or revoke their unlock cookies. Tightening a maximum TTL or requiring passwords389is rejected if existing sites conflict. Update those sites explicitly first. A vault's effective390default TTL must also fit its maximum; unlimited retention cannot fit a finite cap.391392Use `--max-ttl none`, `--allow-unprotected`, and `--no-default-password` to explicitly clear the393respective rules. Policies are not tenant isolation: the owner token can administer every vault;394CLI/MCP `allowVaults` is an additional client-side restriction, not a scoped credential.