npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bigin-io/site-tools

v1.0.1

Published

Operator CLIs for a site repo: provisioning, media upload, the Pagefind build step, and the G5 QA gate (ADR-14)

Readme

@bigin-io/site-tools

The operator CLIs for a site repo (ADR-14): provisioning, media upload, and the Pagefind build step.

This package has THREE purposes now. Said out loud rather than discovered, and the count is going up.

  1. The operator CLIs — the commands a site repo runs: provision, upload-media, build-search, preflight-deploy, prepare-preview.
  2. The G5 quality gate — qa-gate, added by story 6.3, along with the axe-core and jsdom dependencies it needs.
  3. The ops baseline — d1-export, added by story 6.4, plus the ops-nightly.yml template it runs in. It had a second command, error-sweep; story 6.11 removed it on 2026-09-01 and the runbook says what that costs.

Running the gate is an operator command, and so is a nightly export, so both additions sit naturally beside the first purpose. But each is a purpose rather than a command. The gate brought a browser-less DOM and an accessibility engine into a package that previously talked only to Cloudflare, so a site repo installing this to run build-search also installs those. The ops baseline brought workflow templates, which had until now shipped only from site-worker — noted in ops-nightly.yml itself so a reader meeting the split is not left guessing.

This is named here for the same reason site-worker's three roles are named in its README (story 6.2). A package that acquires a purpose silently is how nobody can say what it is for, and a package whose purposes are written down can be split later on a decision rather than on an archaeology. Three purposes in two stories is a trend rather than an accident, and the obvious split — @bigin-io/site-qa, or an @bigin-io/site-ops beside it — is a decision somebody should take deliberately rather than inherit. Nothing here depends on that not happening.

This package exists because every one of these commands used to live in a private: true workspace package. They worked with the platform monorepo checked out and nowhere else — so story 3.3's acceptance line, "pipeline and editors use the same command", could not be true, because editors are not in that monorepo.

pnpm add -D @bigin-io/site-tools

The commands

| Command | When | Tier | | --- | --- | --- | | provision --phase 1 | at G4, before the build | both — the media half is content-only, the Turnstile widget is not (6.8) | | upload-media | whenever editorial media changes | content only | | build-search | every build, after the SSG build and before deploy | content only (a no-op on simple) | | provision --phase 2 | at G6, before the first deploy | both tiers | | preflight-deploy | every deploy, immediately before wrangler | both tiers | | prepare-preview | every preview deploy | both tiers | | qa-gate --stage static | every pull request, before the preview deploy | both tiers | | qa-gate --stage live | every pull request, after it | both tiers |

Run any of them with --help. Each takes --config <path> to your site.config, supports --dry-run and --json, and reports documented exit codes so CI can act on the result.

provision --phase 2 runs on a simple-tier site too: ADR-11 removes R2, collections and Pagefind from a simple site but keeps the forms/newsletter Worker, so it still needs a Worker, a D1 database and secrets.

The two deploy commands (story 6.2)

They exist so the deploy workflows stay thin. YAML is the one artefact in a site repo that nothing type-checks, nothing lints and no test runs, so every decision a deploy encodes that can live in a script does.

preflight-deploy refuses rather than repairs. It checks that provisioning has run (no __PLACEHOLDER__ left, vars.SITE_CONFIG emitted, and no secret name inside it), that the build produced HTML, and that a content-tier site carries a Pagefind index built from this output. Each refusal names the command that produces the missing thing, because the alternative is a Cloudflare API error several layers from the step that was skipped. It prints the paths of the keys at fault and never their values.

prepare-preview derives wrangler.preview.jsonc from the site's provisioned wrangler.jsonc, and appends the noindex rule to the built _headers. It changes the Worker name, the D1 database, and removes routes and triggers. The SITE_CONFIG slice is carried through byte for byte but for one field, which is what makes "a preview shares production's slice" a property rather than a promise: it is not a copy that could drift, it is the same bytes read from the same file at deploy time.

The one field is forms.turnstile.siteKey (story 6.10). A Turnstile key is bound to hostnames, so byte-identity is incoherent for it: every other value in the slice describes the site and travels to any host it is served from, while this one describes a binding between a site and a host — and a preview is by construction on a host production's widget does not know. Carrying production's key would guarantee a preview whose challenge cannot solve, which is not the form path a preview exists to exercise. The preview carries the preview widget's key instead. Recorded as the human's to reverse, since it touches NFR-6 and 6.2's AC6.

The alternative, a [env.preview] block in wrangler.jsonc, is ruled out by wrangler itself — vars and d1_databases are not inherited by named environments, so the block would have to carry a second copy of the whole slice. Its inputs come from the environment (PREVIEW_D1_DATABASE_ID and PREVIEW_TURNSTILE_SITEKEY, and optionally PREVIEW_WORKER_NAME and PREVIEW_D1_DATABASE_NAME) rather than from interpolated arguments, so a workflow never puts a repository value on a shell line. Both required ones are refused when absent rather than defaulted: a missing database id would mean a preview writing into the real subscriber list, and a missing site key would mean falling back to production's — a form on every preview that looks present and cannot be submitted.

Both refuse with exit 3 and change nothing. prepare-preview will not write a configuration that shares production's Worker name or its database id: the first would share the secrets, the second would write into the real subscriber list.

pagefind is an optional peer dependency

Install it only if your site has search:

pnpm add -D pagefind

ADR-11 defines the simple tier as having no Pagefind. Making it a hard dependency would have every simple-tier site download a search-index binary it will never run — installing the very thing the tier is defined by not having. build-search skips the simple tier entirely, so it never looks for it there.

On a content-tier site without it installed, build-search fails and names the install command. Its absence is a configuration fact, not a crash.

Credentials

provision and upload-media read CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID from the environment, never from flags, and scrub them from everything they print — including a failed subprocess's output.

build-search needs no credentials at all. It is a local, file-in/file-out build step, and a search index you could only build while holding a production API token would be a broken local build.

Command reference

Moved here from the private scripts package when ADR-14 extracted these CLIs. It was documentation for site operators living in a package no site repo can install — the same reachability problem, one level down, that produced the package itself.

provision-media is the exception in the list below: it is phase 1 on its own, kept for the pipeline, and has no bin entry because provision --phase 1 is the command an operator should reach for.

provision-media — phase-1 Cloudflare provisioning

Creates a site's R2 media stack: the bucket, the media.<domain> custom domain, and the Cache Rule that makes originals serve with cache headers. Runs inside sf-scaffold at G4, before any media upload, because the build and the G5 image/link checks need media resolving (docs/02-architecture.md § Pipeline). Phase 2 — Worker, D1, secrets, site DNS — is story 6.1; the Turnstile widget moved to phase 1 in story 6.8.

node node_modules/@bigin-io/site-tools/dist/bin/provision-media.js --config <path-to-site.config> [options]

| Option | Effect | | --- | --- | | --config <path> | the site config to read: .ts, .mts, .js, .mjs, or .json | | --dry-run | print the plan; change nothing | | --verify | run the read-only checks only; change nothing | | --probe-key <key> | an object that already exists, used by the cache-header and transform checks | | --json | machine-readable summary on stdout |

| Exit | Meaning | | --- | --- | | 0 | success, or nothing to do | | 1 | unexpected failure | | 2 | usage error | | 3 | preflight refused: bad config, wrong account, unmanaged zone | | 4 | verification failed |

It is idempotent: it reads current state, plans only the difference, and a second run makes no changes. Run it as often as you like.

A simple-tier site exits 0 having done nothing (ADR-11), so the pipeline can call this unconditionally without branching on tier.

Credentials

From the environment only — never flags, and never site.config (NFR-3):

export CLOUDFLARE_API_TOKEN=...
export CLOUDFLARE_ACCOUNT_ID=...

CLOUDFLARE_ACCOUNT_ID must match cloudflare.accountId in the config, or the run aborts before touching anything. Provisioning into the wrong account is the expensive mistake.

Token scopes. The starting set, to be narrowed once a real run confirms which are load-bearing (story 3.1, T7):

| Scope | Needed for | | --- | --- | | Account → Workers R2 Storage:Edit | creating the bucket, binding the domain | | Zone → Zone:Read | resolving cloudflare.zone.name to a zone id | | Zone → DNS:Edit | binding a custom domain writes a DNS record | | Zone → Cache Settings:Edit | the Cache Rule |

The token is scrubbed from every line this tool prints, including the output of a failed wrangler subprocess.

One-time prerequisites, per account and zone

These sit outside the pipeline (docs/02-architecture.md § Pipeline) and this script does not create them:

  1. R2 enabled on the Cloudflare account.
  2. The zone on Cloudflare. If the client's DNS is elsewhere, cloudflare.zone.managed: false and this script refuses — use the documented fallback (CNAME to workers.dev, media on a BigIn-owned zone) and point media.zone at that host.
  3. Image Transformations enabled for the zone: dashboard → Images → Transformations → select the zone → enable. The script verifies this end-to-end rather than trusting a settings flag, but it cannot switch it on.

What "serves with cache headers" actually rests on

Two halves, one constant:

  • This script upserts a Cache Rule on the zone, matched on the media host, with edge and browser TTL from MEDIA_CACHE_MAX_AGE_SECONDS. Browser TTL in override_origin mode is what stamps Cache-Control on the response. Cloudflare caches only certain file types on a custom domain by default, so without this rule the promise would hold for JPEGs and quietly break for PDFs.
  • Story 3.3 stamps the same value onto each object's metadata at upload, reading the same exported constant. Change it in one place.

The rule is upserted non-destructively: the cache-rules entrypoint ruleset is zone-wide and its API replaces the whole list, so the script reads the current rules, replaces only its own (matched on a stable description), and writes the merged list back. A client's other cache rules survive.

When the custom domain looks stuck

A newly bound R2 custom domain is not instant — DNS and certificate issuance take a few minutes. --verify will report the origin check failing until it is ready, which is expected, not a bug. Re-run --verify rather than re-running provisioning; the binding is already made and a second provision run is a no-op anyway.

If it is still failing after ~15 minutes, check in the dashboard that the domain shows as active and that its certificate has issued.

Undoing a run

wrangler r2 bucket domain remove <bucket> --domain <media host>
# remove the "ssg-site-factory: cache media originals" rule from the zone's
# cache-rules ruleset (dashboard → Caching → Cache Rules)
wrangler r2 bucket delete <bucket>     # only if the bucket is genuinely scratch

Deleting a bucket that holds a live site's media is not recoverable. Remove the domain binding first and confirm the site is not serving from it.

upload-media — editorial media into R2

Uploads originals to the site's R2 bucket, names them by content, and records them in an alt-text manifest. One command for both callers — an editor adding a photo and sf-scaffold seeding a site run exactly the same thing.

# an editor, one photo, described
pnpm exec upload-media --config ./site.config.ts \
  --prefix blog --alt "A warehouse robot at dusk" ./photos/hero.jpg

# the pipeline, a whole tree, refusing to proceed on a missing description
pnpm exec upload-media --config ./site.config.ts \
  --prefix blog --require-alt ./photos

| Option | Effect | | --- | --- | | --prefix <path> | key prefix, e.g. blog | | --alt <text> | alt text to record; single file only | | --require-alt | fail if any entry has no alt text | | --manifest <path> | manifest location (default media/manifest.json) | | --dry-run | show what would happen; write nothing, manifest included | | --json | machine-readable summary |

What the key convention guarantees

Keys are <prefix>/<slug>-<8 hex of sha256>.<ext> — derived from the file's name and its bytes, never from the directory it happened to sit in. So:

  • The same image gets the same key whether an editor or CI uploads it, on Windows or Linux. That is what makes "one command for both callers" true in practice rather than only in the flags.
  • Editing an image produces a new key. This is the point: 3.1 serves media with immutable and a one-year TTL, which is only safe if a URL's bytes never change. An overwritten hero.jpg would be served stale from the edge for a year with no purge path from here.
  • Re-running is free. An object already being served is skipped.

The manifest

Default media/manifest.json in the site repo, committed like any other gate artifact. It records key, source name, sha256, size, content type, alt text and upload time, sorted by key so a re-run makes no spurious diff.

It does not satisfy the contract's alt-text rule. Every content reference still needs its own alt in frontmatter — createImageRefSchema enforces that at parse time (NFR-2). The manifest is where sf-content finds the words to put there, and where a QA pass can see which uploads still have none. The file says so about itself, in case it is ever read out of context.

Re-running never overwrites human-written alt text with a blank.

What it refuses, and why

  • Anything under public/. That imagery is owned by code and ships through the build-time image pipeline (ADR-12). Uploading it would create a second, diverging copy of the same asset — this is how ADR-4's ownership boundary erodes, one convenient upload at a time.
  • A simple-tier site. It has no R2 media stack at all (ADR-11); the image belongs in public/. Note this differs from provision-media, which merely skips: that one is called unconditionally by the pipeline, while nobody runs upload-media by accident.
  • SVG. An SVG can carry script and would be served from media.<domain> — inside the client's own domain. Vector art is repo-owned anyway, so public/ is both the safer and the already-correct home.
  • Fonts, HTML, and unknown types. Template assets belong to the build pipeline; an unrecognised type is refused by name rather than uploaded under a guessed content type, which would otherwise be served that way forever behind an immutable cache.

A directory walk skips unsupported files and reports the count, rather than failing the whole run over a .DS_Store. A file named explicitly is refused, because naming it was a decision.

Removing something uploaded by mistake

wrangler r2 object delete <bucket>/<key>

Then delete its entry from the manifest. Remember that content may already reference the URL — grep content/ before removing anything.

build-search — the Pagefind index

Builds the search index for a site that has already been rendered. It sits in the middle of the chain the architecture specifies, and the order is not optional — it indexes rendered HTML, so the SSG build has to have finished:

nuxt generate | next build   ->   build-search   ->   wrangler deploy
pnpm exec build-search --config ./site.config.ts

| Option | Effect | | --- | --- | | --dist <path> | built site directory (default .output/public for Nuxt, out for Next) | | --dry-run | report what would change; write nothing | | --json | machine-readable summary |

No Cloudflare credentials are read or needed. This is a local build step, and a search index you could only build while holding a production API token would be a broken local build.

A simple-tier site exits 0 having done nothing — it has no Pagefind at all (ADR-11) — so the build chain can call this unconditionally.

What excludeRoutes guarantees

search.excludeRoutes holds logical routes. One entry excludes that route under every configured locale. /privacy excludes /en/privacy and /vi/privacy, not merely a bare /privacy.

This matters more than it looks. FR-S8 makes routing locale-prefixed from launch, so on a real site the literal reading matches nothing — and every page the operator asked to hide stays in the index. The failure modes are asymmetric: over-excluding hides a page from search, under-excluding publishes a page someone chose to conceal.

Cross-lane note. Any other code that resolves excludeRoutes — a renderer's own search work in particular — must expand it the same way, or the two renderers will disagree about which pages are public and NFR-7 parity stops being cosmetic.

Two further guarantees:

  • A route that matches no built page fails the build, naming it. A typo'd exclusion that quietly matches nothing is the same leak arriving by another road, and nobody notices until the page turns up in a search engine.
  • The built index is read back and checked. The step does not assume its own flags worked; it opens Pagefind's fragments and asserts the excluded URLs are absent. If the index cannot be read, that is reported as a failure — "could not check" must never look like "checked and it is absent".

Every page must declare its language

build-search refuses a multi-locale build in which any indexed page has no <html lang>, and warns on a single-locale one.

Pagefind splits its index by the language a page declares. Without the attribute every page lands in its unknown index, so on a multilingual site a Vietnamese reader's search returns English results — silently, with no error anywhere. It is also WCAG 2.1 3.1.1, a Level A criterion.

The single-locale case only warns because a site whose pages all land in one unknown index still searches correctly; failing that build would punish it for a defect it does not have.

The check lives here rather than in a renderer because this step reads built HTML: a fix in one renderer protects that renderer, a check here protects the property for every renderer, including ones that do not exist yet.

It writes to build output

Pagefind has no route-level exclusion: --glob takes one pattern with no negation, and --exclude-selectors excludes elements rather than pages. So before indexing, this step adds data-pagefind-ignore="all" to the <body> of each excluded page. The attribute is inert when served and makes the exclusion visible in the shipped HTML.

It only ever adds that attribute — no other byte of the document changes — and it is idempotent, so a second run leaves the file byte-identical.

Confirming a page really is absent

The step already does this and fails if it is not, but to check by hand:

node -e "const fs=require('fs'),z=require('zlib'),p=require('path');
const d=process.argv[1]+'/pagefind/fragment';
for(const f of fs.readdirSync(d)){
  const t=z.gunzipSync(fs.readFileSync(p.join(d,f))).toString();
  console.log(JSON.parse(t.slice(t.indexOf('{'))).url);
}" ./out

Every indexed URL is printed. A page you excluded must not appear.

provision — the two phases

Provisions a site's Cloudflare resources. Two phases, because the two halves are needed at different gates:

# G4, before the build: Turnstile widget (both tiers), then
#                        R2 bucket, media.<domain>, cache rule (content tier)
pnpm exec provision --config ./site.config.ts --phase 1

# G6, before deploy: D1 database + migrations, Worker secrets
pnpm exec provision --config ./site.config.ts --phase 2

Either phase is idempotent. Re-run them freely: a second run reports every resource as already-present and leaves both wrangler.jsonc and the record byte-identical.

The tier asymmetry — the easiest thing here to get wrong

| | simple tier | content tier | | --- | --- | --- | | Phase 1 — Turnstile widget | runs | runs | | Phase 1 — R2, media., cache rule | skipped, with the reason | runs | | Phase 2 | runs | runs |

ADR-11 takes R2, collections and Pagefind away from a simple site — but it keeps the forms/newsletter Worker. So a simple site has no bucket and no search index, and still needs a widget, a Worker, a D1 database and secrets.

Phase 1 is no longer skipped outright on a simple site — story 6.8 moved the Turnstile widget into it, and forms are on every tier. Only the media half is tier-gated now, and a run reports the skipped half with its reason rather than falling silent: a green run that says "nothing to do" while it provisioned a widget is a lie, and this table used to invite one.

Environment

| Variable | Needed by | | --- | --- | | CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID | both phases | | EMAIL_API_KEY | phase 2 | | ESP_API_KEY | phase 2, only when the newsletter relays to an ESP (ADR-6) |

TURNSTILE_SECRET_KEY is not in this table since story 6.8: phase 2 reads it back from the provisioned widget instead of asking you to carry it.

Every missing variable is reported at once, before anything is provisioned — finding the second one after the first has already created resources is how a site ends up half-built.

Secret values are read from the environment and handed to wrangler on stdin. They never reach wrangler.jsonc, the provisioning record, a log, or any file in the site repo — and a secret put that fails has its value scrubbed from the error, since a failure is exactly when a value tends to get echoed back.

What lands in wrangler.jsonc

Phase 2 writes the provisioned state: the Worker name, the ASSETS binding, the DB binding with the D1 database_id, the MEDIA bucket on the content tier, the custom-domain route, and triggers.crons.

triggers.crons comes from dataProtection.purgeSchedule in site.config. Never edit it by hand. The schedule has two homes and a Worker cannot read its own trigger, so nothing at runtime can tell you they have drifted apart; change site.config and re-run phase 2.

Edits are surgical. Any key this tool does not own — compatibility_date, vars, observability, routes you added, and comments — survives untouched. It will not overwrite an assets.directory you chose.

It fills in a config; it does not create one

The site's wrangler.jsonc comes from the template in @bigin-io/site-worker:

cp node_modules/@bigin-io/site-worker/wrangler.template.jsonc ./wrangler.jsonc

Phase 2 refuses to run if it is missing, rather than writing one from scratch. A generated-from-nothing config would have no main and no compatibility_date — undeployable, but written confidently and reported as a success, which is the worst combination available.

Reading the record

.provisioning/record.json, committed on purpose: it is the answer to "what exists for this site?" without opening the Cloudflare dashboard. Per resource it records the kind, name, resolved id, whether the last run created it or found it already-present, which phase wrote it, and when.

It holds no secrets — only the names of the secrets that are set. A run that changes nothing leaves it byte-identical, so a diff always means something actually happened.

Turnstile — provisioned in phase 1, on both tiers (story 6.8)

Phase 1 creates the site's Turnstile widget, or verifies the one that is already there. On both tiers: ADR-11 takes R2, collections and Pagefind away from a simple site but keeps the forms Worker, so every site needs a widget.

That is why the tier table above says partly skipped rather than skipped. A simple-tier provision --phase 1 does real work now, and the run says which half it skipped and why:

created         Turnstile widget: northwind.example
skipped         media: tier is "simple": no R2 media stack to provision (ADR-11)
record:         .provisioning/record.json

Through wrangler turnstile widget, not the REST API. A REST sanction was granted for this and then handed back unused: wrangler 4.127.1 has the command, so CLAUDE.md's "Wrangler for anything Cloudflare" applies with no exception. It is [alpha], which is why wrangler is pinned to an exact version here and why test/turnstile-surface.test.ts fails loudly if a bump moves the subcommands, flags or output this package parses.

The first run of a new site fails on purpose

The schema requires forms.turnstile.siteKey from intake, and the widget does not exist until phase 1 runs. So write the documented placeholder at intake:

forms: { turnstile: { siteKey: '0x4AAAAAAA__PROVISIONING_FILLS_THIS__' } }

Phase 1 then creates the widget and stops, printing the real key and telling you to paste it in. Re-run and it verifies. That is one deliberate non-zero exit on a site's first provisioning, and it buys something: provisioning never edits site.config. The config is an input to the build (NFR-6), not an output of it, and a tool that rewrote it would make every build depend on whether provisioning had run.

Two different messages, because they are two different situations:

| What phase 1 found | What it means | | --- | --- | | the placeholder | expected on a first run. Paste the key it printed, re-run | | 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 |

The placeholder cannot be shipped. If it somehow survives to a build, the QA gate's bundle guard fails on it (story 6.3) — because an operator who never re-runs would otherwise deploy a site whose every form silently rejects every visitor.

You no longer supply TURNSTILE_SECRET_KEY

Phase 2 reads it back from Cloudflare with wrangler turnstile widget get at the moment it hands it to wrangler secret put. It is never displayed, never written to a file, and never reaches the provisioning record. One fewer credential for you to carry between two gates.

EMAIL_API_KEY and (for an ESP newsletter) ESP_API_KEY still come from the environment as before.

The API token needs the Turnstile permission

The same CLOUDFLARE_API_TOKEN provisions R2, D1 and now Turnstile — but Turnstile is its own permission group, and a token that predates this has no access to it. The permission is:

challenge-widgets.write   —   "See and change Turnstile widgets"

Add it to the token, or use one that has it.

What the absence actually looks like, corrected against the live API on 2026-09-01. Story 6.8 predicted a 403 that reads like a broken endpoint, and put the scope name here so nobody would go hunting one. It is better than that, and the record is worth correcting rather than leaving flattering: wrangler refuses in a pre-flight warning that names the missing scope and tells you to re-run wrangler login — it never reaches the API, so there is no 403 to misread. The entry above is still the right entry; the symptom it guards against is legible, not cryptic. That matters here specifically: a runbook describing the wrong symptom sends someone hunting the wrong cause, and the right cause is one wrangler login away.

A site has TWO widgets, and the second one is why production is safe

Phase 1 provisions two Turnstile widgets on both tiers:

| Widget | Domain list | What uses it | | --- | --- | --- | | production | the site's own zone | the live site | | preview | workers.dev | preview deploys, and nothing else |

The one-widget option was considered and refused on 2026-09-01. Closing 6.3's untested live-key path needed a widget that works on a preview's *.workers.dev hostname, and the cheap answer was to add workers.dev to the client's production widget. Turnstile's domain list covers subdomains, so that admits every *.workers.dev host on the platform — any other tenant's Worker could mint tokens that validate against that client's sitekey. Cross- tenant exposure on a live client's bot protection, in exchange for a test.

The same entry on a preview-only widget costs nothing, because that widget guards nothing: a throwaway Worker, its own database, no mail credentials, no production route. The difference is entirely what the widget stands in front of, which is worth remembering the next time one domain list looks like it could hold both hostnames.

Provisioning has no way to modify an existing widget — list, get and create, and nothing that writes a domain list — so production's list cannot be touched by this package even by mistake. There is a test asserting it, because the whole basis of the choice above is that production stays as it is.

Record the preview widget's site key as the PREVIEW_TURNSTILE_SITEKEY repository variable. It is public — served in the HTML of every preview page — so a variable, not a secret, exactly like PREVIEW_D1_DATABASE_ID. prepare-preview refuses to write a preview config without it rather than falling back to production's key, which would render a challenge that cannot solve.

If workers.dev is already on a production widget — earlier versions of the preview-deploy template told operators to put it there — remove it from that widget's domain list in the dashboard and re-run phase 1. Removing it cannot break production: the site is served from its own hostname, which stays.

The widget's mode, and who should be choosing it

The widget is created with --mode managed, which lets Cloudflare pick the lightest challenge that works. That is a default chosen in code, and it is a product and accessibility decision rather than a technical one: an interactive challenge is a barrier some users cannot pass, and a client may need a different mode. site.config has no field for it, deliberately — adding one is a contract change. Flagged for the human in story 6.8.

The QA gate — qa-gate (story 6.3, FR-P6)

The G5 gate. It runs on every pull request and a red result blocks the merge, which is what "before deploy is offered" means in practice: production deploys from main, so the place to stop bad output is in front of the merge, not in front of wrangler deploy where a human can be tempted past it.

# before the preview deploy — needs only dist, no URL, no credentials
pnpm exec qa-gate --config site.config.ts --stage static --out qa-report.txt

# after it
pnpm exec qa-gate --config site.config.ts --stage live \
  --url "$PREVIEW_URL" --database my-site-preview --comment qa-comment.md

Exit 0 passed, 1 failed, 2 usage. Nothing else.

Why it is split in two

| Half | Needs | Runs | | --- | --- | --- | | static — bundle guard, link check, axe, tier surfaces | dist | before the preview deploy | | live — Lighthouse, the Turnstile round-trip | a URL | after it |

A build that fails the static half never gets a preview deployed. For the bundle guard that matters literally rather than economically: deploying the artefact the guard exists to stop is the one deploy that must not happen.

What it checks

| Check | What fails it | | --- | --- | | links | an internal href that resolves to nothing the build emitted; on a multi-locale site, one resolving to an unprefixed route | | a11y | a WCAG 2.1 AA violation (NFR-2) from axe, over documentElement, on every emitted page in every locale | | 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 | | forbidden-hosts | a font or icon CDN reached from the built output — FORBIDDEN_HOSTS, imported from @bigin-io/renderer-core/policy, never copied | | search-index | a content-tier build with no pagefind/pagefind.js | | collection-routes | a declared collection that emitted no entry route | | 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 | | 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 | | 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 |

Two things are warnings and never fail the gate.

  • An unreachable external link. A third party's 503 is not this site's defect, and a gate that blocks on one is a gate people learn to override.
  • An over-budget public/ — since story 6.14; see the next section for the reversal and its reasons.

A warning is not a quiet pass. A check that found something and did not block reports WARNED, not PASSED, its lines print WARN rather than warn, the closing line of the run names it — Gate passed, WITH 1 WARNING(S) THAT DID NOT BLOCK IT: public-images — and on the pull-request comment it gets its own ⚠️ section above the collapsed details, below any failures. That is deliberate and it is story 6.14's AC2: a warning nobody reads is the failure mode that ruling accepts responsibility for.

public-images, and what story 6.12 changed about it

Until story 6.12 this was the second warning, and it was broken in a way worth recording. Its constant was named PUBLIC_IMAGE_BUDGET_BYTES, its doc comment argued about ADR-4, and its report sentence read public/ images total N bytes — while the number came from scanning dist. It reported a measured fact that was never measured. dist is not even a proxy for public/: ADR-12 emits AVIF and WebP variants for every source raster and the originals never ship, so on the parity fixtures the built output is about 4.5x the files and 9x the bytes of the source directory.

Three things changed, on the human's ruling of 2026-09-03 — make it match its name:

  • It measures source public/, resolved from the --config path's directory. Not process.cwd(), and not dist: --dist can point anywhere, so the two need not be related, and a path convention that only holds when the gate runs from the site root is a check that silently measures nothing.
  • It is its own check, on its own row, so a reader scanning the report sees it at all. (6.12 also made it fail. Story 6.14 reversed that half — immediately below.)
  • It is content tier only, and that falls out of ADR-11 rather than taste. A simple-tier site has no R2 — all of its imagery lives in public/ and ships through the ADR-12 pipeline — so there is nowhere else for editorial media to be and no ADR-4 boundary there to cross. It is SKIPPED with that reason, never a lax pass. The consequence is that a simple-tier site now has no image check at all. That is deliberate.

Story 6.14 — it warns, and it does not block

An over-budget public/ reports a finding and the gate exits 0. 6.12 made it red; 6.14 reversed that and left everything else 6.12 changed in place. Three reasons, so this can be argued with rather than merely appealed to:

  1. Story 6.3 made it a warning deliberately — a warning with a number attached, never a failure — on the argument that a warning threshold genuinely was the site's to set. 6.12 changed what is measured and where the number lives, both correctly, and never argued severity: its AC6 asked only that the over-threshold fixture go red, which was written meaning reports a finding and read — reasonably — as the gate is red. The ambiguity was the coordinator's, and 6.12's change log now records it.
  2. The constant says of itself that it is not measured from a real client site. A hard gate on an admittedly-underived number is how gates get turned off — the mistake story 6.13 had just finished undoing from the other side, where a permanent false positive had trained readers to skip a row.
  3. 6.12 moved two things at once: 2 MB to 512 KB, four times tighter, and warn to fail. Fired on the first real client site, nobody could have said which half was wrong.

The threshold did not move back. 512 KB stays with its derivation; the tightening is the part 6.12 got right. No config field either — story 6.9 removed site.config.budgets by human decision and this does not reopen it, in either direction.

What would justify blocking is written down as a condition, not a preference (6.14's AC6, beside the constant in src/qa/public-images.ts): three real content-tier client public/ directories measured, all three under the threshold with ADR-4's set complete, and at least one recorded true positive — after which the threshold and the severity move in separate changes. Points one to three are measurements, not judgements.

The threshold is 512 KB, and the derivation lives beside the constant in src/qa/public-images.ts: favicons, a logo, and an OG fallback card, because everything editorial is R2's by ADR-4. The two values before it — a Zod default of 1500 KB and a round binary 2 MB — traced to nothing, which is how a 37 % loosening went unnoticed for a week. It is derived from what ADR-4 says belongs in public/, not measured from a real client site; none exists in this repository.

Its three green outcomes are not interchangeable. A missing public/, a public/ with no images in it, and a public/ measured and under budget each print a different sentence, because a zero meaning no directory and a zero meaning no images are different facts — the same reason a skip carries a reason rather than reading as a pass.

What was lost, stated rather than absorbed

Dropping the dist scan removed the only measurement of total shipped image weight this repository had, and nothing replaces it. NFR-1 does not: LCP, CLS and JS weight do not add up to image payload, and _headers only pins the variant directory as immutable. If shipped image weight is wanted, it is a separate check with its own name and its own number — it is deliberately not smuggled back in under a name that says public/. This is the discipline story 6.9 applied to publicImagesKb's loosening, applied to its own removal.

What it CANNOT see

This table is the point of the gate, not an appendix to it. Both document-level accessibility failures this platform has found came from correcting the scope of a check that was already running and passing — never from adding a check. So every check is asked what it cannot see, the answers live here, and the gate prints them beside its own results on every run, passing or failing.

| Check | Cannot see | | --- | --- | | link check | a link that resolves but is wrong — the right shape to the wrong page | | 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 | | axe (any) | judgement — contrast inside an image, a label that is present but meaningless, a focus order that is valid but absurd | | 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 | | bundle guard | a forbidden host reached at runtime from a string the build never resolved | | 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 | | search-index | whether the index's content is right — only that it was emitted | | collection-routes | a collection that emitted some entries and silently dropped others | | 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) | | all of them | anything only present on production: they run against a preview on *.workers.dev |

The Turnstile row needs its own paragraph, because half of it changed. Since story 6.10 a preview has its own real widget, so the preview's HTML carries a real site key that a real browser really solves on workers.dev — the provisioning path and the site-key/hostname binding are exercised for real. What is still not: siteverify against a real secret. A Turnstile token can only be minted by a browser solving the challenge, and this gate has no browser, so it posts a fixed dummy token that only the always-passes test secret accepts. Pointing the preview Worker at the preview widget's real secret would turn the accepted case into a 403 and take the stored-row check with it — trading a proven property (a submission is not lost) for a rehearsal. And the negative direction buys nothing back: the Worker collapses siteverify's invalid-input-secret and invalid-input-response into one turnstile_failed, so a bogus token fails identically against a real secret and a garbage one. The production widget's own binding is a different value again, and the first real submission on the client's domain is still the first test of it.

Contrast is deliberately absent, and that is not a hole. A headless DOM does no layout, so axe cannot measure color-contrast — it is disabled, and the disablement is reported. What covers it is site-contract, which computes WCAG contrast at parse time: an AA failure is a config parse error before a page is ever built. The gate says this rather than appearing to cover it, because a gate that looks like it checks contrast is how a real contrast regression ships.

How to read a red run

The report distinguishes five statuses, and only one of them is red:

| Status | Meaning | | --- | --- | | PASSED | it ran and found nothing | | FAILED | it ran and found something — this is what blocks the merge | | SKIPPED | the site's tier gives it no surface (ADR-11), with the reason printed | | BLOCKED | it cannot be written yet, with the dependency named | | DEFERRED | it belongs to the other half of the run |

Read it in this order:

  1. Look at the status column first, not the failure list. A SKIPPED or BLOCKED row is a check that did not run. It is not a pass, and the report never prints one as though it were.
  2. Each FAIL line names the thing that caused it — the page and the href, the file and the rule, the URL and the measured number. If a line does not tell you where to look, that is a defect in the gate; report it.
  3. warn lines never fail the run. An external link, an axe incomplete ("axe could not determine" is not "the page is wrong"), an image budget, and every passing Lighthouse assertion appear here — the last so a budget met by 5 ms is visible before it becomes someone else's failure.
  4. blind lines are what that check could not have caught. A green run with its blind spots printed under it is an honest green.

Two failures read differently from the rest:

  • lighthouse FAILED with "not measured" means Lighthouse did not run — a missing binary, a preview that never loaded. That is a failure and not a skip on purpose: "we could not check" and "it is fine" are different facts, and only one of them should let a merge through.

What cannot be turned off

No check has a switch. A check is skipped only because the site's tier gives it no surface to run against, that reason is printed, and a site.config that tries to declare qa.skipSomething is refused with a red gate rather than ignored with a green one. A gate a site can turn off is a gate that will be turned off on the site that needs it.

forbidden-hosts reads one list, and does not own it

The forbidden-host check imports FORBIDDEN_HOSTS from @bigin-io/renderer-core/policy. It never holds a copy, and it waited for that subpath to exist rather than shipping with a stopgap list — two lists drift, and the one that drifts is the one nobody is looking at, so a font CDN added to one and not the other would ship to production past a green gate.

Adding a host in that one place arms three checks at once: renderer-core's guard over its own source, the Nuxt bundle-build guard over the bytes a real nuxt generate wrote, and this gate over a site's dist.

The rule is zero external requests. A third-party origin is a DNS lookup, a TLS handshake and a cold connection on the critical path (NFR-1), and remote font loading has GDPR case law against it. Self-host through the build instead.

What it cannot see is in the table above and printed on every run: a hostname the build never resolved into one string — assembled at runtime, or read from configuration — is invisible to a scan of the bytes.

The cost, stated

Three Lighthouse runs per URL, serially. On the two URLs the gate measures that is roughly 1.5–2.5 minutes of wall-clock per pull request. The median of three rather than a single run is what stops the gate being flaky, and a flaky gate is one people re-run until it is green — which is a gate that blocks nothing. Every individual run's value is printed beside the median, so a median of 1.9 s over runs of 1.2, 1.9 and 3.4 s is visible as the warning it is.

The ops runbook (story 6.4, amended by 6.11)

The floor under a deployed site: what does not watch it, what backs it up, how to undo a deploy, and how to erase one person's data.

What this baseline does and does not buy, before anything else.

IT DETECTS NOTHING. There is no error detection at all — see Alerting, monitoring, error detection below, which is the first section for that reason. A Worker that throws is noticed by a human or not at all.

And even when there was a sweep, it saw a Worker that BREAKS and was silent about a Worker that LIES. An exception is visible; a wrong answer is not. A 200 carrying nonsense, a submission written to the wrong column, a bot check that always passes — none of them throw, so none of them ever appeared anywhere below. Every one of this project's recorded defects lied rather than broke. No amount of ops tooling fixes that, and this runbook does not pretend otherwise.

What it does buy: a verified nightly backup, a retention purge, a documented rollback, and a named GDPR erasure command.

One-time setup, per account and per site

| # | Step | Scope | | --- | --- | --- | | 1 | wrangler r2 bucket create <site>-ops | per site | | 2 | pnpm exec provision --phase 2 | per site | | 3 | Copy ops-nightly.yml into the site repo and set its repository variables | per site |

No Workers Paid plan is required. It was, until 2026-09-01, because Workers Logpush needs it — that requirement went with the error sweep (below) and there is nothing here that needs a paid base plan.

The ops bucket stays. It is an operator prerequisite on both tiers, like the preview D1 database: ADR-11 keeps media off a simple site, not data, and a simple site has forms, therefore submissions, therefore something worth backing up. Provisioning verifies it and refuses, naming it, when it is absent; it never creates it. The nightly D1 export writes there and that export was verified live on 2026-09-01, in both directions.

Alerting, monitoring, error detection — READ THIS, THERE IS NONE

Nothing detects that a Worker threw an exception. Not this package, not the nightly workflow, not Cloudflare, not anywhere else in this platform. If a Worker starts throwing on every contact-form submission at 02:00, no email is sent, no job goes red, and no dashboard changes colour. The site keeps serving its static pages, so nothing looks wrong from outside either.

Detection is a human noticing, or a reader telling you. That is the whole mechanism. This section exists because an operator who found no alerting section would reasonably assume they had not found it yet, and go on waiting for an alert that is never coming.

Not degraded — absent. If you need to know whether a Worker is throwing right now, the only tool is wrangler tail <worker>, which is live-only: it shows what happens while you watch and retains nothing. There is no history to go back through.

Why, so nobody re-argues it from scratch

There is no Cloudflare notification type for Worker script errors. Not a missing option — verified three ways on 2026-09-01: the Available Notifications catalogue has no Workers entry at all, the alerting API's alert_type enum has no Workers value (the nearest is pages_event_alert, a different product), and Workers Observability has no alerting of its own. Its documented outputs are OpenTelemetry export, Logpush, and Tail Workers.

Story 6.4 therefore shipped a scheduled error sweep instead: Workers Logpush delivering Trace Events into this ops bucket, read on a cadence by a job that failed loudly on exceptions so GitHub would email the repo owner. It was built, tested, published in 0.4.0 — and it could not be provisioned. Logpush has two halves:

| Half | Reachable? | | --- | --- | | Per-Worker flag — "logpush": true in wrangler.jsonc | Yes, deploys clean | | Account-level job — dataset, filter, R2 destination, cadence | No. wrangler logpush does not exist; tail is the only log command |

Without the second, the first moves no data. The flag is a permission slip, not a pipeline — a setting that deploys green and delivers nothing. Three routes were put to the human on 2026-09-01: sanction one narrow REST call; poll Workers Observability instead; or make the Logpush job an operator prerequisite, verified rather than created, the way the ops bucket already is. The human chose to drop the mechanism, and story 6.11 removed it: the error-sweep bin, the workflow step, and logpush: true from provisioning.

It is reversible. The mechanism was correct; only its provisioning was blocked. A later story can restore it by making the account-level Logpush job a verified operator prerequisite. Nothing here forecloses that, and none of the alternatives above has been ruled out — they were simply not chosen now.

One residue to clear by hand. If a site was provisioned before this version, its wrangler.jsonc still carries logpush: true. Provisioning no longer writes that key and does not remove it either — delete the line yourself. It is inert, but an inert flag reads as a pipeline.

And this was always true, sweep or no sweep

It detected a Worker that BREAKS. Nothing ever detected a Worker that LIES. A 200 carrying nonsense, a submission written to the wrong column, a siteverify that always passes — none of them throw, so none of them would have appeared in a Trace Event either. Every one of this project's recorded defects lied rather than broke. Losing the sweep did not open that gap; it was never covered.

The nightly D1 export

pnpm exec d1-export --database <d1-name> --bucket <site>-ops

Exports the remote database, uploads it, and reads it back out of the bucket before calling it done. That read-back is the point: wrangler r2 object put exiting 0 says a process finished, not that an object arrived, and certainly not that the object is a database. This project has paid for that distinction three times.

Keys are d1-exports/<database>/<timestamp>.sql and sort chronologically, so a listing is a timeline and finding "the one from before the thing that broke" is a scroll rather than a search.

What verification does not prove: that the export RESTORES. It proves bytes arrived and are shaped like a dump of this database. The restore below has never been run against a real export, and the first time will be a first.

Restoring

wrangler r2 object get <site>-ops/d1-exports/<database>/<timestamp>.sql --file restore.sql
wrangler d1 execute <database> --remote --file restore.sql

The cost, stated rather than discovered: d1 execute applies the dump on top of what is already there. A dump containing CREATE TABLE fails against existing tables, so a real restore means restoring into a fresh database and repointing the binding, or dropping tables first — which is a destructive act on the only copy. Decide which before you need it, not during. wrangler d1 time-travel is the gentler instrument for a recent mistake and does not involve this bucket at all.

Rolling back a deploy

wrangler versions list                 # find the version to go back to
wrangler rollback <version-id>         # roll production back to it
wrangler deployments list              # confirm which version is live now

A ROLLBACK DOES NOT ROLL BACK D1. Code goes back; the schema and the data do not. A migration applied by the deploy you are undoing is still applied, and a Worker rolled back to before that migration may be talking to a schema it does not expect. This is the most dangerous gap in the whole baseline. If the deploy you are undoing added a migration, rolling back the code is not enough and may be worse than doing nothing.

Secrets and bindings do not roll back either. They are per-Worker, not per-version. A secret rotated after the version you are returning to is still the rotated one.

A PREVIEW WORKER CANNOT BE ROLLED BACK THIS WAY. Story 6.2 uses wrangler versions upload for previews deliberately, so two pull requests neither cancel nor clobber each other and preview traffic never shifts — which means a preview Worker has exactly one deployment, its bootstrap, and wrangler rollback has nothing to act on. That is a property worth keeping, not a bug to fix. Obvious once, expensive to rediscover at the wrong moment.

wrangler rollback AUTO-CONFIRMS IN A NON-INTERACTIVE CONTEXT, AND SHIFTS 100 % OF TRAFFIC. Observed live on 2026-09-01, not inferred: it printed Using fallback value in non-interactive context: yes and rolled production back. A rollback run from a script, from CI, or from anything without a TTY gets no confirmation gate whatsoever — the prompt you would rely on to catch a wrong version id is not there. Know that before you automate one, not after.

Two wrangler confirmation traps, in opposite directions

Both found by doing the work on 2026-09-01, both true regardless of anything else in this runbook, and either one will bite whoever writes the first automation script.

| Command | Non-interactive default | What that costs you | | --- | --- | --- | | wrangler rollback | yes | production moves with no gate (above) | | wrangler turnstile widget delete | no, and it exits 0 | a teardown script reports success having deleted nothing |

The second, verified deliberately against a second throwaway widget:

wrangler turnstile widget delete <sitekey>     # no -y
  -> "Using fallback value in non-interactive context: no"
  -> "Deletion cancelled."
  -> EXIT 0
  -> the widget is still there

That is this project's own rule found inside Wrangler, in a destructive command: an exit code is evidence about a process, not about the world. Pass -y / --skip-confirmation for any scripted teardown, and never treat a zero exit as evidence that a resource is gone — list it back.

Erasing one person's data (GDPR, NFR-8)

pnpm exec erase-subscriber --database <d1-name> --email <address> --remote

It has a name now rather than a node_modules path, which is the point: the moment someone needs it is not the moment to be working out where it lives. On a site with an ESP, it requires --esp <provider> --esp-handled, and without that it reports INCOMPLETE and exits non-zero naming the system that still holds data. That flag attests; it does not verify, and the tool says so.

NFR-5 note, for the human — and how it was settled

The finding, which outlives the mechanism: with today's Cloudflare surface, NFR-5 and push alerting on Worker exceptions are not both satisfiable. There is no native Workers alert to buy, and every push mechanism that exists routes through a third party. That is a property of the platform, discovered by trying, not a nuisance met while implementing.

The open decision about the Workers Paid plan is closed as moot. It was open because Workers Logpush requires that plan, and the handoff carried it as a real recurring per-site cost for whoever is paying. With the sweep withdrawn, nothing in this baseline needs it. Free-tier sites lose nothing they would have had.

What was actually given up: the only path that existed from a Worker throwing to a person knowing, without a third-party observability SaaS. See the alerting section above, which says plainly that there is now no such path at all.

What is not here

The monorepo's own release tooling. verify-publishable checks that this repository's packages carry their build output before publishing; it means nothing in a site repo, so it is deliberately not shipped.