carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Console email sign-in Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** One email field on the console front page: Keymail addresses go to Keymail's OAuth sign-in, every other address gets a magic link and code by email; email sessions re-confirm by emailed link or code; invites and notices reach plain email addresses.
6
7**Architecture:** A new `email_challenges` table (hashed link token, MACed code under an in-memory key) backs sign-in and step-up challenges. `POST /auth/continue` runs discovery against Keymail's public federation lookup, then either starts the existing Keymail ceremony or creates a challenge and sends it on a background goroutine through the existing SES console mailer. Sessions gain a `method` column that decides how `requireFreshAuth` re-confirms.
8
9**Tech Stack:** Go, `net/http`, SQLite (`modernc.org/sqlite`), html/template, AWS SESv2 via `internal/mail`.
10
11**Spec:** `docs/superpowers/specs/2026-10-21-console-email-signin-design.md` (rev 5, approved). Approved copy: `docs/superpowers/specs/2026-10-21-console-email-signin-copy-approved.json`.
12
13## Global Constraints
14
15- All paths are under the worktree `/home/paulca/amadan.net/carlos/platform/.claude/worktrees/email-signin`.
16- Every user-facing string comes **byte for byte** from the approved copy JSON (`strings[].text`, by `id`). Never edit, re-punctuate or "improve" them. Placeholders `{address}`, `{console}`, `{link}`, `{code}`, `{inviter}`, `{account}`, `{to}` are filled with `strings.NewReplacer`. Log lines and internal errors are not copy.
17- No em dashes in any new user-facing text (there is none outside the approved JSON anyway).
18- Challenge lifetimes: sign-in 15 min (`ConfirmationTTL`), step-up 10 min (`stepUpFlowTTL`). 5 wrong codes burn a challenge. Code: 6 decimal digits from `crypto/rand`. Link token: `randomToken()` (32 bytes).
19- Rate limits (spec §7, §5): per address 1/60 s, 5/hour, 20/day (sign-in emails); per IP 10 sign-in emails/hour and 30 `/auth/continue` submissions/hour; console-wide 200 sign-in emails/hour; step-up per address 1/60 s, 10/hour; wrong codes 10/day per address and 30/hour per IP.
20- Discovery uses only Keymail's public answer (domain == `CONSOLE_KEYMAIL` host, or lookup 200). Never a stored grant.
21- Lookup classification: 200 = mailbox; 400, 404, 409 = definitively none; anything else (timeout, network, 5xx) = unreachable.
22- Cookies: `carlos_console_email` (challenge id, 15 min), `carlos_console_linkconfirm` (nonce, 15 min), `carlos_console_next` (10 min); all via `s.setCookie` (HttpOnly, Lax, Secure on https).
23- Tests: run only the touched packages with `-run` (Paul's rule): `go test ./internal/console -run '<names>' -count=1`. CI runs the full suite. `gofmt -l`, `go vet ./internal/console ./cmd/console`, `go build ./...` stay local. If the Go toolchain reports "read-only file system", rerun with the sandbox off; leave `GOCACHE` at its default.
24- Every new route is added to `internal/console/testdata/claim-routes.txt` and gets a probe in `claim_invariant_probes_nonapp_test.go` (copy the shape of `claimSigninProbe`/`claimConfirmProbe`), and the new table gets a `claimSQLTables` row in `store_gate.go` (`NonApp`), in the same task that adds it.
25- Do not spawn Anthropic-model subagents from a Codex run; do not delete files by glob; scratch goes under `$TMPDIR`.
26
27## Review Focus
28
291. **A Keymail user typing their address must never receive an email-method session** (downgrade). Pin: discovery test where lookup says 200 and a grant is absent still redirects to Keymail; session method after Keymail callback is `keymail`; `requireFreshAuth` for a `keymail` session never creates an email challenge. (Task 6, Task 8)
302. **Mail scanners prefetching GET links.** A GET of `/auth/email/link?t=` twice, then POST, must still sign in once. (Task 7)
313. **Mixed-case / padded addresses** (` Bob@Example.COM `) map to one identity, one rate-limit key and one user row; display-name and list forms are refused before anything is stored. (Task 6)
324. **Console restart between request and code entry**: the code fails as "used up", the link still works. Pin with a store reopened with a new key. (Task 1)
335. **Asking twice from one browser** (impatient double submit): the newer challenge replaces the older, so only the newest email's link and code work, and the page says nothing confusing. (Task 1, Task 7)
34
35---
36
37### Task 1: Challenge store and session method
38
39**Files:**
40- Create: `internal/console/emailsignin_store.go`
41- Create: `internal/console/emailsignin_store_test.go`
42- Modify: `internal/console/store.go` (call `migrateEmailSignin()` after `migrateStepUp()` ~line 518; `PurgeExpired` also deletes expired `email_challenges`; `CreateSession` gains a method; add `SessionInfo`)
43- Modify: `internal/console/store_gate.go` (`claimSQLTables`: `email_challenges` → `NonApp`)
44
45**Interfaces:**
46- Produces:
47 ```go
48 const (ChallengeSignIn = "signin"; ChallengeStepUp = "stepup")
49 const (MethodKeymail = "keymail"; MethodEmail = "email")
50 type EmailChallenge struct {
51 Purpose, Address, SessionHash, PendingHash, Next string
52 ExpiresAt time.Time
53 }
54 type CodeResult int // CodeOK, CodeWrong, CodeGone
55 func (s *Store) CreateEmailChallenge(c EmailChallenge, replaceID string, now time.Time) (id, link, code string, err error)
56 func (s *Store) PeekEmailLink(link string, now time.Time) (*EmailChallenge, bool)
57 func (s *Store) TakeEmailLink(link string, now time.Time) (*EmailChallenge, bool)
58 func (s *Store) TakeEmailCode(id, code, purpose string, now time.Time) (*EmailChallenge, CodeResult)
59 func (s *Store) DeleteEmailChallenge(id string) error
60 func (s *Store) CreateSessionMethod(address, keymailToken, method string, now time.Time) (string, error)
61 func (s *Store) SessionInfo(rawToken string, now time.Time) (address, method string, ok bool)
62 ```
63 `CreateSession(address, token, now)` stays and calls `CreateSessionMethod(..., MethodKeymail, now)`.
64
65- [ ] **Step 1: Write failing tests** in `emailsignin_store_test.go`:
66 - `TestEmailChallengeLinkPeekDoesNotConsume`: create (signin, `bob@example.com`, Next `/auth/cli`); `PeekEmailLink` twice → ok both, Address and Next match; `TakeEmailLink` → ok; `TakeEmailLink` again → !ok.
67 - `TestEmailChallengeCode`: right code → `CodeOK` and the row is gone (`TakeEmailLink(link)` → !ok); wrong code 4× → `CodeWrong`, 5th wrong → `CodeGone`, then the right code → `CodeGone`; wrong purpose → `CodeGone`.
68 - `TestEmailChallengeExpiry`: `now+15m+1s` → link and code both fail; the expired row is deleted by the attempt (burn-then-check).
69 - `TestEmailChallengeCodeKeyIsProcessLocal`: create, close, `Open` the same file → code → `CodeGone`, link → ok (Review Focus 4). Assert the stored `code_mac` differs from `hex(sha256(id||code))`.
70 - `TestEmailChallengeReplace`: create A, create B with `replaceID=A.id` → A's link and code both fail, B's work.
71 - `TestSessionMethod`: `CreateSession` → `SessionInfo` method `keymail`; `CreateSessionMethod(..., MethodEmail)` → `email`; a database created before the migration (insert a sessions row without the column via the old schema, then `Open`) reads `keymail`.
72- [ ] **Step 2: Run** `go test ./internal/console -run 'TestEmailChallenge|TestSessionMethod' -count=1` → FAIL (undefined).
73- [ ] **Step 3: Implement.** Table exactly as spec §5 with `code_mac`; `ALTER TABLE sessions ADD COLUMN method TEXT NOT NULL DEFAULT 'keymail'` via the existing duplicate-column-tolerant ALTER list. `Store` gets `codeKey []byte` (32 random bytes minted in `Open`); `code_mac = hex(HMAC-SHA256(codeKey, id + ":" + code))`, compared with `hmac.Equal`. `id`, `link` are `randomToken()`; stored as `hashToken`. Code via `crypto/rand` uniform in [0, 1e6), zero-padded. `TakeEmailCode` runs in one transaction: load by id hash; gone/expired/purpose-mismatch → delete if present, `CodeGone`; MAC mismatch → `attempts+1`, delete at 5 and return `CodeGone` on the 5th, else `CodeWrong`; match → delete, `CodeOK`. `replaceID` deletes that row wholly before the insert.
74- [ ] **Step 4: Run** the same command → PASS. `gofmt -l internal/console`, `go vet ./internal/console`.
75- [ ] **Step 5: Commit** `console: email challenge store and session method`.
76
77### Task 2: Rate limits
78
79**Files:**
80- Create: `internal/console/emailsignin_limits.go`, `internal/console/emailsignin_limits_test.go`
81
82**Interfaces:**
83- Produces:
84 ```go
85 type emailLimits struct { now func() time.Time; /* windows */ }
86 func (l *emailLimits) submit(ip string) bool // 30/h per IP, records
87 func (l *emailLimits) signinSend(addr, ip string) bool // all-or-nothing: addr 1/60s,5/h,20/d; ip 10/h; global 200/h
88 func (l *emailLimits) stepUpSend(addr string) bool // 1/60s, 10/h
89 func (l *emailLimits) codeAllowed(addr, ip string) bool // false once addr ≥10 wrong/24h or ip ≥30 wrong/h
90 func (l *emailLimits) codeFailed(addr, ip string)
91 ```
92- [ ] **Step 1: Tests** with an injected clock: `TestSigninSendLimits` (second within 60 s refused; 6th in an hour refused at 61 s spacing; 21st in a day refused; a refusal records nothing, so a refused call does not push the window); `TestSigninSendIPAndGlobal` (11th from one IP across 11 addresses refused; 201st console-wide refused); `TestStepUpSendSeparate` (a sign-in send does not consume step-up budget, and vice versa); `TestCodeGuessCaps` (10 `codeFailed` for one address → `codeAllowed` false for that address from any IP; recovers after 24 h; 30 from one IP blocks that IP for any address).
93- [ ] **Step 2: Run** `-run 'TestSigninSend|TestStepUpSend|TestCodeGuess'` → FAIL.
94- [ ] **Step 3: Implement** a keyed sliding window (`map[string][]time.Time` + mutex, pruned on access, the `inviteWindow` shape) and the five methods. A periodic prune is not needed; entries are pruned when their key is touched, and a key untouched for a day is dropped by `submit`'s sweep of at most 64 stale keys per call.
95- [ ] **Step 4: Run** → PASS.
96- [ ] **Step 5: Commit** `console: email sign-in rate limits`.
97
98### Task 3: Keymail discovery and the delivery rule
99
100**Files:**
101- Modify: `internal/console/keymailfed.go` (classify lookup errors; add `HasMailbox`)
102- Modify: `internal/console/keymail.go` (`Host() string` = hostname of the configured base URL)
103- Modify: `internal/console/keymail_test.go` (fake lookup can answer 200/400/404/409/500/slow per address)
104- Create: `internal/console/delivery.go`, `internal/console/delivery_test.go`
105- Modify: `internal/console/server.go` `handleInvite` (use `deliver`)
106
107**Interfaces:**
108- Produces:
109 ```go
110 var ErrKeymailUnreachable = errors.New("keymail: lookup unreachable")
111 func (k *Keymail) HasMailbox(ctx context.Context, addr string) (bool, error) // (true,nil) 200; (false,nil) 400/404/409; (false, ErrKeymailUnreachable-wrapped) otherwise
112 func (k *Keymail) Host() string
113 // SendFederated: lookup 400/404/409 → ErrNoKeymailMailbox (as today); other lookup errors → ErrKeymailUnreachable (wrapping the cause)
114 type deliveryChannel string // "keymail", "email", ""
115 func (s *Server) deliver(ctx context.Context, to, subject, body string, keymailShare time.Duration) (deliveryChannel, error)
116 ```
117 `deliver` is spec §4.3: federation first; `IsKeymailBlocked`, `ErrFederationPriceTooHigh`, post-lookup failures → stop with that error; `ErrNoKeymailMailbox` → console mailer; `ErrKeymailUnreachable` → stop if `domainOf(to) == s.km.Host()`, else console mailer; nil mailer → error.
118- [ ] **Step 1: Tests.** `TestHasMailboxClassification` (each status, a 3 s-stalled lookup under a 1 s ctx → unreachable). `TestDeliverChain` table: no mailbox (404) → mailer called, channel `email`; foreign domain (400) → mailer; federation OK → `keymail`, mailer not called; blocked → error, mailer not called; lookup 500 for `x@<keymail host>` → error, mailer not called; lookup 500 for `x@example.com` → mailer. Existing invite tests (`invite_delivery_test.go`) keep passing unchanged except where they assert the old wrap of 5xx.
119- [ ] **Step 2: Run** `-run 'TestHasMailbox|TestDeliverChain|TestInvite'` → FAIL.
120- [ ] **Step 3: Implement**; `handleInvite` replaces its inline chain with `s.deliver(ctx, to, subject, body, s.inviteKeymailShare)` and maps channel → the existing flashes (`keymail` → `inviteFlashKeymail`, `email` → `inviteFlashEmail`, error → `inviteFlashFailed`).
121- [ ] **Step 4: Run** → PASS.
122- [ ] **Step 5: Commit** `console: keymail discovery, and federation falls through to SES only on a definite no`.
123
124### Task 4: Approved copy, console name, mailer readiness
125
126**Files:**
127- Create: `internal/console/signincopy.go`, `internal/console/signincopy_test.go`
128- Modify: `internal/console/consolemail.go` (`Ready`)
129- Modify: `internal/console/server.go` (`Config.ConsoleName string`; `Server.consoleName()` default `"the CARLOS platform"`)
130- Modify: `cmd/console/main.go` (`ConsoleName: os.Getenv("CARLOS_CONSOLE_NAME")`, and the header comment documents it with `CARLOS_CONSOLE_MAIL_FROM`)
131
132**Interfaces:**
133- Produces:
134 ```go
135 // signinCopy holds every approved string by its review id.
136 var signinCopy = map[string]string{ "home.email_label": "Your email address", /* …all 38… */ }
137 func copyText(id string, kv ...string) string // strings.NewReplacer("{"+k+"}", v, ...) over signinCopy[id]; panics on an unknown id
138 type ConsoleMailer interface {
139 Send(ctx context.Context, to, subject, body string) error
140 Ready(ctx context.Context) bool
141 }
142 ```
143- [ ] **Step 1: Tests.** `TestSigninCopyMatchesApproved`: read `../../docs/superpowers/specs/2026-10-21-console-email-signin-copy-approved.json`, assert `action == "approve"` and that `signinCopy` has exactly those ids with byte-identical text. `TestConsoleMailerReady`: unverified → false and no SES call; verified → true; a ctx that has already ended → false. Update every `ConsoleMailer` fake (`fakeConsoleMail` in `invite_delivery_test.go`) with a `ready bool` field.
144- [ ] **Step 2: Run** `-run 'TestSigninCopy|TestConsoleMailerReady|TestInvite'` → FAIL.
145- [ ] **Step 3: Implement.** Generate the map literal from the JSON once (a throwaway script under `$TMPDIR`), paste it, and let the test guard it. `Ready` wraps `m.ready` with `context.WithTimeout(ctx, 2*time.Second)` and returns `err == nil`.
146- [ ] **Step 4: Run** → PASS; `go build ./...`.
147- [ ] **Step 5: Commit** `console: approved sign-in copy, CARLOS_CONSOLE_NAME, mailer readiness`.
148
149### Task 5: Shared sign-in tail and safe `next`
150
151**Files:**
152- Create: `internal/console/emailsignin.go` (helpers here; handlers land in Tasks 6–8)
153- Modify: `internal/console/server.go` (`handleCallback` uses `finishSignin`; `handleSignin` stores `next`)
154- Test: `internal/console/emailsignin_test.go`
155
156**Interfaces:**
157- Produces:
158 ```go
159 const nextCookie = "carlos_console_next"
160 func safeNext(raw string) string // "" unless a local path: starts "/", not "//", no `\`, url.Parse ok with empty Scheme and Host
161 func (s *Server) finishSignin(w http.ResponseWriter, r *http.Request, addr, keymailToken, method, next string)
162 ```
163 `finishSignin`: `EnsureUser`; `CreateSessionMethod`; session cookie; welcome cookie when created; when `method == MethodEmail`, `RecordStepUp(raw, addr, now, now)`; then `s.currentAccount(ctx, addr)` (mints for a new user) and, if `safeNext(next) != ""`, redirect there (303), else the existing `redirectToCurrentAccount` behaviour. `handleCallback` reads and clears `nextCookie` and passes it. `handleSignin` accepts `?next=` and sets `nextCookie` to `safeNext(next)` when non-empty.
164- [ ] **Step 1: Tests.** `TestSafeNext` table: `/auth/cli` ok; `/invite/abc` ok; `//evil.com`, `/\evil.com`, `https://evil.com/x`, `javascript:alert(1)`, `` → "". `TestKeymailCallbackFollowsNext`: fixture, `GET /auth/signin?next=/auth/cli` then the fake-keymail callback → 303 to `/auth/cli`, session method `keymail`, a brand-new address has a membership (personal account minted).
165- [ ] **Step 2: Run** `-run 'TestSafeNext|TestKeymailCallbackFollowsNext|TestCallback'` → FAIL.
166- [ ] **Step 3: Implement.**
167- [ ] **Step 4: Run** → PASS (existing callback tests unchanged).
168- [ ] **Step 5: Commit** `console: one sign-in tail for both doors, and a safe next`.
169
170### Task 6: The front door (discovery)
171
172**Files:**
173- Modify: `internal/console/server.go` (`handleContinue` rewritten; `handleHome` passes `next`/prefill; `handleInvitePage` and `handleCLIApprovePage` (authcli.go) signed-out → render home with prefill/next instead of redirecting)
174- Modify: `internal/console/templates/home.html`, `templates/check.html` (rewrite to the "Check your email" page)
175- Modify: `internal/console/server.go` `page` struct: add `Prefill, Next, Banner string; NoMail bool; CodeError string`; remove `KeymailURL`
176- Test: `internal/console/emailsignin_test.go`, update `claim_invariant_probes_nonapp_test.go` `/auth/continue` probe
177
178**Interfaces:**
179- Consumes: Tasks 1–5.
180- Produces:
181 ```go
182 func (s *Server) sameOrigin(r *http.Request) bool // Origin header host == cfg.Origin host; else Sec-Fetch-Site in {"same-origin","none"}; neither header → true
183 func (s *Server) sendChallengeAsync(to, subject, body string) // semaphore of 4, 20 s ctx, logs failures
184 const challengeCookie = "carlos_console_email"
185 ```
186 `handleContinue` order (spec §4): `sameOrigin` else 403 `Refused`; normalise (lower, trim) and validate (`ValidAddress` + `netmail.ParseAddress(a).Address == a`) else home with `home.bad_address`; `limits.submit(ip)` false → render the normal check page and return (no lookup, no challenge); discovery (`domain == s.km.Host()` or `HasMailbox` true, 5 s) → Keymail: redirect to `/auth/signin?next=<next>` (303); email: `!consoleMail.Ready` → home with `home.no_mail` (before the send limits, spec §4.2); `limits.signinSend(addr, ip)` false → normal check page, nothing stored; else `CreateEmailChallenge(signin, replace = this browser's challenge cookie)`, set the cookie, `sendChallengeAsync` with `email.signin.*` (`{console}` = `s.consoleName()`, `{link}` = `Origin + "/auth/email/link?t=" + link`), render the check page. The check page is the same template and bytes for every email-bound outcome.
187 Home banners: `home.invite_prompt` (invite page signed out, prefill = invited address via `PeekInvite`-style read of the invite's address without consuming; if the token is unknown, no banner), `home.cli_prompt` (`/auth/cli`).
188- [ ] **Step 1: Tests** (fixture + fake keymail + `fakeConsoleMail{ready:true}`):
189 - `TestContinueKeymailHostGoesToKeymail`: address `@<fk host>` → 303 to `/auth/signin`, lookup not called, mailer not called.
190 - `TestContinueLookup200GoesToKeymail` and `...Lookup404SendsEmail` (mailer gets one message to that address containing a 6-digit code and `/auth/email/link?t=`), `...Lookup500SendsEmailForForeignDomain`.
191 - `TestContinueGrantDoesNotDecide`: store a grant for `bob@example.com`, lookup 404 → email (Review Focus 1).
192 - `TestContinueNormalises`: `" Bob@Example.COM "` → mail to `bob@example.com`; `"a<victim@example.com>"` and `"a@x.com,b@y.com"` → bad-address page, no user row, no challenge, mailer not called (Review Focus 3).
193 - `TestContinuePageIdentical`: bodies for a new address, an existing user, and a rate-limited repeat are byte-identical.
194 - `TestContinueNoMailer`: `ready:false` → home with `home.no_mail`; the 6th request for one address within an hour gets the same bytes (the no-mail answer comes before the send limits, spec r3-1).
195 - `TestContinueCrossSiteRefused`: `Origin: https://evil.example` → 403.
196 - `TestInvitePageSignedOutPrefills` and `TestCLIPageSignedOutBanner`.
197- [ ] **Step 2: Run** `-run 'TestContinue|TestInvitePageSignedOut|TestCLIPageSignedOut|TestClaim.*Continue'` → FAIL.
198- [ ] **Step 3: Implement**; templates use only `copyText` values passed in `page` (add fields as needed), the form posts `address` and hidden `next`.
199- [ ] **Step 4: Run** → PASS; also `-run 'TestHome|TestInvite|TestCLI'`.
200- [ ] **Step 5: Commit** `console: one front door; Keymail found from the address`.
201
202### Task 7: Link and code sign-in
203
204**Files:**
205- Modify: `internal/console/emailsignin.go` (handlers), `server.go` (routes; `handleConfirm` retired to the `link.expired` message)
206- Create: `internal/console/templates/email_link.html`
207- Modify: `testdata/claim-routes.txt` (`route GET /auth/email/link`, `route POST /auth/email/link`, `route POST /auth/email/code`), probes
208- Test: `internal/console/emailsignin_test.go`
209
210**Interfaces:**
211- Produces: `handleEmailLinkPage` (GET), `handleEmailLink` (POST), `handleEmailCode` (POST); `const linkConfirmCookie = "carlos_console_linkconfirm"`.
212 GET: `PeekEmailLink`; unknown → 410 "Link expired" with `link.expired`; signin → page titled `link.title` with a POST form (`t`, `nonce`) and button `link.button`, sets `linkConfirmCookie=nonce`; stepup → Task 8.
213 POST: `sameOrigin` and cookie nonce == form nonce (constant time) else 403 with `link.wrong_site`; `TakeEmailLink`; signin → `finishSignin(addr, "", MethodEmail, c.Next)` and clear the challenge cookie if it named this challenge.
214 Code POST: `sameOrigin`; challenge id from `challengeCookie`; `PeekEmailChallenge(id)` (add to the store in this task: read without consuming) gives the address, or `check.code_used_up` when gone; `!limits.codeAllowed(addr, ip)` → `check.code_throttled` without checking or burning; else `TakeEmailCode`: `CodeWrong` → `codeFailed(addr, ip)` and `check.code_wrong`; `CodeGone` → `check.code_used_up`; `CodeOK` → `finishSignin`.
215- [ ] **Step 1: Tests:** `TestEmailLinkGetDoesNotConsume` (GET twice, POST → signed in, session `email`, step-up fresh) (Review Focus 2); `TestEmailLinkLoginCSRF` (POST with a valid token from a client with no nonce cookie → 403, token still redeemable; `Origin` foreign → 403); `TestEmailCodeCrossDevice` (browser A requests, browser B posts the code with no cookie → refused; A posts → signed in); `TestEmailCodeGuessCap` (11 wrong codes across 3 fresh challenges for one address → throttled message; a valid link for that address still signs in) (spec r2-2); `TestNewEmailUserGetsAccount` (a brand-new address signs in via code with `next=/auth/cli` → lands on `/auth/cli`, has a personal account); `TestReplacedChallenge` (ask twice from one browser → first email's code and link both fail, second works); `TestLegacyConfirmRetired`.
216- [ ] **Step 2: Run** `-run 'TestEmailLink|TestEmailCode|TestNewEmailUser|TestReplacedChallenge|TestLegacyConfirm|TestClaimRoute'` → FAIL.
217- [ ] **Step 3: Implement.**
218- [ ] **Step 4: Run** → PASS, including the claim route census tests (`-run 'TestClaim'` limited to the route list and probes).
219- [ ] **Step 5: Commit** `console: sign in by emailed link or code`.
220
221### Task 8: Re-confirmation by email
222
223**Files:**
224- Modify: `internal/console/stepup.go` (`requireFreshAuth` branches on method; `takePendingActionByHash`)
225- Modify: `internal/console/emailsignin.go` (step-up handlers), `server.go` (route `POST /auth/email/stepup/code`)
226- Create: `internal/console/templates/stepup_email.html`
227- Modify: `testdata/claim-routes.txt`, probes; `templates/team.html` (`team.confirm_note`)
228- Test: `internal/console/emailstepup_test.go`
229
230**Interfaces:**
231- Consumes: `SessionInfo`, `CreateEmailChallenge`, `TakeEmailLink`, `TakeEmailCode`, `limits.stepUpSend`, `deliver` is NOT used (spec §4.2: SES only).
232- Produces: `func (s *Store) takePendingActionByHash(hash, sessionRaw string, now time.Time) (*pendingAction, bool)`; `handleStepUpEmailCode`.
233 `requireFreshAuth`: `FreshStepUp` passes as today. Method `keymail` → today's path unchanged. Method `email` → `!consoleMail.Ready` → fail closed with the existing message "Could not hold on to that action. Please try again." (503, title "Error"; existing copy, no action taken). `limits.stepUpSend(addr)` false → if this browser's `challengeCookie` names a live step-up challenge for this session, re-render the `stepup_email` page for it (the earlier email's code still works); otherwise the same fail-closed message. Otherwise stash the pending action, `CreateEmailChallenge(stepup, SessionHash=hashToken(raw), PendingHash=hashToken(pendingRaw), ExpiresAt=now+stepUpFlowTTL)`, send `email.stepup.*` with `sendChallengeAsync`, render `stepup_email` (`stepup.title`, `stepup.sent`, code form → `/auth/email/stepup/code`).
234 Link GET for a stepup challenge: if the request's session hash ≠ `SessionHash` → page with `stepup.wrong_browser` (no consume); else confirm page (`stepup.link_title`, `stepup.link_button`, nonce). POST: session match + nonce + `sameOrigin`; `TakeEmailLink`; `RecordStepUp(raw, addr, now, now)`; `takePendingActionByHash(c.PendingHash, raw, now)` → `resume`; missing → `stepup.expired`.
235- [ ] **Step 1: Tests:** `TestEmailSessionStepUpByCode` (email session; POST an owner-only gated action e.g. remove member → step-up page, one email with a code; post code → action happened); `TestEmailSessionStepUpByLink` (same browser); `TestStepUpLinkOtherBrowser` (another session → wrong-browser text, nothing changed, link still usable in the right browser); `TestKeymailSessionNeverEmailStepUp` (keymail session → redirect to Keymail step-up, mailer not called) (Review Focus 1); `TestStepUpChallengeExpiresWithPendingAction` (11 min → `stepup.expired`); `TestCLILoginByEmailUserOneEmail` (signed-out new email user: `/auth/cli` → home banner → code sign-in → back on `/auth/cli` → start a device grant via `POST /auth/cli/start`, approve with the user code → approved without a second email; mailer saw exactly one message).
236- [ ] **Step 2: Run** `-run 'TestEmailSessionStepUp|TestStepUpLink|TestKeymailSessionNever|TestStepUpChallenge|TestCLILoginByEmail|TestStepUp'` → FAIL.
237- [ ] **Step 3: Implement.**
238- [ ] **Step 4: Run** → PASS (existing step-up tests unchanged).
239- [ ] **Step 5: Commit** `console: email sessions re-confirm by emailed link or code`.
240
241### Task 9: Invites and notices reach plain email
242
243**Files:**
244- Modify: `internal/console/server.go` (`handleInvite` validation error → `team.bad_address`; `inviteMessage` body → `email.invite.body` with `{console}`; subject keeps today's text with "the CARLOS platform" replaced by `s.consoleName()`)
245- Modify: `internal/console/templates/team.html` (`team.invite_placeholder`, `team.invite_note`)
246- Modify: `internal/console/authcli.go` `notifyCLIIssued`; `internal/console/storenotify.go` decision notice
247- Test: `internal/console/invite_delivery_test.go`, `internal/console/storenotify_test.go`, `internal/console/authcli_test.go`
248
249**Interfaces:**
250- Consumes: `s.deliver` (Task 3).
251- `notifyCLIIssued`: grant present → today's `SendApp`; no grant → `s.deliver(ctx, address, subject, body, 12*time.Second)` with the existing subject/body text unchanged. `notifyStoreDecided`: recipients without a grant go through `s.deliver` instead of being logged as missing.
252- [ ] **Step 1: Tests:** `TestInviteEmailInviteeAcceptsEndToEnd` (owner invites `new@example.com` (lookup 404) → mailer message with `/invite/<tok>`; signed-out GET of that link → home with banner and prefill; code sign-in → back on the invite page → accept → member); `TestInviteCopyHelpers` updated to the approved body; `TestCLIIssuedNoticeByEmail`; `TestStoreDecisionNoticeByEmail`.
253- [ ] **Step 2: Run** `-run 'TestInvite|TestCLIIssued|TestStoreDecision'` → FAIL.
254- [ ] **Step 3: Implement.**
255- [ ] **Step 4: Run** → PASS.
256- [ ] **Step 5: Commit** `console: invites and notices reach plain email addresses`.
257
258### Task 10: Census, docs, branch gate
259
260**Files:**
261- Modify: `internal/console/testdata/claim-routes.txt` (already updated per task; verify), census overlay only if `TestPlan14RootReadCensus` (internal/provision) fails: follow `docs/superpowers/notes/census-proposal-console-invite-delivery.md`'s shape with `PROPOSED-email-signin` rows and write `docs/superpowers/notes/census-proposal-email-signin.md`
262- Modify: `docs/site/changelog.json` (one entry for the next release: email sign-in, Keymail discovered from the address, `CARLOS_CONSOLE_NAME`); `cmd/console/main.go` header comment
263- [ ] **Step 1: Run** `go test ./internal/provision -run 'TestPlan14RootReadCensus' -count=1` and `go test ./internal/console -run 'TestClaim' -count=1`. Fix what fails as above.
264- [ ] **Step 2:** `gofmt -l .` empty; `go vet ./internal/console ./cmd/console`; `go build ./...`; `GOOS=darwin go build ./...`; `GOOS=windows go build ./...`.
265- [ ] **Step 3: Commit, push, describe the branch, read CI** with `amadan ci status carlos/platform -branch email-signin`.
266
267### Task 11: Rollout (controller, not a subagent; each production step needs the ruling R4 already given)
268
269- [ ] Final whole-branch review (Astra), fixes, re-merge main, CI green, `amadan branch merge carlos/platform email-signin -squash -expect <sha>`.
270- [ ] Release: list what main carries since v0.28.7 for Paul, cut per `platform-release-recipe`.
271- [ ] Dev: roll boxes then console; `region-host` grant for dev's default From (platform-infrastructure dev root, targeted); live checks: plain-address sign-up by link, by code on a second device, invite accept, CLI login approve, a Keymail address goes to Keymail.
272- [ ] Flagship: `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com`, `CARLOS_CONSOLE_NAME=Carloku` in a console drop-in; restart; read the logged DKIM records; publish them in the carloku.com zone via carloku-infrastructure (targeted); targeted apply of the region-host `console_mail_from`/`console_mail_region` grant; wait for SES verification; roll boxes then console; the same live checks on console.carloku.com.
273