| 1 | # Console email sign-in (magic links), with Keymail found from the address |
| 2 | |
| 3 | Status: rev 6, 2026-10-21 (approved rev 5 + the build's sign-in-flow change, §15). Follows |
| 4 | [console invite delivery](2026-09-28-console-invite-delivery-design.md), |
| 5 | whose §4 console mailer this reuses and whose line 4 named this spec. |
| 6 | |
| 7 | ## 1. Why |
| 8 | |
| 9 | The console's only door is "Sign in with Keymail". Anyone without a Keymail |
| 10 | mailbox cannot sign up, cannot accept an invitation (invites now reach |
| 11 | plain email addresses, but accepting needs a session as that address), |
| 12 | and cannot finish `carlos auth login`. For Carloku, a general-use product, |
| 13 | that is the front door shut to almost everyone. |
| 14 | |
| 15 | Success: a person with any email address can sign up and sign in to the |
| 16 | console with a link sent to that address; an owner can invite any email |
| 17 | address and the invitee can accept; `carlos auth login` and every |
| 18 | re-confirmed action work for that person; Keymail users type their |
| 19 | address into the same field and land in Keymail's own sign-in, as they |
| 20 | do today from the button. |
| 21 | |
| 22 | ## 2. Rulings (Paul, 2026-10-21) |
| 23 | |
| 24 | - **R1. Re-confirmation for email users is a fresh email link.** Sensitive |
| 25 | actions that today demand a fresh Keymail passkey gesture |
| 26 | (`requireFreshAuth`) demand, for a session that signed in by email, a |
| 27 | fresh link sent to the same address. Passkeys for email users are a |
| 28 | later piece of work. |
| 29 | - **R2. Sign-up by email is open to anyone, rate-limited.** Same as |
| 30 | Keymail today: the first successful link for a new address creates the |
| 31 | user, and the existing first-landing path mints their personal account. |
| 32 | Caps per address, per client IP and overall stop the form being a way to |
| 33 | send mail to strangers. |
| 34 | - **R3. Console email comes from a carloku.com address on carloku.com.** |
| 35 | `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` (builder's call on the |
| 36 | local part; see §9). The platform default |
| 37 | (`noreply@console-mail.bab.<suffix>`) stays the default for other |
| 38 | deployments. |
| 39 | - **R4. Enable it.** Paul's instruction ("finish it out and enable it") |
| 40 | is the security ruling the invite-delivery rollout was waiting on for |
| 41 | the `ses:SendEmail` grant (`console_mail_from` / `console_mail_region`). |
| 42 | Roll order stays dev first, then flagship. |
| 43 | - **R5. One door; Keymail is discovered, not chosen.** There is no |
| 44 | separate "Sign in with Keymail" action. The person types their address; |
| 45 | if it is a Keymail address (paul@keymail.dev) the console sends them |
| 46 | straight into Keymail's OAuth ceremony, and otherwise it emails a link |
| 47 | and code. |
| 48 | |
| 49 | ## 3. Goals, non-goals, anti-goals |
| 50 | |
| 51 | Goals |
| 52 | - One email field on the console's front page. A Keymail address goes to |
| 53 | Keymail's sign-in; any other address gets a magic link and code, which |
| 54 | sign up a new person and sign in a returning one. |
| 55 | - A code in the same email, so a link opened on a phone can still finish |
| 56 | a sign-in started on a laptop. |
| 57 | - Re-confirmation (step-up) by email link or code for email sessions. |
| 58 | - Invites a non-Keymail invitee can accept, with the invite page sending |
| 59 | the invitee through sign-in and back. |
| 60 | - `carlos auth login` works end to end for an email user. |
| 61 | - Enabled and verified live on dev, then carloku.com. |
| 62 | |
| 63 | Non-goals |
| 64 | - Passkeys / WebAuthn in the console (R1 defers it). |
| 65 | - CAPTCHA or bot scoring. Rate limits only. |
| 66 | - Linking two addresses into one person. The address is the identity, as |
| 67 | today. |
| 68 | - Changing the Keymail OAuth flow or Keymail step-up. (The old |
| 69 | Apps-inbox confirmation link is retired, §11.) |
| 70 | - A login hint to Keymail. Keymail has none; whoever authenticates there |
| 71 | is who signs in, as today. |
| 72 | - Tito's console (blocked on the store boundary; it picks this up with |
| 73 | its next release like any other deployment). |
| 74 | |
| 75 | Anti-goals (a failure even if it works) |
| 76 | - **A downgrade.** A session that signed in with Keymail must never be |
| 77 | able to re-confirm by email instead of its passkey. A stolen Keymail |
| 78 | session gains nothing from this feature. |
| 79 | - **An oracle.** The front page must not reveal whether an address has an |
| 80 | account here or was rate-limited. (Whether an address is a Keymail |
| 81 | mailbox is already public through Keymail's own lookup, so being sent |
| 82 | to Keymail reveals nothing new. Discovery must therefore use only that |
| 83 | public answer, never console state such as a stored grant.) |
| 84 | - **A mail cannon.** Nobody can make the console send more than a handful |
| 85 | of messages to an address they do not control. |
| 86 | - **Link scanners burning links.** Corporate mail scanners fetch every |
| 87 | URL in a message. A GET must never consume a token. |
| 88 | - Mail to `@keymail.dev`-style addresses by SMTP. Keymail's MX refuses or |
| 89 | screens unknown senders, so Keymail mailboxes are reached through |
| 90 | Keymail, never through SES. |
| 91 | |
| 92 | ## 4. One door: discovery, then delivery |
| 93 | |
| 94 | ### 4.1 Discovery (R5) |
| 95 | |
| 96 | `POST /auth/continue` takes `address` (validated per §11) and decides: |
| 97 | |
| 98 | 1. The domain is the configured Keymail host (`CONSOLE_KEYMAIL`'s host, |
| 99 | e.g. keymail.dev): **Keymail**. |
| 100 | 2. Otherwise ask Keymail's public federation lookup (new |
| 101 | `Keymail.HasMailbox(ctx, addr) (bool, error)`, the same |
| 102 | `/api/federation/lookup` call `SendFederated` makes, 5 s timeout): |
| 103 | - 200: **Keymail** (a Keymail-served custom domain). |
| 104 | - 400 "could not determine target mailbox", 404 "no such mailbox", |
| 105 | 409 "inbox not yet claimed" (checked live 2026-10-21): **email**. |
| 106 | - timeout, network error, 5xx: **email** (a non-Keymail-host domain |
| 107 | should not lose sign-in because Keymail is down). |
| 108 | |
| 109 | Only public answers decide (anti-oracle): never a stored grant or any |
| 110 | other console state. |
| 111 | |
| 112 | **Keymail** means exactly what `GET /auth/signin` does today (state and |
| 113 | PKCE cookie, redirect to `AuthorizeURL`), plus the `next` cookie of §8. |
| 114 | The session it makes is `method=keymail`. Whoever authenticates at |
| 115 | Keymail is who signs in, even if they typed a different address. |
| 116 | |
| 117 | **Email** means a challenge (§5) and an emailed link and code (§4.2). |
| 118 | |
| 119 | `GET /auth/signin` stays as the ceremony's entry (the redirect target, |
| 120 | and old bookmarks) but nothing on the page links to it any more. |
| 121 | |
| 122 | ### 4.2 How a sign-in or step-up message is sent |
| 123 | |
| 124 | Only to addresses discovery called **email**, so only by the console |
| 125 | mailer (`ConsoleMailer.Send`, SES). Never by SES to a Keymail-host |
| 126 | address (anti-goal); discovery has already routed those to Keymail. |
| 127 | |
| 128 | **No console mailer** (none configured, or not yet verified): the front |
| 129 | page says so plainly for an email-bound address ("this console can't |
| 130 | send email yet") instead of pretending a message is on its way. This |
| 131 | reveals only that the address is not a Keymail mailbox, which is public. |
| 132 | This answer is decided **before** any rate limit and is the same whether |
| 133 | or not the request was throttled, so throttling stays invisible (codex |
| 134 | r3-1). With a working mailer, a rate-limited request gets the normal |
| 135 | "check your email" page. |
| 136 | |
| 137 | **Readiness** (codex r3-2). `ConsoleMailer` gains `Ready(ctx) bool`: the |
| 138 | existing private `ready` (verified answers cached for the process, a |
| 139 | not-verified answer rechecked after `consoleMailPendingRecheck`) behind a |
| 140 | short context (2 s; a timeout reads as not ready). `/auth/continue` asks |
| 141 | it before creating a challenge. A nil mailer is not ready. |
| 142 | |
| 143 | **Sends run off the request.** The handler records the challenge, starts |
| 144 | the send on a background goroutine with its own bounded context (20 s), |
| 145 | and renders the "check your email" page at once, so send latency is no |
| 146 | oracle. A process-wide semaphore (builder's call on the size; 4 is fine) |
| 147 | bounds concurrent sends; a send that cannot get a slot within its |
| 148 | context is dropped and logged. |
| 149 | |
| 150 | ### 4.3 Invites and notices keep the two-channel sender |
| 151 | |
| 152 | Invites (existing chain) and the notices of §11 go to addresses the |
| 153 | person did not just type, so they keep federation first, then SES, with |
| 154 | this fall-through rule: today `SendFederated` wraps every lookup error |
| 155 | as `ErrNoKeymailMailbox`. Only the definitive 400/404/409 answers mean |
| 156 | "no Keymail mailbox" and may fall through to SES. A timeout, network |
| 157 | error or 5xx stops when the domain is the Keymail host, and otherwise |
| 158 | falls through. `ErrFederationPriceTooHigh`, `IsKeymailBlocked`, and a |
| 159 | delivery failure after a successful lookup all stop: the address IS a |
| 160 | Keymail mailbox. |
| 161 | |
| 162 | ## 5. The challenge |
| 163 | |
| 164 | A **challenge** is one emailed link + code, for one purpose, bound to the |
| 165 | browser that asked. |
| 166 | |
| 167 | Table (new, in its own file with a `migrateEmailSignin()` per the |
| 168 | authstore/step-up precedent, and classified in `claimSQLTables`): |
| 169 | |
| 170 | ``` |
| 171 | email_challenges( |
| 172 | id_hash TEXT PRIMARY KEY, -- sha256 of the browser-held id |
| 173 | link_hash TEXT UNIQUE, -- sha256 of the emailed link token |
| 174 | code_mac TEXT NOT NULL, -- HMAC-SHA256(process key, id || code) |
| 175 | purpose TEXT NOT NULL, -- 'signin' | 'stepup' |
| 176 | address TEXT NOT NULL, |
| 177 | session_hash TEXT NOT NULL DEFAULT '', -- stepup: the session it serves |
| 178 | pending TEXT NOT NULL DEFAULT '', -- stepup: pending_actions token hash |
| 179 | next TEXT NOT NULL DEFAULT '', -- signin: local path to land on |
| 180 | attempts INTEGER NOT NULL DEFAULT 0, |
| 181 | created_at INTEGER NOT NULL, |
| 182 | expires_at INTEGER NOT NULL |
| 183 | ) |
| 184 | ``` |
| 185 | |
| 186 | - **Lifetime 15 minutes** (`ConfirmationTTL`'s number). Single use: a |
| 187 | successful link or code deletes the row in the same transaction that |
| 188 | checks it (burn-then-check, the `TakeConfirmation` shape). |
| 189 | - **The browser holds the id** in an HttpOnly cookie |
| 190 | (`carlos_console_email`, `Secure` on https, `SameSite=Lax`, 15 min). |
| 191 | Only hashes are stored, so a database read is not a sign-in. |
| 192 | - **The code** is 6 digits from crypto/rand, stored as |
| 193 | HMAC-SHA256 over (challenge id, code) under a **key held only in the |
| 194 | console process's memory**, minted at start (codex r2-1). A plain hash |
| 195 | would not do: six digits under an id the requester knows fall to an |
| 196 | offline search in under a second, so a database read would be a |
| 197 | sign-in. The cost is that a console restart invalidates codes in |
| 198 | flight; links still work (they are 32 random bytes, hashed), and asking |
| 199 | again sends a fresh code. **5 wrong attempts** burn |
| 200 | the challenge. A code is only ever checked against the challenge named |
| 201 | by this browser's cookie, so there is no global code space to guess in. |
| 202 | - **The link token** is 32 random bytes, base64url, the `randomToken` |
| 203 | shape. |
| 204 | - **Guessing is bounded per address, not just per challenge** (codex 2). |
| 205 | A fresh challenge must not buy a fresh guessing budget. Wrong codes are |
| 206 | counted per address across all its challenges (10 per day) and per |
| 207 | client IP (30 per hour). Past either, code checks for that address (or |
| 208 | from that IP) are refused until the window passes, and only the |
| 209 | challenge the wrong code was entered against is burned. Other |
| 210 | challenges' **links** keep working: they carry 32 random bytes and an |
| 211 | attacker without the mailbox cannot have them (codex r2-2). A rate-limited request (§7) creates **no** challenge at all, |
| 212 | while rendering the same page, so refused requests are not redeemable |
| 213 | either. |
| 214 | - One live challenge per (browser, purpose): asking again replaces the |
| 215 | browser's previous one. |
| 216 | |
| 217 | ### 5.1 Sign-in by link |
| 218 | |
| 219 | `GET /auth/email/link?t=<token>` never consumes anything. It looks the |
| 220 | token up and renders a confirm page ("Sign in as you@example.com", one |
| 221 | button) carrying the token in a POST form. Unknown or expired tokens get |
| 222 | the "link expired" message. |
| 223 | |
| 224 | `POST /auth/email/link` (token in the body) consumes the challenge and |
| 225 | signs **this** browser in, whichever browser it is. That is what a magic |
| 226 | link means: possession of the mailbox. The confirm page names the |
| 227 | address, so a forwarded link shows whose account it opens. |
| 228 | |
| 229 | **Login CSRF** (codex 1). The link token alone does not protect the POST: |
| 230 | an attacker holds a valid token for their own address and could |
| 231 | auto-submit it from their own page into a victim's browser. So: |
| 232 | - the GET sets a fresh random nonce in an HttpOnly `SameSite=Lax` cookie |
| 233 | (`carlos_console_linkconfirm`, 15 min) and puts the same nonce in the |
| 234 | form; the POST requires both and compares them in constant time. A |
| 235 | cross-site POST carries no Lax cookie and fails; |
| 236 | - every pre-session POST in this feature (`/auth/continue`, the link POST, |
| 237 | the code POSTs) also refuses a request whose `Origin` (or, when absent, |
| 238 | `Sec-Fetch-Site`) names another site. Absent both, allow (old clients). |
| 239 | The code POST is already browser-bound through the challenge cookie, |
| 240 | which a cross-site POST does not carry. |
| 241 | |
| 242 | ### 5.2 Sign-in by code |
| 243 | |
| 244 | The "check your email" page has a code box. `POST /auth/email/code` |
| 245 | checks the code against the challenge named by this browser's cookie and |
| 246 | signs this browser in. Wrong code: the page again, with an error, the |
| 247 | attempts counter up. This is the cross-device path. |
| 248 | |
| 249 | ### 5.3 What signing in does |
| 250 | |
| 251 | Exactly what the Keymail callback does after it has an address, so both |
| 252 | doors share one function (`finishSignin(w, r, address, method, next)`): |
| 253 | `EnsureUser` (sign-up when `created`), `CreateSession`, session cookie, |
| 254 | welcome cookie when created, then `next` if set and safe, else |
| 255 | `redirectToCurrentAccount`. No grant token is stored for an email sign-in |
| 256 | (it has none), and an existing grant is left alone. |
| 257 | |
| 258 | **A link or code sign-in also records a step-up** (`RecordStepUp` at the |
| 259 | moment of use). It is the same proof a step-up would ask for, minutes |
| 260 | later, of the same mailbox; without it a signed-out `carlos auth login` |
| 261 | sends two emails back to back. |
| 262 | |
| 263 | ### 5.4 The session remembers how it was proved |
| 264 | |
| 265 | `sessions` gains `method TEXT NOT NULL DEFAULT 'keymail'`. Existing rows |
| 266 | read as `keymail`, which is true of every session today (the |
| 267 | Apps-inbox confirmation link is a Keymail channel and stays `keymail`). |
| 268 | A session made by §5.1 or §5.2 is `email`; a Keymail callback's is |
| 269 | `keymail`. Discovery sends Keymail addresses to Keymail, so a Keymail |
| 270 | user always re-confirms with their passkey. `session(r)` (or a sibling) |
| 271 | exposes the method. |
| 272 | |
| 273 | ## 6. Re-confirmation (R1) |
| 274 | |
| 275 | `requireFreshAuth` branches on the session's method: |
| 276 | |
| 277 | - **`keymail`**: unchanged. Keymail step-up, fail closed when Keymail |
| 278 | cannot do it. No email path. (Anti-goal: no downgrade.) |
| 279 | - **`email`**: stash the pending action exactly as today |
| 280 | (`StashPendingAction`), create a `stepup` challenge bound to this |
| 281 | session hash and that pending token, send it by the console mailer (§4.2), |
| 282 | and render a "check your email" page with a code box. |
| 283 | - `GET /auth/email/link?t=` for a stepup challenge renders a confirm |
| 284 | page; `POST` requires a session cookie whose hash equals the |
| 285 | challenge's `session_hash` (and the nonce of §5.1). Opened in another browser it refuses |
| 286 | with "open this link in the browser where you started, or type the |
| 287 | code there". It never signs anyone in. |
| 288 | - `POST /auth/email/stepup/code` checks the code for the stepup |
| 289 | challenge named by this browser's cookie, and requires the same |
| 290 | session. |
| 291 | - On success: `RecordStepUp(raw, addr, now, now)` (the gesture time is |
| 292 | the moment the link or code was used), then redeem the pending |
| 293 | action, then `resume`, the same tail as `handleStepUpCallback`. The |
| 294 | challenge stores the pending token's **hash**, and the existing |
| 295 | `TakePendingAction` hashes its argument, so add a sibling |
| 296 | `takePendingActionByHash` keeping the same transaction, session |
| 297 | binding, expiry check and single-use delete (codex 3). |
| 298 | - A step-up challenge lives **10 minutes**, the pending action's own |
| 299 | lifetime (`stepUpFlowTTL`), not 15 (codex 6). |
| 300 | |
| 301 | The five-minute `StepUpWindow` applies from that moment, as for Keymail. |
| 302 | |
| 303 | The emailed text for a step-up names the action ("confirm: approve a |
| 304 | `carlos auth login` request", etc.) where the pending action's path maps |
| 305 | cleanly to a description; otherwise a generic "confirm an action in the |
| 306 | console". Builder's call on the mapping; it is copy, so it goes through |
| 307 | copy review. |
| 308 | |
| 309 | ## 7. Rate limits (R2) |
| 310 | |
| 311 | In-memory sliding windows, the `inviteWindow` shape (one console process |
| 312 | per deployment). Refusals are invisible to the requester: the "check your |
| 313 | email" page renders as normal and the send is skipped and logged. The |
| 314 | numbers (builder may tune within reason): |
| 315 | |
| 316 | | Key | Limit | |
| 317 | | --- | --- | |
| 318 | | per address, any purpose | 1 per 60 s, 5 per hour, 20 per day | |
| 319 | | 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) | |
| 320 | | whole console | 200 per hour | |
| 321 | |
| 322 | Step-up challenges have their own per-address window (1 per 60 s, |
| 323 | 10 per hour) and do not count against sign-in's address, IP or global |
| 324 | caps (the requester is already signed in as that address). Code attempts |
| 325 | are bounded per challenge (§5), not here. |
| 326 | |
| 327 | Bounces and complaints: the console's sending identity is an ordinary |
| 328 | platform mail domain, so its configuration set and the account-level SES |
| 329 | suppression list apply. Nothing new here. |
| 330 | |
| 331 | ## 8. Where people come back to (`next`) |
| 332 | |
| 333 | Signed-out visits to pages that need a session today redirect to `/` and |
| 334 | lose their place. Two of them matter for this feature: |
| 335 | |
| 336 | - `GET /invite/{token}` signed out: render the front page with the |
| 337 | invited address pre-filled ("Sign in as x@y to accept"), and `next` set |
| 338 | to the invite page (carried as a hidden field into `/auth/continue`, so |
| 339 | either discovery outcome gets it). |
| 340 | - `GET /auth/cli` signed out: `next=/auth/cli` (so a CLI user can sign in |
| 341 | and land on the code entry). |
| 342 | |
| 343 | **Onboarding runs before `next`** (codex 8). `finishSignin` first does |
| 344 | what `redirectToCurrentAccount` does to pick or mint the current account |
| 345 | (so a brand-new user always has their personal account), and only then |
| 346 | redirects to `next` instead of the account page. |
| 347 | |
| 348 | `next` is accepted only as a local path: starts with `/`, not `//`, no |
| 349 | backslash, parsed by `url.Parse` with no scheme or host. For email it is |
| 350 | stored on the challenge row; for Keymail in a short cookie beside the |
| 351 | auth cookie (`carlos_console_next`, 10 min). Anything else is dropped |
| 352 | silently and the usual landing applies. |
| 353 | |
| 354 | ## 9. Mail on carloku.com (R3) |
| 355 | |
| 356 | - `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` on the flagship console. |
| 357 | `mail.EnsureDomain` creates the SES identity for `carloku.com` under the |
| 358 | ops account (`bab`, referrer `console-mail`) and logs the DKIM records |
| 359 | it needs. Publish them in the carloku.com Route53 zone (in |
| 360 | carloku-infrastructure, where that zone is managed, unless the |
| 361 | console's own DNS writer already owns it). |
| 362 | - Before relying on it, check that no tenant has a mail-domain claim on |
| 363 | `carloku.com` (it is served by an app; a claim conflict refuses |
| 364 | `EnsureDomain`) and that SES in the console's mail region is out of the |
| 365 | SES sandbox. Checked 2026-10-21: SES in eu-west-1 is in production |
| 366 | (50,000/day), no SES identity exists for carloku.com, and the |
| 367 | carloku.com hosted zone is in the flagship account. If `carloku.com` |
| 368 | turns out to be refused, use `noreply@mail.carloku.com` (builder's call, |
| 369 | reported to Paul) rather than stop. |
| 370 | - The flagship console today has `CARLOS_MAIL_REGION=eu-west-1` and |
| 371 | `CARLOS_MAIL_PLATFORM_SUFFIX=oncarlos.com`, and its mailer has already |
| 372 | made and verified `console-mail.bab.oncarlos.com` (SES: DKIM SUCCESS, |
| 373 | config set `carlos-bab`). So the host role can create identities; only |
| 374 | the send grant is missing. That default identity is the fallback if |
| 375 | carloku.com is held up. |
| 376 | - **An existing identity needs ownership proof** (codex 9). If an SES |
| 377 | identity for carloku.com exists when the mailer first runs, and CARLOS |
| 378 | did not make it, `EnsureDomain` refuses to adopt it unless TXT |
| 379 | `_carlos-mail.carloku.com` = `carlos-mail-account=bab` is published. |
| 380 | None existed on 2026-10-21; check again at rollout, and publish the TXT |
| 381 | if one has appeared. |
| 382 | - carloku-infrastructure's region-host module call for the console's box |
| 383 | sets `console_mail_from = "noreply@carloku.com"` and |
| 384 | `console_mail_region` to the console's mail region, a targeted apply |
| 385 | (whole-root applies restart every box). |
| 386 | - Dev: the platform default From, verified on the dev platform suffix. |
| 387 | - `CARLOS_HOST_CLAIM` still disables the console mailer (#250). If the |
| 388 | flagship console runs claims-on by the time this rolls, that is a |
| 389 | blocker to raise, not to work around. |
| 390 | |
| 391 | Message text says "CARLOS platform console" today. Whether carloku.com's |
| 392 | messages say "Carloku" is a copy question for the copy review; if it |
| 393 | needs a deployment display name, that is a small config addition the |
| 394 | builder may make (`CARLOS_CONSOLE_NAME`, default "CARLOS platform"). |
| 395 | |
| 396 | ## 10. Front page and copy |
| 397 | |
| 398 | - Home: one email field and one button. No Keymail button (R5). |
| 399 | - "Check your email" page (replaces `check.html`'s Keymail-only text): |
| 400 | where it was sent, the code box, "nothing arriving?" advice. |
| 401 | - The "this console can't send email yet" refusal (§4.2). |
| 402 | - Link confirm page, step-up "check your email" page, the three email |
| 403 | bodies (sign-in, step-up, invite), the invite page's signed-out |
| 404 | variant, the `team.html` invite copy (still says "the platform never |
| 405 | emails anyone"), and the invite form's "keymail address" error. |
| 406 | - Every user-facing string goes through the copy-review skill before it |
| 407 | lands in a file. No em dashes ([[copy voice]]). |
| 408 | |
| 409 | ## 11. Smaller changes that fall out |
| 410 | |
| 411 | - `notifyCLIIssued` and `notifyStoreDecided` (storenotify.go, the |
| 412 | object-storage approved/declined notice to the customer): when the |
| 413 | address has no Keymail grant, send the notice through §4's sender |
| 414 | (federation, then SES under the same fall-through rule), never |
| 415 | straight to SES (codex 4). Operator alerts stay Keymail-only |
| 416 | (operators are Keymail users). |
| 417 | - `handleContinue` (`POST /auth/continue`): becomes the form's target |
| 418 | and runs discovery (§4.1). Its old body (an Apps-inbox confirmation |
| 419 | link for addresses with a stored grant) goes. An empty address |
| 420 | re-renders the front page with the bad-address error. |
| 421 | It validates with the invite handler's rule (codex 7): lower-case, |
| 422 | trim, `ValidAddress`, and `netmail.ParseAddress(addr).Address == addr`, |
| 423 | so `a<victim@example.com>` and list forms are refused before any |
| 424 | challenge, rate-limit key or user row exists. That one canonical |
| 425 | string is used everywhere after. |
| 426 | - `handleConfirm` (`/auth/confirm?token=`, the old Apps-inbox link) is |
| 427 | retired: nothing mints those links any more, and any already sent |
| 428 | lapse within 15 minutes of the roll. The route stays (claim-routes) and |
| 429 | renders the existing "link expired" message; `CreateConfirmation`'s |
| 430 | last caller goes (leave the table; `PurgeExpired` keeps clearing it). |
| 431 | - The "Check your Keymail" template and `page.KeymailURL` go. |
| 432 | - `PurgeExpired` also clears expired `email_challenges`. |
| 433 | - New routes go into `testdata/claim-routes.txt`, the invariant fixtures |
| 434 | and probes, like every route. |
| 435 | |
| 436 | ## 12. Tests that prove it |
| 437 | |
| 438 | Through real wiring (the console fixture, the real store, the router), |
| 439 | with fakes only at the edges (fake Keymail, fake console mailer, a |
| 440 | controllable clock): |
| 441 | |
| 442 | - Sign-up by link for a new address: user created, personal account |
| 443 | minted, session `method=email`, welcome shown. |
| 444 | - Link GET does not consume; POST does; second POST fails. |
| 445 | - Code: right code signs in; 5 wrong codes burn; a code from another |
| 446 | browser's challenge does not work. |
| 447 | - Discovery: Keymail-host address → redirect to Keymail with the state |
| 448 | cookie; lookup 200 → Keymail; 400/404/409 → email; lookup timeout → |
| 449 | email; a stored grant for a 404 address still → email (no console |
| 450 | state in discovery). |
| 451 | - The "check your email" page is byte-identical for a new address, a |
| 452 | known one, and a rate-limited one. |
| 453 | - Invite and notice chain: definitive no-mailbox → SES; lookup 5xx on a |
| 454 | Keymail-host address → nothing sent; blocked → nothing sent. |
| 455 | - Rate limits at each key; a refused request creates no challenge. |
| 456 | - Guessing: wrong codes across several fresh challenges for one address |
| 457 | stop at the per-address cap (codex 2). |
| 458 | - Login CSRF: a cross-site auto-submitted link POST without the nonce |
| 459 | cookie is refused (codex 1). |
| 460 | - Signed-out `carlos auth login` by a new email user, without advancing |
| 461 | the clock between sign-in and approval: one email, a personal account |
| 462 | minted, CLI approved (codex 5, 8). |
| 463 | - Step-up: an email session doing a gated action gets an email challenge, |
| 464 | the pending action resumes after the code or same-session link; the link |
| 465 | in another browser refuses; a **keymail** session never gets an email |
| 466 | challenge (downgrade test). |
| 467 | - `next`: invite page round trip for an email invitee from signed-out to |
| 468 | member; unsafe `next` values ignored. |
| 469 | - `carlos auth login` approval by an email user end to end. |
| 470 | - Migration on an existing database: old sessions read `keymail`. |
| 471 | |
| 472 | ## 13. Rollout |
| 473 | |
| 474 | 1. Merge (CI green on a fresh main merge), release, roll dev boxes → dev |
| 475 | console. Live check on dev: sign up with a plain email address, accept |
| 476 | an invite, approve a CLI login with an emailed code. |
| 477 | 2. Flagship: SES identity for carloku.com verified, region-host grant |
| 478 | applied (targeted), env set on the console unit, roll flagship boxes → |
| 479 | console. The same three live checks on carloku.com, plus a Keymail |
| 480 | sign-in to prove nothing regressed. |
| 481 | |
| 482 | ## 14. Left to the builder |
| 483 | |
| 484 | Names of files, functions and routes beyond those fixed above; the |
| 485 | semaphore size; the step-up action descriptions; tuning the rate numbers |
| 486 | within the same order of magnitude; template structure. |
| 487 | |
| 488 | ## 15. Review dispositions |
| 489 | |
| 490 | Builder check (rev 1), settled as builder's calls: |
| 491 | - B1 superseded by R5: Keymail addresses never get an email link, so the |
| 492 | sign-in door sets the session method (§4.1, §5.4). |
| 493 | - B2 email sign-in counts as a step-up (§5.3); step-up has its own |
| 494 | per-address window (§7). |
| 495 | - B3 carloku.com refused: fall back to `noreply@mail.carloku.com` (§9). |
| 496 | - B4 federation fall-through rule (§4), from Keymail's live answers. |
| 497 | - B5 `notifyStoreDecided` joins `notifyCLIIssued` (§11). |
| 498 | |
| 499 | Astra round 1 (`docs/superpowers/reviews/CODEX-FINDINGS-email-signin-r1.md`), |
| 500 | all accepted: codex 1 login CSRF (§5.1); codex 2 per-address guessing |
| 501 | budget (§5); codex 3 pending-action hash (§6); codex 4 no SMTP to Keymail |
| 502 | (§4, §11); codex 5 CLI step-up cooldown (§5.3, §7); codex 6 step-up TTL |
| 503 | (§6); codex 7 strict address parsing (§11); codex 8 onboarding before |
| 504 | `next` (§8); codex 9 existing-identity proof (§9). |
| 505 | |
| 506 | Astra round 2 (`CODEX-FINDINGS-email-signin-r2.md`): all nine round-1 |
| 507 | fixes confirmed. Both new findings accepted: r2-1 codes MACed under an |
| 508 | in-memory key (§5); r2-2 the guessing cap throttles codes but never burns |
| 509 | another browser's link (§5). |
| 510 | |
| 511 | Rev 4 (Paul, 2026-10-21, R5): no "Sign in with Keymail" action; discovery |
| 512 | from the address picks Keymail OAuth or email. Consequences: sign-in and |
| 513 | step-up messages go by SES only (§4.2); the Apps-inbox confirmation link |
| 514 | is retired (§11); B1's channel-based method is gone; invites and notices |
| 515 | keep the B4 two-channel rule (§4.3). |
| 516 | |
| 517 | Astra round 3 (`CODEX-FINDINGS-email-signin-r3.md`): all eleven earlier |
| 518 | fixes confirmed, no P1. Both P2s accepted: r3-1 the no-mailer answer is |
| 519 | decided before rate limits (§4.2); r3-2 `ConsoleMailer.Ready` (§4.2). |
| 520 | |
| 521 | Rev 6 (build, 2026-10-21, after the whole-branch Astra review): the |
| 522 | browser no longer holds a per-challenge id. A sign-in **flow** cookie |
| 523 | (`carlos_console_flow`), set when the sign-in form renders, ties a |
| 524 | browser's sign-in challenges together; the code box uses the flow's |
| 525 | newest live challenge; `/auth/continue` serialises per flow. A refused |
| 526 | request (rate limits) sends nothing and leaves the flow's live challenge |
| 527 | untouched, or, if it has none, records a never-sent challenge so a wrong |
| 528 | code reads the same either way. That replaces §5's "a rate-limited |
| 529 | request creates no challenge": no refused request creates a challenge |
| 530 | the mailbox could redeem. Over the per-IP submit cap there is no Keymail |
| 531 | lookup and no send. Step-up challenges are found by session. Reasons: |
| 532 | the per-challenge id leaked throttling through the code box and lost the |
| 533 | code on a double-clicked Continue. |
| 534 | |