The RP-ID guard was never actually fixed, and ten follow-ups
The fifth review's one block: B5 was reported fixed in the last commit message and was not. The edit silently failed to match after an earlier pass had already touched that snippet, so the guard still declared the origin as a test-local constant and asserted it against itself — a gate that cannot fail whatever the app configures, on the ruling the gate exists for. It reads passkeyConfig(t).Origin now, uses HasSuffix rather than Contains (which accepts x.oficina.works.attacker.example), and moves to package home, because it calls a helper over the unexported App and a guardstest-package test cannot reach one. Verified present this time rather than asserted. The follow-ups, all real: app.go is modified by Tasks 1 and 2 and appeared in neither's git add, so both intermediate commits would have failed to compile — home.New() takes no arguments on main and Task 1 hands it a Sealer. newTestApp was described in its finished form in the first task that needs one, using BootSchema (Task 2), the signin dependency (Task 4) and App.flow (Task 5). It starts with the database and the Sealer and grows as each lands. Its stub LookupTXT returning nil, nil would have put every sign-in in the suite on the network: an empty TXT result falls through to delegate, which returns the domain itself, and domainInfo then makes a live HTTPS GET to the typed domain's /.well-known/keymail. Task 7's allowlist names /static/webauthn.mjs and /static/passkey.js literally, while rastrillo's asset helper fingerprints. Those two are now routed on stable paths on purpose: a security rule that cannot name its subject is not a rule. sealtest imports testing from a non-test file exactly as logtest does, so the guard that keeps logtest out of the binary now covers it too. Nobody swept sessions or passkey_pending; both get the same boot-plus-on-write shape as the link sweep. loadSecrets was in the test-helpers table while being the production loader main.go calls. scriptSrcs was used by the no-script test and listed in no helper step. The no-script test file was in no git add. Task 4 had two contradictory Consumes lines. And the File Structure still placed logtest on the gate-9 branch, which merged. Last, the two corrections this plan had only "filed": §5.1 and AGENTS.md both still say classification goes through rastrillo/auth, which cannot be done without its address-bearing tables. AGENTS.md's own rule is that such disagreements are fixed in the same commit, so they get fixed in this branch rather than left as a note.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+72
−23
docs/superpowers/plans/2026-09-03-home-signin.md+72 −23
diff --git a/docs/superpowers/plans/2026-09-03-home-signin.md b/docs/superpowers/plans/2026-09-03-home-signin.md| index 916689e..acd926b 100644 |
| --- a/docs/superpowers/plans/2026-09-03-home-signin.md |
| +++ b/docs/superpowers/plans/2026-09-03-home-signin.md |
| @@ -86,6 +86,13 @@ helper folds case for the same reason. |
| §5.1 says "We still use `rastrillo/auth` for **address classification and Keymail upgrade** — the part that is hard to get right and holds nothing." **`auth` cannot be used that way.** Its classification is reachable only through `auth.Begin`, which writes `auth_links.address TEXT NOT NULL` — verified at source, and the very thing ruling 14 exists to avoid. |
| +**Land the correction, do not merely file it.** AGENTS.md's own rule is |
| +that where it disagrees with the spec the spec wins "and this file is wrong |
| +— fix it in the same commit". Both §5.1 and AGENTS.md's Data section say |
| +classification goes through `rastrillo/auth`; both are wrong for the reason |
| +below, and both are on `main`. Fix them in **this branch**, so the plan and |
| +the binding files stop disagreeing the moment it lands. |
| + |
| The usable seam is one level down: `github.com/keymaildev/signin` exports `Classifier` as a plain struct with exported fields (`HTTP`, `LookupTXT`, `Scheme`, `Now`, `TTL`, `NegTTL`) and a `Classify(ctx, address) Class` method. No constructor, no database, no stores. **Depend on `signin` directly** (rastrillo already requires v0.1.1). This plan does that; the spec's sentence should be corrected to match — filed, not silently diverged from. |
| --- |
| @@ -104,10 +111,13 @@ mid-task. |
| | `capturedLink(t, app)` | Pulls the magic link out of the captured mail. | |
| | `signIn(t, app, addr)` | The whole ceremony: POST `/signin`, take the link, GET the confirm page, POST the form. Task 4's tests all go through it. | |
| | `passkeyConfig(t)` | Returns the `passkey.Config` the app builds, for Task 7's guard. | |
| -| `loadSecrets(t)` | `HOME_DEK`, `HOME_PEPPER` and `Flow.Key` from the environment, with the boot refusals below. | |
| -**Build these in Task 1 (`newTestApp`, `loadSecrets`) and Task 5 |
| -(`signIn`), each with its own step and its own test.** An earlier draft |
| + |
| +`loadSecrets` is **not** in this table: it is the production loader |
| +`cmd/home/main.go` calls (Task 1 step 5b), not a test helper. |
| + |
| +**Build these in Task 1 (`newTestApp`) and Task 5 (`signIn`, |
| +`capturedLink`), each with its own step and its own test.** An earlier draft |
| listed them in this table and then had no step create any of them. |
| **`newTestApp` must wire a non-nil `Flow.Keymail`.** `Begin` skips |
| @@ -193,7 +203,7 @@ internal/home/ |
| render.go One parsed tree per page, ui.Funcs plus an asset func. |
| templates/ Scriptless screens, attribute spelling |
| (ruling 20; the seven utilities keep class). |
| - logtest/ Gate 9. Already on feat/gate-9-no-content-in-logs. |
| + logtest/ Gate 9. Already on main (9162ee5). |
| internal/guardstest/ |
| logsinks_test.go Already landed. Forbids non-slog sinks. |
| noaddress_test.go Gate 2's schema half. |
| @@ -327,7 +337,10 @@ Test that unset, malformed and 31-byte values each error, and a valid one return |
| The plan commits `internal/home/sealtest` and no step ever created it. |
| Port `sealtest.go` from sheets-core **with its own `TestLeakedDetectsCanary` |
| -and WAL tests** — a detector nobody has watched detect anything is the |
| +and WAL tests**, and **extend `TestLogtestOnlyImportedByTests` to cover |
| +`sealtest` too** — one string in a slice. Like `logtest`, it imports |
| +`testing` from a non-test file, so importing it from production code would |
| +drag the testing package into the binary. — a detector nobody has watched detect anything is the |
| thing gate 9's review found twice. |
| Export **`CanaryAddress`** (a lowercase, address-shaped |
| @@ -379,10 +392,22 @@ so the fail-open test would pass vacuously without it. Register the |
| `secure_delete` connection hook here too, under a `sync.Once`, and **before** |
| `db.Open` (Task 2 step 5). |
| +**It starts small and grows.** `BootSchema` is Task 2's, the `signin` |
| +dependency is Task 4's and `App.flow` is Task 5's, so build it here with the |
| +database and the Sealer only, and let Tasks 2, 4 and 5 extend it as each |
| +lands. An earlier draft described the finished helper in the first task that |
| +needs one, which does not compile. |
| + |
| +**The stub `LookupTXT` must return a real error, not `nil, nil`.** An empty |
| +TXT result makes `signin` fall through to `delegate`, which returns the |
| +domain itself, and `domainInfo` then makes a live HTTPS GET to |
| +`https://<domain>/.well-known/keymail`. Every `signIn` in the suite would |
| +touch the network. |
| + |
| - [ ] **Step 6: Commit** |
| ```bash |
| -git add internal/home/seal internal/home/sealtest internal/home/keys.go internal/home/keys_test.go internal/home/testapp_test.go cmd/home/main.go go.mod go.sum |
| +git add internal/home/seal internal/home/sealtest internal/home/keys.go internal/home/keys_test.go internal/home/testapp_test.go internal/home/app.go cmd/home/main.go go.mod go.sum |
| git commit -m "Seal values at rest, load the four secrets, and the plaintext gate that proves it" |
| ``` |
| @@ -629,7 +654,6 @@ Where the blindness claim is either kept or quietly broken. |
| not with the handlers) |
| **Interfaces:** |
| -- Consumes: `seal.Sealer`, `NewPersonRef`, `sessions.Sessions`, `mail.Sender`. |
| - Consumes: `seal.Sealer` (Task 1) and the database (Task 2) — and nothing else. |
| - Produces: `(*App).links`, a `signin.LinkStore`. **Routes nothing, and needs no `sessions` or `mail.Sender`** — the handlers are Task 5's, at `POST /signin`, `GET /signin/verify` (renders), `POST /signin/verify` (redeems) and `GET /signin/callback`. |
| @@ -766,7 +790,13 @@ fail is a gate written to pass — the finding that came back twice on gate 9. |
| Sealed rows are not a leak, but unbounded growth is a bug. Delete |
| `WHERE expires_at < ?` **at boot and on each `PutLink`**, the shape |
| -`passkey.Sweep` suggests. (Not "the wake-driven schedule §6 uses" — that |
| +`passkey.Sweep` suggests. |
| + |
| +**And sweep the other two while building the habit**, because nobody else |
| +does: `sessions.Sweep` (rows live 30 days) and `passkey.Sweep` |
| +(`passkey_pending`, `passkey_challenges`). Same boot-plus-on-write shape. |
| +None of these leak — they are keyed on person refs — but a table nothing |
| +ever deletes from is a bug waiting for a big enough database. (Not "the wake-driven schedule §6 uses" — that |
| sentence is about *instances*, and AGENTS.md says **the home does not |
| hibernate**, so there are no wakes to hang it on. A ticker would work here |
| and is simply not worth a goroutine for this.) |
| @@ -1123,6 +1153,14 @@ GET /static/webauthn.mjs webauthn.JS(), text/javascript |
| GET /static/passkey.js the app glue |
| ``` |
| +**Both on stable, unfingerprinted paths.** Everything else may go through |
| +rastrillo's fingerprinting asset helper; these two may not, because Task 7's |
| +allowlist has to name them, and a rule that cannot name its subject is not a |
| +rule. |
| + |
| +``` |
| +``` |
| + |
| `ConfirmPath` is `/passkey/confirm` (its default, named rather than |
| defaulted). `RequireFresh`'s `maxAge` for the codes page is **5 minutes** — |
| it must be short, because the page shows ten codes once, and it must exceed |
| @@ -1169,7 +1207,8 @@ git commit -m "Passkeys as an optional second factor, gated on both sign-in path |
| **Files:** |
| - Create: `internal/home/render.go`, `internal/home/templates/*.html` |
| -- Create: `internal/guardstest/rpid_test.go` |
| +- Create: `internal/home/rpid_test.go` — **package `home`, not `guardstest`**: it reads the app's own config through a helper over the unexported `App`, which a `guards` test cannot reach. |
| +- Create: `internal/home/noscript_test.go` |
| - Create: `internal/home/static/tokens.css`, `internal/home/static/theme.css` — vendored from rastrillo. **Under `internal/home/`, not the repo root:** `go:embed` cannot reach a parent directory, sheets-core embeds `all:static` from inside its package, and `rastrillo new` scaffolds `internal/<pkg>/static/` with a `TestVendoredAssetsMatchTheLibrary` that reads it there. |
| **Interfaces:** |
| @@ -1210,22 +1249,25 @@ ignored `LegacyRPID` entirely. |
| // accepted RP ID — for assertions only; registration never takes it |
| // (webauthn.go rpMatches, allowLegacy). Set to "oficina.works" it |
| // collapses the split §4 exists to make. |
| -const credentialOrigin = "https://id.oficina.works" |
| - |
| +// This test lives in package home, NOT internal/guardstest: it reads the |
| +// config the APP builds, via a helper over the unexported App, and a |
| +// guards-package test cannot call one. |
| func TestRPIDIsExactlyTheCredentialHost(t *testing.T) { |
| - u, err := url.Parse(credentialOrigin) |
| + cfg := passkeyConfig(t) // what the app configures, never a literal |
| + |
| + u, err := url.Parse(cfg.Origin) |
| if err != nil { |
| - t.Fatalf("parse %q: %v", credentialOrigin, err) |
| + t.Fatalf("parse Origin %q: %v", cfg.Origin, err) |
| } |
| if u.Hostname() != "id.oficina.works" { |
| - t.Errorf("RP ID is %q, want id.oficina.works (rulings 13, 16)", u.Hostname()) |
| + t.Errorf("RP ID is %q, want id.oficina.works: passkey derives the RP ID "+ |
| + "from Origin's hostname, so widening Origin widens it (rulings 13, 16)", |
| + u.Hostname()) |
| } |
| - // Wherever a passkey.Config is built, LegacyRPID must be empty, or a |
| - // full host with a comment naming the §5.7 drill that is running. |
| - // Until Task 6 lands there is no passkey.Config; assert the Origin half |
| - // alone and let this half start as a skipped subtest with the reason |
| - // written down, never as an absent assertion. |
| - if cfg := passkeyConfig(t); cfg.LegacyRPID != "" && !strings.Contains(cfg.LegacyRPID, ".oficina.works") { |
| + // LegacyRPID must be empty, or a full host, with a comment naming the |
| + // §5.7 drill that is running. HasSuffix, not Contains: Contains accepts |
| + // "x.oficina.works.attacker.example". |
| + if cfg.LegacyRPID != "" && !strings.HasSuffix(cfg.LegacyRPID, ".oficina.works") { |
| t.Errorf("LegacyRPID %q widens the RP ID (§4, ruling 2)", cfg.LegacyRPID) |
| } |
| } |
| @@ -1253,6 +1295,12 @@ any script at all on those screens. Constrain it: |
| ```go |
| // scriptAllowed is ruling 21's allowlist, by TEMPLATE and by SRC. Naming |
| // the screens is not enough — that would admit any script at all on them. |
| +// Paths, not fingerprints. rastrillo's NewAssets.Path fingerprints |
| +// (/static/passkey.<hash>.js), so if these templates used the asset func |
| +// the allowlist would never match and the carve-out screens would fail |
| +// their own sweep. Task 6 routes these two on STABLE paths for exactly |
| +// that reason — they are the only two assets that need to be nameable in |
| +// a security rule. |
| var scriptAllowed = map[string][]string{ |
| "passkey_confirm.html": {"/static/webauthn.mjs", "/static/passkey.js"}, |
| "passkey_enrol.html": {"/static/webauthn.mjs", "/static/passkey.js"}, |
| @@ -1295,8 +1343,9 @@ func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T) |
| - [ ] **Step 2a: Build the test helpers this task's guards need** |
| -`templateNames(t)`, `renderTo(t, w, name, data)`, `sampleData(name)` and |
| -`passkeyConfig(t)` — all four are used by the two guards above and created |
| +`templateNames(t)`, `renderTo(t, w, name, data)`, `sampleData(name)`, |
| +`scriptSrcs(t, b)` (parse the HTML and return each `<script>`'s `src`, empty |
| +string for an inline one) and `passkeyConfig(t)` — all five are used by the two guards above and created |
| by no step in an earlier draft. `passkeyConfig` must return the config the |
| **app** builds, not one the test declares, or the RP-ID guard asserts a |
| constant against itself. |
| @@ -1304,7 +1353,7 @@ constant against itself. |
| - [ ] **Step 3: Commit** |
| ```bash |
| -git add internal/home/render.go internal/home/templates internal/home/static internal/guardstest/rpid_test.go |
| +git add internal/home/render.go internal/home/templates internal/home/static internal/home/rpid_test.go internal/home/noscript_test.go |
| git commit -m "Scriptless sign-in screens, and the guard that the RP ID is a full host" |
| ``` |