One-time Cloudflare setup
Everything here happens once. Day-to-day content pushes never touch wrangler.
The default architecture targets the Workers Free plan. It requires no paid Email Sending subscription: alerts use Email Routing to one verified destination. Free-tier limits are account-wide, so review the budget notes before exposing a busy or multi-tenant deployment.
cd worker
# 1. auth
npx wrangler login
# 2. private Wrangler config
cp wrangler.jsonc wrangler.local.jsonc
# Edit wrangler.local.jsonc now:
# - routes[0].pattern: your control hostname, such as share.demo.dev
# - routes[1]: wildcard route for *.demo.dev/* with zone_name demo.dev
# - vars.PUBLIC_BASE: https://<control hostname>
# - vars.SITE_DOMAIN: the parent used by <address>.<site domain>, such as demo.dev
# - d1_databases[0].database_id: filled in after D1 creation
# 3. storage
npx wrangler r2 bucket create nzip-content
npx wrangler d1 create nzip
# -> paste the printed database_id into wrangler.local.jsonc
# 4. schema
npx wrangler d1 execute nzip --remote --file schema.sql
# 5. owner token
openssl rand -hex 32 # keep this — it's your CLI token
npx wrangler secret put NZIP_TOKEN --config wrangler.local.jsonc
# 6. alert destination (click the verification link before deploying)
npx wrangler email routing enable example.com
npx wrangler email routing addresses create operator@example.com
# Edit send_email and ALERT_EMAIL_* in wrangler.local.jsonc as shown below.
# 7. wildcard DNS (Cloudflare DNS dashboard)
# Create a proxied `*` CNAME pointing to the control hostname.
# 8. first deploy
npx wrangler deploy --config wrangler.local.jsonc
Hostname model
nzip uses two public surfaces:
PUBLIC_BASE, such ashttps:/, is the exact control origin for owner APIs, notification enrollment, and legacy/ share.demo.dev /links.<address> SITE_DOMAIN, such asdemo.dev, produces isolated artifact origins such ashttps:/./ 2a3f.demo.dev/
The control path permanently redirects to the artifact hostname; it never serves
artifact bytes. This separation gives every hosted site its own browser storage,
service workers, host-only nzip unlock cookie, and default passkey relying-party
ID. Artifact responses also send Origin-Agent-Cluster: ?1 so browsers keep
each site in an origin-keyed agent cluster. Do not replace it with the obsolete
Permissions-Policy: document-domain=() directive.
New site builds should use / as their deployment base; the address is no
longer part of the asset path and no reservation/rebuild/repush cycle is needed.
The Worker includes a compatibility fallback for existing artifacts built with
/: when a prefixed file does not actually exist, it serves the
corresponding root-relative file on the same isolated hostname.
For a Free-plan deployment, use a dedicated Cloudflare zone and keep artifact hosts one label below the zone apex. Universal SSL covers the apex and first-level wildcard. The required wildcard Worker route catches every otherwise-unmatched subdomain in that zone, so do not point it at a zone whose other subdomains must be served elsewhere unless you also add more-specific routes.
The relevant config shape is:
"routes": [
{ "pattern": "share.demo.dev", "custom_domain": true },
{ "pattern": "*.demo.dev/*", "zone_name": "demo.dev" }
],
"vars": {
"PUBLIC_BASE": "https://share.demo.dev",
"SITE_DOMAIN": "demo.dev"
}
Custom Domains do not accept wildcard hostnames. Create a proxied wildcard DNS
record separately, then use a wildcard Worker route. In Cloudflare DNS, add *
as a proxied CNAME to the control hostname (or another proxied target in the
zone). Wait for Universal SSL to report an active certificate covering
*.demo.dev before treating the deployment as ready.
Free-plan security rule
The Worker validates hostnames before dispatching any API or artifact request.
As defense in depth, a single Free-plan WAF custom rule can reject malformed
wildcard hosts before they consume a Worker request. For n.zip, create a
custom rule named nzip: reject invalid site hosts, choose Block, and use
this expression:
ends_with(lower(http.host), ".n.zip") and (
len(http.host) ne 10 or
not (substring(lower(http.host), 0, 1) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or
not (substring(lower(http.host), 1, 2) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or
not (substring(lower(http.host), 2, 3) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"}) or
not (substring(lower(http.host), 3, 4) in {"0" "1" "2" "3" "4" "5" "6" "7" "8" "9" "a" "b" "c" "d" "e" "f"})
)
This avoids the paid matches regular-expression operator. Adapt both the
suffix and expected hostname length for other zones. Keep control-plane security
rules explicitly scoped to http.host eq "n.zip"; rules intended for all public
traffic should include both the apex and valid site hostnames. The Worker's own
rate-limit bindings remain authoritative for address enumeration and
POST /, so the optional edge rule does not consume the Free plan's
single rate-limit rule.
Free-plan RUM exclusion
Cloudflare can automatically inject its Real User Monitoring beacon into HTML
on Free-plan zones. Hosted artifacts should remain byte-for-byte static and
should not produce / requests during PWA reloads. Add a
Configuration Rule named nzip: disable RUM on artifact hosts with this
expression:
ends_with(lower(http.host), ".n.zip")
Set its action to Disable Real User Monitoring (RUM). Adapt the suffix for other zones. This keeps RUM available on the control origin while disabling it for every isolated artifact hostname. Use a Configuration Rule for this on the Free plan; per-host Web Analytics rules are not included there.
Enable Always Use HTTPS for the entire zone. Once every wildcard hostname has
a valid certificate, enable HSTS with a real duration; begin without preload,
verify the deployment, and only then consider a one-year duration plus preload.
includeSubDomains now intentionally covers all isolated site origins.
For passkey applications, use the exact artifact hostname as the RP ID—normally
location.hostname. Subdomains can deliberately select their registrable
parent as a WebAuthn RP ID or set cookies for that parent domain, so per-site
origins alone do not isolate those two credential mechanisms. Applications
should use __Host- or otherwise host-only cookies and exact-host RP IDs. Before
hosting mutually untrusted applications, register SITE_DOMAIN in the
Public Suffix List and wait for the change to
reach the browsers in scope. Until then, do not use the parent SITE_DOMAIN for
application cookies or passkeys.
Free security alert email
Enumeration alerts use the Email Routing Worker binding, not the paid Email
Sending product. Email Routing can send to a verified destination on the free
plan. Enable it for the Worker domain, add and verify the operator address, then
set these values in wrangler.local.jsonc:
npx wrangler email routing enable example.com
npx wrangler email routing addresses create operator@example.com
Click the verification link Cloudflare sends, then configure:
"send_email": [{ "name": "EMAIL", "destination_address": "operator@example.com" }],
"vars": {
"ALERT_EMAIL_TO": "operator@example.com",
"ALERT_EMAIL_FROM": "alerts@share.demo.dev"
}
Apply migrations/,
migrations/,
migrations/, migrations/,
migrations/, and
migrations/ before deploying an upgraded Worker. After
deployment, send a delivery test through the owner-authenticated endpoint:
curl -X POST -H "Authorization: Bearer $NZIP_TOKEN" \
https://share.demo.dev/api/security/test-alert
The Worker opens an incident when one scanner tries 20 distinct addresses in five minutes, or a distributed sweep reaches 128 addresses from 10 scanners with at least 90% misses. A rate-limit hit or a suspicious live-address hit confirms the incident. Duplicate email is suppressed unless severity increases or volume doubles, active incidents summarize hourly, and three quiet windows (15 minutes) resolve the incident. Probe rows are deduplicated by scanner/address, capped at 30 per scanner per minute in each Cloudflare location; 429 confirmations have a separate one-per-minute persistence cap. Alert payloads are written to a D1 outbox before delivery and retried by later cron runs with a stable notification ID. Telemetry contains no raw IP and is pruned after seven days. A daily activity digest is sent only when probes occurred in the preceding 24 hours.
Operational checks after deployment
- Workers Metrics: request volume and errors. Shared-edge caching is intentionally disabled because exact and wildcard hostnames must never share an artifact response; budget for each public request to execute the Worker.
- Workers Observability: filter
event = "security.request"; Free retains Workers Logs for three days. - D1 Metrics → Row Metrics: rows written is the main enumeration-telemetry budget; Free currently includes 100,000 written rows per day.
- Email Routing: the destination must remain verified. A successful test endpoint response means Cloudflare accepted the message for delivery.
Both routes, vars.PUBLIC_BASE, and vars.SITE_DOMAIN are required
user-provided values. PUBLIC_BASE is the server URL passed to nzip auth;
commit responses print the isolated site URL derived from SITE_DOMAIN.
When upgrading an existing deployment created before auth_version was added,
apply its migration before deploying the new Worker:
cd worker
npx wrangler d1 execute nzip --remote --file migrations/0001_auth_version.sql
npx wrangler d1 execute nzip --remote --file migrations/0002_security_alerts.sql
npx wrangler d1 execute nzip --remote --file migrations/0003_security_notification_outbox.sql
npx wrangler d1 execute nzip --remote --file migrations/0004_vault_descriptions.sql
npx wrangler d1 execute nzip --remote --file migrations/0005_notifications.sql
npx wrangler d1 execute nzip --remote --file migrations/0006_notification_pairing_window.sql
npx wrangler d1 execute nzip --remote --file migrations/0007_vault_defaults_and_app_origins.sql
Owner notifications
Notifications are fail-closed by default. Keep NOTIFY_ENABLED set to the
string "false" while provisioning and applying the schema. Before deploying,
copy the two notification rate-limit bindings from wrangler.jsonc into your
ignored wrangler.local.jsonc, then configure one stable VAPID key pair:
# Generates a public/private P-256 VAPID key pair. Keep the private key private.
npx web-push generate-vapid-keys
# Enter the generated private key only at Wrangler's interactive prompt.
npx wrangler secret put VAPID_PRIVATE_KEY --config wrangler.local.jsonc
Set VAPID_PUBLIC_KEY to the generated public key and VAPID_SUBJECT to a
stable mailto: or https: contact value in wrangler.local.jsonc. Populate
WEB_PUSH_ORIGINS with a comma-separated list of exact HTTPS origins observed
from the browsers you intend to pair. Do not add wildcards or the deployment's
own origin. An empty or invalid allowlist rejects subscription attachment and
delivery.
Apply the notification migration before deploying code that reads its tables:
cd worker
npx wrangler d1 execute nzip --remote \
--config wrangler.local.jsonc \
--file migrations/0005_notifications.sql
npx wrangler d1 execute nzip --remote \
--config wrangler.local.jsonc \
--file migrations/0006_notification_pairing_window.sql
npx wrangler d1 execute nzip --remote \
--config wrangler.local.jsonc \
--file migrations/0007_vault_defaults_and_app_origins.sql
npx wrangler deploy --dry-run --config wrangler.local.jsonc
npx wrangler deploy --config wrangler.local.jsonc
NOTIFY_ENABLED is the delivery kill switch. Leave it "false" until the
deployment configuration is valid and a real device is ready for the pairing
flow.
To pair a phone, first open a 10-minute pairing window from an authenticated
terminal. Then open the deployment root in its browser, tap the temporary pair
footer action, and approve the displayed code:
nzip notify pair
nzip notify approve ABCD-1234 --name "Personal phone"
Wait for the browser footer to say paired before adding the site to the Home
Screen. Open the installed app, tap notifications off, and accept the system
permission request. If the installed app cannot recover the approved claim,
remove it, repeat pairing in the browser, wait for paired, and install again.
After pairing succeeds, set NOTIFY_ENABLED to "true", redeploy, and use
nzip notify test for the final end-to-end smoke test. Notification titles and
bodies may appear on a lock screen; never put passwords, tokens, private URLs,
or other sensitive data in them.
Cron gotcha: deploying the
triggersblock fails with a 403 (API error10063) until the account has a workers.dev subdomain registered — even if the Worker only serves a custom domain. Open the Workers dashboard once to auto-create it, orPUT /withaccounts/ {id}/ workers/ subdomain {"subdomain":"<name>"}.
Required domain and wildcard route
- Add a dedicated zone to your Cloudflare account (registrar -> Cloudflare nameservers).
- In DNS, create a proxied wildcard record for
*; do not leave the wildcard DNS-only. - In
wrangler.local.jsonc, set the exact control Custom Domain, wildcard Worker route,PUBLIC_BASE,SITE_DOMAIN, and your D1database_id. - Run
npx wrangler deploy --dry-run --config wrangler.local.jsonc, then deploy. The exact Custom Domain provisions its own DNS and certificate; the wildcard route uses the wildcard DNS record from step 2. - Verify that
/returns<address> 308tohttps:/, an unknown wildcard hostname returns/ <address>.<site-domain>/ 404, and/is unavailable on artifact hostnames.api/ status
CLI
# from the repo root
deno install -g -A -f --config deno.json -n nzip cli/main.ts
nzip auth --server https://share.demo.dev --token <token-from-step-5>
nzip vault add personal # slot 0x0
nzip vault add work # slot 0x1
nzip site push ./docs personal:plan --ttl forever
Local development and test commands live in
CONTRIBUTING.md.
Site feedback migration
Migration 0009_site_feedback.sql adds the opt-in feedback columns to sites and the
site_feedback table; 0010_feedback_callback.sql adds the one-shot submit callback column. Apply it to existing deployments before running a Worker that serves
/; fresh installations get the same schema from schema.sql. The Worker also needs the
RL_FEEDBACK rate-limit binding from wrangler.jsonc in any local deployment config.
npx wrangler d1 migrations apply nzip --remote --config wrangler.local.jsonc
Existing sites keep feedback off. Nothing is exposed until an owner enables it on a password-protected site.
Vault purpose and policy
Vaults partition sites by purpose, audience, retention, and protection. Give each vault a clear
description so an AI client can select the right destination for a task. Names such as public
are labels, not security guarantees.
This branch adds migration 0008_vault_policies.sql. Existing deployments must apply it before
running the updated Worker. Inspect/backup the existing database and use the normal migration
workflow; do not reapply the fresh schema to an existing database. Fresh installations use the
updated schema.sql instead. Do not run 0008 again against a freshly initialized schema.
# Existing deployment, after reviewing/backing up the database:
npx wrangler d1 migrations apply nzip --remote --config wrangler.local.jsonc
The migration adds nullable/default-off policy fields and does not change existing sites or vault behavior. The updated API/CLI supports these policies:
| Field | Behavior |
|---|---|
description |
Purpose/audience metadata used by agents to choose the destination |
defaultTtl |
Inherited retention for new sites; null inherits the global fallback |
maxTtl |
Enforced maximum remaining lifetime in days; null means no cap |
requirePassword |
Every site must remain password-protected |
defaultPassword |
Write-only default visitor password, stored as a PBKDF2 verifier |
hasDefaultPassword |
Read-only indication that new sites can inherit protection |
Example configuration (create the password file privately; never commit it):
nzip 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-password
nzip vault add demos --description "Non-sensitive demos intended for public viewing" --default-ttl 30d --max-ttl 90d
Agents can choose reviews for a private plan and omit TTL/password: publication inherits seven
days and the configured protection. They do not need the shared visitor password. Sharing that
password with intended readers remains an owner action. Explicit TTL/password changes cannot
bypass maxTtl or requirePassword; enforcement exists in the API and D1 triggers, including
revision, policy-patch, and revert paths.
Omitting policy fields on a revision preserves the site's current expiry and password. Changing or removing a vault's default password affects new sites only; it does not rotate existing site passwords or revoke their unlock cookies. Tightening a maximum TTL or requiring passwords is rejected if existing sites conflict. Update those sites explicitly first. A vault's effective default TTL must also fit its maximum; unlimited retention cannot fit a finite cap.
Use --max-ttl none, --allow-unprotected, and --no-default-password to explicitly clear the
respective rules. Policies are not tenant isolation: the owner token can administer every vault;
CLI/MCP allowVaults is an additional client-side restriction, not a scoped credential.