feat(sso): SSO forward-auth gateway — auth once, access all (Phase 1: service + edge) - #2221
Conversation
…all) Traefik forward-auth + custom FastAPI validator (reuses Supabase JWT + BoTZ validate_jwt) fronting wger/firefly/open-notebook (Remote-User header) and jellyfin (Ezeqielle OIDC plugin). Validated against current app auth docs: firefly + open-notebook have no OIDC (header-auth is the only path); jellyfin has no native OIDC (maintained plugin). One IdP (GoTrue), subdomain routing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TDD task-by-task: sso-auth service (verify/login/oidc), Traefik forward-auth, per-app integration (firefly/wger header, open-notebook middleware, jellyfin Ezeqielle OIDC plugin), env pipeline, e2e. Reuses Supabase JWT_SECRET + BoTZ verify. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ability) Task 1's config.py built a module-level Settings singleton at import time, which crashed pytest collection (KeyError) and defeated the tests' env monkeypatching. Replace with a lazy _LazySettings proxy (reads env on first access + _reset() for tests); all modules keep using settings.<field> unchanged. Add --with pytest-asyncio to the test commands (repo-wide conftest imports it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rify from OIDC config Review of Task 1 found: (Critical) verify_session did not check the role claim, so Supabase's PUBLIC anon key (same JWT_SECRET) would authenticate — bypass. Now require role=='authenticated'. (Important) the verify hot path pulled required JELLYFIN_OIDC_* env via Settings.load, risking KeyError->500; make those optional (only SUPABASE_JWT_SECRET required). Adds 2 tests pinning both. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 blocked on plan-test bugs cascading from Task 1's role-check fix: - test helpers minted session tokens without role='authenticated' (verify_session now rejects them) -> add role to Task 2 _access() + Task 3 sess tokens - TestClient default base_url=http://testserver drops the Domain=.pmoves.ai cookie -> base_url=https://auth.pmoves.ai in Task 2/3 tests - python-multipart is a RUNTIME dep (FastAPI Form) -> add to requirements.txt; add --with pytest-asyncio to Task 2/3 test commands (repo conftest) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 2 passed in isolation but the full 'pytest tests/' collided: module-level importlib.reload(config)+os.environ.setdefault at import time polluted the shared config module, breaking test_jwt_verify. The lazy settings proxy makes reloads unnecessary — replace with an autouse _env fixture (monkeypatch.setenv + settings._reset) in both test_login.py and test_oidc.py, matching test_jwt_verify's clean pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Background security review flagged HIGH open-redirect: the rd return-destination in /login, /callback, and login_page was passed to RedirectResponse unvalidated (rd=https://evil.com would phish a logged-in user). Add _safe_rd() — allow only a same-origin relative path or an *.pmoves.ai host, else fall back to '/'; apply in all three sites. Adds test_login_rejects_open_redirect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… logout revoke+local) - /callback wraps exchange_code in try/except GoTrueError -> /login?e=1 (no 500 on stale code) - login_page adds e param + renders error; cb uses urlencode (rd may contain &/#) - /logout best-effort server-side gotrue.logout(token) revoke, redirects to LOCAL /login, secure+httponly delete_cookie - gotrue.logout(access_token) added - +3 tests: callback error, bad-login error, logout local+revoke Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…revoke+local redirect, error rendering
- /callback no longer 500s on a stale/replayed OAuth code; redirects to /login?rd=...&e=1
- login_page renders a "Sign-in failed" message via new `e` query param and encodes the callback URL
- login_submit failure path uses urlencode instead of manual string interpolation
- /logout reads the session cookie, best-effort revokes it server-side via new gotrue.logout(),
and redirects to the browser-reachable local /login (not the internal gotrue_url)
- add gotrue.logout(access_token) — POST {gotrue_url}/logout with Bearer auth
- add 3 reviewer-specified tests: bad-login error rendering, callback error redirect, logout revoke+redirect
- isolate test_logout_clears_cookie from the shared TestClient cookie jar so it no longer
triggers an unmocked network call now that /logout actually reads the session cookie
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- gotrue._post helper wraps httpx.RequestError -> GoTrueError so a GoTrue OUTAGE (connect/timeout) is caught by callers' except GoTrueError, not a raw httpx 500 (satisfies spec fail-closed / never-500 invariant) - +2 tests: transport error -> GoTrueError across grant/exchange/logout; /logout still clears cookie + redirects local when GoTrue down - test_login.py imports httpx Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + gotrue fail-closed on outage Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two independent reviews (task reviewer + automated commit security review) flagged /oidc/authorize reflecting an unvalidated redirect_uri -> open-redirect + auth-code/state leak. Harden per RFC 6749 §4.1.3: - config: jellyfin_oidc_redirect_uris exact-match allowlist (env JELLYFIN_OIDC_REDIRECT_URIS, comma-separated) - authorize(): reject unregistered client_id/redirect_uri with 400 (never redirect) BEFORE session logic or code issuance - _issue_code binds redirect_uri; /oidc/token re-verifies it (400 on mismatch) - client creds compared with hmac.compare_digest (constant-time) - +4 tests (unregistered uri 400, wrong client 400, mismatched-at-token 400, happy-path 303 issues code); env.shared.example + sso compose wire the var Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ret compare (HIGH open-redirect) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs eager json decode) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e, per-app routers Tasks 4(compose)/5/6/7 of the SSO gateway (Known-Road: compose): - docker-compose.sso.yml: pmoves-sso-auth on pmoves_app+pmoves_external, no host port - docker-compose.traefik.yml + config/traefik/dynamic.yml: Traefik v3.3 edge, Cloudflare DNS-01 TLS, forwardAuth middleware -> sso-auth:8080/auth/verify (injects Remote-User/X-Auth-Email/X-Auth-Subject), 401->auth.pmoves.ai/login - docker-compose.external.yml: health/wealth/notebook/media routers on the 4 apps; media (jellyfin) has NO forward-auth (its OIDC plugin auths); all 4 drop host ports (Traefik-only, so Remote-User cannot be spoofed by bypassing the proxy); firefly remote_user_guard env (Task 6); wger WGER_ALLOW_REMOTE_USER env (Task 7) - DEVIATION FROM PLAN (correctness): added traefik.docker.network=pmoves_external to each app — they are on TWO networks, so the docker provider needs the explicit network or it may bind the wrong one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oot, healthcheck)
Task 4 dockerfile (Known-Road: dockerfile). Deviation from plan: pinned to the
fleet-standard python:3.11-slim@sha256:a3ab0b9… (used across pmoves/services/*)
instead of unpinned python:3.12-slim — hardened-image convention; code needs no
3.12 features. Verified: image builds, container starts, /healthz -> 200 {status:ok}.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…registers Reviewer P1: auth router service=sso-auth@docker cannot resolve because the sso-auth container had no traefik.enable=true/network/port under exposedbydefault=false -> auth.pmoves.ai login page unreachable, whole SSO flow broken at the front door. Add the 3 service labels to sso-auth. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h router resolves Reviewer P1 fix: without traefik.enable=true the sso-auth container was invisible to Traefik under exposedbydefault=false, so the auth.pmoves.ai router's service=sso-auth@docker never resolved and the login page was unreachable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents the forward-auth config surface: SSO_PUBLIC_BASE_URL, SSO_COOKIE_DOMAIN, GOTRUE_URL, JELLYFIN_OIDC_CLIENT_ID/SECRET/REDIRECT_URIS, CLOUDFLARE_DNS_API_TOKEN, ACME_EMAIL. Reuses existing JWT_SECRET/SUPABASE_JWT_SECRET (no new session secret). DEFERRED (not in this PR): CHIT-vault/secrets_manifest registration of the two JELLYFIN_OIDC_* client secrets + CF DNS token — the manifest is CGP-vault-sourced (operator voice-activated flow) and the OIDC client values are consumed only by the deferred Jellyfin integration (follow-up PR). Operator registers them at deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/oidc/authorize's bounce to /login interpolated request.url unencoded — the authorize URL's own &/= (redirect_uri, state) would truncate rd at the first & on the login page, dropping the required redirect_uri and 422-ing the post-login bounce-back. urlencode the whole URL. +1 test (23 passed). Same encoding class already fixed in login_page's callback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📝 WalkthroughWalkthroughChangesThe PR adds an SSO gateway design and implementation plan, a FastAPI authentication service with JWT and GoTrue flows, a Jellyfin OIDC adapter, Traefik deployment configuration, protected application routing, service-level tests, and an independent Archon NATS configuration correction. SSO gateway
Archon broker wiring
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Browser
participant Traefik
participant pmoves-sso-auth
participant GoTrue
participant ProtectedApp
Browser->>pmoves-sso-auth: Submit login credentials
pmoves-sso-auth->>GoTrue: Request password grant
GoTrue-->>pmoves-sso-auth: Return session token
pmoves-sso-auth-->>Browser: Set pmoves_session cookie
Browser->>Traefik: Request protected application
Traefik->>pmoves-sso-auth: Verify session cookie
pmoves-sso-auth-->>Traefik: Return Remote-User and identity headers
Traefik->>ProtectedApp: Forward authenticated request
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Coordination note from the #2188 lane (4090) — not touching this branch, just a diagnostic while you're on the Jellyfin piece:
i.e. regenerating the overlays from this branch's Fix: (The Separately, Heads-up on sequencing: #2188 (wire 🤖 4090-claude |
docker-compose.agents.yml was stale relative to source docker-compose.yml — the #2220 archon in-network NATS_URL pin was in source but never regenerated into the agents overlay, so 'Validate Compose Files' (split-overlay drift gate) failed. Pure 'make -C pmoves compose-split' output; no SSO/source changes. Covering the SSO lane (#2221) while z890 is on the Jellyfin piece. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Docker Hardening ValidationHardening Validation ReportValidated: Sat Jul 25 12:18:24 UTC 2026Services CheckedPMOVES.AI Docker Hardening Validation[INFO] Checking: pmoves/docker-compose.hardened.yml [INFO] Validating: hi-rag-gateway-v2 [INFO] Validating: extract-worker [INFO] Validating: langextract [INFO] Validating: presign [INFO] Validating: render-webhook [INFO] Validating: retrieval-eval [INFO] Validating: pdf-ingest [INFO] Validating: jellyfin-bridge [INFO] Validating: invidious-companion-proxy [INFO] Validating: ffmpeg-whisper [INFO] Validating: media-video [INFO] Validating: media-audio [INFO] Validating: hi-rag-gateway-v2-gpu [INFO] Validating: hi-rag-gateway-gpu [INFO] Validating: deepresearch [INFO] Validating: supaserch [INFO] Validating: publisher-discord [INFO] Validating: mesh-agent [INFO] Validating: nats-echo-req [INFO] Validating: nats-echo-res [INFO] Validating: comfy-watcher [INFO] Validating: grayjay-plugin-host [INFO] Validating: agent-zero [INFO] Validating: p7-room-orchestrator [INFO] Validating: archon [INFO] Validating: channel-monitor [INFO] Validating: pmoves-yt [INFO] Validating: notebook-sync [INFO] Validating: supabase_service_role_key [INFO] Validating: supabase_jwt_secret [INFO] Validating: p7_control_token ====================================== |
|
Pushed the compose drift fix to cover you while you're on Jellyfin — commit Turned out the drift wasn't from the SSO service at all:
Still yours: 🤖 4090-claude |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pmoves/docker-compose.agents.yml`:
- Around line 194-199: Remove the credentialed fallback from NATS_URL in the
Compose configuration. Source the complete broker URL through the established
environment/secret wiring, require ARCHON_BUS_NATS_URL to be provided, and fail
fast when it is unset; do not introduce any hardcoded username or password.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 80cf7b0d-9742-4355-a16e-e7e646e91b1e
📒 Files selected for processing (1)
pmoves/docker-compose.agents.yml
| # NATS_URL pinned to the in-network broker (documented default creds — | ||
| # feedback_nats_creds_convention). Two prior sources delivered a | ||
| # HOST-shaped value (localhost) into the container: env.tier-agent after | ||
| # a prod-bundle materialization, and a duplicate ${NATS_URL} entry below | ||
| # (last list entry wins). Broke archon's broker connect 2026-07-24. | ||
| - NATS_URL=${ARCHON_BUS_NATS_URL:-nats://nats:pmoves@nats:4222} |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Remove the committed NATS credential fallback.
Line 199 hardcodes nats:pmoves. If ARCHON_BUS_NATS_URL is unset, Archon uses a known credential or diverges from the broker credentials configured through NATS_USER/NATS_PASSWORD in pmoves/docker-compose.yml. Source the complete URL through the environment/secret mechanism and fail fast when it is unavailable.
As per path instructions, committed Compose configuration must keep secrets out of configuration files. Based on learnings, agent NATS settings should use the established environment/secret wiring rather than a credentialed default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@pmoves/docker-compose.agents.yml` around lines 194 - 199, Remove the
credentialed fallback from NATS_URL in the Compose configuration. Source the
complete broker URL through the established environment/secret wiring, require
ARCHON_BUS_NATS_URL to be provided, and fail fast when it is unset; do not
introduce any hardcoded username or password.
Sources: Path instructions, Learnings
Docker Hardening ValidationHardening Validation ReportValidated: Sat Jul 25 23:03:36 UTC 2026Services CheckedPMOVES.AI Docker Hardening Validation[INFO] Checking: pmoves/docker-compose.hardened.yml [INFO] Validating: hi-rag-gateway-v2 [INFO] Validating: extract-worker [INFO] Validating: langextract [INFO] Validating: presign [INFO] Validating: render-webhook [INFO] Validating: retrieval-eval [INFO] Validating: pdf-ingest [INFO] Validating: jellyfin-bridge [INFO] Validating: invidious-companion-proxy [INFO] Validating: ffmpeg-whisper [INFO] Validating: media-video [INFO] Validating: media-audio [INFO] Validating: hi-rag-gateway-v2-gpu [INFO] Validating: hi-rag-gateway-gpu [INFO] Validating: deepresearch [INFO] Validating: supaserch [INFO] Validating: publisher-discord [INFO] Validating: mesh-agent [INFO] Validating: nats-echo-req [INFO] Validating: nats-echo-res [INFO] Validating: comfy-watcher [INFO] Validating: grayjay-plugin-host [INFO] Validating: agent-zero [INFO] Validating: p7-room-orchestrator [INFO] Validating: archon [INFO] Validating: channel-monitor [INFO] Validating: pmoves-yt [INFO] Validating: notebook-sync [INFO] Validating: supabase_service_role_key [INFO] Validating: supabase_jwt_secret [INFO] Validating: p7_control_token ====================================== |
…2188) * test(topology): add pending xfail for PMOVES_NETWORKS wiring gap (design plan, no fix yet) Fast-follow investigation for #2183. TopologyContext.from_env() reads PMOVES_NETWORKS, but nothing sets it — no Compose file or bootstrap script populates the var for any of the ~90 services in pmoves/docker-compose.yml, so has_external_egress()/on_network() always see an empty set in production. Currently dormant (no consumers yet) but must be wired before anything routes on it. This PR does NOT implement the wiring — see the PR description for why and for the design plan. It adds a strict-xfail pending test (test_topology_networks_wiring.py) that inspects the real source docker-compose.yml and will start passing (turning the xfail into a hard failure, per strict=True) once a real fix lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(topology): wire PMOVES_NETWORKS into all 93 networked services Closes the dormant #2183 gap: topology.TopologyContext.from_env() reads PMOVES_NETWORKS to populate docker_networks (backing on_network() and has_external_egress()), but nothing set it — every deployed service saw docker_networks=frozenset() and has_external_egress()=False. - scripts/inject_pmoves_networks.py: idempotent ruamel.yaml (0.19.1, matching split_compose.py) injector that mirrors each service's networks: list into a PMOVES_NETWORKS=<comma-joined> entry in its own environment: block. Mutates the SOURCE docker-compose.yml (both the default 'make up' path and the overlay path must agree). --check mode for the drift gate. (ruamel normalized 3 services that had non-standard 6-space sequence indent to the file's 4-space style.) - docker-compose.yml: 93 services wired; overlays regenerated via compose-split. - Makefile: compose-networks (inject + split) + compose-networks-check (drift gate). - hardening-validation.yml: run the wiring drift gate in CI before the split gate. - test_topology_networks_wiring.py: xfail(strict) -> passing regression guard. Verified: 8 passed (2 wiring cases now pass); compose-networks-check OK (93 in sync, idempotent); structural YAML valid; cipher-api mirrors all 4 networks. Sequencing note: best landed after #2221 (SSO) to avoid compose-overlay conflicts; provides the network-awareness primitive the SSO network-segmentation follow-up needs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-doc room plan (#2234) 06_linkedin_profile.md — reframed founder-only → DUAL (founder + Applied AI Architect), per notes2.md: - Headline: new primary (Applied AI Architect + founder + open-to-roles); founder headline kept as alt. - About: rewritten to lead with an availability statement + plain-language 'what I do', PMOVES specifics as support (2,108/2,600). - New §7 PMOVES→employer-language translation table + lead line. - Automation fabric represented: 34 n8n flows + ActivePieces (Experience bullets, Featured Item 6, Skills cluster, SEO keywords). - Filled pending Featured links to real repo docs (AGNOTE4482, Grand Convergence); investor/financial link left as an explicit operator TODO (keep private). 07_linkedin_living_doc_room.md (NEW) — design plan for the profile as a living-doc 'room' on pmoves.ai proper (non-CF): Remotion walkthrough (a2ui renderer) + PreTeXt technical panels + website/rooms design tokens, hosted behind the #2221 Traefik edge. Scope-only; open decisions (host, PreTeXt vs MyST/Quarto, public-vs-gated) listed for sign-off before any build. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2237) Living-doc room (plan 07_...md), Phase 2: a self-contained, host-agnostic static page rendering the persona (Phase-1 content model). Editorial treatment: - Dual-frequency palette (warm=beats, cool=code — 'same person, different frequency'), theme-aware light+dark, responsive. - Orbitron display (website design language) inlined as base64 woff2 (11.8KB) — no external font fetch; works on any host + offline. - Waveform/signal hero motif (canvas, reduced-motion aware) evoking BPM/Geometry Bus; scroll reveals. - Sections: plain-language lead (employer-first), highlights, skills clusters, featured (filled links + investor/soundcloud TODOs), PMOVES→employer table, automation fabric, Remotion + PreTeXt 'coming' slots, closer/connect. Deployable behind the #2221 Traefik edge (non-CF). Preview rendered + reviewed. Next: Phase 3 Remotion walkthrough, Phase 4 PreTeXt panels, Phase 5 host cutover. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…el (#2238) Living-doc room (plan 07_...md), Phase 4: authored technical panel content in PreTeXt (the skill the room is meant to show). - pmoves/rooms/persona/pretext/: buildable PreTeXt project (source/main.ptx + project.ptx + publication) — 'CHIT & the Metal-Organic Framework: A Structural Isomorphism': the egress gate as set membership (pmoves_external ∈ N(s), ties to the PMOVES_NETWORKS wiring), the MOF isomorphism map Φ, and CGP {δ,Hz,κ,A,F} as a state vector. Build VERIFIED (pretext-cli 2.45.0: 'pretext build web' → HTML+math). - README.md: build recipe + deploy wiring (Phase 4b: build into the room's served path behind the #2221 Traefik edge; MathJax loads on pmoves.ai, not in the CSP Artifact preview). - index.html: PreTeXt slot 'planned' → 'authored', links to source/main.ptx. - .gitignore: pretext output/ (generated; rebuild on deploy). Next: Phase 3 Remotion walkthrough · Phase 4b render+embed on the deployed host. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Wires the persona living-doc for public serving behind the merged Traefik edge (#2221), as the rendered static site (a2ui shell + PreTeXt + Remotion) — NOT the OpenRoom operator desktop, which stays private. - docker-compose.persona.yml — hardened unprivileged-nginx persona-room service; Traefik labels for Host(persona.pmoves.ai), PUBLIC (no forward-auth middleware), on pmoves_external. (Compose edit via Known Road: handoff brief below.) - config/nginx/persona.conf — static serve :8080, security headers, /healthz. - Makefile: persona-render (PreTeXt + Remotion → rooms/persona/dist/), up-persona, down-persona, persona-health. - .gitignore — rooms/persona/dist/ (rendered bundle, rebuilt on deploy). - docs/handoffs/persona-room-public-edge.md — edge-overlay brief + operator runbook. - 07_linkedin_living_doc_room.md — Phase 5 status table + runbook. - rooms/persona/index.html — walkthrough/PreTeXt slots wired to served paths (walkthrough.mp4, pretext/), graceful in the standalone preview. Operator does the DNS record (persona.pmoves.ai) + `make persona-render` + `make up-persona`. Verified: `docker compose config` OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2252) Wires the persona living-doc for public serving behind the merged Traefik edge (#2221), as the rendered static site (a2ui shell + PreTeXt + Remotion) — NOT the OpenRoom operator desktop, which stays private. - docker-compose.persona.yml — hardened unprivileged-nginx persona-room service; Traefik labels for Host(persona.pmoves.ai), PUBLIC (no forward-auth middleware), on pmoves_external. (Compose edit via Known Road: handoff brief below.) - config/nginx/persona.conf — static serve :8080, security headers, /healthz. - Makefile: persona-render (PreTeXt + Remotion → rooms/persona/dist/), up-persona, down-persona, persona-health. - .gitignore — rooms/persona/dist/ (rendered bundle, rebuilt on deploy). - docs/handoffs/persona-room-public-edge.md — edge-overlay brief + operator runbook. - 07_linkedin_living_doc_room.md — Phase 5 status table + runbook. - rooms/persona/index.html — walkthrough/PreTeXt slots wired to served paths (walkthrough.mp4, pretext/), graceful in the standalone preview. Operator does the DNS record (persona.pmoves.ai) + `make persona-render` + `make up-persona`. Verified: `docker compose config` OK. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…er, runbook Three findings addressed, one deferred with reasoning. FIXED — OS-aware port probe (Minor, but a real bug I introduced). The preflight matched `LISTENING`, which is Windows netstat's wording; Linux and BSD print `LISTEN`. So on the Linux hosts where the edge actually runs, the probe matched nothing, treated 80/443 as free, and protected nothing — the failure it exists to prevent. Now prefers `ss`, falls back to `netstat` matching BOTH spellings, and warns-and-continues if neither tool exists. Deviation from the review, which asked to fail when no probe is found: a missing probe is not evidence of a conflict, and hard-failing would block a legitimate bring-up on a minimal host. Compose still surfaces a bind error there. FIXED — router-label reader in edge-health (Minor). It grepped the whole `docker inspect` blob for the literal string, so any env var or comment containing "pmoves-forward-auth" counted, and it printed only the container name — useless on a container serving several routers. Now parses `traefik.http.routers.<name>.middlewares` labels and prints container + router + middleware chain. Positive-controlled against the same pipeline matching `.rule`, which correctly extracts `router=media` from pmoves-jellyfin. FIXED — operator documentation (Major). New docs/operations/EDGE_TRAEFIK_SSO_RUNBOOK.md: targets, required secrets and what breaks without each, network prerequisites and why app/api are not auto-created, port ownership and idempotent-rerun behaviour, run steps, rollback (including the acme volume and the rate-limit reason not to delete it), troubleshooting table, and an explicit Known Gaps section — stale containers missing labels, services with no edge presence, the double-prompt rule, why Jellyfin is deliberately excluded, and sso-auth's single-tenant limit. Also splits the preflight into its own `edge-preflight` target. It was flagged by checkmake for body length, and a prerequisite check that can be run without starting anything is more useful standalone. DEFERRED — CWE-319 on the ForwardAuth hop (Major). The finding is fair: the hop is `http://` and `authRequestHeaders` is unset, so Traefik forwards every incoming header to the verifier. Two reasons not to change it in this PR: 1. It is pre-existing configuration in config/traefik/dynamic.yml from #2221, not introduced here. This PR adds a make target; fixing middleware security in it couples an unrelated behaviour change to the bring-up. 2. Getting the allowlist wrong breaks login, and the happy path has never run even once — Traefik has never started on this fleet. Tightening auth transport before we can observe a working login means any breakage is indistinguishable from the many other things not yet wired. Right sequence is: land the edge, prove one login end to end, then tighten the hop with a working baseline to diff against. Recorded in the runbook's Known Gaps. Verified: all four targets resolve; edge-preflight passes on z890 (networks present, 80/443 free, dynamic.yml present); edge-health correctly reports no protected routers and now explains that external.yml does set them but the running containers predate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… target (#2411) * feat(edge): add up-edge — Traefik + sso-auth had overlays but no make target docker-compose.traefik.yml and docker-compose.sso.yml have existed since #2221 with NO make target to start them, so the edge was never brought up through the pipeline. The only reference to the traefik overlay anywhere in the Makefile is a comment. Observed on z890 2026-08-05: - no pmoves-traefik container has ever been created on this node - so media.pmoves.ai and auth.pmoves.ai resolve to nothing, and every traefik.* label in the fleet is inert - pmoves-sso-auth is Up 2 days (healthy) and protecting nothing: sweeping all running containers, ZERO carry a traefik.http.routers.*.middlewares label, so the pmoves-forward-auth middleware defined in config/traefik/dynamic.yml is referenced by no router - services therefore fall back to their own auth. Open Notebook's RemoteUserAuthMiddleware is fail-closed and needs the Traefik-injected X-Forward-Auth-Secret header, so a direct :8503 hit gets the password prompt exactly as designed Adds up-edge / down-edge / edge-health following the existing EXTERNAL_DC pattern, so the edge starts through COMPOSE_ENV_FILES like everything else rather than by raw compose. Both overlays go up together on purpose: the auth.pmoves.ai router is declared on the traefik container but points at `sso-auth@docker`, so Traefik without sso-auth is an edge whose own login route 404s. up-edge preflights before starting, because Traefik publishes 80/443 directly and a bind failure otherwise scrolls past in compose output: - creates pmoves_external if absent (same as up-external) - refuses to start if 80 or 443 is already LISTENING - refuses to start if config/traefik/dynamic.yml is missing, since the forward-auth middleware would silently not load The port check tests the OUTPUT, not the exit code. A pipeline ending in `head` exits 0 on no matches; writing it the obvious way reports every port as occupied. That bug produced a false "80/443 are in use" reading while diagnosing this, which is why the guard is written the awkward way. edge-health checks each component separately rather than with one combined docker ps filter — a combined filter passes when only ONE of the two is up, which is precisely the misleading state this fixes (sso-auth healthy behind a Traefik that does not exist). It also prints which routers use forward-auth, and flags that an empty [] binding list means the publish silently no-oped (the same-subnet ghost-adapter pattern on Windows). Verified on z890: - all three targets resolve; merged compose config validates - pmoves_external is internal=false so it can publish; 80/443 confirmed free; host port binding demonstrably works (grafana bound on 0.0.0.0:3002) - the substrate probe does flag a same-subnet ghost adapter on this host, a latent risk for Docker port binds — not currently manifesting, hence the note in edge-health rather than a hard block - edge-health output before bring-up correctly reports traefik NOT RUNNING, sso-auth healthy, and no routers on forward-auth NOT in this PR: attaching pmoves-forward-auth to any router. That is a security behaviour change on protected compose files, needs a compose: Known Road, and needs a decision per service — notably whether Jellyfin uses gateway forward-auth or the in-app OIDC plugin, since running both double-prompts. Prerequisites the operator must confirm (secrets manifest is zero-access, correctly): CLOUDFLARE_DNS_API_TOKEN for the ACME DNS-01 resolver, and SSO_FORWARD_AUTH_SECRET without which header trust can never engage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(edge): address Codex P2s — missing external networks, non-idempotent rerun P2 #1 (Makefile:4203) — up-edge only created pmoves_external, but the SSO overlay also declares pmoves_app and pmoves_api as external, so compose fails with network-not-found before Traefik ever starts on a host where the core stack has not run. Correct. Implemented, but NOT by creating them. Both are `internal: true` in the core stack (docker-compose.yml:5364,5373 — confirmed at runtime, internal=true). A plain `docker network create --driver bridge` would materialize them NON-internal, and the core stack would later attach to a network that no longer blocks egress. That turns a loud missing-network error into a quiet security regression, which is worse than the bug. up-edge now requires them to pre-exist and points at the core stack instead. pmoves_external is still created here — it is ours, non-internal by design, and up-external already does the same. P2 #2 (Makefile:4210) — the port preflight treated our own running Traefik as a conflict, so a second `make up-edge` to pick up config/env changes was blocked by its own listener. The target was effectively one-shot. Correct. Now skipped entirely when a pmoves-traefik container is already running; compose handles the recreate and port handoff. First bring-up (no traefik) still gets the full check. Verified: all three targets resolve; the missing-network branch fires on an absent network; with no traefik running the preflight still executes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(edge): CodeRabbit review — OS-aware port probe, router-label reader, runbook Three findings addressed, one deferred with reasoning. FIXED — OS-aware port probe (Minor, but a real bug I introduced). The preflight matched `LISTENING`, which is Windows netstat's wording; Linux and BSD print `LISTEN`. So on the Linux hosts where the edge actually runs, the probe matched nothing, treated 80/443 as free, and protected nothing — the failure it exists to prevent. Now prefers `ss`, falls back to `netstat` matching BOTH spellings, and warns-and-continues if neither tool exists. Deviation from the review, which asked to fail when no probe is found: a missing probe is not evidence of a conflict, and hard-failing would block a legitimate bring-up on a minimal host. Compose still surfaces a bind error there. FIXED — router-label reader in edge-health (Minor). It grepped the whole `docker inspect` blob for the literal string, so any env var or comment containing "pmoves-forward-auth" counted, and it printed only the container name — useless on a container serving several routers. Now parses `traefik.http.routers.<name>.middlewares` labels and prints container + router + middleware chain. Positive-controlled against the same pipeline matching `.rule`, which correctly extracts `router=media` from pmoves-jellyfin. FIXED — operator documentation (Major). New docs/operations/EDGE_TRAEFIK_SSO_RUNBOOK.md: targets, required secrets and what breaks without each, network prerequisites and why app/api are not auto-created, port ownership and idempotent-rerun behaviour, run steps, rollback (including the acme volume and the rate-limit reason not to delete it), troubleshooting table, and an explicit Known Gaps section — stale containers missing labels, services with no edge presence, the double-prompt rule, why Jellyfin is deliberately excluded, and sso-auth's single-tenant limit. Also splits the preflight into its own `edge-preflight` target. It was flagged by checkmake for body length, and a prerequisite check that can be run without starting anything is more useful standalone. DEFERRED — CWE-319 on the ForwardAuth hop (Major). The finding is fair: the hop is `http://` and `authRequestHeaders` is unset, so Traefik forwards every incoming header to the verifier. Two reasons not to change it in this PR: 1. It is pre-existing configuration in config/traefik/dynamic.yml from #2221, not introduced here. This PR adds a make target; fixing middleware security in it couples an unrelated behaviour change to the bring-up. 2. Getting the allowlist wrong breaks login, and the happy path has never run even once — Traefik has never started on this fleet. Tightening auth transport before we can observe a working login means any breakage is indistinguishable from the many other things not yet wired. Right sequence is: land the edge, prove one login end to end, then tighten the hop with a working baseline to diff against. Recorded in the runbook's Known Gaps. Verified: all four targets resolve; edge-preflight passes on z890 (networks present, 80/443 free, dynamic.yml present); edge-health correctly reports no protected routers and now explains that external.yml does set them but the running containers predate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Phase-1 SSO forward-auth gateway — "auth once, access all." A user who signs in once against Supabase GoTrue reaches the forked apps already logged in, with Supabase as the single IdP (reuses
JWT_SECRET/SUPABASE_JWT_SECRET, HS256 — no new session secret).This PR lands the service + edge + env (plan Tasks 1–7 + 10). The per-app fork integrations (Open-Notebook middleware, Jellyfin plugin) and live cross-app e2e (Tasks 8/9/11) are split into a tracked follow-up.
Architecture
pmoves-sso-auth(new FastAPI service):/auth/verify(HS256 local verify, no network on the hot path), GoTrue login (/login,/callback,/logout, GitHub + email/pw), and a minimal OIDC subset for the Jellyfin plugin.auth.*router has no forward-auth (login must be reachable);media.*(Jellyfin) has no forward-auth (its OIDC plugin owns auth).remote_user_guard, wger remote-user env; all four apps de-published from host ports soRemote-Usercan't be spoofed by bypassing the proxy.Security (hardened through review)
/auth/verifyrequiresrole=="authenticated"— rejects the public anon key (closed a Critical bypass)._safe_rd(login flow — rejects backslash/CR-LF-TAB/protocol-relative/@-authority/suffix-confusion) and an exact-match OIDCredirect_uriallowlist bound to the auth code (closed a HIGH flagged by two independent reviews)./logoutalways clears the cookie.Tests
/healthz→200 {"status":"ok"}.Deferred to follow-up (tracked in the plan)
RemoteUserMiddleware, Jellyfin OIDC plugin (entrypoint copy-on-start to survive the config bind mount; asset `oidc-rbac.zip @ v1.0.8), live e2e across all 4 apps.Remote-Userfrom anypmoves_apppeer (Phase-1 accepted; follow-up = network segmentation).JELLYFIN_OIDC_*+CLOUDFLARE_DNS_API_TOKEN(operator deploy step).Known-Road grants (compose/dockerfile) recorded in
known-roads.jsonl; handoff brief atpmoves/docs/handoffs/sso-gateway-forward-auth-2026-07-25.md.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Bug Fixes
Tests