clone

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

docs: split project guides and refresh examples

Dami committed 8d8a4a1
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>
11 files changed, +423 -432
AARCHITECTURE.md117++++++++
ACONTRIBUTING.md95++++++
MREADME.md454+++++-------------------------
ASECURITY.md82+++++
Mcli/README.md11+-
Mcli/main.ts4+-
Mskills/nzip/SKILL.md2+-
Mworker/setup.md22+-
Mworker/test/index.runtime.test.ts56++--
Mworker/test/notify.test.mjs6+-
Mworker/wrangler.jsonc6+-
A ARCHITECTURE.md +117 -0
@@ -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.
A CONTRIBUTING.md +95 -0
@@ -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.
M README.md +80 -374
@@ -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 [![JSR](https://jsr.io/badges/@nzip/cli)](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.
A SECURITY.md +82 -0
@@ -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).
M cli/README.md +6 -5
@@ -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.
M cli/main.ts +2 -2
M skills/nzip/SKILL.md +1 -1
M worker/setup.md +6 -16
@@ -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).
M worker/test/index.runtime.test.ts +28 -28
@@ -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" }),
M worker/test/notify.test.mjs +3 -3
M worker/wrangler.jsonc +3 -3