Skip to content

feat(sso): SSO forward-auth gateway — auth once, access all (Phase 1: service + edge) - #2221

Merged
POWERFULMOVES merged 28 commits into
mainfrom
feat/sso-gateway
Jul 25, 2026
Merged

POWERFULMOVES merged 28 commits into
mainfrom
feat/sso-gateway

Conversation

@POWERFULMOVES

@POWERFULMOVES POWERFULMOVES commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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

Browser ──HTTPS──> Traefik (pmoves_external, TLS via Cloudflare DNS-01)
   │ Host: {auth,health,wealth,notebook,media}.pmoves.ai
   ▼ ForwardAuth ──subrequest──> pmoves-sso-auth  GET /auth/verify
   • valid pmoves_session cookie → 200 + Remote-User / X-Auth-Email / X-Auth-Subject
   • invalid/absent → 401 → 302 auth.pmoves.ai/login?rd=<orig>
  • 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.
  • Traefik edge: forward-auth middleware + per-app subdomain routers; auth.* router has no forward-auth (login must be reachable); media.* (Jellyfin) has no forward-auth (its OIDC plugin owns auth).
  • Apps: firefly remote_user_guard, wger remote-user env; all four apps de-published from host ports so Remote-User can't be spoofed by bypassing the proxy.

Security (hardened through review)

  • /auth/verify requires role=="authenticated" — rejects the public anon key (closed a Critical bypass).
  • Two open-redirect guards: _safe_rd (login flow — rejects backslash/CR-LF-TAB/protocol-relative/@-authority/suffix-confusion) and an exact-match OIDC redirect_uri allowlist bound to the auth code (closed a HIGH flagged by two independent reviews).
  • Fail-closed: GoTrue outages are caught (never a 500); /logout always clears the cookie.
  • Login template autoescaped; no secrets inlined anywhere.

Tests

  • 23 unit tests (jwt_verify / login / oidc) — asserting the role check, all seven open-redirect vectors, and fail-closed-on-outage.
  • Dockerfile verified live: image builds, container starts, /healthz200 {"status":"ok"}.

Deferred to follow-up (tracked in the plan)

  • Tasks 8/9/11: Open-Notebook 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.
  • Jellyfin id_token HS256-vs-JWKS signing decision.
  • Defense-in-depth: apps trust Remote-User from any pmoves_app peer (Phase-1 accepted; follow-up = network segmentation).
  • Firefly's separate tmpfs-500 prerequisite PR (needed before firefly serves).
  • Manifest/CHIT-vault registration of JELLYFIN_OIDC_* + CLOUDFLARE_DNS_API_TOKEN (operator deploy step).

Known-Road grants (compose/dockerfile) recorded in known-roads.jsonl; handoff brief at pmoves/docs/handoffs/sso-gateway-forward-auth-2026-07-25.md.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an SSO gateway for shared sign-in across supported services.
    • Enabled secure session validation with header-based access for Firefly, Wger, and Open Notebook.
    • Added Jellyfin-focused OpenID Connect support.
    • Integrated Traefik routing with TLS, cookies, and domain-based configuration.
  • Documentation

    • Added an architecture design and deployment plan for the SSO gateway.
  • Bug Fixes

    • Corrected agent service message-broker connection wiring.
  • Tests

    • Added automated coverage for SSO session validation, login/logout redirects, and OIDC endpoints.

POWERFULMOVES and others added 26 commits July 24, 2026 22:59
…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>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The 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

Layer / File(s) Summary
Gateway architecture and scope
docs/superpowers/specs/..., docs/superpowers/plans/...
Defines the ForwardAuth architecture, session trust model, application integrations, security constraints, phased tasks, and validation scope.
Session verification service
pmoves/services/sso-auth/{config.py,jwt_verify.py,app.py,requirements.txt}, pmoves/services/sso-auth/tests/test_jwt_verify.py
Adds lazy environment configuration, strict HS256 session verification, identity headers, health checking, dependencies, and JWT validation tests.
GoTrue login and session lifecycle
pmoves/services/sso-auth/{app.py,gotrue.py,templates/login.html}, pmoves/services/sso-auth/tests/test_login.py
Implements password and callback login, secure cookie issuance, redirect validation, logout, GoTrue error mapping, and lifecycle tests.
Jellyfin OIDC adapter
pmoves/services/sso-auth/{oidc.py,app.py}, pmoves/services/sso-auth/tests/test_oidc.py
Adds discovery, authorization, token, and userinfo endpoints with client, redirect, session, and token validation.
Traefik deployment and application routing
pmoves/docker-compose*.yml, pmoves/config/traefik/dynamic.yml, pmoves/docker-compose.external.yml, pmoves/env.shared.example, pmoves/services/sso-auth/Dockerfile
Adds container and proxy configuration, ForwardAuth middleware, TLS settings, protected service routers, Jellyfin routing, environment keys, and downstream integration tasks.

Archon broker wiring

Layer / File(s) Summary
In-network NATS configuration
pmoves/docker-compose.agents.yml
Sets Archon’s NATS endpoint to the credentialed in-network broker and removes the duplicate environment override.

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
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: a Phase 1 SSO forward-auth gateway at the service and edge layers.
Description check ✅ Passed The description covers scope, architecture, security, tests, and deferred work, so it is mostly complete despite not matching the template headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sso-gateway

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added compose Compose files / service Dockerfiles docs Documentation services Service source under pmoves/services/ labels Jul 25, 2026
@POWERFULMOVES

Copy link
Copy Markdown
Owner Author

Coordination note from the #2188 lane (4090) — not touching this branch, just a diagnostic while you're on the Jellyfin piece:

Validate Compose Files is red on the split-overlay drift gate, not on anything in the SSO code. The failing step is "Check split overlays are in sync with source (drift gate)" and the drifted file is:

 M pmoves/docker-compose.agents.yml

i.e. regenerating the overlays from this branch's docker-compose.yml produces a different docker-compose.agents.yml than what's committed — so a source change (looks like the new sso-auth service landing in the agents tier) went in without regenerating the split overlays.

Fix:

make -C pmoves compose-split
git add pmoves/docker-compose.*.yml && git commit -m "chore(compose): regenerate split overlays"

(The sso/traefik/external overlays you added aren't in the split set — those are fine hand-authored; it's specifically agents.yml that's generated from source and stale.)

Separately, CodeQL is also failing — that one's real code analysis on the new sso-auth service, your call.

Heads-up on sequencing: #2188 (wire PMOVES_NETWORKS into all 93 services) also touches docker-compose.yml + the 7 split overlays incl. agents.yml. It's ready-for-review and I'm holding it to land after this PR to avoid an overlay collision — so once you regenerate here, we're clean; when #2188 later merges you'd just re-run compose-split on rebase.

🤖 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>
@github-actions

Copy link
Copy Markdown
Contributor

Docker Hardening Validation

Hardening Validation Report

Validated: Sat Jul 25 12:18:24 UTC 2026

Services Checked

PMOVES.AI Docker Hardening Validation

[INFO] Checking: pmoves/docker-compose.hardened.yml

[INFO] Validating: hi-rag-gateway-v2
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: extract-worker
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: langextract
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: presign
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: render-webhook
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: retrieval-eval
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: pdf-ingest
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: jellyfin-bridge
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: invidious-companion-proxy
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: ffmpeg-whisper
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: media-video
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: media-audio
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: hi-rag-gateway-v2-gpu
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: hi-rag-gateway-gpu
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: deepresearch
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: supaserch
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: publisher-discord
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: mesh-agent
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: nats-echo-req
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: nats-echo-res
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: comfy-watcher
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: grayjay-plugin-host
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: agent-zero
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: p7-room-orchestrator
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: archon
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: channel-monitor
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: pmoves-yt
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: notebook-sync
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: supabase_service_role_key
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

[INFO] Validating: supabase_jwt_secret
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

[INFO] Validating: p7_control_token
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

======================================
Summary: 112 passed, 43 warnings, 0 errors

@POWERFULMOVES

Copy link
Copy Markdown
Owner Author

Pushed the compose drift fix to cover you while you're on Jellyfin — commit f8f20d3 (chore(compose): regenerate split overlays to clear drift gate).

Turned out the drift wasn't from the SSO service at all: docker-compose.agents.yml was stale vs source because #2220's archon in-network NATS_URL pin was in your source docker-compose.yml but never regenerated into the agents overlay. Pure make -C pmoves compose-split output — no SSO or source changes. compose-split-check is green locally now, so Validate Compose Files should clear on the next run.

⚠️ git pull before your next push so you don't collide with f8f20d3.

Still yours: CodeQL (real analysis on the new sso-auth code) — didn't touch it.

🤖 4090-claude

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 35b5ec8 and f8f20d3.

📒 Files selected for processing (1)
  • pmoves/docker-compose.agents.yml

Comment on lines +194 to +199
# 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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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

@github-actions

Copy link
Copy Markdown
Contributor

Docker Hardening Validation

Hardening Validation Report

Validated: Sat Jul 25 23:03:36 UTC 2026

Services Checked

PMOVES.AI Docker Hardening Validation

[INFO] Checking: pmoves/docker-compose.hardened.yml

[INFO] Validating: hi-rag-gateway-v2
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: extract-worker
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: langextract
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: presign
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: render-webhook
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: retrieval-eval
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: pdf-ingest
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: jellyfin-bridge
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: invidious-companion-proxy
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: ffmpeg-whisper
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: media-video
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: media-audio
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: hi-rag-gateway-v2-gpu
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: hi-rag-gateway-gpu
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: deepresearch
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: supaserch
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: publisher-discord
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: mesh-agent
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: nats-echo-req
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: nats-echo-res
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: comfy-watcher
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: grayjay-plugin-host
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: agent-zero
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: p7-room-orchestrator
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: archon
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: channel-monitor
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: pmoves-yt
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: notebook-sync
[PASS] Non-root user: 65532:65532
[PASS] Read-only filesystem
[PASS] All capabilities dropped
[PASS] No-new-privileges enabled
[WARN] No resource limits

[INFO] Validating: supabase_service_role_key
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

[INFO] Validating: supabase_jwt_secret
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

[INFO] Validating: p7_control_token
[WARN] No user directive
[WARN] No read_only directive
[WARN] No cap_drop: ["ALL"]
[WARN] No no-new-privileges
[WARN] No resource limits

======================================
Summary: 112 passed, 43 warnings, 0 errors

@POWERFULMOVES
POWERFULMOVES merged commit 8be4972 into main Jul 25, 2026
32 of 34 checks passed
@POWERFULMOVES
POWERFULMOVES deleted the feat/sso-gateway branch July 25, 2026 23:44
POWERFULMOVES added a commit that referenced this pull request Jul 26, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Jul 26, 2026
…-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>
POWERFULMOVES added a commit that referenced this pull request Jul 26, 2026
…#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>
POWERFULMOVES added a commit that referenced this pull request Jul 26, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Jul 27, 2026
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>
POWERFULMOVES added a commit that referenced this pull request Jul 27, 2026
#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>
POWERFULMOVES added a commit that referenced this pull request Aug 5, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Aug 5, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compose Compose files / service Dockerfiles config pmoves/config(s)/ changes docs Documentation services Service source under pmoves/services/

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants