carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1# Movement Engine Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use
4> superpowers:subagent-driven-development (recommended) or
5> superpowers:executing-plans to implement this plan task-by-task. Steps use
6> checkbox (`- [ ]`) syntax for tracking.
7
8**Goal:** Build every mechanism of the Movement signup app — challenge sealing,
9proof of work, double opt-in, mail budgets, admin, and the sweep — with all
10user-facing words held as marked placeholders, so the whole engine is testable
11and canary-reviewable before copy review happens.
12
13**Architecture:** One Go binary on rastrillo. A public signup surface posts to a
14single handler that checks a honeypot, an HMAC-sealed challenge, input bounds
15and a proof of work bound to the submitted address, then does one write
16transaction (spend the nonce, upsert the signup, bump a mail counter) and
17sends a confirmation mail after commit. Confirmation is a POST. Admin is a
18magic link restricted to an allowlist. A scheduled tick sweeps.
19
20**Tech Stack:** Go 1.25, `github.com/carlosframework/rastrillo` v0.23.0,
21`github.com/go-chi/chi/v5`, GORM with `rastrillo/gormlite` (pure-Go
22`modernc.org/sqlite`), `html/template`, hand-written ES modules. No bundler, no
23CDN, `CGO_ENABLED=0`.
24
25**Spec:** `docs/superpowers/specs/2026-09-01-movement-design.md`
26
27**Framework pin:** rastrillo v0.23.0 — the version the sibling `sheets` runs in
28production, so its API shape is known-good rather than assumed. Go 1.25 to
29match. **Where rastrillo's own `SKILL.md` disagrees with this plan about an API,
30SKILL.md wins** — read it before Task 1 and treat any mismatch as this plan
31being stale, not as a reason to improvise.
32
33**Scope boundary:** This plan is the engine. It deliberately excludes real copy,
34the three stance slugs, the visual design of any page, difficulty calibration,
35and deployment to the canonical host. Those are plan 2, which runs after copy
36review. Every string this plan writes is a placeholder registered in
37`internal/copy`.
38
39---
40
41## Global Constraints
42
43Copied from the spec. Every task's requirements implicitly include these.
44
45- **The one rule:** an address joins the list only by clicking a link in its own
46 inbox, and the public surface never reveals which addresses have.
47- Every `/signup` outcome renders the same bytes. The accepting path is one
48 write transaction and at most one send; every rejecting path writes nothing.
49- `/signup` never writes `Confirmed`, `ConfirmedAt`, `Stance`, `Background`,
50 `TokenHash` or `TokenExpiry` on a confirmed row.
51- Email and stance together are the sensitive pair. Neither reaches a log line,
52 error string, job status or metric label. The logging package takes typed
53 reason codes and **no `error` values**.
54- `CGO_ENABLED=0`. Never import `github.com/glebarez/*` or
55 `gorm.io/driver/sqlite` — both collide with `rastrillo/gormlite`.
56- Migrations are additive-only; never edit a shipped migration. Add subsystems
57 to `BootSchema`, never `Schema`.
58- Never bind a request body onto a GORM model. Named fields via
59 `r.PostFormValue`; writes through an explicit `.Select` allowlist.
60- `csrf.Protect(origin)` is mounted app-wide on the chi router. The tick route
61 is mounted **outside** it.
62- Blank `opts.DBPath` before `rastrillo.Serve` — the app opens its own handle.
63- `/healthz` and `/api/version` come from `rastrillo.Serve`. Never hand-roll
64 them.
65- No literal user-facing text in any template. Every string resolves through
66 `internal/copy`.
67- Build `linux/arm64`, stamped with
68 `-ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=$(git rev-parse --short HEAD)"`.
69
70---
71
72## File Structure
73
74```
75cmd/movement/main.go thin dispatch: Resolve -> db.Open -> App -> Serve
76internal/movement/
77 app.go migrate, sessions, auth, router assembly
78 config.go env loading and the boot refusals
79 logging.go the only slog user; typed reason codes
80 copy.go the copy table and placeholder discipline
81 seal.go challenge minting, HMAC seal, key derivation
82 pow.go proof-of-work verification (server side)
83 models.go Signup, SpentNonce, MailCounter
84 migrations.go Schema and BootSchema
85 signup.go POST /signup: checks, transaction, send
86 confirm.go GET renders, POST burns
87 mailer.go budget-wrapping mail.Sender, send-after-commit
88 budget.go per-address and global budget arithmetic
89 admin.go app-owned signin, allowlist middleware, list, CSV
90 sweep.go the tick handler
91 render.go template registry, buffered render
92 pages.go stance page handlers
93 templates/*.html markup only; every string via copy
94 static/pow.js the Worker solver (hand-written ES module)
95 static/sha256.js synchronous SHA-256 used by the Worker
96 migrations/*.sql
97internal/movementtest/ HTTP harness and the gates
98```
99
100**Rationale for the splits.** `signup.go` is the one place all four checks and
101the transaction meet, so everything it depends on — sealing, PoW, budgets — is
102a separate file with a testable surface, keeping the handler readable. `copy.go`
103and `logging.go` are single-purpose because both are enforced by guard tests
104that forbid their concerns appearing anywhere else. `admin.go` holds the
105allowlist in one place so the "seven routes 404" gate can iterate one list.
106
107---
108
109### Task 1: Scaffold, pins, and the platform contract
110
111**Files:**
112- Create: everything `rastrillo new movement` emits, then edit `go.mod`,
113 `Makefile`, `.amadan/ci.d/`
114- Test: the scaffold's own `internal/movementtest/`
115
116**Interfaces:**
117- Consumes: nothing.
118- Produces: a booting binary; `movement.App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error)`.
119
120- [ ] **Step 1: Read the framework's own rules first**
121
122Read `/home/paulca/go/pkg/mod/github.com/carlosframework/rastrillo@v0.23.0/SKILL.md`
123in full. It is written to be read instead of the source. If anything below
124contradicts it, it wins.
125
126- [ ] **Step 2: Scaffold**
127
128```bash
129cd /home/paulca/amadan.net/carlos/movement/.claude/worktrees/design
130rastrillo new movement
131```
132
133This emits `cmd/`, `internal/movement/`, `internal/movementtest/`, `Makefile`,
134`.amadan/ci.d/`, `static/tokens.css`, a `vendored_test.go` pin test, and a
135`//go:build browser` chromedp test. Do not hand-write these.
136
137- [ ] **Step 3: Pin the versions**
138
139Set `go 1.25.0` in `go.mod`, the module path to `amadan.net/carlos/movement`,
140and `github.com/carlosframework/rastrillo v0.23.0`. Then:
141
142```bash
143go mod tidy && go build ./...
144```
145
146- [ ] **Step 4: Add the browser and version steps to the one CI definition**
147
148`make test` and `.amadan/ci.d/*` must stay one definition. Add a
149`test-browser` target running `go test -tags browser ./...`, and a
150`.amadan/ci.d/60-browser` that is exactly `exec make test-browser`. Keep the
151scaffold's `version-check`.
152
153If the runner cannot install Chromium, set `RASTRILLO_BROWSER_OPTIONAL=1` in
154that ci.d step and record in `AGENTS.md` that gates 3 and 7 are a manual
155pre-deploy step — the browser test **fails rather than skips** without a
156browser, so this must be a deliberate choice.
157
158- [ ] **Step 5: Stamp the build version**
159
160In the `Makefile`'s release target:
161
162```make
163release:
164 @git diff --quiet || (echo "dirty tree" && exit 1)
165 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
166 -ldflags "-X github.com/carlosframework/rastrillo.BuildVersion=$$(git rev-parse --short HEAD)" \
167 -o movement-linux-arm64 ./cmd/movement
168```
169
170Without the stamp `/api/version` reports `dev` and the deploy watch is blind.
171
172- [ ] **Step 6: Run the gate**
173
174Run: `make test`
175Expected: PASS, including `migration check` and the pin test.
176
177- [ ] **Step 7: Commit**
178
179```bash
180git add -A
181git commit -m "Skeleton: the platform contract and a build that stamps itself"
182```
183
184---
185
186### Task 2: Config, the boot refusals, and the logging package
187
188**Files:**
189- Create: `internal/movement/config.go`, `internal/movement/logging.go`
190- Create: `internal/movement/config_test.go`, `internal/movement/logging_test.go`
191- Create: `internal/movementtest/guards_test.go`
192
193**Interfaces:**
194- Consumes: nothing.
195- Produces:
196 - `type Env struct { Origin, InstanceKey string; Admins []string }`
197 - `func LoadEnv() (Env, error)`
198 - `type Reason string` with constants `ReasonSealInvalid`, `ReasonNonceSpent`,
199 `ReasonTooFast`, `ReasonTooOld`, `ReasonPowShort`, `ReasonHoneypot`,
200 `ReasonBounds`, `ReasonMailFailed`, `ReasonMailBudget`
201 - `func Rejected(r Reason)`, `func Info(msg string)` — neither accepts an
202 `error` or any request-derived string.
203
204- [ ] **Step 1: Write the failing config test**
205
206```go
207func TestLoadEnvRefusesMissing(t *testing.T) {
208 t.Setenv("MOVEMENT_ORIGIN", "")
209 t.Setenv("MOVEMENT_INSTANCE_KEY", "")
210 if _, err := LoadEnv(); err == nil {
211 t.Fatal("expected refusal with no origin and no instance key")
212 }
213}
214
215func TestLoadEnvRefusesUnconfiguredMailOnPublicOrigin(t *testing.T) {
216 t.Setenv("MOVEMENT_ORIGIN", "https://movement.bdj.oncarlos.com")
217 t.Setenv("MOVEMENT_INSTANCE_KEY", strings.Repeat("a", 64))
218 t.Setenv("CARLOS_SMTP_HOST", "")
219 t.Setenv("CARLOS_SMTP_FROM", "")
220 if _, err := LoadEnv(); err == nil {
221 t.Fatal("expected refusal: mail.Logged would write tokens into carlos logs")
222 }
223}
224
225func TestLoadEnvAllowsUnconfiguredMailOnLoopback(t *testing.T) {
226 t.Setenv("MOVEMENT_ORIGIN", "http://127.0.0.1:8080")
227 t.Setenv("MOVEMENT_INSTANCE_KEY", strings.Repeat("a", 64))
228 t.Setenv("CARLOS_SMTP_HOST", "")
229 t.Setenv("CARLOS_SMTP_FROM", "")
230 if _, err := LoadEnv(); err != nil {
231 t.Fatalf("loopback should warn, not refuse: %v", err)
232 }
233}
234```
235
236- [ ] **Step 2: Run it and watch it fail**
237
238Run: `go test ./internal/movement/ -run TestLoadEnv -v`
239Expected: FAIL, `undefined: LoadEnv`.
240
241- [ ] **Step 3: Implement config.go**
242
243```go
244// Package movement ... (keep the scaffold's doc comment)
245
246// loopback reports whether origin is a local development origin. The mail
247// refusal below is relaxed only here, and the check is deliberately
248// conservative: an instance claiming a loopback origin in production is a
249// misconfiguration we would rather see loudly than quietly excuse.
250func loopback(origin string) bool {
251 u, err := url.Parse(origin)
252 if err != nil {
253 return false
254 }
255 h := u.Hostname()
256 return h == "localhost" || h == "127.0.0.1" || h == "::1"
257}
258
259type Env struct {
260 Origin string
261 InstanceKey string
262 Admins []string
263}
264
265func LoadEnv() (Env, error) {
266 env := Env{
267 Origin: strings.TrimSpace(os.Getenv("MOVEMENT_ORIGIN")),
268 InstanceKey: strings.TrimSpace(os.Getenv("MOVEMENT_INSTANCE_KEY")),
269 }
270 for _, a := range strings.Split(os.Getenv("MOVEMENT_ADMINS"), ",") {
271 if a = strings.ToLower(strings.TrimSpace(a)); a != "" {
272 env.Admins = append(env.Admins, a)
273 }
274 }
275
276 var missing []string
277 if env.Origin == "" {
278 missing = append(missing, "MOVEMENT_ORIGIN")
279 }
280 if env.InstanceKey == "" {
281 missing = append(missing, "MOVEMENT_INSTANCE_KEY")
282 }
283 if len(missing) > 0 {
284 return Env{}, fmt.Errorf("movement: set %s", strings.Join(missing, ", "))
285 }
286
287 // mail.FromEnv falls back to mail.Logged when SMTP is unset, and Logged
288 // writes recipient, subject and body -- the magic link and the
289 // confirmation token -- into carlos logs, which cannot be grepped clean
290 // afterwards. loggedSender is unexported, so the fallback cannot be
291 // detected from the returned value; check the environment instead.
292 if os.Getenv("CARLOS_SMTP_HOST") == "" || os.Getenv("CARLOS_SMTP_FROM") == "" {
293 if !loopback(env.Origin) {
294 return Env{}, errors.New("movement: no SMTP configured; run carlos email enable")
295 }
296 }
297 return env, nil
298}
299```
300
301- [ ] **Step 4: Run the config tests**
302
303Run: `go test ./internal/movement/ -run TestLoadEnv -v`
304Expected: PASS.
305
306- [ ] **Step 5: Write the failing logging containment guard**
307
308```go
309// internal/movementtest/guards_test.go
310func TestOnlyLoggingPackageImportsSlog(t *testing.T) {
311 root := "../movement"
312 err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
313 if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
314 return err
315 }
316 if filepath.Base(path) == "logging.go" {
317 return nil
318 }
319 src, err := os.ReadFile(path)
320 if err != nil {
321 return err
322 }
323 if bytes.Contains(src, []byte(`"log/slog"`)) {
324 t.Errorf("%s imports log/slog; route it through logging.go", path)
325 }
326 return nil
327 })
328 if err != nil {
329 t.Fatal(err)
330 }
331}
332```
333
334- [ ] **Step 6: Run it and watch it fail**
335
336Run: `go test ./internal/movementtest/ -run TestOnlyLogging -v`
337Expected: FAIL — the scaffold's `app.go` imports slog.
338
339- [ ] **Step 7: Implement logging.go and route existing uses through it**
340
341```go
342// Reason is a fixed vocabulary. It exists so that a log line cannot carry a
343// request-derived string: email and stance together are the sensitive pair,
344// and rastrillo/mail formats its own errors as "send to <address>", so
345// logging an error verbatim would assemble that pair by accident.
346type Reason string
347
348const (
349 ReasonSealInvalid Reason = "seal_invalid"
350 ReasonNonceSpent Reason = "nonce_spent"
351 ReasonTooFast Reason = "too_fast"
352 ReasonTooOld Reason = "too_old"
353 ReasonPowShort Reason = "pow_short"
354 ReasonHoneypot Reason = "honeypot"
355 ReasonBounds Reason = "bounds"
356 ReasonMailFailed Reason = "mail_failed"
357 ReasonMailBudget Reason = "mail_budget"
358)
359
360var logger = slog.Default()
361
362func SetLogger(l *slog.Logger) { logger = l }
363
364// Rejected records why a submission was refused. It takes no address, no
365// stance, and no error.
366func Rejected(r Reason) { logger.Info("signup rejected", "reason", string(r)) }
367
368func Info(msg string) { logger.Info(msg) }
369```
370
371- [ ] **Step 8: Run the whole gate**
372
373Run: `make test`
374Expected: PASS.
375
376- [ ] **Step 9: Commit**
377
378```bash
379git add -A
380git commit -m "Config that refuses to boot, and logging that cannot leak the pair"
381```
382
383---
384
385### Task 3: The copy table and its placeholder discipline
386
387**Files:**
388- Create: `internal/movement/copy.go`, `internal/movement/copy_test.go`
389- Modify: `internal/movementtest/guards_test.go`
390
391**Interfaces:**
392- Consumes: `Env` from Task 2.
393- Produces:
394 - `func T(id string) string` — resolves a copy ID, panicking on an unknown one
395 - `func Placeholders() []string` — IDs still unreviewed
396 - `func CheckPlaceholders(origin string, canonicalHost string) error`
397
398- [ ] **Step 1: Write the failing test**
399
400```go
401func TestPlaceholdersBlockCanonicalOriginOnly(t *testing.T) {
402 canonical := "movement.bdj.oncarlos.com"
403
404 err := CheckPlaceholders("https://movement.bdj.oncarlos.com", canonical)
405 if err == nil {
406 t.Fatal("placeholders must refuse to boot on the canonical host")
407 }
408
409 // A canary serves the same binary and must boot, or the branch cannot be
410 // reviewed on a canary as the shipping cadence requires.
411 if err := CheckPlaceholders("https://c1.movement.bdj.oncarlos.com", canonical); err != nil {
412 t.Fatalf("a canary must boot with placeholders: %v", err)
413 }
414 if err := CheckPlaceholders("http://127.0.0.1:8080", canonical); err != nil {
415 t.Fatalf("loopback must boot with placeholders: %v", err)
416 }
417}
418
419func TestUnknownCopyIDPanics(t *testing.T) {
420 defer func() {
421 if recover() == nil {
422 t.Fatal("an unknown copy ID must panic, not render empty")
423 }
424 }()
425 T("no.such.id")
426}
427```
428
429- [ ] **Step 2: Run it and watch it fail**
430
431Run: `go test ./internal/movement/ -run 'TestPlaceholders|TestUnknownCopy' -v`
432Expected: FAIL, `undefined: CheckPlaceholders`.
433
434- [ ] **Step 3: Implement copy.go**
435
436```go
437type entry struct {
438 text string
439 placeholder bool
440}
441
442// table holds every user-facing string. Plan 2 replaces these with reviewed
443// copy and clears the placeholder flag; until then the app will not boot on
444// the canonical host.
445var table = map[string]entry{
446 "index.headline": {"PLACEHOLDER headline", true},
447 "index.lede": {"PLACEHOLDER lede", true},
448 "stance.a.label": {"PLACEHOLDER stance A", true},
449 "stance.b.label": {"PLACEHOLDER stance B", true},
450 "stance.c.label": {"PLACEHOLDER stance C", true},
451 "stance.a.message": {"PLACEHOLDER message A", true},
452 "stance.b.message": {"PLACEHOLDER message B", true},
453 "stance.c.message": {"PLACEHOLDER message C", true},
454 "form.email": {"PLACEHOLDER email label", true},
455 "form.background": {"PLACEHOLDER background label", true},
456 "form.submit": {"PLACEHOLDER submit", true},
457 "form.nojs": {"PLACEHOLDER no-javascript explanation", true},
458 "form.solving": {"PLACEHOLDER working", true},
459 "form.bademail": {"PLACEHOLDER that address does not look right", true},
460 "sent.title": {"PLACEHOLDER check your inbox", true},
461 "sent.body": {"PLACEHOLDER check your inbox body", true},
462 "confirm.title": {"PLACEHOLDER confirm", true},
463 "confirm.button": {"PLACEHOLDER confirm button", true},
464 "confirm.done": {"PLACEHOLDER you are on the list", true},
465 "confirm.dead": {"PLACEHOLDER that link is no longer valid", true},
466 "mail.confirm.subject": {"PLACEHOLDER confirm subject", true},
467 "mail.confirm.body": {"PLACEHOLDER confirm body with %s", true},
468 "signin.title": {"PLACEHOLDER sign in", true},
469 "signin.sent": {"PLACEHOLDER check your inbox for a link", true},
470 "admin.title": {"PLACEHOLDER admin", true},
471 "banner.placeholder": {"This build carries unreviewed placeholder copy.", false},
472}
473
474// T resolves a copy ID. It panics on an unknown one rather than rendering
475// empty: a missing string should fail in a test, not ship as a blank page.
476func T(id string) string {
477 e, ok := table[id]
478 if !ok {
479 panic("movement: unknown copy id " + id)
480 }
481 return e.text
482}
483
484func Placeholders() []string {
485 var ids []string
486 for id, e := range table {
487 if e.placeholder {
488 ids = append(ids, id)
489 }
490 }
491 sort.Strings(ids)
492 return ids
493}
494
495// CheckPlaceholders refuses the canonical host while any copy is unreviewed.
496// The check is at boot against the origin, not at build time, because there is
497// only one build: the canary serves the same binary production would, and a
498// build-time refusal would make canary review impossible.
499func CheckPlaceholders(origin, canonicalHost string) error {
500 if len(Placeholders()) == 0 {
501 return nil
502 }
503 u, err := url.Parse(origin)
504 if err != nil {
505 return fmt.Errorf("movement: unparseable origin")
506 }
507 if u.Hostname() == canonicalHost {
508 return fmt.Errorf("movement: %d unreviewed copy strings; not booting on %s",
509 len(Placeholders()), canonicalHost)
510 }
511 return nil
512}
513```
514
515- [ ] **Step 4: Run the tests**
516
517Run: `go test ./internal/movement/ -run 'TestPlaceholders|TestUnknownCopy' -v`
518Expected: PASS.
519
520- [ ] **Step 5: Add the no-literal-copy guard**
521
522Append to `internal/movementtest/guards_test.go`: walk
523`internal/movement/templates/*.html`, strip `{{...}}` actions and HTML tags, and
524fail if any run of 3+ consecutive alphabetic words remains. Markup and
525attribute values are fine; sentences are not.
526
527- [ ] **Step 6: Run it**
528
529Run: `go test ./internal/movementtest/ -v`
530Expected: PASS (no templates yet — it will start biting in Task 6).
531
532- [ ] **Step 7: Commit**
533
534```bash
535git add -A
536git commit -m "Copy lives in a table, and placeholders never reach production"
537```
538
539---
540
541### Task 4: The seal, derived keys, and spent nonces
542
543**Files:**
544- Create: `internal/movement/seal.go`, `internal/movement/seal_test.go`
545- Create: `internal/movement/models.go` (the `SpentNonce` part)
546
547**Interfaces:**
548- Consumes: `Env.InstanceKey`.
549- Produces:
550 - `type Challenge struct { Nonce string; IssuedAt int64; Difficulty int; Seal string }`
551 - `func NewChallenge(key []byte, now time.Time, difficulty int) Challenge`
552 - `func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool)`
553 - `func challengeKey(instanceKey string) []byte`
554 - `const MinAge = 3 * time.Second`, `const MaxAge = 2 * time.Hour`
555
556- [ ] **Step 1: Write the failing test**
557
558```go
559func TestSealRoundTripAndBounds(t *testing.T) {
560 key := challengeKey(strings.Repeat("a", 64))
561 now := time.Now()
562 c := NewChallenge(key, now, 18)
563
564 if _, ok := VerifySeal(key, c, now.Add(10*time.Second)); !ok {
565 t.Fatal("a fresh seal must verify")
566 }
567 if r, _ := VerifySeal(key, c, now.Add(time.Second)); r != ReasonTooFast {
568 t.Fatalf("under 3s must be too_fast, got %q", r)
569 }
570 if r, _ := VerifySeal(key, c, now.Add(3*time.Hour)); r != ReasonTooOld {
571 t.Fatalf("over 2h must be too_old, got %q", r)
572 }
573
574 forged := c
575 forged.Difficulty = 1 // the seal covers difficulty; lowering it must fail
576 if r, _ := VerifySeal(key, forged, now.Add(10*time.Second)); r != ReasonSealInvalid {
577 t.Fatalf("a tampered difficulty must be seal_invalid, got %q", r)
578 }
579}
580
581func TestChallengeKeyIsDomainSeparated(t *testing.T) {
582 // The instance key is shared with rastrillo/auth. Deriving with a label
583 // stops a token from one subsystem verifying in the other.
584 if bytes.Equal(challengeKey("abc"), []byte("abc")) {
585 t.Fatal("the challenge key must be derived, not the raw instance key")
586 }
587}
588```
589
590- [ ] **Step 2: Run it and watch it fail**
591
592Run: `go test ./internal/movement/ -run 'TestSeal|TestChallengeKey' -v`
593Expected: FAIL, `undefined: challengeKey`.
594
595- [ ] **Step 3: Implement seal.go**
596
597```go
598const (
599 MinAge = 3 * time.Second
600 MaxAge = 2 * time.Hour
601)
602
603// challengeKey derives the HMAC key for challenge seals from the instance key
604// with a label, mirroring auth's own domain separation. Two subsystems sharing
605// one secret must not be able to verify each other's tokens.
606func challengeKey(instanceKey string) []byte {
607 sum := sha256.Sum256([]byte("movement/challenge\x00" + instanceKey))
608 return sum[:]
609}
610
611type Challenge struct {
612 Nonce string
613 IssuedAt int64
614 Difficulty int
615 Seal string
616}
617
618func sealOf(key []byte, nonce string, issuedAt int64, difficulty int) string {
619 m := hmac.New(sha256.New, key)
620 fmt.Fprintf(m, "%s\x00%d\x00%d", nonce, issuedAt, difficulty)
621 return hex.EncodeToString(m.Sum(nil))
622}
623
624func NewChallenge(key []byte, now time.Time, difficulty int) Challenge {
625 var raw [16]byte
626 if _, err := rand.Read(raw[:]); err != nil {
627 panic("movement: out of randomness")
628 }
629 nonce := hex.EncodeToString(raw[:])
630 issued := now.Unix()
631 return Challenge{nonce, issued, difficulty, sealOf(key, nonce, issued, difficulty)}
632}
633
634// VerifySeal returns the reason a challenge is unacceptable, and whether it is
635// acceptable. Order matters: the seal is checked before the age, because
636// IssuedAt is attacker-controlled until the signature says otherwise.
637func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool) {
638 want := sealOf(key, c.Nonce, c.IssuedAt, c.Difficulty)
639 if !hmac.Equal([]byte(want), []byte(c.Seal)) {
640 return ReasonSealInvalid, false
641 }
642 age := now.Sub(time.Unix(c.IssuedAt, 0))
643 switch {
644 case age < MinAge:
645 return ReasonTooFast, false
646 case age > MaxAge:
647 return ReasonTooOld, false
648 }
649 return "", true
650}
651```
652
653- [ ] **Step 4: Run the tests**
654
655Run: `go test ./internal/movement/ -run 'TestSeal|TestChallengeKey' -v`
656Expected: PASS.
657
658- [ ] **Step 5: Add the SpentNonce model and migration**
659
660```go
661// models.go
662// SpentNonce records a nonce that has been accepted. Nothing is written when a
663// challenge is minted: a row per page view would make a crawler into a
664// serialised-write attack on the single SQLite writer. Written on acceptance,
665// each row costs an attacker one proof-of-work solve.
666type SpentNonce struct {
667 Nonce string `gorm:"primaryKey"`
668 ExpiresAt time.Time `gorm:"index"`
669}
670```
671
672Run `rastrillo migration generate`, read the emitted SQL, and commit it.
673
674- [ ] **Step 6: Run the gate**
675
676Run: `make test`
677Expected: PASS, including `migration check`.
678
679- [ ] **Step 7: Commit**
680
681```bash
682git add -A
683git commit -m "Sealed challenges, a derived key, and somewhere to spend a nonce"
684```
685
686---
687
688### Task 5: Proof of work, both sides, and the crossing test
689
690**Files:**
691- Create: `internal/movement/pow.go`, `internal/movement/pow_test.go`
692- Create: `internal/movement/static/sha256.js`, `internal/movement/static/pow.js`
693- Create: `internal/movementtest/browser_pow_test.go` (`//go:build browser`)
694
695**Interfaces:**
696- Consumes: `Challenge` from Task 4.
697- Produces:
698 - `func NormalizeEmail(s string) string` — ASCII-only lowercasing
699 - `func PowPreimage(nonce, email, counter string) string`
700 - `func VerifyPow(nonce, email, counter string, difficulty int) bool`
701 - `const DefaultDifficulty = 18`
702
703- [ ] **Step 1: Write the failing test**
704
705```go
706func TestPowBindsToTheAddress(t *testing.T) {
707 const d = 8 // low, so the test can brute-force it quickly
708 nonce := "abc123"
709
710 counter := solveForTest(t, nonce, "alice@example.com", d)
711 if !VerifyPow(nonce, "alice@example.com", counter, d) {
712 t.Fatal("a valid solution must verify")
713 }
714 // Ruling 3: the whole point. One solve must not be reusable against
715 // another address, or a bulk attack costs an attacker one solve total.
716 if VerifyPow(nonce, "bob@example.com", counter, d) {
717 t.Fatal("a solution must not transfer to another address")
718 }
719 if VerifyPow(nonce, "alice@example.com", "0", d+8) {
720 t.Fatal("a short solution must be rejected")
721 }
722}
723
724func TestNormalizeEmailIsASCIIOnly(t *testing.T) {
725 // Go's strings.ToLower and JS toLowerCase disagree on some Unicode, and a
726 // disagreement here is a silent pow_short nobody can diagnose.
727 in := "İX@Example.COM"
728 if got := NormalizeEmail(in); got != "İx@example.com" {
729 t.Fatalf("ASCII-only lowering expected, got %q", got)
730 }
731}
732
733func solveForTest(t *testing.T, nonce, email string, d int) string {
734 t.Helper()
735 for i := 0; i < 1<<24; i++ {
736 c := strconv.Itoa(i)
737 if VerifyPow(nonce, email, c, d) {
738 return c
739 }
740 }
741 t.Fatal("no solution found")
742 return ""
743}
744```
745
746- [ ] **Step 2: Run it and watch it fail**
747
748Run: `go test ./internal/movement/ -run 'TestPow|TestNormalize' -v`
749Expected: FAIL, `undefined: VerifyPow`.
750
751- [ ] **Step 3: Implement pow.go**
752
753```go
754const DefaultDifficulty = 18
755
756// NormalizeEmail lowercases ASCII only. Unicode case folding differs between
757// Go and JavaScript, and the preimage must be byte-identical on both sides.
758func NormalizeEmail(s string) string {
759 b := []byte(strings.TrimSpace(s))
760 for i, c := range b {
761 if c >= 'A' && c <= 'Z' {
762 b[i] = c + 32
763 }
764 }
765 return string(b)
766}
767
768func PowPreimage(nonce, email, counter string) string {
769 return nonce + ":" + email + ":" + counter
770}
771
772func leadingZeroBits(sum []byte) int {
773 n := 0
774 for _, b := range sum {
775 if b == 0 {
776 n += 8
777 continue
778 }
779 n += bits.LeadingZeros8(b)
780 break
781 }
782 return n
783}
784
785func VerifyPow(nonce, email, counter string, difficulty int) bool {
786 sum := sha256.Sum256([]byte(PowPreimage(nonce, NormalizeEmail(email), counter)))
787 return leadingZeroBits(sum[:]) >= difficulty
788}
789```
790
791- [ ] **Step 4: Run the tests**
792
793Run: `go test ./internal/movement/ -run 'TestPow|TestNormalize' -v`
794Expected: PASS.
795
796- [ ] **Step 5: Write the browser solver**
797
798`static/sha256.js` — a synchronous SHA-256 exporting `sha256Bytes(str)`.
799WebCrypto's `subtle.digest` is async per call and cannot be an inner loop.
800
801`static/pow.js` — a module that starts a Worker, and a worker body that loops:
802
803```js
804// pow.js — solves SHA-256(nonce ":" email ":" counter) for `difficulty`
805// leading zero bits. Runs in a Worker: 2^18 hashes on the main thread freezes
806// a phone for seconds.
807import { sha256Bytes } from "./sha256.js";
808
809export function solve(nonce, email, difficulty, onProgress) {
810 return new Promise((resolve) => {
811 const lower = email.trim().replace(/[A-Z]/g, (c) =>
812 String.fromCharCode(c.charCodeAt(0) + 32)); // ASCII only, matching Go
813 let i = 0;
814 const step = () => {
815 const deadline = performance.now() + 50;
816 while (performance.now() < deadline) {
817 const sum = sha256Bytes(nonce + ":" + lower + ":" + i);
818 if (leadingZeroBits(sum) >= difficulty) return resolve(String(i));
819 i++;
820 }
821 onProgress(i);
822 setTimeout(step, 0);
823 };
824 step();
825 });
826}
827```
828
829- [ ] **Step 6: Write the crossing test**
830
831```go
832//go:build browser
833
834// The gate that matters: the browser's solver against the Go verifier. A
835// Go-only test proves the check, not the scheme.
836func TestBrowserSolverSatisfiesGoVerifier(t *testing.T) { /* drive pow.js via chromedp, assert VerifyPow */ }
837```
838
839- [ ] **Step 7: Run both**
840
841Run: `make test && make test-browser`
842Expected: PASS.
843
844- [ ] **Step 8: Commit**
845
846```bash
847git add -A
848git commit -m "Proof of work bound to the address, proven across the wire"
849```
850
851---
852
853### Task 6: Models, migrations, and the signup handler
854
855**Files:**
856- Create: `internal/movement/signup.go`, `internal/movement/budget.go`,
857 `internal/movement/render.go`, `internal/movement/pages.go`,
858 `internal/movement/templates/*.html`
859- Modify: `internal/movement/models.go`, `internal/movement/migrations.go`
860- Create: `internal/movementtest/signup_test.go`
861
862**Interfaces:**
863- Consumes: `Challenge`, `VerifySeal`, `VerifyPow`, `T`, `Rejected`.
864- Produces: `func (a *app) handleSignup(w http.ResponseWriter, r *http.Request)`;
865 `type Signup`, `type MailCounter`.
866
867- [ ] **Step 1: Write the failing gates**
868
869Write, in `internal/movementtest/signup_test.go`: no-enumeration (unknown,
870pending and confirmed produce byte-identical responses); nonce replay rejected;
871confirmed row immutable and unmailed; honeypot returns success and writes
872nothing; a stance-page GET writes no row; bounds rejection; and that budget
873columns survive a pending overwrite.
874
875- [ ] **Step 2: Run and watch them fail**
876
877Run: `go test ./internal/movementtest/ -v`
878Expected: FAIL, no `/signup` route.
879
880- [ ] **Step 3: Add the models**
881
882```go
883type Signup struct {
884 ID int64
885 Email string `gorm:"uniqueIndex"`
886 Stance string
887 Background string
888 Confirmed bool
889 ConfirmedAt *time.Time
890 TokenHash []byte
891 TokenExpiry time.Time
892 LastMailedAt *time.Time
893 MailCount int
894 CreatedAt time.Time
895 UpdatedAt time.Time
896}
897
898type MailCounter struct {
899 HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15"
900 Count int
901}
902```
903
904`rastrillo migration generate`, read the SQL, commit it.
905
906- [ ] **Step 4: Implement the handler**
907
908Check order — honeypot first, because it is free and must not sit behind
909anything that writes; then seal, age, bounds, PoW; then the in-memory limiter
910(after PoW, so tripping a stranger's budget costs a solve); then one
911transaction: insert the nonce `ON CONFLICT DO NOTHING` and roll back if
912`RowsAffected == 0`, then the signup write, then the counter bump. Send after
913commit (Task 8).
914
915```go
916func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) {
917 if r.PostFormValue("website") != "" { // the honeypot
918 Rejected(ReasonHoneypot)
919 a.renderSent(w, r) // success, and nothing written
920 return
921 }
922 // ... seal, age, bounds, pow, limiter — every failure calls Rejected and
923 // then a.renderSent(w, r), so every outcome renders the same bytes.
924
925 err := a.db.Transaction(func(tx *gorm.DB) error {
926 res := tx.Clauses(clause.OnConflict{DoNothing: true}).
927 Create(&SpentNonce{Nonce: c.Nonce, ExpiresAt: time.Unix(c.IssuedAt, 0).Add(MaxAge)})
928 if res.Error != nil {
929 return res.Error
930 }
931 if res.RowsAffected == 0 {
932 return errReplay // rolls back; the only statement was a no-op
933 }
934 // ... the signup write per ruling 14, then the counter bump
935 return nil
936 })
937 // ... classify, Rejected(...), then always a.renderSent(w, r)
938}
939```
940
941The pending overwrite must name its columns and exclude the budget ones:
942
943```go
944tx.Model(&row).Select("Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt").
945 Updates(map[string]any{ /* ... */ })
946```
947
948- [ ] **Step 5: Run the gates**
949
950Run: `go test ./internal/movementtest/ -v`
951Expected: PASS.
952
953- [ ] **Step 6: Prove the no-enumeration gate can fail**
954
955Temporarily make the confirmed path render a different message. Re-run; the
956gate must catch it. Revert.
957
958- [ ] **Step 7: Commit**
959
960```bash
961git add -A
962git commit -m "One handler, four checks, one transaction, one page for every outcome"
963```
964
965---
966
967### Task 7: Confirmation, GET renders and POST burns
968
969**Files:**
970- Create: `internal/movement/confirm.go`, `internal/movement/templates/confirm.html`
971- Create: `internal/movementtest/confirm_test.go`
972
973**Interfaces:**
974- Consumes: `Signup`, `T`.
975- Produces: `handleConfirmPage`, `handleConfirm`.
976
977- [ ] **Step 1: Write the failing test** — a `GET /confirm?token=…` confirms
978 nothing (mail scanners fetch links on arrival); a `POST` confirms once; a
979 second POST is indistinguishable from an unknown token; an expired token is
980 refused.
981
982- [ ] **Step 2: Run and watch it fail.** Expected: 404, no route.
983
984- [ ] **Step 3: Implement.** GET renders a page with one Confirm button and the
985 token in a hidden field. POST hashes the token, finds a live row with that
986 hash and an unexpired `TokenExpiry`, sets `Confirmed`/`ConfirmedAt`, clears
987 `TokenHash`. Unknown, expired and burned all render the same page.
988
989- [ ] **Step 4: Run.** Expected: PASS.
990
991- [ ] **Step 5: Commit**
992
993```bash
994git add -A
995git commit -m "Confirmation burns on POST, so a mail scanner cannot join for you"
996```
997
998---
999
1000### Task 8: The mailer, the budgets, and sending after commit
1001
1002**Files:**
1003- Create: `internal/movement/mailer.go`
1004- Modify: `internal/movement/budget.go`, `internal/movement/signup.go`
1005- Create: `internal/movementtest/mail_test.go`
1006
1007**Interfaces:**
1008- Consumes: `mail.Sender`, `MailCounter`, `Signup`.
1009- Produces: `func BudgetedSender(inner mail.Sender, db *gorm.DB) mail.Sender`;
1010 `const PerAddressPerDay = 3`, `const GlobalPerHour = 200`.
1011
1012- [ ] **Step 1: Write the failing tests** — a fourth mail to one address in 24h
1013 is suppressed; the global ceiling suppresses and logs `mail_budget`; a
1014 suppressed send returns `nil` (an error would make `auth.Begin` redirect to
1015 `?err=`, a different response, and log the address); a hung sender does not
1016 block a second submission; the send happens after commit.
1017
1018- [ ] **Step 2: Run and watch them fail.**
1019
1020- [ ] **Step 3: Implement.** Bump the counter in one statement inside the
1021 transaction:
1022
1023```go
1024tx.Exec(`INSERT INTO mail_counters (hour_bucket, count) VALUES (?, 1)
1025 ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1`, bucket)
1026```
1027
1028Wrap the sender handed to `auth.New` so admin sign-in mail draws on the same
1029global budget. The wrapper commits its count before sending, applies its own
1030deadline — `mail.Send` discards the context it is given and `smtp.SendMail`
1031dials with none — and returns `nil` when it suppresses.
1032
1033Send from a goroutine after commit, with a deadline of a few seconds, well
1034under the platform's idle window.
1035
1036- [ ] **Step 4: Run.** Expected: PASS.
1037
1038- [ ] **Step 5: Commit**
1039
1040```bash
1041git add -A
1042git commit -m "Budgets that survive a restart, and a send that cannot hold the writer"
1043```
1044
1045---
1046
1047### Task 9: Admin — an app-owned sign-in, and an export that cannot execute
1048
1049**Files:**
1050- Create: `internal/movement/admin.go`, templates
1051- Create: `internal/movementtest/admin_test.go`
1052
1053**Interfaces:**
1054- Consumes: `auth`, `Env.Admins`.
1055- Produces: `func (a *app) requireAdmin(next http.Handler) http.Handler`;
1056 `handleSigninPage`, `handleSignin`, `handleAdmin`, `handleExport`.
1057
1058- [ ] **Step 1: Write the failing tests** — all seven admin routes 404 with an
1059 empty allowlist, iterated not spot-checked; a non-allowlisted address posted
1060 to `/signin` sends no mail and gets the same redirect; a removed admin loses
1061 access after a restart with the old cookie (not `t.Setenv` — the environment
1062 is fixed at exec, so that would pass while proving nothing); the CSV excludes
1063 pending rows and escapes formula-leading cells.
1064
1065- [ ] **Step 2: Run and watch them fail.**
1066
1067- [ ] **Step 3: Implement.** `POST /signin` is ours: check `MOVEMENT_ADMINS`
1068 first, then delegate to `auth.Begin`, answering the identical redirect either
1069 way — `auth.Begin` sends before `Authorize` ever runs, which would make this
1070 app a mailer anyone could point at any address. Set
1071 `auth.Config.SessionTTL = 8 * time.Hour`. `requireAdmin` re-checks the
1072 allowlist per request. Escape every CSV cell beginning `=`, `+`, `-`, `@`,
1073 tab or CR.
1074
1075- [ ] **Step 4: Run.** Expected: PASS.
1076
1077- [ ] **Step 5: Commit**
1078
1079```bash
1080git add -A
1081git commit -m "Admin that fails closed, and a sign-in that will not mail strangers"
1082```
1083
1084---
1085
1086### Task 10: The sweep
1087
1088**Files:**
1089- Create: `internal/movement/sweep.go`
1090- Modify: `internal/movement/app.go` (mount outside the CSRF router)
1091- Create: `internal/movementtest/sweep_test.go`
1092
1093**Interfaces:**
1094- Consumes: `carlos.Tick`, `auth.Sweep`.
1095- Produces: `func (a *app) handleSweep(w http.ResponseWriter, r *http.Request)`.
1096
1097- [ ] **Step 1: Write the failing test** — the headline gate: a POST carrying
1098 **no** `Origin`, `Referer` or `Sec-Fetch-Site` and a valid bearer must reach
1099 the sweep. `csrf.SameOrigin` refuses header-less POSTs, and the platform's
1100 scheduler sends none of the three, so mounted inside the protected router the
1101 tick 403s forever and the platform reads 4xx as "do not retry". Plus: the
1102 sweep spares confirmed rows and anything with a live token.
1103
1104- [ ] **Step 2: Run and watch it fail.**
1105
1106- [ ] **Step 3: Implement.** Mount `POST /tick/sweep` on the outer
1107 `http.ServeMux` beside `mux.Handle("/", r)`, guarded by `carlos.Tick(r)`
1108 alone. Work synchronously in the request — a 202 and a goroutine risks
1109 hibernation mid-job. Delete unconfirmed rows older than 30 days, expired
1110 `spent_nonces`, and call `auth.Sweep(now)`. All three are idempotent, so
1111 at-least-once delivery needs no `TickOccurrence` dedupe.
1112
1113- [ ] **Step 4: Run.** Expected: PASS.
1114
1115- [ ] **Step 5: Prove the CSRF gate can fail.** Temporarily mount the tick
1116 inside the chi router; the test must catch the 403. Revert.
1117
1118- [ ] **Step 6: Declare the schedule**
1119
1120```bash
1121carlos schedule set -app movement -name sweep -every 1h -path /tick/sweep
1122```
1123
1124- [ ] **Step 7: Commit**
1125
1126```bash
1127git add -A
1128git commit -m "A tick the scheduler can actually reach"
1129```
1130
1131---
1132
1133## Deferred to plan 2 (copy-dependent)
1134
1135Named here so their absence is a decision rather than a gap: real copy in
1136`internal/copy`, the three stance slugs, page design and `tokens.css` work,
1137difficulty calibration at p95/p99 on real hardware, and the deploy to
1138`movement.bdj.oncarlos.com`.
1139
1140## Self-review notes
1141
1142**Spec coverage.** §3 → Task 2. §4 → Tasks 4, 6. §5 → Task 5. §6 → Task 4.
1143§7 → Tasks 4, 6. §8 → Task 6. §9 → Task 8. §10 → Tasks 2, 8. §11 → Task 9.
1144§11a → Task 10. §12 gates 1–14a → Tasks 2–10; gate 15 is plan 2.
1145
1146**Type consistency.** `Challenge` is produced in Task 4 and consumed unchanged
1147in Tasks 5 and 6. `Reason` constants are defined once in Task 2 and used by
1148name thereafter. `NormalizeEmail` is the single lowercasing path and both the
1149Go verifier and `pow.js` must agree byte-for-byte — Task 5's crossing test is
1150what proves it.
1151
1152**Known soft spots.** Task 5's `sha256.js` is the one substantial piece of
1153hand-written cryptographic code; it is a straight transcription and its test is
1154comparison against Go's `crypto/sha256` over a corpus, not cleverness. Task 6's
1155handler is the largest single file and the place to watch for growth — if it
1156passes ~250 lines, the checks should move to `checks.go` behind one function
1157returning `(Reason, bool)`. Task 8's send-after-commit is the only place the
1158app does work after writing the response; the deadline must stay short.
1159