Move architecture, security, and contributor guidance out of the README into portable Markdown documents. Align command ordering and share.demo.dev examples across CLI help, Worker config, setup docs, and tests.
- commit
- 8d8a4a17a8277d2b116e8a1e8b6e1726f6d6810a
- parent
- a59a55bba70b1fbdd66fbcd6c02393ab1a309265
- author
- Dami <dami.etoile@gmail.com>
| A | ARCHITECTURE.md | 117 | ++++++++ |
| A | CONTRIBUTING.md | 95 | ++++++ |
| M | README.md | 454 | +++++------------------------- |
| A | SECURITY.md | 82 | +++++ |
| M | cli/ | 11 | +- |
| M | cli/ | 4 | +- |
| M | skills/ | 2 | +- |
| M | worker/ | 22 | +- |
| M | worker/ | 56 | ++-- |
| M | worker/ | 6 | +- |
| M | worker/ | 6 | +- |
@@ -0,0 +1,117 @@
1+# nzip architecture2+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.6+7+## Components8+9+```text10+ bearer-authenticated API11+Deno CLI ------------------------------------------------> Worker12+ |13+visitor -> Workers Cache -> Worker |14+ | hit | miss |15+ +--------------+--------------------------------+16+ | |17+ v v18+ R2 blobs/manifests D1 state/history19+```20+21+- `shared/` defines manifests, hashing, target parsing, limits, media types, and API contracts.22+- `cli/` bundles local files, uploads missing blobs, commits sites, and manages owner operations.23+- `worker/` serves public sites and implements authenticated management, security telemetry,24+ notifications, cleanup, and delivery retries.25+26+## Push protocol27+28+A push is a stateless three-step exchange; the manifest is the state carried between steps:29+30+1. `POST /api/push/prepare` sends the manifest and returns missing blob hashes.31+2. `PUT /api/blob/{sha256}` uploads only missing blobs, with bounded concurrency. The Worker32+ re-hashes each body and rejects mismatches or oversized blobs.33+3. `POST /api/push/commit` verifies every blob, resolves or allocates the address, applies TTL and34+ password policy, repoints the site, and appends retained history atomically.35+36+Content-addressed blobs deduplicate across every site. The last ten pushes per site remain37+addressable for revert operations.38+39+## Serving and caching40+41+Serving normally requires one D1 read for site state and two R2 reads for the manifest and file.42+Public responses use `ETag` revalidation and a 60-second Workers Cache entry. Cache hits skip Worker43+execution and storage reads, although they still count as Worker requests.44+45+Public responses carry a site cache tag. Pushes, reverts, deletion, TTL changes, and password-policy46+changes purge that tag. Protected responses and errors are never publicly cached.47+48+Single-file sites serve at the bare address. Multi-file sites redirect to the trailing-slash form so49+relative asset paths resolve correctly.50+51+## Storage model52+53+Core publishing state:54+55+| table | purpose |56+| -------- | -------------------------------------------------------------------- |57+| `vaults` | named vault slots, descriptions, and site grouping |58+| `sites` | address, vault, alias, current manifest, expiry, and password policy |59+| `pushes` | retained per-site manifest history |60+61+Security and notification state:62+63+| table | purpose |64+| ----------------------------- | ---------------------------------------------------- |65+| `security_probes` | deduplicated scanner/address observations |66+| `security_signals` | rate-limit and suspicious-hit confirmations |67+| `security_incidents` | alert lifecycle, severity, and suppression state |68+| `security_notifications` | durable alert email outbox |69+| `notification_pairing_window` | owner-controlled enrollment deadline |70+| `notification_devices` | pending, approved, active, and revoked device claims |71+| `notification_events` | bounded notification payloads and click targets |72+| `notification_deliveries` | leased delivery attempts, retries, and outcomes |73+74+An R2 object is deleted only when no live site or retained history entry references it and it is75+older than 24 hours. This prevents concurrent garbage collection from deleting an in-flight push.76+77+## Background work78+79+The five-minute schedule evaluates enumeration windows and drains notification deliveries. The daily80+schedule expires sites, garbage-collects content, prunes telemetry and notification state, and sends81+the security activity digest.82+83+Durable outboxes separate request acceptance from email and Web Push delivery. Delivery workers use84+leases, bounded retry schedules, and terminal outcomes so overlapping cron executions remain safe.85+86+## Observability87+88+```text89+Worker response -> classify request -> deterministic scanner sample -> Workers Logs90+ |91+ +-> bounded D1 probe windows92+ |93+ five-minute evaluator94+ |95+ incident log + durable email outbox96+```97+98+Workers Logs provide sampled request-level context. D1 probe windows remain bounded but unsampled99+for automatic incident decisions. D1 row metrics are the direct storage-budget signal.100+101+## Free-tier design target102+103+nzip is designed so a small personal deployment can stay inside Cloudflare's included usage. This is104+a design target, not a guarantee: quotas are account-wide and Cloudflare may change them.105+106+| resource | bounding strategy | usage reference |107+| ------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |108+| Workers | short request path; cache before execution | [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) |109+| Workers Cache | public content only; short TTL; mutation purge | [Workers pricing](https://developers.cloudflare.com/workers/platform/pricing/) |110+| Workers Logs | invocation logs off; deterministic identity sample | [Workers Logs pricing](https://developers.cloudflare.com/workers/observability/logs/workers-logs/#pricing) |111+| D1 | deduplicated windows, write caps, and seven-day pruning | [D1 pricing](https://developers.cloudflare.com/d1/platform/pricing/) |112+| R2 | content deduplication, history cap, and daily GC | [R2 pricing](https://developers.cloudflare.com/r2/pricing/) |113+| Alert email | one verified Email Routing destination | [Email Service pricing](https://developers.cloudflare.com/email-service/platform/pricing/) |114+115+Probe persistence happens after the public response and catches its own errors. Exhausting the116+telemetry budget reduces detection coverage until quotas reset but does not stop ordinary site117+serving.@@ -0,0 +1,95 @@
1+# Contributing to nzip2+3+Thanks for helping improve nzip. CLI and shared-package work is Deno-first; the Cloudflare Worker4+uses Node.js tooling for type generation, tests, and Wrangler.5+6+## Prerequisites7+8+- Deno 2.x9+- Node.js 24 and npm10+- A Cloudflare account only when testing a real deployment11+12+Install the repository tooling:13+14+```sh15+npm ci16+cd worker17+npm ci18+```19+20+## Repository checks21+22+Run the Deno workspace checks from the repository root:23+24+```sh25+deno task fmt:check26+deno task lint27+deno task doc28+deno task check29+deno task test30+deno publish --dry-run --allow-dirty31+```32+33+Run the Worker checks from `worker/`:34+35+```sh36+npm run check37+npm test38+npx wrangler deploy --dry-run39+```40+41+The Worker runtime tests bind localhost and Wrangler writes diagnostic logs. If a sandbox blocks42+either operation, rerun the same command with the required permissions rather than treating it as a43+code failure.44+45+## Local Worker development46+47+Create local secrets from the non-secret example, initialize local D1 state, and start Wrangler:48+49+```sh50+cd worker51+cp .dev.vars.example .dev.vars52+npx wrangler d1 execute nzip --local --file schema.sql53+npm run dev54+```55+56+In another terminal, point the CLI at the local Worker:57+58+```sh59+nzip auth --server http://localhost:8787 --token dev-token-local-only60+```61+62+To exercise scheduled handlers, run `npx wrangler dev --test-scheduled`, then request a cron:63+64+```sh65+curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # alerts and delivery retries66+curl "http://localhost:8787/__scheduled?cron=0+4+*+*+*" # GC, pruning, and digest67+```68+69+Local configuration and secrets are ignored by Git. Never commit `.dev.vars` or70+`worker/wrangler.local.jsonc`.71+72+## Project structure73+74+- `shared/` contains the runtime-neutral wire contract and canonical manifest logic.75+- `cli/` contains the Deno command-line client published as `@nzip/cli`.76+- `worker/` contains serving, storage, security telemetry, notifications, and scheduled work.77+78+See [ARCHITECTURE.md](ARCHITECTURE.md) for component boundaries and data flow.79+80+## Documentation81+82+Keep documentation portable Markdown:83+84+- Prefer headings, lists, tables, fenced code blocks, and relative links.85+- Avoid raw HTML that only renders correctly on one host.86+- Keep user workflows in `README.md`, contributor workflows here, internals in `ARCHITECTURE.md`,87+ security guidance in `SECURITY.md`, and deployment operations in `worker/setup.md`.88+- Update the CLI help, project README, CLI README, and repo-local skill together when command89+ grammar changes.90+91+## Releases92+93+Package versions in `cli/deno.json`, `shared/deno.json`, and `shared/version.ts` must match. Tags in94+the form `vX.Y.Z` trigger the publication workflow, which validates the workspace, publishes both95+JSR packages, and creates the GitHub Release.@@ -2,82 +2,83 @@
22 33 **Push a directory of HTML from the terminal. Get a four-character URL back.**44 5-_Deno CLI → Cloudflare Worker → R2 + D1. Free-tier-oriented static site hosting6-with optional expiration, password protection, and enumeration alerts._5+_Deno CLI → Cloudflare Worker → R2 + D1. Free-tier-oriented static site hosting with optional6+expiration, password protection, owner notifications, and enumeration alerts._77 88 [](https://jsr.io/@nzip/cli)99 10-[github.com/FelineStateMachine/nzip](https://github.com/FelineStateMachine/nzip)11-· [args.io/cat/nzip](https://args.io/cat/nzip)10+[github.com/FelineStateMachine/nzip](https://github.com/FelineStateMachine/nzip) ·11+[args.io/cat/nzip](https://args.io/cat/nzip)1212 1313 ```console1414 $ 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.example.com/12d8 (expires in 30d, push #1)17+✓ pushed work:demo -> https://share.demo.dev/12d8 (expires in 30d, push #1)1818 ```1919 20----20+## Install21+22+The CLI ships on [JSR](https://jsr.io/@nzip/cli). With [Deno](https://docs.deno.com/runtime/) on23+your `PATH`:24+25+```sh26+deno install -g -A -f -n nzip jsr:@nzip/cli27+nzip auth --server https://share.demo.dev28+nzip site push ./site work:demo29+```30+31+`nzip auth` prompts for omitted values and saves configuration to `~/.config/nzip/config.json` with32+mode 0600. Try the CLI without installing it with `deno run -A jsr:@nzip/cli --help`.33+34+On another machine, install the CLI and authenticate with the same server and token. Hosted vaults35+and sites are already available; only the `nzip site where` breadcrumb registry is machine-local.2136 2237 ## How addresses work2338 24-Every share lives at four hex characters. The first digit selects one of **1625-registered vaults**, the rest a site within it. Each vault holds 4,096 slots,26-allocated randomly so URLs don't leak how many sites exist.39+Every share lives at four hex characters. The first digit selects one of 16 registered vaults, and40+the remaining three select one of 4,096 randomly allocated sites within that vault.2741 2842 ```text29-https://share.example.com/2a3f30- |+- site 0xa3f (0x000-0xFFF, random within the vault)31- +-- vault 0x2 ("work" - 16 slots, registered by name)43+https://share.demo.dev/2a3f44+ |+- site 0xa3f45+ +-- vault 0x2 ("work")3246 ```3347 34-Aliases like `work:demo` are resolved by the API; the URL never exposes them.35-Commands accept any of three target forms:48+Aliases such as `work:demo` are resolved by the authenticated API; public URLs do not expose them.3649 3750 | form | example | meaning |3851 | ----------- | ----------- | -------------------------- |39-| address | `2a3f` | direct hex address |40-| vault:alias | `work:demo` | alias within a named vault |52+| address | `2a3f` | direct hexadecimal address |53+| vault alias | `work:demo` | alias within a named vault |4154 | bare alias | `demo` | alias in the default vault |4255 4356 ## Features4457 45-- **Instant.** A push is one manifest exchange plus the blobs the server hasn't46- seen. Nothing rebuilds, nothing redeploys.47-- **Content-addressed.** Per-file sha256 blobs plus a canonical manifest per48- push. Identical files are stored once across every site; re-pushing an49- unchanged directory uploads `0 new blobs`.50-- **Ephemeral by default.** 14-day TTL, with `--ttl 30d` or `--ttl forever` to51- override. Expired shares answer `410 Gone`; a daily cron sweeps them and52- garbage-collects unreferenced content.53-- **Revertible.** The last 10 pushes per site are kept. `nzip site revert` repoints54- the address at any of them, and the revert itself is recorded as a push.55-- **Password-protectable.** `nzip site push ./demo work:demo --password …` publishes56- the content and password policy atomically, then gates the site behind an57- unlock form (PBKDF2 hashing, signed per-site cookie, 7 days). Changing the58- password policy revokes existing cookies immediately. Use `nzip site update` to59- change protection later.60-- **Single-user by design.** One bearer token, stored as a Worker secret and in61- `~/.config/nzip/config.json` (mode 0600).62-- **Owner notifications.** Pair explicitly approved phones from the quiet root63- PWA, then send Web Push notifications from the authenticated CLI. Clicks may64- open `/` or a manifest-pinned nzip site; arbitrary external URLs are refused.65-- **Locates itself.** `nzip site where <target>` prints the local directory this66- machine pushed a site from. A breadcrumb registry67- (`~/.config/nzip/paths.json`) records the source path on every push and68- self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls`69- reconciles against the live set. Use it as `cd "$(nzip site where personal:plan)"`.70-- **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json`71- restricts which vaults this install may target. Targets outside the list and72- raw hex addresses are refused before any upload, so a home-project agent can't73- drop a doc into a vault that sits adjacent to what you share professionally.74- Absent = unrestricted.58+- **Instant and content-addressed.** Push one canonical manifest and only the blobs the server does59+ not already have. Identical files are stored once across every site.60+- **Ephemeral by default.** Shares expire after 14 days unless `--ttl` selects another finite61+ duration or `forever`.62+- **Revertible.** The last ten pushes per site are retained and available through63+ `nzip site revert`.64+- **Password-protectable.** Password policy is committed atomically with content. Signed per-site65+ cookies are invalidated when the policy changes.66+- **Recoverable.** `nzip site cp` reconstructs and verifies the currently hosted bundle.67+- **Owner notifications.** Explicitly approved phones receive Web Push notifications with bounded,68+ same-origin click targets.69+- **Machine-local breadcrumbs.** `nzip site where` locates the source directory recorded during a70+ push.71+- **Vault guardrails.** Optional `allowVaults` configuration refuses disallowed vaults and raw72+ addresses before upload.73+- **Observable abuse controls.** Enumeration is rate-limited and evaluated through bounded,74+ privacy-preserving telemetry.7575 7676 ## Commands7777 7878 ```text7979 nzip8080 ├─ auth [--server URL] [--token T] authenticate and save config81+├─ status show server and vault status8182 ├─ vault8283 │ ├─ add <name> [--slot N] [--description TEXT]8384 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]@@ -92,358 +93,63 @@ nzip
9293 │ ├─ where <target> print this machine's source directory9394 │ ├─ rm <target> [--yes] delete a site9495 │ └─ revert <target> [--to N] [--list] inspect or restore push history95-├─ status show server and vault status9696 └─ notify9797 ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]9898 ├─ test send a diagnostic notification9999 ├─ pair allow device pairing for 10 minutes100100 ├─ approve <code> --name NAME [--yes]101- ├─ devices [--all] list current notification devices101+ ├─ devices [--all] list notification devices102102 └─ revoke <device-id> [--yes] revoke a notification device103103 ```104104 105-Vault descriptions are returned by `vault ls --json` so agents can choose an106-appropriate destination from its purpose or audience. Pass `--no-description`107-to `vault update` to clear it.108-109-Password and TTL are committed with the content. On a new site, omitting110-`--password` creates an unprotected site; on an existing target, omission111-preserves its current password. Pass `--no-password` to clear protection112-explicitly.113-114-Pushing a single `page.html` stores it as the site's `index.html`. Directory115-pushes skip dotfiles and `node_modules`, and honor a `.nzipignore` (one glob per116-line). Single-file sites serve directly at the bare address (`/2a3f`);117-multi-file bundles redirect to `/2a3f/` so relative asset URLs resolve.105+Password and TTL values are committed with content. Omitting `--password` preserves the current106+policy on an existing site; `--no-password` clears it. Directory pushes skip dotfiles and107+`node_modules` and honor a `.nzipignore` file containing one glob per line.118108 119-`nzip site cp work:demo ./recovered-demo` recovers the exact current bundle from the120-authenticated server when the original local directory is unavailable. It121-refuses non-empty destinations unless `--overwrite` is passed, and verifies122-every downloaded file against the hosted manifest. It can only restore uploaded123-files—not dotfiles, `.nzipignore`, or other local project metadata excluded from124-a push.109+See [`cli/README.md`](cli/README.md) for detailed CLI behavior and examples.125110 126-### Owner notifications111+## Owner notifications127112 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:113+Pairing is closed by default. Open a 10-minute window, then tap the temporary `pair` action on the114+deployment root and approve the displayed code:131115 132116 ```sh133117 nzip notify pair134118 nzip notify approve ABCD-1234 --name "Personal phone"135119 ```136120 137-Wait for `paired` before installing the PWA. In the installed app, tap138-`notifications off` to request permission and attach the subscription. Send a139-message with `nzip notify send "Build finished"`; add `--open work:report` to open an140-existing same-origin site when the notification is tapped. Use141-`nzip notify devices` to inspect current delivery health and `nzip notify revoke` to142-remove a device. Pass `--all` to include disabled, revoked, and expired tombstones.143-144-Notification titles and bodies may be visible on a lock screen. Never put145-passwords, tokens, private URLs, or sensitive personal data in them. If an146-installed app loses its pairing cookie, remove it, pair again in the browser,147-wait for `paired`, and reinstall it.148-149-## Architecture150-151-### Overview152-153-```mermaid154-flowchart LR155- CLI["nzip<br/>Deno CLI"] -- "push API<br/>bearer token" --> W156- V(["visitor"]) -- "GET /2a3f" --> C{"Workers Cache<br/>60s public TTL"}157- C -- "HIT" --> V158- C -- "MISS / BYPASS" --> W["nzip Worker<br/>serve · API"]159- W -- "cacheable response<br/>tagged by site" --> C160- W --> R2[("R2<br/>blobs · manifests")]161- W --> D1[("D1<br/>sites · vaults · history")]162-```163-164-### Security lens165-166-<details>167-<summary>View the security request and alert flow</summary>168-169-```mermaid170-flowchart LR171- REQ(["request"]) --> SURFACE{"request surface"}172- SURFACE -- "/api/*" --> TOKEN["bearer-token check"]173- SURFACE -- "GET /xxxx" --> ENUM["enumeration limiter"]174- SURFACE -- "POST /xxxx/__unlock" --> UNLOCK["password limiter"]175- TOKEN --> API["owner API"]176- ENUM --> SERVE["site lookup / serve"]177- UNLOCK --> SERVE178- SERVE --> GATE{"password protected?"}179- GATE -- "yes" --> COOKIE["PBKDF2 + signed cookie"]180- GATE -- "no" --> PUBLIC["public response"]181- ENUM -. "HMAC scanner identity" .-> PROBES[("D1 probe windows")]182- PROBES --> EVAL["5-minute incident evaluator"]183- EVAL --> OUTBOX[("D1 notification outbox")]184- OUTBOX --> EMAIL["verified-destination email"]185- EMAIL -. "retry on later cron" .-> OUTBOX186- EMAIL --> INBOX(["operator inbox"])187-```188-189-</details>190-191-### Observability lens192-193-<details>194-<summary>View the logging and metrics flow</summary>195-196-```mermaid197-flowchart LR198- RESP["Worker response"] --> CLASSIFY["classify security-relevant request"]199- CLASSIFY --> SAMPLE{"deterministic 1%<br/>scanner sample"}200- SAMPLE -- "normal + selected" --> LOGS["Workers Logs<br/>security.request"]201- SAMPLE -- "429 + selected" --> RESAMPLE{"secondary 1%<br/>request sample"}202- SAMPLE -- "not selected" --> DROP["no log event"]203- RESAMPLE -- "selected" --> LOGS204- RESAMPLE -- "not selected" --> DROP205- PROBES[("D1 probe windows")] --> CRON["5-minute aggregation"]206- CRON --> WINDOW["security.enumeration_window"]207- LOGS --> DASH["Cloudflare Observability"]208- WINDOW --> DASH209- PROBES --> METRICS["D1 row metrics"]210-```211-212-</details>213-214-<details>215-<summary>Protocol, caching, and storage details</summary>216-217-A push is a stateless three-step protocol, and the manifest itself is the state:218-219-1. `POST /api/push/prepare`: send the manifest, get back which blob hashes the220- server is missing221-2. `PUT /api/blob/{sha256}`: upload only those, one per request, 6 at a time;222- the server re-hashes and rejects mismatches or blobs above 50 MiB223-3. `POST /api/push/commit`: the server re-verifies every blob exists, then224- commits atomically, resolving or allocating the address, applying225- TTL/password policy, repointing the site, and appending history226-227-Serving is one D1 read (address → manifest, expiry, password) and two R2 reads,228-with `ETag` revalidation and a 60-second Workers Cache. Cloudflare checks this229-cache before invoking the Worker, so a hit skips Worker execution and the D1/R2230-reads. Public responses are tagged per site and purged when a push, revert,231-deletion, TTL, or password policy changes; protected responses and errors are232-never cached.233-234-Content metadata lives in three tables:235-236-| table | keys |237-| -------- | -------------------------------------------------------------------------------- |238-| `vaults` | slot (0–15), name, optional description |239-| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash/version |240-| `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |241-242-Security alert state is deliberately separate:243-244-| table | purpose |245-| ------------------------ | ------------------------------------------------------------------------ |246-| `security_probes` | deduplicated five-minute scanner/address observations; retained 7 days |247-| `security_signals` | rate-limit confirmations, deduplicated by scanner and window |248-| `security_incidents` | open/closed state, severity, suppression timestamps, and incident totals |249-| `security_notifications` | durable email payloads, delivery attempts, and retry state |250-| `notification_devices` | approved claims and active Web Push subscriptions |251-| `notification_events` | bounded payloads and manifest-pinned click targets |252-| `notification_deliveries`| leased per-device attempts, retries, and terminal outcomes |253-254-**GC safety rule:** an R2 object is deleted only if no live site or retained255-history entry references it _and_ it's older than 24 hours, so an in-flight push256-can never lose objects to a concurrent sweep.257-258-</details>259-260-## Security and observability261-262-The public address space is intentionally small and human-friendly, so nzip263-treats enumeration as something to observe and rate-limit rather than pretending264-four hex characters are a secret. The two telemetry paths serve different jobs:265-266-<details>267-<summary>Signals, alert policy, and operator workflow</summary>268-269-| path | signal and retention |270-| ---------------------------- | ----------------------------------------------------------------------------------- |271-| Workers Logs | structured `security.request` events for a deterministic 1% scanner-identity sample |272-| D1 five-minute probe windows | bounded, unsampled bare-address observations used for automatic incident decisions |273-274-Log events classify bare addresses, missing address assets, unlock attempts, bad275-API requests, and other scanner-shaped paths. They include the response result,276-derived vault/site slots (for example, `/0123` → vault `0x0`, site `0x123`),277-country, colo, ASN, and an HMAC-derived `scanner_id`. Raw client IPs are neither278-logged nor stored. Automatic invocation logs are disabled, successful site279-assets are omitted, paths are length-bounded, and blocked floods are sampled a280-second time to control log volume.281-282-The five-minute evaluator opens or escalates an enumeration incident on these283-signals:284-285-| severity | signal |286-| --------- | ------------------------------------------------------------------------------------- |287-| warning | one scanner tries 20 distinct addresses in 5 minutes |288-| warning | 128 addresses from 10 scanners in 5 minutes, with at least 90% misses |289-| confirmed | an enumeration request reaches the 429 limiter |290-| confirmed | a live hit after 8-address/sequence evidence, or any live hit during an open incident |291-292-Duplicate email is suppressed unless severity increases, volume doubles, a live293-site is hit for the first time, or a new vault is targeted after 30 minutes.294-Active incidents summarize at most hourly and resolve after three quiet windows295-(15 minutes). Alert state and the exact email payload are committed to a D1296-outbox before delivery; transient failures retry on later cron runs with a297-stable notification ID. A daily activity digest is sent only when probes298-occurred. Alerts use a Worker email binding restricted to one verified299-destination; the owner-authenticated `/api/security/test-alert` endpoint300-validates delivery.301-302-Inspect sampled events under **Workers & Pages → nzip → Observability**,303-filtering on `event = "security.request"`. Each event's `sample_rate` records304-its sampling factor for aggregate estimates. Watch D1 row metrics as the direct305-free-tier budget signal for alert storage, and Worker request/log counts for the306-overall service budget.307-308-</details>309-310-## Free-tier design target311-312-nzip is designed so a small personal deployment can stay inside Cloudflare's313-included free usage without disabling security or observability. This is a314-design target, not a promise: account-wide traffic and storage count toward the315-same quotas, Cloudflare can change its limits, and a sufficiently distributed316-attack can exhaust a daily quota.317-318-<details>319-<summary>Included quotas, safeguards, and failure behavior</summary>320-321-| resource | how nzip keeps usage bounded | current included usage to watch |322-| ------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |323-| Workers | short request path; cache checked before invocation | [100,000 requests/day; 10 ms CPU/invocation](https://developers.cloudflare.com/workers/platform/limits/) |324-| Workers Cache | public content only, 60-second TTL, tag purge on mutation | [no separate request allowance](https://developers.cloudflare.com/workers/platform/pricing/); cached Worker requests still count toward Workers usage |325-| Workers Logs | invocation logs off; deterministic 1% identity sample; successful assets omitted | [200,000 log events/day with 3-day retention](https://developers.cloudflare.com/workers/observability/logs/workers-logs/#pricing) |326-| D1 | probe rows deduplicated by scanner/address/window, capped per scanner/location, pruned after 7 days | [5M rows read/day, 100K written/day, 5 GB](https://developers.cloudflare.com/d1/platform/pricing/) |327-| R2 Standard | content-addressed deduplication, retained-history cap, daily GC | [10 GB-month, 1M Class A and 10M Class B operations/month; free egress](https://developers.cloudflare.com/r2/pricing/#free-tier) |328-| Alert email | restricted to a verified Email Routing destination; no arbitrary-recipient sending | [verified-destination sends are free on all plans](https://developers.cloudflare.com/email-service/platform/pricing/) |329-330-On the Workers Free plan, exceeding included daily usage does not create an331-overage bill. D1 queries fail until the quota resets; Worker request-limit332-behavior follows the route's configured fail-open/fail-closed mode. Probe333-persistence runs after the response and catches its own errors, so an exhausted334-alert-storage budget does not stop normal site serving; it does reduce detection335-coverage until the quota resets. Cache hits reduce execution and storage reads,336-but they do not remove the request from the Workers request quota. The telemetry337-rate limiter is best-effort and local to a Cloudflare location; deduplication338-and seven-day pruning provide the durable bounds, while a distributed attack can339-still consume the daily D1 allowance.340-341-</details>342-343-## Repo layout344-345-```text346-shared/ manifest canonicalization, hashing, addressing (imported by both sides)347-cli/ the nzip command (Deno, no framework), published as jsr:@nzip/cli348-worker/ Cloudflare Worker: serving, cache, API, security alerts, and GC (wrangler)349-```350-351-`shared/` is the contract: canonical JSON serialization lives in exactly one352-file ([`shared/manifest.ts`](shared/manifest.ts)) so the CLI and Worker can353-never disagree about a manifest hash. It sticks to Web-standard APIs only, so354-the same code runs under Deno and workerd.355-356-## Install357-358-The CLI ships on [JSR](https://jsr.io/@nzip/cli). With359-[Deno](https://docs.deno.com/runtime/) on your `PATH`, install it in one line,360-then point it at your server and push:121+Wait for `paired` before installing the PWA. In the installed app, tap `notifications off` to122+request permission and attach the subscription. Send a notification with:361123 362124 ```sh363-deno install -g -A -f -n nzip jsr:@nzip/cli # 1. install the `nzip` command364-365-nzip auth --server https://share.example.com # 2. authenticate (prompts for the token)366-367-nzip site push ./site work:demo # 3. get a URL back125+nzip notify send "Build finished" --open work:report368126 ```369127 370-`nzip auth` prompts for anything you omit and saves it to371-`~/.config/nzip/config.json` (mode 0600), so every later command just works.372-Upgrade any time by re-running the install line; try it without installing via373-`deno run -A jsr:@nzip/cli --help`.374-375-Adding a **second machine**? Only the CLI is per-machine; the Worker, R2, D1,376-vaults, and token are already provisioned. Install from JSR and run `nzip auth`377-with the same server and token, and every vault and site is instantly there.378-(The `nzip site where` breadcrumb registry is local, so it only knows sites pushed379-from this machine.)380-381-`nzip` is self-hosted. The server URL is supplied by the operator; there is no382-bundled public service. Use the same URL you set as `vars.PUBLIC_BASE` in your383-Wrangler config. Standing up that server is the one-time setup below.128+Notification content may be visible on a lock screen. Never include passwords, tokens, private URLs,129+or sensitive personal information. See [SECURITY.md](SECURITY.md) for the enrollment and delivery130+trust boundaries.384131 385-## Self-hosting: one-time Cloudflare setup386-387-See [`worker/setup.md`](worker/setup.md) for the full checklist. In short:388-389-<details>390-<summary>Cloudflare provisioning commands and deployment gotchas</summary>391-392-```sh393-cd worker394-npx wrangler login395-cp wrangler.jsonc wrangler.local.jsonc396-# edit wrangler.local.jsonc:397-# - routes[0].pattern: your hostname, such as share.example.com398-# - vars.PUBLIC_BASE: https://<that hostname>399-# - d1_databases[0].database_id: filled in after D1 creation400-npx wrangler r2 bucket create nzip-content401-npx wrangler d1 create nzip # paste id into wrangler.local.jsonc402-npx wrangler d1 execute nzip --remote --file schema.sql403-openssl rand -hex 32 | npx wrangler secret put NZIP_TOKEN --config wrangler.local.jsonc404-# configure and verify the alert destination; see worker/setup.md405-npx wrangler deploy --config wrangler.local.jsonc406-nzip auth --server https://share.example.com # use your actual PUBLIC_BASE407-```132+## Self-hosting408133 409-`routes[0].pattern` and `vars.PUBLIC_BASE` are required for a real deployment.410-`PUBLIC_BASE` controls the URLs returned by `nzip site push`, and the CLI411-authenticates against that same origin.134+nzip is self-hosted; there is no bundled public service. The Worker uses a custom domain, R2, D1,135+rate-limit bindings, and optional Email Routing and Web Push configuration.412136 413-Two gotchas learned the hard way:137+Follow [`worker/setup.md`](worker/setup.md) for provisioning, migrations, secrets, notification138+setup, deployment, and operational checks. The system is designed for small personal deployments on139+Cloudflare's included usage; see the140+[architecture budget notes](ARCHITECTURE.md#free-tier-design-target) before exposing a busy or141+multi-tenant instance.414142 415-- **Cron triggers require a workers.dev subdomain** on the account (API error416- `10063`), even if the Worker only serves a custom domain. Register one in the417- dashboard (or `PUT /accounts/{id}/workers/subdomain`) before deploying with418- `triggers`.419-- The required custom-domain route420- (`"routes": [{ "pattern": "share.example.com",421- "custom_domain": true }]`)422- needs the zone on your account; it provisions DNS and the cert automatically423- on deploy.143+## Project documentation424144 425-</details>426-427-## Development428-429-<details>430-<summary>Local checks, Worker development, and scheduled-handler testing</summary>431-432-```sh433-deno task check # typecheck CLI + shared434-cd worker && npx tsc --noEmit # typecheck Worker435-436-npx wrangler d1 execute nzip --local --file schema.sql # once per fresh state437-npx wrangler dev # local R2/D1; token in .dev.vars438-nzip auth --server http://localhost:8787 --token dev-token-local-only439-```440-441-Test scheduled handlers with `npx wrangler dev --test-scheduled`, then invoke442-either cron locally:443-444-```sh445-curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" # alert evaluation446-curl "http://localhost:8787/__scheduled?cron=0+4+*+*+*" # GC + digest447-```145+- [ARCHITECTURE.md](ARCHITECTURE.md) — components, push protocol, caching, storage, background work,146+ observability, and resource budgets.147+- [SECURITY.md](SECURITY.md) — trust boundaries, pairing security, enumeration controls, reporting,148+ and monitoring.149+- [CONTRIBUTING.md](CONTRIBUTING.md) — development setup, checks, local Worker workflow,150+ documentation conventions, and releases.151+- [`worker/setup.md`](worker/setup.md) — production Cloudflare deployment and upgrades.152+- [`cli/README.md`](cli/README.md) — CLI installation and command reference.153+- [`shared/README.md`](shared/README.md) — shared package contract and usage.448154 449-</details>155+MIT licensed.@@ -0,0 +1,82 @@
1+# nzip security2+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+## Reporting a vulnerability8+9+Do not disclose a vulnerability in a public issue. Use the repository's private security advisory10+channel when available, or contact the repository owner privately. Include affected versions,11+reproduction steps, impact, and any suggested mitigation.12+13+## Trust boundaries14+15+- Every `/api/*` management route requires the deployment-wide bearer token.16+- The bearer token stays in Worker secrets and the CLI's mode-0600 configuration file; browsers and17+ installed notification apps never receive it.18+- Public site addresses occupy only 16 bits and should be treated as enumerable.19+- Password-protected sites use PBKDF2 hashes and signed, versioned cookies. Changing password policy20+ invalidates existing cookies immediately.21+- Notification click targets are same-origin paths pinned to an existing site manifest. Arbitrary22+ external URLs are rejected.23+- Web Push subscription endpoints must match configured exact HTTPS provider origins. Wildcards,24+ credentials, IP literals, alternate ports, and the deployment's own origin are rejected.25+26+## Notification pairing27+28+Pairing is closed by default. An authenticated owner opens a fixed 10-minute window with29+`nzip notify pair`. The public root reveals the pairing action only during that window, and the30+Worker enforces the same deadline when creating an enrollment.31+32+Pairing codes are short-lived, normalized, and stored only as keyed hashes. A code identifies a33+pending device but cannot approve itself; approval still requires the owner bearer token. Device34+claims use `HttpOnly`, `Secure`, `SameSite=Lax`, host-only cookies and transition through pending,35+approved, and active states before notifications can be delivered.36+37+The root pairing surface cannot be framed. Pairing-state reads are same-origin and rate-limited, and38+pending pages do not expose a second enrollment action.39+40+## Enumeration controls41+42+The address space is intentionally small, so nzip observes and limits enumeration rather than43+claiming addresses are unguessable.44+45+```text46+request -> surface classifier -> per-surface limiter -> serve or owner API47+ |48+ +-> HMAC scanner identity -> bounded D1 probe window49+ |50+ incident evaluator51+ |52+ durable email outbox -> operator53+```54+55+Raw client IPs are neither logged nor stored. Scanner identities are HMAC-derived. Probe rows are56+deduplicated, capped per scanner and Cloudflare location, and pruned after seven days.57+58+The evaluator opens or escalates incidents on these signals:59+60+| severity | signal |61+| --------- | ---------------------------------------------------------------------- |62+| warning | one scanner tries 20 distinct addresses in five minutes |63+| warning | 128 addresses from 10 scanners with at least 90% misses |64+| confirmed | an enumeration request reaches the rate limiter |65+| confirmed | a live hit follows sequence evidence or occurs during an open incident |66+67+Duplicate alert email is suppressed unless severity increases, volume doubles, a live site is hit68+for the first time, or a new vault is targeted after 30 minutes. Active incidents summarize at most69+hourly and resolve after three quiet windows. Alert payloads are committed before delivery and70+retried with stable notification identifiers.71+72+## Operational monitoring73+74+- Filter Workers Logs on `event = "security.request"` for sampled request context.75+- Watch D1 row writes for the enumeration-telemetry budget.76+- Keep the Email Routing destination verified and test it through the authenticated77+ `/api/security/test-alert` endpoint.78+- Treat notification titles and bodies as lock-screen-visible. Never include credentials, private79+ URLs, or sensitive personal information.80+81+Deployment configuration and security-alert setup are documented in82+[`worker/setup.md`](worker/setup.md).@@ -24,10 +24,10 @@ deno run -A jsr:@nzip/cli --help
2424 ## Use2525 2626 ```sh27-nzip auth --server https://share.example.com # authenticate (prompts for the token)27+nzip auth --server https://share.demo.dev # authenticate (prompts for the token)2828 nzip vault add work # register a named vault2929 nzip site push ./site work:demo --ttl 30d --password secret30- # → https://share.example.com/12d830+ # → https://share.demo.dev/12d83131 ```3232 3333 Config is saved to `~/.config/nzip/config.json` (mode 0600), so later commands just work. On a@@ -39,6 +39,7 @@ and site is already there.
3939 ```text4040 nzip4141 ├─ auth [--server URL] [--token T] authenticate and save config42+├─ status show server and vault status4243 ├─ vault4344 │ ├─ add <name> [--slot N] [--description TEXT]4445 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]@@ -53,7 +54,6 @@ nzip
5354 │ ├─ where <target> print this machine's source directory5455 │ ├─ rm <target> [--yes] delete a site5556 │ └─ revert <target> [--to N] [--list] inspect or restore push history56-├─ status show server and vault status5757 └─ notify5858 ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]5959 ├─ test send a diagnostic notification@@ -96,5 +96,6 @@ notification delivery path. Use `nzip notify revoke <device-id> --yes` to remove
9696 Notification content may appear on a lock screen. Never include passwords, tokens, private URLs, or9797 sensitive personal data in a title or body.9898 99-See the [project README](https://github.com/FelineStateMachine/nzip) for how addresses work,100-self-hosting setup, and architecture. MIT licensed.99+See the [project README](../README.md) for how addresses work, the100+[deployment runbook](../worker/setup.md) for self-hosting, and [ARCHITECTURE.md](../ARCHITECTURE.md)101+for internals. MIT licensed.@@ -8,7 +8,7 @@
88 *99 * ```sh1010 * deno install -g -A -f -n nzip jsr:@nzip/cli11- * nzip auth --server https://share.example.com11+ * nzip auth --server https://share.demo.dev1212 * nzip site push ./site work:demo1313 * ```1414 *@@ -30,6 +30,7 @@ const HELP = `nzip — html share tool
3030 commands:3131 nzip3232 ├─ auth [--server URL] [--token T] authenticate against the server33+ ├─ status show server and vault status3334 ├─ vault3435 │ ├─ add <name> [--slot N] [--description TEXT]3536 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]@@ -44,7 +45,6 @@ commands:
4445 │ ├─ where <target> print this machine's source directory4546 │ ├─ rm <target> [--yes] delete a site4647 │ └─ revert <target> [--to N] [--list] inspect or restore push history47- ├─ status show server and vault status4848 └─ notify4949 ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]5050 ├─ test queue a diagnostic notification@@ -88,6 +88,7 @@ Prefer `--json` when consuming command output programmatically.
8888 ```text8989 nzip9090 ├─ auth [--server URL] [--token T]91+├─ status9192 ├─ vault9293 │ ├─ add <name> [--slot N] [--description TEXT]9394 │ ├─ update <name> [--name NEW_NAME] [--description TEXT | --no-description]@@ -102,7 +103,6 @@ nzip
102103 │ ├─ where <target>103104 │ ├─ rm <target> [--yes]104105 │ └─ revert <target> [--to N] [--list]105-├─ status106106 └─ notify107107 ├─ send <body> [--title TEXT] [--open TARGET] [--tag TEXT]108108 ├─ test@@ -5,7 +5,7 @@ Everything here happens once. Day-to-day content pushes never touch wrangler.
55 The default architecture targets the Workers Free plan. It requires no paid66 Email Sending subscription: alerts use Email Routing to one verified destination.77 Free-tier limits are account-wide, so review the8-[budget notes](../README.md#free-tier-design-target) before exposing a busy or8+[budget notes](../ARCHITECTURE.md#free-tier-design-target) before exposing a busy or99 multi-tenant deployment.1010 1111 ```sh@@ -17,7 +17,7 @@ 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.example.com20+# - routes[0].pattern: your hostname, such as share.demo.dev2121 # - vars.PUBLIC_BASE: https://<that hostname>2222 # - d1_databases[0].database_id: filled in after D1 creation2323 @@ -60,7 +60,7 @@ Click the verification link Cloudflare sends, then configure:
6060 "send_email": [{ "name": "EMAIL", "destination_address": "operator@example.com" }],6161 "vars": {6262 "ALERT_EMAIL_TO": "operator@example.com",63- "ALERT_EMAIL_FROM": "alerts@share.example.com"63+ "ALERT_EMAIL_FROM": "alerts@share.demo.dev"6464 }6565 ```6666 @@ -72,7 +72,7 @@ After deployment, send a delivery test through the owner-authenticated endpoint:
7272 7373 ```sh7474 curl -X POST -H "Authorization: Bearer $NZIP_TOKEN" \75- https://share.example.com/api/security/test-alert75+ https://share.demo.dev/api/security/test-alert7676 ```7777 7878 The Worker opens an incident when one scanner tries 20 distinct addresses in@@ -197,20 +197,10 @@ deno install -g -f -n nzip \
197197 --allow-net --allow-read --allow-write --allow-env \198198 cli/main.ts199199 200-nzip auth --server https://share.example.com --token <token-from-step-5>200+nzip auth --server https://share.demo.dev --token <token-from-step-5>201201 nzip vault add personal # slot 0x0202202 nzip vault add work # slot 0x1203203 nzip site push ./docs personal:plan --ttl forever204204 ```205205 206-## Local development207-208-```sh209-cd worker210-npx wrangler d1 execute nzip --local --file schema.sql # once211-npx wrangler dev # local R2/D1, token from .dev.vars212-nzip auth --server http://localhost:8787 --token dev-token-local-only213-```214-215-Test the GC cron locally: run `wrangler dev --test-scheduled`, then216-`curl "http://localhost:8787/__scheduled?cron=0+4+*+*+*"`.206+Local development and test commands live in [`CONTRIBUTING.md`](../CONTRIBUTING.md).@@ -5,7 +5,7 @@ import { hashPassword, verifyPassword } from "../src/password.ts";
55 66 describe("Worker runtime", () => {77 it("serves the landing page with public cache headers", async () => {8- const response = await SELF.fetch("https://share.example.com/");8+ const response = await SELF.fetch("https://share.demo.dev/");99 1010 expect(response.status).toBe(200);1111 expect(response.headers.get("cache-control")).toBe("public, max-age=3600");@@ -15,7 +15,7 @@ describe("Worker runtime", () => {
1515 });1616 1717 it("keeps the root shell claim-independent and hides pairing by default", async () => {18- const response = await SELF.fetch("https://share.example.com/");18+ const response = await SELF.fetch("https://share.demo.dev/");1919 const html = await response.text();2020 2121 expect(response.headers.get("set-cookie")).toBeNull();@@ -30,24 +30,24 @@ describe("Worker runtime", () => {
3030 3131 it("rejects enrollment until the owner opens a pairing window", async () => {3232 const browserHeaders = {33- origin: "https://share.example.com",33+ origin: "https://share.demo.dev",3434 "sec-fetch-site": "same-origin",3535 "content-type": "application/json",3636 };37- const status = await SELF.fetch("https://share.example.com/_notify/pairing", {37+ const status = await SELF.fetch("https://share.demo.dev/_notify/pairing", {3838 headers: { "sec-fetch-site": "same-origin" },3939 });4040 expect(status.status).toBe(200);4141 expect(await status.json()).toEqual({ enabled: false, expiresAt: null });4242 4343 const enrolled = await SELF.fetch(44- "https://share.example.com/_notify/enrollments",44+ "https://share.demo.dev/_notify/enrollments",4545 { method: "POST", headers: browserHeaders, body: "{}" },4646 );4747 expect(enrolled.status).toBe(404);4848 expect(await enrolled.json()).toEqual({ error: "pairing unavailable" });4949 50- const crossOrigin = await SELF.fetch("https://share.example.com/_notify/pairing", {50+ const crossOrigin = await SELF.fetch("https://share.demo.dev/_notify/pairing", {5151 headers: {5252 origin: "https://attacker.example",5353 "sec-fetch-site": "cross-site",@@ -55,7 +55,7 @@ describe("Worker runtime", () => {
5555 });5656 expect(crossOrigin.status).toBe(403);5757 58- await SELF.fetch("https://share.example.com/api/notify/pairing", {58+ await SELF.fetch("https://share.demo.dev/api/notify/pairing", {5959 method: "POST",6060 headers: {6161 authorization: "Bearer runtime-test-token",@@ -67,7 +67,7 @@ describe("Worker runtime", () => {
6767 "UPDATE notification_pairing_window SET enabled_until = 0 WHERE id = 1",6868 ).run();6969 const expired = await SELF.fetch(70- "https://share.example.com/_notify/enrollments",70+ "https://share.demo.dev/_notify/enrollments",7171 { method: "POST", headers: browserHeaders, body: "{}" },7272 );7373 expect(expired.status).toBe(404);@@ -75,7 +75,7 @@ describe("Worker runtime", () => {
7575 7676 it("enrolls, previews, approves, and activates while delivery is disabled", async () => {7777 const browserHeaders = {78- origin: "https://share.example.com",78+ origin: "https://share.demo.dev",7979 "sec-fetch-site": "same-origin",8080 "content-type": "application/json",8181 "user-agent": "runtime mobile browser",@@ -85,18 +85,18 @@ describe("Worker runtime", () => {
8585 "content-type": "application/json",8686 };8787 const opened = await SELF.fetch(88- "https://share.example.com/api/notify/pairing",88+ "https://share.demo.dev/api/notify/pairing",8989 { method: "POST", headers: ownerHeaders, body: "{}" },9090 );9191 expect(opened.status).toBe(200);9292 expect(await opened.json()).toMatchObject({ enabled: true });93- const pairing = await SELF.fetch("https://share.example.com/_notify/pairing", {93+ const pairing = await SELF.fetch("https://share.demo.dev/_notify/pairing", {9494 headers: { "sec-fetch-site": "same-origin" },9595 });9696 expect(await pairing.json()).toMatchObject({ enabled: true });9797 9898 const enrolled = await SELF.fetch(99- "https://share.example.com/_notify/enrollments",99+ "https://share.demo.dev/_notify/enrollments",100100 { method: "POST", headers: browserHeaders, body: "{}" },101101 );102102 expect(enrolled.status).toBe(201);@@ -108,7 +108,7 @@ describe("Worker runtime", () => {
108108 expect(enrollment.code).toMatch(/^[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$/);109109 110110 const preview = await SELF.fetch(111- `https://share.example.com/api/notify/approvals/${enrollment.code}`,111+ `https://share.demo.dev/api/notify/approvals/${enrollment.code}`,112112 { headers: ownerHeaders },113113 );114114 expect(preview.status).toBe(200);@@ -118,7 +118,7 @@ describe("Worker runtime", () => {
118118 });119119 120120 const approved = await SELF.fetch(121- "https://share.example.com/api/notify/approvals",121+ "https://share.demo.dev/api/notify/approvals",122122 {123123 method: "POST",124124 headers: ownerHeaders,@@ -132,7 +132,7 @@ describe("Worker runtime", () => {
132132 });133133 134134 const activated = await SELF.fetch(135- "https://share.example.com/_notify/enrollments/activate",135+ "https://share.demo.dev/_notify/enrollments/activate",136136 {137137 method: "POST",138138 headers: { ...browserHeaders, cookie: cookie! },@@ -146,7 +146,7 @@ describe("Worker runtime", () => {
146146 });147147 148148 const disabledSend = await SELF.fetch(149- "https://share.example.com/api/notify",149+ "https://share.demo.dev/api/notify",150150 {151151 method: "POST",152152 headers: ownerHeaders,@@ -158,12 +158,12 @@ describe("Worker runtime", () => {
158158 159159 it("serves install assets with explicit content and cache policies", async () => {160160 const manifest = await SELF.fetch(161- "https://share.example.com/_notify/app.webmanifest",161+ "https://share.demo.dev/_notify/app.webmanifest",162162 );163163 const serviceWorker = await SELF.fetch(164- "https://share.example.com/_notify/sw.js",164+ "https://share.demo.dev/_notify/sw.js",165165 );166- const icon = await SELF.fetch("https://share.example.com/_notify/icon.svg");166+ const icon = await SELF.fetch("https://share.demo.dev/_notify/icon.svg");167167 168168 expect(manifest.headers.get("content-type")).toContain(169169 "application/manifest+json",@@ -177,7 +177,7 @@ describe("Worker runtime", () => {
177177 });178178 179179 it("rejects unauthenticated API requests before touching storage", async () => {180- const response = await SELF.fetch("https://share.example.com/api/status");180+ const response = await SELF.fetch("https://share.demo.dev/api/status");181181 182182 expect(response.status).toBe(401);183183 expect(await response.json()).toEqual({ error: "unauthorized" });@@ -192,7 +192,7 @@ describe("Worker runtime", () => {
192192 });193193 194194 it("routes authenticated status requests with the shared release version", async () => {195- const response = await SELF.fetch("https://share.example.com/api/status", {195+ const response = await SELF.fetch("https://share.demo.dev/api/status", {196196 headers: { authorization: "Bearer runtime-test-token" },197197 });198198 @@ -205,7 +205,7 @@ describe("Worker runtime", () => {
205205 authorization: "Bearer runtime-test-token",206206 "content-type": "application/json",207207 };208- const created = await SELF.fetch("https://share.example.com/api/vaults", {208+ const created = await SELF.fetch("https://share.demo.dev/api/vaults", {209209 method: "POST",210210 headers,211211 body: JSON.stringify({@@ -221,7 +221,7 @@ describe("Worker runtime", () => {
221221 });222222 223223 const updated = await SELF.fetch(224- "https://share.example.com/api/vaults/agent-work",224+ "https://share.demo.dev/api/vaults/agent-work",225225 {226226 method: "PATCH",227227 headers,@@ -237,13 +237,13 @@ describe("Worker runtime", () => {
237237 description: "Human review links; safe to share with collaborators",238238 });239239 240- const listed = await SELF.fetch("https://share.example.com/api/vaults", { headers });240+ const listed = await SELF.fetch("https://share.demo.dev/api/vaults", { headers });241241 expect(await listed.json()).toContainEqual(expect.objectContaining({242242 name: "reviews",243243 description: "Human review links; safe to share with collaborators",244244 }));245245 246- const cleared = await SELF.fetch("https://share.example.com/api/vaults/reviews", {246+ const cleared = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {247247 method: "PATCH",248248 headers,249249 body: JSON.stringify({ description: "" }),@@ -251,7 +251,7 @@ describe("Worker runtime", () => {
251251 expect(cleared.status).toBe(200);252252 expect(await cleared.json()).toMatchObject({ name: "reviews", description: null });253253 254- const clearedWithNull = await SELF.fetch("https://share.example.com/api/vaults/reviews", {254+ const clearedWithNull = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {255255 method: "PATCH",256256 headers,257257 body: JSON.stringify({ description: null }),@@ -259,14 +259,14 @@ describe("Worker runtime", () => {
259259 expect(clearedWithNull.status).toBe(200);260260 expect(await clearedWithNull.json()).toMatchObject({ name: "reviews", description: null });261261 262- const multiline = await SELF.fetch("https://share.example.com/api/vaults/reviews", {262+ const multiline = await SELF.fetch("https://share.demo.dev/api/vaults/reviews", {263263 method: "PATCH",264264 headers,265265 body: JSON.stringify({ description: "line1\nline2" }),266266 });267267 expect(multiline.status).toBe(400);268268 269- const badEncoding = await SELF.fetch("https://share.example.com/api/vaults/%zz", {269+ const badEncoding = await SELF.fetch("https://share.demo.dev/api/vaults/%zz", {270270 method: "PATCH",271271 headers,272272 body: JSON.stringify({ description: "x" }),@@ -6,7 +6,7 @@ import { validateNotifyRequest } from "../src/notify.ts";
66 import { buildWebPushRequest, validatePushEndpoint } from "../src/web_push.ts";77 88 const env = {9- PUBLIC_BASE: "https://share.example.com",9+ PUBLIC_BASE: "https://share.demo.dev",1010 WEB_PUSH_ORIGINS:1111 "https://fcm.googleapis.com,https://web.push.apple.com,https://updates.push.services.mozilla.com",1212 };@@ -72,7 +72,7 @@ describe("push endpoint policy", () => {
7272 ...env,7373 VAPID_PUBLIC_KEY: vapid.publicKey,7474 VAPID_PRIVATE_KEY: vapid.privateKey,75- VAPID_SUBJECT: "https://share.example.com",75+ VAPID_SUBJECT: "https://share.demo.dev",7676 }, {7777 endpoint: "https://fcm.googleapis.com/fcm/send/test",7878 keys: {@@ -93,7 +93,7 @@ describe("push endpoint policy", () => {
9393 it("rejects own-zone, credentialed, IP-literal, and alternate-port endpoints", () => {9494 for (9595 const endpoint of [96- "https://share.example.com/_notify/reenter",96+ "https://share.demo.dev/_notify/reenter",9797 "https://user:pass@fcm.googleapis.com/push",9898 "https://127.0.0.1/push",9999 "https://2130706433/push",@@ -13,7 +13,7 @@
1313 "invocation_logs": false1414 }1515 },16- "routes": [{ "pattern": "share.example.com", "custom_domain": true }],16+ "routes": [{ "pattern": "share.demo.dev", "custom_domain": true }],1717 "r2_buckets": [1818 {1919 "binding": "CONTENT",@@ -69,9 +69,9 @@
6969 ]7070 },7171 "vars": {72- "PUBLIC_BASE": "https://share.example.com",72+ "PUBLIC_BASE": "https://share.demo.dev",7373 "ALERT_EMAIL_TO": "operator@example.com",74- "ALERT_EMAIL_FROM": "alerts@share.example.com",74+ "ALERT_EMAIL_FROM": "alerts@share.demo.dev",7575 "NOTIFY_ENABLED": "false",7676 "WEB_PUSH_ORIGINS": "",7777 "VAPID_PUBLIC_KEY": "replace-with-url-safe-base64-public-key",