carlos / platform Public
Console email sign-in (magic links), with Keymail found from the address
Status: rev 6, 2026-10-21 (approved rev 5 + the build's sign-in-flow change, §15). Follows console invite delivery, whose §4 console mailer this reuses and whose line 4 named this spec.
1. Why
The console's only door is "Sign in with Keymail". Anyone without a Keymail mailbox cannot sign up, cannot accept an invitation (invites now reach plain email addresses, but accepting needs a session as that address), and cannot finish carlos auth login. For Carloku, a general-use product, that is the front door shut to almost everyone.
Success: a person with any email address can sign up and sign in to the console with a link sent to that address; an owner can invite any email address and the invitee can accept; carlos auth login and every re-confirmed action work for that person; Keymail users type their address into the same field and land in Keymail's own sign-in, as they do today from the button.
2. Rulings (Paul, 2026-10-21)
- R1. Re-confirmation for email users is a fresh email link. Sensitive actions that today demand a fresh Keymail passkey gesture (
requireFreshAuth) demand, for a session that signed in by email, a fresh link sent to the same address. Passkeys for email users are a later piece of work. - R2. Sign-up by email is open to anyone, rate-limited. Same as Keymail today: the first successful link for a new address creates the user, and the existing first-landing path mints their personal account. Caps per address, per client IP and overall stop the form being a way to send mail to strangers.
- R3. Console email comes from a carloku.com address on carloku.com.
CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com(builder's call on the local part; see §9). The platform default (noreply@console-mail.bab.<suffix>) stays the default for other deployments. - R4. Enable it. Paul's instruction ("finish it out and enable it") is the security ruling the invite-delivery rollout was waiting on for the
ses:SendEmailgrant (console_mail_from/console_mail_region). Roll order stays dev first, then flagship. - R5. One door; Keymail is discovered, not chosen. There is no separate "Sign in with Keymail" action. The person types their address; if it is a Keymail address (paul@keymail.dev) the console sends them straight into Keymail's OAuth ceremony, and otherwise it emails a link and code.
3. Goals, non-goals, anti-goals
Goals
- One email field on the console's front page. A Keymail address goes to Keymail's sign-in; any other address gets a magic link and code, which sign up a new person and sign in a returning one.
- A code in the same email, so a link opened on a phone can still finish a sign-in started on a laptop.
- Re-confirmation (step-up) by email link or code for email sessions.
- Invites a non-Keymail invitee can accept, with the invite page sending the invitee through sign-in and back.
carlos auth loginworks end to end for an email user.- Enabled and verified live on dev, then carloku.com.
Non-goals
- Passkeys / WebAuthn in the console (R1 defers it).
- CAPTCHA or bot scoring. Rate limits only.
- Linking two addresses into one person. The address is the identity, as today.
- Changing the Keymail OAuth flow or Keymail step-up. (The old Apps-inbox confirmation link is retired, §11.)
- A login hint to Keymail. Keymail has none; whoever authenticates there is who signs in, as today.
- Tito's console (blocked on the store boundary; it picks this up with its next release like any other deployment).
Anti-goals (a failure even if it works)
- A downgrade. A session that signed in with Keymail must never be able to re-confirm by email instead of its passkey. A stolen Keymail session gains nothing from this feature.
- An oracle. The front page must not reveal whether an address has an account here or was rate-limited. (Whether an address is a Keymail mailbox is already public through Keymail's own lookup, so being sent to Keymail reveals nothing new. Discovery must therefore use only that public answer, never console state such as a stored grant.)
- A mail cannon. Nobody can make the console send more than a handful of messages to an address they do not control.
- Link scanners burning links. Corporate mail scanners fetch every URL in a message. A GET must never consume a token.
- Mail to
@keymail.dev-style addresses by SMTP. Keymail's MX refuses or screens unknown senders, so Keymail mailboxes are reached through Keymail, never through SES.
4. One door: discovery, then delivery
4.1 Discovery (R5)
POST /auth/continue takes address (validated per §11) and decides:
- The domain is the configured Keymail host (
CONSOLE_KEYMAIL's host, e.g. keymail.dev): Keymail. - Otherwise ask Keymail's public federation lookup (new
Keymail.HasMailbox(ctx, addr) (bool, error), the same/api/federation/lookupcallSendFederatedmakes, 5 s timeout): - 200: Keymail (a Keymail-served custom domain). - 400 "could not determine target mailbox", 404 "no such mailbox", 409 "inbox not yet claimed" (checked live 2026-10-21): email. - timeout, network error, 5xx: email (a non-Keymail-host domain should not lose sign-in because Keymail is down).
Only public answers decide (anti-oracle): never a stored grant or any other console state.
Keymail means exactly what GET /auth/signin does today (state and PKCE cookie, redirect to AuthorizeURL), plus the next cookie of §8. The session it makes is method=keymail. Whoever authenticates at Keymail is who signs in, even if they typed a different address.
Email means a challenge (§5) and an emailed link and code (§4.2).
GET /auth/signin stays as the ceremony's entry (the redirect target, and old bookmarks) but nothing on the page links to it any more.
4.2 How a sign-in or step-up message is sent
Only to addresses discovery called email, so only by the console mailer (ConsoleMailer.Send, SES). Never by SES to a Keymail-host address (anti-goal); discovery has already routed those to Keymail.
No console mailer (none configured, or not yet verified): the front page says so plainly for an email-bound address ("this console can't send email yet") instead of pretending a message is on its way. This reveals only that the address is not a Keymail mailbox, which is public. This answer is decided before any rate limit and is the same whether or not the request was throttled, so throttling stays invisible (codex r3-1). With a working mailer, a rate-limited request gets the normal "check your email" page.
Readiness (codex r3-2). ConsoleMailer gains Ready(ctx) bool: the existing private ready (verified answers cached for the process, a not-verified answer rechecked after consoleMailPendingRecheck) behind a short context (2 s; a timeout reads as not ready). /auth/continue asks it before creating a challenge. A nil mailer is not ready.
Sends run off the request. The handler records the challenge, starts the send on a background goroutine with its own bounded context (20 s), and renders the "check your email" page at once, so send latency is no oracle. A process-wide semaphore (builder's call on the size; 4 is fine) bounds concurrent sends; a send that cannot get a slot within its context is dropped and logged.
4.3 Invites and notices keep the two-channel sender
Invites (existing chain) and the notices of §11 go to addresses the person did not just type, so they keep federation first, then SES, with this fall-through rule: today SendFederated wraps every lookup error as ErrNoKeymailMailbox. Only the definitive 400/404/409 answers mean "no Keymail mailbox" and may fall through to SES. A timeout, network error or 5xx stops when the domain is the Keymail host, and otherwise falls through. ErrFederationPriceTooHigh, IsKeymailBlocked, and a delivery failure after a successful lookup all stop: the address IS a Keymail mailbox.
5. The challenge
A challenge is one emailed link + code, for one purpose, bound to the browser that asked.
Table (new, in its own file with a migrateEmailSignin() per the authstore/step-up precedent, and classified in claimSQLTables):
email_challenges(
id_hash TEXT PRIMARY KEY, -- sha256 of the browser-held id
link_hash TEXT UNIQUE, -- sha256 of the emailed link token
code_mac TEXT NOT NULL, -- HMAC-SHA256(process key, id || code)
purpose TEXT NOT NULL, -- 'signin' | 'stepup'
address TEXT NOT NULL,
session_hash TEXT NOT NULL DEFAULT '', -- stepup: the session it serves
pending TEXT NOT NULL DEFAULT '', -- stepup: pending_actions token hash
next TEXT NOT NULL DEFAULT '', -- signin: local path to land on
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)- Lifetime 15 minutes (
ConfirmationTTL's number). Single use: a successful link or code deletes the row in the same transaction that checks it (burn-then-check, theTakeConfirmationshape). - The browser holds the id in an HttpOnly cookie (
carlos_console_email,Secureon https,SameSite=Lax, 15 min). Only hashes are stored, so a database read is not a sign-in. - The code is 6 digits from crypto/rand, stored as HMAC-SHA256 over (challenge id, code) under a key held only in the console process's memory, minted at start (codex r2-1). A plain hash would not do: six digits under an id the requester knows fall to an offline search in under a second, so a database read would be a sign-in. The cost is that a console restart invalidates codes in flight; links still work (they are 32 random bytes, hashed), and asking again sends a fresh code. 5 wrong attempts burn the challenge. A code is only ever checked against the challenge named by this browser's cookie, so there is no global code space to guess in.
- The link token is 32 random bytes, base64url, the
randomTokenshape. - Guessing is bounded per address, not just per challenge (codex 2). A fresh challenge must not buy a fresh guessing budget. Wrong codes are counted per address across all its challenges (10 per day) and per client IP (30 per hour). Past either, code checks for that address (or from that IP) are refused until the window passes, and only the challenge the wrong code was entered against is burned. Other challenges' links keep working: they carry 32 random bytes and an attacker without the mailbox cannot have them (codex r2-2). A rate-limited request (§7) creates no challenge at all, while rendering the same page, so refused requests are not redeemable either.
- One live challenge per (browser, purpose): asking again replaces the browser's previous one.
5.1 Sign-in by link
GET /auth/email/link?t=<token> never consumes anything. It looks the token up and renders a confirm page ("Sign in as you@example.com", one button) carrying the token in a POST form. Unknown or expired tokens get the "link expired" message.
POST /auth/email/link (token in the body) consumes the challenge and signs this browser in, whichever browser it is. That is what a magic link means: possession of the mailbox. The confirm page names the address, so a forwarded link shows whose account it opens.
Login CSRF (codex 1). The link token alone does not protect the POST: an attacker holds a valid token for their own address and could auto-submit it from their own page into a victim's browser. So:
- the GET sets a fresh random nonce in an HttpOnly
SameSite=Laxcookie (carlos_console_linkconfirm, 15 min) and puts the same nonce in the form; the POST requires both and compares them in constant time. A cross-site POST carries no Lax cookie and fails; - every pre-session POST in this feature (
/auth/continue, the link POST, the code POSTs) also refuses a request whoseOrigin(or, when absent,Sec-Fetch-Site) names another site. Absent both, allow (old clients). The code POST is already browser-bound through the challenge cookie, which a cross-site POST does not carry.
5.2 Sign-in by code
The "check your email" page has a code box. POST /auth/email/code checks the code against the challenge named by this browser's cookie and signs this browser in. Wrong code: the page again, with an error, the attempts counter up. This is the cross-device path.
5.3 What signing in does
Exactly what the Keymail callback does after it has an address, so both doors share one function (finishSignin(w, r, address, method, next)): EnsureUser (sign-up when created), CreateSession, session cookie, welcome cookie when created, then next if set and safe, else redirectToCurrentAccount. No grant token is stored for an email sign-in (it has none), and an existing grant is left alone.
A link or code sign-in also records a step-up (RecordStepUp at the moment of use). It is the same proof a step-up would ask for, minutes later, of the same mailbox; without it a signed-out carlos auth login sends two emails back to back.
5.4 The session remembers how it was proved
sessions gains method TEXT NOT NULL DEFAULT 'keymail'. Existing rows read as keymail, which is true of every session today (the Apps-inbox confirmation link is a Keymail channel and stays keymail). A session made by §5.1 or §5.2 is email; a Keymail callback's is keymail. Discovery sends Keymail addresses to Keymail, so a Keymail user always re-confirms with their passkey. session(r) (or a sibling) exposes the method.
6. Re-confirmation (R1)
requireFreshAuth branches on the session's method:
keymail: unchanged. Keymail step-up, fail closed when Keymail cannot do it. No email path. (Anti-goal: no downgrade.)email: stash the pending action exactly as today (StashPendingAction), create astepupchallenge bound to this session hash and that pending token, send it by the console mailer (§4.2), and render a "check your email" page with a code box. -GET /auth/email/link?t=for a stepup challenge renders a confirm page;POSTrequires a session cookie whose hash equals the challenge'ssession_hash(and the nonce of §5.1). Opened in another browser it refuses with "open this link in the browser where you started, or type the code there". It never signs anyone in. -POST /auth/email/stepup/codechecks the code for the stepup challenge named by this browser's cookie, and requires the same session. - On success:RecordStepUp(raw, addr, now, now)(the gesture time is the moment the link or code was used), then redeem the pending action, thenresume, the same tail ashandleStepUpCallback. The challenge stores the pending token's hash, and the existingTakePendingActionhashes its argument, so add a siblingtakePendingActionByHashkeeping the same transaction, session binding, expiry check and single-use delete (codex 3). - A step-up challenge lives 10 minutes, the pending action's own lifetime (stepUpFlowTTL), not 15 (codex 6).
The five-minute StepUpWindow applies from that moment, as for Keymail.
The emailed text for a step-up names the action ("confirm: approve a carlos auth login request", etc.) where the pending action's path maps cleanly to a description; otherwise a generic "confirm an action in the console". Builder's call on the mapping; it is copy, so it goes through copy review.
7. Rate limits (R2)
In-memory sliding windows, the inviteWindow shape (one console process per deployment). Refusals are invisible to the requester: the "check your email" page renders as normal and the send is skipped and logged. The numbers (builder may tune within reason):
| Key | Limit | | --- | --- | | per address, any purpose | 1 per 60 s, 5 per hour, 20 per day | | per client IP (s.clientIP, edge-proof aware) | 10 emails per hour; 30 /auth/continue submissions per hour of any outcome (each one may cost a Keymail lookup) | | whole console | 200 per hour |
Step-up challenges have their own per-address window (1 per 60 s, 10 per hour) and do not count against sign-in's address, IP or global caps (the requester is already signed in as that address). Code attempts are bounded per challenge (§5), not here.
Bounces and complaints: the console's sending identity is an ordinary platform mail domain, so its configuration set and the account-level SES suppression list apply. Nothing new here.
8. Where people come back to (next)
Signed-out visits to pages that need a session today redirect to / and lose their place. Two of them matter for this feature:
GET /invite/{token}signed out: render the front page with the invited address pre-filled ("Sign in as x@y to accept"), andnextset to the invite page (carried as a hidden field into/auth/continue, so either discovery outcome gets it).GET /auth/clisigned out:next=/auth/cli(so a CLI user can sign in and land on the code entry).
Onboarding runs before next (codex 8). finishSignin first does what redirectToCurrentAccount does to pick or mint the current account (so a brand-new user always has their personal account), and only then redirects to next instead of the account page.
next is accepted only as a local path: starts with /, not //, no backslash, parsed by url.Parse with no scheme or host. For email it is stored on the challenge row; for Keymail in a short cookie beside the auth cookie (carlos_console_next, 10 min). Anything else is dropped silently and the usual landing applies.
9. Mail on carloku.com (R3)
CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.comon the flagship console.mail.EnsureDomaincreates the SES identity forcarloku.comunder the ops account (bab, referrerconsole-mail) and logs the DKIM records it needs. Publish them in the carloku.com Route53 zone (in carloku-infrastructure, where that zone is managed, unless the console's own DNS writer already owns it).- Before relying on it, check that no tenant has a mail-domain claim on
carloku.com(it is served by an app; a claim conflict refusesEnsureDomain) and that SES in the console's mail region is out of the SES sandbox. Checked 2026-10-21: SES in eu-west-1 is in production (50,000/day), no SES identity exists for carloku.com, and the carloku.com hosted zone is in the flagship account. Ifcarloku.comturns out to be refused, usenoreply@mail.carloku.com(builder's call, reported to Paul) rather than stop. - The flagship console today has
CARLOS_MAIL_REGION=eu-west-1andCARLOS_MAIL_PLATFORM_SUFFIX=oncarlos.com, and its mailer has already made and verifiedconsole-mail.bab.oncarlos.com(SES: DKIM SUCCESS, config setcarlos-bab). So the host role can create identities; only the send grant is missing. That default identity is the fallback if carloku.com is held up. - An existing identity needs ownership proof (codex 9). If an SES identity for carloku.com exists when the mailer first runs, and CARLOS did not make it,
EnsureDomainrefuses to adopt it unless TXT_carlos-mail.carloku.com=carlos-mail-account=babis published. None existed on 2026-10-21; check again at rollout, and publish the TXT if one has appeared. - carloku-infrastructure's region-host module call for the console's box sets
console_mail_from = "noreply@carloku.com"andconsole_mail_regionto the console's mail region, a targeted apply (whole-root applies restart every box). - Dev: the platform default From, verified on the dev platform suffix.
CARLOS_HOST_CLAIMstill disables the console mailer (#250). If the flagship console runs claims-on by the time this rolls, that is a blocker to raise, not to work around.
Message text says "CARLOS platform console" today. Whether carloku.com's messages say "Carloku" is a copy question for the copy review; if it needs a deployment display name, that is a small config addition the builder may make (CARLOS_CONSOLE_NAME, default "CARLOS platform").
10. Front page and copy
- Home: one email field and one button. No Keymail button (R5).
- "Check your email" page (replaces
check.html's Keymail-only text): where it was sent, the code box, "nothing arriving?" advice. - The "this console can't send email yet" refusal (§4.2).
- Link confirm page, step-up "check your email" page, the three email bodies (sign-in, step-up, invite), the invite page's signed-out variant, the
team.htmlinvite copy (still says "the platform never emails anyone"), and the invite form's "keymail address" error. - Every user-facing string goes through the copy-review skill before it lands in a file. No em dashes ([[copy voice]]).
11. Smaller changes that fall out
notifyCLIIssuedandnotifyStoreDecided(storenotify.go, the object-storage approved/declined notice to the customer): when the address has no Keymail grant, send the notice through §4's sender (federation, then SES under the same fall-through rule), never straight to SES (codex 4). Operator alerts stay Keymail-only (operators are Keymail users).handleContinue(POST /auth/continue): becomes the form's target and runs discovery (§4.1). Its old body (an Apps-inbox confirmation link for addresses with a stored grant) goes. An empty address re-renders the front page with the bad-address error. It validates with the invite handler's rule (codex 7): lower-case, trim,ValidAddress, andnetmail.ParseAddress(addr).Address == addr, soa<victim@example.com>and list forms are refused before any challenge, rate-limit key or user row exists. That one canonical string is used everywhere after.handleConfirm(/auth/confirm?token=, the old Apps-inbox link) is retired: nothing mints those links any more, and any already sent lapse within 15 minutes of the roll. The route stays (claim-routes) and renders the existing "link expired" message;CreateConfirmation's last caller goes (leave the table;PurgeExpiredkeeps clearing it).- The "Check your Keymail" template and
page.KeymailURLgo. PurgeExpiredalso clears expiredemail_challenges.- New routes go into
testdata/claim-routes.txt, the invariant fixtures and probes, like every route.
12. Tests that prove it
Through real wiring (the console fixture, the real store, the router), with fakes only at the edges (fake Keymail, fake console mailer, a controllable clock):
- Sign-up by link for a new address: user created, personal account minted, session
method=email, welcome shown. - Link GET does not consume; POST does; second POST fails.
- Code: right code signs in; 5 wrong codes burn; a code from another browser's challenge does not work.
- Discovery: Keymail-host address → redirect to Keymail with the state cookie; lookup 200 → Keymail; 400/404/409 → email; lookup timeout → email; a stored grant for a 404 address still → email (no console state in discovery).
- The "check your email" page is byte-identical for a new address, a known one, and a rate-limited one.
- Invite and notice chain: definitive no-mailbox → SES; lookup 5xx on a Keymail-host address → nothing sent; blocked → nothing sent.
- Rate limits at each key; a refused request creates no challenge.
- Guessing: wrong codes across several fresh challenges for one address stop at the per-address cap (codex 2).
- Login CSRF: a cross-site auto-submitted link POST without the nonce cookie is refused (codex 1).
- Signed-out
carlos auth loginby a new email user, without advancing the clock between sign-in and approval: one email, a personal account minted, CLI approved (codex 5, 8). - Step-up: an email session doing a gated action gets an email challenge, the pending action resumes after the code or same-session link; the link in another browser refuses; a keymail session never gets an email challenge (downgrade test).
next: invite page round trip for an email invitee from signed-out to member; unsafenextvalues ignored.carlos auth loginapproval by an email user end to end.- Migration on an existing database: old sessions read
keymail.
13. Rollout
- Merge (CI green on a fresh main merge), release, roll dev boxes → dev console. Live check on dev: sign up with a plain email address, accept an invite, approve a CLI login with an emailed code.
- Flagship: SES identity for carloku.com verified, region-host grant applied (targeted), env set on the console unit, roll flagship boxes → console. The same three live checks on carloku.com, plus a Keymail sign-in to prove nothing regressed.
14. Left to the builder
Names of files, functions and routes beyond those fixed above; the semaphore size; the step-up action descriptions; tuning the rate numbers within the same order of magnitude; template structure.
15. Review dispositions
Builder check (rev 1), settled as builder's calls:
- B1 superseded by R5: Keymail addresses never get an email link, so the sign-in door sets the session method (§4.1, §5.4).
- B2 email sign-in counts as a step-up (§5.3); step-up has its own per-address window (§7).
- B3 carloku.com refused: fall back to
noreply@mail.carloku.com(§9). - B4 federation fall-through rule (§4), from Keymail's live answers.
- B5
notifyStoreDecidedjoinsnotifyCLIIssued(§11).
Astra round 1 (docs/superpowers/reviews/CODEX-FINDINGS-email-signin-r1.md), all accepted: codex 1 login CSRF (§5.1); codex 2 per-address guessing budget (§5); codex 3 pending-action hash (§6); codex 4 no SMTP to Keymail (§4, §11); codex 5 CLI step-up cooldown (§5.3, §7); codex 6 step-up TTL (§6); codex 7 strict address parsing (§11); codex 8 onboarding before next (§8); codex 9 existing-identity proof (§9).
Astra round 2 (CODEX-FINDINGS-email-signin-r2.md): all nine round-1 fixes confirmed. Both new findings accepted: r2-1 codes MACed under an in-memory key (§5); r2-2 the guessing cap throttles codes but never burns another browser's link (§5).
Rev 4 (Paul, 2026-10-21, R5): no "Sign in with Keymail" action; discovery from the address picks Keymail OAuth or email. Consequences: sign-in and step-up messages go by SES only (§4.2); the Apps-inbox confirmation link is retired (§11); B1's channel-based method is gone; invites and notices keep the B4 two-channel rule (§4.3).
Astra round 3 (CODEX-FINDINGS-email-signin-r3.md): all eleven earlier fixes confirmed, no P1. Both P2s accepted: r3-1 the no-mailer answer is decided before rate limits (§4.2); r3-2 ConsoleMailer.Ready (§4.2).
Rev 6 (build, 2026-10-21, after the whole-branch Astra review): the browser no longer holds a per-challenge id. A sign-in flow cookie (carlos_console_flow), set when the sign-in form renders, ties a browser's sign-in challenges together; the code box uses the flow's newest live challenge; /auth/continue serialises per flow. A refused request (rate limits) sends nothing and leaves the flow's live challenge untouched, or, if it has none, records a never-sent challenge so a wrong code reads the same either way. That replaces §5's "a rate-limited request creates no challenge": no refused request creates a challenge the mailbox could redeem. Over the per-IP submit cap there is no Keymail lookup and no send. Step-up challenges are found by session. Reasons: the per-challenge id leaked throttling through the code box and lost the code on a double-clicked Continue.