{"_id":"@bigin-io/site-tools","_rev":"2-5db4d4c113fb692529f6b7ee36986c94","name":"@bigin-io/site-tools","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@bigin-io/site-tools","version":"1.0.0","license":"UNLICENSED","_id":"@bigin-io/site-tools@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":{"qa-gate":"dist/bin/qa-gate.js","d1-export":"dist/bin/d1-export.js","provision":"dist/bin/provision.js","build-search":"dist/bin/build-search.js","upload-media":"dist/bin/upload-media.js","prepare-preview":"dist/bin/prepare-preview.js","preflight-deploy":"dist/bin/preflight-deploy.js"},"dist":{"shasum":"701f79650f8ce60f1dbf5a14792ee67713febd4a","tarball":"https://registry.npmjs.org/@bigin-io/site-tools/-/site-tools-1.0.0.tgz","fileCount":267,"integrity":"sha512-1vCTjF+z3kW7Yatyh2s9r1lgFTFro8jFZFem4e8Xz/ehGWGlqFNCk+caPNq8JdPwet9U/lDVZ8jsJOe32ctNTw==","signatures":[{"sig":"MEQCIAjbOisattZEfmwjS0J6RSJnIX1YmE7WDbvMKyHY3Ul8AiAGTJZ26y5mrWaS0DqFUGrl+rrIXZ+XG723Bb4MZCN06g==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":838151},"main":"./dist/index.js","type":"module","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-site-tools-1.0.0.tgz","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json"},"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"},"_npmUser":{"name":"maichitam","email":"tammai.it@gmail.com"},"_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-site-tools-1.0.0.tgz","_integrity":"sha512-1vCTjF+z3kW7Yatyh2s9r1lgFTFro8jFZFem4e8Xz/ehGWGlqFNCk+caPNq8JdPwet9U/lDVZ8jsJOe32ctNTw==","repository":{"url":"git+https://github.com/bigin-io/ssg-site-factory.git","type":"git","directory":"packages/site-tools"},"_npmVersion":"11.2.0","description":"Operator CLIs for a site repo: provisioning, media upload, the Pagefind build step, and the G5 QA gate (ADR-14)","directories":{},"_nodeVersion":"22.14.0","dependencies":{"jsdom":"^30.0.1","axe-core":"^4.13.0","wrangler":"4.127.1","jsonc-parser":"^3.3.1","@bigin-io/site-worker":"1.0.0","@bigin-io/renderer-core":"1.0.0","@bigin-io/site-contract":"1.0.0"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"pagefind":"^1.5.2","@types/jsdom":"^30.0.0"},"peerDependencies":{"pagefind":"^1.5.2"},"peerDependenciesMeta":{"pagefind":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/site-tools_1.0.0_1788517260137_0.7129546542135181","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@bigin-io/site-tools","version":"1.0.1","description":"Operator CLIs for a site repo: provisioning, media upload, the Pagefind build step, and the G5 QA gate (ADR-14)","license":"UNLICENSED","type":"module","repository":{"type":"git","url":"git+https://github.com/bigin-io/ssg-site-factory.git","directory":"packages/site-tools"},"publishConfig":{"registry":"https://registry.npmjs.org","access":"public"},"bin":{"build-search":"dist/bin/build-search.js","d1-export":"dist/bin/d1-export.js","preflight-deploy":"dist/bin/preflight-deploy.js","prepare-preview":"dist/bin/prepare-preview.js","provision":"dist/bin/provision.js","qa-gate":"dist/bin/qa-gate.js","upload-media":"dist/bin/upload-media.js"},"exports":{".":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"./package.json":"./package.json"},"main":"./dist/index.js","types":"./dist/index.d.ts","dependencies":{"axe-core":"^4.13.0","jsdom":"^30.0.1","jsonc-parser":"^3.3.1","wrangler":"4.127.1","@bigin-io/site-worker":"1.0.1","@bigin-io/renderer-core":"1.0.1","@bigin-io/site-contract":"1.0.1"},"peerDependencies":{"pagefind":"^1.5.2"},"peerDependenciesMeta":{"pagefind":{"optional":true}},"devDependencies":{"@types/jsdom":"^30.0.0","pagefind":"^1.5.2"},"scripts":{"build":"tsc -p tsconfig.build.json","typecheck":"tsc -p tsconfig.json --noEmit","clean":"node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""},"_id":"@bigin-io/site-tools@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-uabliHqmvUeAAzymfkn09Je2LBGwGX5oZsSvEoBUC+ho8T48leAQz1zU/+3rTFtOUkxV4IQu0B0jmEEBCVdlmg==","_resolved":"C:\\Users\\Admin\\AppData\\Local\\Temp\\sf-publish\\bigin-io-site-tools-1.0.1.tgz","_from":"file:C:/Users/Admin/AppData/Local/Temp/sf-publish/bigin-io-site-tools-1.0.1.tgz","_nodeVersion":"22.14.0","_npmVersion":"11.2.0","dist":{"integrity":"sha512-uabliHqmvUeAAzymfkn09Je2LBGwGX5oZsSvEoBUC+ho8T48leAQz1zU/+3rTFtOUkxV4IQu0B0jmEEBCVdlmg==","shasum":"bdc5319812debb7fe4a04c3701f13bb243e63d8a","tarball":"https://registry.npmjs.org/@bigin-io/site-tools/-/site-tools-1.0.1.tgz","fileCount":267,"unpackedSize":838151,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIGx6VwGCyzsPWM6o/m6zUdsUBYEKzFQY2sbRDwjeSt1KAiEAiwOC9KnclZ6KTxus3II7E8qnJw6R5XkhnMnGWqtw4PY="}]},"_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-tools_1.0.1_1788517760200_0.8040315106576688"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-04T10:20:59.924Z","modified":"2026-09-04T10:29:20.537Z","1.0.0":"2026-09-04T10:21:00.307Z","1.0.1":"2026-09-04T10:29:20.363Z"},"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-tools"},"description":"Operator CLIs for a site repo: provisioning, media upload, the Pagefind build step, and the G5 QA gate (ADR-14)","maintainers":[{"name":"maichitam","email":"tammai.it@gmail.com"},{"name":"tammai.bigin","email":"tam.mai@bigin.vn"}],"readme":"# `@bigin-io/site-tools`\n\nThe operator CLIs for a site repo (ADR-14): provisioning, media upload, and the\nPagefind build step.\n\n> **This package has THREE purposes now. Said out loud rather than discovered,\n> and the count is going up.**\n>\n> 1. **The operator CLIs** — the commands a site repo runs: `provision`,\n>    `upload-media`, `build-search`, `preflight-deploy`, `prepare-preview`.\n> 2. **The G5 quality gate** — `qa-gate`, added by story 6.3, along with the\n>    `axe-core` and `jsdom` dependencies it needs.\n> 3. **The ops baseline** — `d1-export`, added by story 6.4, plus the\n>    `ops-nightly.yml` template it runs in. It had a second command,\n>    `error-sweep`; **story 6.11 removed it on 2026-09-01** and the runbook says\n>    what that costs.\n>\n> Running the gate *is* an operator command, and so is a nightly export, so both\n> additions sit naturally beside the first purpose. But each is a *purpose*\n> rather than a command. The gate brought a browser-less DOM and an\n> accessibility engine into a package that previously talked only to Cloudflare,\n> so a site repo installing this to run `build-search` also installs those. The\n> ops baseline brought **workflow templates**, which had until now shipped only\n> from `site-worker` — noted in `ops-nightly.yml` itself so a reader meeting the\n> split is not left guessing.\n>\n> This is named here for the same reason `site-worker`'s three roles are named\n> in its README (story 6.2). **A package that acquires a purpose silently is how\n> nobody can say what it is for**, and a package whose purposes are written down\n> can be split later on a decision rather than on an archaeology. Three purposes\n> in two stories is a trend rather than an accident, and the obvious split —\n> `@bigin-io/site-qa`, or an `@bigin-io/site-ops` beside it — is a decision\n> somebody should take deliberately rather than inherit. Nothing here depends on\n> that not happening.\n\nThis package exists because every one of these commands used to live in a\n`private: true` workspace package. They worked with the platform monorepo\nchecked out and nowhere else — so story 3.3's acceptance line, *\"pipeline and\neditors use the same command\"*, could not be true, because editors are not in\nthat monorepo.\n\n```sh\npnpm add -D @bigin-io/site-tools\n```\n\n## The commands\n\n| Command | When | Tier |\n| --- | --- | --- |\n| `provision --phase 1` | at G4, before the build | **both** — the media half is content-only, the Turnstile widget is not (6.8) |\n| `upload-media` | whenever editorial media changes | content only |\n| `build-search` | every build, after the SSG build and before deploy | content only (a no-op on simple) |\n| `provision --phase 2` | at G6, before the first deploy | **both tiers** |\n| `preflight-deploy` | every deploy, immediately before wrangler | both tiers |\n| `prepare-preview` | every preview deploy | both tiers |\n| `qa-gate --stage static` | every pull request, **before** the preview deploy | both tiers |\n| `qa-gate --stage live` | every pull request, after it | both tiers |\n\nRun any of them with `--help`. Each takes `--config <path>` to your\n`site.config`, supports `--dry-run` and `--json`, and reports documented exit\ncodes so CI can act on the result.\n\n`provision --phase 2` runs on a **simple-tier** site too: ADR-11 removes R2,\ncollections and Pagefind from a simple site but keeps the forms/newsletter\nWorker, so it still needs a Worker, a D1 database and secrets.\n\n## The two deploy commands (story 6.2)\n\nThey exist so the deploy workflows stay thin. YAML is the one artefact in a site\nrepo that nothing type-checks, nothing lints and no test runs, so every decision\na deploy encodes that *can* live in a script does.\n\n**`preflight-deploy`** refuses rather than repairs. It checks that provisioning\nhas run (no `__PLACEHOLDER__` left, `vars.SITE_CONFIG` emitted, and no secret\nname inside it), that the build produced HTML, and that a content-tier site\ncarries a Pagefind index built from *this* output. Each refusal names the\ncommand that produces the missing thing, because the alternative is a Cloudflare\nAPI error several layers from the step that was skipped. It prints the *paths*\nof the keys at fault and never their values.\n\n**`prepare-preview`** derives `wrangler.preview.jsonc` from the site's\nprovisioned `wrangler.jsonc`, and appends the `noindex` rule to the built\n`_headers`. It changes the Worker name, the D1 database, and removes `routes`\nand `triggers`. The `SITE_CONFIG` slice is carried through byte for byte **but\nfor one field**, which is what makes \"a preview shares production's slice\" a\nproperty rather than a promise: it is not a copy that could drift, it is the\nsame bytes read from the same file at deploy time.\n\n**The one field is `forms.turnstile.siteKey`** (story 6.10). A Turnstile key is\nbound to **hostnames**, so byte-identity is *incoherent* for it: every other\nvalue in the slice describes the **site** and travels to any host it is served\nfrom, while this one describes a binding **between a site and a host** — and a\npreview is by construction on a host production's widget does not know. Carrying\nproduction's key would guarantee a preview whose challenge cannot solve, which\nis not the form path a preview exists to exercise. The preview carries the\n**preview widget's** key instead. Recorded as the human's to reverse, since it\ntouches NFR-6 and 6.2's AC6.\n\nThe alternative, a `[env.preview]` block in `wrangler.jsonc`, is ruled out by\nwrangler itself — `vars` and `d1_databases` are **not inherited** by named\nenvironments, so the block would have to carry a second copy of the whole slice.\nIts inputs come from the environment (`PREVIEW_D1_DATABASE_ID` and\n`PREVIEW_TURNSTILE_SITEKEY`, and optionally `PREVIEW_WORKER_NAME` and\n`PREVIEW_D1_DATABASE_NAME`) rather than from interpolated arguments, so a\nworkflow never puts a repository value on a shell line. Both required ones are\n**refused when absent** rather than defaulted: a missing database id would mean\na preview writing into the real subscriber list, and a missing site key would\nmean falling back to production's — a form on every preview that looks present\nand cannot be submitted.\n\nBoth refuse with exit 3 and change nothing. `prepare-preview` will not write a\nconfiguration that shares production's Worker name or its database id: the first\nwould share the secrets, the second would write into the real subscriber list.\n\n## `pagefind` is an optional peer dependency\n\nInstall it only if your site has search:\n\n```sh\npnpm add -D pagefind\n```\n\nADR-11 defines the simple tier as having *no Pagefind*. Making it a hard\ndependency would have every simple-tier site download a search-index binary it\nwill never run — installing the very thing the tier is defined by not having.\n`build-search` skips the simple tier entirely, so it never looks for it there.\n\nOn a content-tier site without it installed, `build-search` fails and names the\ninstall command. Its absence is a configuration fact, not a crash.\n\n## Credentials\n\n`provision` and `upload-media` read `CLOUDFLARE_API_TOKEN` and\n`CLOUDFLARE_ACCOUNT_ID` from the environment, never from flags, and scrub them\nfrom everything they print — including a failed subprocess's output.\n\n**`build-search` needs no credentials at all.** It is a local, file-in/file-out\nbuild step, and a search index you could only build while holding a production\nAPI token would be a broken local build.\n\n## Command reference\n\nMoved here from the private `scripts` package when ADR-14 extracted these CLIs.\nIt was documentation for site operators living in a package no site repo can\ninstall — the same reachability problem, one level down, that produced the\npackage itself.\n\n`provision-media` is the exception in the list below: it is phase 1 on its own,\nkept for the pipeline, and has no `bin` entry because `provision --phase 1` is\nthe command an operator should reach for.\n\n## `provision-media` — phase-1 Cloudflare provisioning\n\nCreates a site's R2 media stack: the bucket, the `media.<domain>` custom\ndomain, and the Cache Rule that makes originals serve with cache headers. Runs\ninside `sf-scaffold` at G4, *before* any media upload, because the build and the\nG5 image/link checks need media resolving (`docs/02-architecture.md` §\nPipeline). Phase 2 — Worker, D1, secrets, site DNS — is story 6.1; the Turnstile widget\nmoved to phase 1 in story 6.8.\n\n```sh\nnode node_modules/@bigin-io/site-tools/dist/bin/provision-media.js --config <path-to-site.config> [options]\n```\n\n| Option | Effect |\n| --- | --- |\n| `--config <path>` | the site config to read: `.ts`, `.mts`, `.js`, `.mjs`, or `.json` |\n| `--dry-run` | print the plan; change nothing |\n| `--verify` | run the read-only checks only; change nothing |\n| `--probe-key <key>` | an object that already exists, used by the cache-header and transform checks |\n| `--json` | machine-readable summary on stdout |\n\n| Exit | Meaning |\n| --- | --- |\n| 0 | success, or nothing to do |\n| 1 | unexpected failure |\n| 2 | usage error |\n| 3 | preflight refused: bad config, wrong account, unmanaged zone |\n| 4 | verification failed |\n\nIt is **idempotent**: it reads current state, plans only the difference, and a\nsecond run makes no changes. Run it as often as you like.\n\nA **simple-tier** site exits 0 having done nothing (ADR-11), so the pipeline can\ncall this unconditionally without branching on tier.\n\n### Credentials\n\nFrom the environment only — never flags, and never `site.config` (NFR-3):\n\n```sh\nexport CLOUDFLARE_API_TOKEN=...\nexport CLOUDFLARE_ACCOUNT_ID=...\n```\n\n`CLOUDFLARE_ACCOUNT_ID` must match `cloudflare.accountId` in the config, or the\nrun aborts before touching anything. Provisioning into the wrong account is the\nexpensive mistake.\n\n**Token scopes.** The starting set, to be narrowed once a real run confirms\nwhich are load-bearing (story 3.1, T7):\n\n| Scope | Needed for |\n| --- | --- |\n| Account → Workers R2 Storage:Edit | creating the bucket, binding the domain |\n| Zone → Zone:Read | resolving `cloudflare.zone.name` to a zone id |\n| Zone → DNS:Edit | binding a custom domain writes a DNS record |\n| Zone → Cache Settings:Edit | the Cache Rule |\n\nThe token is scrubbed from every line this tool prints, including the output of\na failed `wrangler` subprocess.\n\n### One-time prerequisites, per account and zone\n\nThese sit **outside** the pipeline (`docs/02-architecture.md` § Pipeline) and\nthis script does not create them:\n\n1. **R2 enabled** on the Cloudflare account.\n2. **The zone on Cloudflare.** If the client's DNS is elsewhere,\n   `cloudflare.zone.managed: false` and this script refuses — use the documented\n   fallback (CNAME to `workers.dev`, media on a BigIn-owned zone) and point\n   `media.zone` at that host.\n3. **Image Transformations enabled** for the zone: dashboard → Images →\n   Transformations → select the zone → enable. The script verifies this\n   end-to-end rather than trusting a settings flag, but it cannot switch it on.\n\n### What \"serves with cache headers\" actually rests on\n\nTwo halves, one constant:\n\n- **This script** upserts a Cache Rule on the zone, matched on the media host,\n  with edge and browser TTL from `MEDIA_CACHE_MAX_AGE_SECONDS`. Browser TTL in\n  `override_origin` mode is what stamps `Cache-Control` on the response.\n  Cloudflare caches only certain file types on a custom domain by default, so\n  without this rule the promise would hold for JPEGs and quietly break for PDFs.\n- **Story 3.3** stamps the same value onto each object's metadata at upload,\n  reading the same exported constant. Change it in one place.\n\nThe rule is upserted **non-destructively**: the cache-rules entrypoint ruleset\nis zone-wide and its API replaces the whole list, so the script reads the\ncurrent rules, replaces only its own (matched on a stable description), and\nwrites the merged list back. A client's other cache rules survive.\n\n### When the custom domain looks stuck\n\nA newly bound R2 custom domain is not instant — DNS and certificate issuance\ntake a few minutes. `--verify` will report the origin check failing until it is\nready, which is expected, not a bug. Re-run `--verify` rather than re-running\nprovisioning; the binding is already made and a second `provision` run is a\nno-op anyway.\n\nIf it is still failing after ~15 minutes, check in the dashboard that the\ndomain shows as active and that its certificate has issued.\n\n### Undoing a run\n\n```sh\nwrangler r2 bucket domain remove <bucket> --domain <media host>\n# remove the \"ssg-site-factory: cache media originals\" rule from the zone's\n# cache-rules ruleset (dashboard → Caching → Cache Rules)\nwrangler r2 bucket delete <bucket>     # only if the bucket is genuinely scratch\n```\n\nDeleting a bucket that holds a live site's media is not recoverable. Remove the\ndomain binding first and confirm the site is not serving from it.\n\n## `upload-media` — editorial media into R2\n\nUploads originals to the site's R2 bucket, names them by content, and records\nthem in an alt-text manifest. **One command for both callers** — an editor\nadding a photo and `sf-scaffold` seeding a site run exactly the same thing.\n\n```sh\n# an editor, one photo, described\npnpm exec upload-media --config ./site.config.ts \\\n  --prefix blog --alt \"A warehouse robot at dusk\" ./photos/hero.jpg\n\n# the pipeline, a whole tree, refusing to proceed on a missing description\npnpm exec upload-media --config ./site.config.ts \\\n  --prefix blog --require-alt ./photos\n```\n\n| Option | Effect |\n| --- | --- |\n| `--prefix <path>` | key prefix, e.g. `blog` |\n| `--alt <text>` | alt text to record; single file only |\n| `--require-alt` | fail if any entry has no alt text |\n| `--manifest <path>` | manifest location (default `media/manifest.json`) |\n| `--dry-run` | show what would happen; write nothing, manifest included |\n| `--json` | machine-readable summary |\n\n### What the key convention guarantees\n\nKeys are `<prefix>/<slug>-<8 hex of sha256>.<ext>` — derived from the file's\nname and its **bytes**, never from the directory it happened to sit in. So:\n\n- The same image gets the same key whether an editor or CI uploads it, on\n  Windows or Linux. That is what makes \"one command for both callers\" true in\n  practice rather than only in the flags.\n- **Editing an image produces a new key.** This is the point: 3.1 serves media\n  with `immutable` and a one-year TTL, which is only safe if a URL's bytes never\n  change. An overwritten `hero.jpg` would be served stale from the edge for a\n  year with no purge path from here.\n- Re-running is free. An object already being served is skipped.\n\n### The manifest\n\nDefault `media/manifest.json` in the site repo, committed like any other gate\nartifact. It records key, source name, sha256, size, content type, alt text and\nupload time, sorted by key so a re-run makes no spurious diff.\n\n**It does not satisfy the contract's alt-text rule.** Every content reference\nstill needs its own `alt` in frontmatter — `createImageRefSchema` enforces that\nat parse time (NFR-2). The manifest is where `sf-content` finds the words to put\nthere, and where a QA pass can see which uploads still have none. The file says\nso about itself, in case it is ever read out of context.\n\nRe-running never overwrites human-written alt text with a blank.\n\n### What it refuses, and why\n\n- **Anything under `public/`.** That imagery is owned by code and ships through\n  the build-time image pipeline (ADR-12). Uploading it would create a second,\n  diverging copy of the same asset — this is how ADR-4's ownership boundary\n  erodes, one convenient upload at a time.\n- **A simple-tier site.** It has no R2 media stack at all (ADR-11); the image\n  belongs in `public/`. Note this differs from `provision-media`, which merely\n  skips: that one is called unconditionally by the pipeline, while nobody runs\n  `upload-media` by accident.\n- **SVG.** An SVG can carry script and would be served from `media.<domain>` —\n  inside the client's own domain. Vector art is repo-owned anyway, so `public/`\n  is both the safer and the already-correct home.\n- **Fonts, HTML, and unknown types.** Template assets belong to the build\n  pipeline; an unrecognised type is refused by name rather than uploaded under a\n  guessed content type, which would otherwise be served that way forever behind\n  an immutable cache.\n\nA directory walk *skips* unsupported files and reports the count, rather than\nfailing the whole run over a `.DS_Store`. A file named explicitly is refused,\nbecause naming it was a decision.\n\n### Removing something uploaded by mistake\n\n```sh\nwrangler r2 object delete <bucket>/<key>\n```\n\nThen delete its entry from the manifest. Remember that content may already\nreference the URL — grep `content/` before removing anything.\n\n## `build-search` — the Pagefind index\n\nBuilds the search index for a site that has already been rendered. It sits in\nthe middle of the chain the architecture specifies, and the order is not\noptional — it indexes rendered HTML, so the SSG build has to have finished:\n\n```text\nnuxt generate | next build   ->   build-search   ->   wrangler deploy\n```\n\n```sh\npnpm exec build-search --config ./site.config.ts\n```\n\n| Option | Effect |\n| --- | --- |\n| `--dist <path>` | built site directory (default `.output/public` for Nuxt, `out` for Next) |\n| `--dry-run` | report what would change; write nothing |\n| `--json` | machine-readable summary |\n\n**No Cloudflare credentials are read or needed.** This is a local build step,\nand a search index you could only build while holding a production API token\nwould be a broken local build.\n\nA simple-tier site exits 0 having done nothing — it has no Pagefind at all\n(ADR-11) — so the build chain can call this unconditionally.\n\n### What `excludeRoutes` guarantees\n\n`search.excludeRoutes` holds *logical* routes. **One entry excludes that route\nunder every configured locale.** `/privacy` excludes `/en/privacy` and\n`/vi/privacy`, not merely a bare `/privacy`.\n\nThis matters more than it looks. FR-S8 makes routing locale-prefixed from\nlaunch, so on a real site the literal reading matches *nothing* — and every page\nthe operator asked to hide stays in the index. The failure modes are\nasymmetric: over-excluding hides a page from search, under-excluding publishes\na page someone chose to conceal.\n\n> **Cross-lane note.** Any other code that resolves `excludeRoutes` — a\n> renderer's own search work in particular — must expand it the same way, or the\n> two renderers will disagree about which pages are public and NFR-7 parity\n> stops being cosmetic.\n\nTwo further guarantees:\n\n- **A route that matches no built page fails the build**, naming it. A typo'd\n  exclusion that quietly matches nothing is the same leak arriving by another\n  road, and nobody notices until the page turns up in a search engine.\n- **The built index is read back and checked.** The step does not assume its\n  own flags worked; it opens Pagefind's fragments and asserts the excluded URLs\n  are absent. If the index cannot be read, that is reported as a failure — \"could\n  not check\" must never look like \"checked and it is absent\".\n\n### Every page must declare its language\n\n`build-search` refuses a **multi-locale** build in which any indexed page has no\n`<html lang>`, and warns on a single-locale one.\n\nPagefind splits its index by the language a page declares. Without the\nattribute every page lands in its `unknown` index, so on a multilingual site a\nVietnamese reader's search returns English results — silently, with no error\nanywhere. It is also WCAG 2.1 3.1.1, a Level A criterion.\n\nThe single-locale case only warns because a site whose pages all land in one\n`unknown` index still searches correctly; failing that build would punish it for\na defect it does not have.\n\nThe check lives here rather than in a renderer because this step reads *built\nHTML*: a fix in one renderer protects that renderer, a check here protects the\nproperty for every renderer, including ones that do not exist yet.\n\n### It writes to build output\n\nPagefind has no route-level exclusion: `--glob` takes one pattern with no\nnegation, and `--exclude-selectors` excludes elements rather than pages. So\nbefore indexing, this step adds `data-pagefind-ignore=\"all\"` to the `<body>` of\neach excluded page. The attribute is inert when served and makes the exclusion\nvisible in the shipped HTML.\n\nIt only ever *adds* that attribute — no other byte of the document changes — and\nit is idempotent, so a second run leaves the file byte-identical.\n\n### Confirming a page really is absent\n\nThe step already does this and fails if it is not, but to check by hand:\n\n```sh\nnode -e \"const fs=require('fs'),z=require('zlib'),p=require('path');\nconst d=process.argv[1]+'/pagefind/fragment';\nfor(const f of fs.readdirSync(d)){\n  const t=z.gunzipSync(fs.readFileSync(p.join(d,f))).toString();\n  console.log(JSON.parse(t.slice(t.indexOf('{'))).url);\n}\" ./out\n```\n\nEvery indexed URL is printed. A page you excluded must not appear.\n\n## `provision` — the two phases\n\nProvisions a site's Cloudflare resources. Two phases, because the two halves\nare needed at different gates:\n\n```sh\n# G4, before the build: Turnstile widget (both tiers), then\n#                        R2 bucket, media.<domain>, cache rule (content tier)\npnpm exec provision --config ./site.config.ts --phase 1\n\n# G6, before deploy: D1 database + migrations, Worker secrets\npnpm exec provision --config ./site.config.ts --phase 2\n```\n\n**Either phase is idempotent.** Re-run them freely: a second run reports every\nresource as `already-present` and leaves both `wrangler.jsonc` and the record\nbyte-identical.\n\n### The tier asymmetry — the easiest thing here to get wrong\n\n| | simple tier | content tier |\n| --- | --- | --- |\n| Phase 1 — Turnstile widget | **runs** | runs |\n| Phase 1 — R2, media.<domain>, cache rule | **skipped**, with the reason | runs |\n| Phase 2 | runs | runs |\n\nADR-11 takes R2, collections and Pagefind away from a simple site — but it keeps\nthe forms/newsletter Worker. So a simple site has no bucket and no search index,\nand still needs a widget, a Worker, a D1 database and secrets.\n\n**Phase 1 is no longer skipped outright on a simple site** — story 6.8 moved the\nTurnstile widget into it, and forms are on every tier. Only the media half is\ntier-gated now, and a run reports the skipped half **with its reason** rather\nthan falling silent: a green run that says \"nothing to do\" while it provisioned\na widget is a lie, and this table used to invite one.\n\n### Environment\n\n| Variable | Needed by |\n| --- | --- |\n| `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID` | both phases |\n| `EMAIL_API_KEY` | phase 2 |\n| `ESP_API_KEY` | phase 2, only when the newsletter relays to an ESP (ADR-6) |\n\n`TURNSTILE_SECRET_KEY` is **not** in this table since story 6.8: phase 2 reads it\nback from the provisioned widget instead of asking you to carry it.\n\nEvery missing variable is reported **at once, before anything is provisioned** —\nfinding the second one after the first has already created resources is how a\nsite ends up half-built.\n\nSecret values are read from the environment and handed to `wrangler` on stdin.\nThey never reach `wrangler.jsonc`, the provisioning record, a log, or any file\nin the site repo — and a `secret put` that fails has its value scrubbed from the\nerror, since a failure is exactly when a value tends to get echoed back.\n\n### What lands in `wrangler.jsonc`\n\nPhase 2 writes the provisioned state: the Worker name, the `ASSETS` binding, the\n`DB` binding with the D1 **`database_id`**, the `MEDIA` bucket on the content\ntier, the custom-domain route, and `triggers.crons`.\n\n**`triggers.crons` comes from `dataProtection.purgeSchedule` in `site.config`.**\nNever edit it by hand. The schedule has two homes and a Worker cannot read its\nown trigger, so nothing at runtime can tell you they have drifted apart; change\n`site.config` and re-run phase 2.\n\nEdits are **surgical**. Any key this tool does not own — `compatibility_date`,\n`vars`, `observability`, routes you added, and comments — survives untouched.\nIt will not overwrite an `assets.directory` you chose.\n\n### It fills in a config; it does not create one\n\nThe site's `wrangler.jsonc` comes from the template in `@bigin-io/site-worker`:\n\n```sh\ncp node_modules/@bigin-io/site-worker/wrangler.template.jsonc ./wrangler.jsonc\n```\n\nPhase 2 **refuses to run** if it is missing, rather than writing one from\nscratch. A generated-from-nothing config would have no `main` and no\n`compatibility_date` — undeployable, but written confidently and reported as a\nsuccess, which is the worst combination available.\n\n### Reading the record\n\n`.provisioning/record.json`, committed on purpose: it is the answer to \"what\nexists for this site?\" without opening the Cloudflare dashboard. Per resource it\nrecords the kind, name, resolved id, whether the last run `created` it or found\nit `already-present`, which phase wrote it, and when.\n\nIt holds **no secrets** — only the *names* of the secrets that are set. A run\nthat changes nothing leaves it byte-identical, so a diff always means something\nactually happened.\n\n### Turnstile — provisioned in phase 1, on both tiers (story 6.8)\n\nPhase 1 creates the site's Turnstile widget, or verifies the one that is already\nthere. **On both tiers**: ADR-11 takes R2, collections and Pagefind away from a\nsimple site but keeps the forms Worker, so every site needs a widget.\n\nThat is why the tier table above says *partly* skipped rather than skipped. A\nsimple-tier `provision --phase 1` does real work now, and the run says which\nhalf it skipped and why:\n\n```\ncreated         Turnstile widget: northwind.example\nskipped         media: tier is \"simple\": no R2 media stack to provision (ADR-11)\nrecord:         .provisioning/record.json\n```\n\n**Through `wrangler turnstile widget`, not the REST API.** A REST sanction was\ngranted for this and then handed back **unused**: wrangler 4.127.1 has the\ncommand, so `CLAUDE.md`'s *\"Wrangler for anything Cloudflare\"* applies with no\nexception. It is `[alpha]`, which is why wrangler is pinned to an exact version\nhere and why `test/turnstile-surface.test.ts` fails loudly if a bump moves the\nsubcommands, flags or output this package parses.\n\n#### The first run of a new site fails on purpose\n\nThe schema requires `forms.turnstile.siteKey` from intake, and the widget does\nnot exist until phase 1 runs. So write the documented placeholder at intake:\n\n```ts\nforms: { turnstile: { siteKey: '0x4AAAAAAA__PROVISIONING_FILLS_THIS__' } }\n```\n\nPhase 1 then creates the widget and **stops**, printing the real key and telling\nyou to paste it in. Re-run and it verifies. That is one deliberate non-zero exit\non a site's first provisioning, and it buys something: **provisioning never\nedits `site.config`.** The config is an input to the build (NFR-6), not an\noutput of it, and a tool that rewrote it would make every build depend on\nwhether provisioning had run.\n\nTwo different messages, because they are two different situations:\n\n| What phase 1 found | What it means |\n| --- | --- |\n| the placeholder | expected on a first run. Paste the key it printed, re-run |\n| a real but **different** key | **stop and check.** The config may point at another site's widget, or this account may have two widgets for this domain. The build bakes this key into every page |\n\n**The placeholder cannot be shipped.** If it somehow survives to a build, the QA\ngate's bundle guard fails on it (story 6.3) — because an operator who never\nre-runs would otherwise deploy a site whose every form silently rejects every\nvisitor.\n\n#### You no longer supply `TURNSTILE_SECRET_KEY`\n\nPhase 2 reads it back from Cloudflare with `wrangler turnstile widget get` at\nthe moment it hands it to `wrangler secret put`. It is never displayed, never\nwritten to a file, and never reaches the provisioning record. One fewer\ncredential for you to carry between two gates.\n\n`EMAIL_API_KEY` and (for an ESP newsletter) `ESP_API_KEY` still come from the\nenvironment as before.\n\n#### The API token needs the Turnstile permission\n\nThe same `CLOUDFLARE_API_TOKEN` provisions R2, D1 and now Turnstile — but\nTurnstile is its own permission group, and a token that predates this has no\naccess to it. The permission is:\n\n```\nchallenge-widgets.write   —   \"See and change Turnstile widgets\"\n```\n\nAdd it to the token, or use one that has it.\n\n**What the absence actually looks like, corrected against the live API on\n2026-09-01.** Story 6.8 predicted *a 403 that reads like a broken endpoint*, and\nput the scope name here so nobody would go hunting one. **It is better than\nthat, and the record is worth correcting rather than leaving flattering:**\nwrangler refuses in a **pre-flight warning that names the missing scope** and\ntells you to re-run `wrangler login` — it never reaches the API, so there is no\n403 to misread. The entry above is still the right entry; the symptom it guards\nagainst is **legible, not cryptic**. That matters here specifically: a runbook\ndescribing the wrong symptom sends someone hunting the wrong cause, and the\nright cause is one `wrangler login` away.\n\n#### A site has TWO widgets, and the second one is why production is safe\n\nPhase 1 provisions **two** Turnstile widgets on **both tiers**:\n\n| Widget | Domain list | What uses it |\n| --- | --- | --- |\n| production | the site's own zone | the live site |\n| **preview** | `workers.dev` | preview deploys, and nothing else |\n\n**The one-widget option was considered and refused on 2026-09-01.** Closing\n6.3's untested live-key path needed a widget that works on a preview's\n`*.workers.dev` hostname, and the cheap answer was to add `workers.dev` to the\nclient's *production* widget. **Turnstile's domain list covers subdomains**, so\nthat admits **every** `*.workers.dev` host on the platform — any other tenant's\nWorker could mint tokens that validate against that client's sitekey. Cross-\ntenant exposure on a live client's bot protection, in exchange for a test.\n\nThe same entry on a preview-only widget costs nothing, because that widget\nguards nothing: a throwaway Worker, its own database, no mail credentials, no\nproduction route. **The difference is entirely what the widget stands in front\nof**, which is worth remembering the next time one domain list looks like it\ncould hold both hostnames.\n\nProvisioning has **no way to modify an existing widget** — `list`, `get` and\n`create`, and nothing that writes a domain list — so production's list cannot be\ntouched by this package even by mistake. There is a test asserting it, because\nthe whole basis of the choice above is that production stays as it is.\n\n**Record the preview widget's site key** as the `PREVIEW_TURNSTILE_SITEKEY`\nrepository *variable*. It is public — served in the HTML of every preview page —\nso a variable, not a secret, exactly like `PREVIEW_D1_DATABASE_ID`.\n`prepare-preview` refuses to write a preview config without it rather than\nfalling back to production's key, which would render a challenge that cannot\nsolve.\n\n**If `workers.dev` is already on a production widget** — earlier versions of the\npreview-deploy template told operators to put it there — remove it from that\nwidget's domain list in the dashboard and re-run phase 1. Removing it cannot\nbreak production: the site is served from its own hostname, which stays.\n\n#### The widget's mode, and who should be choosing it\n\nThe widget is created with `--mode managed`, which lets Cloudflare pick the\nlightest challenge that works. That is a default chosen in code, and it is a\n**product and accessibility decision** rather than a technical one: an\ninteractive challenge is a barrier some users cannot pass, and a client may need\na different mode. `site.config` has **no field for it**, deliberately — adding\none is a contract change. Flagged for the human in story 6.8.\n\n## The QA gate — `qa-gate` (story 6.3, FR-P6)\n\nThe G5 gate. It runs on every pull request and **a red result blocks the\nmerge**, which is what \"before deploy is offered\" means in practice: production\ndeploys from `main`, so the place to stop bad output is in front of the merge,\nnot in front of `wrangler deploy` where a human can be tempted past it.\n\n```sh\n# before the preview deploy — needs only dist, no URL, no credentials\npnpm exec qa-gate --config site.config.ts --stage static --out qa-report.txt\n\n# after it\npnpm exec qa-gate --config site.config.ts --stage live \\\n  --url \"$PREVIEW_URL\" --database my-site-preview --comment qa-comment.md\n```\n\nExit `0` passed, `1` failed, `2` usage. Nothing else.\n\n### Why it is split in two\n\n| Half | Needs | Runs |\n| --- | --- | --- |\n| static — bundle guard, link check, axe, tier surfaces | `dist` | **before** the preview deploy |\n| live — Lighthouse, the Turnstile round-trip | a URL | after it |\n\nA build that fails the static half never gets a preview deployed. For the bundle\nguard that matters literally rather than economically: deploying the artefact\nthe guard exists to stop is the one deploy that must not happen.\n\n### What it checks\n\n| Check | What fails it |\n| --- | --- |\n| **links** | an internal `href` that resolves to nothing the build emitted; on a multi-locale site, one resolving to an **unprefixed** route |\n| **a11y** | a WCAG 2.1 AA violation (NFR-2) from axe, over `documentElement`, on **every emitted page in every locale** |\n| **bundle** | a SQLite WASM asset in `dist`; a root-relative `/cdn-cgi/image/` URL; a Node-only module (`sharp`, `satori`) in the emitted bundle; the **Turnstile placeholder site key**, which would make every form on the site reject every visitor |\n| **forbidden-hosts** | a font or icon CDN reached from the built output — `FORBIDDEN_HOSTS`, imported from `@bigin-io/renderer-core/policy`, never copied |\n| **search-index** | a content-tier build with no `pagefind/pagefind.js` |\n| **collection-routes** | a declared collection that emitted no entry route |\n| **public-images** | **nothing — this one WARNS and never blocks** (story 6.14). It reports a content-tier site whose **source `public/`** holds more than `PUBLIC_IMAGE_BUDGET_BYTES` (512 KB) of images. Content tier only — see below |\n| **lighthouse** | LCP ≥ 2.0 s, CLS ≥ 0.1, or JS ≥ 100 KB gzipped — NFR-1's numbers, as three separate assertions over a median of three runs |\n| **turnstile** | a submission accepted without a bot-check token; a response that breaks the `/api/*` contract; a `200` with no row in the preview database |\n\n**Two** things are warnings and never fail the gate.\n\n- **An unreachable external link.** A third party's 503 is not this site's\n  defect, and a gate that blocks on one is a gate people learn to override.\n- **An over-budget `public/`** — since story 6.14; see the next section for the\n  reversal and its reasons.\n\nA warning is not a quiet pass. A check that found something and did not block\nreports **`WARNED`**, not `PASSED`, its lines print `WARN` rather than `warn`,\nthe closing line of the run names it — `Gate passed, WITH 1 WARNING(S) THAT DID\nNOT BLOCK IT: public-images` — and on the pull-request comment it gets its own\n`⚠️` section **above** the collapsed details, below any failures. That is\ndeliberate and it is story 6.14's AC2: **a warning nobody reads is the failure\nmode that ruling accepts responsibility for.**\n\n### `public-images`, and what story 6.12 changed about it\n\nUntil story 6.12 this was the *second* warning, and it was broken in a way worth\nrecording. Its constant was named `PUBLIC_IMAGE_BUDGET_BYTES`, its doc comment\nargued about ADR-4, and its report sentence read `public/ images total N bytes`\n— **while the number came from scanning `dist`.** It reported a measured fact\nthat was never measured. `dist` is not even a proxy for `public/`: ADR-12 emits\nAVIF and WebP variants for every source raster and the originals never ship, so\non the parity fixtures the built output is about 4.5x the files and 9x the\nbytes of the source directory.\n\nThree things changed, on the human's ruling of 2026-09-03 — *make it match its\nname*:\n\n- **It measures source `public/`**, resolved from the **`--config` path's\n  directory**. Not `process.cwd()`, and not `dist`: `--dist` can point anywhere,\n  so the two need not be related, and a path convention that only holds when the\n  gate runs from the site root is a check that silently measures nothing.\n- **It is its own check**, on its own row, so a reader scanning the report sees\n  it at all. (6.12 also made it *fail*. **Story 6.14 reversed that half** —\n  immediately below.)\n- **It is content tier only**, and that falls out of ADR-11 rather than taste. A\n  simple-tier site has **no R2** — all of its imagery lives in `public/` and\n  ships through the ADR-12 pipeline — so there is nowhere else for editorial\n  media to be and no ADR-4 boundary there to cross. It is `SKIPPED` with that\n  reason, never a lax pass. **The consequence is that a simple-tier site now has\n  no image check at all.** That is deliberate.\n\n#### Story 6.14 — it warns, and it does not block\n\n**An over-budget `public/` reports a finding and the gate exits 0.** 6.12 made it\nred; 6.14 reversed that and left everything else 6.12 changed in place. Three\nreasons, so this can be argued with rather than merely appealed to:\n\n1. **Story 6.3 made it a warning deliberately** — *a warning with a number\n   attached, never a failure* — on the argument that a warning threshold\n   genuinely was the site's to set. 6.12 changed **what** is measured and\n   **where the number lives**, both correctly, and **never argued severity**:\n   its AC6 asked only that the over-threshold fixture *go red*, which was\n   written meaning *reports a finding* and read — reasonably — as *the gate is\n   red*. The ambiguity was the coordinator's, and 6.12's change log now records\n   it.\n2. **The constant says of itself that it is not measured from a real client\n   site.** A hard gate on an admittedly-underived number is **how gates get\n   turned off** — the mistake story 6.13 had just finished undoing from the\n   other side, where a permanent false positive had trained readers to skip a\n   row.\n3. **6.12 moved two things at once**: 2 MB to 512 KB, four times tighter, *and*\n   warn to fail. Fired on the first real client site, nobody could have said\n   which half was wrong.\n\n**The threshold did not move back.** 512 KB stays with its derivation; the\ntightening is the part 6.12 got right. **No config field either** — story 6.9\nremoved `site.config.budgets` by human decision and this does not reopen it, in\neither direction.\n\n**What would justify blocking is written down as a condition, not a preference**\n(6.14's AC6, beside the constant in `src/qa/public-images.ts`): three real\ncontent-tier client `public/` directories measured, all three under the\nthreshold with ADR-4's set complete, and at least one recorded true positive —\nafter which the threshold and the severity move in separate changes. Points one\nto three are measurements, not judgements.\n\nThe threshold is **512 KB**, and the derivation lives beside the constant in\n`src/qa/public-images.ts`: favicons, a logo, and an OG fallback card, because\neverything editorial is R2's by ADR-4. The two values before it — a Zod default\nof 1500 KB and a round binary 2 MB — traced to nothing, which is how a 37 %\nloosening went unnoticed for a week. It is derived from what ADR-4 says belongs\nin `public/`, **not measured from a real client site**; none exists in this\nrepository.\n\nIts three green outcomes are **not interchangeable**. A missing `public/`, a\n`public/` with no images in it, and a `public/` measured and under budget each\nprint a different sentence, because a zero meaning *no directory* and a zero\nmeaning *no images* are different facts — the same reason a skip carries a\nreason rather than reading as a pass.\n\n#### What was lost, stated rather than absorbed\n\nDropping the `dist` scan **removed the only measurement of total shipped image\nweight this repository had**, and nothing replaces it. NFR-1 does not: LCP, CLS\nand JS weight do not add up to image payload, and `_headers` only pins the\nvariant directory as immutable. If shipped image weight is wanted, it is a\n**separate check with its own name and its own number** — it is deliberately not\nsmuggled back in under a name that says `public/`. This is the discipline story\n6.9 applied to `publicImagesKb`'s loosening, applied to its own removal.\n\n### What it CANNOT see\n\nThis table is the point of the gate, not an appendix to it. Both document-level\naccessibility failures this platform has found came from correcting the\n**scope** of a check that was already running and passing — never from adding a\ncheck. So every check is asked what it cannot see, the answers live here, and\nthe gate prints them beside its own results on every run, passing or failing.\n\n| Check | Cannot see |\n| --- | --- |\n| link check | a link that resolves but is **wrong** — the right shape to the wrong page |\n| axe (jsdom) | anything needing **layout**, `color-contrast` above all; and anything that only exists **after client JS runs** — on a static export that is small but not empty: the consent banner, the search UI, the form widget |\n| axe (any) | judgement — contrast inside an image, a label that is present but meaningless, a focus order that is valid but absurd |\n| Lighthouse | a page nobody routed to; it measures the URLs it is given, and it is given `/` and (content tier) `/blog`; and a site that wants to be **faster** than the floor — since story 6.9 these are the platform's numbers and the only ones |\n| bundle guard | a forbidden host reached at **runtime** from a string the build never resolved |\n| Turnstile round-trip | **`siteverify` against a real secret**, and **the production widget's own hostname binding**. See below — the boundary moved in story 6.10 and it is narrower than it was |\n| search-index | whether the index's *content* is right — only that it was emitted |\n| collection-routes | a collection that emitted some entries and silently dropped others |\n| public-images | **whether an over-budget `public/` is actually editorial media** — it weighs bytes, it cannot read intent, and that is why it warns rather than blocks (story 6.14); **what an image is** — it weighs bytes by extension, so a 400 KB photograph committed as a logo is under budget and indistinguishable from one; anything outside `public/`, such as an editorial image imported through the bundler from `src/`; and **total shipped image weight, which nothing measures any more** (story 6.12, above) |\n| all of them | anything only present on **production**: they run against a preview on `*.workers.dev` |\n\n**The Turnstile row needs its own paragraph, because half of it changed.**\nSince story 6.10 a preview has its **own real widget**, so the preview's HTML\ncarries a real site key that a real browser really solves on `workers.dev` — the\nprovisioning path and the site-key/hostname binding are exercised for real. What\nis still not: **`siteverify` against a real secret.** A Turnstile token can only\nbe minted by a **browser solving the challenge**, and this gate has no browser,\nso it posts a fixed dummy token that only the always-passes **test secret**\naccepts. Pointing the preview Worker at the preview widget's real secret would\nturn the accepted case into a `403` and take the **stored-row check** with it —\ntrading a proven property (*a submission is not lost*) for a rehearsal. And the\nnegative direction buys nothing back: the Worker collapses siteverify's\n`invalid-input-secret` and `invalid-input-response` into one `turnstile_failed`,\nso a bogus token fails identically against a real secret and a garbage one.\n**The production widget's own binding is a different value again, and the first\nreal submission on the client's domain is still the first test of it.**\n\n**Contrast is deliberately absent, and that is not a hole.** A headless DOM does\nno layout, so axe cannot measure `color-contrast` — it is disabled, and the\ndisablement is reported. What covers it is `site-contract`, which computes WCAG\ncontrast **at parse time**: an AA failure is a config parse error before a page\nis ever built. The gate says this rather than appearing to cover it, because a\ngate that *looks* like it checks contrast is how a real contrast regression\nships.\n\n### How to read a red run\n\nThe report distinguishes five statuses, and only one of them is red:\n\n| Status | Meaning |\n| --- | --- |\n| `PASSED` | it ran and found nothing |\n| `FAILED` | it ran and found something — **this is what blocks the merge** |\n| `SKIPPED` | the site's **tier** gives it no surface (ADR-11), with the reason printed |\n| `BLOCKED` | it cannot be written yet, with the dependency named |\n| `DEFERRED` | it belongs to the other half of the run |\n\nRead it in this order:\n\n1. **Look at the status column first, not the failure list.** A `SKIPPED` or\n   `BLOCKED` row is a check that did **not run**. It is not a pass, and the\n   report never prints one as though it were.\n2. **Each `FAIL` line names the thing that caused it** — the page and the href,\n   the file and the rule, the URL and the measured number. If a line does not\n   tell you where to look, that is a defect in the gate; report it.\n3. **`warn` lines never fail the run.** An external link, an axe `incomplete`\n   (\"axe could not determine\" is not \"the page is wrong\"), an image budget, and\n   every *passing* Lighthouse assertion appear here — the last so a budget met\n   by 5 ms is visible before it becomes someone else's failure.\n4. **`blind` lines are what that check could not have caught.** A green run with\n   its blind spots printed under it is an honest green.\n\nTwo failures read differently from the rest:\n\n- **`lighthouse` FAILED with \"not measured\"** means Lighthouse did not run — a\n  missing binary, a preview that never loaded. That is a failure and not a skip\n  on purpose: *\"we could not check\"* and *\"it is fine\"* are different facts, and\n  only one of them should let a merge through.\n\n### What cannot be turned off\n\nNo check has a switch. A check is skipped **only** because the site's tier gives\nit no surface to run against, that reason is printed, and a `site.config` that\ntries to declare `qa.skipSomething` is refused with a red gate rather than\nignored with a green one. A gate a site can turn off is a gate that will be\nturned off on the site that needs it.\n\n### `forbidden-hosts` reads one list, and does not own it\n\nThe forbidden-host check imports `FORBIDDEN_HOSTS` from\n`@bigin-io/renderer-core/policy`. It **never** holds a copy, and it waited for\nthat subpath to exist rather than shipping with a stopgap list — two lists\ndrift, and the one that drifts is the one nobody is looking at, so a font CDN\nadded to one and not the other would ship to production past a green gate.\n\nAdding a host in that one place arms three checks at once: `renderer-core`'s\nguard over its own source, the Nuxt bundle-build guard over the bytes a real\n`nuxt generate` wrote, and this gate over a site's `dist`.\n\nThe rule is *zero external requests*. A third-party origin is a DNS lookup, a\nTLS handshake and a cold connection on the critical path (NFR-1), and remote\nfont loading has GDPR case law against it. Self-host through the build instead.\n\nWhat it cannot see is in the table above and printed on every run: a hostname\nthe build never resolved into one string — assembled at runtime, or read from\nconfiguration — is invisible to a scan of the bytes.\n\n### The cost, stated\n\nThree Lighthouse runs per URL, serially. On the two URLs the gate measures that\nis roughly **1.5–2.5 minutes** of wall-clock per pull request. The median of\nthree rather than a single run is what stops the gate being flaky, and a flaky\ngate is one people re-run until it is green — which is a gate that blocks\nnothing. Every individual run's value is printed beside the median, so a median\nof 1.9 s over runs of 1.2, 1.9 and 3.4 s is visible as the warning it is.\n\n## The ops runbook (story 6.4, amended by 6.11)\n\nThe floor under a deployed site: **what does not watch it**, what backs it up,\nhow to undo a deploy, and how to erase one person's data.\n\n> **What this baseline does and does not buy, before anything else.**\n>\n> **IT DETECTS NOTHING. There is no error detection at all** — see *Alerting,\n> monitoring, error detection* below, which is the first section for that\n> reason. A Worker that throws is noticed by a human or not at all.\n>\n> **And even when there was a sweep, it saw a Worker that BREAKS and was silent\n> about a Worker that LIES.** An exception is visible; a wrong answer is not. A\n> 200 carrying nonsense, a submission written to the wrong column, a bot check\n> that always passes — none of them throw, so none of them ever appeared\n> anywhere below. Every one of this project's recorded defects lied rather than\n> broke. No amount of ops tooling fixes that, and this runbook does not pretend\n> otherwise.\n>\n> What it does buy: **a verified nightly backup**, **a retention purge**, **a\n> documented rollback**, and **a named GDPR erasure command**.\n\n### One-time setup, per account and per site\n\n| # | Step | Scope |\n| --- | --- | --- |\n| 1 | `wrangler r2 bucket create <site>-ops` | per site |\n| 2 | `pnpm exec provision --phase 2` | per site |\n| 3 | Copy `ops-nightly.yml` into the site repo and set its repository variables | per site |\n\n**No Workers Paid plan is required.** It was, until 2026-09-01, because Workers\nLogpush needs it — that requirement went with the error sweep (below) and there\nis nothing here that needs a paid base plan.\n\n**The ops bucket stays.** It is an operator prerequisite on both tiers, like the\npreview D1 database: ADR-11 keeps media off a simple site, **not data**, and a\nsimple site has forms, therefore submissions, therefore something worth backing\nup. Provisioning verifies it and refuses, naming it, when it is absent; it never\ncreates it. The nightly D1 export writes there and that export was verified live\non 2026-09-01, in both directions.\n\n### Alerting, monitoring, error detection — READ THIS, THERE IS NONE\n\n**Nothing detects that a Worker threw an exception.** Not this package, not the\nnightly workflow, not Cloudflare, not anywhere else in this platform. If a\nWorker starts throwing on every contact-form submission at 02:00, **no email is\nsent, no job goes red, and no dashboard changes colour.** The site keeps serving\nits static pages, so nothing looks wrong from outside either.\n\n**Detection is a human noticing, or a reader telling you.** That is the whole\nmechanism. This section exists because an operator who found no alerting section\nwould reasonably assume they had not found it yet, and go on waiting for an\nalert that is never coming.\n\nNot degraded — **absent**. If you need to know whether a Worker is throwing\nright now, the only tool is `wrangler tail <worker>`, which is **live-only**: it\nshows what happens while you watch and retains nothing. There is no history to\ngo back through.\n\n#### Why, so nobody re-argues it from scratch\n\nThere is **no Cloudflare notification type for Worker script errors.** Not a\nmissing option — verified three ways on 2026-09-01: the Available Notifications\ncatalogue has no Workers entry at all, the alerting API's `alert_type` enum has\nno Workers value (the nearest is `pages_event_alert`, a different product), and\nWorkers Observability has no alerting of its own. Its documented outputs are\nOpenTelemetry export, Logpush, and Tail Workers.\n\nStory 6.4 therefore shipped a **scheduled error sweep** instead: Workers Logpush\ndelivering Trace Events into this ops bucket, read on a cadence by a job that\nfailed loudly on exceptions so GitHub would email the repo owner. It was built,\ntested, published in `0.4.0` — and **it could not be provisioned.** Logpush has\ntwo halves:\n\n| Half | Reachable? |\n| --- | --- |\n| Per-Worker flag — `\"logpush\": true` in `wrangler.jsonc` | **Yes**, deploys clean |\n| Account-level job — dataset, filter, R2 destination, cadence | **No. `wrangler logpush` does not exist**; `tail` is the only log command |\n\nWithout the second, the first moves no data. **The flag is a permission slip,\nnot a pipeline** — a setting that deploys green and delivers nothing. Three\nroutes were put to the human on 2026-09-01: sanction one narrow REST call; poll\nWorkers Observability instead; or make the Logpush job an operator prerequisite,\nverified rather than created, the way the ops bucket already is. **The human\nchose to drop the mechanism**, and story 6.11 removed it: the `error-sweep`\nbin, the workflow step, and `logpush: true` from provisioning.\n\n**It is reversible.** The mechanism was correct; only its provisioning was\nblocked. A later story can restore it by making the account-level Logpush job a\nverified operator prerequisite. Nothing here forecloses that, and none of the\nalternatives above has been ruled out — they were simply not chosen now.\n\n**One residue to clear by hand.** If a site was provisioned before this version,\nits `wrangler.jsonc` still carries `logpush: true`. Provisioning no longer writes\nthat key and does not remove it either — delete the line yourself. It is inert,\nbut an inert flag reads as a pipeline.\n\n#### And this was always true, sweep or no sweep\n\n**It detected a Worker that BREAKS. Nothing ever detected a Worker that LIES.**\nA 200 carrying nonsense, a submission written to the wrong column, a\n`siteverify` that always passes — none of them throw, so none of them would have\nappeared in a Trace Event either. Every one of this project's recorded defects\nlied rather than broke. Losing the sweep did not open that gap; it was never\ncovered.\n\n### The nightly D1 export\n\n```sh\npnpm exec d1-export --database <d1-name> --bucket <site>-ops\n```\n\nExports the remote database, uploads it, and **reads it back out of the bucket**\nbefore calling it done. That read-back is the point: `wrangler r2 object put`\nexiting 0 says a process finished, not that an object arrived, and certainly not\nthat the object is a database. This project has paid for that distinction three\ntimes.\n\nKeys are `d1-exports/<database>/<timestamp>.sql` and sort chronologically, so a\nlisting is a timeline and finding \"the one from before the thing that broke\" is\na scroll rather than a search.\n\n**What verification does not prove: that the export RESTORES.** It proves bytes\narrived and are shaped like a dump of this database. The restore below has never\nbeen run against a real export, and the first time will be a first.\n\n#### Restoring\n\n```sh\nwrangler r2 object get <site>-ops/d1-exports/<database>/<timestamp>.sql --file restore.sql\nwrangler d1 execute <database> --remote --file restore.sql\n```\n\n**The cost, stated rather than discovered:** `d1 execute` applies the dump on\ntop of what is already there. A dump containing `CREATE TABLE` fails against\nexisting tables, so a real restore means restoring into a **fresh database** and\nrepointing the binding, or dropping tables first — which is a destructive act on\nthe only copy. Decide which before you need it, not during. `wrangler d1\ntime-travel` is the gentler instrument for a recent mistake and does not involve\nthis bucket at all.\n\n### Rolling back a deploy\n\n```sh\nwrangler versions list                 # find the version to go back to\nwrangler rollback <version-id>         # roll production back to it\nwrangler deployments list              # confirm which version is live now\n```\n\n**A ROLLBACK DOES NOT ROLL BACK D1.** Code goes back; the schema and the data do\nnot. A migration applied by the deploy you are undoing is still applied, and a\nWorker rolled back to before that migration may be talking to a schema it does\nnot expect. This is the most dangerous gap in the whole baseline. If the deploy\nyou are undoing added a migration, rolling back the code is not enough and may\nbe worse than doing nothing.\n\n**Secrets and bindings do not roll back either.** They are per-Worker, not\nper-version. A secret rotated after the version you are returning to is still\nthe rotated one.\n\n**A PREVIEW WORKER CANNOT BE ROLLED BACK THIS WAY.** Story 6.2 uses\n`wrangler versions upload` for previews deliberately, so two pull requests\nneither cancel nor clobber each other and preview traffic never shifts — which\nmeans a preview Worker has exactly one *deployment*, its bootstrap, and\n`wrangler rollback` has nothing to act on. That is a property worth keeping, not\na bug to fix. Obvious once, expensive to rediscover at the wrong moment.\n\n**`wrangler rollback` AUTO-CONFIRMS IN A NON-INTERACTIVE CONTEXT, AND SHIFTS\n100 % OF TRAFFIC.** Observed live on 2026-09-01, not inferred: it printed\n*Using fallback value in non-interactive context: yes* and rolled production\nback. **A rollback run from a script, from CI, or from anything without a TTY\ngets no confirmation gate whatsoever** — the prompt you would rely on to catch a\nwrong version id is not there. Know that before you automate one, not after.\n\n### Two wrangler confirmation traps, in opposite directions\n\nBoth found by doing the work on 2026-09-01, both true regardless of anything\nelse in this runbook, and either one will bite whoever writes the first\nautomation script.\n\n| Command | Non-interactive default | What that costs you |\n| --- | --- | --- |\n| `wrangler rollback` | **yes** | production moves with no gate (above) |\n| `wrangler turnstile widget delete` | **no**, *and it exits 0* | a teardown script reports success having deleted nothing |\n\nThe second, verified deliberately against a second throwaway widget:\n\n```\nwrangler turnstile widget delete <sitekey>     # no -y\n  -> \"Using fallback value in non-interactive context: no\"\n  -> \"Deletion cancelled.\"\n  -> EXIT 0\n  -> the widget is still there\n```\n\n**That is this project's own rule found inside Wrangler, in a destructive\ncommand:** *an exit code is evidence about a process, not about the world.* Pass\n`-y` / `--skip-confirmation` for any scripted teardown, and never treat a zero\nexit as evidence that a resource is gone — **list it back.**\n\n### Erasing one person's data (GDPR, NFR-8)\n\n```sh\npnpm exec erase-subscriber --database <d1-name> --email <address> --remote\n```\n\nIt has a name now rather than a `node_modules` path, which is the point: the\nmoment someone needs it is not the moment to be working out where it lives. On a\nsite with an ESP, it requires `--esp <provider> --esp-handled`, and without that\nit reports **INCOMPLETE** and exits non-zero naming the system that still holds\ndata. That flag **attests**; it does not verify, and the tool says so.\n\n### NFR-5 note, for the human — and how it was settled\n\n**The finding, which outlives the mechanism:** with today's Cloudflare surface,\nNFR-5 and *push* alerting on Worker exceptions are **not both satisfiable**.\nThere is no native Workers alert to buy, and every push mechanism that exists\nroutes through a third party. That is a property of the platform, discovered by\ntrying, not a nuisance met while implementing.\n\n**The open decision about the Workers Paid plan is closed as moot.** It was open\nbecause Workers Logpush requires that plan, and the handoff carried it as a real\nrecurring per-site cost for whoever is paying. With the sweep withdrawn, nothing\nin this baseline needs it. Free-tier sites lose nothing they would have had.\n\n**What was actually given up:** the only path that existed from a Worker throwing\nto a person knowing, without a third-party observability SaaS. See the alerting\nsection above, which says plainly that there is now no such path at all.\n\n## What is *not* here\n\nThe monorepo's own release tooling. `verify-publishable` checks that this\nrepository's packages carry their build output before publishing; it means\nnothing in a site repo, so it is deliberately not shipped.\n","readmeFilename":"README.md"}