The shell now renders the CORE nav entries — Home · Account for everyone,
Users · Modules only for an admin — alongside the registry entries, with the
discovered `home` dashboard collapsed onto the core Home entry (SPEC-010 §3).
A signed-in user with no passkey, who has not opted out and is not on a
loopback origin, now sees a prominent "Add a passkey" banner linking to the
Account page (SPEC-010 §6). This closes the D-041 gap where the wizard's
direct session mint bypasses the TOTP-login nudge, so the admin was never
prompted.
The door computes (admin, banner) in serveGUID — it already holds the
session, identity and user — and attaches it to the request context; the new
web.PageFor copies it onto every authenticated page (RequestContext is
untouched, so identification stays separate from authorisation, D-003).
The first-run wizard's `admin.totp.begin` delegated to the enroll flow's
`TOTPBegin`, which is `webauthn.js`'s JSON API: the browser was shown raw
`{"otpauth_url":…}` and the wizard stalled at step 2, rendering no secret.
Extract the issue step into a write-free seam, `flow.Actions.IssueTOTP`, and
keep `TOTPBegin`'s JSON contract by having it call the seam. The wizard now
issues via the seam and renders its own HTML page (`text/html`, inside the
shell, no inline scripts) showing the otpauth URL, the human-readable secret
for manual entry, and the verify form (pre-session CSRF, A15).
`setup.Enroller` drops `TOTPBegin` — the wizard no longer calls it, and a
JSON-writing method on a no-JS seam is the coupling that caused the bug.
Tighten the tests that missed it: `setup_wizard_test.go` asserts the `begin`
response is `text/html` and carries the secret and the verify form (not merely
200), keeping the end-to-end begin→code→303 path; `enroll_login_test.go` pins
the enroll flow's JSON contract (`application/json` + `otpauth_url`).
Spec: docs/specs/spec-005-setup-wizard.md §11.
Implements D-025 (revised 2026-10-15) and D-043.
Setup gate (D-025):
- Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback);
Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy.
- The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback
TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404
before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is
read before ClientIP SETS it (A6).
Proxy scheme (D-043):
- httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted
proxy's X-Forwarded-Proto. The door carries it on the request context
(httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session
and ceremony cookies, and HSTS all agree (AC-13).
- Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the
bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed
as now dead.
Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test
SetupRequestAllowed / RequestIsHTTPS.
REVIEW-001 (M0) L7c: shouldNudge trusted the request Host, so a reverse
proxy that rewrites Host wrongly suppressed (or enabled) the nudge.
Prefer the configured server.base_url host (Deps.BaseURL) and fall back to
the request Host only when it is unset. Wire the secure session-cookie name
through SessionCookieNameFor so the `__Host-` prefix (L1s) is used
consistently by the flows.
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
double-submit CSRF — A15), rendered inside the shell