carlos / platform Public
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, byid). Never edit, re-punctuate or "improve" them. Placeholders{address},{console},{link},{code},{inviter},{account},{to}are filled withstrings.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 fromcrypto/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/continuesubmissions/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_KEYMAILhost, 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 vias.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; leaveGOCACHEat its default. - Every new route is added to
internal/console/testdata/claim-routes.txtand gets a probe inclaim_invariant_probes_nonapp_test.go(copy the shape ofclaimSigninProbe/claimConfirmProbe), and the new table gets aclaimSQLTablesrow instore_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
- 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;requireFreshAuthfor akeymailsession never creates an email challenge. (Task 6, Task 8) - Mail scanners prefetching GET links. A GET of
/auth/email/link?t=twice, then POST, must still sign in once. (Task 7) - 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) - 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)
- 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(callmigrateEmailSignin()aftermigrateStepUp()~line 518;PurgeExpiredalso deletes expiredemail_challenges;CreateSessiongains a method; addSessionInfo) - 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 callsCreateSessionMethod(..., MethodKeymail, now)`.
- [ ] Step 1: Write failing tests in
emailsignin_store_test.go: -TestEmailChallengeLinkPeekDoesNotConsume: create (signin,bob@example.com, Next/auth/cli);PeekEmailLinktwice → ok both, Address and Next match;TakeEmailLink→ ok;TakeEmailLinkagain → !ok. -TestEmailChallengeCode: right code →CodeOKand 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,Openthe same file → code →CodeGone, link → ok (Review Focus 4). Assert the storedcode_macdiffers fromhex(sha256(id||code)). -TestEmailChallengeReplace: create A, create B withreplaceID=A.id→ A's link and code both fail, B's work. -TestSessionMethod:CreateSession→SessionInfomethodkeymail;CreateSessionMethod(..., MethodEmail)→email; a database created before the migration (insert a sessions row without the column via the old schema, thenOpen) readskeymail. - [ ] 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.StoregetscodeKey []byte(32 random bytes minted inOpen);code_mac = hex(HMAC-SHA256(codeKey, id + ":" + code)), compared withhmac.Equal.id,linkarerandomToken(); stored ashashToken. Code viacrypto/randuniform in [0, 1e6), zero-padded.TakeEmailCoderuns in one transaction: load by id hash; gone/expired/purpose-mismatch → delete if present,CodeGone; MAC mismatch →attempts+1, delete at 5 and returnCodeGoneon the 5th, elseCodeWrong; match → delete,CodeOK.replaceIDdeletes 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(10codeFailedfor one address →codeAllowedfalse 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, theinviteWindowshape) 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 bysubmit'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; addHasMailbox) - 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.gohandleInvite(usedeliver)
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)`deliveris spec §4.3: federation first;IsKeymailBlocked,ErrFederationPriceTooHigh, post-lookup failures → stop with that error;ErrNoKeymailMailbox→ console mailer;ErrKeymailUnreachable→ stop ifdomainOf(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).TestDeliverChaintable: no mailbox (404) → mailer called, channelemail; foreign domain (400) → mailer; federation OK →keymail, mailer not called; blocked → error, mailer not called; lookup 500 forx@<keymail host>→ error, mailer not called; lookup 500 forx@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;
handleInvitereplaces its inline chain withs.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 withCARLOS_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, assertaction == "approve"and thatsigninCopyhas exactly those ids with byte-identical text.TestConsoleMailerReady: unverified → false and no SES call; verified → true; a ctx that has already ended → false. Update everyConsoleMailerfake (fakeConsoleMailininvite_delivery_test.go) with aready boolfield. - [ ] 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.Readywrapsm.readywithcontext.WithTimeout(ctx, 2*time.Second)and returnserr == 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(handleCallbackusesfinishSignin;handleSigninstoresnext) - 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; whenmethod == MethodEmail,RecordStepUp(raw, addr, now, now); thens.currentAccount(ctx, addr)(mints for a new user) and, ifsafeNext(next) != "", redirect there (303), else the existingredirectToCurrentAccountbehaviour.handleCallbackreads and clearsnextCookieand passes it.handleSigninaccepts?next=and setsnextCookietosafeNext(next)` when non-empty. - [ ] Step 1: Tests.
TestSafeNexttable:/auth/cliok;/invite/abcok;//evil.com,/\evil.com,https://evil.com/x,javascript:alert(1), `→ "".TestKeymailCallbackFollowsNext: fixture,GET /auth/signin?next=/auth/clithen the fake-keymail callback → 303 to/auth/cli, session methodkeymail`, 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(handleContinuerewritten;handleHomepassesnext/prefill;handleInvitePageandhandleCLIApprovePage(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.gopagestruct: addPrefill, Next, Banner string; NoMail bool; CodeError string; removeKeymailURL - Test:
internal/console/emailsignin_test.go, updateclaim_invariant_probes_nonapp_test.go/auth/continueprobe
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"`handleContinueorder (spec §4):sameOriginelse 403Refused; normalise (lower, trim) and validate (ValidAddress+netmail.ParseAddress(a).Address == a) else home withhome.badaddress;limits.submit(ip)false → render the normal check page and return (no lookup, no challenge); discovery (domain == s.km.Host()orHasMailboxtrue, 5 s) → Keymail: redirect to/auth/signin?next=<next>(303); email:!consoleMail.Ready→ home withhome.nomail(before the send limits, spec §4.2);limits.signinSend(addr, ip)false → normal check page, nothing stored; elseCreateEmailChallenge(signin, replace = this browser's challenge cookie), set the cookie,sendChallengeAsyncwithemail.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 viaPeekInvite-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. -TestContinueLookup200GoesToKeymailand...Lookup404SendsEmail(mailer gets one message to that address containing a 6-digit code and/auth/email/link?t=),...Lookup500SendsEmailForForeignDomain. -TestContinueGrantDoesNotDecide: store a grant forbob@example.com, lookup 404 → email (Review Focus 1). -TestContinueNormalises:" Bob@Example.COM "→ mail tobob@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 withhome.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. -TestInvitePageSignedOutPrefillsandTestCLIPageSignedOutBanner. - [ ] Step 2: Run
-run 'TestContinue|TestInvitePageSignedOut|TestCLIPageSignedOut|TestClaim.*Continue'→ FAIL. - [ ] Step 3: Implement; templates use only
copyTextvalues passed inpage(add fields as needed), the form postsaddressand hiddennext. - [ ] 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;handleConfirmretired to thelink.expiredmessage) - 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" withlink.expired; signin → page titledlink.titlewith a POST form (t,nonce) and buttonlink.button, setslinkConfirmCookie=nonce; stepup → Task 8. POST:sameOriginand cookie nonce == form nonce (constant time) else 403 withlink.wrong_site;TakeEmailLink; signin →finishSignin(addr, "", MethodEmail, c.Next)and clear the challenge cookie if it named this challenge. Code POST:sameOrigin; challenge id fromchallengeCookie;PeekEmailChallenge(id)(add to the store in this task: read without consuming) gives the address, orcheck.code_used_upwhen gone;!limits.codeAllowed(addr, ip)→check.code_throttledwithout checking or burning; elseTakeEmailCode:CodeWrong→codeFailed(addr, ip)andcheck.code_wrong;CodeGone→check.code_used_up;CodeOK→finishSignin. - [ ] Step 1: Tests:
TestEmailLinkGetDoesNotConsume(GET twice, POST → signed in, sessionemail, step-up fresh) (Review Focus 2);TestEmailLinkLoginCSRF(POST with a valid token from a client with no nonce cookie → 403, token still redeemable;Originforeign → 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 withnext=/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(requireFreshAuthbranches on method;takePendingActionByHash) - Modify:
internal/console/emailsignin.go(step-up handlers),server.go(routePOST /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,deliveris NOT used (spec §4.2: SES only). - Produces:
func (s *Store) takePendingActionByHash(hash, sessionRaw string, now time.Time) (*pendingAction, bool);handleStepUpEmailCode.requireFreshAuth:FreshStepUppasses as today. Methodkeymail→ today's path unchanged. Methodemail→!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'schallengeCookienames a live step-up challenge for this session, re-render thestepup_emailpage 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), sendemail.stepup.*withsendChallengeAsync, renderstepup_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 withstepup.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 viaPOST /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(handleInvitevalidation error →team.bad_address;inviteMessagebody →email.invite.bodywith{console}; subject keeps today's text with "the CARLOS platform" replaced bys.consoleName()) - Modify:
internal/console/templates/team.html(team.invite_placeholder,team.invite_note) - Modify:
internal/console/authcli.gonotifyCLIIssued;internal/console/storenotify.godecision 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'sSendApp; no grant →s.deliver(ctx, address, subject, body, 12*time.Second)with the existing subject/body text unchanged.notifyStoreDecided: recipients without a grant go throughs.deliverinstead of being logged as missing.- [ ] Step 1: Tests:
TestInviteEmailInviteeAcceptsEndToEnd(owner invitesnew@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);TestInviteCopyHelpersupdated 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 ifTestPlan14RootReadCensus(internal/provision) fails: followdocs/superpowers/notes/census-proposal-console-invite-delivery.md's shape withPROPOSED-email-signinrows and writedocs/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.goheader comment - [ ] Step 1: Run
go test ./internal/provision -run 'TestPlan14RootReadCensus' -count=1andgo 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-hostgrant 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=Carlokuin 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-hostconsole_mail_from/console_mail_regiongrant; wait for SES verification; roll boxes then console; the same live checks on console.carloku.com.