fix: the recovery page's globe must write the HOUSEHOLD's setting, not a cookie nothing reads
gates / gates (push) Successful in 24s

/recovery is in the AUTHENTICATED route table — its reader is the household, not a visitor. The
first draft of v0.254.0 gave it the anonymous form, which sets the felhom_lang cookie that
langFor deliberately ignores once there is a session: the button would have appeared to work
and done nothing. Found by the live probe on demo-hp reporting no globe on /recovery (it 302s
to /login without a session) and then reading the route table.

executeTemplateLang now branches on hasSession: household form with its session CSRF, or the
visitor form without. The parity harness and TestI18nDirectRenderPagesFollowLanguage carry the
same branch, so the fixture is the form the real page serves — the trap this release already
walked into once with the shells.

A per-session CSRF token cannot be a fixture value, so it is blanked on both sides of every
parity comparison, exactly as relative ages already were. What stays pinned is that the field is
THERE and WHICH form it sits in — the half that says whether the globe writes the household's
setting or the visitor's cookie.

Evidence regenerated: 3 change shapes across 106 fixtures, 5 byte-identical (both guest share
pages and the catch-all — the three that must not change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 14:28:24 +02:00
parent 48f3336956
commit 0b1486df8f
104 changed files with 257 additions and 211 deletions
+19 -6
View File
@@ -915,17 +915,30 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string,
lang := s.langFor(r)
if data != nil {
data["Lang"] = lang
// v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household
// switch, and carries NO CSRF field — there is no session to mint one from, and the handler
// writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning).
// v0.254.0: the globe, on a page rendered outside the dashboard chrome. WHICH FORM it posts to
// depends on WHO is reading, and getting that wrong makes the globe do nothing:
//
// `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the
// handler is what makes that safe to take from an anonymous form.
// * NO SESSION — the sign-in and claim pages. The reader is a visitor: they have no setting
// and may not write the household's, so the globe posts to /lang and their choice lives in
// their own browser. No CSRF field: there is no session to mint one from.
// * WITH A SESSION — /recovery is in the AUTHENTICATED route table; its reader IS the
// household. Their globe must write their SETTING, because langFor deliberately ignores the
// cookie once there is a session — so an anonymous form here would set a cookie nothing
// reads and the button would appear to do nothing. Measured live on demo-hp before this
// branch existed.
//
// Only the language form's own fields are added, never the dashboard chrome R-543 keeps off
// these pages (TestI18nDirectRenderPagesHaveNoAdminChrome still passes: it names CSRFField,
// CSRFToken and the escrow banner, none of which appear here).
back := "/"
if r != nil && r.URL != nil && r.URL.Path != "" {
back = r.URL.Path
}
addLangOptions(data, lang, langCookiePath, back, "")
if s.hasSession(r) {
addLangOptions(data, lang, "/settings/language", back, s.csrfField(r))
} else {
addLangOptions(data, lang, langCookiePath, back, "")
}
}
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
}