| 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 | |
| 29 | 1. **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) |
| 30 | 2. **Mail scanners prefetching GET links.** A GET of `/auth/email/link?t=` twice, then POST, must still sign in once. (Task 7) |
| 31 | 3. **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) |
| 32 | 4. **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) |
| 33 | 5. **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 | |