carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

Console email sign-in Implementation Plan

> 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.

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.

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.

Tech Stack: Go, net/http, SQLite (modernc.org/sqlite), html/template, AWS SESv2 via internal/mail.

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.

Global Constraints

  • All paths are under the worktree /home/paulca/amadan.net/carlos/platform/.claude/worktrees/email-signin.
  • 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.
  • No em dashes in any new user-facing text (there is none outside the approved JSON anyway).
  • 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).
  • 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.
  • Discovery uses only Keymail's public answer (domain == CONSOLE_KEYMAIL host, or lookup 200). Never a stored grant.
  • Lookup classification: 200 = mailbox; 400, 404, 409 = definitively none; anything else (timeout, network, 5xx) = unreachable.
  • 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).
  • 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.
  • 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.
  • Do not spawn Anthropic-model subagents from a Codex run; do not delete files by glob; scratch goes under $TMPDIR.

Review Focus

  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)
  2. Mail scanners prefetching GET links. A GET of /auth/email/link?t= twice, then POST, must still sign in once. (Task 7)
  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)
  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)
  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)

---

Task 1: Challenge store and session method

Files:

  • Create: internal/console/emailsignin_store.go
  • Create: internal/console/emailsignin_store_test.go
  • Modify: internal/console/store.go (call migrateEmailSignin() after migrateStepUp() ~line 518; PurgeExpired also deletes expired email_challenges; CreateSession gains a method; add SessionInfo)
  • Modify: internal/console/store_gate.go (claimSQLTables: email_challenges → NonApp)

Interfaces:

  • Produces: ``go const (ChallengeSignIn = "signin"; ChallengeStepUp = "stepup") const (MethodKeymail = "keymail"; MethodEmail = "email") type EmailChallenge struct { Purpose, Address, SessionHash, PendingHash, Next string ExpiresAt time.Time } type CodeResult int // CodeOK, CodeWrong, CodeGone func (s *Store) CreateEmailChallenge(c EmailChallenge, replaceID string, now time.Time) (id, link, code string, err error) func (s *Store) PeekEmailLink(link string, now time.Time) (*EmailChallenge, bool) func (s *Store) TakeEmailLink(link string, now time.Time) (*EmailChallenge, bool) func (s *Store) TakeEmailCode(id, code, purpose string, now time.Time) (*EmailChallenge, CodeResult) func (s *Store) DeleteEmailChallenge(id string) error func (s *Store) CreateSessionMethod(address, keymailToken, method string, now time.Time) (string, error) func (s *Store) SessionInfo(rawToken string, now time.Time) (address, method string, ok bool) ` CreateSession(address, token, now) stays and calls CreateSessionMethod(..., MethodKeymail, now)`.
  • [ ] Step 1: Write failing tests in emailsignin_store_test.go: - TestEmailChallengeLinkPeekDoesNotConsume: create (signin, bob@example.com, Next /auth/cli); PeekEmailLink twice → ok both, Address and Next match; TakeEmailLink → ok; TakeEmailLink again → !ok. - 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. - TestEmailChallengeExpiry: now+15m+1s → link and code both fail; the expired row is deleted by the attempt (burn-then-check). - 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)). - TestEmailChallengeReplace: create A, create B with replaceID=A.id → A's link and code both fail, B's work. - 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.
  • [ ] Step 2: Run go test ./internal/console -run 'TestEmailChallenge|TestSessionMethod' -count=1 → FAIL (undefined).
  • [ ] 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.
  • [ ] Step 4: Run the same command → PASS. gofmt -l internal/console, go vet ./internal/console.
  • [ ] Step 5: Commit console: email challenge store and session method.

Task 2: Rate limits

Files:

  • Create: internal/console/emailsignin_limits.go, internal/console/emailsignin_limits_test.go

Interfaces:

  • Produces: ``go type emailLimits struct { now func() time.Time; /* windows */ } func (l *emailLimits) submit(ip string) bool // 30/h per IP, records func (l *emailLimits) signinSend(addr, ip string) bool // all-or-nothing: addr 1/60s,5/h,20/d; ip 10/h; global 200/h func (l *emailLimits) stepUpSend(addr string) bool // 1/60s, 10/h func (l *emailLimits) codeAllowed(addr, ip string) bool // false once addr ≥10 wrong/24h or ip ≥30 wrong/h func (l *emailLimits) codeFailed(addr, ip string) ``
  • [ ] 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).
  • [ ] Step 2: Run -run 'TestSigninSend|TestStepUpSend|TestCodeGuess' → FAIL.
  • [ ] 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.
  • [ ] Step 4: Run → PASS.
  • [ ] Step 5: Commit console: email sign-in rate limits.

Task 3: Keymail discovery and the delivery rule

Files:

  • Modify: internal/console/keymailfed.go (classify lookup errors; add HasMailbox)
  • Modify: internal/console/keymail.go (Host() string = hostname of the configured base URL)
  • Modify: internal/console/keymail_test.go (fake lookup can answer 200/400/404/409/500/slow per address)
  • Create: internal/console/delivery.go, internal/console/delivery_test.go
  • Modify: internal/console/server.go handleInvite (use deliver)

Interfaces:

  • Produces: ``go var ErrKeymailUnreachable = errors.New("keymail: lookup unreachable") func (k *Keymail) HasMailbox(ctx context.Context, addr string) (bool, error) // (true,nil) 200; (false,nil) 400/404/409; (false, ErrKeymailUnreachable-wrapped) otherwise func (k *Keymail) Host() string // SendFederated: lookup 400/404/409 → ErrNoKeymailMailbox (as today); other lookup errors → ErrKeymailUnreachable (wrapping the cause) type deliveryChannel string // "keymail", "email", "" func (s *Server) deliver(ctx context.Context, to, subject, body string, keymailShare time.Duration) (deliveryChannel, error) ` 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.
  • [ ] 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.
  • [ ] Step 2: Run -run 'TestHasMailbox|TestDeliverChain|TestInvite' → FAIL.
  • [ ] 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).
  • [ ] Step 4: Run → PASS.
  • [ ] Step 5: Commit console: keymail discovery, and federation falls through to SES only on a definite no.

Task 4: Approved copy, console name, mailer readiness

Files:

  • Create: internal/console/signincopy.go, internal/console/signincopy_test.go
  • Modify: internal/console/consolemail.go (Ready)
  • Modify: internal/console/server.go (Config.ConsoleName string; Server.consoleName() default "the CARLOS platform")
  • Modify: cmd/console/main.go (ConsoleName: os.Getenv("CARLOS_CONSOLE_NAME"), and the header comment documents it with CARLOS_CONSOLE_MAIL_FROM)

Interfaces:

  • Produces: ``go // signinCopy holds every approved string by its review id. var signinCopy = map[string]string{ "home.email_label": "Your email address", /* …all 38… */ } func copyText(id string, kv ...string) string // strings.NewReplacer("{"+k+"}", v, ...) over signinCopy[id]; panics on an unknown id type ConsoleMailer interface { Send(ctx context.Context, to, subject, body string) error Ready(ctx context.Context) bool } ``
  • [ ] 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.
  • [ ] Step 2: Run -run 'TestSigninCopy|TestConsoleMailerReady|TestInvite' → FAIL.
  • [ ] 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.
  • [ ] Step 4: Run → PASS; go build ./....
  • [ ] Step 5: Commit console: approved sign-in copy, CARLOS_CONSOLE_NAME, mailer readiness.

Task 5: Shared sign-in tail and safe next

Files:

  • Create: internal/console/emailsignin.go (helpers here; handlers land in Tasks 6–8)
  • Modify: internal/console/server.go (handleCallback uses finishSignin; handleSignin stores next)
  • Test: internal/console/emailsignin_test.go

Interfaces:

  • Produces: ``go const nextCookie = "carlos_console_next" func safeNext(raw string) string // "" unless a local path: starts "/", not "//", no \, url.Parse ok with empty Scheme and Host func (s *Server) finishSignin(w http.ResponseWriter, r *http.Request, addr, keymailToken, method, next string) ` 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.
  • [ ] 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).
  • [ ] Step 2: Run -run 'TestSafeNext|TestKeymailCallbackFollowsNext|TestCallback' → FAIL.
  • [ ] Step 3: Implement.
  • [ ] Step 4: Run → PASS (existing callback tests unchanged).
  • [ ] Step 5: Commit console: one sign-in tail for both doors, and a safe next.

Task 6: The front door (discovery)

Files:

  • 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)
  • Modify: internal/console/templates/home.html, templates/check.html (rewrite to the "Check your email" page)
  • Modify: internal/console/server.go page struct: add Prefill, Next, Banner string; NoMail bool; CodeError string; remove KeymailURL
  • Test: internal/console/emailsignin_test.go, update claim_invariant_probes_nonapp_test.go /auth/continue probe

Interfaces:

  • Consumes: Tasks 1–5.
  • Produces: ``go 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 func (s *Server) sendChallengeAsync(to, subject, body string) // semaphore of 4, 20 s ctx, logs failures const challengeCookie = "carlos_console_email" ` handleContinue order (spec §4): sameOrigin else 403 Refused; normalise (lower, trim) and validate (ValidAddress + netmail.ParseAddress(a).Address == a) else home with home.badaddress; 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.nomail (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. Home banners: home.inviteprompt (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.cliprompt (/auth/cli`).
  • [ ] Step 1: Tests (fixture + fake keymail + fakeConsoleMail{ready:true}): - TestContinueKeymailHostGoesToKeymail: address @<fk host> → 303 to /auth/signin, lookup not called, mailer not called. - TestContinueLookup200GoesToKeymail and ...Lookup404SendsEmail (mailer gets one message to that address containing a 6-digit code and /auth/email/link?t=), ...Lookup500SendsEmailForForeignDomain. - TestContinueGrantDoesNotDecide: store a grant for bob@example.com, lookup 404 → email (Review Focus 1). - 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). - TestContinuePageIdentical: bodies for a new address, an existing user, and a rate-limited repeat are byte-identical. - 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). - TestContinueCrossSiteRefused: Origin: https://evil.example → 403. - TestInvitePageSignedOutPrefills and TestCLIPageSignedOutBanner.
  • [ ] Step 2: Run -run 'TestContinue|TestInvitePageSignedOut|TestCLIPageSignedOut|TestClaim.*Continue' → FAIL.
  • [ ] Step 3: Implement; templates use only copyText values passed in page (add fields as needed), the form posts address and hidden next.
  • [ ] Step 4: Run → PASS; also -run 'TestHome|TestInvite|TestCLI'.
  • [ ] Step 5: Commit console: one front door; Keymail found from the address.

Task 7: Link and code sign-in

Files:

  • Modify: internal/console/emailsignin.go (handlers), server.go (routes; handleConfirm retired to the link.expired message)
  • Create: internal/console/templates/email_link.html
  • Modify: testdata/claim-routes.txt (route GET /auth/email/link, route POST /auth/email/link, route POST /auth/email/code), probes
  • Test: internal/console/emailsignin_test.go

Interfaces:

  • Produces: handleEmailLinkPage (GET), handleEmailLink (POST), handleEmailCode (POST); const linkConfirmCookie = "carlos_console_linkconfirm". 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. 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. 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.
  • [ ] 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.
  • [ ] Step 2: Run -run 'TestEmailLink|TestEmailCode|TestNewEmailUser|TestReplacedChallenge|TestLegacyConfirm|TestClaimRoute' → FAIL.
  • [ ] Step 3: Implement.
  • [ ] Step 4: Run → PASS, including the claim route census tests (-run 'TestClaim' limited to the route list and probes).
  • [ ] Step 5: Commit console: sign in by emailed link or code.

Task 8: Re-confirmation by email

Files:

  • Modify: internal/console/stepup.go (requireFreshAuth branches on method; takePendingActionByHash)
  • Modify: internal/console/emailsignin.go (step-up handlers), server.go (route POST /auth/email/stepup/code)
  • Create: internal/console/templates/stepup_email.html
  • Modify: testdata/claim-routes.txt, probes; templates/team.html (team.confirm_note)
  • Test: internal/console/emailstepup_test.go

Interfaces:

  • Consumes: SessionInfo, CreateEmailChallenge, TakeEmailLink, TakeEmailCode, limits.stepUpSend, deliver is NOT used (spec §4.2: SES only).
  • Produces: func (s *Store) takePendingActionByHash(hash, sessionRaw string, now time.Time) (*pendingAction, bool); handleStepUpEmailCode. 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). 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.
  • [ ] 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).
  • [ ] Step 2: Run -run 'TestEmailSessionStepUp|TestStepUpLink|TestKeymailSessionNever|TestStepUpChallenge|TestCLILoginByEmail|TestStepUp' → FAIL.
  • [ ] Step 3: Implement.
  • [ ] Step 4: Run → PASS (existing step-up tests unchanged).
  • [ ] Step 5: Commit console: email sessions re-confirm by emailed link or code.

Task 9: Invites and notices reach plain email

Files:

  • 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())
  • Modify: internal/console/templates/team.html (team.invite_placeholder, team.invite_note)
  • Modify: internal/console/authcli.go notifyCLIIssued; internal/console/storenotify.go decision notice
  • Test: internal/console/invite_delivery_test.go, internal/console/storenotify_test.go, internal/console/authcli_test.go

Interfaces:

  • Consumes: s.deliver (Task 3).
  • 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.
  • [ ] 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.
  • [ ] Step 2: Run -run 'TestInvite|TestCLIIssued|TestStoreDecision' → FAIL.
  • [ ] Step 3: Implement.
  • [ ] Step 4: Run → PASS.
  • [ ] Step 5: Commit console: invites and notices reach plain email addresses.

Task 10: Census, docs, branch gate

Files:

  • 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
  • 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
  • [ ] 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.
  • [ ] Step 2: gofmt -l . empty; go vet ./internal/console ./cmd/console; go build ./...; GOOS=darwin go build ./...; GOOS=windows go build ./....
  • [ ] Step 3: Commit, push, describe the branch, read CI with amadan ci status carlos/platform -branch email-signin.

Task 11: Rollout (controller, not a subagent; each production step needs the ruling R4 already given)

  • [ ] Final whole-branch review (Astra), fixes, re-merge main, CI green, amadan branch merge carlos/platform email-signin -squash -expect <sha>.
  • [ ] Release: list what main carries since v0.28.7 for Paul, cut per platform-release-recipe.
  • [ ] 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.
  • [ ] 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.