Persist bounded probe windows in D1 and evaluate scanner, distributed, rate-limit, and live-hit signals. Send free-plan Email Routing notifications with escalation, hourly summary, quiet resolution, daily activity digest, and authenticated delivery testing.
- commit
- 66cfb8308a8cee55391cece624b432809cde01de
- parent
- 5b67fbe6c462226d2647efde6df45162f2418779
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 232 | +++++++++++++---------- |
| A | worker/ | 32 | +++ |
| M | worker/ | 33 | +++ |
| M | worker/ | 68 | +++++- |
| M | worker/ | 5 | + |
| M | worker/ | 11 | +- |
| M | worker/ | 46 | ++++- |
| M | worker/ | 56 | +++++- |
| A | worker/ | 309 | ++++++++++++++++++++++++++++++ |
| A | worker/ | 126 | ++++++++++++ |
| M | worker/ | 99 | ++++++---- |
@@ -2,13 +2,13 @@
22 33 **Push a directory of HTML from the terminal. Get a four-character URL back.**44 5-_Deno CLI → Cloudflare Worker → R2 + D1. Zero-cost static site hosting with optional expiration6-and/or password protection._5+_Deno CLI → Cloudflare Worker → R2 + D1. Zero-cost static site hosting with6+optional expiration and/or password protection._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 push ./demo work:demo --ttl 30d@@ -21,9 +21,9 @@ $ nzip push ./demo work:demo --ttl 30d
2121 2222 ## How addresses work2323 24-Every share lives at four hex characters. The first digit selects one of **16 registered vaults**,25-the rest a site within it. Each vault holds 4,096 slots, allocated randomly so URLs don't leak how26-many sites exist.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.2727 2828 ```text2929 https://share.example.com/2a3f@@ -31,8 +31,8 @@ https://share.example.com/2a3f
3131 +-- vault 0x2 ("work" - 16 slots, registered by name)3232 ```3333 34-Aliases like `work:demo` are resolved by the API; the URL never exposes them. Commands accept any of35-three target forms:34+Aliases like `work:demo` are resolved by the API; the URL never exposes them.35+Commands accept any of three target forms:3636 3737 | form | example | meaning |3838 | ----------- | ----------- | -------------------------- |@@ -42,28 +42,33 @@ three target forms:
4242 4343 ## Features4444 45-- **Instant.** A push is one manifest exchange plus the blobs the server hasn't seen. Nothing46- rebuilds, nothing redeploys.47-- **Content-addressed.** Per-file sha256 blobs plus a canonical manifest per push. Identical files48- are stored once across every site; re-pushing an unchanged directory uploads `0 new blobs`.49-- **Ephemeral by default.** 14-day TTL, with `--ttl 30d` or `--ttl forever` to override. Expired50- shares answer `410 Gone`; a daily cron sweeps them and garbage-collects unreferenced content.51-- **Revertible.** The last 10 pushes per site are kept. `nzip revert` repoints the address at any of52- them, and the revert itself is recorded as a push.53-- **Password-protectable.** `nzip push ./demo work:demo --password …` publishes the content and54- password policy atomically, then gates the site behind an unlock form (PBKDF2 hashing, signed55- per-site cookie, 7 days). Changing the password policy revokes existing cookies immediately. Use56- `nzip site` to change protection later.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 revert` repoints54+ the address at any of them, and the revert itself is recorded as a push.55+- **Password-protectable.** `nzip 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` to59+ change protection later.5760 - **Single-user by design.** One bearer token, stored as a Worker secret and in5861 `~/.config/nzip/config.json` (mode 0600).59-- **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site60- from. A breadcrumb registry (`~/.config/nzip/paths.json`) records the source path on every push61- and self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls` reconciles62- against the live set. Use it as `cd "$(nzip where personal:plan)"`.63-- **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json` restricts which vaults64- this install may target by name. Pushes or aliases outside the list are refused before any upload,65- so a home-project agent can't drop a doc into a vault that sits adjacent to what you share66- professionally. Absent = unrestricted; raw hex addresses bypass it (they name no vault).62+- **Locates itself.** `nzip where <target>` prints the local directory this63+ machine pushed a site from. A breadcrumb registry64+ (`~/.config/nzip/paths.json`) records the source path on every push and65+ self-cleans: expired entries drop on write, `rm` forgets its entry, and `ls`66+ reconciles against the live set. Use it as `cd "$(nzip where personal:plan)"`.67+- **Vault guardrail.** An optional `"allowVaults": ["home"]` in `config.json`68+ restricts which vaults this install may target by name. Pushes or aliases69+ outside the list are refused before any upload, so a home-project agent can't70+ drop a doc into a vault that sits adjacent to what you share professionally.71+ Absent = unrestricted; raw hex addresses bypass it (they name no vault).6772 6873 ## Commands6974 @@ -81,21 +86,24 @@ nzip status server + vault overview
8186 nzip revert <target> [--to N] [--list] repoint to a previous push8287 ```8388 84-Password and TTL are committed with the content. On a new site, omitting `--password` creates an85-unprotected site; on an existing target, omission preserves its current password. Pass86-`--no-password` to clear protection explicitly. The former `nzip share` command remains available as87-a compatibility alias for `nzip site`.88-89-Pushing a single `page.html` stores it as the site's `index.html`. Directory pushes skip dotfiles90-and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file sites serve directly91-at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs92-resolve.93-94-`nzip cp work:demo ./recovered-demo` recovers the exact current bundle from the authenticated server95-when the original local directory is unavailable. It refuses non-empty destinations unless96-`--overwrite` is passed, and verifies every downloaded file against the hosted manifest. It can only97-restore uploaded files—not dotfiles, `.nzipignore`, or other local project metadata excluded from a98-push. The former `nzip download` command remains available as a compatibility alias.89+Password and TTL are committed with the content. On a new site, omitting90+`--password` creates an unprotected site; on an existing target, omission91+preserves its current password. Pass `--no-password` to clear protection92+explicitly. The former `nzip share` command remains available as a compatibility93+alias for `nzip site`.94+95+Pushing a single `page.html` stores it as the site's `index.html`. Directory96+pushes skip dotfiles and `node_modules`, and honor a `.nzipignore` (one glob per97+line). Single-file sites serve directly at the bare address (`/2a3f`);98+multi-file bundles redirect to `/2a3f/` so relative asset URLs resolve.99+100+`nzip cp work:demo ./recovered-demo` recovers the exact current bundle from the101+authenticated server when the original local directory is unavailable. It102+refuses non-empty destinations unless `--overwrite` is passed, and verifies103+every downloaded file against the hosted manifest. It can only restore uploaded104+files—not dotfiles, `.nzipignore`, or other local project metadata excluded from105+a push. The former `nzip download` command remains available as a compatibility106+alias.99107 100108 ## Architecture101109 @@ -109,27 +117,29 @@ flowchart LR
109117 110118 A push is a stateless three-step protocol, and the manifest itself is the state:111119 112-1. `POST /api/push/prepare`: send the manifest, get back which blob hashes the server is missing113-2. `PUT /api/blob/{sha256}`: upload only those, one per request, 6 at a time; the server re-hashes114- and rejects mismatches115-3. `POST /api/push/commit`: the server re-verifies every blob exists, then commits atomically,116- resolving or allocating the address, applying TTL/password policy, repointing the site, and117- appending history118-119-Serving is one D1 read (address → manifest, expiry, password) and two R2 reads, with `ETag`120-revalidation and a 60-second tiered Workers Cache. Cache hits skip Worker execution and the D1/R2121-reads entirely. Public responses are tagged per site and purged when a push, revert, deletion, TTL,122-or password policy changes; protected responses and errors are never cached. Metadata lives in three tables:123-124-| table | keys |125-| -------- | ------------------------------------------------------------------------ |126-| `vaults` | slot (0–15), name |120+1. `POST /api/push/prepare`: send the manifest, get back which blob hashes the121+ server is missing122+2. `PUT /api/blob/{sha256}`: upload only those, one per request, 6 at a time;123+ the server re-hashes and rejects mismatches124+3. `POST /api/push/commit`: the server re-verifies every blob exists, then125+ commits atomically, resolving or allocating the address, applying126+ TTL/password policy, repointing the site, and appending history127+128+Serving is one D1 read (address → manifest, expiry, password) and two R2 reads,129+with `ETag` revalidation and a 60-second tiered Workers Cache. Cache hits skip130+Worker execution and the D1/R2 reads entirely. Public responses are tagged per131+site and purged when a push, revert, deletion, TTL, or password policy changes;132+protected responses and errors are never cached. Metadata lives in three tables:133+134+| table | keys |135+| -------- | -------------------------------------------------------------------------------- |136+| `vaults` | slot (0–15), name |127137 | `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash/version |128-| `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |138+| `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |129139 130-**GC safety rule:** an R2 object is deleted only if no live site or retained history entry131-references it _and_ it's older than 24 hours, so an in-flight push can never lose objects to a132-concurrent sweep.140+**GC safety rule:** an R2 object is deleted only if no live site or retained141+history entry references it _and_ it's older than 24 hours, so an in-flight push142+can never lose objects to a concurrent sweep.133143 134144 ## Repo layout135145 @@ -139,14 +149,16 @@ cli/ the nzip command (Deno, no framework), published as jsr:@nzip/cli
139149 worker/ Cloudflare Worker: serving + API + GC cron (wrangler)140150 ```141151 142-`shared/` is the contract: canonical JSON serialization lives in exactly one file143-([`shared/manifest.ts`](shared/manifest.ts)) so the CLI and Worker can never disagree about a144-manifest hash. It sticks to Web-standard APIs only, so the same code runs under Deno and workerd.152+`shared/` is the contract: canonical JSON serialization lives in exactly one153+file ([`shared/manifest.ts`](shared/manifest.ts)) so the CLI and Worker can154+never disagree about a manifest hash. It sticks to Web-standard APIs only, so155+the same code runs under Deno and workerd.145156 146157 ## Install147158 148-The CLI ships on [JSR](https://jsr.io/@nzip/cli). With [Deno](https://docs.deno.com/runtime/) on149-your `PATH`, install it in one line, then point it at your server and push:159+The CLI ships on [JSR](https://jsr.io/@nzip/cli). With160+[Deno](https://docs.deno.com/runtime/) on your `PATH`, install it in one line,161+then point it at your server and push:150162 151163 ```sh152164 deno install -g -A -f -n nzip jsr:@nzip/cli # 1. install the `nzip` command@@ -156,18 +168,20 @@ nzip auth --server https://share.example.com # 2. authenticate (prompts for th
156168 nzip push ./site work:demo # 3. get a URL back157169 ```158170 159-`nzip auth` prompts for anything you omit and saves it to `~/.config/nzip/config.json` (mode 0600),160-so every later command just works. Upgrade any time by re-running the install line; try it without161-installing via `deno run -A jsr:@nzip/cli --help`.171+`nzip auth` prompts for anything you omit and saves it to172+`~/.config/nzip/config.json` (mode 0600), so every later command just works.173+Upgrade any time by re-running the install line; try it without installing via174+`deno run -A jsr:@nzip/cli --help`.162175 163-Adding a **second machine**? Only the CLI is per-machine; the Worker, R2, D1, vaults, and token are164-already provisioned. Install from JSR and run `nzip auth` with the same server and token, and every165-vault and site is instantly there. (The `nzip where` breadcrumb registry is local, so it only knows166-sites pushed from this machine.)176+Adding a **second machine**? Only the CLI is per-machine; the Worker, R2, D1,177+vaults, and token are already provisioned. Install from JSR and run `nzip auth`178+with the same server and token, and every vault and site is instantly there.179+(The `nzip where` breadcrumb registry is local, so it only knows sites pushed180+from this machine.)167181 168-`nzip` is self-hosted. The server URL is supplied by the operator; there is no bundled public169-service. Use the same URL you set as `vars.PUBLIC_BASE` in your Wrangler config. Standing up that170-server is the one-time setup below.182+`nzip` is self-hosted. The server URL is supplied by the operator; there is no183+bundled public service. Use the same URL you set as `vars.PUBLIC_BASE` in your184+Wrangler config. Standing up that server is the one-time setup below.171185 172186 ## Self-hosting: one-time Cloudflare setup173187 @@ -189,18 +203,21 @@ npx wrangler deploy --config wrangler.local.jsonc
189203 nzip auth --server https://share.example.com # use your actual PUBLIC_BASE190204 ```191205 192-`routes[0].pattern` and `vars.PUBLIC_BASE` are required for a real deployment. `PUBLIC_BASE`193-controls the URLs returned by `nzip push`, and the CLI authenticates against that same origin.206+`routes[0].pattern` and `vars.PUBLIC_BASE` are required for a real deployment.207+`PUBLIC_BASE` controls the URLs returned by `nzip push`, and the CLI208+authenticates against that same origin.194209 195210 Two gotchas learned the hard way:196211 197-- **Cron triggers require a workers.dev subdomain** on the account (API error `10063`), even if the198- Worker only serves a custom domain. Register one in the dashboard (or199- `PUT /accounts/{id}/workers/subdomain`) before deploying with `triggers`.212+- **Cron triggers require a workers.dev subdomain** on the account (API error213+ `10063`), even if the Worker only serves a custom domain. Register one in the214+ dashboard (or `PUT /accounts/{id}/workers/subdomain`) before deploying with215+ `triggers`.200216 - The required custom-domain route201217 (`"routes": [{ "pattern": "share.example.com",202- "custom_domain": true }]`) needs the zone on your203- account; it provisions DNS and the cert automatically on deploy.218+ "custom_domain": true }]`)219+ needs the zone on your account; it provisions DNS and the cert automatically220+ on deploy.204221 205222 ## Development206223 @@ -218,17 +235,34 @@ Test the GC cron: `npx wrangler dev --test-scheduled`, then
218235 219236 ### Security observability220237 221-Workers Logs are enabled by default for structured scan telemetry, with automatic invocation logs222-disabled to keep volume and cost bounded. The Worker retains complete request sequences for a223-deterministic 1% sample of scanner identities. Events are emitted as `security.request` and include224-the request class, bounded path, response result, derived vault/site slots for four-hex addresses,225-country, Cloudflare colo, ASN, and an HMAC-derived `scanner_id`; raw client IPs are never logged.226-227-Inspect these events under **Workers & Pages → nzip → Observability**, filtering on228-`event = "security.request"`. The `sample_rate` field records the sampling factor for aggregate229-estimates. Normal successful asset requests are omitted, while bare address probes, unlock attempts,230-bad API requests, invalid paths, and missing address assets are eligible.231-232-Rejected `429` floods receive a second 1% per-request sample after the identity sample. This keeps233-the allowed probe sequence intact while preventing a single blocked client from producing unbounded234-log volume; each event's `sample_rate` reflects the effective rate.238+Workers Logs are enabled by default for structured scan telemetry, with239+automatic invocation logs disabled to keep volume and cost bounded. The Worker240+retains complete request sequences for a deterministic 1% sample of scanner241+identities. Events are emitted as `security.request` and include the request242+class, bounded path, response result, derived vault/site slots for four-hex243+addresses, country, Cloudflare colo, ASN, and an HMAC-derived `scanner_id`; raw244+client IPs are never logged.245+246+Inspect these events under **Workers & Pages → nzip → Observability**, filtering247+on `event = "security.request"`. The `sample_rate` field records the sampling248+factor for aggregate estimates. Normal successful asset requests are omitted,249+while bare address probes, unlock attempts, bad API requests, invalid paths, and250+missing address assets are eligible.251+252+Rejected `429` floods receive a second 1% per-request sample after the identity253+sample. This keeps the allowed probe sequence intact while preventing a single254+blocked client from producing unbounded log volume; each event's `sample_rate`255+reflects the effective rate.256+257+### Enumeration email alerts258+259+The Worker also evaluates bounded, unsampled bare-address observations every260+five minutes and sends incident email through the free Email Routing binding.261+The primary warning is 20 distinct addresses from one scanner in five minutes; a262+distributed 128-address sweep from 10 scanners also warns when at least 90%263+miss. A limiter rejection or a suspicious live-address hit promotes the incident264+to confirmed. Re-alerting is immediate when severity increases or volume265+doubles, otherwise limited to an hourly summary, and an incident resolves after266+15 quiet minutes. An activity digest is also sent daily, but only when probes267+occurred. See [`worker/setup.md`](worker/setup.md#free-security-alert-email) for268+setup and delivery validation.@@ -0,0 +1,32 @@
1+CREATE TABLE IF NOT EXISTS security_probes (2+ bucket INTEGER NOT NULL,3+ scanner_id TEXT NOT NULL,4+ address INTEGER NOT NULL CHECK (address BETWEEN 0 AND 65535),5+ vault_slot INTEGER NOT NULL CHECK (vault_slot BETWEEN 0 AND 15),6+ is_live INTEGER NOT NULL CHECK (is_live IN (0, 1)),7+ country TEXT,8+ asn INTEGER,9+ PRIMARY KEY (bucket, scanner_id, address)10+);11+12+CREATE TABLE IF NOT EXISTS security_signals (13+ bucket INTEGER NOT NULL,14+ scanner_id TEXT NOT NULL,15+ kind TEXT NOT NULL,16+ PRIMARY KEY (bucket, scanner_id, kind)17+);18+19+CREATE TABLE IF NOT EXISTS security_incidents (20+ name TEXT PRIMARY KEY,21+ status TEXT NOT NULL CHECK (status IN ('open', 'closed')),22+ severity INTEGER NOT NULL,23+ opened_at INTEGER NOT NULL,24+ last_seen_at INTEGER NOT NULL,25+ last_alerted_at INTEGER NOT NULL,26+ quiet_windows INTEGER NOT NULL,27+ peak_addresses INTEGER NOT NULL,28+ total_addresses INTEGER NOT NULL,29+ total_live_hits INTEGER NOT NULL,30+ total_rate_limited INTEGER NOT NULL,31+ vault_mask INTEGER NOT NULL32+);@@ -29,3 +29,36 @@ CREATE TABLE IF NOT EXISTS pushes (
2929 note TEXT,3030 PRIMARY KEY (address, seq)3131 );32+33+CREATE TABLE IF NOT EXISTS security_probes (34+ bucket INTEGER NOT NULL,35+ scanner_id TEXT NOT NULL,36+ address INTEGER NOT NULL CHECK (address BETWEEN 0 AND 65535),37+ vault_slot INTEGER NOT NULL CHECK (vault_slot BETWEEN 0 AND 15),38+ is_live INTEGER NOT NULL CHECK (is_live IN (0, 1)),39+ country TEXT,40+ asn INTEGER,41+ PRIMARY KEY (bucket, scanner_id, address)42+);43+44+CREATE TABLE IF NOT EXISTS security_signals (45+ bucket INTEGER NOT NULL,46+ scanner_id TEXT NOT NULL,47+ kind TEXT NOT NULL,48+ PRIMARY KEY (bucket, scanner_id, kind)49+);50+51+CREATE TABLE IF NOT EXISTS security_incidents (52+ name TEXT PRIMARY KEY,53+ status TEXT NOT NULL CHECK (status IN ('open', 'closed')),54+ severity INTEGER NOT NULL,55+ opened_at INTEGER NOT NULL,56+ last_seen_at INTEGER NOT NULL,57+ last_alerted_at INTEGER NOT NULL,58+ quiet_windows INTEGER NOT NULL,59+ peak_addresses INTEGER NOT NULL,60+ total_addresses INTEGER NOT NULL,61+ total_live_hits INTEGER NOT NULL,62+ total_rate_limited INTEGER NOT NULL,63+ vault_mask INTEGER NOT NULL64+);@@ -31,29 +31,73 @@ npx wrangler secret put NZIP_TOKEN --config wrangler.local.jsonc
3131 npx wrangler deploy --config wrangler.local.jsonc3232 ```3333 34-`routes[0].pattern` and `vars.PUBLIC_BASE` are required user-provided values. `PUBLIC_BASE` is the35-origin printed in share URLs and the server URL passed to `nzip auth`.34+## Free security alert email3635 37-When upgrading an existing deployment created before `auth_version` was added, apply its migration38-before deploying the new Worker:36+Enumeration alerts use the Email Routing Worker binding, not the paid Email37+Sending product. Email Routing can send to a verified destination on the free38+plan. Enable it for the Worker domain, add and verify the operator address, then39+set these values in `wrangler.local.jsonc`:40+41+```sh42+npx wrangler email routing enable example.com43+npx wrangler email routing addresses create operator@example.com44+```45+46+Click the verification link Cloudflare sends, then configure:47+48+```jsonc49+"send_email": [{ "name": "EMAIL", "destination_address": "operator@example.com" }],50+"vars": {51+ "ALERT_EMAIL_TO": "operator@example.com",52+ "ALERT_EMAIL_FROM": "alerts@share.example.com"53+}54+```55+56+Apply `migrations/0002_security_alerts.sql` before deploying an upgraded Worker.57+After deployment, send a delivery test through the owner-authenticated endpoint:58+59+```sh60+curl -X POST -H "Authorization: Bearer $NZIP_TOKEN" \61+ https://share.example.com/api/security/test-alert62+```63+64+The Worker opens an incident when one scanner tries 20 distinct addresses in65+five minutes, or a distributed sweep reaches 128 addresses from 10 scanners with66+at least 90% misses. A rate-limit hit or a suspicious live-address hit confirms67+the incident. Duplicate email is suppressed unless severity increases or volume68+doubles, active incidents summarize hourly, and three quiet windows (15 minutes)69+resolve the incident. Probe rows are deduplicated by scanner/address, capped at70+30 per scanner per minute, contain no raw IP, and are pruned after seven days. A71+daily activity digest is sent only when probes occurred in the preceding 2472+hours.73+74+`routes[0].pattern` and `vars.PUBLIC_BASE` are required user-provided values.75+`PUBLIC_BASE` is the origin printed in share URLs and the server URL passed to76+`nzip auth`.77+78+When upgrading an existing deployment created before `auth_version` was added,79+apply its migration before deploying the new Worker:3980 4081 ```sh4182 cd worker4283 npx wrangler d1 execute nzip --remote --file migrations/0001_auth_version.sql84+npx wrangler d1 execute nzip --remote --file migrations/0002_security_alerts.sql4385 ```4486 45-> **Cron gotcha:** deploying the `triggers` block fails with a 403 (API error `10063`) until the46-> account has a workers.dev subdomain registered — even if the Worker only serves a custom domain.47-> Open the Workers dashboard once to auto-create it, or `PUT /accounts/{id}/workers/subdomain` with87+> **Cron gotcha:** deploying the `triggers` block fails with a 403 (API error88+> `10063`) until the account has a workers.dev subdomain registered — even if89+> the Worker only serves a custom domain. Open the Workers dashboard once to90+> auto-create it, or `PUT /accounts/{id}/workers/subdomain` with4891 > `{"subdomain":"<name>"}`.4992 5093 ## Required custom domain5194 52-1. Add your zone to your Cloudflare account (registrar -> Cloudflare nameservers).53-2. In `wrangler.local.jsonc`, set `routes[0].pattern`, `vars.PUBLIC_BASE`, and your D154- `database_id`.55-3. `npx wrangler deploy --config wrangler.local.jsonc` - the custom-domain route provisions DNS +56- cert automatically.95+1. Add your zone to your Cloudflare account (registrar -> Cloudflare96+ nameservers).97+2. In `wrangler.local.jsonc`, set `routes[0].pattern`, `vars.PUBLIC_BASE`, and98+ your D1 `database_id`.99+3. `npx wrangler deploy --config wrangler.local.jsonc` - the custom-domain route100+ provisions DNS + cert automatically.57101 58102 ## CLI59103 @@ -39,6 +39,7 @@ import {
3939 import { type Env, err, json, siteUrl } from "./env.ts";4040 import { purgeSiteCache } from "./cache.ts";4141 import { hashPassword } from "./password.ts";42+import { sendAlertTest } from "./security_alerts.ts";4243 4344 const HEX64 = /^[0-9a-f]{64}$/;4445 const DEFAULT_TTL_DAYS = 14;@@ -218,6 +219,10 @@ export async function api(
218219 219220 // status220221 if (parts[1] === "status" && method === "GET") return await handleStatus(env);222+ if (parts[1] === "security" && parts[2] === "test-alert" && method === "POST") {223+ await sendAlertTest(env);224+ return json({ ok: true });225+ }221226 222227 // vaults223228 if (parts[1] === "vaults" && parts.length === 2) {@@ -7,9 +7,18 @@ export interface Env {
77 RL_ENUM: RateLimit;88 // Per-IP+address limiter for the unlock endpoint — throttles password guessing.99 RL_UNLOCK: RateLimit;10+ // Caps persisted enumeration observations per scanner to bound D1 usage.11+ RL_OBSERVE: RateLimit;12+ EMAIL: SendEmail;13+ ALERT_EMAIL_TO: string;14+ ALERT_EMAIL_FROM: string;1015 }1116 12-export function json<T = unknown>(body: T, status = 200, headers: HeadersInit = {}): Response {17+export function json<T = unknown>(18+ body: T,19+ status = 200,20+ headers: HeadersInit = {},21+): Response {1322 return new Response(JSON.stringify(body), {1423 status,1524 headers: { "content-type": "application/json; charset=utf-8", ...headers },@@ -3,21 +3,34 @@ import { checkBearer } from "./auth.ts";
33 import type { Env } from "./env.ts";44 import { err } from "./env.ts";55 import { runGc } from "./gc.ts";6-import { logSecurityRequest } from "./observability.ts";6+import { logSecurityRequest, recordEnumerationProbe } from "./observability.ts";7+import {8+ evaluateEnumerationWindow,9+ pruneSecurityTelemetry,10+ sendDailySecurityDigest,11+} from "./security_alerts.ts";712 import { serve } from "./serve.ts";813 914 function tooManyRequests(): Response {1015 return new Response("rate limited — slow down\n", {1116 status: 429,12- headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60" },17+ headers: {18+ "content-type": "text/plain; charset=utf-8",19+ "retry-after": "60",20+ },1321 });1422 }1523 1624 export default {17- async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {25+ async fetch(26+ req: Request,27+ env: Env,28+ ctx: ExecutionContext,29+ ): Promise<Response> {1830 const url = new URL(req.url);1931 const finish = (response: Response): Response => {2032 ctx.waitUntil(logSecurityRequest(req, env, url, response));33+ ctx.waitUntil(recordEnumerationProbe(req, env, url, response));2134 return response;2235 };2336 @@ -26,7 +39,8 @@ export default {
2639 return finish(await api(req, env, url, ctx));2740 }2841 29- const isUnlock = req.method === "POST" && /^\/[0-9a-f]{4}\/__unlock$/.test(url.pathname);42+ const isUnlock = req.method === "POST" &&43+ /^\/[0-9a-f]{4}\/__unlock$/.test(url.pathname);3044 if (req.method !== "GET" && req.method !== "HEAD" && !isUnlock) {3145 return finish(err("method not allowed", 405));3246 }@@ -37,7 +51,9 @@ export default {
3751 const ip = req.headers.get("cf-connecting-ip") ?? "local";3852 if (isUnlock) {3953 const address = url.pathname.slice(1, 5);40- const { success } = await env.RL_UNLOCK.limit({ key: `${ip}:${address}` });54+ const { success } = await env.RL_UNLOCK.limit({55+ key: `${ip}:${address}`,56+ });4157 if (!success) return finish(tooManyRequests());4258 } else if (/^\/[0-9a-f]{4}\/?$/.test(url.pathname)) {4359 const { success } = await env.RL_ENUM.limit({ key: ip });@@ -47,10 +63,24 @@ export default {
4763 return finish(await serve(req, env, url));4864 },4965 50- async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {66+ async scheduled(67+ controller: ScheduledController,68+ env: Env,69+ ctx: ExecutionContext,70+ ): Promise<void> {71+ if (controller.cron === "*/5 * * * *") {72+ ctx.waitUntil(evaluateEnumerationWindow(env));73+ return;74+ }5175 ctx.waitUntil(52- runGc(env).then((r) =>53- console.log(`gc: ${r.expiredSites} expired sites, ${r.deletedObjects} objects deleted`)76+ Promise.all([77+ runGc(env),78+ pruneSecurityTelemetry(env),79+ sendDailySecurityDigest(env),80+ ]).then(([r]) =>81+ console.log(82+ `gc: ${r.expiredSites} expired sites, ${r.deletedObjects} objects deleted`,83+ )5484 ),5585 );5686 },@@ -88,7 +88,10 @@ export function buildSecurityRequestEvent(
8888 };8989 }9090 91-async function scannerDigest(ip: string, secret: string): Promise<Uint8Array> {91+export async function scannerDigest(92+ ip: string,93+ secret: string,94+): Promise<Uint8Array> {9295 const encoder = new TextEncoder();9396 const key = await crypto.subtle.importKey(9497 "raw",@@ -102,12 +105,61 @@ async function scannerDigest(ip: string, secret: string): Promise<Uint8Array> {
102105 );103106 }104107 105-function hex(bytes: Uint8Array): string {108+export function hex(bytes: Uint8Array): string {106109 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(107110 "",108111 );109112 }110113 114+/** Persist a bounded, privacy-preserving record for five-minute alert evaluation. */115+export async function recordEnumerationProbe(116+ req: Request,117+ env: Env,118+ url: URL,119+ response: Response,120+): Promise<void> {121+ const match = url.pathname.match(EXACT_ADDRESS_PATH);122+ if (!match) return;123+124+ try {125+ const ip = req.headers.get("cf-connecting-ip") ?? "local";126+ const digest = await scannerDigest(ip, env.NZIP_TOKEN);127+ const scannerId = hex(digest.slice(0, 8));128+ const { success } = await env.RL_OBSERVE.limit({ key: scannerId });129+ if (!success && response.status !== 429) return;130+131+ const now = Math.floor(Date.now() / 1000);132+ const bucket = now - (now % 300);133+ const address = Number.parseInt(match[1], 16);134+ const cf = req.cf;135+ await env.DB.prepare(136+ `INSERT OR IGNORE INTO security_probes137+ (bucket, scanner_id, address, vault_slot, is_live, country, asn)138+ VALUES (?, ?, ?, ?, ?, ?, ?)`,139+ ).bind(140+ bucket,141+ scannerId,142+ address,143+ address >>> 12,144+ response.status < 400 ? 1 : 0,145+ cf?.country ?? null,146+ typeof cf?.asn === "number" ? cf.asn : null,147+ ).run();148+149+ if (response.status === 429) {150+ await env.DB.prepare(151+ `INSERT OR IGNORE INTO security_signals (bucket, scanner_id, kind)152+ VALUES (?, ?, 'rate_limited')`,153+ ).bind(bucket, scannerId).run();154+ }155+ } catch (error) {156+ console.error({157+ event: "security.recording_failed",158+ error: error instanceof Error ? error.message : String(error),159+ });160+ }161+}162+111163 /**112164 * Retain complete sequences for a deterministic ~1% sample of scanner identities.113165 * This is more useful for enumeration detection, and much cheaper, than sampling@@ -0,0 +1,309 @@
1+import type { Env } from "./env.ts";2+3+export interface WindowStats {4+ bucket: number;5+ addresses: number;6+ scanners: number;7+ misses: number;8+ liveHits: number;9+ rateLimited: number;10+ topScannerAddresses: number;11+ longestSequence: number;12+ vaultMask: number;13+ countries: string[];14+ asns: number[];15+}16+17+interface IncidentRow {18+ status: "open" | "closed";19+ severity: number;20+ opened_at: number;21+ last_seen_at: number;22+ last_alerted_at: number;23+ quiet_windows: number;24+ peak_addresses: number;25+ total_addresses: number;26+ total_live_hits: number;27+ total_rate_limited: number;28+ vault_mask: number;29+}30+31+export type AlertAction = "open" | "escalate" | "summary" | "resolve" | null;32+33+export function signalSeverity(34+ stats: WindowStats,35+ incidentOpen = false,36+): number {37+ const distributed = stats.addresses >= 128 && stats.scanners >= 10 &&38+ (stats.addresses === 0 || stats.misses / stats.addresses >= 0.9);39+ const scannerSweep = stats.topScannerAddresses >= 20;40+ const suspiciousLiveHit = stats.liveHits > 0 &&41+ (incidentOpen || stats.topScannerAddresses >= 8 ||42+ stats.longestSequence >= 8);43+ if (stats.rateLimited > 0 || suspiciousLiveHit) return 2;44+ if (scannerSweep || distributed) return 1;45+ return 0;46+}47+48+export function chooseAlertAction(49+ incident: IncidentRow | null,50+ stats: WindowStats,51+ now: number,52+): AlertAction {53+ const severity = signalSeverity(stats, incident?.status === "open");54+ if (!incident || incident.status === "closed") {55+ return severity > 0 ? "open" : null;56+ }57+ if (severity === 0) return incident.quiet_windows >= 2 ? "resolve" : null;58+ if (59+ severity > incident.severity ||60+ stats.addresses >= Math.max(20, incident.peak_addresses * 2) ||61+ (stats.liveHits > 0 && incident.total_live_hits === 0)62+ ) return "escalate";63+ const reachedNewVault = (stats.vaultMask & ~incident.vault_mask) !== 0;64+ if (reachedNewVault && now - incident.last_alerted_at >= 1800) {65+ return "escalate";66+ }67+ if (now - incident.last_alerted_at >= 3600) return "summary";68+ return null;69+}70+71+function longestAdjacentRun(addresses: number[]): number {72+ if (addresses.length === 0) return 0;73+ const sorted = [...new Set(addresses)].sort((a, b) => a - b);74+ let longest = 1;75+ let current = 1;76+ for (let i = 1; i < sorted.length; i++) {77+ current = sorted[i] === sorted[i - 1] + 1 ? current + 1 : 1;78+ longest = Math.max(longest, current);79+ }80+ return longest;81+}82+83+async function loadWindow(env: Env, bucket: number): Promise<WindowStats> {84+ const probes = await env.DB.prepare(85+ `SELECT scanner_id, address, is_live, vault_slot, country, asn86+ FROM security_probes WHERE bucket = ?`,87+ ).bind(bucket).all<{88+ scanner_id: string;89+ address: number;90+ is_live: number;91+ vault_slot: number;92+ country: string | null;93+ asn: number | null;94+ }>();95+ const signals = await env.DB.prepare(96+ `SELECT COUNT(*) AS count FROM security_signals97+ WHERE bucket = ? AND kind = 'rate_limited'`,98+ ).bind(bucket).first<{ count: number }>();99+100+ const byScanner = new Map<string, number[]>();101+ let liveHits = 0;102+ let vaultMask = 0;103+ const countries = new Set<string>();104+ const asns = new Set<number>();105+ for (const probe of probes.results) {106+ const addresses = byScanner.get(probe.scanner_id) ?? [];107+ addresses.push(probe.address);108+ byScanner.set(probe.scanner_id, addresses);109+ liveHits += probe.is_live;110+ vaultMask |= 1 << probe.vault_slot;111+ if (probe.country) countries.add(probe.country);112+ if (probe.asn !== null) asns.add(probe.asn);113+ }114+ const scannerAddresses = [...byScanner.values()];115+ return {116+ bucket,117+ addresses: probes.results.length,118+ scanners: byScanner.size,119+ misses: probes.results.length - liveHits,120+ liveHits,121+ rateLimited: signals?.count ?? 0,122+ topScannerAddresses: Math.max(123+ 0,124+ ...scannerAddresses.map((values) => values.length),125+ ),126+ longestSequence: Math.max(0, ...scannerAddresses.map(longestAdjacentRun)),127+ vaultMask,128+ countries: [...countries].sort().slice(0, 10),129+ asns: [...asns].sort((a, b) => a - b).slice(0, 10),130+ };131+}132+133+function subject(action: Exclude<AlertAction, null>, severity: number): string {134+ if (action === "resolve") return "[nzip] Enumeration incident resolved";135+ if (action === "summary") return "[nzip] Enumeration incident summary";136+ return `[nzip] ${137+ severity >= 2 ? "Confirmed" : "Possible"138+ } enumeration ${action}`;139+}140+141+function emailText(142+ action: Exclude<AlertAction, null>,143+ stats: WindowStats,144+ incident: IncidentRow,145+): string {146+ const start = new Date(incident.opened_at * 1000).toISOString();147+ return [148+ `Enumeration incident: ${action}`,149+ `Severity: ${incident.severity >= 2 ? "confirmed" : "warning"}`,150+ `Opened: ${start}`,151+ `This window: ${stats.addresses} distinct scanner/address pairs, ${stats.scanners} scanners`,152+ `Top scanner: ${stats.topScannerAddresses} distinct addresses`,153+ `Live hits: ${stats.liveHits}; rate-limited scanners: ${stats.rateLimited}`,154+ `Longest adjacent run: ${stats.longestSequence}`,155+ `Incident totals: ${incident.total_addresses} observations, ${incident.total_live_hits} live hits, ${incident.total_rate_limited} rate-limit confirmations`,156+ `Countries: ${stats.countries.join(", ") || "unknown"}`,157+ `ASNs: ${stats.asns.join(", ") || "unknown"}`,158+ "Re-alert policy: immediate on escalation; otherwise at most hourly; resolves after 15 quiet minutes.",159+ ].join("\n");160+}161+162+async function sendAlert(163+ env: Env,164+ action: Exclude<AlertAction, null>,165+ stats: WindowStats,166+ incident: IncidentRow,167+): Promise<void> {168+ const text = emailText(action, stats, incident);169+ await env.EMAIL.send({170+ from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },171+ to: env.ALERT_EMAIL_TO,172+ subject: subject(action, incident.severity),173+ text,174+ html:175+ `<pre style="font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap">${176+ text.replaceAll("&", "&").replaceAll("<", "<")177+ }</pre>`,178+ });179+}180+181+export async function evaluateEnumerationWindow(182+ env: Env,183+ now = Math.floor(Date.now() / 1000),184+): Promise<void> {185+ const bucket = now - (now % 300) - 300;186+ const stats = await loadWindow(env, bucket);187+ const current = await env.DB.prepare(188+ "SELECT * FROM security_incidents WHERE name = 'enumeration'",189+ ).first<IncidentRow>();190+ const action = chooseAlertAction(current ?? null, stats, now);191+ const severity = signalSeverity(stats, current?.status === "open");192+193+ let incident: IncidentRow;194+ if (!current || current.status === "closed") {195+ incident = {196+ status: severity > 0 ? "open" : "closed",197+ severity,198+ opened_at: now,199+ last_seen_at: severity > 0 ? now : 0,200+ last_alerted_at: action ? now : 0,201+ quiet_windows: 0,202+ peak_addresses: stats.addresses,203+ total_addresses: stats.addresses,204+ total_live_hits: stats.liveHits,205+ total_rate_limited: stats.rateLimited,206+ vault_mask: stats.vaultMask,207+ };208+ } else {209+ incident = {210+ ...current,211+ status: action === "resolve" ? "closed" : "open",212+ severity: Math.max(current.severity, severity),213+ last_seen_at: severity > 0 ? now : current.last_seen_at,214+ last_alerted_at: action ? now : current.last_alerted_at,215+ quiet_windows: severity > 0 ? 0 : current.quiet_windows + 1,216+ peak_addresses: Math.max(current.peak_addresses, stats.addresses),217+ total_addresses: current.total_addresses + stats.addresses,218+ total_live_hits: current.total_live_hits + stats.liveHits,219+ total_rate_limited: current.total_rate_limited + stats.rateLimited,220+ vault_mask: current.vault_mask | stats.vaultMask,221+ };222+ }223+224+ // Deliver before advancing last_alerted_at so transient failures retry on225+ // the next scheduled window instead of being silently suppressed.226+ if (action) await sendAlert(env, action, stats, incident);227+228+ await env.DB.prepare(229+ `INSERT INTO security_incidents230+ (name, status, severity, opened_at, last_seen_at, last_alerted_at, quiet_windows,231+ peak_addresses, total_addresses, total_live_hits, total_rate_limited, vault_mask)232+ VALUES ('enumeration', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)233+ ON CONFLICT(name) DO UPDATE SET status=excluded.status, severity=excluded.severity,234+ opened_at=excluded.opened_at, last_seen_at=excluded.last_seen_at,235+ last_alerted_at=excluded.last_alerted_at, quiet_windows=excluded.quiet_windows,236+ peak_addresses=excluded.peak_addresses, total_addresses=excluded.total_addresses,237+ total_live_hits=excluded.total_live_hits, total_rate_limited=excluded.total_rate_limited,238+ vault_mask=excluded.vault_mask`,239+ ).bind(240+ incident.status,241+ incident.severity,242+ incident.opened_at,243+ incident.last_seen_at,244+ incident.last_alerted_at,245+ incident.quiet_windows,246+ incident.peak_addresses,247+ incident.total_addresses,248+ incident.total_live_hits,249+ incident.total_rate_limited,250+ incident.vault_mask,251+ ).run();252+253+ console.log({ event: "security.enumeration_window", action, ...stats });254+}255+256+export async function sendDailySecurityDigest(257+ env: Env,258+ now = Math.floor(Date.now() / 1000),259+): Promise<void> {260+ const since = now - 86400;261+ const probes = await env.DB.prepare(262+ `SELECT COUNT(*) AS addresses, COUNT(DISTINCT scanner_id) AS scanners,263+ SUM(is_live) AS live_hits264+ FROM security_probes WHERE bucket >= ?`,265+ ).bind(since).first<266+ { addresses: number; scanners: number; live_hits: number | null }267+ >();268+ if (!probes || probes.addresses === 0) return;269+ const signals = await env.DB.prepare(270+ `SELECT COUNT(*) AS count FROM security_signals271+ WHERE bucket >= ? AND kind = 'rate_limited'`,272+ ).bind(since).first<{ count: number }>();273+ const text = [274+ "nzip security activity for the last 24 hours",275+ `Distinct scanner/address pairs: ${probes.addresses}`,276+ `Scanner identities: ${probes.scanners}`,277+ `Live-address hits: ${probes.live_hits ?? 0}`,278+ `Rate-limit confirmations: ${signals?.count ?? 0}`,279+ "No digest is sent on days without bare-address probe activity.",280+ ].join("\n");281+ await env.EMAIL.send({282+ from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },283+ to: env.ALERT_EMAIL_TO,284+ subject: "[nzip] Daily security activity summary",285+ text,286+ });287+}288+289+export async function sendAlertTest(env: Env): Promise<void> {290+ await env.EMAIL.send({291+ from: { email: env.ALERT_EMAIL_FROM, name: "nzip security" },292+ to: env.ALERT_EMAIL_TO,293+ subject: "[nzip] Security alert delivery test",294+ text: "nzip security alert delivery is configured correctly.",295+ });296+}297+298+export async function pruneSecurityTelemetry(299+ env: Env,300+ now = Math.floor(Date.now() / 1000),301+): Promise<void> {302+ const cutoff = now - 7 * 86400;303+ await env.DB.batch([304+ env.DB.prepare("DELETE FROM security_probes WHERE bucket < ?").bind(cutoff),305+ env.DB.prepare("DELETE FROM security_signals WHERE bucket < ?").bind(306+ cutoff,307+ ),308+ ]);309+}@@ -0,0 +1,126 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import { chooseAlertAction, signalSeverity } from "../src/security_alerts.ts";5+6+function stats(overrides = {}) {7+ return {8+ bucket: 0,9+ addresses: 0,10+ scanners: 0,11+ misses: 0,12+ liveHits: 0,13+ rateLimited: 0,14+ topScannerAddresses: 0,15+ longestSequence: 0,16+ vaultMask: 0,17+ countries: [],18+ asns: [],19+ ...overrides,20+ };21+}22+23+function incident(overrides = {}) {24+ return {25+ status: "open",26+ severity: 1,27+ opened_at: 100,28+ last_seen_at: 100,29+ last_alerted_at: 100,30+ quiet_windows: 0,31+ peak_addresses: 20,32+ total_addresses: 20,33+ total_live_hits: 0,34+ total_rate_limited: 0,35+ vault_mask: 1,36+ ...overrides,37+ };38+}39+40+test("a scanner trying 20 distinct addresses opens a warning", () => {41+ const window = stats({42+ addresses: 20,43+ scanners: 1,44+ misses: 20,45+ topScannerAddresses: 20,46+ });47+ assert.equal(signalSeverity(window), 1);48+ assert.equal(chooseAlertAction(null, window, 300), "open");49+});50+51+test("rate limiting and suspicious live hits are confirmed signals", () => {52+ assert.equal(signalSeverity(stats({ rateLimited: 1 })), 2);53+ assert.equal(54+ signalSeverity(stats({ liveHits: 1, topScannerAddresses: 8 })),55+ 2,56+ );57+});58+59+test("distributed enumeration requires breadth, scanners, and mostly misses", () => {60+ assert.equal(61+ signalSeverity(stats({ addresses: 128, scanners: 10, misses: 120 })),62+ 1,63+ );64+ assert.equal(65+ signalSeverity(stats({ addresses: 128, scanners: 9, misses: 128 })),66+ 0,67+ );68+ assert.equal(69+ signalSeverity(stats({ addresses: 128, scanners: 10, misses: 100 })),70+ 0,71+ );72+});73+74+test("active incidents suppress duplicates but summarize hourly", () => {75+ const window = stats({76+ addresses: 20,77+ scanners: 1,78+ misses: 20,79+ topScannerAddresses: 20,80+ });81+ assert.equal(chooseAlertAction(incident(), window, 200), null);82+ assert.equal(chooseAlertAction(incident(), window, 3700), "summary");83+});84+85+test("severity and volume changes bypass duplicate suppression", () => {86+ assert.equal(87+ chooseAlertAction(incident(), stats({ rateLimited: 1 }), 200),88+ "escalate",89+ );90+ assert.equal(91+ chooseAlertAction(92+ incident(),93+ stats({94+ addresses: 40,95+ scanners: 1,96+ misses: 40,97+ topScannerAddresses: 40,98+ }),99+ 200,100+ ),101+ "escalate",102+ );103+});104+105+test("a newly targeted vault breaks suppression after 30 minutes", () => {106+ const window = stats({107+ addresses: 20,108+ scanners: 1,109+ misses: 20,110+ topScannerAddresses: 20,111+ vaultMask: 2,112+ });113+ assert.equal(chooseAlertAction(incident(), window, 1800), null);114+ assert.equal(chooseAlertAction(incident(), window, 1900), "escalate");115+});116+117+test("an incident resolves on its third quiet window", () => {118+ assert.equal(119+ chooseAlertAction(incident({ quiet_windows: 1 }), stats(), 600),120+ null,121+ );122+ assert.equal(123+ chooseAlertAction(incident({ quiet_windows: 2 }), stats(), 900),124+ "resolve",125+ );126+});@@ -1,42 +1,61 @@
11 {2- "$schema": "node_modules/wrangler/config-schema.json",3- "name": "nzip",4- "main": "src/index.ts",5- "compatibility_date": "2026-06-01",6- "cache": { "enabled": true },7- "observability": {8- "enabled": true,9- "logs": {10- "enabled": true,11- "head_sampling_rate": 1,12- "invocation_logs": false13- }14- },15- "routes": [{ "pattern": "share.example.com", "custom_domain": true }],16- "r2_buckets": [17- {18- "binding": "CONTENT",19- "bucket_name": "nzip-content"20- }21- ],22- "d1_databases": [23- {24- "binding": "DB",25- "database_name": "nzip",26- "database_id": "00000000-0000-0000-0000-000000000000"27- }28- ],29- "ratelimits": [30- { "name": "RL_ENUM", "namespace_id": "1001", "simple": { "limit": 30, "period": 60 } },31- { "name": "RL_UNLOCK", "namespace_id": "1002", "simple": { "limit": 5, "period": 60 } }32- ],33- "triggers": {34- "crons": [35- "0 4 * * *"36- ]37- },38- "vars": {39- "PUBLIC_BASE": "https://share.example.com"40- }41- // Secrets (wrangler secret put --config wrangler.jsonc): NZIP_TOKEN2+ "$schema": "node_modules/wrangler/config-schema.json",3+ "name": "nzip",4+ "main": "src/index.ts",5+ "compatibility_date": "2026-06-01",6+ "cache": { "enabled": true },7+ "observability": {8+ "enabled": true,9+ "logs": {10+ "enabled": true,11+ "head_sampling_rate": 1,12+ "invocation_logs": false13+ }14+ },15+ "routes": [{ "pattern": "share.example.com", "custom_domain": true }],16+ "r2_buckets": [17+ {18+ "binding": "CONTENT",19+ "bucket_name": "nzip-content"20+ }21+ ],22+ "d1_databases": [23+ {24+ "binding": "DB",25+ "database_name": "nzip",26+ "database_id": "00000000-0000-0000-0000-000000000000"27+ }28+ ],29+ "ratelimits": [30+ {31+ "name": "RL_ENUM",32+ "namespace_id": "1001",33+ "simple": { "limit": 30, "period": 60 }34+ },35+ {36+ "name": "RL_UNLOCK",37+ "namespace_id": "1002",38+ "simple": { "limit": 5, "period": 60 }39+ },40+ {41+ "name": "RL_OBSERVE",42+ "namespace_id": "1003",43+ "simple": { "limit": 30, "period": 60 }44+ }45+ ],46+ "send_email": [47+ { "name": "EMAIL", "destination_address": "operator@example.com" }48+ ],49+ "triggers": {50+ "crons": [51+ "*/5 * * * *",52+ "0 4 * * *"53+ ]54+ },55+ "vars": {56+ "PUBLIC_BASE": "https://share.example.com",57+ "ALERT_EMAIL_TO": "operator@example.com",58+ "ALERT_EMAIL_FROM": "alerts@share.example.com"59+ }60+ // Secrets (wrangler secret put --config wrangler.jsonc): NZIP_TOKEN4261 }