feat/overnight-origin-split
Prompts
Independently review this security prerequisite design AND implementation as Claude Fable. Do not edit files or execute mutations. Report exact severity, file, issue, and verdict. Scope: one binary serves credential origin id.oficina.works and directory oficina.works; reject unknown Host and credential routes on directory; keep existing credential root login completion working via empty-body 303 to directory root. Static CSS and health public on both. No new product copy. No assertion secrets/protocol decisions in this slice. Review host-header trust, router coverage, no cookie or RP broadening, downstream proxy/canary assumptions, regressions, missing tests. The draft plan origin task is unreviewed; review this bounded plan independently, not whole assertion protocol. Strict exact Host intentionally rejects alternate port/case hosts until edge evidence. Give plan verdict and branch verdict separately. Binding instructions: # π€ The Oficina home The suite's glue: identity, directory, provisioning, person preferences. Consumed by `oficina/sheets` and `oficina/docs`. **It is not an application and holds no documents.** This file is binding. Where it disagrees with a general skill, this file wins. Where it disagrees with the design spec, the spec wins and this file is wrong β fix it in the same commit. **The design:** `docs/superpowers/specs/2026-08-30-home-design.md`. Β§1 is the settled-decisions table: dated, attributed, with the rejected alternative beside each. --- ## The one rule > **The home is glue: thin, blind, replaceable. It knows that you exist and > where your teams are. It never stores a readable address, and it never > retains what is in anything.** **"Retains", amended from "learns" on 2026-09-05** β spec ruling 18 and `design-activity-pull`, approved that day. The falsifiable form is unchanged **by that amendment** β ruling 23 later changed it, and the form above is the current one. **It means holds without a bound**: the home does see titles and does keep them in RAM across requests, bounded by a TTL and a hard maximum; at rest is the unbounded case, and that is what it never does. "Blind" above is Factor XI's category label, not a claim it never sees one. Spec Β§0 carries the full gloss. The falsifiable form: **publish this database in full and the worst anyone learns is how many people exist, what their teams are called, which person refs belong to which team, and which of them own it.** No document. No cell. No readable address. **"And which of them own it" arrived with ruling 23** and is the whole cost of the membership roster: an owner row names a person ref from one team plus a fact that is usually public, where the old wording needed several teams and an outside fact. Spec Β§0 and Β§3.3 carry the full reasoning. *Two absolutes were argued and lost, and stay lost:* **"never stores your address"** β a magic link's address is at rest between send and click, sealed (Β§3.2), not absent β and **"some unidentified person"**: a team-set fingerprint plus one outside fact names the row (Β§3.3). Three gates enforce it (Β§12; Β§13 step 1 lists all three): a **plaintext grep** over the raw SQLite file and WAL **plus this app's store objects**, against a seeded corpus of known addresses; a **no-address-at-rest** test asserting the schema has no address column and that a full sign-in plus directory upsert **plus rekey plus the roster seed verb** leaves no readable address anywhere, **WAL included**; and **no-content-in-logs** (gate 9). **Gate 9 needs no database** β it scans strings, and `cmd/home/main.go` emits log lines β **so it was never sequenced behind one**, and it landed beside the skeleton. Gates 1 and 2 do need one, and it now exists: gate 1 scans the raw file, WAL and SHM for canaries, and gate 2 runs in two halves, a static read of every migration and a full sign-in asserted to leave no readable address anywhere. Gate 2's expensive half must be **extended, not replaced**, when the directory upsert, the rekey and the roster seed verb land β it asserts the sign-in third of its sentence today. A guard written after the code it guards is a guard written to pass. (Stated as the rule rather than as a status: which gates exist today is a question for `git log`, and a status line in a binding file is a sentence that goes stale without anyone noticing.) --- ## The four things this service never does Each of these is something somebody will propose, in good faith, as an improvement. 1. **Never *store* content from an instance.** No previews at rest, no sheet thumbnails, no cell values, no titles in a database, a log, an error string, a metric label or an on-disk cache. It **may render**, transiently and per request, **and only to that person**, what an instance hands it for the person it hands it for β and nothing wider: no search over pulled content, no cross-person aggregation, no analytics, no operator view, and no retry queue holding a response body. **"Per request" does not forbid the cache the spec approves.** `design-activity-pull`'s Β§7 keeps pulled titles in RAM across requests, bounded by a refresh TTL and a hard maximum. Read alone, "transiently and per request" would rule that out, so read it with spec Β§0's gloss: **retains means holds without a bound**, at rest is the unbounded case, and the RAM hold is permitted because it is bounded and tested. **Amended 2026-09-05** from *never render*, on spec ruling 18. "Renders" was doing custody's work: the clause exists to stop the home becoming a place documents live, and storage is what makes it that. It also gained a test β spec Β§12's gates 1 and 9 grew a corpus of known titles, URLs and actor strings that day, so *never stores* has a grep behind it where *never renders* had none. 2. **Never hold a per-item grant.** Which sheets you may edit, which document a guest may comment on: all instance-side, decided there on every request. **The home does hold the membership roster** β `(team, person_ref, role)`, ruling 23 β so it knows who is on a team and who owns it. It never knows what they may do with any particular thing. This replaced *"never hold an authorization model"* on 2026-09-05; the line moved from *facts versus may-this-person* to **resources the home owns versus an instance's items**, because the roster is a person-level entitlement the home now holds on purpose. 3. **Never cross a team.** Data does not cross a team label; a team's instances across apps are one trust domain (spec ruling 23, suite ruling 1). This replaced *"never federate β data does not cross an instance boundary"* on 2026-09-05: the boundary was drawn in the wrong place, since `acme.docs` and `acme.sheets` are the same team and always were. **Sharing across teams** is a guest grant on the instance, never a copy. **Team standing is the roster**, not a grant and not a directory row (spec Β§5.5, Β§6: a directory row never grants team standing whatever its `kind` says). Β§6 keeps the two apart deliberately: "a row implying an org membership someone does not have is a lie the directory tells on the org's behalf". 4. **Never become a support console.** Reading someone's data to help them is precisely what **rule 1 above** forbids β "only to that person", "no operator view". It used to be the one rule; since the 2026-09-05 amendment "never retains" no longer reaches a console that displays without keeping, so the anchor moved and a reference left pointing at the one rule would rest on nothing. **And it stays small.** Every proposed feature is weighed against "does this belong in an instance instead?" The answer is usually yes. This service has a suite-wide blast radius, and a home that grows features grows that radius. --- ## Review β RULED 2026-08-30 by Paul **Every spec and every branch is reviewed by the highest-capability model available before it is approved or merged.** Cheaper models draft; the most capable available reviews. A review by a weaker model than the author is not a review. **Record which model reviewed** β in the spec's status line, and in the branch discussion for a branch. A review nobody can attribute is a review nobody can check. The model is **discovered, not hardcoded**; a session that cannot reach one at least as capable as the author's says so and waits. --- ## Shipping cadence β RULED 2026-08-30 by Paul Everything on a worktree, on a branch. Never directly on `main`. 1. Branch per piece of work, cut from `main`. 2. Commit regularly and push to the branch. 3. **Squash-merge to `main` only when** the work is done, it is approved (including the review pass above), and **CI is actually green** β checked, not assumed. Any one of the three missing means it does not merge. Review on a deployed canary, never localhost. **Every branch gets a discussion, and that discussion is the review record** β it survives a squash where a PR body does not. Tasks are a checklist, not a log. **A refusal gets surfaced in the discussion, never routed around** (amadan's own house rule, and the one most likely to be quietly ignored). --- ## Mechanical rules **Identity** - The **credential origin** serves sign-in, WebAuthn, recovery and assertion minting, and **never renders an account-authored string**. It is the relying party. This is a permanent constraint with its own test, not a naming choice. - **The RP ID is the full host β `id.oficina.works` β asserted by a test.** Never widen it to a registrable suffix. WebAuthn *permits* an RP ID that is a suffix of the origin's hostname, so `oficina.works` would be accepted; it is also valid at every host in the zone, the directory and every team instance included, which collapses the very split the two origins exist to make (Β§4, rulings 2 and 13; the host itself is ruling 16). Note where the first door actually is: `passkey.Config` has no RP ID field β the RP ID is always `Origin`'s hostname β so the way to widen it is to widen `Origin`, or to bypass `passkey` and build a `webauthn.Config` directly. The second door is `passkey.Config.LegacyRPID`, passed straight through as an RP ID accepted **for assertions only** (registration never takes it), so it must stay empty unless a rename drill (Β§5.7) is actually running, and then hold a full host. The test asserts both: `Origin`'s hostname is exactly `id.oficina.works`, and `LegacyRPID` is empty or a full host. Both are accepted silently, which is why neither can rest on care. - Assertions carry **identity and display preferences, never authorization**. The boundary test (Β§5.4) is **two-sided**: a claim may ride the assertion only if **(a)** the instance makes no trust decision based on it, validating against a **closed enumerated set** and treating a failed match as *absent* rather than as the raw value; **and (b)** the home already holds it for its own purposes, so carrying it adds nothing to what the home knows. Theme passes both. A role fails (a). **"Which documents you can see" passes (a) and fails (b)** β it hands the home a document-level graph it has no business holding. - **Do not restore "the instance would behave identically given a hostile value."** Β§5.4 retracts it: a hostile theme gives an ugly page, which *is* different behaviour, so the test's own example fails it β and without (a)'s closed set, a free-string theme in CSS is an injection, not an ugly page. - Assertion expiry is **120 seconds, not "seconds"** β sized to worst-case cold wake plus clock skew, because the exchange request *is* what wakes a hibernating instance and a tighter window makes the first sign-in to any cold instance fail and loop. Widening it costs nothing **against the threat model**, because **single-use `jti` is what stops replay**, not brevity. The long-lived thing is the session the instance mints. - Every assertion names the **app and instance host** it is for. Without audience binding, team A's assertion replays against team B, and a deployment has thousands of siblings to replay against. - **Signed identity claims are trusted for identity; every other claim must be cosmetically failable.** The subject flagrantly fails (a) β a hostile subject is account takeover β so the test must exempt identity by construction or it proves too much and gets argued around. **One narrow exception, added 2026-09-05 with ruling 23: `roster_epoch`.** It is not a closed enumerated set and does not fail cosmetically, so it would fail test (a) as written. What admits it is that **the instance decides nothing from it** β an unparseable value is ignored, a differing value buys exactly one call-8 refetch, and call 8's answer is what is enforced. A builder following the test without this exception rejects a claim Β§5.3 now puts on every assertion. Spec Β§5.4 carries the reasoning. - **Any "prune when all consumers have caught up" is unbounded in a hibernating fleet.** A dormant instance runs no timers to advance its cursor. Retention is a fixed horizon or a per-consumer TTL, never a slowest-consumer rule. This shipped twice in one spec before it was caught. **Data** - **No address column exists**, so there is none to accidentally populate. Addresses are learned transiently and hashed β and where one must survive a restart (a magic link between send and click), it is **sealed before it is written**, never cleaned up afterwards. Deleting a row does not erase bytes: SQLite leaves deleted pages and Litestream ships WAL frames continuously, so a transiently readable write reaches the bucket before any delete. `PRAGMA secure_delete` is on. - **The session Subject is an opaque person ref, never an address.** `rastrillo/auth` as shipped sets `Subject: id.Address` and stores readable addresses in `auth_links.address` and, via `Subject: id.Address`, in `sessions.subject` β `auth_sessions` is no longer written at v0.22.0, its rows having been migrated into the shared `sessions` table, so the leak moved rather than closed. Verified at source, filed upstream as `rastrillo/rastrillo` #2. We hand-roll session minting until that lands, and take classification and the Keymail upgrade from **`keymaildev/signin` directly, not from `auth`** β corrected 2026-09-05: `auth`'s classification is reachable only through `auth.Begin`, which hardwires `Links: &linkStore{db: cfg.DB}` and writes `auth_links.address`, so "use `auth` only for the parts that hold nothing" was never a thing that could be done. `signin.Flow` takes a `LinkStore` the app supplies, so the app seals what it stores. - **The home hashes; instances never hold the pepper.** A pepper on every instance makes one compromise an offline dictionary attack against the whole suite directory. - Migrations are additive-only. - **No address, team name, queue payload, or pulled content β a title, a URL, an actor (added 2026-09-05 with Β§2.2's amendment) β in any log line, error string, job status message or metric label.** Platform-held logs are storage we cannot grep. **Frontend** - **Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** β spec ruling 20, 2026-09-05. Β§11 ruled the class vocabulary on 2026-08-30 and every reason it gave has since died, so it went back to Paul rather than being propped up with a third. rastrillo's design system rules "One spelling, not two. No permanent class fallback" and drops the class selectors before 1.0; this app has no `rst-` markup yet, so it starts on the spelling that survives β free today, a sweep after the first template. **The seven utilities are the exception, and they are not a loophole.** `rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`, `rst-danger` and `rst-cell-mut` keep `class=`, because at v0.22.0 they have **no attribute twin at all** β five `.rst-sr-only` selectors, zero `[rst-sr-only]`. tokens.css's header says why ("Utilities keep the class spelling and are not pairedβ¦ they stay class after the move") and Β§6-v3 agrees ("`class` is for utilities and the app's own CSS"). Write `<span rst-sr-only>` and nothing matches, so screen-reader-only text renders **visible**. Kinds, variants and parts are attributes; these seven are classes. This is also what the 6 surviving `class=` occurrences in v0.22.0's partials are β utilities, not residue awaiting a flip. Record the graveyard, so neither dead reason is revived: - *"The attribute grammar is ruled but not released."* Dead since **v0.21.0**, this repo's *first* pin. tokens.css there pairs every *component* rule with an attribute twin and says so in its own header: "TWO SPELLINGS, ON PURPOSE, FOR ONE RELEASE." The original claim β zero bare `rst-*` selectors against ~528 class occurrences β was verified on 2026-08-30 against v0.20.x, was true then, and was already false by the time the skeleton existed. No later bump falsified it. - *"The partials we render alongside write classes."* Dead at **v0.22.0**, the current pin, where the shipped partials flipped to the attribute spelling β rastrillo's design-system Β§6-v3 stage 2. Counted in `ui/partials/` with template comments stripped, the class spelling falls from **124 occurrences at v0.21.0 to 6 at v0.22.0**, replaced by the attribute spelling. (Count the class spelling, not the attribute one: `class="rst-β¦"` is unambiguous, while attribute-spelling totals move with how you treat valued attributes like `rst-tone="β¦"`, and two careful counts of it disagreed.) `callout.html` at the pin is `<div rst-callout rst-tone="β¦">`. tokens.css's header still claims "the class spelling is what every app and every shipped partial writes today" β **that sentence is stale upstream**, and this file quoted it as evidence until it was checked. **Re-check on every pin move, name the version you checked, and count both `ui/tokens.css` and `ui/partials/`** β checking one and not the other is how the second reason survived a pass that was looking for exactly this. - **`rastrillo doctor` has landed** β in v0.21.0, the first version this repo pinned. "Adopt it the day it lands" was a trigger that had already fired when it was written. When this app vendors `static/`, run `rastrillo doctor --fix` on each upgrade rather than re-copying by hand, and add the `TestVendoredAssetsMatchTheLibrary` that `rastrillo new` scaffolds as the standing gate β "add", not "keep": this repo was hand-written, not scaffolded, so it has no `internal/hometest/` and no copy of that test. There is no vendored `static/` in the tree today β `internal/home/page.go` links no stylesheet, deliberately β so this is an instruction with no object until the first asset arrives. - Zero JS, **with exactly one carve-out** (ruling 21): the passkey enrolment and ceremony screens **may load script**. rastrillo ships the hard half β `webauthn.JS()` embeds `js/webauthn.mjs` β but that module only exports `navigator.credentials` wrappers and makes no network call, so the page also needs a few lines of app glue to import it, call it and POST the result. Both are allowed; both are **static same-origin files, never inline `<script>`**. That sentence used to read "no surface here has any reason to require script, and the day one does is the day the spec is wrong" β and the day came, with passkeys. It was a tripwire and it fired correctly. Everything else stays scriptless: magic-link sign-in, recovery-code redemption and the springboard, so script-off keeps a working sign-in and loses only an optional second factor. The no-`<script>` test carries an **explicit allowlist of the carved-out screens** β never a blanket exemption, because a rule with a list is checkable and a rule with an escape hatch is not. **Deploys** - One binary, `CGO_ENABLED=0`, cgo-free SQLite. - A 200 is not a deploy verification. Only `X-Carlos-Version` on the canonical host, after the watch. - A promote is not a deploy. Until the process cycles, the old binary serves. - **The home does not hibernate.** It is the one app on the estate where cold-wake latency is a suite-wide outage. Stated because hibernation is the platform default and would otherwise apply silently. - **A backup you have never restored is a hope, not a backup.** Losing this database loses credentials **and the roster**, neither of which rebuilds β call 2 cannot write membership and no channel carries an instance's copy back, so roster recovery is an operator reading instances out and re-seeding (spec Β§10). The restore drill runs before launch. --- ## Authorship π€ / π¨ markers, `Co-Authored-By: Claude β¦` trailers, published prompt and carbon ledgers. AI is a guest with a name tag: opt-in, disclosed, and every path works with it switched off. Diff: diff --git a/internal/home/app.go b/internal/home/app.go index e5562cb..ef62fdd 100644 --- a/internal/home/app.go +++ b/internal/home/app.go @@ -136,9 +136,17 @@ func (a *App) Router() http.Handler { // blind-SSRF surface by design β and csrf.Protect is pure origin // evidence (Sec-Fetch-Site, then Origin, then Referer) that never // consults a session, so it works before anybody is signed in. + r.Use(serveKnownHost) r.Use(csrf.Protect(Origin)) - r.Get("/", a.handleIndex) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + if r.Host == credentialHost { + w.Header().Set("Location", directoryOrigin+"/") + w.WriteHeader(http.StatusSeeOther) + return + } + a.handleIndex(w, r) + }) // This /healthz is NOT what answers in a deployed instance: // rastrillo.Serve registers its own GET /healthz on the outer mux // (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that @@ -150,48 +158,51 @@ func (a *App) Router() http.Handler { w.WriteHeader(http.StatusOK) }) - r.Get("/signin", a.handleSigninForm) - r.Post("/signin", a.handleSigninBegin) - r.Get("/signin/sent", a.handleSigninSent) - // GET renders, POST redeems. See handleVerifyForm for why. - r.Get(linkPath, a.handleVerifyForm) - r.Post(linkPath, a.handleVerify) - r.Get(callbackPath, a.handleCallback) - - // The second factor. The ceremony endpoints are JSON POSTs driven by - // static/passkey.js; the recovery POST is a plain form and works - // with script off, because recovery is exactly the moment WebAuthn - // is not working. - r.Get(confirmPath, a.handlePasskeyConfirm) - r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin) - r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish) - r.Post("/passkey/signin/begin", a.passkeys.SignInBegin) - r.Post("/passkey/signin/finish", a.passkeys.SignInFinish) - r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery) - // Enrolling changes who can get in, and there is no operator reset, - // so it wants a recent session rather than merely a valid one β see - // enrolFreshness for the lockout this closes. r.Group(func(r chi.Router) { - r.Use(a.sessions.RequireFresh(enrolFreshness)) - r.Get("/passkey/enrol", a.handlePasskeyEnrol) - r.Post("/passkey/register/begin", a.passkeys.RegisterBegin) - r.Post("/passkey/register/finish", a.passkeys.RegisterFinish) + r.Use(credentialOnly) + r.Get("/signin", a.handleSigninForm) + r.Post("/signin", a.handleSigninBegin) + r.Get("/signin/sent", a.handleSigninSent) + // GET renders, POST redeems. See handleVerifyForm for why. + r.Get(linkPath, a.handleVerifyForm) + r.Post(linkPath, a.handleVerify) + r.Get(callbackPath, a.handleCallback) + + // The second factor. The ceremony endpoints are JSON POSTs driven by + // static/passkey.js; the recovery POST is a plain form and works + // with script off, because recovery is exactly the moment WebAuthn + // is not working. + r.Get(confirmPath, a.handlePasskeyConfirm) + r.Post("/passkey/stepup/begin", a.passkeys.StepUpBegin) + r.Post("/passkey/stepup/finish", a.passkeys.StepUpFinish) + r.Post("/passkey/signin/begin", a.passkeys.SignInBegin) + r.Post("/passkey/signin/finish", a.passkeys.SignInFinish) + r.Post("/passkey/signin/recovery", a.passkeys.SignInRecovery) + // Enrolling changes who can get in, and there is no operator reset, + // so it wants a recent session rather than merely a valid one β see + // enrolFreshness for the lockout this closes. + r.Group(func(r chi.Router) { + r.Use(a.sessions.RequireFresh(enrolFreshness)) + r.Get("/passkey/enrol", a.handlePasskeyEnrol) + r.Post("/passkey/register/begin", a.passkeys.RegisterBegin) + r.Post("/passkey/register/finish", a.passkeys.RegisterFinish) + }) + + // The codes page shows ten codes once, so it wants a recent session + // rather than merely a valid one. + r.Group(func(r chi.Router) { + r.Use(a.sessions.RequireFresh(codesFreshness)) + r.Get("/passkey/codes", a.handleRecoveryCodesPage) + r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes) + }) + + // Stable, unfingerprinted paths: Task 7's scriptless allowlist has + // to name them, and a rule that cannot name its subject is not a + // rule. + r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8")) + r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8")) }) - // The codes page shows ten codes once, so it wants a recent session - // rather than merely a valid one. - r.Group(func(r chi.Router) { - r.Use(a.sessions.RequireFresh(codesFreshness)) - r.Get("/passkey/codes", a.handleRecoveryCodesPage) - r.Post("/passkey/codes", a.handleRegenerateRecoveryCodes) - }) - - // Stable, unfingerprinted paths: Task 7's scriptless allowlist has - // to name them, and a rule that cannot name its subject is not a - // rule. - r.Get("/static/webauthn.mjs", serveAsset(webauthnJS(), "text/javascript; charset=utf-8")) - r.Get("/static/passkey.js", serveAsset(staticAsset("passkey.js"), "text/javascript; charset=utf-8")) - // Vendored from rastrillo's ui package, not linked from it: an app // owns its stylesheet from the moment it lands, and // `rastrillo doctor --fix` is what reconciles it on an upgrade. diff --git a/internal/home/app_test.go b/internal/home/app_test.go index 76b5755..efd584e 100644 --- a/internal/home/app_test.go +++ b/internal/home/app_test.go @@ -12,6 +12,7 @@ import ( func TestIndex(t *testing.T) { app := newTestApp(t) req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = directoryHost rec := httptest.NewRecorder() app.Router().ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -30,6 +31,7 @@ func TestIndex(t *testing.T) { func TestHealthz(t *testing.T) { app := newTestApp(t) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + req.Host = directoryHost rec := httptest.NewRecorder() app.Router().ServeHTTP(rec, req) if rec.Code != http.StatusOK { diff --git a/internal/home/keymailstub_test.go b/internal/home/keymailstub_test.go index bc28754..1cde613 100644 --- a/internal/home/keymailstub_test.go +++ b/internal/home/keymailstub_test.go @@ -174,6 +174,7 @@ func callback(t *testing.T, app *testApp, code, state string, cookies ...*http.C t.Helper() req := httptest.NewRequest(http.MethodGet, callbackPath+"?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) + req.Host = credentialHost for _, c := range cookies { if c != nil { req.AddCookie(c) diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go index 73dbdea..ef84f57 100644 --- a/internal/home/logs_test.go +++ b/internal/home/logs_test.go @@ -122,6 +122,10 @@ func TestNoContentInLogs(t *testing.T) { }.Encode()) req := httptest.NewRequest(tg.method, tg.path+"?q="+url.QueryEscape(logtest.Canaries[0]), body) + req.Host = credentialHost + if req.URL.Path == "/" { + req.Host = directoryHost + } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // csrf.Protect is mounted router-wide and short-circuits a // state-changing request with no origin evidence β before chi @@ -172,6 +176,10 @@ func TestNoContentInLogs(t *testing.T) { } body := strings.NewReader(url.Values{"address": {logtest.Canaries[0]}}.Encode()) req := httptest.NewRequest(tg.method, tg.path, body) + req.Host = credentialHost + if req.URL.Path == "/" { + req.Host = directoryHost + } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Referer", "https://"+logtest.Canaries[1]+".example.invalid") req.Header.Set("User-Agent", logtest.Canaries[0]) diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go index d463d20..51e020d 100644 --- a/internal/home/noscript_test.go +++ b/internal/home/noscript_test.go @@ -300,6 +300,10 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) { for _, tg := range targets { p := tg.path req := httptest.NewRequest(tg.method, p, strings.NewReader("address=someone@example.invalid")) + req.Host = credentialHost + if req.URL.Path == "/" { + req.Host = directoryHost + } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The POSTs are behind csrf.Protect, which short-circuits before // the handler runs β and a handler that never runs sets no diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go index fde7d45..6c71d40 100644 --- a/internal/home/passkey_test.go +++ b/internal/home/passkey_test.go @@ -40,6 +40,7 @@ func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body payload = "{}" } req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)) + req.Host = credentialHost req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", Origin) if cookie != nil { @@ -190,6 +191,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) { form := url.Values{"code": {codes[0]}} req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) for _, c := range (&http.Response{Header: gated.Header()}).Cookies() { @@ -216,6 +218,7 @@ func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) { func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string { t.Helper() req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = credentialHost req.AddCookie(cookie) sess, ok := app.sessions.From(req) if !ok { @@ -230,6 +233,7 @@ func TestScriptAssetsAreServedOnNamedPaths(t *testing.T) { app := newTestApp(t) for _, path := range []string{"/static/webauthn.mjs", "/static/passkey.js"} { req := httptest.NewRequest(http.MethodGet, path, nil) + req.Host = credentialHost w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) if w.Code != http.StatusOK { @@ -253,6 +257,7 @@ func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) { get := func() int { req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) + req.Host = credentialHost req.AddCookie(cookie) w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) @@ -304,6 +309,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) { form := url.Values{"code": {codes[0]}} req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) for _, c := range (&http.Response{Header: gated.Header()}).Cookies() { @@ -319,6 +325,7 @@ func TestRecoveryCodeProducesAFreshSession(t *testing.T) { // The RequireFresh-guarded page opens to it. page := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) + page.Host = credentialHost page.AddCookie(recovered) pw := httptest.NewRecorder() app.Router().ServeHTTP(pw, page) @@ -361,6 +368,7 @@ func TestEnrolmentNeedsAFreshSession(t *testing.T) { // The page itself redirects to re-authenticate rather than 404ing or // 403ing: a detour, not a dead end. req := httptest.NewRequest(http.MethodGet, "/passkey/enrol", nil) + req.Host = credentialHost req.AddCookie(cookie) w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) @@ -402,6 +410,7 @@ func TestEnrolThenReadCodesWithoutReauthenticating(t *testing.T) { enrol(t, app, cookie) req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil) + req.Host = credentialHost req.AddCookie(cookie) w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) diff --git a/internal/home/signin.go b/internal/home/signin.go index 4931bcf..973f3c7 100644 --- a/internal/home/signin.go +++ b/internal/home/signin.go @@ -15,7 +15,7 @@ import ( // the session cookie and is the WebAuthn RP. Β§4 splits it from the // directory origin deliberately, and both the CSRF check and the cookie // attributes are keyed on this exact string. -const Origin = "https://id.oficina.works" +const Origin = "https://" + credentialHost // linkPath is where magic links land. It must match the route mounted // below; signin's own default is /auth/verify, which this app does not diff --git a/internal/home/signin_test.go b/internal/home/signin_test.go index 3a0d078..e21409c 100644 --- a/internal/home/signin_test.go +++ b/internal/home/signin_test.go @@ -27,6 +27,7 @@ func signIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder t.Helper() form := url.Values{"address": {addr}} req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) w := httptest.NewRecorder() @@ -65,6 +66,7 @@ func redeem(t *testing.T, app *testApp, token string) *httptest.ResponseRecorder t.Helper() form := url.Values{"token": {token}} req := httptest.NewRequest(http.MethodPost, linkPath, strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) w := httptest.NewRecorder() @@ -164,6 +166,7 @@ func TestGetDoesNotRedeemTheLink(t *testing.T) { token := capturedLink(t, app) req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil) + req.Host = credentialHost w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) @@ -197,6 +200,7 @@ func TestVerifyPageDoesNotDistinguishTokens(t *testing.T) { get := func(token string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(token), nil) + req.Host = credentialHost w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) return w @@ -331,6 +335,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) { for _, path := range []string{"/signin", linkPath} { form := url.Values{"address": {"person@example.invalid"}} req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) @@ -350,6 +355,7 @@ func TestSigninPostRequiresOriginEvidence(t *testing.T) { func TestClientIPNeverCollapsesToOneBucket(t *testing.T) { req := func(remote, xff string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/signin", nil) + r.Host = credentialHost r.RemoteAddr = remote if xff != "" { r.Header.Set("X-Forwarded-For", xff) @@ -396,6 +402,7 @@ func TestCallbackWithNoPendingCookieMintsNothing(t *testing.T) { "", } { req := httptest.NewRequest(http.MethodGet, callbackPath+q, nil) + req.Host = credentialHost w := httptest.NewRecorder() app.Router().ServeHTTP(w, req) @@ -431,6 +438,7 @@ func TestForgedPendingBlobIsRefused(t *testing.T) { app := newTestApp(t) req := httptest.NewRequest(http.MethodGet, callbackPath+"?code=x&state=s", nil) + req.Host = credentialHost req.AddCookie(&http.Cookie{ Name: pendingCookie, // Well-formed shape β payload.mac β with a MAC that is not one. @@ -461,6 +469,7 @@ func TestSigninIsRateLimitedPerIPNotGlobally(t *testing.T) { post := func(ip, addr string) int { form := url.Values{"address": {addr}} req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) req.Header.Set("X-Forwarded-For", ip) @@ -499,6 +508,7 @@ func TestSigninIsRateLimitedPerAddress(t *testing.T) { for i := range 12 { form := url.Values{"address": {"target@example.invalid"}} req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) + req.Host = credentialHost req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", Origin) req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1)) @@ -527,6 +537,7 @@ func TestVerifyPageWritesNothing(t *testing.T) { before := dumpDatabase(t, app) for _, tok := range []string{token, "not-a-real-token", ""} { req := httptest.NewRequest(http.MethodGet, linkPath+"?token="+url.QueryEscape(tok), nil) + req.Host = credentialHost app.Router().ServeHTTP(httptest.NewRecorder(), req) } if after := dumpDatabase(t, app); after != before { FILE internal/home/origins.go package home import "net/http" const ( suiteDomain = "oficina.works" credentialHost = "id." + suiteDomain directoryHost = suiteDomain directoryOrigin = "https://" + directoryHost ) func serveKnownHost(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Host != credentialHost && r.Host != directoryHost { w.WriteHeader(http.StatusNotFound) return } next.ServeHTTP(w, r) }) } func credentialOnly(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Host != credentialHost { w.WriteHeader(http.StatusNotFound) return } next.ServeHTTP(w, r) }) } FILE internal/home/origins_test.go package home import ( "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" ) func TestEveryCredentialRouteRefusesDirectoryHost(t *testing.T) { app := newTestApp(t) router := app.Router() routes := router.(chi.Routes) count := 0 err := chi.Walk(routes, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { switch route { case "/", "/healthz", "/static/tokens.css", "/static/theme.css": return nil } count++ req := httptest.NewRequest(method, "https://"+directoryHost+route, nil) req.Header.Set("Origin", Origin) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { t.Errorf("%s %s on directory: status=%d cookies=%d body=%q", method, route, rec.Code, len(rec.Result().Cookies()), rec.Body.String()) } return nil }) if err != nil || count < 19 { t.Fatalf("route walk err=%v credential routes=%d", err, count) } if _, ok := app.mailer.last(); ok { t.Fatal("directory request sent mail") } } func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) { app := newTestApp(t) req := httptest.NewRequest(http.MethodGet, Origin+"/?team=untrusted", nil) rec := httptest.NewRecorder() app.Router().ServeHTTP(rec, req) if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != directoryOrigin+"/" || rec.Body.Len() != 0 { t.Fatalf("credential root: status=%d location=%q body=%q", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } } func TestUnknownHostsCannotReachRoutes(t *testing.T) { app := newTestApp(t) router := app.Router() hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "ID.OFICINA.WORKS", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"} err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { for _, host := range hosts { req := httptest.NewRequest(method, route, nil) req.Host = host req.Header.Set("Origin", Origin) req.Header.Set("X-Forwarded-Host", credentialHost) req.Header.Set("Forwarded", "host="+credentialHost) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound || len(rec.Result().Cookies()) != 0 || rec.Body.Len() != 0 { t.Errorf("%s %s host=%q status=%d", method, route, host, rec.Code) } } return nil }) if err != nil { t.Fatal(err) } } func TestDirectoryIsNotWithinCredentialRP(t *testing.T) { if credentialHost == directoryHost || rpID() == directoryHost || Origin == directoryOrigin { t.Fatal("directory overlaps the credential origin or relying party") } } FILE internal/home/page.go package home import "net/http" // indexPage is the entire placeholder page: bare fact, not marketing. // It has no dynamic content and no user input to escape, so a plain // constant string stands in for html/template here rather than // pretending to need one. // // No stylesheet is linked. rastrillo's ui/tokens.css needs a theme // (day, plain or signal β see ui/themes/*.css) to paint anything; // picking one is a design decision, and design-system questions are // paused on this project (AGENTS.md's Frontend section assumes a // themed app, which this skeleton deliberately is not yet). Semantic, // unstyled HTML is the honest choice until that pause lifts. const indexPage = `<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title>Oficina home</title> </head> <body> <main> <h1>Oficina home</h1> <p>This is the Oficina suite home: identity, a directory of your teams, and provisioning, shared by every app in the suite.</p> <p>It is not built yet. This page is a placeholder that proves the deploy path works. There is no sign-in, no directory, and no provisioning here.</p> <p>The design is at <code>docs/superpowers/specs/2026-08-30-home-design.md</code> in this repository.</p> </main> </body> </html> ` // handleIndex serves the one page this skeleton has. func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(indexPage)) }
User authorized an overnight implementation of the suite readiness plan, independent Claude Fable reviews, Amadan coordination and continuous reviewed deployments. Parent delegated Home assertion prerequisites, limited here to host routing and origin separation.
Independently review a bounded follow-on design to Home origin split. Read-only. Previous Fable review approved origin split plan with required real canary evidence. Production routing fixed hosts means cannot review via isolated canary hostname. Parent specifically asks configurable named canary origins without trusting forwarded headers. Proposed: immutable Origins config containing validated credential and directory HTTPS origins. NewWithOrigins constructor validates config; existing New defaults to production id.oficina.works and oficina.works. cmd loads HOME_CREDENTIAL_ORIGIN/HOME_DIRECTORY_ORIGIN together or neither. Strict URL must be exactly https://DNS-host, no port, path, query, fragment, userinfo, IP literal; canonical lowercase no trailing dot. Hosts must differ and directory may not equal or descend from credential host, preventing credential RP accepting directory. Production pair remains default; custom pair must not mix production hosts with custom ones. Routing exact Host only, no forwarded-header inference. Config propagates to sessions.New Origin, signin.Flow Origin, Keymail callback Origin, passkey.Config Origin and template RPID, csrf.Protect per credential group, canonical redirect destination. Existing production RPID/cookie tests retained; custom origin integration tests exercise sign-in links, Host admission, CSRF and passkey RPID. Canary infrastructure uses separate database and separately generated config keys, never a second RP over production auth state. No env values or keys in logs. No new UI strings; existing internal error strings reused if necessary; production defaults unchanged. Review security and feasibility; identify blocking gaps, give APPROVE/REQUEST CHANGES with concise specific conditions. State exact model.
Independently review the WHOLE Home origin separation branch as Claude Fable. Read only; no edits, agents, commits or deployments. Worktree /home/paulca/amadan.net/oficina/home/.claude/worktrees/overnight-origin-split exact tip651e5b3, parent bb95399. Complete diff /tmp/oficina-home-review/final-branch.diff. Read AGENTS.md and relevant origin/app/signin/passkey/config tests. Stay within this bounded slice; do not explore unrelated histories. Prior review /tmp/oficina-home-review/review.json requested shared CSS/health positive tests and moving CSRF into credential group; both fixed in5109a48. Original Home owner independently ran full gate/race on5109a48 and checked CSRF safety, recommending merge (Home9). Prior configurable-origin DESIGN review /tmp/oficina-home-review/config-plan.json approved pair with strict isolation conditions. Final651e5b3 adds paired env HOME_CREDENTIAL_ORIGIN + HOME_DIRECTORY_ORIGIN; absent both uses existing canonical production origins. Both required if either configured. Strict lowercase HTTPS DNS origin without port/path/query/user/etc; different hosts; directory not within credential RP; custom pair cannot include production directory or credential host or descend from production credential RP. Request Host comparison canonicalizes case only, ignores forwarded headers. Session, CSRF, Flow, Keymail, passkey Config and template RP ID use validated credential origin. Fixed empty303 credential-root redirect to configured directory. No assertion minting, changes to secrets, credential rotation, new user-facing copy or production deployment. New tests cover validation attacks, env defaults/partial settings, actual custom routing and canonical-host refusal, shared assets, custom RP and Keymail wiring, CSRF rejection of production origin on custom host, custom magic-link redemption and secure host-only cookies. Restoring Flow Origin to production made the new integration test fail for the intended mismatch. Full make test and race passed: /tmp/oficina-home-review/config-gate.log, config-race.log. Real CI must be green independently before any merge. Remaining platform condition: SMTP is pending, so no production restart/deploy authorized here. Original prior review required deployed host-preservation/canary proof before merge; paired configuration now makes a truly isolated canary possible, but it has not yet been deployed. State clearly whether code is APPROVED with deployment validation remaining or merge must wait for canary. Do not claim this evidence exists. Review correctness/security, trust boundaries, validation omissions, hidden default-origin seams and backward compatibility. Return concise APPROVE / CHANGES REQUIRED with concrete blocking findings and any exact canary preconditions.