{"_id":"@bigin-io/site-worker","_rev":"2-2345b00b2aaef56f763663f4f3e77c9f","name":"@bigin-io/site-worker","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@bigin-io/site-worker","version":"1.0.0","license":"UNLICENSED","_id":"@bigin-io/site-worker@1.0.0","maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"homepage":"https://github.com/bigin-io/ssg-site-factory#readme","bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"bin":{"erase-subscriber":"scripts/erase-subscriber.mjs"},"dist":{"shasum":"f13e23d8c681af9d8e4ac14677e8d7d51e3da1f9","tarball":"https://registry.npmjs.org/@bigin-io/site-worker/-/site-worker-1.0.0.tgz","fileCount":145,"integrity":"sha512-ode63QxRQ4tkSthsKLF7X/hHdlEehQVMJFtYZPtXV2gZFv95AGwITLdTLcnfPp7atkmXDNqNJV8nnjpSZgBveg==","signatures":[{"sig":"MEQCICbONRh3MqEjevPGTPMaJ/JWft5D+WBqP1oLw1ob+bzxAiBhDVwS85R2kNRwtsC0jDY/2STuddsdus0wpU8768HGKQ==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":259405},"main":"./dist/index.js","type":"module","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-site-worker-1.0.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./erase":{"types":"./dist/retention/erase.d.ts","default":"./dist/retention/erase.js"},"./config":{"types":"./dist/config.d.ts","default":"./dist/config.js"},"./package.json":"./package.json","./wrangler.template.jsonc":"./wrangler.template.jsonc"},"scripts":{"build":"tsc -p tsconfig.build.json","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"","typecheck":"tsc -p tsconfig.json --noEmit","test:integration":"vitest run --config vitest.integration.config.ts"},"_npmUser":{"name":"maichitam","email":"tammai.it@gmail.com"},"_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-site-worker-1.0.0.tgz","_integrity":"sha512-ode63QxRQ4tkSthsKLF7X/hHdlEehQVMJFtYZPtXV2gZFv95AGwITLdTLcnfPp7atkmXDNqNJV8nnjpSZgBveg==","repository":{"url":"git+https://github.com/bigin-io/ssg-site-factory.git","type":"git","directory":"packages/site-worker"},"_npmVersion":"11.2.0","description":"Cloudflare Worker serving a site's static assets and its /api/* form and newsletter endpoints","directories":{},"sideEffects":false,"_nodeVersion":"22.14.0","dependencies":{"zod":"^4.5.4","@bigin-io/site-contract":"^1.0.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"wrangler":"4.127.1","@cloudflare/workers-types":"^5.20260830.1","@cloudflare/vitest-pool-workers":"^0.22.0"},"_npmOperationalInternal":{"tmp":"tmp/site-worker_1.0.0_1788517264679_0.6488964142199383","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@bigin-io/site-worker","version":"1.0.1","description":"Cloudflare Worker serving a site's static assets and its /api/* form and newsletter endpoints","license":"UNLICENSED","type":"module","sideEffects":false,"repository":{"type":"git","url":"git+https://github.com/bigin-io/ssg-site-factory.git","directory":"packages/site-worker"},"publishConfig":{"registry":"https://registry.npmjs.org","access":"public"},"main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./wrangler.template.jsonc":"./wrangler.template.jsonc","./package.json":"./package.json","./config":{"types":"./dist/config.d.ts","default":"./dist/config.js"},"./erase":{"types":"./dist/retention/erase.d.ts","default":"./dist/retention/erase.js"}},"dependencies":{"zod":"^4.5.4","@bigin-io/site-contract":"^1.0.1"},"devDependencies":{"@cloudflare/vitest-pool-workers":"^0.22.0","@cloudflare/workers-types":"^5.20260830.1","wrangler":"4.127.1"},"bin":{"erase-subscriber":"scripts/erase-subscriber.mjs"},"scripts":{"build":"tsc -p tsconfig.build.json","typecheck":"tsc -p tsconfig.json --noEmit","test:integration":"vitest run --config vitest.integration.config.ts","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""},"_id":"@bigin-io/site-worker@1.0.1","bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"homepage":"https://github.com/bigin-io/ssg-site-factory#readme","_integrity":"sha512-1k/N8iIjbzkCkyKJr6i5qXr+z/DwH2RLOXWpB0HMzG1nfzDrbvMxJqyZigwGjS/MHWmD0vvdbKAT/ptc3RmO3w==","_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-site-worker-1.0.1.tgz","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-site-worker-1.0.1.tgz","_nodeVersion":"22.14.0","_npmVersion":"11.2.0","dist":{"integrity":"sha512-1k/N8iIjbzkCkyKJr6i5qXr+z/DwH2RLOXWpB0HMzG1nfzDrbvMxJqyZigwGjS/MHWmD0vvdbKAT/ptc3RmO3w==","shasum":"a7fa7a6a3a22d7c4ced5fc7caea2d4035e21d830","tarball":"https://registry.npmjs.org/@bigin-io/site-worker/-/site-worker-1.0.1.tgz","fileCount":145,"unpackedSize":259405,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQD9HhOdmBAUN/J7IzxPeHcF6fb040x1MM1GYuURjUbHnAIhAOr/ikusYHc5PkFKfup78hAIodpf3b/2L7qaFLDR+1kk"}]},"_npmUser":{"name":"maichitam","email":"tammai.it@gmail.com"},"directories":{},"maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/site-worker_1.0.1_1788517764649_0.9041439082069984"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-04T10:21:04.418Z","modified":"2026-09-04T10:29:25.003Z","1.0.0":"2026-09-04T10:21:04.801Z","1.0.1":"2026-09-04T10:29:24.806Z"},"bugs":{"url":"https://github.com/bigin-io/ssg-site-factory/issues"},"license":"UNLICENSED","homepage":"https://github.com/bigin-io/ssg-site-factory#readme","repository":{"type":"git","url":"git+https://github.com/bigin-io/ssg-site-factory.git","directory":"packages/site-worker"},"description":"Cloudflare Worker serving a site's static assets and its /api/* form and newsletter endpoints","maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"readme":"# `@bigin-io/site-worker`\r\n\r\nThe Worker that serves a site: static assets **and** the `/api/*` form and\r\nnewsletter endpoints, from a single deploy (ADR-2, ADR-5). One per site,\r\nidentical for both renderers.\r\n\r\nShapes come from `@bigin-io/site-contract`. This package never redefines a\r\nconfig field or a binding name.\r\n\r\n> **It is also the platform's site-repo starter, and that is deliberate.**\r\n> Alongside the Worker itself this package ships `wrangler.template.jsonc`, a\r\n> `scripts` directory, and — since story 6.2 — the two deploy workflow\r\n> templates in `templates/workflows/`. Three kinds of thing in one package.\r\n>\r\n> The reason is reachability rather than tidiness: this is the only package\r\n> **every** site installs regardless of renderer *and* regardless of tier, since\r\n> ADR-5 puts forms in the same Worker and ADR-11 keeps forms on a simple site.\r\n> A deploy workflow is renderer-agnostic, `site-contract` is Zod-only by rule,\r\n> and putting it in `renderer-*` would force a per-renderer copy of something\r\n> neither renderer decides. So a future reader does not have to wonder: the\r\n> deploy workflows live in the forms package because the forms package is the\r\n> one a site repo is guaranteed to have.\r\n\r\n> **Status — story 4.6.** Lane B is complete. All four endpoints are live on\r\n> both storage paths — D1 (the default) and an ESP relay — plus the scheduled\r\n> retention purge and the data-subject erasure command.\r\n>\r\n> Two configurations are still refused, loudly, at startup rather than at the\r\n> moment they would matter: `email.provider: \"ses\"` (no SES adapter exists) and\r\n> `newsletter.storage` with `provider: \"brevo\"` and `doubleOptIn: true` (Brevo's\r\n> own opt-in flow needs a template id and a redirect URL that `site.config`\r\n> cannot carry).\r\n>\r\n> `email.provider: \"ses\"` is **not** supported yet. The contract accepts it\r\n> (ADR-7) but no adapter exists, so the Worker refuses the whole configuration\r\n> at startup rather than failing a real submission at send time.\r\n\r\n## Routes\r\n\r\n| Route | Behaviour | Story |\r\n| --- | --- | --- |\r\n| `POST /api/contact` | Turnstile → rate limit → D1 `submissions` → notification email | 4.2 ✅ |\r\n| `POST /api/subscribe` | Turnstile → D1 upsert `pending` + token → confirmation email, or an ESP relay | 4.3, 4.4 ✅ |\r\n| `GET /api/confirm?token=` | Validate token → `confirmed` | 4.3 ✅ |\r\n| `GET /api/unsubscribe?token=` | Validate token → `unsubscribed` | 4.3 ✅ |\r\n\r\nAnything outside `/api/*` goes to the `ASSETS` binding untouched.\r\n\r\n## The response contract\r\n\r\nEvery `/api/*` response is JSON in one envelope. Import the types rather than\r\nre-describing them — the renderer form components (4.5a, 4.5b) are built\r\nagainst exactly this:\r\n\r\n```ts\r\nimport type { ApiResponseBody, ApiErrorCode } from '@bigin-io/site-worker'\r\n```\r\n\r\n```jsonc\r\n// success\r\n{ \"ok\": true, \"data\": { /* route-specific, often absent */ } }\r\n\r\n// failure\r\n{ \"ok\": false, \"error\": { \"code\": \"validation_failed\", \"message\": \"…\", \"fields\": { \"email\": \"…\" } } }\r\n```\r\n\r\n`code` is one of `validation_failed`, `turnstile_failed`, `rate_limited`,\r\n`invalid_token`, `already_confirmed`, `not_found`, `method_not_allowed`,\r\n`not_implemented`, `internal_error`. It maps to a fixed HTTP status, and it is\r\nwhat a renderer switches on.\r\n\r\n`message` is **English and never localized** — the Worker has no locale to\r\nnegotiate against. Localize from `code` in the renderer; treat `message` as a\r\ndeveloper and log fallback. `fields` maps a `forms.contact.fields[].name` to a\r\nmessage, so a component can render errors next to inputs rather than as one\r\nbanner.\r\n\r\n## Posting a contact form\r\n\r\nPost JSON or `application/x-www-form-urlencoded` — both work, so a form with\r\nthe Turnstile widget and no JavaScript is a real option. Keys are the\r\n`forms.contact.fields[].name` values from `site.config`, plus the token:\r\n\r\n```ts\r\nimport { TURNSTILE_FIELD } from '@bigin-io/site-worker'   // 'cf-turnstile-response'\r\n\r\nawait fetch('/api/contact', {\r\n  method: 'POST',\r\n  headers: { 'content-type': 'application/json' },\r\n  body: JSON.stringify({ [TURNSTILE_FIELD]: token, name, email, message }),\r\n})\r\n```\r\n\r\nA field the config does not declare is rejected, not ignored. Bodies are capped\r\nat 16 KB, single fields at 500 characters and textareas at 5 000 — exported as\r\n`MAX_BODY_BYTES`, `MAX_FIELD_LENGTH` and `MAX_TEXTAREA_LENGTH` so a component\r\ncan validate before it posts.\r\n\r\nWhat comes back:\r\n\r\n| Status | `error.code` | What the form should do |\r\n| --- | --- | --- |\r\n| 200 | — | Success. Reset the form and reset the widget — the token is spent |\r\n| 400 | `validation_failed` | Render `error.fields[name]` next to each input |\r\n| 403 | `turnstile_failed` | Reset the widget and ask for another attempt |\r\n| 404 | `not_found` | The site has no contact form; hide it |\r\n| 429 | `rate_limited` | Back off; `Retry-After` says how long |\r\n| 500 | `internal_error` | Generic failure. Offer the email address instead |\r\n\r\nEvery token is single-use, so a component must reset the widget before a retry\r\n— reposting the same token comes back `turnstile_failed`.\r\n\r\n## The newsletter loop\r\n\r\n`POST /api/subscribe` takes one field, `email`, plus the Turnstile token —\r\nthe contract has no `newsletter.fields`, so there is nothing else to collect.\r\n\r\n```ts\r\nawait fetch('/api/subscribe', {\r\n  method: 'POST',\r\n  headers: { 'content-type': 'application/json' },\r\n  body: JSON.stringify({ [TURNSTILE_FIELD]: token, email }),\r\n})\r\n```\r\n\r\n**The response is deliberately uninformative.** A new address, one already\r\npending, and one already confirmed all get the same `200` and the same body.\r\nAnything else would let someone post an address and read the difference to\r\nlearn whether that person is on the list.\r\n\r\nTwo tokens per subscriber, and they are not interchangeable:\r\n\r\n| Token | Lifetime | Used by |\r\n| --- | --- | --- |\r\n| confirm | 7 days, rotated on every signup | `GET /api/confirm?token=` |\r\n| unsubscribe | forever, never rotated | `GET /api/unsubscribe?token=` |\r\n\r\nThe unsubscribe token never expires and never changes on purpose: it is printed\r\nin every newsletter, and a link that stopped working after a year would be a\r\ncompliance problem rather than a UX one. That is also what makes unsubscribing\r\nidempotent.\r\n\r\n### Pages the renderer has to serve\r\n\r\n`confirm` and `unsubscribe` are opened from a mail client, so a browser gets a\r\n`302` and only `Accept: application/json` gets the envelope. The redirect\r\ntargets are a **convention**, exported as `NEWSLETTER_PAGES`:\r\n\r\n| Path | When |\r\n| --- | --- |\r\n| `/newsletter/confirmed` | confirmed, and on a second click of the same link |\r\n| `/newsletter/unsubscribed` | unsubscribed — see below |\r\n| `/newsletter/invalid-link` | unknown, expired, or malformed token |\r\n\r\nThey are not configurable: `site.config` has no field for them and the contract\r\nis frozen. Until a renderer serves them the site's designed 404 answers, which\r\nis visible rather than silent.\r\n\r\n**`/newsletter/unsubscribed` must offer a one-click resubscribe.** It arrives\r\nwith `?resubscribe=<token>`, and the page should link that straight to\r\n`/api/confirm?token=<token>`. Mail scanners and link prefetchers issue GETs, so\r\nsome unsubscribes are nobody's decision; what makes a GET acceptable here is\r\nthat undoing one is a single click. The row is never deleted — `unsubscribed`\r\nis a state.\r\n\r\n### Choosing a storage adapter (ADR-6)\r\n\r\n`newsletter.storage.adapter` picks where a subscriber goes. Everything else\r\nabout `/api/subscribe` — Turnstile, the rate limit, the uninformative response\r\n— is identical either way, and a parity test holds it that way.\r\n\r\n| | `d1` (default) | `esp` |\r\n| --- | --- | --- |\r\n| Where subscribers live | this site's D1 | the client's Mailchimp or Brevo list |\r\n| Who sends the confirmation | this Worker | the ESP |\r\n| `confirm` / `unsubscribe` | this Worker | the ESP — both routes answer `404` here |\r\n| CSV export | below | the ESP's own export |\r\n| Retention and deletion | this site's, story 4.6 | **the client's ESP, under their own controls** |\r\n\r\nThat last row matters for a data-subject request: on an ESP site this Worker\r\nholds no subscriber data, so there is nothing here to delete and a deletion run\r\nagainst it would be a false record of compliance.\r\n\r\n**Mailchimp** needs `listId` set to the audience id and `ESP_API_KEY` set to a\r\nkey ending in its datacentre (`…-us14`) — the datacentre is read from the key,\r\nnot configured. `doubleOptIn: true` asks Mailchimp for its own `pending` status,\r\nwhich sends its own confirmation.\r\n\r\n**Brevo** needs `listId` set to the numeric list id. `doubleOptIn` must be\r\n`false`: Brevo's double opt-in needs a template id and a redirect URL that live\r\nin the client's Brevo account and have no `site.config` field, so the Worker\r\nrefuses the combination rather than subscribing someone who was promised a\r\nconfirmation step.\r\n\r\n`ESP_API_KEY` is required whenever the adapter is `esp`. The contract types it\r\noptional because most sites do not need it; the Worker cross-checks it against\r\nthe config at startup and refuses to serve `/api/*` without it.\r\n\r\n### Exporting subscribers\r\n\r\nD1 sites only — an ESP site's list lives in the ESP. Confirmed subscribers,\r\nwith what a sender needs to build unsubscribe links:\r\n\r\n```sh\r\nwrangler d1 execute <database-name> --remote --json   --command \"SELECT email, confirmed_at, token AS unsubscribe_token              FROM subscribers WHERE status = 'confirmed' ORDER BY confirmed_at\"\r\n```\r\n\r\nThe unsubscribe URL for a row is\r\n`https://<domain>/api/unsubscribe?token=<unsubscribe_token>`.\r\n\r\n> The output is personal data **and** a set of live credentials: anyone holding\r\n> a token can unsubscribe that person. Treat the file accordingly, and delete it\r\n> when the send is done.\r\n\r\n### Running the loop in a preview environment\r\n\r\n1. Deploy the preview and apply the migrations (`--remote`).\r\n2. Set `TURNSTILE_SECRET_KEY` to `1x0000000000000000000000000000000AA`, the\r\n   always-passes test secret, which accepts any token that is present. On a\r\n   preview derived by `prepare-preview` the site key is the **preview widget's**\r\n   real one (story 6.10), so the challenge renders and solves for real; to drive\r\n   the loop by hand instead, point `forms.turnstile.siteKey` at\r\n   `1x00000000000000000000AA` and the widget mints the dummy token directly.\r\n3. Subscribe from the site. Expect `200`, and a row with `status = 'pending'`:\r\n\r\n   ```sh\r\n   wrangler d1 execute <database-name> --remote      --command \"SELECT email, status FROM subscribers\"\r\n   ```\r\n\r\n4. Open the link in the confirmation email. Expect a redirect to\r\n   `/newsletter/confirmed` and `status = 'confirmed'`.\r\n5. Open the unsubscribe URL built from that row's token. Expect a redirect to\r\n   `/newsletter/unsubscribed?resubscribe=…` and `status = 'unsubscribed'`.\r\n6. Follow the resubscribe link. Expect `status = 'confirmed'` again.\r\n\r\nSwap the secret to `2x0000000000000000000000000000000AA` and repeat step 3 to\r\nsee a `403` with no row written.\r\n\r\n## Installing\r\n\r\nInside this monorepo, via the workspace protocol:\r\n\r\n```jsonc\r\n{ \"dependencies\": { \"@bigin-io/site-worker\": \"workspace:^\" } }\r\n```\r\n\r\nIn a site repo, an exact version from npm (public since ADR-18; GitHub Packages before it) (ADR-8). Add to `.npmrc`:\r\n\r\n```ini\r\n# Nothing. The @bigin-io scope publishes publicly to npm (ADR-18), so a\r\n# site repo needs no registry mapping and no token at all. This block used\r\n# to read:\r\n#\r\n#   # Not needed: the scope is public on npm (ADR-18).\r\n#   //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\r\n```\r\n\r\n```jsonc\r\n{ \"dependencies\": { \"@bigin-io/site-worker\": \"0.3.1\" } }\r\n```\r\n\r\n**An exact version, not a range.** NFR-6 says config + content + template\r\nversion determine the build, and story 6.2's AC12 is that a site repo *pins* the\r\ntemplate so an upgrade is a deliberate bump rather than whatever the platform's\r\n`main` happens to hold. A caret would give up exactly that.\r\n\r\nAll five `@bigin-io` packages version and publish together, so keep their pins in\r\nstep: `site-contract`, `renderer-core`, `renderer-nuxt`, `site-worker`,\r\n`site-tools`. There were six until story 7.0a withdrew `renderer-next`, whose\r\nlast release is `0.7.0`.\r\n\r\nVerified rather than aspirational: `0.3.1` was installed from npm (public since ADR-18; GitHub Packages before it)\r\ninto a repo outside the platform workspace, with only the two `.npmrc` lines\r\nabove, and the whole deploy chain was run from the installed artefacts (story\r\n6.2, T7). The token needs `read:packages` — see\r\n[`@bigin-io/site-contract`](../site-contract/README.md#install) for the\r\nscope, the two places a token comes from, and the error a wrongly-scoped one\r\ngives.\r\n\r\n## Wiring a site repo\r\n\r\n1. Copy `wrangler.template.jsonc` from this package to the site repo root as\r\n   `wrangler.jsonc` and replace every `__PLACEHOLDER__`. Lane C's provisioning\r\n   (story 6.1, phase 2) does this from `site.config.ts`:\r\n\r\n   | Placeholder | Source |\r\n   | --- | --- |\r\n   | `__WORKER_NAME__` | `cloudflare.workerName` |\r\n   | `__D1_DATABASE_NAME__` | `cloudflare.d1DatabaseName` |\r\n   | `__D1_DATABASE_ID__` | provisioning output — never `site.config` |\r\n   | `__DIST_DIR__` | `.output/public` (Nuxt) or `out` (Next) |\r\n   | `__SITE_CONFIG_JSON__` | the `forms`, `newsletter`, `email` and `dataProtection` blocks of the parsed `site.config`, as JSON |\r\n   | `__PURGE_SCHEDULE__` | `dataProtection.purgeSchedule` (default `0 3 * * *`) |\r\n\r\n   The `SITE_CONFIG` var is how the Worker reads its configuration: it cannot\r\n   import `site.config.ts`, because `main` points into `node_modules`. It is\r\n   **generated, never hand-edited**, and it carries no secrets — a var is\r\n   readable in the Cloudflare dashboard, so the Worker refuses to serve\r\n   `/api/*` at all if it finds a secret name anywhere in the slice.\r\n\r\n2. Apply the migrations:\r\n\r\n   ```sh\r\n   wrangler d1 migrations apply <database-name>            # local\r\n   wrangler d1 migrations apply <database-name> --remote    # the real database\r\n   ```\r\n\r\n   `migrations_dir` points into this package, so a version bump that adds a\r\n   migration is picked up by re-running the command. Re-applying is a no-op.\r\n\r\n3. Set the secrets. **None of these ever appear in `site.config`** (NFR-3) —\r\n   the contract names them in `WORKER_SECRET_NAMES` and carries nothing else:\r\n\r\n   ```sh\r\n   wrangler secret put TURNSTILE_SECRET_KEY\r\n   wrangler secret put EMAIL_API_KEY\r\n   wrangler secret put ESP_API_KEY   # only when newsletter.storage.adapter is \"esp\"\r\n   ```\r\n\r\n   They are validated on the first `/api/*` request that needs them, not at\r\n   startup — a site missing a secret still serves every page (NFR-4).\r\n\r\n4. Deploy: `wrangler deploy`.\r\n\r\n## Deploying from CI (story 6.2)\r\n\r\nStep 4 above is the manual version. The published loop is *merge to `main` →\r\nbuild → deploy*, with a preview URL on every pull request, and an editor should\r\nnever have to ask an engineer to run a command (FR-S9).\r\n\r\nTwo workflow templates ship in this package. Copy them into the site repo —\r\nGitHub only runs workflows committed to the repository, so they cannot be\r\nreferenced from `node_modules`:\r\n\r\n```sh\r\nmkdir -p .github/workflows\r\ncp node_modules/@bigin-io/site-worker/templates/workflows/*.yml .github/workflows/\r\n```\r\n\r\n| File | Trigger | What it does |\r\n| --- | --- | --- |\r\n| `deploy-production.yml` | push to `main` | builds, indexes, deploys the site |\r\n| `deploy-preview.yml` | pull request | the same build, uploaded as a preview version, URL commented on the PR |\r\n\r\nRe-copy them after a template version bump: the site's own copy is what runs,\r\nso an upgrade stays a deliberate act rather than whatever the platform's `main`\r\nhappens to hold (NFR-6).\r\n\r\n### The chain, and why the order is enforced rather than assumed\r\n\r\n```\r\npnpm build  ->  build-search  ->  preflight-deploy  ->  wrangler deploy\r\n```\r\n\r\n`build-search` is [`@bigin-io/site-tools`](../site-tools/README.md)' step, not\r\nbare `pagefind`: it honours `search.excludeRoutes` under every locale, refuses\r\nan unbuilt `dist`, requires a `lang` on every page, and reads the finished index\r\nback. `preflight-deploy` then refuses a deploy whose inputs were never produced\r\n— an unprovisioned `wrangler.jsonc`, a missing `SITE_CONFIG`, an empty `dist`,\r\na missing index — and names the step that produces them, so the failure is not\r\na Cloudflare API error four layers from the cause.\r\n\r\n### What the site repo needs\r\n\r\nThe committed `.npmrc` needs only the scope line from **Installing** above:\r\n\r\n```ini\r\n# Not needed: the scope is public on npm (ADR-18).\r\n```\r\n\r\nThe auth entry is written by `actions/setup-node` from `NODE_AUTH_TOKEN` — that\r\nis what `registry-url` in the templates is for, and why setting the environment\r\nvariable alone would do nothing.\r\n\r\n| Repository **secret** | Used by | Notes |\r\n| --- | --- | --- |\r\n| `CLOUDFLARE_API_TOKEN` | both | Workers Scripts:Edit and D1:Edit |\r\n| `CLOUDFLARE_ACCOUNT_ID` | both | must match `cloudflare.accountId` |\r\n| `GH_PACKAGES_TOKEN` | both, optional | see below |\r\n\r\n| Repository **variable** | Used by | Notes |\r\n| --- | --- | --- |\r\n| `PREVIEW_D1_DATABASE_ID` | preview | the preview database, never production's |\r\n| `PREVIEW_WORKER_NAME` | preview, optional | defaults to `<worker>-preview` |\r\n| `PREVIEW_D1_DATABASE_NAME` | preview, optional | defaults to `<database>-preview` |\r\n\r\n**The `GITHUB_TOKEN` caveat.** A workflow's automatic `GITHUB_TOKEN` is scoped\r\nto its own repository. It can install `@bigin-io/*` only if the platform\r\nrepository has granted this site repo read access to those packages. Where it\r\nhas not, supply a token that can as `GH_PACKAGES_TOKEN`; the templates prefer it\r\nand fall back to `GITHUB_TOKEN`. Without either, `pnpm install` fails with a 401\r\nthat reads like a broken registry.\r\n\r\n### Preview isolation\r\n\r\nA preview is a **different Worker**, and that is what makes it safe rather than\r\nany rule in the config:\r\n\r\n- **Its own D1 database.** `prepare-preview` refuses to write a configuration\r\n  whose preview database id is production's.\r\n- **Its own secrets.** Secrets are per-Worker in Cloudflare, so a different\r\n  name is a different secret store. `prepare-preview` refuses a preview that\r\n  reuses production's Worker name.\r\n- **No routes.** The derived config drops `routes`. A preview that kept them\r\n  would not be isolated — it would take the site's own domain and point it at a\r\n  pull request.\r\n- **No cron trigger.** The retention purge is production's obligation under\r\n  NFR-8, and a trigger is the one part of the config that keeps running after\r\n  the pull request is forgotten.\r\n- **Not indexable.** The preview build appends `X-Robots-Tag: noindex, nofollow`\r\n  to the `_headers` the renderer already wrote — appended, so the site's own CSP\r\n  and HSTS still apply. `robots.txt` is deliberately left alone: a crawler told\r\n  to `Disallow` never fetches the page, so it never reads the `noindex`, and a\r\n  URL that is merely disallowed can still be indexed from a link elsewhere.\r\n\r\n**A preview shares production's `SITE_CONFIG` slice, byte for byte.** The slice\r\ndescribes the *site*, not the environment, and every environment-specific hazard\r\nin it is a binding or a secret rather than a config value: no `EMAIL_API_KEY`,\r\nno mail; no `ESP_API_KEY`, no relay; a different `DB`, a different subscriber\r\nlist. A preview with no mail key is therefore the **correct** test rather than a\r\ndegraded one — an email-send failure is non-fatal under NFR-8, so the submission\r\nis still stored and the request still returns 200, and the whole form path is\r\nexercised without a client ever being emailed from a pull request.\r\n\r\n### The preview environment, once\r\n\r\nThe workflow uploads a *version* of a preview Worker that must already exist:\r\n\r\n```sh\r\nwrangler d1 create <site>-preview          # record the id as PREVIEW_D1_DATABASE_ID\r\npnpm exec prepare-preview --config site.config.ts\r\nwrangler deploy -c wrangler.preview.jsonc  # creates the Worker, once\r\nwrangler d1 migrations apply <site>-preview --remote\r\nwrangler secret put TURNSTILE_SECRET_KEY --name <site>-preview\r\n```\r\n\r\nSet only the secrets the preview should be able to act on. Leaving\r\n`EMAIL_API_KEY` and `ESP_API_KEY` unset is the configuration, not an omission.\r\n\r\n#### The preview's Turnstile widget — and the thing this page used to tell you to do\r\n\r\nA Turnstile widget is bound to hostnames, and a preview lives on\r\n`*.workers.dev`, which the client's production widget does not know.\r\n\r\n**This page used to say: \"add the preview hostname to the widget.\" Do not. Undo\r\nit if you already have.** That meant putting `workers.dev` on the **client's\r\nproduction widget**, and **Turnstile's domain list covers subdomains** — so it\r\nadmits *every* `*.workers.dev` host on the platform, not only this site's\r\npreview. Any other tenant's Worker could then mint tokens that validate against\r\nthis client's sitekey. Story 6.10 refused it on 2026-09-01.\r\n\r\n**Instead**, provisioning phase 1 creates a **second, preview-only widget**\r\nscoped to `workers.dev`, and prints its site key. Record that key as the\r\n`PREVIEW_TURNSTILE_SITEKEY` repository *variable* — it is public, so a variable\r\nrather than a secret — and `prepare-preview` substitutes it into the preview's\r\n`SITE_CONFIG`. It is the one field of that slice a preview does not inherit from\r\nproduction, because it is the one value in it that describes a binding between a\r\nsite and a **host** rather than the site itself.\r\n\r\n**If `workers.dev` is already on a production widget:** remove it from that\r\nwidget's domain list in the Cloudflare dashboard, then run provisioning phase 1\r\nto get the preview widget and its key. Removing it cannot break production —\r\nproduction is served from the site's own hostname, which stays on the list.\r\n\r\n**The preview Worker's `TURNSTILE_SECRET_KEY` stays the always-passes test\r\nsecret**, and that is deliberate. A Turnstile token can only be minted by a\r\nbrowser solving the challenge, and the QA gate has no browser — so it posts a\r\nfixed dummy token, which only the test secret accepts. Giving the preview the\r\nreal secret would turn the gate's accepted case into a `403` and lose its\r\nstored-row check, which is a proven property traded for a rehearsal. What the\r\npreview widget does buy is that a **human** opening a preview form gets a\r\nchallenge that actually renders and actually solves, on `workers.dev`, without\r\nanyone touching the client's production widget.\r\n\r\n`wrangler.preview.jsonc` is generated on every run and must not be committed;\r\nadd it to `.gitignore`.\r\n\r\n### What this does not do yet\r\n\r\n- **Migrations are not applied by the deploy.** Provisioning phase 2 applies\r\n  them (story 6.1). A template version bump that adds a migration therefore\r\n  needs `provision --phase 2` re-run, or the code deploys ahead of its schema.\r\n  Automating it inside the deploy is a data-changing step and a decision for\r\n  story 6.4's ops baseline.\r\n- **Preview cleanup is not automated.** Versions accumulate on the one preview\r\n  Worker rather than Workers accumulating per pull request, which is much the\r\n  smaller problem, but nothing prunes them (story 6.4).\r\n- **Quality gates are not here.** Lighthouse, axe, the link check and the\r\n  Turnstile round-trip run against the preview URL in story 6.3, and block on\r\n  red. This story produces the URL; it does not judge what is at it.\r\n\r\n## Local development\r\n\r\n```sh\r\npnpm --filter @bigin-io/renderer-nuxt build\r\nwrangler dev                                   # from the site repo\r\n```\r\n\r\n`wrangler dev` serves `__DIST_DIR__` through the same asset pipeline as\r\nproduction and runs `/api/*` against a local D1. Apply the migrations without\r\n`--remote` first, and use Turnstile's testing keys rather than the live ones:\r\n\r\n| Key | Behaviour |\r\n| --- | --- |\r\n| sitekey `1x00000000000000000000AA` | always passes |\r\n| sitekey `2x00000000000000000000AB` | always fails |\r\n| sitekey `3x00000000000000000000FF` | forces an interactive challenge |\r\n| secret `1x0000000000000000000000000000000AA` | always passes |\r\n| secret `2x0000000000000000000000000000000AA` | always fails |\r\n| secret `3x0000000000000000000000000000000AA` | returns `timeout-or-duplicate` |\r\n\r\nTest sitekeys mint the dummy token `XXXX.DUMMY.TOKEN.XXXX`, which only test\r\nsecrets accept — so a live secret paired with a test sitekey fails, which is\r\nthe mix-up worth knowing about.\r\n\r\n## Retention and data-subject requests (NFR-8)\r\n\r\n### The scheduled purge\r\n\r\nA cron trigger runs a purge that deletes `submissions` older than\r\n`dataProtection.retentionMonths` (default 12) and clears expired\r\n`rate_limit_hits`. It reads the retention from `site.config` at run time, so\r\nchanging it there changes the next run.\r\n\r\n**It deletes no `subscribers` row, of any status.** A `confirmed` row is a live\r\nrelationship; an `unsubscribed` row is the suppression record that proves\r\nsomeone said no, and deleting it would let that address be re-added later with\r\nno trace. NFR-8 names submissions, and the literal reading is also the safe one.\r\n\r\nThe Worker cannot read its own cron trigger, so nothing at run time can tell you\r\n`__PURGE_SCHEDULE__` has drifted from `dataProtection.purgeSchedule`. It does\r\nnot need to: every run deletes everything past the window, so a wrong cron\r\nchanges *when* data goes, never *whether* it goes.\r\n\r\nEach run logs `retention_purge` with counts — never rows. A run that hits its\r\nper-run ceiling reports `truncated: true` and the next one continues, so a site\r\nwith years of backlog does not fail once and stay failed. Story 6.4 should alert\r\non the *absence* of this event: a purge that quietly stopped running is a\r\ncompliance failure that makes no noise on its own.\r\n\r\n### Erasing one person's data\r\n\r\n```sh\r\nnode node_modules/@bigin-io/site-worker/scripts/erase-subscriber.mjs   --database <name> --email <address> --remote\r\n```\r\n\r\nIt deletes that address from `submissions` and `subscribers`, matching\r\ncase-insensitively — someone who wrote `Ada@Example.test` on the form and\r\n`ada@example.test` in their request is one person — and prints what it removed\r\nfrom each table.\r\n\r\nA command rather than a route, deliberately: an endpoint that erases a person's\r\ndata is an unauthenticated destruction primitive aimed at your own database\r\nunless it is authenticated, and authenticating it would cost a fourth secret and\r\na permanent attack surface for something run a handful of times a year. The SQL\r\nit runs is exported from the package and executed by an integration test, so\r\nthis runbook cannot drift from the code.\r\n\r\n> **On an ESP site, this command is only half the job, and it will tell you so.**\r\n> Submissions are always local. Subscribers are not — they live in the client's\r\n> Mailchimp or Brevo account, which this tool cannot reach.\r\n>\r\n> ```sh\r\n> … --esp mailchimp                 # reports INCOMPLETE, exits 1\r\n> … --esp mailchimp --esp-handled   # reports COMPLETE\r\n> ```\r\n>\r\n> `--esp-handled` records that **you attest** you have already deleted the\r\n> person from that ESP. The tool cannot check, and does not claim to. Without\r\n> it the run exits non-zero and names the system that still holds their data,\r\n> because `deleted: 2 submissions, 0 subscribers` is literally true on an ESP\r\n> site and reads as discharged — and closing an erasure request on that would\r\n> be a false record of compliance.\r\n\r\n`dataProtection.dataSubjectContact` is where requests arrive. It is published on\r\nthe privacy page by the renderer; this Worker never uses it, and no endpoint\r\nhere accepts a request.\r\n\r\n## Static serving never depends on the API (NFR-4)\r\n\r\nTwo layers, both deliberate:\r\n\r\nThe scheduled purge is wrapped the same way: an exception in it is caught and\r\nlogged rather than left to reach a request or vanish.\r\n\r\n1. **`run_worker_first` is unset in the template.** Cloudflare serves a request\r\n   that matches a static asset without invoking this script at all, so a\r\n   handler that throws — or a Worker that fails to boot — cannot take down a\r\n   page that exists.\r\n2. **The handler hands non-`/api/*` requests to `ASSETS` as its first\r\n   statement**, before any API module does work or any secret is read, and\r\n   outside the `try`/`catch` that turns an API fault into a `500`.\r\n\r\nTurning `run_worker_first` on would put every page request through the fetch\r\nhandler and undo layer 1.\r\n\r\n## Tests\r\n\r\n```sh\r\npnpm test                                              # unit, from the repo root\r\npnpm --filter @bigin-io/site-worker test:integration    # workerd + real D1\r\n```\r\n\r\nTwo projects, because the Workers Vitest pool supports only Istanbul coverage\r\nwhile the workspace gate is on v8, and one Vitest run has one provider. The\r\nunit project stays in the root run under that gate; the integration project\r\nruns on its own and covers what only a real runtime can show — that the\r\nmigrations actually apply, and that the handler behaves the same on workerd.\r\n","readmeFilename":"README.md"}