AI agent index: llms.txt
A template for running a public petition site on Cloudflare Workers — one deployment = one petition, modeled on the excellent UX of 150proc.pl: hero → evidence with cited sources → mechanism → signature form → live counter and Poland map → public supporters list → share → FAQ.
Status: in development. Eleven of the twelve slices have landed: the site signs, counts, maps, publishes, reads as a petition in both languages, and
pnpm run init-projectpersonalizes it. What remains is the one-click Deploy to Cloudflare pipeline. See Current state before running anything.
- Live signature counter + Poland voivodeship map — a
LiveCounterDurable Object broadcasts over WebSockets (Hibernation API, ~1 push/sec coalescing) with automatic polling fallback; D1 stays the single source of truth. - Complete Polish legal layer — three RODO consent checkboxes, inline "Klauzula informacyjna", and full Klauzula RODO + Polityka prywatności routes, copied verbatim from 150proc.pl with proper nouns tokenized for your campaign.
- Signature form with trust pipeline — osoba prywatna / firma toggle, Cloudflare Turnstile (always-pass test keys shipped as defaults), per-IP rate limiting, unique-e-mail dedup, geo attribution from Cloudflare request metadata. No e-mail provider needed, ever.
- Public supporters list — consent-gated, paginated: "Imię N., Miejscowość" or company name.
- Bilingual PL/EN —
/serves Polish,/enEnglish; legal documents stay Polish-only with an EN notice. All copy lives in Zod-validated content files — zero text in components. - Sign CTA always in reach — hero button plus a floating bottom bar with the live count once you scroll.
- One-click start — a Deploy to Cloudflare button yields a working placeholder demo (D1 + Durable Object auto-provisioned, migrations applied in the deploy command);
pnpm run init-projectthen interviews you for petition name, organizer, addressee, domain, and Turnstile keys. - Organizer data access — documented
wrangler d1queries export the full signature CSV and the updates-consent e-mail list. No admin panel, no auth surface.
visitor ──► Worker (TanStack Start SSR + Hono /api)
│ validate → Turnstile → rate limit → geo → dedup
▼
D1 (single source of truth)
│ fire-and-forget notify
▼
LiveCounter DO (in-memory counts, rebuilt from D1 on cold start)
│ WebSocket hibernation, ~1 push/sec
▼
connected browsers (counter + voivodeship map; polling fallback)
Full decision log: PRD (issue #1) and the durable-decisions header of plans/petition-template.md.
Implementation is dispatched as dependency-ordered tracer-bullet slices — each issue is independently implementable and verifiable:
| Issue | Slice | Status |
|---|---|---|
| #2 | D1 walking skeleton (Neon → D1, demo cleanup, signatures schema) |
landed |
| #3 | Legal text capture from 150proc.pl (verbatim fixtures) — HITL | landed |
| #4 | Minimal sign path | landed |
| #5 | Content module + PL/EN routing | landed |
| #6 | Trust pipeline: Turnstile, rate limit, geo | landed |
| #7 | Live counter DO + floating bar | landed |
| #8 | Voivodeship map | landed |
| #9 | Full legal layer | landed |
| #10 | Supporters list | landed |
| #11 | Full page anatomy + mobile pass | landed |
| #12 | init-project extension | landed |
| #13 | Deploy to Cloudflare button + acceptance run — HITL | planned |
The repo was generated from tstack-on-cf (TanStack Start + Hono on Workers, Drizzle, Zod, Shadcn/UI, Biome + Vitest + knip). Eleven of the twelve slices have landed on top of it:
- Persistence is Cloudflare D1, reached through Drizzle's SQLite driver behind
src/db/setup.ts. Thesignaturestable ships as a migration, and the landing page server-renders the total count from it — the number is in the first byte of HTML, not fetched afterwards. - The demo
clientsdomain is gone, along with the Neon driver, its three credentials, and the seed script. - Signing works.
POST /api/signaturesruns the full trust pipeline — validate → Turnstile → per-IP rate limit → region attribution → unique-e-mail dedup — and the form renders a distinct answer for each way it can end. Every signature stores an ISO 3166-2:PL voivodeship code derived from its postal code, or from Cloudflare's geo-IP, or theunknownbucket. - Copy is bilingual and lives outside the components.
/is Polish,/enEnglish. - The counter is live. A
LiveCounterDurable Object holds the counts, rebuilds them from D1 whenever it is asked cold, and pushes them over a hibernatable WebSocket to every open page — coalesced to about one push a second, with a 30-second reconciliation heartbeat behind it. A page that cannot open a socket falls back to polling/api/signatures/snapshotwithout saying so. The floating bar arrives once the hero is behind the reader and carries the same number. - The map and the legal layer are in. Sixteen voivodeships shaded by the codes the pipeline stores, every count also written out beside the drawing; the RODO clause and the privacy policy served as real routes from tokenized Markdown.
- The supporters list is public and consent-gated.
GET /api/signatures/supporterspages through the signers who ticked the publication consent, newest first — "Anna K., Warszawa" for a person, the entity's name alone for an organisation. The first page is server-rendered with the count and the map; new names arrive at the top over the same connection that feeds the counter, and older pages arrive when the reader asks. A signature whose signer declined publication moves the counter and never joins the list, which is why the list is shorter than the total. - The page is a petition, not a page about the template. Nine sections in one order — hero, evidence, demands, counter, form, map, supporters, share, FAQ — with every statistic carrying a mandatory source and share links hand-built rather than loaded from a network's script.
init-projectinterviews you for the identity. One pass writes the petition name and addressee, the organizer's legal, prose and four declined short names, their registered address and registry numbers, the contact e-mail, the domain, the word this deployment uses for a signer that is not a person — declined into the cases the copy needs — and, optionally, the social profiles and real Turnstile keys. Public values go tosrc/content/site-config.ts; the Turnstile secret goes to.dev.varsand nowhere else. Re-running never overwrites an answer, so it is safe to stop halfway and come back.- Still ahead: the one-click deploy (#13).
The local loop needs no Cloudflare account and no real credentials — D1 runs on your machine and the shipped Turnstile keys are Cloudflare's always-pass test pair.
pnpm install
cp .dev.vars.example .dev.vars # Turnstile test secret; no account needed
pnpm cf-typegen
pnpm run db:migrate:dev # applies migrations to the local D1
pnpm dev # port 3000pnpm db:seed:dev fills the local database with 195 demo signatures spread unevenly across the voivodeships, so the counter has a number, the map has something to shade and the supporters list has something to page through. It leaves one voivodeship empty and seven signatures unattributed on purpose, a third of the rows withhold the publication consent, and six of them are organisations rather than people — those are the states easiest to break without noticing. Running it twice changes nothing; scripts/seed-dev.sql says how to empty the table again.
Read or write the local database directly with Wrangler — this is also how you add a single row and watch the counter move:
pnpm run db:seed:dev
pnpm exec wrangler d1 execute DB --local --command "SELECT count(*) FROM signatures"Before declaring any change done: pnpm lint && pnpm types && pnpm test && pnpm knip.
Drizzle generates the SQL (pnpm db:generate:<env>); Wrangler applies it (pnpm db:migrate:<env>). Each environment's migrations_dir in wrangler.jsonc points at the directory its own generator writes to.
| Environment | Directory | Status |
|---|---|---|
dev |
src/db/migrations/dev |
In the repository |
staging |
src/db/migrations/staging |
Created by pnpm db:generate:staging |
production |
src/db/migrations/production |
Created by pnpm db:generate:production |
wrangler.jsonc ships an all-zero placeholder database_id for every environment. Create the databases and paste the real ids in before deploying:
pnpm exec wrangler d1 create petition-staging
pnpm run db:generate:staging
pnpm run db:migrate:staging
pnpm run deploy:stagingAutomating this into the Deploy to Cloudflare button is issue #13.
Base-stack documentation (testing projects, deploy runbook, error handling) lives in the upstream README and stays accurate where the slices have not rewritten this repo — this README grows the template's own quick start as features land (issue #13 finalizes it).
This template has no auth surface, by design. The petition site is entirely public, and an organizer reaches their own data with wrangler d1 export queries from their machine rather than through a protected endpoint — so there is no admin panel, no account, and no password to leak.
Every API route is public because every API route is meant to be. Health (/api/health/*) reports status. Signing (POST /api/signatures) is the one write path, and it is public for the same reason the form is. GET /api/signatures/snapshot returns a total and a per-region split, and nothing else. GET /api/signatures/supporters is the one route that reads signatures back out, and what it can return is bounded by the query rather than by a filter: it selects only rows whose signer ticked the publication consent, and of the surname it selects one character. There is no request it will answer with an e-mail address, a full surname or a consent flag.
The write path is guarded by a trust pipeline rather than by authentication: validate → Turnstile → per-IP rate limit → region attribution → unique-e-mail dedup, in that order. A submission without a Turnstile token, or with one Cloudflare's siteverify does not approve, is refused with 403 before it reaches the database; a sixth submission from the same address inside a minute is refused with 429. The order matters — a signer who mistyped their e-mail is told that, rather than accused of being a robot, and a machine spends a challenge before it spends a rate-limit slot. With the shipped test keys none of this stops anything: read the next section before you deploy.
TanStack Start server functions are the one exception to "public by default": they are same-origin RPC endpoints, so src/start.tsx registers a CSRF middleware that answers 403 to a cross-site call. It currently guards two reads — the public count and the list's first page — and it does not cover POST /api/signatures, which is a Hono route — nor would it help there, since a site with no session cookie gains an attacker nothing they could not do from their own server. It is the default the next server function inherits.
That is a decision about what to build, not a claim that nothing needs guarding. Authentication attaches at src/hono/factory.ts: createHono(...middleware) accepts ApiMiddleware handlers and applies them to every route of the endpoint it builds, so a guard added there covers the whole surface instead of one handler. If you add an endpoint this template does not have, that is where it goes.
Before you deploy:
- Replace the placeholder
database_idvalues inwrangler.jsoncwith real ones fromwrangler d1 create. They are all-zero and syntactically valid, so a deploy that skips this step succeeds and then fails on the first query. - Keep production off the workers.dev subdomain — it ships off, and a custom domain is the intended way to reach it. A guessable second URL serving the same form collects signatures under no campaign identity at all.
- Replace the Turnstile test keys with real ones — see Turnstile keys directly below. The shipped values accept every submission, including a script's.
- Review the legal texts against your campaign; responsibility for their sufficiency rests with the organizer.
Turnstile has two halves, and they are not interchangeable:
| Key | Where it goes | Public? |
|---|---|---|
| Site key | turnstileSiteKey in src/content/site-config.ts |
Yes — the widget script reads it in the visitor's browser |
| Secret key | TURNSTILE_SECRET_KEY, a Worker secret |
No — it never leaves the Worker |
What ships is Cloudflare's official always-pass test pair (1x00000000000000000000AA and 1x0000000000000000000000000000000AA), so a fresh clone signs with no Cloudflare account and CI needs no credentials. They render a real widget and approve every visitor — they are the absence of bot protection, not bot protection.
To switch to real keys, create a widget in the Cloudflare dashboard under Turnstile → Add widget, add your deployment's hostnames, then:
# 1. Site key — public, committed
# Paste it into turnstileSiteKey in src/content/site-config.ts
# 2. Secret key — never committed
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY --env staging
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY --env productionwrangler.jsonc declares TURNSTILE_SECRET_KEY under secrets.required in every environment, so wrangler deploy refuses to ship an environment where it was never set and names it. Locally, cp .dev.vars.example .dev.vars is enough — the test secret in it works offline.
To watch the failure path by hand, put 2x00000000000000000000AB (Cloudflare's always-blocks site key) in the config and submit the form: the bot-check message appears instead of the success state.
src/secrets-contract.test.ts fails the build if a secret key ever appears in the source, if the content files mention one, or if the Worker reads it from anywhere but its env binding.
There is no admin panel and no export endpoint — by design. An organizer reads their own data with Wrangler, from their own machine, against the environment they name. Both queries below emit CSV with a header row, and both need jq: wrangler d1 execute --json returns JSON, and the CSV shaping happens locally rather than in SQL so the same command works against any environment.
Swap --env production for --env staging, or --local for the database on your own machine. --remote is the flag that means "the real one".
Everything the petition holds, newest last, for delivering the petition to its addressee.
pnpm exec wrangler d1 execute DB --env production --remote --json \
--command "SELECT id, first_name, surname, email, city, postal_code, signer_type, company_name, signer_role, voivodeship_code, consent_rodo, consent_public_list, consent_updates, datetime(created_at, 'unixepoch') AS signed_at FROM signatures ORDER BY created_at" \
| jq -r '.[0].results | (.[0] | keys_unsorted), (.[] | [.[]]) | @csv' > signatures.csv| Column | What it holds |
|---|---|
id |
UUID of the signature |
first_name |
As given |
surname |
In full — the public list only ever shows its initial |
email |
The dedup key; unique across the table |
city |
Free text, shown as typed, never matched against a dictionary |
postal_code |
NN-NNN or empty — always optional |
signer_type |
person or company |
company_name |
The entity's name, empty for a private person |
signer_role |
Their role in it, empty unless this deployment asks for one |
voivodeship_code |
ISO 3166-2:PL, or empty where attribution failed |
consent_rodo |
1 — mandatory, stored so the record shows what was agreed |
consent_public_list |
1 where the signature may be published |
consent_updates |
1 where they asked to hear how it went |
signed_at |
UTC, YYYY-MM-DD HH:MM:SS |
The only people this campaign may write to. Nothing in this template sends e-mail — there is no provider and no sending code — so this list is for pasting into whatever you do send with, and the consent is the whole basis for having it.
pnpm exec wrangler d1 execute DB --env production --remote --json \
--command "SELECT email, first_name, surname FROM signatures WHERE consent_updates = 1 ORDER BY created_at" \
| jq -r '.[0].results | (.[0] | keys_unsorted), (.[] | [.[]]) | @csv' > updates-consent.csv| Column | What it holds |
|---|---|
email |
Where to write |
first_name |
For addressing them by name |
surname |
In full |
Both files are personal data the moment they exist. src/db/signatures/export-queries.test.ts fails the build if a documented column stops matching what the query returns.
- PRD — issue #1: problem, 38 user stories, implementation decisions, assumptions, tradeoffs, validation strategy
plans/petition-template.md: durable architectural decisions + 11 phased slices with acceptance criteria- Issues #2–#13: dependency-ordered work items, labeled
AFK(agent-implementable end-to-end) orHITL(named human checkpoint)
- UX and legal-layer reference: 150proc.pl. Only the legal texts are copied (verbatim, tokenized); campaign content is not.
- Base stack: tstack-on-cf.
Open source under the MIT License.