| index 636ebba..807747a 100644 |
| --- a/docs/superpowers/specs/2026-09-01-movement-design.md |
| +++ b/docs/superpowers/specs/2026-09-01-movement-design.md |
| @@ -1,11 +1,13 @@ |
| # 🤖 Movement — design |
| |
| -**Status:** design, 2026-09-01. Approved in brainstorming by Paul on |
| -2026-09-01. Awaiting the §11 review pass. Every ruling is dated and |
| +**Status:** design, revised 2026-09-01 after review. Approved in brainstorming |
| +by Paul on 2026-09-01; revised the same day against a review dispatched to |
| +**Fable** (22 findings, all accepted or answered). Every ruling is dated and |
| attributed, with the rejected alternative beside it. |
| **Reviewing model, per §11:** dispatched to Fable, 2026-09-01. *(Recorded as |
| what was dispatched, not what an agent self-reported — models are unreliable |
| -about their own identity.)* |
| +about their own identity.)* Rulings 12–22 are material changes made after that |
| +pass and require a second review before implementation begins. |
| **Scope:** the Movement recruitment app: a public signup surface, a confirmed |
| email list, and an admin view over it. It does not define a mailing-list |
| sender; §13 names the trigger for that. |
| @@ -22,20 +24,25 @@ Both halves are load-bearing and both are tested from the first commit. |
| The first half is what makes the list worth having. Proof of work and a |
| honeypot raise the cost of submitting an address; neither says anything about |
| whether the address exists, receives mail, or belongs to the person who typed |
| -it. Only a click from inside that inbox does. So a row is inert until |
| -confirmed: it is never exported, never counted, never mailed again beyond its |
| -own confirmation. |
| +it. Only a deliberate action from inside that inbox does. So a row is inert |
| +until confirmed: never exported, never counted, never mailed beyond its own |
| +confirmation. |
| |
| The second half is what stops the signup form doubling as a membership |
| -oracle. Every public response — status, body, headers, timing class — is |
| -identical whether an address is unknown, pending, or long confirmed. An app |
| -whose whole subject is who is willing to associate themselves with a position |
| -on AI must not let a stranger enumerate who did. |
| +oracle. Every public response — status, headers, body — is identical whether |
| +an address is unknown, pending, or long confirmed. An app whose subject is who |
| +will associate themselves with a position on AI must not let a stranger |
| +enumerate who did. |
| |
| -**What the rule does not claim.** The server reads every address in plaintext |
| -and holds its own HMAC key (§5). This is not an encrypted-at-rest app and the |
| -README will not imply it is. What is promised is narrower and actually true: |
| -nothing on the public surface distinguishes one address's state from another's. |
| +**What the rule does not claim.** |
| + |
| +- The server reads every address in plaintext and holds the key that seals its |
| + challenges. This is not an encrypted-at-rest app and the README will not |
| + imply it is. |
| +- **Timing is not covered.** Responses are identical in content; they are not |
| + constant-time. The honeypot path does no work and is measurably faster, and |
| + a mail-send failure is slower. Ruling 19 states the invariant that is |
| + actually tested, and this is the limit of the claim. |
| |
| --- |
| |
| @@ -45,21 +52,32 @@ nothing on the public surface distinguishes one address's state from another's. |
| |---|---|---|---| |
| | 1 | **Three stance routes, each server-rendered with its own template, all posting to one `/signup`** | Single page with JS-revealed stance (copy is the product; it wants three reviewable files); session-backed wizard (mints sessions for anonymous visitors to buy nothing) | Paul, 2026-09-01 | |
| | 2 | **Proof of work is required; JavaScript is mandatory to sign up** | PoW when JS is present, falling back to honeypot + seal + rate limit (recommended by this session); no PoW at all | Paul, 2026-09-01 | |
| -| 3 | **The PoW challenge is bound to the submitted email address** | An unbound nonce-only challenge — solved once, replayed across thousands of addresses, worth nothing | Proposed by this session, 2026-09-01 | |
| -| 4 | **Double opt-in: a row is pending until a link in its own inbox is clicked** | Store immediately; store immediately and send a welcome mail | Paul, 2026-09-01 | |
| -| 5 | **Admin is a magic link to an allowlist of addresses** | Shared password (needs `carlos secrets`, direct-bucket-only today, and one secret shared between people); no admin surface at all | Paul, 2026-09-01 | |
| -| 6 | **The instance key is generated on first boot and stored in the database** | `carlos secrets`/`carlos env` (both direct-bucket-only, no console path); baking a key in at build time via ldflags (a key in the binary is a key in every build log) | Proposed by this session, 2026-09-01 | |
| +| 3 | **The PoW challenge is bound to the submitted email address** | An unbound nonce-only challenge — solved once, replayed across thousands of addresses | Proposed by this session, 2026-09-01 | |
| +| 4 | **Double opt-in: a row is pending until confirmed from its own inbox** | Store immediately; store immediately and send a welcome mail | Paul, 2026-09-01 | |
| +| 5 | **Admin is a magic link to an allowlist of addresses** | A shared password — one secret between people, rotated only by redeploy, and revoked for nobody | Paul, 2026-09-01 | |
| +| 6 | ~~The instance key is generated on first boot and stored in the database~~ — **superseded by ruling 12** | — | Proposed by this session, 2026-09-01; superseded same day | |
| | 7 | **The app is reachable at `movement.bdj.oncarlos.com` and nowhere else for v1** | Attaching a custom domain now; attaching one before launch | Paul, 2026-09-01 | |
| -| 8 | **The anti-replay token is HMAC-sealed, not encrypted** | Encrypting it. Nothing in it needs to be secret from the person holding it; it needs to be unforgeable, which is a signature's job | This session, 2026-09-01 | |
| -| 9 | **A filled honeypot answers an ordinary success and stores nothing** | Answering an error, which teaches a crawler exactly what to avoid next time | This session, 2026-09-01 | |
| -| 10 | **Unconfirmed rows are swept at 14 days by a `carlos schedule` tick** | A `time.Ticker` (meaningless in a process that hibernates); never sweeping (an unbounded pile of addresses nobody consented to keep) | This session, 2026-09-01 | |
| +| 8 | **The anti-replay token is HMAC-sealed, not encrypted** | Encrypting it. Nothing in it needs to be secret from its holder; it needs to be unforgeable, which is a signature's job | This session, 2026-09-01 | |
| +| 9 | **A filled honeypot answers an ordinary success and stores nothing** | Answering an error, which teaches a crawler which field to avoid | This session, 2026-09-01 | |
| +| 10 | **Unconfirmed rows are swept by a `carlos schedule` tick** | A `time.Ticker` (meaningless in a hibernating process); never sweeping | This session, 2026-09-01 | |
| | 11 | **All user-facing copy passes the copy-review gate before it enters a template** | Drafting final copy inside this spec, where it would ship unreviewed | This session, 2026-09-01 | |
| +| 12 | **The instance key and origin arrive as `MOVEMENT_INSTANCE_KEY` and `MOVEMENT_ORIGIN`; the app refuses to boot without either** | Ruling 6's mint-into-the-database (its stated justification was false — `carlos secrets`/`env` do work through a logged-in console — and it would put the token-forging key into the Litestream replica); baking a key in via ldflags | This session, 2026-09-01, on corrected facts | |
| +| 13 | **The challenge nonce is single-use, consumed by `DELETE … RETURNING` against a `challenges` table** | An unconsumed nonce — one seal replayed for two hours, every replay mailing the same victim again | This session, 2026-09-01 | |
| +| 14 | **`/signup` never modifies a confirmed row** | Blanket upsert, which lets a stranger un-confirm any address or re-file a named person under a different stance | This session, 2026-09-01 | |
| +| 15 | **Confirmation is a POST from a page the GET renders** | Confirm-on-GET, which mail-scanning gateways trigger on arrival — confirming people who never clicked, and burning the link before the human reaches it | This session, 2026-09-01 | |
| +| 16 | **The app refuses to boot on the `mail.Logged` fallback unless the origin is loopback** | `mail.FromEnv`'s silent default, which writes magic links, confirmation tokens and addresses into `carlos logs` — the exact thing §7 forbids | This session, 2026-09-01 | |
| +| 17 | **The admin allowlist is re-checked on every admin request, and admin sessions last 8 hours** | `Authorize` at sign-in only, with the 30-day default TTL — a removed admin keeps access for a month | This session, 2026-09-01 | |
| +| 18 | **The scheduled-tick route is mounted outside the CSRF-protected router and guarded by `carlos.Tick` alone** | Mounting it inside, where `csrf.Protect` refuses a POST carrying no browser headers — a permanent 403 the platform reads as "do not retry" | This session, 2026-09-01 | |
| +| 19 | **Every non-honeypot `/signup` outcome is one write and at most one synchronous send, rendering the same page** | Leaving "identical response" as an assertion with no stated mechanism | This session, 2026-09-01 | |
| +| 20 | **Outbound mail is budgeted per address and globally, both persisted** | Relying on the in-memory per-address limiter, which resets on every hibernation, restart and deploy | This session, 2026-09-01 | |
| +| 21 | **`/admin` lists confirmed rows; pending appears only as a count** | Listing pending rows — an address nobody has proven owns it, joined to a stance, which is exactly the pair §7 protects | This session, 2026-09-01 | |
| +| 22 | **Pinned: Go 1.25, rastrillo v0.23.0; the app is scaffolded with `rastrillo new movement`** | Hand-writing the app shape, which redoes the scaffold's work and skips its pin test, CI files and browser harness | This session, 2026-09-01 | |
| |
| --- |
| |
| ## 2. Shape |
| |
| -Six public routes, four admin, two platform. |
| +Five public routes, seven admin, two platform. |
| |
| ``` |
| GET / headline, the link out to carlosframework.com, three choices |
| @@ -67,24 +85,27 @@ GET /<stance-a> tailored message + signup form (slugs settled at co |
| GET /<stance-b> " |
| GET /<stance-c> " |
| POST /signup one handler for all three; stance arrives as a hidden field |
| -GET /confirm?token=… burns the token, marks the row confirmed |
| +GET /confirm?token=… renders a page with one Confirm button — burns nothing |
| +POST /confirm burns the token, marks the row confirmed |
| |
| +GET /signin the form, and where auth redirects every outcome |
| POST /signin magic link begin (rastrillo/auth) |
| GET /auth/callback " |
| GET /auth/verify " |
| -GET /admin the list (allowlisted addresses only) |
| +POST /signout " |
| +GET /admin confirmed rows, newest first, plus a pending count |
| GET /admin/export.csv confirmed rows only |
| -POST /signout |
| |
| -GET /healthz required by the platform contract |
| +POST /tick/sweep outside the CSRF router; carlos.Tick guards it |
| +GET /healthz from rastrillo.Serve — never hand-rolled |
| GET /api/version " |
| ``` |
| |
| -Ruling 1 puts the stance copy in three separate templates rather than three |
| -branches of one. The copy is the product here — it is the thing that sorts |
| -people — and it will be edited far more often than the code around it. Three |
| -files also means a copy change touches one template and one test, not a |
| -conditional that renders all three. |
| +All seven admin routes answer **404** when `MOVEMENT_ADMINS` is empty — |
| +including `GET`/`POST /signin`. Leaving sign-in live under an empty allowlist |
| +would let anyone make the app send magic-link mail from its verified identity |
| +to any address, and `/auth/verify` would answer 403 rather than the promised |
| +404, which is itself a signal. |
| |
| `cmd/movement/main.go` stays a thin dispatcher: `rastrillo.Resolve` → |
| `db.Open` → `movement.App` → blank `opts.DBPath` → `rastrillo.Serve`. The |
| @@ -94,222 +115,280 @@ for traffic. |
| |
| --- |
| |
| -## 3. The signup path |
| - |
| -1. `GET /<stance>` renders the form and, with it, a fresh challenge: a random |
| - nonce, an issued-at timestamp, a difficulty, and an HMAC over the three |
| - (§5). All four travel as hidden fields. |
| -2. The visitor types an address and a note about themselves. On submit the |
| - client solves the proof of work (§4) and posts the counter alongside. |
| -3. The server checks, in this order and with the same answer for every |
| - failure: seal valid → age within bounds → PoW satisfied → honeypot empty. |
| -4. The row is upserted **pending**. A confirmation token is minted, its |
| - SHA-256 stored, the plaintext mailed. |
| -5. The response is the same "check your inbox" page in every case. |
| - |
| -Step 5 is where the second half of the one rule lives, and it costs something |
| -real: a returning visitor who has already confirmed is told to check their |
| -inbox, and gets a mail saying they are already on the list. That is worse |
| -copy than "you're already signed up" and it is the price of not answering |
| -"is this address on the list?" to anyone who asks. |
| - |
| -`GET /confirm` hashes the presented token, looks for a live row with that |
| -hash, marks it confirmed, and clears the hash so the link is single-use. An |
| -expired, unknown, or already-burned token all render the same page: a note |
| -that the link is no longer valid and an invitation to sign up again. |
| +## 3. Configuration, and refusing to boot |
| + |
| +Three variables, read once at boot, delivered by `carlos env`/`carlos secrets` |
| +(both of which work through a logged-in console — the earlier claim that they |
| +were direct-bucket-only was wrong): |
| + |
| +| Variable | Purpose | Missing | |
| +|---|---|---| |
| +| `MOVEMENT_ORIGIN` | absolute origin; base of every emailed link, and the argument to `csrf.Protect` and `auth.Config.Origin` | refuse to boot | |
| +| `MOVEMENT_INSTANCE_KEY` | hex-encoded 32 bytes; seals auth's pending blob and the challenge | refuse to boot | |
| +| `MOVEMENT_ADMINS` | comma-separated admin addresses | boot, all admin routes 404 | |
| + |
| +A missing origin refuses at boot; a *wrong* one boots happily and mails links |
| +to the wrong host, so it is worth getting right once. This matters directly |
| +for canary review, which AGENTS.md requires: a canary runs at |
| +`<canary>.movement.bdj.oncarlos.com`, and a hard-coded production origin |
| +would have the canary mailing links into production. |
| + |
| +`auth.New` refuses an empty `InstanceKey` on its own, but with a framework |
| +message at the wiring call. Refusing at boot, naming the variable an operator |
| +must set, is the difference between a five-minute fix and an afternoon. |
| + |
| +**The mail fallback is refused too (ruling 16).** `mail.FromEnv` returns |
| +`mail.Logged` when SMTP is unconfigured, and `Logged` writes recipient, |
| +subject and body — the magic link, the confirmation token, the address — |
| +into platform-held storage §7 says cannot be grepped clean. The app detects |
| +that fallback itself and refuses, unless the origin is loopback, where it |
| +warns loudly instead. |
| + |
| +--- |
| + |
| +## 4. The signup path |
| + |
| +1. `GET /<stance>` mints a challenge: a random nonce **persisted to |
| + `challenges`**, an issued-at, a difficulty, and an HMAC over the three |
| + (§6). All four travel as hidden fields. |
| +2. The visitor types an address and a note. On submit the module solves the |
| + proof of work (§5) in a Worker and posts the counter. |
| +3. The server checks, in order, answering identically to every failure: seal |
| + valid → age within bounds → **nonce consumed by `DELETE … RETURNING`** → |
| + PoW satisfied → honeypot empty → field bounds (§8). |
| +4. The row is written per ruling 14: a confirmed row is **left untouched**; a |
| + pending row is overwritten wholesale, new token replacing old; an unknown |
| + address is inserted pending. |
| +5. A token is minted, its SHA-256 stored, the plaintext mailed — subject to |
| + the budgets in §9. |
| +6. The response is the same "check your inbox" page in every case. |
| + |
| +**Ruling 13 is what makes step 3 worth anything.** Without a consumed nonce, |
| +one solved challenge is replayable for two hours, and each replay mails the |
| +same address again — a mail-bombing primitive aimed at a stranger's inbox, |
| +sent from your own verified identity, with SES reputation as the collateral. |
| +The nonce table is swept by the same tick as everything else; an in-memory set |
| +would not survive hibernation, which is the normal state of this process. |
| + |
| +**Ruling 14 is what protects the signal.** A blanket upsert lets anyone who |
| +knows an address knock it off the list, or — worse for an app that exists to |
| +sort people by stance — re-file a named person under a stance they never |
| +chose. A confirmed row is immutable from the public surface. Changing one's |
| +stance after confirming is not built (§13). |
| + |
| +`GET /confirm` renders a page with a single Confirm button; `POST /confirm` |
| +hashes the token, marks the row, and clears the hash. Ruling 15 exists because |
| +link-scanning gateways fetch every URL in an arriving mail: confirm-on-GET |
| +would let Outlook Safe Links confirm a row no human ever clicked, and then |
| +show the human "this link is no longer valid." An expired, unknown, or burned |
| +token renders one page, identical for all three. |
| |
| --- |
| |
| -## 4. Proof of work |
| +## 5. Proof of work |
| |
| -The client must find a counter such that: |
| +The client must find a counter such that |
| |
| ``` |
| SHA-256(nonce ‖ ":" ‖ lowercased-email ‖ ":" ‖ counter) |
| ``` |
| |
| -has at least `difficulty` leading zero bits. The server re-runs that one hash |
| -and compares — O(1) to check, ~2^difficulty to find. |
| +has at least `difficulty` leading zero bits. The server re-runs that one hash. |
| + |
| +**Binding the address into the preimage is ruling 3.** Unbound, a challenge is |
| +solved once and reused against every address in a list. Bound, the cost is one |
| +solve per address — the axis a bulk signup attack actually scales along. |
| + |
| +**What proof of work does not do.** 2^18 SHA-256 is under a millisecond on a |
| +commodity GPU; a determined attacker solves thousands of bound challenges a |
| +second. PoW prices out casual and scripted abuse, and nothing more. The |
| +defence against a serious bulk attack is the persisted mail budget in §9, not |
| +this. §9 says so plainly so that nobody later mistakes the speed bump for the |
| +wall. |
| |
| -**Binding the address into the preimage is the whole point of ruling 3.** An |
| -unbound challenge is solved once and reused against every address in a list; |
| -the cost to an attacker is one solve total. Bound, the cost is one solve per |
| -address, which is exactly the axis a bulk signup attack scales along. It also |
| -explains the JS requirement without hand-waving: the work cannot begin until |
| -the visitor has typed the address it is bound to. |
| +**Difficulty starts at 18 bits and is a tunable constant**, carried in the |
| +seal. §12 requires it be re-set against real mid-range hardware before launch, |
| +recording **p95 and p99, not the mean** — solve time is geometric, and p99 is |
| +about 4.6× the mean, so calibrating on the average ships a form that hangs for |
| +one visitor in a hundred. A raised difficulty does not apply to seals already |
| +issued; that two-hour tail is accepted. |
| |
| -**Difficulty starts at 18 bits and is a tunable constant.** 18 is an estimate |
| -(~262k expected hashes), not a measurement; §12 requires it be re-set against |
| -a real mid-range phone before launch. Too high is a broken form for anyone on |
| -old hardware, and that failure is invisible to us. |
| +**The solver runs in a Worker**, not on the main thread — 262k hashes in plain |
| +JS freezes a phone for seconds. Same-origin workers are allowed by the default |
| +`default-src 'self'` policy. It reports progress, and the submit button stays |
| +disabled until it finishes. |
| |
| **The hash is a small synchronous SHA-256 inside the module.** WebCrypto's |
| -`subtle.digest` is async per call, and hundreds of thousands of promise |
| -round-trips is not a viable inner loop. About fifty lines of plain JS, no |
| -build step, no dependency — the same hand-written-`.mjs` convention rastrillo |
| -uses for `webauthn.mjs`, `keyring.mjs` and the rest. |
| +`subtle.digest` is async per call; a worker could manage it, but a hand-rolled |
| +~50 lines with no promise round-trip per attempt is simpler and matches the |
| +hand-written-`.mjs` convention rastrillo already uses. |
| |
| **No-JS visitors cannot sign up (ruling 2).** This session recommended a |
| -fallback and Paul chose otherwise, with the trade-off stated. The obligation |
| -that follows is honesty at the point of failure: the form renders disabled |
| -inside `<noscript>` with a plain explanation that signing up needs JavaScript |
| -and why, not a live-looking form that silently does nothing. A visitor who |
| -cannot join must at least learn that immediately, and from us. |
| +fallback; Paul chose otherwise with the trade-off stated. The mechanism must |
| +be the workable one: **the form renders with its submit button `disabled` and |
| +the explanation inside `<noscript>`; the module removes `disabled` once |
| +loaded.** JavaScript cannot enable a control inside `<noscript>`, so the |
| +earlier phrasing was unbuildable. This ordering also fails safe — a module |
| +that throws leaves the honest disabled state rather than a live-looking form |
| +that does nothing. |
| |
| --- |
| |
| -## 5. Keys, seals, and the thing that isn't plumbed |
| - |
| -**One key, generated once, stored in the database.** On first boot the app |
| -reads its key row; if absent it generates 32 bytes from `crypto/rand` and |
| -writes it. That key serves three consumers: `auth.Config.InstanceKey` (which |
| -rastrillo refuses to start without), the HMAC on the challenge seal, and the |
| -confirmation-token derivation. |
| - |
| -This is ruling 6, and it exists to route around a real operational hole: |
| -`carlos env` and `carlos secrets` are both direct-bucket-mode only today — |
| -they want `CARLOS_DEPLOYMENT_BUCKET` and AWS credentials in the operator's |
| -shell, and there is no console path. Making the app's ability to boot depend |
| -on that is a bad trade when the app can mint its own key and let Litestream |
| -replicate it like every other row. |
| - |
| -The honest cost: the server can read its own key, so a compromised live host |
| -can forge a seal or a confirmation token. That is acceptable precisely because |
| -§0 makes no confidentiality claim. It would not be acceptable in an app that |
| -did. |
| - |
| -**The seal** is `HMAC-SHA256(key, nonce ‖ issuedAt ‖ difficulty)`, carried as |
| -a hidden field. On submit the server recomputes it; a mismatch, an age under |
| -**3 seconds**, or an age over **2 hours** is rejected. Three seconds is not a |
| -throughput limit, it is a claim that nobody reads three positions on AI |
| -ethics, picks one, and composes a note about themselves in less time. |
| - |
| -**The admin allowlist** is the one thing left needing configuration: |
| -`MOVEMENT_ADMINS`, comma-separated addresses, read at boot and handed to |
| -`auth.Config.Authorize`. **When it is empty every admin route answers 404** — |
| -not 403, and not an open admin page. Fail closed, and indistinguishable from |
| -an app that has no admin at all. Setting it needs direct-bucket mode once; |
| -that wrinkle is recorded here rather than dodged with a worse mechanism. |
| +## 6. Keys and seals |
| + |
| +`MOVEMENT_INSTANCE_KEY` is hex-encoded 32 bytes (`auth.Config.InstanceKey` is |
| +a `string`). It seals auth's pending blob and the challenge. It does **not** |
| +derive confirmation tokens — those are 32 random bytes, stored only as a |
| +SHA-256, following the `auth_links` pattern. The earlier draft said both, and |
| +key-derived tokens would die on any key rotation. |
| + |
| +**The seal** is `HMAC-SHA256(key, nonce ‖ issuedAt ‖ difficulty)`, a hidden |
| +field. Rejected on mismatch, on an age under **3 seconds**, or over **2 |
| +hours**. The floor is a claim that nobody reads three positions, picks one and |
| +writes about themselves in less time. It is only meaningful because ruling 13 |
| +makes the nonce single-use — per seal, a floor is trivially waited out once. |
| + |
| +**Confirmation tokens expire in 24 hours.** A re-submission of a pending row |
| +issues a new token and a new expiry; the old hash is replaced and the old link |
| +is dead. |
| |
| --- |
| |
| -## 6. Data |
| +## 7. Data |
| |
| ```go |
| type Signup struct { |
| - ID int64 |
| - Email string `gorm:"uniqueIndex"` // stored lowercased; the index is the dedupe |
| - Stance string // never | curious | enthusiast |
| - Background string // why they're interested, how they can help |
| - Confirmed bool |
| - ConfirmedAt *time.Time |
| - TokenHash []byte // SHA-256 of the confirmation token; cleared on burn |
| - TokenExpiry time.Time |
| - CreatedAt time.Time |
| + ID int64 |
| + Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe |
| + Stance string // internal key, never a visitor-facing label |
| + Background string // capped at 2000 runes, C0 controls stripped |
| + Confirmed bool |
| + ConfirmedAt *time.Time |
| + TokenHash []byte // SHA-256 of a random token; cleared on burn |
| + TokenExpiry time.Time |
| + LastMailedAt *time.Time // the per-address budget in §9 |
| + MailCount int |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| } |
| |
| -type Setting struct { // one row today: the instance key |
| - Key string `gorm:"primaryKey"` |
| - Value []byte |
| +type Challenge struct { // ruling 13; consumed, never read twice |
| + Nonce string `gorm:"primaryKey"` |
| + ExpiresAt time.Time |
| } |
| ``` |
| |
| `Stance` stores a stable internal key, never the label a visitor read. Labels |
| -are copy and will change; a column that changes with them is a migration for |
| -every wording tweak and a corrupted history for the ones already stored. |
| +are copy and copy changes; a column that changes with them is a migration per |
| +wording tweak and a corrupted history for rows already stored. |
| |
| There is no `User` model and no ownership column, because there is no owned |
| -resource. That absence is worth naming: rastrillo's most expensive trap is |
| -`sessions.UserID` silently returning `(0, false)` under the `auth` plugin, |
| -where the session subject is a verified address rather than a numeric id. |
| -This app never calls it. The admin view is gated by `Authorize` on the way in |
| -and reads the whole table, which is correct here and would be a severe bug in |
| -an app with tenants. |
| - |
| -Migrations are additive-only and generated with `rastrillo migration generate`; |
| +resource. Worth naming: rastrillo's most expensive trap is `sessions.UserID` |
| +returning `(0, false)` under the `auth` plugin, where the session subject is a |
| +verified address rather than a numeric id. This app never calls it. The admin |
| +view is gated on the way in and reads the whole table — correct here, a severe |
| +bug in an app with tenants. |
| + |
| +Migrations are additive-only, generated with `rastrillo migration generate`; |
| `BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)`, in that |
| order, because auth's backfill reads the sessions table. |
| |
| --- |
| |
| -## 7. What must never reach a log |
| +## 8. Input bounds |
| |
| -Email and stance **together** are the sensitive pair — an address alone is |
| -ordinary, a stance alone is anonymous, the two joined is a political position |
| -attached to a named person. Neither appears in any log line, error string, |
| -job status, or metric label. `carlos logs` is platform-held storage that |
| -cannot be grepped clean afterwards, so this is enforced by a guard test over |
| -the source, not by remembering. |
| +Unbounded free text reaches three dangerous places: an SMTP header, a CSV cell, |
| +and the database. |
| |
| -Stance slugs appear in request paths, which the platform logs. That is |
| -acceptable and is not the sensitive pair: a path says someone read a page, not |
| -that anyone signed up, and it carries no address. |
| +- **Email** — rejected if it contains CR or LF (header injection, before it |
| + ever reaches `Send`), over 254 bytes, or fails `net/mail.ParseAddress`. |
| + Stored lowercased. |
| +- **Background** — capped at 2000 runes, C0 control characters stripped. |
| +- **The request** — wrapped in `http.MaxBytesReader`. |
| +- **Stance** — must be one of the three known keys; anything else renders the |
| + same page as every other rejection. |
| +- **The CSV** — every cell is escaped against formula injection. A value |
| + beginning `=`, `+`, `-`, `@`, tab or CR executes when the export is opened in |
| + Excel, and this export exists to be opened in Excel. Prefixed and quoted per |
| + OWASP, and tested. |
| |
| --- |
| |
| -## 8. Rate limiting |
| - |
| -Rastrillo ships no IP-level limiter, by design — its own docs call that |
| -deployment's job — and the platform gives the app no hook into one. So the |
| -budget is per-address and in-memory, mirroring the shape already in |
| -`password/limit.go`: a bounded map keyed by lowercased address, a fixed |
| -window, a cap on tracked keys, pruned on write. |
| +## 9. Mail budgets |
| |
| -This is deliberately modest, and proof of work is the real cost function for |
| -volume. A per-address budget cannot stop a spread-out attack across many |
| -addresses; PoW makes each of those addresses cost CPU, which is the axis that |
| -actually bites. Naming that division here so nobody later mistakes the limiter |
| -for the defence. |
| - |
| ---- |
| +Two budgets, both **persisted**, because the in-memory limiter resets on every |
| +hibernation, restart and deploy — and this process hibernates as its normal |
| +state. |
| |
| -## 9. Admin |
| +- **Per address**: `LastMailedAt` and `MailCount` on the row. Past the budget, |
| + the send is suppressed and the same page renders. A replayed submission |
| + therefore sends no second mail, which is ruling 13's belt-and-braces. |
| +- **Global**: an hourly outbound ceiling. Past it, the same page renders and |
| + the app logs `mail_budget`. This exists because the real damage from a bulk |
| + attack is not junk rows, it is thousands of mails to strangers from a |
| + verified identity, followed by SES suspension. |
| |
| -`rastrillo/auth` is the magic-link plugin, and its `Mailer` is a |
| -`mail.Sender` — the same interface `carlos email enable` feeds through |
| -`CARLOS_SMTP_*` and `mail.FromEnv("CARLOS", logger)`. No adapter, no |
| -third-party provider, no AWS key in the app. |
| - |
| -`/admin` lists signups newest first with stance and confirmation state. |
| -`/admin/export.csv` emits **confirmed rows only** — that restriction is the |
| -one rule's first half made mechanical, and it is a test, not a convention. |
| - |
| -Signup mail and admin mail share one sending identity on |
| -`movement.bdj.oncarlos.com`, verified by `carlos email enable`. Two notes |
| -from the platform's own documentation, both of which have bitten people: |
| -freshly minted SMTP credentials fail for the first minute or two while SES |
| -propagates, and a `535` inside that window is not a real failure; and DKIM is |
| -the only working alignment rail, since SPF's envelope sender is a region |
| -subdomain that strict alignment reads as a different domain. DKIM signing is |
| -therefore never turned off. |
| +The in-memory per-address limiter from `password/limit.go`'s shape stays as a |
| +cheap first filter, but §5 and this section both state that it is not the |
| +defence. |
| |
| --- |
| |
| -## 10. Errors |
| +## 10. Errors and logging |
| |
| Every rejected submission renders the same page with the same status. The |
| -server logs a reason code — `seal_invalid`, `too_fast`, `too_old`, `pow_short`, |
| -`honeypot` — with no address attached, so a broken client can be diagnosed |
| -without building the oracle §0 forbids. |
| - |
| -The honeypot is the exception that proves the rule: it answers **success** |
| -(ruling 9) and writes nothing. A crawler that gets an error learns which field |
| -to leave alone; one that gets a cheerful confirmation page learns nothing and |
| -goes away satisfied. |
| - |
| -Template execution renders into a buffer before anything is written, so a |
| -template error becomes a clean 500 rather than a half-painted page — the |
| -pattern `examples/notes/render.go` already uses. |
| +server logs a reason code — `seal_invalid`, `nonce_spent`, `too_fast`, |
| +`too_old`, `pow_short`, `honeypot`, `bounds`, `mail_failed`, `mail_budget` — |
| +**with no address attached**. |
| + |
| +**A mail-send failure renders the same page.** The pending row survives and |
| +re-submitting is the recovery path. SMTP runs with a context timeout inside the |
| +request. This is not hypothetical: §11 notes that freshly minted SES |
| +credentials fail for the first minute or two after `carlos email enable`, and |
| +those sends land exactly here. |
| + |
| +The honeypot answers **success** (ruling 9) and writes nothing. A crawler that |
| +gets an error learns which field to leave alone; one that gets a confirmation |
| +page learns nothing. |
| + |
| +**All app logging goes through one internal package** that accepts typed reason |
| +codes and no strings drawn from request data. The guard test forbids any other |
| +`slog` import in the app. "No call site takes both an address and a stance" is |
| +not mechanically checkable over arbitrary Go — wrappers, structs, a `%v` of a |
| +`Signup` — so the boundary is enforced by making the sensitive pair |
| +unreachable from a log call rather than by pattern-matching for it. |
| + |
| +Templates render into a buffer before anything is written, so a template error |
| +becomes a clean 500 rather than a half-painted page. |
| |
| --- |
| |
| -## 11. Review |
| - |
| -Every spec and every branch is reviewed by the highest-capability model |
| -available before it is approved or merged. Cheaper models draft; the most |
| -capable one available reviews. A review by a weaker model than the author is |
| -not a review, and the reviewing model is recorded — in the status line above |
| -for this spec, in the branch discussion for each branch. |
| +## 11. Admin |
| + |
| +`rastrillo/auth` is the magic-link plugin; its `Mailer` is a `mail.Sender`, |
| +the same interface `carlos email enable` feeds through `CARLOS_SMTP_*` and |
| +`mail.FromEnv("CARLOS", logger)`. No adapter, no third-party provider, no AWS |
| +key in the app. |
| + |
| +**The allowlist is re-checked on every admin request (ruling 17).** |
| +`auth.Config.Authorize` is consulted only when a magic link is redeemed; |
| +`RequireSession` thereafter checks only that a session exists, and the default |
| +TTL is 30 days. Without a per-request middleware, removing someone from |
| +`MOVEMENT_ADMINS` does nothing for a month. Admin sessions are capped at |
| +**8 hours** — a surface listing named people's political positions does not |
| +want a month-long cookie. |
| + |
| +`/admin` lists **confirmed rows only**, newest first, with a count of pending |
| +(ruling 21). A pending row is an address nobody has proven owns it joined to a |
| +stance — the pair §7 exists to protect — and showing it to satisfy curiosity |
| +about the funnel is the wrong trade. `/admin/export.csv` is confirmed rows, |
| +escaped per §8, and that restriction is a test, not a convention. |
| + |
| +Two platform notes, both of which have bitten people: freshly minted SMTP |
| +credentials fail for the first minute or two while SES propagates, and a `535` |
| +in that window is not a real failure; and DKIM is the only working alignment |
| +rail, since SPF's envelope sender is a region subdomain that strict alignment |
| +reads as a different domain. DKIM signing is never turned off. |
| |
| --- |
| |
| @@ -318,29 +397,39 @@ for this spec, in the branch discussion for each branch. |
| `make test` and `.amadan/ci.d/*` are one definition, never two lists that |
| drift. Cheapest first: vet, fmt, build, `rastrillo migration check`, test, race. |
| |
| -1. **No enumeration.** Drive `POST /signup` against an unknown, a pending, and |
| - a confirmed address; assert status, headers and body are identical. This is |
| - §0's second half and it is the test most likely to be broken by a |
| - well-meaning copy change. |
| -2. **Export excludes the unconfirmed.** Pending rows never appear in the CSV. |
| +1. **No enumeration.** `POST /signup` against unknown, pending and confirmed |
| + addresses returns identical status, headers and body — and so do the |
| + budget-suppressed, limiter-tripped, mail-failed and invalid-stance paths. |
| + §0 names timing as outside the claim. |
| +2. **Export excludes the unconfirmed**, and every cell is formula-escaped. |
| 3. **PoW is enforced and bound.** A short solution is rejected; a solution |
| - valid for one address is rejected when replayed with another. The second |
| - assertion is ruling 3's only real defence. |
| -4. **Seal bounds.** Forged seal, submission under 3s, submission over 2h — all |
| - rejected, all identically. |
| -5. **Honeypot.** A filled honeypot returns the success page and writes no row. |
| -6. **Confirmation is single-use.** A second click on a burned token does not |
| - re-confirm and does not error differently from an unknown token. |
| -7. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, every admin route |
| - answers 404. |
| -8. **No sensitive pair in logs.** A guard test over the source: no log call |
| - site takes both an address and a stance. |
| -9. **The difficulty is calibrated, not assumed.** A recorded measurement on |
| - real mid-range hardware before launch, per §4. |
| - |
| -Gate 3 is the one that must cross from the client's solver to the server's |
| -verifier — a test that only exercises the Go side proves the check, not the |
| -scheme. |
| + valid for one address is rejected for another. This gate must cross from |
| + the browser solver to the Go verifier — the scaffold's chromedp harness |
| + under `-tags browser` — because a Go-only test proves the check, not the |
| + scheme. |
| +4. **The nonce is single-use.** A byte-identical replay of a valid, accepted |
| + POST is rejected and **sends no second mail**. This is ruling 13's gate and |
| + the most important one in the list. |
| +5. **A confirmed row is immutable from `/signup`.** Re-submission with a |
| + different stance changes nothing and does not un-confirm. |
| +6. **Seal bounds.** Forged seal, under 3s, over 2h — rejected identically. |
| +7. **Honeypot** returns the success page and writes no row. Plus a browser-rig |
| + assertion that Chrome's autofill leaves the field empty. |
| +8. **Confirmation is single-use and POST-only.** A `GET /confirm` confirms |
| + nothing; a second POST on a burned token is indistinguishable from an |
| + unknown one. |
| +9. **Admin fails closed.** With `MOVEMENT_ADMINS` empty, all seven admin |
| + routes answer 404 — iterated, not spot-checked. |
| +10. **A removed admin loses access on the next request**, not on session |
| + expiry. |
| +11. **The tick reaches the sweep.** A POST with no browser headers and a valid |
| + bearer must not 403 — the CSRF trap in ruling 18, which fails silently and |
| + permanently. |
| +12. **The sweep spares the confirmed** and anything with a live token. |
| +13. **Boot refusals.** Missing origin, missing instance key, and the |
| + `mail.Logged` fallback on a non-loopback origin each refuse to start. |
| +14. **Logging containment.** No `slog` import outside the logging package. |
| +15. **Difficulty is calibrated**, p95 and p99 recorded on real hardware. |
| |
| --- |
| |
| @@ -348,11 +437,12 @@ scheme. |
| |
| | Thing | Trigger | |
| |---|---| |
| +| Changing stance after confirming | Someone asks; today a confirmed row is immutable (ruling 14) | |
| | Push into mailing-list software | A CARLOS-family mailing list app exists | |
| | Self-service unsubscribe and deletion | Before the first bulk send | |
| | Passkey second factor on admin | A third admin, or the first export leaving this machine | |
| | A custom domain | Paul asks; the platform host is ruling 7 for v1 | |
| -| Funnel analytics | Asked for twice | |
| +| Funnel analytics beyond a pending count | Asked for twice | |
| | i18n | — | |
| |
| A thing deferred without a trigger is a thing forgotten. |
| @@ -361,20 +451,29 @@ A thing deferred without a trigger is a thing forgotten. |
| |
| ## 14. Sequencing |
| |
| -1. Skeleton: the platform contract, `/healthz`, `/api/version`, one boot migration. |
| -2. The instance key, the seal, and their tests. |
| -3. The PoW verifier in Go, then the solver module, then the crossing test. |
| -4. Models, migrations, and the signup handler with all four checks. |
| -5. Confirmation tokens and the confirm route. |
| -6. `carlos email enable`, the mailer, and the two mails. |
| -7. Copy review, then the templates and static assets. |
| -8. Admin: auth allowlist, list, CSV export. |
| -9. The sweep tick. |
| -10. Deploy to `movement.bdj.oncarlos.com`, calibrate difficulty, record it. |
| - |
| -Copy review sits at 7 deliberately. The templates need real copy to be worth |
| -writing, and the copy needs Paul. Everything before it is copy-independent, |
| -so the wait costs nothing. |
| +Two plans, split at the copy gate. Everything in the first is |
| +copy-independent, so waiting on Paul costs nothing. |
| + |
| +**Plan 1 — the engine.** Placeholder copy throughout; every gate in §12 except |
| +15 is green at its end. |
| + |
| +1. `rastrillo new movement`, pins, `BuildVersion` stamping, the platform |
| + contract. `/healthz` and `/api/version` come from `rastrillo.Serve` and are |
| + never hand-rolled. |
| +2. Config and the three boot refusals (§3). |
| +3. The seal, the `challenges` table, and single-use consumption. |
| +4. The PoW verifier in Go, the Worker solver, and the browser-crossing test. |
| +5. Models, migrations, and the signup handler with every check and bound. |
| +6. Confirmation: GET renders, POST burns. |
| +7. `carlos email enable`, the mailer, the budgets, the two mails. |
| +8. Admin: allowlist, per-request re-check, list, escaped CSV export. |
| +9. The sweep tick, mounted outside the CSRF router. |
| + |
| +**Plan 2 — the surface.** |
| + |
| +10. Copy review, then templates and static assets. |
| +11. Deploy to a canary, calibrate difficulty at p95/p99, record it. |
| +12. Promote, and verify `X-Carlos-Version` on the canonical host. |
| |
| --- |
| |
| @@ -384,12 +483,16 @@ so the wait costs nothing. |
| issues" describes a real group accurately but will not be self-selected by |
| it — it asks someone to volunteer that they have not thought about |
| something. Those people will pick the middle option, polluting the one |
| - signal this whole app exists to isolate. The label needs to be something |
| - that group would own willingly, with the diagnosis living in Paul's reading |
| - of the bucket rather than in the words on the button. Settled at copy review. |
| + signal this app exists to isolate. The label needs to be something that |
| + group would own willingly, with the diagnosis living in Paul's reading of |
| + the bucket rather than in the words on the button. Settled at copy review. |
| - **The headline against carlosframework.com.** The proposed line frames |
| CARLOS as simplifying how we build connected software; the site sells cost, |
| security, resilience, privacy and sovereignty, with per-person encrypted |
| databases. A visitor who follows the link meets a different pitch. Settled |
| at copy review. |
| -- **Difficulty.** 18 bits is an estimate. §12 gate 9 closes this. |
| +- **Ruling 21 costs Paul something.** Pending rows carry the free-text note, |
| + and hiding them means not reading what someone wrote until they confirm. If |
| + that is the wrong trade, ruling 21 is the row to revise — but it should be |
| + revised deliberately, not discovered as a missing feature. |
| +- **Difficulty.** 18 bits is an estimate. Gate 15 closes it. |