Describe the wildcard DNS and Worker route workflow, Free-plan WAF rule, root build base, legacy compatibility, browser storage boundaries, and exact-host passkey guidance. Call out the remaining Public Suffix List and HSTS hardening required before claiming hostile-tenant identity isolation.
- commit
- 3edf2f937f320b726efd71826491911a7be3b55e
- parent
- be8f7ee4301de6f773250dcbf9d5ef8cbaf220e2
- author
- Dami <dami.etoile@gmail.com>
| M | ARCHITECTURE.md | 123 | ++++++++++++++++--------- |
| M | CONTRIBUTING.md | 5 | + |
| M | README.md | 21 | +++-- |
| M | SECURITY.md | 125 | ++++++++++++++++--------- |
| M | cli/ | 2 | +- |
| M | shared/ | 2 | +- |
| M | skills/ | 19 | ++-- |
| M | worker/ | 146 | +++++++++++++++++++++++++----- |
@@ -1,15 +1,16 @@
11 # nzip architecture22 3-nzip is a Deno CLI backed by a Cloudflare Worker, R2 object storage, and D1 relational state. The4-CLI and Worker share one runtime-neutral package so both sides canonicalize and hash manifests5-identically.3+nzip is a Deno CLI backed by a Cloudflare Worker, R2 object storage, and D14+relational state. The CLI and Worker share one runtime-neutral package so both5+sides canonicalize and hash manifests identically.66 77 ## Components88 99 ```mermaid1010 flowchart TB11- CLI["Deno CLI"] -- "bearer API" --> W["Worker"]12- V(["visitor"]) -- "GET /2a3f" --> C{"Workers Cache"}11+ CLI["Deno CLI"] -- "bearer API at control host" --> W["Worker"]12+ SHORT(["GET control/2a3f"]) -- "308" --> SITE["https://2a3f.site-domain/"]13+ V(["visitor"]) -- "GET site hostname" --> C{"Workers Cache"}1314 C -- "hit" --> RESP["public response"]1415 C -- "miss" --> W1516 W -- "tagged response" --> RESP@@ -17,35 +18,60 @@ flowchart TB
1718 W --> D1[("D1 state")]1819 ```1920 20-- `shared/` defines manifests, hashing, target parsing, limits, media types, and API contracts.21-- `cli/` bundles local files, uploads missing blobs, commits sites, and manages owner operations.22-- `worker/` serves public sites and implements authenticated management, security telemetry,23- notifications, cleanup, and delivery retries.21+- `shared/` defines manifests, hashing, target parsing, limits, media types, and22+ API contracts.23+- `cli/` bundles local files, uploads missing blobs, commits sites, and manages24+ owner operations.25+- `worker/` serves public sites and implements authenticated management,26+ security telemetry, notifications, cleanup, and delivery retries.2427 2528 ## Push protocol2629 27-A push is a stateless three-step exchange; the manifest is the state carried between steps:30+A push is a stateless three-step exchange; the manifest is the state carried31+between steps:2832 2933 1. `POST /api/push/prepare` sends the manifest and returns missing blob hashes.30-2. `PUT /api/blob/{sha256}` uploads only missing blobs, with bounded concurrency. The Worker31- re-hashes each body and rejects mismatches or oversized blobs.32-3. `POST /api/push/commit` verifies every blob, resolves or allocates the address, applies TTL and33- password policy, repoints the site, and appends retained history atomically.34+2. `PUT /api/blob/{sha256}` uploads only missing blobs, with bounded35+ concurrency. The Worker re-hashes each body and rejects mismatches or36+ oversized blobs.37+3. `POST /api/push/commit` verifies every blob, resolves or allocates the38+ address, applies TTL and password policy, repoints the site, and appends39+ retained history atomically.3440 35-Content-addressed blobs deduplicate across every site. The last ten pushes per site remain36-addressable for revert operations.41+Content-addressed blobs deduplicate across every site. The last ten pushes per42+site remain addressable for revert operations.3743 3844 ## Serving and caching3945 40-Serving normally requires one D1 read for site state and two R2 reads for the manifest and file.41-Public responses use `ETag` revalidation and a 60-second Workers Cache entry. Cache hits skip Worker42-execution and storage reads, although they still count as Worker requests.43-44-Public responses carry a site cache tag. Pushes, reverts, deletion, TTL changes, and password-policy45-changes purge that tag. Protected responses and errors are never publicly cached.46-47-Single-file sites serve at the bare address. Multi-file sites redirect to the trailing-slash form so48-relative asset paths resolve correctly.46+The configured control origin owns authenticated APIs, notification enrollment,47+and legacy short paths. A legacy `/<address>` request receives a permanent `308`48+to `https://<address>.<site-domain>/`; the control origin never serves artifact49+bytes. The wildcard route accepts only exact four-character lowercase50+hexadecimal hostnames. Management and notification routes are not dispatched on51+artifact hosts.52+53+Each artifact hostname is a distinct browser origin. Origin-scoped storage,54+credentials, passkey defaults, and service-worker control therefore stay within55+one site. Artifact responses also disable `document.domain` relaxation. Until56+`SITE_DOMAIN` is registered in the Public Suffix List, WebAuthn still permits a57+site to deliberately choose that parent domain as its RP ID; applications that58+require isolation must explicitly use their exact hostname.59+60+Serving normally requires one D1 read for site state and two R2 reads for the61+manifest and file. Public responses use `ETag` revalidation and a 60-second62+Workers Cache entry. Cache hits skip Worker execution and storage reads,63+although they still count as Worker requests.64+65+Public responses carry a site cache tag. Pushes, reverts, deletion, TTL changes,66+and password-policy changes purge that tag. Protected responses and errors are67+never publicly cached.68+69+Site roots always use `/`. Directory paths redirect to the trailing-slash form70+so relative asset paths resolve correctly, and explicit `index.html` paths71+redirect to their containing directory. For migration compatibility, an72+unresolved `/<address>/...` asset path on that address's hostname falls back to73+the corresponding root-relative asset. A real file at the prefixed path always74+wins.4975 5076 ## Storage model5177 @@ -70,14 +96,15 @@ Security and notification state:
7096 | `notification_events` | bounded notification payloads and click targets |7197 | `notification_deliveries` | leased delivery attempts, retries, and outcomes |7298 73-An R2 object is deleted only when no live site or retained history entry references it and it is74-older than 24 hours. This prevents concurrent garbage collection from deleting an in-flight push.99+An R2 object is deleted only when no live site or retained history entry100+references it and it is older than 24 hours. This prevents concurrent garbage101+collection from deleting an in-flight push.75102 76103 ## Notification flow77104 78-Notification setup separates enrollment, owner approval, and subscription attachment. Delivery is79-also asynchronous: the owner request persists an event and per-device attempts before background80-work contacts a Web Push provider.105+Notification setup separates enrollment, owner approval, and subscription106+attachment. Delivery is also asynchronous: the owner request persists an event107+and per-device attempts before background work contacts a Web Push provider.81108 82109 ```mermaid83110 flowchart TB@@ -104,19 +131,21 @@ flowchart TB
104131 TARGET --> SITE["root or pinned site"]105132 ```106133 107-Pairing codes cannot approve themselves; the approval path always requires the owner bearer token.108-Subscription endpoints must match the configured provider-origin allowlist. Notification click109-targets are resolved at tap time and open only the deployment root or the unchanged site manifest110-recorded with the event.134+Pairing codes cannot approve themselves; the approval path always requires the135+owner bearer token. Subscription endpoints must match the configured136+provider-origin allowlist. Notification click targets are resolved at tap time137+and open only the deployment root or the unchanged site manifest recorded with138+the event.111139 112140 ## Background work113141 114-The five-minute schedule evaluates enumeration windows and drains notification deliveries. The daily115-schedule expires sites, garbage-collects content, prunes telemetry and notification state, and sends116-the security activity digest.142+The five-minute schedule evaluates enumeration windows and drains notification143+deliveries. The daily schedule expires sites, garbage-collects content, prunes144+telemetry and notification state, and sends the security activity digest.117145 118-Durable outboxes separate request acceptance from email and Web Push delivery. Delivery workers use119-leases, bounded retry schedules, and terminal outcomes so overlapping cron executions remain safe.146+Durable outboxes separate request acceptance from email and Web Push delivery.147+Delivery workers use leases, bounded retry schedules, and terminal outcomes so148+overlapping cron executions remain safe.120149 121150 ## Observability122151 @@ -134,13 +163,15 @@ flowchart TB
134163 PROBES --> METRICS["D1 row metrics"]135164 ```136165 137-Workers Logs provide sampled request-level context. D1 probe windows remain bounded but unsampled138-for automatic incident decisions. D1 row metrics are the direct storage-budget signal.166+Workers Logs provide sampled request-level context. D1 probe windows remain167+bounded but unsampled for automatic incident decisions. D1 row metrics are the168+direct storage-budget signal.139169 140170 ## Free-tier design target141171 142-nzip is designed so a small personal deployment can stay inside Cloudflare's included usage. This is143-a design target, not a guarantee: quotas are account-wide and Cloudflare may change them.172+nzip is designed so a small personal deployment can stay inside Cloudflare's173+included usage. This is a design target, not a guarantee: quotas are174+account-wide and Cloudflare may change them.144175 145176 | resource | bounding strategy | usage reference |146177 | ------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |@@ -151,6 +182,6 @@ a design target, not a guarantee: quotas are account-wide and Cloudflare may cha
151182 | R2 | content deduplication, history cap, and daily GC | [R2 pricing](https://developers.cloudflare.com/r2/pricing/) |152183 | Alert email | one verified Email Routing destination | [Email Service pricing](https://developers.cloudflare.com/email-service/platform/pricing/) |153184 154-Probe persistence happens after the public response and catches its own errors. Exhausting the155-telemetry budget reduces detection coverage until quotas reset but does not stop ordinary site156-serving.185+Probe persistence happens after the public response and catches its own errors.186+Exhausting the telemetry budget reduces detection coverage until quotas reset187+but does not stop ordinary site serving.@@ -59,6 +59,11 @@ In another terminal, point the CLI at the local Worker:
5959 nzip auth --server http://localhost:8787 --token dev-token-local-only6060 ```6161 62+The example environment sets `SITE_DOMAIN=localhost`, so a committed address is returned as63+`http://2a3f.localhost:8787/`. Modern browsers and `curl` resolve `*.localhost` to loopback; the64+control shorthand `http://localhost:8787/2a3f` exercises the same permanent redirect used in65+production.66+6267 To exercise scheduled handlers, run `npx wrangler dev --test-scheduled`, then request a cron:6368 6469 ```sh@@ -14,7 +14,7 @@ expiration, password protection, owner notifications, and enumeration alerts._
1414 $ nzip site push ./demo work:demo --ttl 30d1515 bundling ./demo … 3 files, 197 B1616 manifest 4a91d75d — 3 new blobs (197 B), 0 deduped17-✓ pushed work:demo -> https://share.demo.dev/12d8 (expires in 30d, push #1)17+✓ pushed work:demo -> https://12d8.demo.dev/ (expires in 30d, push #1)1818 ```1919 2020 ## Install@@ -43,12 +43,16 @@ Every share lives at four hex characters. The first digit selects one of 16 regi
4343 the remaining three select one of 4,096 randomly allocated sites within that vault.4444 4545 ```text46-https://share.demo.dev/2a3f47- |+- site 0xa3f48- +-- vault 0x2 ("work")46+https://2a3f.demo.dev/47+ |+- site 0xa3f48+ +-- vault 0x2 ("work")4949 ```5050 5151 Aliases such as `work:demo` are resolved by the authenticated API; public URLs do not expose them.52+The control-plane shorthand `https://share.demo.dev/2a3f` permanently redirects to the canonical53+site hostname. Each site hostname is a separate browser origin, so its storage, service workers,54+and host-only cookies are isolated from other nzip sites. The default passkey relying-party ID is55+also the site hostname; see the parent-domain caveat in [SECURITY.md](SECURITY.md).5256 5357 | form | example | meaning |5458 | ----------- | ----------- | -------------------------- |@@ -64,8 +68,8 @@ Aliases such as `work:demo` are resolved by the authenticated API; public URLs d
6468 duration or `forever`.6569 - **Revertible.** The last ten pushes per site are retained and available through6670 `nzip site revert`.67-- **Password-protectable.** Password policy is committed atomically with content. Signed per-site68- cookies are invalidated when the policy changes.71+- **Password-protectable.** Password policy is committed atomically with content. Signed, host-only72+ per-site cookies are invalidated when the policy changes.6973 - **Recoverable.** `nzip site cp` reconstructs and verifies the currently hosted bundle.7074 - **Owner notifications.** Explicitly approved phones receive Web Push notifications with bounded,7175 same-origin click targets.@@ -135,8 +139,9 @@ trust boundaries.
135139 136140 ## Self-hosting137141 138-nzip is self-hosted; there is no bundled public service. The Worker uses a custom domain, R2, D1,139-rate-limit bindings, and optional Email Routing and Web Push configuration.142+nzip is self-hosted; there is no bundled public service. The Worker uses an exact control hostname,143+a wildcard site route, R2, D1, rate-limit bindings, and optional Email Routing and Web Push144+configuration.140145 141146 Follow [`worker/setup.md`](worker/setup.md) for provisioning, migrations, secrets, notification142147 setup, deployment, and operational checks. The system is designed for small personal deployments on@@ -1,56 +1,86 @@
11 # nzip security22 3-nzip is a personal, single-owner service. Its short public URLs are convenient addresses, not4-secrets. Security comes from explicit authentication, password protection where needed, bounded5-public surfaces, owner-approved device enrollment, and observable abuse controls.6-7-Only publish content you trust. Sites hosted at `https://<deployment>/<address>/` share one browser8-origin. They therefore share origin-scoped storage and credentials, including localStorage,9-IndexedDB, OPFS, CacheStorage, and passkey relying-party identity. Path-scoped service workers and10-unlock cookies do not create an isolation boundary: script in one site can request another site's11-path, and the browser can attach that site's unlock cookie. Password protection gates12-unauthenticated network access; it does not isolate mutually untrusted sites.3+nzip is a personal, single-owner service. Its short public URLs are convenient4+addresses, not secrets. Security comes from explicit authentication, password5+protection where needed, bounded public surfaces, owner-approved device6+enrollment, and observable abuse controls.7+8+Sites execute at `https://<address>.<site-domain>/`, giving every address a9+distinct browser origin. Origin-scoped localStorage, IndexedDB, OPFS,10+CacheStorage, service workers, cookies, and passkey defaults are isolated11+between sites. The control-plane shorthand12+`https://<control-origin>/<address>` is redirect-only and never serves artifact13+bytes.14+15+Subdomain isolation is a browser security boundary, not a content-safety review.16+A hosted application still has its own origin's ambient authority, can make17+cross-origin network requests where a target permits them, and can deceive its18+visitors. Artifact responses disable `document.domain` relaxation so sibling19+site hostnames cannot opt back into a shared origin.20+21+WebAuthn has an additional domain rule: a subdomain may deliberately choose its22+registrable parent as its RP ID. On `2a3f.n.zip`, the safe per-site default is23+`2a3f.n.zip`, and passkey applications should explicitly set24+`rp.id = location.hostname`. Until `n.zip` is accepted into the Public Suffix25+List, an arbitrary site can still request the parent RP ID `n.zip`; it cannot26+request a sibling RP ID such as `ffff.n.zip`. Do not use passkeys on the control27+origin or claim hostile-tenant passkey isolation until that Public Suffix List28+boundary ships in target browsers.1329 1430 ## Reporting a vulnerability1531 16-Do not disclose a vulnerability in a public issue. Use the repository's private security advisory17-channel when available, or contact the repository owner privately. Include affected versions,18-reproduction steps, impact, and any suggested mitigation.32+Do not disclose a vulnerability in a public issue. Use the repository's private33+security advisory channel when available, or contact the repository owner34+privately. Include affected versions, reproduction steps, impact, and any35+suggested mitigation.1936 2037 ## Trust boundaries2138 2239 - Every `/api/*` management route requires the deployment-wide bearer token.23-- The bearer token stays in Worker secrets and the CLI's mode-0600 configuration file; browsers and24- installed notification apps never receive it.40+- The bearer token stays in Worker secrets and the CLI's mode-0600 configuration41+ file; browsers and installed notification apps never receive it.2542 - Public site addresses occupy only 16 bits and should be treated as enumerable.26-- Public sites are mutually trusted because all path-hosted addresses share the deployment origin.27- Use separate per-site hostnames before hosting mutually untrusted applications or relying on28- site-specific browser identity or storage isolation.29-- Password-protected sites use PBKDF2 hashes and signed, versioned cookies. Changing password policy30- invalidates existing cookies immediately.31-- Notification click targets are same-origin paths pinned to an existing site manifest. Arbitrary32- external URLs are rejected.33-- Web Push subscription endpoints must match configured exact HTTPS provider origins. Wildcards,34- credentials, IP literals, alternate ports, and the deployment's own origin are rejected.43+- The wildcard route accepts only exact four-character lowercase hexadecimal44+ site hostnames. Unknown wildcard hosts cannot dispatch management or45+ notification APIs.46+- Artifact hostnames are mutually isolated browser origins. The exact control47+ origin alone owns `/api/*`, notification enrollment, and the root notification48+ PWA.49+- Password-protected sites use PBKDF2 hashes and signed, versioned cookies.50+ Changing password policy invalidates existing cookies immediately. Unlock51+ cookies use the `__Host-` prefix and are host-only, so one site cannot plant52+ or receive another site's unlock state.53+- Notification click targets are same-origin paths pinned to an existing site54+ manifest. Arbitrary external URLs are rejected.55+- Web Push subscription endpoints must match configured exact HTTPS provider56+ origins. Wildcards, credentials, IP literals, alternate ports, and the57+ deployment's own origin are rejected.58+59+The deployment runbook includes an optional Free-plan WAF custom rule that60+mirrors the hostname shape at the edge. Worker validation remains the source of61+truth, so correctness does not depend on the WAF rule or paid regular-expression62+support.3563 3664 ## Notification pairing3765 38-Pairing is closed by default. An authenticated owner opens a fixed 10-minute window with39-`nzip notify pair`. The public root reveals the pairing action only during that window, and the40-Worker enforces the same deadline when creating an enrollment.66+Pairing is closed by default. An authenticated owner opens a fixed 10-minute67+window with `nzip notify pair`. The public root reveals the pairing action only68+during that window, and the Worker enforces the same deadline when creating an69+enrollment.4170 42-Pairing codes are short-lived, normalized, and stored only as keyed hashes. A code identifies a43-pending device but cannot approve itself; approval still requires the owner bearer token. Device44-claims use `HttpOnly`, `Secure`, `SameSite=Lax`, host-only cookies and transition through pending,45-approved, and active states before notifications can be delivered.71+Pairing codes are short-lived, normalized, and stored only as keyed hashes. A72+code identifies a pending device but cannot approve itself; approval still73+requires the owner bearer token. Device claims use `HttpOnly`, `Secure`,74+`SameSite=Lax`, host-only cookies and transition through pending, approved, and75+active states before notifications can be delivered.4676 47-The root pairing surface cannot be framed. Pairing-state reads are same-origin and rate-limited, and48-pending pages do not expose a second enrollment action.77+The root pairing surface cannot be framed. Pairing-state reads are same-origin78+and rate-limited, and pending pages do not expose a second enrollment action.4979 5080 ## Enumeration controls5181 52-The address space is intentionally small, so nzip observes and limits enumeration rather than53-claiming addresses are unguessable.82+The address space is intentionally small, so nzip observes and limits83+enumeration rather than claiming addresses are unguessable.5484 5585 ```mermaid5686 flowchart TB@@ -71,8 +101,9 @@ flowchart TB
71101 EMAIL --> INBOX(["operator inbox"])72102 ```73103 74-Raw client IPs are neither logged nor stored. Scanner identities are HMAC-derived. Probe rows are75-deduplicated, capped per scanner and Cloudflare location, and pruned after seven days.104+Raw client IPs are neither logged nor stored. Scanner identities are105+HMAC-derived. Probe rows are deduplicated, capped per scanner and Cloudflare106+location, and pruned after seven days.76107 77108 The evaluator opens or escalates incidents on these signals:78109 @@ -83,19 +114,21 @@ The evaluator opens or escalates incidents on these signals:
83114 | confirmed | an enumeration request reaches the rate limiter |84115 | confirmed | a live hit follows sequence evidence or occurs during an open incident |85116 86-Duplicate alert email is suppressed unless severity increases, volume doubles, a live site is hit87-for the first time, or a new vault is targeted after 30 minutes. Active incidents summarize at most88-hourly and resolve after three quiet windows. Alert payloads are committed before delivery and89-retried with stable notification identifiers.117+Duplicate alert email is suppressed unless severity increases, volume doubles, a118+live site is hit for the first time, or a new vault is targeted after 30119+minutes. Active incidents summarize at most hourly and resolve after three quiet120+windows. Alert payloads are committed before delivery and retried with stable121+notification identifiers.90122 91123 ## Operational monitoring92124 93-- Filter Workers Logs on `event = "security.request"` for sampled request context.125+- Filter Workers Logs on `event = "security.request"` for sampled request126+ context.94127 - Watch D1 row writes for the enumeration-telemetry budget.95-- Keep the Email Routing destination verified and test it through the authenticated96- `/api/security/test-alert` endpoint.97-- Treat notification titles and bodies as lock-screen-visible. Never include credentials, private98- URLs, or sensitive personal information.128+- Keep the Email Routing destination verified and test it through the129+ authenticated `/api/security/test-alert` endpoint.130+- Treat notification titles and bodies as lock-screen-visible. Never include131+ credentials, private URLs, or sensitive personal information.99132 100133 Deployment configuration and security-alert setup are documented in101134 [`worker/setup.md`](worker/setup.md).@@ -28,7 +28,7 @@ deno run -A jsr:@nzip/cli --help
2828 nzip auth --server https://share.demo.dev # authenticate (prompts for the token)2929 nzip vault add work # register a named vault3030 nzip site push ./site work:demo --ttl 30d --password secret31- # → https://share.demo.dev/12d831+ # → https://12d8.demo.dev/3232 ```3333 3434 Config is saved to `~/.config/nzip/config.json` (mode 0600), so later commands just work. On a@@ -74,7 +74,7 @@ export interface CommitRequest {
7474 export interface CommitResponse {7575 /** Four-character lowercase hexadecimal address, such as `"2a3f"`. */7676 address: string;77- /** Absolute public URL of the committed site. */77+ /** Absolute public URL on the site's isolated hostname. */7878 url: string;7979 /** Resolved site alias, or `null` for an unaliased address. */8080 alias: string | null;@@ -94,8 +94,10 @@ permissions when a sandbox blocks a check instead of reporting nzip as broken.
9494 nzip site push .nzip-<purpose>/ <vault>:<alias> --ttl 1d9595 ```9696 97-5. Return the canonical URL and expiry. If the user requested a review boundary or asked for only98- the link, return only the link and wait for approval before implementing the reviewed work.97+5. Return the canonical URL and expiry. Current servers return a per-site hostname such as98+ `https://2a3f.n.zip/`; a control-origin `/<address>` URL is only a compatibility redirect. If the99+ user requested a review boundary or asked for only the link, return only the link and wait for100+ approval before implementing the reviewed work.99101 100102 ## Use guide101103 @@ -137,13 +139,12 @@ records, but other clients keep the old name; renaming to a name outside `allowV
137139 Treat `revert`, vault renames, password changes, TTL changes, and deletion as state changes; do not138140 perform them while merely diagnosing. Never infer deletion confirmation.139141 140-Notifications are external, lock-screen-visible side effects. Do not send,141-test, approve, or revoke unless the user explicitly requests that action. Never142-put credentials, private URLs, sensitive personal data, or other secrets in a143-notification title or body. Prefer `--open <target>` for click-throughs because144-the CLI resolves an existing nzip site, applies vault guardrails, and sends only145-a same-origin path. A pairing code is short-lived but is not sufficient on its146-own; approval still uses the configured owner bearer token.142+Notifications are external, lock-screen-visible side effects. Do not send, test, approve, or revoke143+unless the user explicitly requests that action. Never put credentials, private URLs, sensitive144+personal data, or other secrets in a notification title or body. Prefer `--open <target>` for145+click-throughs because the CLI resolves an existing nzip site, applies vault guardrails, and sends146+only a control-origin path that redirects to the isolated site hostname. A pairing code is147+short-lived but is not sufficient on its own; approval still uses the configured owner bearer token.147148 148149 ## Pair notifications149150 @@ -3,10 +3,10 @@
33 Everything here happens once. Day-to-day content pushes never touch wrangler.44 55 The default architecture targets the Workers Free plan. It requires no paid6-Email Sending subscription: alerts use Email Routing to one verified destination.7-Free-tier limits are account-wide, so review the8-[budget notes](../ARCHITECTURE.md#free-tier-design-target) before exposing a busy or9-multi-tenant deployment.6+Email Sending subscription: alerts use Email Routing to one verified7+destination. Free-tier limits are account-wide, so review the8+[budget notes](../ARCHITECTURE.md#free-tier-design-target) before exposing a9+busy or multi-tenant deployment.1010 1111 ```sh1212 cd worker@@ -17,8 +17,10 @@ npx wrangler login
1717 # 2. private Wrangler config1818 cp wrangler.jsonc wrangler.local.jsonc1919 # Edit wrangler.local.jsonc now:20-# - routes[0].pattern: your hostname, such as share.demo.dev21-# - vars.PUBLIC_BASE: https://<that hostname>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.dev2224 # - d1_databases[0].database_id: filled in after D1 creation2325 2426 # 3. storage@@ -38,10 +40,97 @@ npx wrangler email routing enable example.com
3840 npx wrangler email routing addresses create operator@example.com3941 # Edit send_email and ALERT_EMAIL_* in wrangler.local.jsonc as shown below.4042 41-# 7. first deploy43+# 7. wildcard DNS (Cloudflare DNS dashboard)44+# Create a proxied `*` CNAME pointing to the control hostname.45+46+# 8. first deploy4247 npx wrangler deploy --config wrangler.local.jsonc4348 ```4449 50+## Hostname model51+52+nzip uses two public surfaces:53+54+- `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/`.58+59+The control path permanently redirects to the artifact hostname; it never serves60+artifact bytes. This separation gives every hosted site its own browser storage,61+credentials, service workers, and default passkey relying-party ID.62+63+New site builds should use `/` as their deployment base; the address is no64+longer part of the asset path and no reservation/rebuild/repush cycle is needed.65+The Worker includes a compatibility fallback for existing artifacts built with66+`/<address>/`: when a prefixed file does not actually exist, it serves the67+corresponding root-relative file on the same isolated hostname.68+69+For a Free-plan deployment, use a dedicated Cloudflare zone and keep artifact70+hosts one label below the zone apex. Universal SSL covers the apex and71+first-level wildcard. The required wildcard Worker route catches every72+otherwise-unmatched subdomain in that zone, so do not point it at a zone whose73+other subdomains must be served elsewhere unless you also add more-specific74+routes.75+76+The relevant config shape is:77+78+```jsonc79+"routes": [80+ { "pattern": "share.demo.dev", "custom_domain": true },81+ { "pattern": "*.demo.dev/*", "zone_name": "demo.dev" }82+],83+"vars": {84+ "PUBLIC_BASE": "https://share.demo.dev",85+ "SITE_DOMAIN": "demo.dev"86+}87+```88+89+Custom Domains do not accept wildcard hostnames. Create a proxied wildcard DNS90+record separately, then use a wildcard Worker route. In Cloudflare DNS, add `*`91+as a proxied CNAME to the control hostname (or another proxied target in the92+zone). Wait for Universal SSL to report an active certificate covering93+`*.demo.dev` before treating the deployment as ready.94+95+### Free-plan security rule96+97+The Worker validates hostnames before dispatching any API or artifact request.98+As defense in depth, a single Free-plan WAF custom rule can reject malformed99+wildcard hosts before they consume a Worker request. For `n.zip`, create a100+custom rule named `nzip: reject invalid site hosts`, choose `Block`, and use101+this expression:102+103+```text104+ends_with(lower(http.host), ".n.zip") and (105+ len(http.host) ne 10 or106+ not (substring(lower(http.host), 0, 1) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or107+ not (substring(lower(http.host), 1, 2) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or108+ not (substring(lower(http.host), 2, 3) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or109+ not (substring(lower(http.host), 3, 4) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"})110+)111+```112+113+This avoids the paid `matches` regular-expression operator. Adapt both the114+suffix and expected hostname length for other zones. Keep control-plane security115+rules explicitly scoped to `http.host eq "n.zip"`; rules intended for all public116+traffic should include both the apex and valid site hostnames. The Worker's own117+rate-limit bindings remain authoritative for address enumeration and118+`POST /__unlock`, so the optional edge rule does not consume the Free plan's119+single rate-limit rule.120+121+Enable `Always Use HTTPS` for the entire zone. Once every wildcard hostname has122+a valid certificate, enable HSTS with a real duration; begin without `preload`,123+verify the deployment, and only then consider a one-year duration plus preload.124+`includeSubDomains` now intentionally covers all isolated site origins.125+126+For passkey applications, use the exact artifact hostname as the RP ID—normally127+`location.hostname`. Subdomains can deliberately select their registrable128+parent as a WebAuthn RP ID, so per-site origins alone do not make the parent129+domain safe for control-plane passkeys. Before hosting mutually untrusted130+passkey applications, register `SITE_DOMAIN` in the Public Suffix List and wait131+for the change to reach the browsers in scope. Until then, do not use passkeys132+with the parent `SITE_DOMAIN` RP ID.133+45134 ## Free security alert email46135 47136 Enumeration alerts use the Email Routing Worker binding, not the paid Email@@ -67,8 +156,8 @@ Click the verification link Cloudflare sends, then configure:
67156 Apply `migrations/0002_security_alerts.sql`,68157 `migrations/0003_security_notification_outbox.sql`,69158 `migrations/0004_vault_descriptions.sql`, and70-`migrations/0005_notifications.sql` before deploying an upgraded Worker.71-After deployment, send a delivery test through the owner-authenticated endpoint:159+`migrations/0005_notifications.sql` before deploying an upgraded Worker. After160+deployment, send a delivery test through the owner-authenticated endpoint:72161 73162 ```sh74163 curl -X POST -H "Authorization: Bearer $NZIP_TOKEN" \@@ -84,13 +173,13 @@ resolve the incident. Probe rows are deduplicated by scanner/address, capped at
84173 30 per scanner per minute in each Cloudflare location; 429 confirmations have a85174 separate one-per-minute persistence cap. Alert payloads are written to a D186175 outbox before delivery and retried by later cron runs with a stable notification87-ID. Telemetry contains no raw IP and is pruned after seven days. A daily activity88-digest is sent only when probes occurred in the preceding 24 hours.176+ID. Telemetry contains no raw IP and is pruned after seven days. A daily177+activity digest is sent only when probes occurred in the preceding 24 hours.89178 90179 ### Operational checks after deployment91180 92-- **Workers Metrics:** request volume and errors; cache hits reduce execution and93- storage reads but still count as Worker requests.181+- **Workers Metrics:** request volume and errors; cache hits reduce execution182+ and storage reads but still count as Worker requests.94183 - **Workers Observability:** filter `event = "security.request"`; Free retains95184 Workers Logs for three days.96185 - **D1 Metrics → Row Metrics:** rows written is the main enumeration-telemetry@@ -98,9 +187,9 @@ digest is sent only when probes occurred in the preceding 24 hours.
98187 - **Email Routing:** the destination must remain verified. A successful test99188 endpoint response means Cloudflare accepted the message for delivery.100189 101-`routes[0].pattern` and `vars.PUBLIC_BASE` are required user-provided values.102-`PUBLIC_BASE` is the origin printed in share URLs and the server URL passed to103-`nzip auth`.190+Both routes, `vars.PUBLIC_BASE`, and `vars.SITE_DOMAIN` are required191+user-provided values. `PUBLIC_BASE` is the server URL passed to `nzip auth`;192+commit responses print the isolated site URL derived from `SITE_DOMAIN`.104193 105194 When upgrading an existing deployment created before `auth_version` was added,106195 apply its migration before deploying the new Worker:@@ -156,8 +245,8 @@ deployment configuration is valid and a real device is ready for the pairing
156245 flow.157246 158247 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:248+terminal. Then open the deployment root in its browser, tap the temporary `pair`249+footer action, and approve the displayed code:161250 162251 ```sh163252 nzip notify pair@@ -180,14 +269,20 @@ or other sensitive data in them.
180269 > auto-create it, or `PUT /accounts/{id}/workers/subdomain` with181270 > `{"subdomain":"<name>"}`.182271 183-## Required custom domain272+## Required domain and wildcard route184273 185-1. Add your zone to your Cloudflare account (registrar -> Cloudflare274+1. Add a dedicated zone to your Cloudflare account (registrar -> Cloudflare186275 nameservers).187-2. In `wrangler.local.jsonc`, set `routes[0].pattern`, `vars.PUBLIC_BASE`, and188- your D1 `database_id`.189-3. `npx wrangler deploy --config wrangler.local.jsonc` - the custom-domain route190- provisions DNS + cert automatically.276+2. In DNS, create a proxied wildcard record for `*`; do not leave the wildcard277+ DNS-only.278+3. In `wrangler.local.jsonc`, set the exact control Custom Domain, wildcard279+ Worker route, `PUBLIC_BASE`, `SITE_DOMAIN`, and your D1 `database_id`.280+4. Run `npx wrangler deploy --dry-run --config wrangler.local.jsonc`, then281+ deploy. The exact Custom Domain provisions its own DNS and certificate; the282+ wildcard route uses the wildcard DNS record from step 2.283+5. Verify that `/<address>` returns `308` to `https://<address>.<site-domain>/`,284+ an unknown wildcard hostname returns `404`, and `/api/status` is unavailable285+ on artifact hostnames.191286 192287 ## CLI193288 @@ -203,4 +298,5 @@ nzip vault add work # slot 0x1
203298 nzip site push ./docs personal:plan --ttl forever204299 ```205300 206-Local development and test commands live in [`CONTRIBUTING.md`](../CONTRIBUTING.md).301+Local development and test commands live in302+[`CONTRIBUTING.md`](../CONTRIBUTING.md).