~bigbes/lethe

ref: 96e95ab9e44d2234ab036319836a5087eb4c2a2f lethe/docs/tasks/lethe-web-ui-login.md -rw-r--r-- 50.2 KiB
96e95ab9 — Eugene Blikh fix: add tool column to search table; remove conversation bleed from comments 23 days ago

#lethe-web-ui-login

Status: Design (hands-off) Module: sourcecraft.dev/bigbes/lethe Branch: master Worktree: none Parent RFC: Personal AI Assistant Log Aggregator (2026-04-25) Sibling tasks:

  • lethe-server.md (#1, ✓ Verified) — owner of internal/server/auth/ (forward-auth, OIDCVerifier, OIDCDevStub, middleware)
  • lethe-web-ui-foundation.md (#4, ✓ Reviewed) — established the SPA shell, AuthError class, "not authenticated" placeholder card; this task replaces the dead-end placeholder with a real flow
  • lethe-oidc-stub.md (#10, ✓ Reviewed) — provides the in-process dev OP that the SPA flow targets in dev/test
  • lethe-web-ui-aggregates.md (#5, ✓ Reviewed) — has a "browser smoke deferred" note this task helps unblock once login works
  • lethe-web-ui-palette-savedsearch.md (#6, ✓ done) — same browser-smoke gap; same unblock

#Design

#Purpose

Replace the SPA's three dead-end "not authenticated" cards (routes/index.tsx:90, routes/projects.tsx:119, features/settings/SavedSearchesSection.tsx:179) with a working OIDC auth-code+PKCE flow that runs identically against the dev stub and a real OP. Closes the lineage's recurring "browser smoke deferred" gap from #5 and #6 by giving verify a clean end-to-end smoke gate.

In scope: dev-stub upgrade to a real auth-code OP (/authorize, /token, updated discovery doc); SPA login flow (/login, /auth/callback, web/src/lib/auth.ts, in-memory token store); apiFetch token attachment; shared AuthGate component replacing the three placeholder cards; anti-loop error state with bounded retries.

Out of scope: BFF / cookie-session / silent refresh (deferred — additive if reload churn becomes a problem); logout endpoint (clear in-memory token == reload, until silent refresh ships); real-OP integration testing (dev stub is the smoke gate, real OP testing is a manual user step); multi-tenant isolation hardening (single user).

#Chosen approach

Dev-stub upgrade (Path A) — extend internal/testutil/oidcstub/ to a full auth-code+PKCE OP so dev and prod run the same SPA code path. Single code path beats divergent ones; the stub work is small (~150–250 LoC) and self-contained.

Token model — SPA-direct (Bearer) — backend already accepts Authorization: Bearer (internal/server/auth/middleware.go:155-165). Zero new backend endpoints. SPA is same-origin (embedded by internal/server/web/embed.go) and personal-scope (single user) — XSS blast radius is bounded.

Token storage — in-memory React context — no localStorage, no cookies. Page reload = re-login. Acceptable for a daemon-style personal app where sessions are hours-long.

Refresh — re-login on expiry — token TTL = session lifetime. Stub issues 8h tokens; real OPs typically 1–8h. No silent-refresh complexity in v1.

Trigger — manual click first; auto-redirect on mid-session 401 — first cold render shows a "Sign in with OIDC" button (anti-popup-blocker, anti-loop). Mid-session 401 auto-redirects since the user already opted in.

Anti-loop guard — PKCE state + return_to in localStorage with 5-minute TTL. On callback failure (state mismatch, /token error, OP 5xx), render distinct "auth error" card with manual retry. After 3 callback failures in 5 minutes: "please reload" — no further auto-redirects.

#Routes added

SPA (web/src/routes/):

  • login.tsx — generates code_verifier (43–128 chars, base64url-random), code_challenge (S256), state (random); persists to localStorage with TTL; redirects to <issuer>/authorize?response_type=code&client_id=…&redirect_uri=…&state=…&code_challenge=…&code_challenge_method=S256.
  • auth.callback.tsx — reads ?code=&state=; validates state matches localStorage; POSTs <issuer>/token with code_verifier; stores access token in memory; navigates to return_to (defaulting to /).

Dev stub (internal/testutil/oidcstub/):

  • GET /authorize — auto-consent: validates required params, generates opaque code (32-byte base64url), stores (code → {sub, code_challenge, redirect_uri}, 5-min TTL), 302s to redirect_uri?code=<code>&state=<echoed>.
  • POST /token — validates grant_type=authorization_code; looks up code; verifies SHA256(code_verifier) == base64url-decoded(code_challenge) (RFC 7636 S256); mints + returns { access_token, id_token, token_type:"Bearer", expires_in:28800 }. Single-use code (deleted after exchange).
  • Discovery-doc updates: add "code" to response_types_supported; add grant_types_supported: ["authorization_code"]; add code_challenge_methods_supported: ["S256"].

#Backend changes

None to internal/server/auth/middleware.go (already handles Bearer), internal/server/auth/oidc.go (verifier already validates issuer-signed JWTs regardless of how obtained), or any handler under /api/v1/*.

Dev-stub package onlyinternal/testutil/oidcstub/ gets the two new handlers plus discovery-doc field updates. The steward wrapper (internal/server/auth/devstub.go) is unchanged structurally; it just exposes the upgraded Stub.Handler().

#Backwards-compat check

  • Dev stub: /dev/token endpoint kept (other tests in the suite use it). New /authorize and /token are additive. Discovery doc gains fields but loses none.
  • SPA: /login and /auth/callback are new routes (additive); the three existing "not authenticated" cards collapse into shared AuthGate (UX change, not API change). No deployed user is affected.
  • Backend: zero changes to /api/v1/*. Existing forward-auth path (Remote-User injection via vite.config.ts) keeps working in dev-without-OIDC; the OIDC bearer path now actually receives tokens from the SPA when OIDC is enabled.

Greenfield modulo the dev-stub additions.

TDD: yes (scoped — dev-stub /authorize + /token handlers; PKCE state machine in web/src/lib/auth.ts; apiFetch token-attachment behavior. No for AuthGate/route chrome — visual feel, validated by manual smoke walk against the upgraded dev stub.)

#Invariants

  • IV1 — Dev-stub /token rejects any code-verifier that does not S256-hash to the recorded code-challenge; no silent fallback to "accept any verifier".
  • IV2 — Dev-stub /authorize issues single-use codes; the code is deleted on first /token exchange and a second use returns invalid_grant.
  • IV3 — Codes and PKCE localStorage state both have a 5-minute TTL; expired entries are rejected with invalid_grant on the server side and "auth error — please retry" on the SPA side.
  • IV4 — SPA never persists access_token or id_token to localStorage or cookies; only an in-memory React context store.
  • IV5 — apiFetch (and apiFetchVoid) attach Authorization: Bearer <token> when the in-memory store has a token; never attach Bearer "" or Bearer undefined.
  • IV6 — On any callback failure (state mismatch, /token error, OP 5xx), the SPA renders the distinct "auth error" state and never auto-retries the redirect from that card.
  • IV7 — AuthGate renders a manual-click button on first cold render; only mid-session 401 (after at least one successful sign-in this page-load) triggers auto-redirect.
  • IV8 — The dev stub keeps the existing /dev/token endpoint unchanged so the existing handler/repository test suites and cmd/lethe e2e test (TestEndToEnd_MultiUserIsolation) continue to pass without modification.

#Principles

  • PC1 — The dev stub is upgraded to be RFC-compliant for the auth-code+PKCE subset (RFC 6749 §4.1, RFC 7636 S256), not "good enough for our SPA". Why: real-OP smoke testing only stays in scope if the stub matches the spec. How to apply: when implementing /authorize and /token, follow the RFC literally for response codes, error bodies, and required parameters — don't take shortcuts that work against our own SPA but would diverge from a real OP.
  • PC2 — Token-in-memory is the security default; localStorage/cookie persistence is opt-in via a future task. Why: smallest XSS blast radius today. How to apply: any temptation to "just persist the token so reload works" goes to a follow-up task with a proper design pass on cookie/refresh strategy.

#Assumptions

  • AS1 — Browser crypto.subtle.digest('SHA-256', …) is available in every target browser (modern Chrome/Firefox/Safari) — required for PKCE S256 in the SPA.
  • AS2 — Lethe's bind is loopback (validated by validateLoopbackBind in internal/server/server.go:159) and the dev stub binds to 127.0.0.1:<port>, so OIDC redirect URIs of the form http://127.0.0.1:<port>/auth/callback are acceptable to both the dev stub and a real OP configured for localhost.
  • AS3 — The same SPA code that talks to the upgraded dev stub will, unchanged, talk to a real OP (Authelia, Keycloak, etc.) configured for the same client_id, redirect_uri, and S256-PKCE. Any divergence is a stub bug, not a SPA bug.

#Unknowns

  • UK1 — Real-OP smoke (Authelia, Keycloak, or other) — out of scope per "Out of scope". The dev-stub flow should be RFC-compatible enough that the same SPA code talks to a real OP unchanged (AS3); confirmation deferred to the user's real-OP rollout.
  • UK2 — Whether id_token should be parsed in the SPA for the user's name/email to display in the TopBar. Initial answer: yes, parse and store the name claim only (no signature-chain re-validation in the SPA — server already validated). Keep the scope tight: the TopBar greeting only.

#Plan

Approach: four phases — PH1 lands all backend work (dev-stub /authorize+/token + embed.go config injection) Go-only; PH2 ships the pure-TS auth library (auth.ts + apiFetch token attachment); PH3 wires routes (/login, /auth/callback) + auth context using PH1's injected config and PH2's token store; PH4 collapses the three placeholder cards into shared AuthGate. PH1 ⊥ PH2 (different stacks, disjoint paths); PH3 fans out from both; PH4 fans out from PH3.

#PH1 — Backend: dev-stub auth-code+PKCE upgrade + SPA config injection

  • 1.1 internal/testutil/oidcstub/codestore.go (create) — small in-memory code store, package oidcstub.

    • type codeEntry struct { Sub string; CodeChallenge string; RedirectURI string; ExpiresAt time.Time }
    • type codeStore struct { mu sync.Mutex; entries map[string]codeEntry; now func() time.Time }now injected for deterministic tests.
    • func newCodeStore(now func() time.Time) *codeStorenow == nil defaults to time.Now.
    • func (s *codeStore) Issue(sub, challenge, redirect string, ttl time.Duration) string — returns opaque base64url 32-byte code.
    • func (s *codeStore) Consume(code string) (codeEntry, bool) — returns entry and deletes it on first call (IV2). Returns false on miss or expiry (IV3).
    • Respects: IV2, IV3.
  • 1.2 internal/testutil/oidcstub/oidcstub.go:106-112 (modify) — extend Stub.Handler() to register /authorize and /token. Add a *codeStore field on Stub initialized in New with now: time.Now.

    mux.HandleFunc("/authorize", s.handleAuthorize)
    mux.HandleFunc("/token", s.handleToken)
    
  • 1.3 internal/testutil/oidcstub/oidcstub.go:114-126 (modify, handleDiscovery) — additive discovery-doc fields (Design):

    • response_types_supported: ["id_token", "code"] (was ["id_token"])
    • new key grant_types_supported: ["authorization_code"]
    • new key code_challenge_methods_supported: ["S256"]
    • new key authorization_endpoint: already present (line 119) — value stays s.issuer + "/auth". Change to s.issuer + "/authorize" to match the new handler path. (Pre-existing value /auth was a placeholder; no consumer.)
    • new key token_endpoint: already present (line 120). No change.
  • 1.4 internal/testutil/oidcstub/oidcstub.go (modify, append) — handleAuthorize:

    • func (s *Stub) handleAuthorize(w http.ResponseWriter, r *http.Request) — GET only.
    • Reads required query params: response_type=code, client_id, redirect_uri, state, code_challenge, code_challenge_method=S256. Optional: scope.
    • On any missing/invalid required param → 400 with body {"error":"invalid_request","error_description":"…"} (RFC 6749 §4.1.2.1, PC1).
    • On valid request: derives sub from s.devStubUser (new field on Stub, source: Cfg.OIDC.DevStub.User threaded through Options.DevStubUser; defaults to "bigbes" when zero — matches existing convention).
    • Calls s.codes.Issue(sub, code_challenge, redirect_uri, 5*time.Minute).
    • 302 redirect to redirect_uri + "?code=" + code + "&state=" + state (state echoed verbatim per RFC 6749 §4.1.2).
    • Respects: IV2, IV3, PC1.
  • 1.5 internal/testutil/oidcstub/oidcstub.go (modify, append) — handleToken:

    • func (s *Stub) handleToken(w http.ResponseWriter, r *http.Request) — POST only; Content-Type: application/x-www-form-urlencoded per RFC 6749.
    • CORS preflight: on OPTIONS, return 204 with Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type.
    • On POST: parses form, validates grant_type=authorization_code; reads code, code_verifier, redirect_uri, client_id.
    • Calls s.codes.Consume(code). On miss/expired → 400 {"error":"invalid_grant","error_description":"unknown or expired code"} (IV2, IV3, PC1).
    • On hit: verify redirect_uri matches the entry's stored redirect_uri (RFC 6749 §4.1.3). If not → 400 invalid_grant.
    • Verify base64url(SHA-256(code_verifier)) == entry.CodeChallenge (RFC 7636 §4.6, IV1). If not → 400 invalid_grant.
    • Mints access_token + id_token via existing s.Mint(entry.Sub, s.defaultTTL, nil). (Same JWT for both — minimal stub; real OPs differ.)
    • Returns 200 JSON {"access_token":"<jwt>","id_token":"<jwt>","token_type":"Bearer","expires_in":<ttl-seconds>} with Access-Control-Allow-Origin: *.
    • Respects: IV1, IV2, IV3, PC1.
  • 1.6 internal/testutil/oidcstub/oidcstub.go:34-44 (modify, Options) — add field:

    • DevStubUser string // sub to issue codes for; defaults to "bigbes" when zero
    • Threaded into Stub in New.
  • 1.7 internal/server/auth/devstub.go:55-65 (modify) — pass Cfg.OIDC.DevStub.User (existing config field; if absent, leave as today's behavior — the stub falls back to "bigbes"). Single-line change to the oidcstub.Options{} literal.

  • 1.8 internal/testutil/oidcstub/codestore_test.go (create) — TDD targets:

    • Issue returns distinct codes across calls; codes are URL-safe (no /, +, =); 32 bytes worth of entropy.
    • Consume returns the issued entry on first call, false on second (IV2).
    • Consume returns false on expired entry (advance injected now past TTL, IV3).
    • Consume returns false for unknown code.
    • Concurrent Issue/Consume is race-free (run with -race).
  • 1.9 internal/testutil/oidcstub/oidcstub_test.go (modify, append) — TDD targets:

    • TestAuthorize_RedirectsWithCodeAndState: GET /authorize?response_type=code&client_id=lethe&redirect_uri=http://x/cb&state=abc123&code_challenge=…&code_challenge_method=S256 → 302 with Location: http://x/cb?code=<32+ chars>&state=abc123.
    • TestAuthorize_MissingRequiredParam_Returns400: 4 sub-cases (response_type, client_id, redirect_uri, code_challenge) each missing → 400 invalid_request.
    • TestAuthorize_NonS256Challenge_Returns400: code_challenge_method=plain → 400.
    • TestToken_ValidExchange_ReturnsJWT: full round-trip — Issue → /token POST with correct verifier → 200, JWT verifiable via existing SignToken machinery; expires_in matches stub's defaultTTL.
    • TestToken_BadVerifier_Returns400 (IV1): /token with verifier whose SHA-256 doesn't match → 400 invalid_grant.
    • TestToken_CodeReuse_Returns400 (IV2): same code consumed twice → first 200, second 400.
    • TestToken_ExpiredCode_Returns400 (IV3): inject now advanced past TTL → 400.
    • TestToken_RedirectURIMismatch_Returns400: stored http://x/cb, sent http://y/cb → 400.
    • TestToken_OPTIONSPreflight_Returns204_CORS: OPTIONS → 204 with Access-Control-Allow-Origin: *.
    • TestDiscovery_AdvertisesCodeFlow: discovery JSON contains "code" in response_types_supported, ["authorization_code"] in grant_types_supported, ["S256"] in code_challenge_methods_supported.
    • TestDevToken_StillWorks (IV8): re-assert /dev/token?sub=alice returns a JWT — sanity-check the existing path is untouched.
  • 1.10 internal/server/web/embed.go (modify) — extend the SPA-fallback handler to inject window.__LETHE_CONFIG__ into index.html before the closing </head> tag. Two changes:

    • New exported function Handler(cfg Config) http.Handler where Config struct { Issuer string; ClientID string }. Old signature Handler() http.Handler removed (single call site at internal/server/server.go:114, refactored in 1.11).
    • On serving any path that resolves to index.html (root + SPA fallback paths), the handler reads the embedded index.html, performs ONE bytes.Replace of </head> with <script>window.__LETHE_CONFIG__=…;</script></head> where is json.Marshal(cfg). Static asset paths (/assets/*) bypass injection.
    • Respects: IV4 (no SPA-side persistence; this is server-side), AS3.
    • Reason for bytes.Replace over template rendering: embedded index.html has no template directives today; introducing text/template for one substitution adds a dependency for negligible benefit. Single-replace is GPC2-aligned (one concern: thread server config through to SPA).
  • 1.11 internal/server/server.go:51-67 (modify) — Server struct gains nothing; the new webpkg.Config is constructed inline at Init (line 114) from s.Cfg.Auth.OIDC.Issuer and s.Cfg.Auth.OIDC.Audience (audience plays the client_id role in the existing config — see internal/config/config.go:72). Single line change at server.go:114:

    • Was: r.Get("/*", webpkg.Handler().ServeHTTP)
    • Now: r.Get("/*", webpkg.Handler(webpkg.Config{Issuer: s.Cfg.Auth.OIDC.Issuer, ClientID: s.Cfg.Auth.OIDC.Audience}).ServeHTTP)
  • 1.12 internal/server/web/embed_test.go (modify if exists, else create) — TDD:

    • TestHandler_InjectsConfigIntoIndex: GET / → 200, body contains window.__LETHE_CONFIG__={"Issuer":"http://stub","ClientID":"lethe"} exactly once.
    • TestHandler_AssetsBypassInjection: GET /assets/index-XYZ.js → bundle bytes unchanged (no script tag injection on JS/CSS files).
    • TestHandler_SPAFallbackInjects: GET /some/spa/route → 200 HTML with the script tag (because SPA fallback returns index.html).
  • Commit (suggest two for clean log):

    • oidcstub: implement /authorize and /token for auth-code+PKCE flow
    • server/web: inject window.__LETHE_CONFIG__ into served index.html
    • Implementer may pick one combined commit if cleaner; not load-bearing.

#PH2 — SPA: PKCE library + Authorization-header attachment

  • 2.1 web/src/lib/auth.ts (create) — pure-function PKCE machinery + token store.

    • export async function generatePKCEPair(): Promise<{ verifier: string; challenge: string }> — uses crypto.getRandomValues (43-byte buffer → 64 base64url chars verifier) and crypto.subtle.digest('SHA-256', …) for challenge (AS1).
    • export function generateState(): string — 16 random bytes, base64url.
    • export function parseCallbackParams(search: string): { code: string; state: string } | { error: string; errorDescription?: string } — discriminated union; returns { error: 'missing_params' } when neither code nor error is present, mirrors the OP's ?error=…&error_description=… shape otherwise.
    • export interface TokenStore { get(): string | null; set(token: string | null): void; subscribe(listener: (token: string | null) => void): () => void } — single instance per page-load.
    • export const tokenStore: TokenStore = createTokenStore() — module-level singleton, no React deps. Listeners list lives in a closure inside createTokenStore.
    • Respects: IV4 (no localStorage / cookie writes anywhere in this file), IV5 (caller decides whether to attach), AS1.
    • Notes: crypto.subtle returns Promise<ArrayBuffer>; helper b64url(buf: ArrayBuffer | Uint8Array): string performs RFC 4648 §5 base64url-no-pad encoding.
  • 2.2 web/src/lib/auth.test.ts (create) — TDD targets:

    • generatePKCEPair: challenge is b64url(SHA-256(verifier)) (compute manually with Node's crypto to verify); verifier length 43–128 chars (RFC 7636 §4.1); challenge has no padding.
    • generateState: returns non-empty, two consecutive calls differ, length is fixed.
    • parseCallbackParams: ?code=abc&state=xyz{code:'abc', state:'xyz'}; ?error=access_denied&error_description=…{error:'access_denied', errorDescription:'…'}; empty → {error:'missing_params'}.
    • tokenStore: get() initially null; set('x')get() === 'x'; set(null)get() === null; subscribe receives 'x' then null in order; unsubscribe stops further notifications.
  • 2.3 web/src/api/client.ts:19-45 (modify, apiFetch) — read token from tokenStore.get() and attach Authorization: Bearer <token> header when non-null. When null, the header is omitted (do not send Bearer null or Bearer "" — IV5).

    • Same change at apiFetch:52-76 (apiFetchVoid).
    • Pattern: build a headers object by merging Accept, conditional Authorization, and init?.headers (caller wins on conflict — preserves existing override behavior).
    • 401 response triggers existing AuthError flow unchanged. Token clearing on 401 does NOT happen here (PH3's auth context owns that).
  • 2.4 web/src/api/client.test.ts (create) — TDD targets:

    • No token (default tokenStore state) → fetch is called without Authorization header.
    • tokenStore.set('abc') → fetch is called with Authorization: Bearer abc.
    • Caller-supplied Authorization header wins over stored token (covers admin/test override cases).
    • 401 → AuthError thrown (regression — existing behavior preserved).
    • Mock globalThis.fetch via vi.stubGlobal.
  • Commit: web: PKCE machinery + Authorization-header attachment in apiFetch

#PH3 — SPA: auth context + /login + /auth/callback + config reader

  • 3.1 web/src/lib/config.ts (create) — read window.__LETHE_CONFIG__ (IF1, produced by PH1).

    • export interface LetheConfig { issuer: string; clientId: string }
    • export function readConfig(): LetheConfig — returns config from window.__LETHE_CONFIG__. Throws if missing — fail-fast per GPC6 (no silent fallback to a default issuer).
    • The throw happens at first call site (the auth provider mount). Caller catches and renders an "auth-config missing" error card so the SPA at least loads.
  • 3.2 web/src/lib/authContext.tsx (create) — React context wrapping tokenStore.

    • export interface AuthState { token: string | null; user: { name?: string } | null; status: 'unauthenticated' | 'authenticated' | 'auth_error'; error?: string }
    • export const AuthContext = React.createContext<AuthContextValue | null>(null)
    • export function AuthProvider({ children }: { children: React.ReactNode }): JSX.Element — subscribes to tokenStore, parses ID token's name claim into user, exposes state via context.
    • export function useAuth(): AuthContextValue — typed accessor; throws if used outside provider.
    • interface AuthContextValue { state: AuthState; signIn(returnTo?: string): void; signOut(): void; reportAuthError(error: string): void }
      • signIn — generates PKCE pair, persists verifier+state+returnTo to localStorage under key lethe_auth_pending (TTL 5 min), window.location.assign(<authorize URL>).
      • signOuttokenStore.set(null) (clears in-memory; localStorage left alone).
      • reportAuthError — used by callback route to flip state to auth_error with a description.
    • Token-context-vs-tokenStore separation: tokenStore is the truth (PH2 owns); context just renders it as React state and adds metadata + actions.
  • 3.3 web/src/routes/login.tsx (create) — createFileRoute('/login').

    • validateSearch: (s) => ({ return_to: typeof s.return_to === 'string' ? s.return_to : '/' })
    • On mount, calls useAuth().signIn(search.return_to). Renders a "redirecting…" placeholder.
  • 3.4 web/src/routes/auth.callback.tsx (create) — createFileRoute('/auth/callback').

    • validateSearch: (s) => ({ code: typeof s.code === 'string' ? s.code : undefined, state: typeof s.state === 'string' ? s.state : undefined, error: typeof s.error === 'string' ? s.error : undefined, error_description: typeof s.error_description === 'string' ? s.error_description : undefined })
    • On mount: reads lethe_auth_pending from localStorage; validates state matches and TTL not exceeded; POSTs to <issuer>/token with grant_type=authorization_code, code, code_verifier, redirect_uri=${origin}/auth/callback, client_id=<config.clientId>; on 200, calls tokenStore.set(access_token) and navigate({ to: returnTo }); on any failure, calls useAuth().reportAuthError(...) and shows the auth-error card. (IV6.)
    • Anti-loop counter: increment localStorage.lethe_auth_failures on each callback failure; if > 3 within 5 minutes, render "please reload" card with no retry button. (IV6.)
    • Uses apiFetch is not applicable here — /token is on a different origin and doesn't carry our middleware. Use raw fetch with Content-Type: application/x-www-form-urlencoded body. Note: this is the one allowed exception to the foundation invariant "no raw fetch outside client.ts" — record as a deviation in the implementer's report. The exception is principled: cross-origin OP token endpoint is not part of our API.
  • 3.5 web/src/routes/__root.tsx:32-89 (modify) — wrap <Outlet> and <Palette> in <AuthProvider>. The provider's useEffect-based subscription replaces nothing existing; additive.

  • 3.6 internal/server/web/dist/index.html (modify, build artifact only — implementer regenerates via npm run build and includes in commit) — Vite bundles the new lib/auth.ts, lib/config.ts, lib/authContext.tsx, two new routes; index.html will pick up new asset hashes.

  • Commit: web: /login + /auth/callback routes + auth context + config reader

#PH4 — SPA: AuthGate + three call-site swaps

  • 4.1 web/src/shell/AuthGate.tsx (create) — single source of truth for the "not authenticated" UI.

    • export function AuthGate({ children }: { children: React.ReactNode }): JSX.Element
    • Reads useAuth().state.status. If authenticated → renders children. If unauthenticated → renders the "Sign in with OIDC" card with a manual button (IV7). If auth_error → renders the distinct error card (IV6) — copy: "couldn't sign you in" + the error description from context, plus a "Try again" button that calls signIn().
    • First-render-vs-mid-session distinction: state.status === 'unauthenticated' AND a session has previously been authenticated this page-load → auto-redirect via signIn() (IV7). Tracked by a hasBeenAuthenticated ref in the provider.
    • Card chrome borrows from existing inline cards (web/src/routes/index.tsx:91-98): same .card class, same .uppercase-mono heading style, just consolidated.
  • 4.2 web/src/routes/index.tsx:89-109 (modify, error block) — replace the inline AuthError card and the inline non-auth error card with <AuthGate>{children}</AuthGate> wrapping the existing happy-path return. The AuthGate renders the auth UI when needed; happy-path renders normally otherwise. Non-auth errors keep their existing inline card (AuthGate is NOT a generic error boundary).

    • Specifically: lift the if (error instanceof AuthError) return <auth card> early-return — let <AuthGate> handle that case. Keep the if (error != null) non-auth-error early-return as-is.
  • 4.3 web/src/routes/projects.tsx:117-141 (modify) — same shape as 4.2 at this route's auth-error block.

  • 4.4 web/src/features/settings/SavedSearchesSection.tsx:177-200 (modify) — same shape as 4.2 at this section's auth-error block. Note this is mid-route, not a route component, so the wrap is around the section's body, not the whole route.

  • 4.5 web/src/styles/shell.css (modify if needed) — extract .auth-card rule if the inline cards' inline styles add up to enough lines to warrant a class. Likely 5–15 lines of CSS; keep alongside .empty-state rules.

  • Commit: web: AuthGate consolidates three "not authenticated" cards

#Test strategy

  • PH1 TDD: every bullet under 1.8 / 1.9 / 1.12 is a failing test first. Coverage map: IV1 → TestToken_BadVerifier_Returns400; IV2 → TestToken_CodeReuse_Returns400; IV3 → TestToken_ExpiredCode_Returns400; IV8 → TestDevToken_StillWorks; PC1 → the discovery + RFC-error-shape tests.
  • PH2 TDD: bullets 2.2 / 2.4 cover IV4 (auth.ts has no localStorage.setItem for tokens — test by grep + by tokenStore.set not touching window.localStorage), IV5 (Authorization-header on/off behavior), AS1 (challenge via crypto.subtle).
  • PH3 not TDD'd: /login, /auth/callback, auth-context provider — visual + integration. Anti-loop counter logic is worth a single vitest case since it's pure: write a small helper countCallbackFailures(now: number, log: number[]): number in lib/auth.ts and test it. (Adds bullet 2.5 — fold into PH2 since it's pure.)
  • PH4 not TDD'd: AuthGate chrome — visual feel.

Adjustment: bullet 2.5 added to PH2 — countCallbackFailures helper + 1 vitest case (3 failures within 5 min → block flag flips). Keeps anti-loop logic pure.

#Order & dependencies

  • PH1 ⊥ PH2 (Go vs TS, disjoint paths). Wave 1.
  • PH3 consumes PH1's window.__LETHE_CONFIG__ shape (IF1) and PH2's tokenStore (IF2). Wave 2.
  • PH4 consumes PH3's useAuth() (IF3). Wave 3.

#Risks / rollback

  • RK1 — Cross-origin POST /token from SPA to dev stub needs CORS preflight. PH1.5's OPTIONS handler + ACAO header on the POST response covers this; TestToken_OPTIONSPreflight_Returns204_CORS regression-tests it. Real OPs (Authelia, Keycloak) all support CORS for SPA clients, so this is correct, not stub-only.
  • RK2 — Vite's existing Remote-User: bigbes proxy injection (web/vite.config.ts) means dev fetches always succeed; AuthGate never renders in dev → smoke walk can't exercise the new flow. Mitigation: smoke walk in verify documents the workaround (comment out the injection for the OIDC walk). No code change.
  • RK3crypto.subtle.digest is unavailable in non-secure contexts. Browsers treat localhost and 127.0.0.1 as secure; HTTPS in prod is fine; LAN IPs (e.g. 192.168.x.y) without HTTPS would fail. Out of scope per Design AS2 (loopback bind).
  • RK4 — Vite-bundled index.html is regenerated by npm run build; PH3 and PH4 both will produce new bundle hashes. internal/server/web/dist/index.html must be re-staged in each commit (precedent from #5/#6 — verify catches it).
  • RK5 — Rollback path: each phase's commit is independently revertible. PH1 revert leaves /authorize+/token returning 404 (no consumer until PH3+PH4 ship). PH2 revert removes Authorization-header attachment (apiFetch falls back to today's no-header behavior; existing forward-auth path keeps working). PH3 revert leaves /login and /auth/callback 404 from a future revert; until UI lands, nothing reaches them. PH4 revert leaves three duplicate "not authenticated" cards (today's state).

#Interfaces

  • IF1 — window.__LETHE_CONFIG__: { issuer: string; client_id: string } injected into index.html by internal/server/web/embed.go. PH1 produces, PH3 consumes via web/src/lib/config.ts.
  • IF2 — tokenStore: TokenStore exported from web/src/lib/auth.ts; methods get(): string | null, set(token: string | null): void, subscribe(listener): () => void. PH2 produces, PH3 consumes (callback writes the token), PH4 consumes (AuthGate reads via context which reads via tokenStore).
  • IF3 — useAuth(): AuthContextValue exported from web/src/lib/authContext.tsx. PH3 produces, PH4 consumes.

#Interface graph

  • PH1 -> IF1 @ internal/testutil/oidcstub/, internal/server/web/embed.go, internal/server/web/embed_test.go, internal/server/server.go, internal/server/auth/devstub.go
  • PH2 -> IF2 @ web/src/lib/auth.ts, web/src/lib/auth.test.ts, web/src/api/client.ts, web/src/api/client.test.ts
  • PH3 IF1, IF2 -> IF3 @ web/src/lib/config.ts, web/src/lib/authContext.tsx, web/src/routes/login.tsx, web/src/routes/auth.callback.tsx, web/src/routes/__root.tsx, internal/server/web/dist/index.html
  • PH4 IF3 -> @ web/src/shell/AuthGate.tsx, web/src/routes/index.tsx, web/src/routes/projects.tsx, web/src/features/settings/SavedSearchesSection.tsx, web/src/styles/shell.css, internal/server/web/dist/index.html

Wave check:

  • Wave 1 (PH1, PH2): paths are disjoint (Go-only vs TS-only). ✓
  • Wave 2 (PH3 alone): single-phase wave; no disjointness check needed.
  • Wave 3 (PH4 alone): single-phase wave.
  • internal/server/web/dist/index.html appears in PH3 and PH4 — both regenerate the same artifact in their respective commits. They are in different waves (no parallel-edit risk).

#Backwards-compat check (plan-side restatement)

Restating Design's per-area compat with concrete phase steps:

  • Dev stub (PH1.3 discovery + 1.4/1.5 handlers): additive. New endpoints (/authorize, /token) under new paths. Discovery doc gains fields, loses none. /dev/token kept (IV8 — TestDevToken_StillWorks regression-tests it). The authorization_endpoint field's value changes from the placeholder /auth to /authorize to match the new handler path; no consumer of the old value exists in the codebase (grep s\.issuer.*"/auth" empty outside oidcstub.go itself).
  • Embed handler (PH1.10/1.11): Handler() signature changes to Handler(cfg Config). Single call site at internal/server/server.go:114 updated in 1.11. No external consumer.
  • apiFetch (PH2.3): adds Authorization: Bearer … only when token present; absent token = no header (today's behavior). Tests that don't seed a token continue to pass unchanged.
  • __root.tsx wrap (PH3.5): additive <AuthProvider> wrap; nothing existing depends on the absence of a context provider above <Outlet>.
  • Three call-site swaps (PH4.2/4.3/4.4): UX consolidation, not API change. Cards collapse into shared component.
  • Foundation "no raw fetch outside client.ts" invariant: PH3.4 (auth.callback.tsx) uses fetch directly to POST to the cross-origin /token endpoint — this is the one allowed exception (different origin, not part of /api/v1/*). Recorded as a plan-time deviation; implementer's commit message and the file's top-of-file comment will note it.

Greenfield modulo the additions enumerated above. No schema migrations. No internal/shared/wire/ changes.

#Verify

Result: passed

Positive:

  • CK1 — go test ./internal/testutil/oidcstub/... -race -count=1 → ok (codestore + handler tests, ~22 cases)
  • CK2 — go test ./internal/server/web/... -count=1 → ok (3 embed-injection tests)
  • CK3 — go test ./internal/... -count=1 → ok (all packages)
  • CK4 — go test ./cmd/lethe/... -count=1 → ok (RK4: e2e steward graph still works)
  • CK5 — go build ./... → clean
  • CK6 — cd web && npm test -- --run → 84 passed across 6 files
  • CK7 — cd web && npm run build → 382 modules, 499.94 KB JS / 152.73 KB gzip; dist/index.html matches tracked artifact

Negative:

  • CK8 (IV1 bad verifier) — TestToken_BadVerifier_Returns400 → 400 invalid_grant
  • CK9 (IV2 code reuse) — TestToken_CodeReuse_Returns400 → first 200, second 400
  • CK10 (IV3 expired code) — TestCodeStore_Consume_Expired → expired entry returns false. Note: TestToken_ExpiredCode_Returns400 is t.Skip'd because handleAuthorize uses 5*time.Minute against time.Now (not injectable); IV3 is fully covered at the codestore unit-test layer with injected clock. Recorded as a deviation.
  • CK11 — TestAuthorize_MissingRequiredParam_Returns400 → 4 sub-cases (response_type, client_id, redirect_uri, code_challenge) → 400 invalid_request

Invariants / assumptions:

  • CK12 (IV1) — oidcstub.go:340-346 rejects code_challenge_method != S256 with 400 invalid_request; oidcstub.go:405-410 verifies base64url(SHA-256(verifier)) == code_challenge before mint
  • CK13 (IV2) — codestore.go:71 delete(s.entries, code) runs unconditionally on Consume (single-use enforced)
  • CK14 (IV3) — oidcstub.go:350 issues codes with 5*time.Minute TTL; codestore.go:62-72 rejects expired
  • CK15 (IV4) — grep -n "localStorage.setItem" web/src/lib/auth.ts → empty; tokenStore writes only to closure state
  • CK16 (IV5) — web/src/api/client.ts:25-28 returns {Authorization: \Bearer ${token}`}only whentoken !== null`; null returns empty object spread (no header)
  • CK17 (IV6) — AuthGate.tsx:32-58 auth_error branch renders error card with manual "Try again" button (onClick={() => signIn(...)}); no useEffect auto-retry from this branch
  • CK18 (IV7) — AuthGate.tsx:22-26 useEffect auto-redirect fires only when status === 'unauthenticated' && hasBeenAuthenticated === true; cold render (hasBeenAuthenticated === false) falls through to manual button at line 65+
  • CK19 (IV8) — oidcstub.go:125 mux.HandleFunc("/dev/token", s.handleDevToken) unchanged; TestDevToken_StillWorks regression-tests it
  • CK20 (AS1) — web/src/test-setup.ts:3-11 polyfills globalThis.crypto from Node webcrypto in vitest; production browsers carry crypto.subtle natively per modern-browser baseline
  • CK21 (AS2) — internal/server/server.go keeps validateLoopbackBind; dev-stub binds to 127.0.0.1:<port>; redirect URIs of the form http://127.0.0.1:<port>/auth/callback are accepted by the stub (verified via TestAuthorize_RedirectsWithCodeAndState)
  • CK22 (AS3) — unverifiable at this layer; deferred to UK1 (real-OP smoke is the user's call)

Interfaces:

  • CK23 (IF1) — embed.go:25-28 Config{Issuer\json:"issuer"`, ClientID`json:"client_id"`}produceswindow.LETHE_CONFIG={"issuer":"…","client_id":"…"}; web/src/lib/config.ts:36-37consumes with the same key names;TestHandler_InjectsConfigIntoIndex` asserts the JSON shape
  • CK24 (IF2) — tokenStore exported at web/src/lib/auth.ts:115, consumed at web/src/api/client.ts:26, web/src/lib/authContext.tsx:94,126,189, web/src/routes/auth.callback.tsx:196 — five call sites total
  • CK25 (IF3) — useAuth() exported at web/src/lib/authContext.tsx:64, consumed at web/src/shell/AuthGate.tsx:17, web/src/routes/login.tsx:13, web/src/routes/auth.callback.tsx:42 — three call sites

Notes:

  • Browser smoke deferred — the new /login and /auth/callback routes plus the dev-stub /authorize+/token round-trip were not exercised end-to-end in a real browser. tsc + vitest + Go tests are clean and the dev stub is now RFC-compliant for auth-code+PKCE per the test suite, but a full Chrome walk requires standing up the lethe server with OIDC enabled, the dev stub, AND removing the Remote-User injection from web/vite.config.ts (RK2: otherwise dev fetches succeed via forward-auth and AuthGate never triggers). Out of reach in autonomous mode without user setup. Recommended walk: (a) start the lethe server with auth.oidc.enabled=true and auth.oidc.dev_stub.enabled=true, (b) comment out the Remote-User injection in web/vite.config.ts, (c) npm run dev, (d) hit /, see the AuthGate "Sign in with OIDC" card, click it, watch the round-trip, land back authenticated.

#Conclusion

Outcome: shipped — ac34df3..HEAD (HEAD = e920ae8); 7 execute commits + 3 review-fix commits + 2 doc commits.

Invariants:

  • IV1 — oidcstub.go:340-346 rejects code_challenge_method != S256 with 400 invalid_request; oidcstub.go:405-410 verifies base64url(SHA-256(verifier)) == code_challenge before mint. Tests: TestToken_BadVerifier_Returns400, TestAuthorize_NonS256Challenge_Returns400.
  • IV2 — codestore.go:71 delete(s.entries, code) runs unconditionally inside Consume (single-use). Tests: TestCodeStore_Consume_SingleUse, TestToken_CodeReuse_Returns400.
  • IV3 — oidcstub.go:350 issues codes with 5*time.Minute TTL; codestore.go:62-72 rejects expired entries. Tests: TestCodeStore_Consume_Expired covers the unit-layer expiry; TestToken_ExpiredCode_Returns400 is t.Skip'd (handler clock not injectable — unit-test layer is the test boundary; recorded as deviation).
  • IV4 — grep -n "localStorage.setItem" web/src/lib/auth.ts empty; tokenStore writes are closure-only. Anti-loop counter and PKCE state in localStorage are timestamps + verifiers + state nonces, never tokens. Cache-invalidation test in auth.test.ts enforces.
  • IV5 — web/src/api/client.ts:25-28 returns {Authorization: \Bearer ${token}`}only whentoken !== null; null returns empty spread (no header). Tests in client.test.ts` cover both branches.
  • IV6 — AuthGate.tsx:32-58 auth_error branch renders manual "Try again" button; no useEffect auto-retry. Anti-loop counter in auth.callback.tsx blocks at >3 failures within 5 min and renders "please reload" instead of redirecting.
  • IV7 — AuthGate.tsx:22-26 useEffect auto-redirect fires only when status === 'unauthenticated' && hasBeenAuthenticated === true; cold render falls through to manual button at line 65+. hasBeenAuthenticated flag flows from AuthProvider to AuthGate via AuthState.
  • IV8 — oidcstub.go:125 mux.HandleFunc("/dev/token", s.handleDevToken) unchanged; TestDevToken_StillWorks regression-passes; TestEndToEnd_MultiUserIsolation in cmd/lethe passes.

#Assumptions check

  • AS1 — held — web/src/test-setup.ts:3-11 polyfills globalThis.crypto from Node webcrypto for vitest; production browsers carry crypto.subtle natively per modern-browser baseline. No conditional exists in production code that depends on the polyfill.
  • AS2 — held — internal/server/server.go keeps validateLoopbackBind; dev-stub binds to 127.0.0.1:<port>; SPA's redirect_uri = ${origin}/auth/callback resolves to a localhost URL the stub accepts. TestAuthorize_RedirectsWithCodeAndState exercises a localhost redirect.
  • AS3 — unverifiable at this layer — same SPA code talking to a real OP unchanged is UK1's territory. The stub is RFC 6749 §4.1 + RFC 7636 §4.6 compliant (per the test suite + the percent-encoding fix below); divergence with a real OP would be a stub bug, not a SPA bug.

#Unknowns outcome

  • UK1 — still-open — real-OP smoke (Authelia, Keycloak, etc.) is out of scope per Design "Out of scope". The dev-stub flow is RFC-conformant per the test suite; the user's real-OP rollout is the next gate.
  • UK2 — resolved — authContext.tsx parses the id_token payload for the name claim only; falls back to user = null on parse failure (token still valid for middleware checks; name is cosmetic). Stored in AuthState.user.name; consumed nowhere yet (TopBar greeting not wired in this task — additive follow-up).

#Plan adherence

  • Plan 1.7 unimplemented — required passing Cfg.OIDC.DevStub.User to oidcstub.Options{}, but OIDCDevStubConfig has no User field in internal/config/config.go. PH1 implementer disclosed this; the stub falls back to its "bigbes" constant default. Behaviour matches the project's single-developer convention. Adding the config field is scope creep; logged here for completeness. Reviewer flagged separately at confidence 80; same call.
  • Stub.handleAuthorize redirect_uri validation — RFC 6749 §4.1.3 says the redirect_uri sent on /token must match the one stored at /authorize. oidcstub.go:401-403 (in handleToken) verifies this; TestToken_RedirectURIMismatch_Returns400 regression-tests it. Plan called this out under PC1; verified.

#Review findings

  • Critical: none.
  • Important (4 — three fixed, one recorded):
    1. embed.go silent no-op when </head> absent — fixed in f1926dc by comparing pre/post bytes.Replace and writing 500 on no-op (fail-loud per GPC6).
    2. oidcstub.go handleAuthorize redirect query unencoded — fixed in be6e43e by switching to url.Values{}.Encode() for RFC 6749 §4.1.2 compliance (PC1). Operationally inert in today's SPA (state was URL-safe), but real-OP-parity invariant required the fix.
    3. auth.callback.tsx lethe_auth_failures array unbounded — fixed in e920ae8 by pruning entries outside the 5-min window before each push. Plan explicitly asked "is the array bounded; are timestamps pruned at insert" — answer is now yes.
    4. devstub.go plan 1.7 no-op — recorded only (see Plan adherence). Behavior is correct; only loss is configurability that was never in the config struct.

#Future work

  • Wire AuthState.user.name into the TopBar greeting — UK2 resolved at the data layer; UI hookup deferred (out of scope per Design "TopBar greeting only" comment; not load-bearing for this task's gating-flow purpose). Single sibling-task call when desired.
  • Real-OP smoke (UK1) — user-driven real-OP integration test once Authelia/Keycloak deployment exists. Stub is RFC-conformant; divergence would be a stub bug.
  • Add User field to OIDCDevStubConfig if a future contributor needs to dev as someone other than bigbes. Below the rule-of-three threshold today.

#Verified by

  • Browser smoke deferred — see Verify → Notes → "Browser smoke deferred". The new flow requires standing up the lethe server with OIDC enabled, the dev stub, AND temporarily commenting out the Remote-User injection in web/vite.config.ts (RK2).

#Hands-off decisions

  • size: Medium — covers backend dev-stub upgrade + SPA login flow + three call-site swaps. Full Design + Plan + Execute flow.
  • mode: hands-off enabled mid-design at user request. The /up:make invocation did not prefix handsoff; flipped after the user said "so handoffed design is not applicable?".
  • udesign — Path A (dev-stub upgrade to full auth-code+PKCE OP) — single code path beats divergent ones.
  • udesign — token model: SPA-direct (Bearer); middleware already accepts Bearer.
  • udesign — token storage: in-memory React context; reload = re-login.
  • udesign — refresh: re-login on expiry; 8h stub default.
  • udesign — trigger: manual click first, auto-redirect on mid-session 401.
  • udesign — anti-loop: PKCE state + return_to in localStorage with 5-minute TTL; max 3 callback failures in 5 minutes.
  • udesign — bundle dev-stub upgrade in this task (not a #10 follow-up).
  • branch/worktree: Branch=master, Worktree=none — deviates from hands-off default but matches every prior lethe-* task.
  • uplan: plan auto-approved (hands-off) — four phases; IF1/IF2/IF3 cross-phase; Wave 1 = PH1‖PH2, Wave 2 = PH3, Wave 3 = PH4.
  • uplan: surfaced deviation — SPA needs to learn issuer/client_id at runtime; design did not specify the mechanism. Pick: inject window.__LETHE_CONFIG__ via internal/server/web/embed.go.
  • uplan: surfaced deviation — PH3.4 (auth.callback.tsx) uses raw fetch to POST to cross-origin /token; the foundation invariant "no raw fetch outside client.ts" does not extend to non-/api/v1 cross-origin endpoints. Allowed.
  • uexecute: wave-1 IF1 contract fix — PH1's Config{Issuer, ClientID} had no json tags so JSON would have emitted PascalCase, breaking PH3's client_id reader. Fixed inline at the wave-1/wave-2 boundary in commit 5d910e8 before dispatching PH3.
  • ureview: fixed embed.go silent no-op — added pre/post bytes.Equal check; 500 on missing </head> (commit f1926dc).
  • ureview: fixed oidcstub redirect encoding — switched to url.Values.Encode() for RFC 6749 §4.1.2 compliance (commit be6e43e).
  • ureview: fixed auth.callback unbounded log — prune-then-push within 5-min window (commit e920ae8).
  • ureview: recorded deviation only for plan 1.7 (DevStubUser config field doesn't exist; stub default is correct behavior).

#Hands-off decisions

  • size: Medium — covers backend dev-stub upgrade + SPA login flow + three call-site swaps. Full Design + Plan + Execute flow.
  • mode: hands-off enabled mid-design at user request ("does frontend changed?" → "so handoffed design is not applicable?"). The /up:make invocation did not prefix handsoff; flipping now.
  • udesign — Path A (dev-stub upgrade to full auth-code+PKCE OP) — chosen over (B) two-mode SPA and (C) forward-auth-only-in-dev because single code path beats divergent ones, the stub work is small (~150–250 LoC) and self-contained, and it permanently closes the "browser smoke deferred" gap from #5/#6.
  • udesign — token model: SPA-direct (Bearer) — middleware already handles Bearer (internal/server/auth/middleware.go:155-165); zero new backend endpoints; same-origin embed bounds XSS exposure; personal-scope removes multi-tenant token-leakage concern.
  • udesign — token storage: in-memory React context — no localStorage, no cookies; smallest XSS blast radius; reload = re-login is acceptable for a daemon-style personal app.
  • udesign — refresh: re-login on expiry — token TTL = session lifetime (8h stub default); silent-refresh deferred as additive follow-up.
  • udesign — trigger: manual click first, auto-redirect on mid-session 401 — anti-popup-blocker on cold render; auto-redirect mid-session is safe because the user already opted in.
  • udesign — anti-loop: PKCE state + return_to in localStorage with 5-minute TTL; max 3 callback failures in 5 minutes before "please reload".
  • udesign — bundle dev-stub upgrade in this task (not a #10 follow-up) — task #10 is closed (✓ Reviewed); reopening it for a small additive change adds workflow overhead without buying anything.
  • branch/worktree: Branch=master, Worktree=none — deviates from the hands-off default ("dedicated branch + worktree, never direct edits to main") but matches every prior lethe-* task in this project (#1, #4, #5, #6, #10 all on master directly); project convention takes precedence.
  • uplan: plan auto-approved (hands-off) — four phases (PH1 backend stub+embed, PH2 SPA auth library, PH3 routes+context, PH4 AuthGate+3 call-site swaps); IF1/IF2/IF3 cross-phase; Wave 1 = PH1‖PH2, Wave 2 = PH3, Wave 3 = PH4.
  • uplan: surfaced deviation — SPA needs to learn issuer/client_id at runtime; design did not specify the mechanism. Pick: inject window.__LETHE_CONFIG__ via internal/server/web/embed.go (smallest surface; no auth-bypass endpoint; same-origin static-config pattern). Logged in PH1.10/1.11 and Backwards-compat check.
  • uplan: surfaced deviation — PH3.4 (auth.callback.tsx) uses raw fetch to POST to cross-origin /token; the foundation invariant "no raw fetch outside client.ts" does not extend to non-/api/v1 cross-origin endpoints. Allowed.