diff --git a/.env.local.example b/.env.local.example index a51caa8..1ce4b6b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -65,9 +65,30 @@ SMTP_FROM_NAME= # the constant. Each frontend's .env.* file still pins its own # AUTH_ZITADEL_ID (the one its sign-in flow uses). See the "Zitadel # bearer authentication" section of AGENTS.md for the full shape. +# +# For LOCAL development this points at the Zitadel in this repo's compose +# stack, not the production instance. Bring it up and provision the app: +# +# docker compose up -d --wait zitadel-db zitadel zitadel-login zitadel-proxy +# # then, from cdcf-infra/auth, with .env.local.cdcf-website carrying +# # (per-property file — every property has its own local Zitadel, and a +# # shared .env.local silently provisions into whichever was configured last) +# # ZITADEL_ISSUER=http://localhost:8090 +# # ZITADEL_INTERNAL_URL=http://127.0.0.1:8090 +# # ZITADEL_PAT_FILE=/cdcf-website/.zitadel-data/automation-user.pat +# ENV_FILE=.env.local.cdcf-website ./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website +# +# That run prints AUTH_ZITADEL_ID, AUTH_ZITADEL_SECRET and the CDCF Org ID. +# If you change ZITADEL_PORT in .env, change the URL below to match — see +# docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md §4. AUTH_ZITADEL_ID= AUTH_ZITADEL_SECRET= -AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org +AUTH_ZITADEL_ISSUER=http://localhost:8090 + +# The LOCAL CDCF Org ID from the provisioning run above — not the production +# one. Read by lib/auth.ts:69. A wrong value fails sign-in in a way that looks +# like a credentials problem, so it is worth checking first. +AUTH_ZITADEL_ORG_ID= # Auth.js session encryption key. Generate per-environment with: # openssl rand -base64 32 diff --git a/.github/workflows/test-worker.yml b/.github/workflows/test-worker.yml index 97f7951..3ab26a1 100644 --- a/.github/workflows/test-worker.yml +++ b/.github/workflows/test-worker.yml @@ -18,12 +18,14 @@ on: paths: - 'scripts/cdcf_queue_worker*' - 'scripts/tests/**' + - 'docker-compose.yml' - '.github/workflows/test-worker.yml' push: branches: [main] paths: - 'scripts/cdcf_queue_worker*' - 'scripts/tests/**' + - 'docker-compose.yml' - '.github/workflows/test-worker.yml' concurrency: diff --git a/AGENTS.md b/AGENTS.md index e8ac8fe..548cfc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,10 @@ -# CLAUDE.md +# AGENTS.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for AI coding agents working in this repository. Nothing here is +specific to one vendor — `CLAUDE.md` and `GEMINI.md` are symlinks to this +file, so whichever agent reads its own conventional filename gets these same +instructions. Add a new symlink rather than a second copy if another tool +needs a different name. ## Build & Development Commands @@ -326,7 +330,7 @@ Required in `.env.local` (Next.js) or `.env` (Docker Compose): - `WP_APP_USERNAME`, `WP_APP_PASSWORD` — WordPress Application Password (used by the Python client) - `WP_PREVIEW_SECRET` — Shared secret for preview + revalidation - `WP_DB_ROOT_PASSWORD`, `WP_DB_NAME`, `WP_DB_USER`, `WP_DB_PASSWORD` — Database config -- `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`, `AUTH_ZITADEL_ISSUER`, `AUTH_SECRET` — Auth.js v5 OIDC client config (see `lib/auth.ts`). The client_id/secret come from cdcf-infra's `setup-zitadel.sh --provision-cdcf-website` handoff; the issuer is `https://auth.catholicdigitalcommons.org`; `AUTH_SECRET` is generated per-env via `openssl rand -base64 32`. Each frontend (prod vs non-prod) pins **its own** `AUTH_ZITADEL_ID` (the one its sign-in flow uses). On the **shared** WordPress backend, `wp-config.php` must define `CDCF_ZITADEL_EXPECTED_AUD` as a **comma-separated allow-list of BOTH client IDs** — see [Zitadel bearer authentication](#zitadel-bearer-authentication) above for the full shape. Setting only one client ID there would 403 tokens minted by the other frontend. +- `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`, `AUTH_ZITADEL_ISSUER`, `AUTH_ZITADEL_ORG_ID`, `AUTH_SECRET` — Auth.js v5 OIDC client config (see `lib/auth.ts`). The client_id/secret come from cdcf-infra's `setup-zitadel.sh --provision-cdcf-website` handoff. The issuer is environment-dependent: deployed environments (staging/production) use `https://auth.catholicdigitalcommons.org`; LOCAL development uses `http://localhost:8090`, the Zitadel in this repo's own compose stack — see the README's "Local Identity Provider (Zitadel)" section. `AUTH_ZITADEL_ORG_ID` is the CDCF Org ID (read by `lib/auth.ts:69`). It is **optional**: unset, `buildOrgScope()` contributes no scope and Zitadel authorizes any instance-wide user, so CDCF Org members still sign in. Set it to scope authentication to the CDCF Org — that routes registrations into it and rejects sign-ins from sibling-property Orgs and from the umbrella IAM admin. A value from the wrong instance (the production Org ID against a local Zitadel, say) does fail sign-in, in a way that looks like a credentials problem. `AUTH_SECRET` is generated per-env via `openssl rand -base64 32`. Each frontend (prod vs non-prod) pins **its own** `AUTH_ZITADEL_ID` (the one its sign-in flow uses). On the **shared** WordPress backend, `wp-config.php` must define `CDCF_ZITADEL_EXPECTED_AUD` as a **comma-separated allow-list of BOTH client IDs** — see [Zitadel bearer authentication](#zitadel-bearer-authentication) above for the full shape. Setting only one client ID there would 403 tokens minted by the other frontend. - Docker Compose reads `.env` not `.env.local` for variable substitution ## Deployment @@ -353,3 +357,27 @@ gh run view --json jobs \ ``` The scp upload step occasionally fails transiently with `kex_exchange_identification: read: Connection reset by peer` (VPS SSH rate-limit after back-to-back deploys) — just re-run. + + + + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md index b5d04ba..32865a5 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,144 @@ Edit `.env.local`: | `WP_DB_USER` | WordPress database user (default: `wordpress`) | | `WP_DB_PASSWORD` | WordPress database password | +### Local Identity Provider (Zitadel) + +Local sign-in runs against a Zitadel in this repo's compose stack — **not** +the production instance at `auth.catholicdigitalcommons.org`. + +```bash +docker compose up -d --wait zitadel-db zitadel zitadel-login zitadel-proxy +``` + +`--wait` blocks until every service passes its healthcheck. Without it the +command returns while Zitadel is still migrating, and the provisioning run +below fails on a PAT file that does not exist yet. + +Four services, mirroring how production is assembled: + +| Service | Role | +| --------------- | --------------------------------------------------------------------------- | +| `zitadel-db` | Zitadel's own PostgreSQL — the stack's `db` is MariaDB | +| `zitadel` | the backend; publishes no host port | +| `zitadel-login` | the v2 sign-in UI, served under `/ui/v2/login` | +| `zitadel-proxy` | nginx owning `8090`, routing `/ui/v2/login*` to the UI, rest to the backend | + +`zitadel-login` runs because production requires Login V2; a local stack on V1 +would exercise a sign-in UI no deployed environment serves. It is an interim +dependency — the intended end state is this project implementing sign-in +natively against the Zitadel APIs, at which point the container goes away. + +First boot runs migrations and writes **two** tokens: a machine-user token to +`.zitadel-data/automation-user.pat` (used by provisioning below) and +`.zitadel-data/login-client.pat` (used by `zitadel-login`). Both are +first-instance settings, so enabling Login V2 on a stack that was already +initialised requires removing `zitadel_db_data` first — otherwise the login UI +starts but fails every request unauthenticated. + +Provisioning the OIDC app is done from +[`cdcf-infra`](https://github.com/CatholicOS/cdcf-infra), which owns +Zitadel configuration for every property — this repo adds no provisioning +script. From `cdcf-infra/auth`, in a **per-property** env file — +`.env.local.cdcf-website`, not the shared `.env.local` — carrying: + +```bash +ZITADEL_ISSUER=http://localhost:8090 +ZITADEL_INTERNAL_URL=http://127.0.0.1:8090 +ZITADEL_PAT_FILE=/cdcf-website/.zitadel-data/automation-user.pat +``` + +then: + +```bash +ENV_FILE=.env.local.cdcf-website \ + ./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website +``` + +All three of those values are specific to **this** repo's local Zitadel, and +every umbrella property runs its own on its own port — `martyrology-api` on +8080, this one on 8090. Sharing one `.env.local` across them means whichever +property you configured last wins, and the failure is silent rather than +loud: run `--provision-martyrology` while the file still points here and +Martyrology's project is created inside cdcf-website's Zitadel, because this +PAT is a valid IAM_OWNER for this instance. Keeping one file per property +makes that impossible. `ENV_FILE` overrides the default, and each run echoes +the instance it is about to touch: + +```text +[setup-zitadel] Target: local (issuer: http://localhost:8090, ...) +``` + +`--create-orgs` must come first — provisioning exits 13 without the CDCF Org. +Copy the printed `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET` and Org ID into +`.env.local`. + +Then confirm you are actually on the local instance — every other step can +pass while sign-in still silently uses production: + +1. Check where sign-in actually sends you, not what the file says. Auth.js + does not expose the issuer via `/api/auth/providers`, so read it off the + authorize redirect: + + ```bash + CSRF=$(curl -s -c /tmp/ck http://localhost:3000/api/auth/csrf \ + | python3 -c "import json,sys;print(json.load(sys.stdin)['csrfToken'])") + curl -s -b /tmp/ck -o /dev/null -D - -X POST \ + -d "csrfToken=$CSRF&callbackUrl=http://localhost:3000/" \ + http://localhost:3000/api/auth/signin/zitadel | grep -i '^location:' + ``` + + The `Location` must start `http://localhost:8090/oauth/v2/authorize`. If it + starts `https://auth.catholicdigitalcommons.org`, `AUTH_ZITADEL_ISSUER` did + not reach the running server — restart `npm run dev` after editing + `.env.local`. + +2. Sign in, then sign out through `/api/auth/zitadel-signout`, with the + browser devtools Network tab filtered on `catholicdigitalcommons`. Both + `lib/auth.ts`'s authorize/token calls and the sign-out redirect must + produce **no** requests to `auth.catholicdigitalcommons.org`. + +A stale `AUTH_ZITADEL_ISSUER` in a running dev server is the usual cause of a +green-looking setup that never left production. + +These Compose variables come from `.env` (Compose does not read `.env.local`): + +| Variable | Default | Notes | +| --------------------- | ---------------------------------- | ---------------------------------------------------- | +| `ZITADEL_PORT` | `8090` | 8080 collides with the LitCal and Martyrology stacks | +| `ZITADEL_MASTERKEY` | `MasterkeyNeedsToHave32Characters` | Exactly 32 chars, and never change it — see below | +| `ZITADEL_DB_PASSWORD` | `postgres` / `zitadel` | Local only | + +Changing `ZITADEL_MASTERKEY` after first boot makes existing instance data +undecryptable; recovery means removing the Zitadel services and their +database volume, then re-provisioning, which invalidates the client IDs in +`.env.local`. **Never change the masterkey on data you want to keep** — +scope the recovery to Zitadel only, not the whole stack (`docker compose +down -v` would also delete `db_data`, `redis_data` and `wordpress_data`, +wiping your local WordPress install): + +```bash +docker compose rm -sf zitadel zitadel-db +docker volume ls | grep zitadel # read the real name from this output +docker volume rm +``` + +Take the volume name from that listing rather than pasting a literal: the +`cdcf-website_` prefix comes from the Compose project name and differs if +you've overridden it, so a hard-coded `cdcf-website_zitadel_db_data` can +silently miss the volume you meant to remove — or match one you did not. + +`ZITADEL_DB_PASSWORD` has the same one-way property as the masterkey, for a +different reason: `POSTGRES_PASSWORD` initialises the Postgres role **only on +an empty volume**. Change it once `zitadel_db_data` exists and the role keeps +its old password, so Zitadel fails to authenticate against its own database +while the variable reads as correct. Recovery is the same sequence above — +stop the two services, remove the volume, bring the stack back up with the +intended password, and re-provision. + +Changing `ZITADEL_PORT` means updating +`AUTH_ZITADEL_ISSUER` in `.env.local` and the two `cdcf-infra` URLs above to +match. + ### Development #### Full Stack (Docker) diff --git a/docker-compose.yml b/docker-compose.yml index 34501dd..fba2d10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,174 @@ services: volumes: - redis_data:/data + # ── Local identity provider ──────────────────────────────────────────── + # Local development authenticates against THIS Zitadel, not the production + # one at auth.catholicdigitalcommons.org. See + # docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md. + # + # Its own Postgres: Zitadel requires PostgreSQL and this stack's `db` is + # MariaDB, so it cannot be shared the way martyrology-api shares its. + zitadel-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:-postgres} + POSTGRES_DB: zitadel + volumes: + - zitadel_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 5s + timeout: 3s + retries: 10 + + zitadel: + # PINNED. cdcf-infra/auth/setup-zitadel.sh calls the management API by + # versioned path (/zitadel.application.v2.ApplicationService/…), so a + # silent major bump breaks provisioning with no change in this repo. + image: ghcr.io/zitadel/zitadel:v4.15.0 + restart: unless-stopped + # The master key must be EXACTLY 32 chars and must never change: Zitadel + # encrypts instance data with it and cannot decrypt after a rotation. + # Losing it means deleting zitadel_db_data and re-provisioning, which + # invalidates the client IDs in .env.local. + command: start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}" + # The image is scratch-based; without uid 0 the PAT lands in the bind + # mount with ownership the host-run provisioning script cannot read. + user: "0" + # No published port. zitadel-proxy owns ${ZITADEL_PORT:-8090} and fronts + # both this backend and the v2 login UI on a single origin — the same + # shape production runs, so local reproduces its cookie/CSRF/Host + # behaviour instead of a dev-only two-origin variant. + expose: + - "8080" + environment: + # EXTERNALPORT must equal the published port above — issuer and + # discovery URLs are minted from these two. + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: ${ZITADEL_PORT:-8090} + ZITADEL_EXTERNALSECURE: "false" + ZITADEL_TLS_ENABLED: "false" + + # Points at zitadel-db above, NOT the stack's mariadb `db`. + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:-postgres} + # Both SSL modes must be `disable`: zitadel-db serves no TLS, and + # omitting these leaves Zitadel retrying TLS and failing during + # migration, before it ever answers a request. + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:-zitadel} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # Login V2, matching production (cdcf-infra auth/docker-compose.prod.yml). + # Local dev must exercise the same login UI production serves; running V1 + # here would mean sign-in bugs only ever surface after deploy. The v2 UI + # is the zitadel-login service below, reached through zitadel-proxy at + # /ui/v2/login. These URLs are where Zitadel sends the browser, so they + # must be the PUBLIC origin, not the internal service name. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login + ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/logout?post_logout_redirect= + ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/login?samlRequest= + + # These four are one unit. PATPATH alone names a file that is never + # written — without the machine user there is no token, and the + # host-run provisioning script has nothing to authenticate with. + ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-data/automation-user.pat + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: automation-user + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automation User + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z" + + # A SECOND machine user, distinct from the one above: the v2 login UI + # authenticates to the Zitadel API as this identity. Like the block + # above it only takes effect on an empty database, so switching a + # already-initialised stack to Login V2 requires removing + # zitadel_db_data — the flag alone will not mint this token. + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login V2 Client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat + volumes: + # Bind mount, not a named volume: the host-run cdcf-infra script must + # be able to read the PAT. + - ./.zitadel-data:/zitadel-data:delegated + depends_on: + zitadel-db: + condition: service_healthy + healthcheck: + # Zitadel's own readiness check, so dependents wait for migrations + # rather than merely for the port to open. + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 5s + retries: 20 + + # Zitadel Login V2 UI (Next.js). Serves /ui/v2/login/* — the sign-in + # screens end users and the admin console both land on. Version-pinned to + # the backend: mixing zitadel and zitadel-login versions is unsupported. + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:v4.15.0 + restart: unless-stopped + depends_on: + zitadel: + condition: service_healthy + environment: + # Internal API call over the compose network, not the public origin. + ZITADEL_API_URL: http://zitadel:8080 + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + # Written by the LOGINCLIENT first-instance block on the zitadel + # service. If this file is missing the UI starts but every request + # fails unauthenticated — the usual symptom of enabling Login V2 on a + # database that was initialised before the setting existed. + ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel-data/login-client.pat + # Zitadel selects the instance by Host header, so API calls from this + # container must present the public host rather than `zitadel:8080`. + CUSTOM_REQUEST_HEADERS: "Host:localhost:${ZITADEL_PORT:-8090}" + # Without this the UI omits email.verification.send_code on + # AddHumanUser, so signups land ACTIVE with an unverified email and no + # verification mail is ever queued. Matches production. + EMAIL_VERIFICATION: "true" + volumes: + # Read-only: this service only consumes login-client.pat. + - ./.zitadel-data:/zitadel-data:ro + expose: + - "3000" + + # Single public entry point for the Zitadel stack, mirroring the internal + # proxy production runs. Routes /ui/v2/login* to the v2 UI and everything + # else to the backend, so the login UI and the OIDC/API endpoints share one + # origin exactly as they do on auth.catholicdigitalcommons.org. + zitadel-proxy: + image: nginx:alpine + restart: unless-stopped + depends_on: + zitadel: + condition: service_healthy + zitadel-login: + condition: service_started + volumes: + - ./nginx/zitadel.conf:/etc/nginx/conf.d/default.conf:ro + ports: + # 8090, not 8080: martyrology-api and LiturgicalCalendarFrontend both + # default their local Zitadel to 8080, and a collision presents as an + # opaque bind failure. This must equal ZITADEL_EXTERNALPORT above. + - "127.0.0.1:${ZITADEL_PORT:-8090}:80" + healthcheck: + # 127.0.0.1, not localhost: nginx listens on IPv4 only, while localhost + # inside the container resolves to ::1 first and the probe would fail + # with "connection refused" against a perfectly healthy proxy. + test: + ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/debug/healthz"] + interval: 10s + timeout: 5s + retries: 10 + wordpress: image: wordpress:beta-7.0-php8.4-apache restart: unless-stopped @@ -147,3 +315,4 @@ volumes: db_data: redis_data: wordpress_data: + zitadel_db_data: diff --git a/docs/superpowers/plans/2026-08-17-local-zitadel-stack.md b/docs/superpowers/plans/2026-08-17-local-zitadel-stack.md new file mode 100644 index 0000000..6a4b6ae --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-local-zitadel-stack.md @@ -0,0 +1,620 @@ +# Local Zitadel Stack Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a local Zitadel to this repo's Docker Compose stack and make it the default identity provider for local development, so `cdcf-infra` #20 can remove the `http://localhost:3000` client from the production Zitadel. + +**Architecture:** Four new Compose services — `zitadel-db` (its own PostgreSQL, because the stack's `db` is MariaDB), `zitadel`, `zitadel-login` (the v2 sign-in UI production also runs) and `zitadel-proxy` (nginx, giving all of it one origin on `${ZITADEL_PORT:-8090}`) — with Zitadel writing two first-instance PATs into a bind-mounted `./.zitadel-data/`: `automation-user.pat` for provisioning and `login-client.pat` for the login UI. A host-run `cdcf-infra/auth/setup-zitadel.sh --target local` reads the former and provisions the CDCF app. No provisioning script is added to this repo. + +> **Amendment (post-implementation):** this plan was written for a two-service, Login V1 stack. Login V2 was added afterwards to match production, which turned it into four services and moved the published port from `zitadel` to `zitadel-proxy`. The Global Constraints below are current; the literal compose and bats snippets inside Task 1 and Task 2 are **not** — they predate the change. Read `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md` §3.2–§3.3 and the committed `docker-compose.yml` / `scripts/tests/zitadel_compose.bats` for the shipped shape. + +**Tech Stack:** Docker Compose, `ghcr.io/zitadel/zitadel:v4.15.0`, `postgres:16-alpine`, bats-core (`scripts/tests/*.bats`), Auth.js v5 (already present). + +**Spec:** `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md` + +## Global Constraints + +Copied verbatim from the spec. Every task's requirements implicitly include these. + +- Zitadel image is **pinned**: `ghcr.io/zitadel/zitadel:v4.15.0`. Never `:latest` — `cdcf-infra`'s script calls the management API by versioned path. +- `zitadel-db` is `postgres:16-alpine`, a **separate** service. The stack's `db` is `mariadb:11` and cannot host Zitadel. +- Published port: `127.0.0.1:${ZITADEL_PORT:-8090}:8080`. Default is **8090, not 8080** — `martyrology-api` and `LiturgicalCalendarFrontend` both use 8080. +- `ZITADEL_EXTERNALPORT` must equal the published host port, derived from the same `${ZITADEL_PORT:-8090}` expression. +- **One port, four places** (spec §4): `ZITADEL_PORT` is the only knob, but its value is restated in `ZITADEL_EXTERNALPORT`, `cdcf-infra`'s `ZITADEL_ISSUER` / `ZITADEL_INTERNAL_URL`, and `.env.local.example`'s `AUTH_ZITADEL_ISSUER`. Task 1 and Task 2 each add a test pinning an agreement that nothing else validates. +- Both Postgres SSL modes are `disable`: `ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE` and `..._USER_SSL_MODE`. +- `ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true"` — Login V2, matching production. Requires the `zitadel-login` container AND the four `ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_*`/`LOGINCLIENTPATPATH` settings that mint its `login-client.pat`; the flag alone yields a redirect to `/ui/v2/login` that nothing serves. The container is interim — it goes away once cdcf-website implements sign-in natively against the Zitadel APIs. +- `zitadel-proxy` owns the published port; `zitadel` publishes none. In `nginx/zitadel.conf` both `Host` and `X-Forwarded-Host` must be `$http_host` — `$host` drops the port and Auth.js then fails discovery on an issuer mismatch. +- `ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-data/automation-user.pat`, plus all three `ZITADEL_FIRSTINSTANCE_ORG_MACHINE_*` settings. `PATPATH` alone writes nothing. +- `user: "0"` on the `zitadel` service, or the PAT is unreadable from the host. +- Master key must be **exactly 32 characters** and stable forever; changing it makes existing instance data undecryptable. +- `ZITADEL_DB_PASSWORD` is equally write-once: `POSTGRES_PASSWORD` initialises the role only on an empty volume, so changing it after `zitadel_db_data` exists breaks Zitadel's database auth. Same recovery — remove the volume and re-provision. +- Compose reads `.env`, **not** `.env.local`. `ZITADEL_PORT`, `ZITADEL_MASTERKEY`, `ZITADEL_DB_PASSWORD` all come from `.env` or their defaults. +- `./.zitadel-data/` is already gitignored (`.gitignore:58`). Do not re-add it. + +--- + +### Task 1: Compose services, with a config test that pins the port derivation + +**Files:** + +- Create: `scripts/tests/zitadel_compose.bats` +- Modify: `docker-compose.yml` (add two services + one volume) +- Modify: `.github/workflows/test-worker.yml:17-26` (add `docker-compose.yml` to both `paths:` lists) +- Delete: `zitadel/` (stray, root-owned) + +**Interfaces:** + +- Consumes: nothing. +- Produces: Compose services `zitadel-db` and `zitadel`; named volume `zitadel_db_data`; the host-readable PAT at `./.zitadel-data/automation-user.pat`; the published endpoint `http://localhost:${ZITADEL_PORT:-8090}`. Task 2 depends on 8090 being the default. + +The test asserts against `docker compose config`, which resolves interpolation without starting containers. It passes `-f docker-compose.yml` explicitly so `docker-compose.override.yml` cannot change the result. + +- [ ] **Step 1: Write the failing test** + +Create `scripts/tests/zitadel_compose.bats`: + +```bash +#!/usr/bin/env bats +# +# Coverage for the local Zitadel services in docker-compose.yml. +# +# These assert the RESOLVED compose config (`docker compose config`), not the +# raw YAML, so ${ZITADEL_PORT:-8090} is evaluated the way Compose evaluates it. +# -f docker-compose.yml is explicit: docker-compose.override.yml is merged by +# default and would otherwise make the result depend on local overrides. +# +# The port cases are the point of this file. ZITADEL_PORT is restated in four +# places across two repos and nothing validates that they agree (spec §4); a +# published port that disagrees with ZITADEL_EXTERNALPORT yields issuer and +# discovery URLs that look right and do not resolve. + +setup() { + cd "$BATS_TEST_DIRNAME/../.." || return 1 + # The cases below invoke this through `run bash -c`, which starts a fresh + # shell that does NOT inherit shell functions. Without the export they + # fail with "compose_service_json: command not found" — a failure that + # reads like a broken compose file rather than a broken harness. + export -f compose_service_json +} + +# Emit the resolved config for one service as JSON. +compose_service_json() { + docker compose -f docker-compose.yml config --format json 2>/dev/null \ + | python3 -c "import sys,json;print(json.dumps(json.load(sys.stdin)['services']['$1']))" +} + +@test "zitadel: image is pinned to v4.15.0, never :latest" { + run bash -c "compose_service_json zitadel | python3 -c \"import sys,json;print(json.load(sys.stdin)['image'])\"" + [ "$status" -eq 0 ] + [ "$output" = "ghcr.io/zitadel/zitadel:v4.15.0" ] +} + +@test "zitadel-db: is its own postgres, not the stack's mariadb" { + run bash -c "compose_service_json zitadel-db | python3 -c \"import sys,json;print(json.load(sys.stdin)['image'])\"" + [ "$status" -eq 0 ] + [ "$output" = "postgres:16-alpine" ] +} + +@test "zitadel: default published port is 8090, not 8080" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +p=json.load(sys.stdin)['ports'][0] +print(f\\\"{p['published']}:{p['target']}\\\")\"" + [ "$status" -eq 0 ] + [ "$output" = "8090:8080" ] +} + +@test "zitadel: EXTERNALPORT tracks the published port under an override" { + run bash -c "ZITADEL_PORT=9099 compose_service_json zitadel | python3 -c \" +import sys,json +s=json.load(sys.stdin) +print(f\\\"{s['ports'][0]['published']}:{s['environment']['ZITADEL_EXTERNALPORT']}\\\")\"" + [ "$status" -eq 0 ] + [ "$output" = "9099:9099" ] +} + +@test "zitadel: both postgres SSL modes are disabled" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +print(e['ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE'], e['ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE'])\"" + [ "$status" -eq 0 ] + [ "$output" = "disable disable" ] +} + +@test "zitadel: the machine-user block that produces the PAT is complete" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +keys=['ZITADEL_FIRSTINSTANCE_PATPATH', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE'] +print('ok' if all(e.get(k) for k in keys) else 'missing') +print(e['ZITADEL_FIRSTINSTANCE_PATPATH'])\"" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "ok" ] + [ "${lines[1]}" = "/zitadel-data/automation-user.pat" ] +} + +@test "zitadel: Login V2 is disabled, since no zitadel-login service exists" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +print(json.load(sys.stdin)['environment']['ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED'])\"" + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +@test "zitadel: master key is exactly 32 characters" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json,re +c=json.load(sys.stdin)['command'] +c=' '.join(c) if isinstance(c,list) else c +m=re.search(r'--masterkey\s+\\\"?([^\\\" ]+)', c) +print(len(m.group(1)) if m else 'nomatch')\"" + [ "$status" -eq 0 ] + [ "$output" = "32" ] +} + +@test "zitadel: runs as uid 0 so the host can read the PAT" { + run bash -c "compose_service_json zitadel | python3 -c \"import sys,json;print(json.load(sys.stdin).get('user',''))\"" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./scripts/tests/bats/bin/bats scripts/tests/zitadel_compose.bats` +Expected: every case FAILs — `docker compose config` succeeds but has no `zitadel` key, so the python lookup raises `KeyError` and `compose_service_json` exits non-zero. + +- [ ] **Step 3: Add the two services and the volume to `docker-compose.yml`** + +Add under `services:` (placement next to `db` keeps storage services together): + +```yaml +# ── Local identity provider ──────────────────────────────────────────── +# Local development authenticates against THIS Zitadel, not the production +# one at auth.catholicdigitalcommons.org. See +# docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md. +# +# Its own Postgres: Zitadel requires PostgreSQL and this stack's `db` is +# MariaDB, so it cannot be shared the way martyrology-api shares its. +zitadel-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${ZITADEL_DB_PASSWORD:-postgres} + POSTGRES_DB: zitadel + volumes: + - zitadel_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d zitadel"] + interval: 5s + timeout: 3s + retries: 10 + +zitadel: + # PINNED. cdcf-infra/auth/setup-zitadel.sh calls the management API by + # versioned path (/zitadel.application.v2.ApplicationService/…), so a + # silent major bump breaks provisioning with no change in this repo. + image: ghcr.io/zitadel/zitadel:v4.15.0 + restart: unless-stopped + # The master key must be EXACTLY 32 chars and must never change: Zitadel + # encrypts instance data with it and cannot decrypt after a rotation. + # Losing it means deleting zitadel_db_data and re-provisioning, which + # invalidates the client IDs in .env.local. + command: start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}" + # The image is scratch-based; without uid 0 the PAT lands in the bind + # mount with ownership the host-run provisioning script cannot read. + user: "0" + ports: + # 8090, not 8080: martyrology-api and LiturgicalCalendarFrontend both + # default their local Zitadel to 8080, and a collision presents as an + # opaque bind failure. + - "127.0.0.1:${ZITADEL_PORT:-8090}:8080" + environment: + # EXTERNALPORT must equal the published port above — issuer and + # discovery URLs are minted from these two. + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: ${ZITADEL_PORT:-8090} + ZITADEL_EXTERNALSECURE: "false" + ZITADEL_TLS_ENABLED: "false" + + # Points at zitadel-db above, NOT the stack's mariadb `db`. + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:-postgres} + # Both SSL modes must be `disable`: zitadel-db serves no TLS, and + # omitting these leaves Zitadel retrying TLS and failing during + # migration, before it ever answers a request. + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:-zitadel} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # Login V1. Zitadel v4 can require the separate zitadel-login + # container; with no such service the authorize flow lands on a route + # nothing serves. Auth.js drives sign-in here, so V1 suffices. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "false" + + # These four are one unit. PATPATH alone names a file that is never + # written — without the machine user there is no token, and the + # host-run provisioning script has nothing to authenticate with. + ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-data/automation-user.pat + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: automation-user + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automation User + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z" + volumes: + # Bind mount, not a named volume: the host-run cdcf-infra script must + # be able to read the PAT. + - ./.zitadel-data:/zitadel-data:delegated + depends_on: + zitadel-db: + condition: service_healthy + healthcheck: + # Zitadel's own readiness check, so dependents wait for migrations + # rather than merely for the port to open. + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 5s + retries: 20 +``` + +Add to the existing `volumes:` block: + +```yaml +zitadel_db_data: +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./scripts/tests/bats/bin/bats scripts/tests/zitadel_compose.bats` +Expected: all 9 PASS. + +- [ ] **Step 5: Gate the new test on the file it tests** + +In `.github/workflows/test-worker.yml`, add `docker-compose.yml` to **both** `paths:` lists (the `pull_request` one at `:17-20` and the `push` one at `:24-26`), so editing compose runs these tests: + +```yaml +- "scripts/cdcf_queue_worker*" +- "scripts/tests/**" +- "docker-compose.yml" +- ".github/workflows/test-worker.yml" +``` + +Without this, `scripts/tests/**` only fires when a test changes — the compose file could regress untested. (Note the job is `continue-on-error: true`, so this reports rather than blocks; that is the existing repo posture, not a decision of this plan.) + +- [ ] **Step 6: Delete the stray `zitadel/` directory** + +It is untracked on `main` and holds only an empty, root-owned `nginx.conf/` **directory** — a bind-mount artifact. It is root-owned, so: + +```bash +sudo rm -rf zitadel/ +``` + +Do **not** touch the `feature/zitadel-integration` branch on `origin`, where `zitadel/nginx.conf` exists as a real file. + +- [ ] **Step 7: Verify the stack actually boots** + +The config test proves the file is right; it does not prove Zitadel starts. Run: + +```bash +docker compose up -d zitadel-db zitadel +docker compose ps zitadel-db zitadel # both healthy (allow ~60s for migrations) +ls -l .zitadel-data/automation-user.pat # exists and is host-readable +curl -sf http://localhost:8090/debug/healthz && echo OK +``` + +If `zitadel` restarts in a loop, read `docker compose logs zitadel`. The two most likely causes are covered by Step 3's comments: an SSL-mode omission (fails during migration) or an `EXTERNALPORT` mismatch. + +- [ ] **Step 8: Commit** + +```bash +git add docker-compose.yml scripts/tests/zitadel_compose.bats .github/workflows/test-worker.yml +git commit -m "feat(auth): add a local Zitadel to the compose stack + +Local development authenticates against the production Zitadel today, via a +localhost:3000 client registered there. This is the replacement that lets +cdcf-infra #20 remove that client. + +Its own postgres:16-alpine rather than the stack's db, which is mariadb:11 and +cannot host Zitadel. Port defaults to 8090 because martyrology-api and +LiturgicalCalendarFrontend both use 8080, and a third stack there means only +one runs at a time. + +The bats cases pin what nothing else validates: that the published port and +ZITADEL_EXTERNALPORT stay equal under a ZITADEL_PORT override, since a +mismatch mints issuer URLs that look correct and do not resolve." +``` + +--- + +### Task 2: Point local development at the local Zitadel + +**Files:** + +- Modify: `.env.local.example:70` (and the comment block at `:53-69`) +- Modify: `README.md` (after `### Environment Variables`, `:44`) +- Modify: `scripts/tests/zitadel_compose.bats` (add the cross-file agreement case) + +**Interfaces:** + +- Consumes: Task 1's `${ZITADEL_PORT:-8090}` default and the PAT path. +- Produces: `.env.local.example` with `AUTH_ZITADEL_ISSUER=http://localhost:8090` and a new `AUTH_ZITADEL_ORG_ID` key. No code reads anything new — `lib/auth.ts:69` already consumes `AUTH_ZITADEL_ORG_ID`. + +This is the task that actually retires the production localhost client. Leaving the example pointed at production means developers stay on it and `cdcf-infra` #20 can never remove the origin. + +- [ ] **Step 1: Write the failing test** + +Append to `scripts/tests/zitadel_compose.bats`: + +```bash +@test "env example: AUTH_ZITADEL_ISSUER matches the compose default port" { + port=$(compose_service_json zitadel | python3 -c " +import sys,json +print(json.load(sys.stdin)['ports'][0]['published'])") + run grep -E "^AUTH_ZITADEL_ISSUER=http://localhost:${port}$" .env.local.example + [ "$status" -eq 0 ] +} + +@test "env example: local dev does not point at the production Zitadel" { + run grep -E "^AUTH_ZITADEL_ISSUER=.*auth\.catholicdigitalcommons\.org" .env.local.example + [ "$status" -ne 0 ] +} + +@test "env example: AUTH_ZITADEL_ORG_ID is present for lib/auth.ts to read" { + run grep -E "^AUTH_ZITADEL_ORG_ID=" .env.local.example + [ "$status" -eq 0 ] +} +``` + +The first case is the "one port, four places" guard from the spec: it reads the port out of the resolved compose config and requires the env example to agree, so the two cannot drift silently. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `./scripts/tests/bats/bin/bats scripts/tests/zitadel_compose.bats` +Expected: the three new cases FAIL — `AUTH_ZITADEL_ISSUER` is still the production URL and `AUTH_ZITADEL_ORG_ID` does not exist. + +- [ ] **Step 3: Update `.env.local.example`** + +Replace line 70 and extend the comment block above it. The existing production/non-production `aud` guidance stays — it still applies to deployed environments: + +```bash +# For LOCAL development this points at the Zitadel in this repo's compose +# stack, not the production instance. Bring it up and provision the app: +# +# docker compose up -d zitadel-db zitadel +# # then, from cdcf-infra/auth, with .env.local carrying +# # ZITADEL_ISSUER=http://localhost:8090 +# # ZITADEL_INTERNAL_URL=http://127.0.0.1:8090 +# # ZITADEL_PAT_FILE=/cdcf-website/.zitadel-data/automation-user.pat +# ./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website +# +# That run prints AUTH_ZITADEL_ID, AUTH_ZITADEL_SECRET and the CDCF Org ID. +# If you change ZITADEL_PORT in .env, change the URL below to match — see +# docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md §4. +AUTH_ZITADEL_ID= +AUTH_ZITADEL_SECRET= +AUTH_ZITADEL_ISSUER=http://localhost:8090 + +# The LOCAL CDCF Org ID from the provisioning run above — not the production +# one. Read by lib/auth.ts:69. A wrong value fails sign-in in a way that looks +# like a credentials problem, so it is worth checking first. +AUTH_ZITADEL_ORG_ID= +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `./scripts/tests/bats/bin/bats scripts/tests/zitadel_compose.bats` +Expected: all 12 PASS. + +- [ ] **Step 5: Document the stack in `README.md`** + +Insert after the `### Environment Variables` section (`:44`), before `### Development` (`:57`): + +````markdown +### Local Identity Provider (Zitadel) + +Local sign-in runs against a Zitadel in this repo's compose stack — **not** +the production instance at `auth.catholicdigitalcommons.org`. + +```bash +docker compose up -d zitadel-db zitadel +``` + +First boot runs migrations and writes a machine-user token to +`.zitadel-data/automation-user.pat`. Provisioning the OIDC app is done from +[`cdcf-infra`](https://github.com/CatholicOS/cdcf-infra), which owns +Zitadel configuration for every property — this repo adds no provisioning +script. From `cdcf-infra/auth`, with `.env.local` carrying: + +```bash +ZITADEL_ISSUER=http://localhost:8090 +ZITADEL_INTERNAL_URL=http://127.0.0.1:8090 +ZITADEL_PAT_FILE=/cdcf-website/.zitadel-data/automation-user.pat +``` + +then: + +```bash +./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website +``` + +`--create-orgs` must come first — provisioning exits 13 without the CDCF Org. +Copy the printed `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET` and Org ID into +`.env.local`. + +These Compose variables come from `.env` (Compose does not read `.env.local`): + +| Variable | Default | Notes | +| --------------------- | ---------------------------------- | ---------------------------------------------------- | +| `ZITADEL_PORT` | `8090` | 8080 collides with the LitCal and Martyrology stacks | +| `ZITADEL_MASTERKEY` | `MasterkeyNeedsToHave32Characters` | Exactly 32 chars, and never change it — see below | +| `ZITADEL_DB_PASSWORD` | `postgres` / `zitadel` | Local only | + +Changing `ZITADEL_MASTERKEY` after first boot makes existing instance data +undecryptable; recovery means `docker compose down -v` and re-provisioning, +which invalidates the client IDs in `.env.local`. Changing `ZITADEL_PORT` +means updating `AUTH_ZITADEL_ISSUER` in `.env.local` and the two +`cdcf-infra` URLs above to match. +```` + +- [ ] **Step 6: Verify sign-in end to end** + +The tests pin configuration; only this proves the goal. With the stack up and `.env.local` filled from the provisioning run: + +1. `npm run dev` (or `docker compose up -d nextjs`), open `http://localhost:3000`. +2. Sign in. The browser should be redirected to `localhost:8090`, never to `auth.catholicdigitalcommons.org`. +3. Confirm the session carries the expected roles. +4. Sign out via `/api/auth/zitadel-signout`. +5. **The load-bearing check:** with devtools' network tab open across the whole flow, confirm no request reaches `auth.catholicdigitalcommons.org`. Every other step can pass while still silently using production. + +- [ ] **Step 7: Commit** + +```bash +git add .env.local.example README.md scripts/tests/zitadel_compose.bats +git commit -m "feat(auth): default local development to the local Zitadel + +Flips AUTH_ZITADEL_ISSUER from the production instance to localhost:8090 and +adds AUTH_ZITADEL_ORG_ID, which lib/auth.ts already reads but no example +documented. + +Defaulting rather than documenting an opt-in is the point: while the example +points at production, developers keep using the localhost:3000 client +registered there, and cdcf-infra #20 can never remove it. + +The new bats case reads the port out of the resolved compose config and +requires the env example to agree, so the two cannot drift." +``` + +--- + +### Task 3: Correct the stale OIDC plan document + +**Files:** + +- Modify: `docs/zitadel-oidc-plan.md` (`:14-16`, `:115`, `:158`, `:212-226`) + +**Interfaces:** + +- Consumes: nothing. +- Produces: nothing. Documentation only. + +Its "Production Deployment" section is the reason this is a task rather than a footnote: it instructs standing up Zitadel at `auth.catholicdigitalcommons.org`, an instance that already exists and is managed by `cdcf-infra`. Following it would create a second production identity provider. + +- [ ] **Step 1: Add a status banner directly under the `# Zitadel OIDC Integration Plan` heading** + +```markdown +> **Status (2026-08-17):** partially superseded. Phase 2.1–2.3 (Auth.js v5) is +> **done** — see `lib/auth.ts` and `app/api/auth/`. Local Zitadel setup is now +> `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md`, and OIDC +> app provisioning is automated by `cdcf-infra`'s +> `setup-zitadel.sh --provision-cdcf-website`. The WordPress OIDC/passkey work +> (§1.2) and WordPress bearer validation (§2.4) remain **deferred and +> accurate**. **Do not follow "Production Deployment" below** — see §Production +> Deployment for why. +``` + +- [ ] **Step 2: Replace the body of `## Production Deployment` (`:212-226`)** + +Delete the Plesk/Docker install instructions and the `ZITADEL_EXTERNAL_DOMAIN` block, and replace with: + +```markdown +**Superseded — do not follow the previous contents of this section.** + +The shared Zitadel at `auth.catholicdigitalcommons.org` already exists and is +owned by [`cdcf-infra`](https://github.com/CatholicOS/cdcf-infra), which +provisions the CDCF Website OIDC apps via +`./setup-zitadel.sh --target production --provision-cdcf-website`. This +section previously described standing up a _second_ instance; following it +would have created a competing production identity provider. + +For local development see the "Local Identity Provider (Zitadel)" section of +the README. +``` + +- [ ] **Step 3: Mark §1.1 and §1.3 superseded in place** + +Under `### 1.1 Add Zitadel to Docker Compose`, add: + +```markdown +> **Superseded** by `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md`. +> The shipped stack differs in three ways that matter: a dedicated +> `zitadel-db` (this stack's `db` is MariaDB, which Zitadel cannot use), a +> pinned `v4.15.0` image rather than `:latest`, and port 8090 rather than 8080. +``` + +Under `### 1.3 Zitadel Configuration (Manual, Post-Boot)`, add: + +```markdown +> **Superseded.** App creation is automated — see the README's "Local Identity +> Provider (Zitadel)" section. Creating the app by hand in the console produces +> a client `cdcf-infra` does not know about and will not converge. +``` + +- [ ] **Step 4: Mark Phase 2.1–2.3 done** + +Under `## Phase 2: Next.js Frontend Auth`, add: + +```markdown +> **2.1–2.3 are done.** `next-auth@5.0.0-beta.31` is installed, `lib/auth.ts` +> and `app/api/auth/[...nextauth]` exist, and `app/api/auth/zitadel-signout` +> handles RP-initiated logout. §2.4 (WordPress bearer validation) is still +> outstanding. +``` + +- [ ] **Step 5: Verify formatting passes the repo's own gates** + +```bash +npm run format:md +npm run lint:md +``` + +Expected: both clean. `format:md` rewrites in place; re-stage afterwards. + +- [ ] **Step 6: Commit** + +```bash +git add docs/zitadel-oidc-plan.md +git commit -m "docs: correct the stale Zitadel OIDC plan + +Its Production Deployment section instructed standing up Zitadel at +auth.catholicdigitalcommons.org. That instance exists and is managed by +cdcf-infra, so following the section would have created a second production +identity provider. + +Also marks Phase 2.1-2.3 done (Auth.js v5 shipped), and §1.1/§1.3 superseded by +the local stack design and by automated provisioning. The WordPress OIDC and +bearer-validation phases are genuinely outstanding and are left as they are." +``` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +| ------------------------------------------------ | --------------------------------------------------------------------------------------- | +| §3.1 `zitadel-db` | Task 1, Steps 1/3 (test + service) | +| §3.2 `zitadel` service + all seven failure notes | Task 1, Steps 1/3, each note a comment or a test case | +| §3 stray `zitadel/` directory | Task 1, Step 6 | +| §4 provisioning contract | Task 2, Steps 3/5 (env comment + README) | +| §4 "one port, four places" | Task 1 Step 1 (EXTERNALPORT override case) + Task 2 Step 1 (env-example agreement case) | +| §5 Next.js environment | Task 2, Step 3 | +| §6 documentation corrections | Task 3, all steps — one per table row | +| §7 verification 1–4 | Task 1, Step 7 | +| §7 verification 5–7 | Task 2, Step 6 | +| §8 out of scope | No task; nothing here touches `cdcf-infra` or WordPress | + +No gaps. + +**Placeholder scan:** none. Every step carries the literal YAML, bash, or markdown to apply. The one `` is a genuine per-machine absolute path, and it appears in prose the developer fills in, not in code this plan writes. + +**Type consistency:** `compose_service_json` is defined once in Task 1 Step 1 and reused by Task 2 Step 1's appended cases in the same file. Service names (`zitadel`, `zitadel-db`), the volume (`zitadel_db_data`), the PAT path (`/zitadel-data/automation-user.pat`), and the port default (`8090`) are identical everywhere they appear across all three tasks. + +**One departure from the spec, deliberate:** §7 says verification is manual only, on the grounds that an automated check would need a browser OIDC round-trip. That holds for the _flow_, and Task 1 Step 7 and Task 2 Step 6 keep it manual. It does not hold for the _configuration_ — `docker compose config` resolves interpolation without starting anything, so the port derivation, SSL modes, image pin, and machine-user block are cheaply testable. Given the spec itself flags that four restatements of one port have nothing validating them, leaving that untested would have been the wrong call. diff --git a/docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md b/docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md new file mode 100644 index 0000000..c2617fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md @@ -0,0 +1,184 @@ +# Local Zitadel stack for CDCF website development — design + +**Date:** 2026-08-17 +**Repos touched:** `cdcf-website`; `cdcf-infra` provisions into it, unchanged +**Blocks:** `cdcf-infra` issue #20 (`docs/superpowers/specs/2026-08-17-target-aware-oidc-provisioning-design.md`) +**Status:** design, pending implementation plan + +--- + +## 1. Why + +Local development of this repo authenticates against the **production** Zitadel. `.env.local.example:70` sets `AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org`, and the sign-in flow uses a `http://localhost:3000` redirect URI registered on the production instance (`cdcf-infra`'s `CDCF_FRONTEND_NONPROD_URLS`). + +That localhost client contradicts `CatholicOS/martyrology-api#26`, which settled that local development happens against a separate local Zitadel rather than a localhost client in the production instance — which is why LitCal and Martyrology have none. `cdcf-infra` #20 removes it. + +It cannot be removed first. Auth.js v5 is live here (`next-auth 5.0.0-beta.31`, `lib/auth.ts`, `app/api/auth/[...nextauth]`, `app/api/auth/zitadel-signout`), so the localhost client is **in active use**. Dropping it without a replacement would not relocate local sign-in, it would delete it. This stack is that replacement, and #20 waits on it. + +## 2. Decision + +**Add a local Zitadel to this repo's compose stack, provisioned by `cdcf-infra`'s `setup-zitadel.sh --target local`, and make it the default target for local development.** + +Making it the _default_ rather than an opt-in is the point. If `.env.local.example` keeps pointing at production, developers stay on the production client and its origin can never be removed — the problem is deferred, not solved. + +`martyrology-api`'s stack is the template: same image, same first-instance PAT convention, same host-run provisioning. One forced divergence, in §3.1. + +## 3. What gets added + +### 3.1 `zitadel-db` — and why it is not the existing `db` + +`martyrology-api` points Zitadel at its stack's existing Postgres. **This repo's `db` is `mariadb:11`, and Zitadel requires PostgreSQL**, so Zitadel gets its own `postgres:16-alpine` service and named volume, with a `pg_isready` healthcheck. This is the only place the Martyrology template cannot be copied, and it matches what `docs/zitadel-oidc-plan.md` §1.1 already proposed. + +### 3.2 `zitadel` + +`ghcr.io/zitadel/zitadel:v4.15.0` — **pinned**, matching `martyrology-api`. The existing plan doc says `:latest`; that is wrong for a stack whose management API `cdcf-infra/auth/setup-zitadel.sh` calls by versioned path (`/zitadel.application.v2.ApplicationService/…`, `/v2/users/…`). A silent major bump would break provisioning with no change in this repo. + +`depends_on: zitadel-db` healthy, with Zitadel's own `["CMD", "/app/zitadel", "ready"]` healthcheck so dependents wait for migrations rather than for the port to open. + +The load-bearing configuration: + +```yaml +# No published port — zitadel-proxy (§3.3) owns ${ZITADEL_PORT:-8090}. +expose: ["8080"] +command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}"' +user: "0" +environment: + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: ${ZITADEL_PORT:-8090} + ZITADEL_EXTERNALSECURE: "false" + ZITADEL_TLS_ENABLED: "false" + + # Points at §3.1's dedicated service, NOT the stack's mariadb `db`. + ZITADEL_DATABASE_POSTGRES_HOST: zitadel-db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${ZITADEL_DB_PASSWORD:-postgres} + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PASSWORD:-zitadel} + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # Login V2, as production. These are the URLs Zitadel sends the BROWSER + # to, so they carry the public origin, never the internal service name. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: "true" + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login + ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/logout?post_logout_redirect= + ZITADEL_SAML_DEFAULTLOGINURLV2: http://localhost:${ZITADEL_PORT:-8090}/ui/v2/login/login?samlRequest= + + # These four are one unit: the first three create the machine user, the + # PATPATH writes its token where §4's host-run script reads it. + ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-data/automation-user.pat + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: automation-user + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Automation User + ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z" + + # A SECOND machine user, for the v2 login UI (§3.3) — not the same token. + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME: login-client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME: Login V2 Client + ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z" + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat +volumes: ["./.zitadel-data:/zitadel-data:delegated"] +``` + +`ZITADEL_MASTERKEY` and `ZITADEL_DB_PASSWORD` are read by Compose from `.env`, not `.env.local` — Compose does not read the latter. + +The usual failure points, in the order they tend to bite: + +- **`ZITADEL_EXTERNALPORT` must track the published host port.** Zitadel mints issuer and discovery URLs from `EXTERNALDOMAIN`/`EXTERNALPORT`; if they disagree with what the browser reaches, sign-in fails at discovery with a URL that looks superficially correct. +- **Port default is 8090, not 8080.** Both `martyrology-api` and `LiturgicalCalendarFrontend` default their local Zitadel to `127.0.0.1:8080`. A third stack on 8080 means only one can run at a time, and the collision presents as an opaque bind failure. `ZITADEL_PORT` keeps it overridable. +- **The master key needs a value, and the same value forever.** It must be exactly 32 characters. Zitadel encrypts instance data with it and cannot decrypt after it changes, so a rotated or lost key means deleting the volume and re-provisioning — losing the client IDs `.env.local` was filled from. The default above keeps a fresh clone working; `ZITADEL_MASTERKEY` overrides it. +- **`ZITADEL_DB_PASSWORD` is also write-once.** `POSTGRES_PASSWORD` initialises the Postgres role only on an empty volume, so changing it after `zitadel_db_data` exists leaves the role on its old password and Zitadel failing to authenticate while the variable looks right. Recovery is the masterkey recovery: stop the two services, remove the volume, restart with the intended password, re-provision. +- **Both Postgres SSL modes must be `disable`.** `zitadel-db` is `postgres:16-alpine` with no TLS. Omitting `ADMIN_SSL_MODE`/`USER_SSL_MODE` leaves Zitadel attempting TLS and failing during migration, before it ever serves a request. +- **Login V2 is required, because production requires it.** `cdcf-infra`'s `auth/docker-compose.prod.yml` runs `ghcr.io/zitadel/zitadel-login:v4.15.0` with `LOGINV2_REQUIRED: 'true'`, so a local stack on V1 would exercise a sign-in UI that no deployed environment serves — every V2-specific bug would first appear after deploy. Enabling the flag alone is not enough: without the `zitadel-login` service the authorize flow redirects to `/ui/v2/login`, which nothing answers. The container is an **interim** dependency. `cdcf-infra` deliberately does not own a login UI on each property's behalf; the intended end state is that each property implements sign-in natively against the Zitadel APIs, at which point this service can be dropped. Until cdcf-website does that, it runs the same V2 UI production does. +- **The v2 UI needs its own machine user.** `ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_*` plus `LOGINCLIENTPATPATH` mint a **second** PAT (`login-client.pat`) that `zitadel-login` authenticates with — distinct from `automation-user.pat`, which is for host-run provisioning. Both are first-instance settings, so switching an already-initialised stack to Login V2 requires removing `zitadel_db_data`; flipping the flag on an existing database yields a login UI that starts but fails every request unauthenticated. +- **The machine-user block is what produces the PAT.** `PATPATH` alone names a file that is never written. Without the three `FIRSTINSTANCE_ORG_MACHINE_*` settings no machine user exists, `./.zitadel-data/automation-user.pat` never appears, and §4's provisioning run has nothing to authenticate with. +- **`user: "0"` is required for the PAT to be readable.** The Zitadel image is scratch-based; without it the token lands in the bind mount with ownership the host script cannot read. + +`./.zitadel-data/` is already gitignored (`.gitignore:58`). The stray `zitadel/` directory at the repo root is deleted: it is untracked on `main` and contains only an empty, root-owned `nginx.conf/` **directory** — a bind-mount artifact from a compose file that once referenced a file at that path. Two implementation notes follow from that. It is root-owned, so removal needs elevated privileges. And it is not quite historyless: `zitadel/nginx.conf` existed as a real file on the unmerged `feature/zitadel-integration` branch (still present on `origin`), which this deletion does not touch. + +### 3.3 `zitadel-login` and `zitadel-proxy` + +Login V2 is two services, not one. `zitadel-login` (`ghcr.io/zitadel/zitadel-login:v4.15.0`, version-pinned to the backend — mixing versions is unsupported upstream) serves `/ui/v2/login/*`, calls the API internally at `http://zitadel:8080`, and reads `login-client.pat` read-only. It also sets `EMAIL_VERIFICATION: "true"`, matching production: without it the UI omits `email.verification.send_code` on `AddHumanUser`, so signups land `ACTIVE` with an unverified email and no verification mail is ever queued. + +`zitadel-proxy` (`nginx:alpine`, config at `nginx/zitadel.conf`) is what makes the two look like one instance. It owns `127.0.0.1:${ZITADEL_PORT:-8090}` and routes `/ui/v2/login*` to the login UI and everything else to the backend, so the login pages and the OIDC/API endpoints share a single origin exactly as they do on `auth.catholicdigitalcommons.org`. The `zitadel` service consequently publishes **no** host port. + +Two things about the proxy are local-only and both were found by the stack failing: + +- **`Host` and `X-Forwarded-Host` must be `$http_host`, not `$host`.** Zitadel mints its issuer and every endpoint URL from the forwarded host, and nginx's `$host` strips the port. Either header left as `$host` produces `issuer: http://localhost` and Auth.js rejects sign-in with `"issuer" property does not match the expected value` against `AUTH_ZITADEL_ISSUER=http://localhost:8090`. `X-Forwarded-Host` takes precedence, so fixing only `Host` still fails. Production's config uses `$host` safely because it serves on 443, where the port is absent from the URL regardless. +- **`X-Forwarded-Proto` is `http`**, matching `ZITADEL_EXTERNALSECURE: "false"`. Copying production's `https` makes Zitadel mint `https://` URLs the browser cannot reach locally. + +The proxy's healthcheck targets `127.0.0.1`, not `localhost`: nginx listens on IPv4 only, and `localhost` resolves to `::1` first inside the container, so a healthy proxy reports unhealthy and `--wait` fails. + +## 4. Provisioning contract + +The bind-mounted PAT is the entire integration. Zitadel writes `automation-user.pat` into `./.zitadel-data/` on first boot; the **host-run** `cdcf-infra` script reads it. No script is added to this repo, and `cdcf-infra` needs no change — the same shape Martyrology already uses. + +From `cdcf-infra/auth`, in a per-property env file — `.env.local.cdcf-website`, **not** the shared `.env.local` — carrying: + +```bash +ZITADEL_ISSUER=http://localhost:8090 +ZITADEL_INTERNAL_URL=http://127.0.0.1:8090 +ZITADEL_PAT_FILE=/cdcf-website/.zitadel-data/automation-user.pat +``` + +then: + +```bash +ENV_FILE=.env.local.cdcf-website \ + ./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website +``` + +**Why a per-property file.** All three values above describe _this_ repo's local Zitadel, and every umbrella property runs its own — `martyrology-api` on 8080, `martyrology-frontend` with its own `.zitadel-data/`, this one on 8090. `setup-zitadel.sh` resolves `ENV_FILE="${ENV_FILE:-.env.local}"` per target, so a single shared `.env.local` is last-writer-wins across properties. The resulting failure is silent, not loud: running `--provision-martyrology` while the file still points here creates Martyrology's project inside cdcf-website's Zitadel, since this PAT is a valid IAM_OWNER for this instance and the call succeeds normally. The script does echo its target (`Target: local (issuer: http://localhost:8090, ...)`), but that is a log line, not a guard. One file per property removes the ambiguity without any change to `cdcf-infra`. + +`--create-orgs` is required first: `do_provision_cdcf_website` exits 13 if the CDCF Org is absent. The run prints `AUTH_ZITADEL_ID` and `AUTH_ZITADEL_SECRET` for this repo's `.env.local`. + +Note `ZITADEL_INTERNAL_URL` must be set explicitly: `setup-zitadel.sh` defaults it to `http://127.0.0.1:8080` for every target, which is the port this stack deliberately avoids (§3.2). + +**One port, four places.** `ZITADEL_PORT` above is the only knob, but its value is restated in `ZITADEL_ISSUER`, `ZITADEL_INTERNAL_URL`, and §5's `AUTH_ZITADEL_ISSUER`, and nothing validates that the four agree. Overriding `ZITADEL_PORT` without updating the other three points provisioning and Next.js at a port with no Zitadel behind it. Implementations should derive them from a single documented value; the `8090` written out below is that value's default, not an independent constant. + +Note this depends on #20's target-aware work for the `local` origin set (`http://localhost:3000`, devMode=true). Until #20 lands, a local run registers the production origins — harmless in a local instance, but not yet correct. The two land in order: this stack, then #20. + +## 5. Next.js environment + +`.env.local.example`: + +- `AUTH_ZITADEL_ISSUER` → `http://localhost:8090` (from the production URL), with a comment pointing at §4's command. This is the fourth restatement of `ZITADEL_PORT` — see §4's note; it must move whenever that does. +- `AUTH_ZITADEL_ORG_ID` needs the **local** CDCF Org ID, not the production one. It is consumed at `lib/auth.ts:69` and is easy to miss because sign-in fails in a way that looks like a credentials problem; the same provisioning run prints it. +- `AUTH_ZITADEL_ID` / `AUTH_ZITADEL_SECRET` stay blank in the example, filled from the run. + +## 6. Documentation corrections + +`docs/zitadel-oidc-plan.md` is corrected where it misleads, not rewritten. + +| Section | Status | Action | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Production Deployment (`:212-226`) | **Wrong and dangerous** — instructs standing up Zitadel at `auth.catholicdigitalcommons.org`. That instance exists and is managed by `cdcf-infra`; following this creates a second production IdP. | Replace with a pointer to `cdcf-infra`. | +| §1.1 Add Zitadel to Docker Compose | Superseded | Point at this design (separate `zitadel-db`, pinned image, port 8090). | +| §1.3 Zitadel Configuration (Manual, Post-Boot) | Superseded | Provisioning is automated by `--provision-cdcf-website`; replace the console walkthrough. | +| §1.2 WordPress OIDC plugin / passkey | Genuinely undone | Keep, marked deferred. | +| Phase 2.1–2.3 Auth.js v5 | **Done** | Mark done. | +| §2.4 WordPress bearer validation | Genuinely undone | Keep, marked deferred. | + +The doc's original motivation — passkey login to the WordPress admin — is untouched by this design and stays deferred. + +## 7. Verification + +This is developer infrastructure, and the honest verification is a documented manual sequence rather than an automated suite. An automated check would need a full browser OIDC round-trip in CI; that is not worth building here, and claiming coverage that does not exist would be worse than saying so. + +1. `docker compose up -d zitadel-db zitadel` → both healthy. +2. `./.zitadel-data/automation-user.pat` exists and is readable from the host. +3. The `cdcf-infra` run in §4 completes and prints app credentials. +4. The app exists in the local Zitadel with redirect `http://localhost:3000/api/auth/callback/zitadel` and devMode=true. +5. Sign in end-to-end at `localhost:3000`; confirm the session carries the expected roles. +6. Sign out via `app/api/auth/zitadel-signout`. +7. Confirm nothing in the flow reaches `auth.catholicdigitalcommons.org` — the point of the exercise. + +Step 7 is the one that actually proves the goal; the rest can pass while still silently using production. + +## 8. Out of scope + +- WordPress OIDC and passkey admin login (plan §1.2), and WP bearer validation (§2.4). +- Any change to `cdcf-infra/auth/setup-zitadel.sh`. Its target-aware work is #20, which this unblocks. +- Removing `http://localhost:3000` from the production Zitadel — that is #20's §4.1, and it happens only after developers are verified onto this stack. diff --git a/docs/zitadel-oidc-plan.md b/docs/zitadel-oidc-plan.md index f5f9c04..906332d 100644 --- a/docs/zitadel-oidc-plan.md +++ b/docs/zitadel-oidc-plan.md @@ -1,5 +1,16 @@ # Zitadel OIDC Integration Plan +> **Status (2026-08-17):** partially superseded. Phase 2.1–2.3 (Auth.js v5) is +> **done** — see `lib/auth.ts` and `app/api/auth/`. Local Zitadel setup is now +> `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md`, and OIDC +> app provisioning is automated by `cdcf-infra`'s +> `setup-zitadel.sh --provision-cdcf-website`. §2.4 (WordPress bearer +> validation) is **done** — `includes/auth/zitadel-bearer.php` ships and is +> loaded from `functions.php`; AGENTS.md is authoritative for its current +> behaviour, which has moved on from what §2.4 describes. Only the WordPress +> OIDC/passkey work (§1.2) remains **deferred and accurate**. **Do not follow +> "Production Deployment" below** — see §Production Deployment for why. + ## Context The CDCF website currently has no user authentication on the Next.js frontend, and WordPress uses its native username/password login. The goal is to add Zitadel as a centralized OIDC identity provider to: @@ -15,6 +26,11 @@ This plan covers both phases but **defers implementation** — it documents exac ### 1.1 Add Zitadel to Docker Compose +> **Superseded** by `docs/superpowers/specs/2026-08-17-local-zitadel-stack-design.md`. +> The shipped stack differs in three ways that matter: a dedicated +> `zitadel-db` (this stack's `db` is MariaDB, which Zitadel cannot use), a +> pinned `v4.15.0` image rather than `:latest`, and port 8090 rather than 8080. + **File:** `docker-compose.yml` Add two new services and one new volume: @@ -114,19 +130,17 @@ define('OIDC_REDIRECT_USER_BACK', true); ### 1.3 Zitadel Configuration (Manual, Post-Boot) -After `docker compose up`, access `http://localhost:8085/ui/console` and: +> **Superseded.** App creation is automated — see the README's "Local Identity +> Provider (Zitadel)" section. Creating the app by hand in the console produces +> a client `cdcf-infra` does not know about and will not converge. -1. **Create project** "CDCF" -2. **Create WordPress app** (Web, client_secret_post) - - Redirect URI: `http://localhost/wp-admin/admin-ajax.php?action=openid-connect-authorize` - - Post-logout URI: `http://localhost/wp-login.php` -3. **Create Next.js app** (Web, client_secret_post) — for Phase 2 - - Redirect URI: `http://localhost:3000/api/auth/callback/zitadel` - - Post-logout URI: `http://localhost:3000` - - Enable Dev Mode (allows HTTP redirects) -4. **Enable passkeys:** Settings > Login Behavior > Passwordless Type = "Allowed" -5. **Define roles** in the CDCF project: `admin`, `editor`, `member` -6. **Create user accounts** matching existing WordPress admin emails +Provisioning the CDCF project, apps, and roles is handled by running +[`cdcf-infra`](https://github.com/CatholicOS/cdcf-infra)'s +`./setup-zitadel.sh --target local --create-orgs --provision-cdcf-website` +against this stack's Zitadel. See the README's "Local Identity Provider +(Zitadel)" section for the full setup, including the required `.env.local` +variables and the printed `AUTH_ZITADEL_ID` / `AUTH_ZITADEL_SECRET` / Org ID +handoff. Do not create the app by hand in the console. ### 1.4 Environment Variables @@ -157,6 +171,11 @@ AUTH_SECRET= # openssl rand -base64 32 ## Phase 2: Next.js Frontend Auth +> **2.1–2.3 are done.** `next-auth@5.0.0-beta.31` is installed, `lib/auth.ts` +> and `app/api/auth/[...nextauth]` exist, and `app/api/auth/zitadel-signout` +> handles RP-initiated logout. §2.4 (WordPress bearer validation) is still +> outstanding. + ### 2.1 Install Auth.js v5 ```bash @@ -194,6 +213,15 @@ npm install next-auth@beta ### 2.4 WordPress Bearer Token Validation +> **Done, and the description below is stale.** This shipped as +> `wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php`, loaded +> from `functions.php`, with coverage in `tests/ZitadelBearerTest.php`. What +> shipped goes well beyond the sketch here — audience allow-listing before any +> network call, resolution by immutable `sub` claim with email only as a +> migration fallback, and Subscriber auto-provisioning. Treat AGENTS.md's +> "Zitadel bearer authentication" section as authoritative; the steps below +> are kept only as the original intent. + **File:** `wordpress/themes/cdcf-headless/functions.php` Add a `determine_current_user` filter (priority 20) that: @@ -211,22 +239,17 @@ Existing auth methods (cookies, Application Passwords) are checked first and rem ## Production Deployment -| Component | Domain | Notes | -| --------- | ------------------------------------ | ----------------------------------------------------------- | -| Zitadel | `auth.catholicdigitalcommons.org` | Docker or binary install on Plesk, TLS via Let's Encrypt | -| WordPress | `cms.catholicdigitalcommons.org` | Install OIDC plugin, configure endpoints to `auth.*` domain | -| Next.js | `staging.catholicdigitalcommons.org` | Set `AUTH_*` env vars, register callback URI in Zitadel | - -Production env overrides: +**Superseded — do not follow the previous contents of this section.** -```text -ZITADEL_EXTERNAL_DOMAIN=auth.catholicdigitalcommons.org -ZITADEL_EXTERNAL_PORT=443 -ZITADEL_EXTERNAL_SECURE=true -ZITADEL_ISSUER_URL=https://auth.catholicdigitalcommons.org -``` +The shared Zitadel at `auth.catholicdigitalcommons.org` already exists and is +owned by [`cdcf-infra`](https://github.com/CatholicOS/cdcf-infra), which +provisions the CDCF Website OIDC apps via +`./setup-zitadel.sh --target production --provision-cdcf-website`. This +section previously described standing up a _second_ instance; following it +would have created a competing production identity provider. -**Migration:** Create Zitadel users with matching emails for existing WP admins. The OIDC plugin's `OIDC_LINK_EXISTING_USERS` links them on first login. Application Passwords are unaffected. +For local development see the "Local Identity Provider (Zitadel)" section of +the README. --- @@ -234,7 +257,7 @@ ZITADEL_ISSUER_URL=https://auth.catholicdigitalcommons.org ### Phase 1 -1. `docker compose up` — verify Zitadel boots (`curl http://localhost:8085/debug/ready`) +1. `docker compose up` — verify Zitadel boots (`curl http://localhost:8090/debug/ready`) 2. Access Zitadel console, create project + apps, note client IDs/secrets 3. Set env vars, restart WordPress 4. Visit `http://localhost/wp-login.php` — verify "Login with OpenID Connect" button appears diff --git a/nginx/zitadel.conf b/nginx/zitadel.conf new file mode 100644 index 0000000..f961deb --- /dev/null +++ b/nginx/zitadel.conf @@ -0,0 +1,79 @@ +# Single-origin proxy for the local Zitadel stack, mirroring the internal +# proxy cdcf-infra runs in production (auth/nginx/zitadel.conf). +# +# Routing: +# /ui/v2/login* -> ghcr.io/zitadel/zitadel-login (v2 UI, port 3000) +# everything else -> ghcr.io/zitadel/zitadel (backend, port 8080) +# +# Service names resolve because compose puts every service on a default +# user-defined network. +# +# Differences from production, all consequences of local being plain HTTP on +# a non-default port: +# * X-Forwarded-Proto is http, matching ZITADEL_EXTERNALSECURE=false. +# Sending https here makes Zitadel mint https:// issuer and redirect +# URLs that the browser cannot reach over the local port. +# * Host AND X-Forwarded-Host are both $http_host, NOT $host. Zitadel +# mints its issuer and every endpoint URL from the forwarded host, and +# nginx's $host drops the port. Either header left as $host yields +# `issuer: http://localhost` and Auth.js fails discovery with "issuer +# property does not match the expected value" against +# AUTH_ZITADEL_ISSUER=http://localhost:8090. X-Forwarded-Host wins when +# both are present, so fixing only Host is not enough. Production uses +# $host safely only because it serves on 443, where the port is absent +# from the URL anyway. +# * The CSP connect-src allows http://localhost:3000 (the Next.js dev +# server) instead of the deployed property origins. + +upstream zitadel_backend { + server zitadel:8080; +} + +upstream zitadel_login { + server zitadel-login:3000; +} + +server { + listen 80 default_server; + server_name _; + + # Room for org/project assets uploaded through the admin console. + client_max_body_size 10m; + + # Login V2 UI — Next.js app served under the /ui/v2/login prefix. + location /ui/v2/login { + proxy_pass http://zitadel_login; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header Connection ''; + + # zitadel-login ships `connect-src 'self'`, which blocks the Next.js + # RSC prefetch when the post-login 302 lands on a different-origin + # OIDC client. Locally that client is the dev server on :3000, a + # different origin from :8090. Login still succeeds via a full + # navigation, but DevTools fills with CSP violations that mask real + # errors. Mirror the upstream policy and widen connect-src only. + # proxy_hide_header is required: multiple CSP headers intersect, so + # appending one would not widen anything. + proxy_hide_header Content-Security-Policy; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' http://localhost:3000; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' http://zitadel:8080; frame-ancestors 'none'; object-src 'none'" always; + } + + # Everything else — the Zitadel backend: admin console at /ui/console, + # OIDC at /oidc/v1, OAuth at /oauth/v2, management API at /management/v1. + location / { + proxy_pass http://zitadel_backend; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Host $http_host; + proxy_request_buffering off; + proxy_buffering off; + } +} diff --git a/scripts/tests/zitadel_compose.bats b/scripts/tests/zitadel_compose.bats new file mode 100644 index 0000000..32992b8 --- /dev/null +++ b/scripts/tests/zitadel_compose.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats +# +# Coverage for the local Zitadel services in docker-compose.yml. +# +# These assert the RESOLVED compose config (`docker compose config`), not the +# raw YAML, so ${ZITADEL_PORT:-8090} is evaluated the way Compose evaluates it. +# -f docker-compose.yml is explicit: docker-compose.override.yml is merged by +# default and would otherwise make the result depend on local overrides. +# +# The port cases are the point of this file. ZITADEL_PORT is restated in four +# places across two repos and nothing validates that they agree (spec §4); a +# published port that disagrees with ZITADEL_EXTERNALPORT yields issuer and +# discovery URLs that look right and do not resolve. + +setup() { + cd "$BATS_TEST_DIRNAME/../.." || return 1 + # The cases below invoke this through `run bash -c`, which starts a fresh + # shell that does NOT inherit shell functions. Without the export they + # fail with "compose_service_json: command not found" — a failure that + # reads like a broken compose file rather than a broken harness. + export -f compose_service_json +} + +# Emit the resolved config for one service as JSON. +compose_service_json() { + docker compose -f docker-compose.yml config --format json 2>/dev/null \ + | python3 -c "import sys,json;print(json.dumps(json.load(sys.stdin)['services']['$1']))" +} + +@test "zitadel: image is pinned to v4.15.0, never :latest" { + run bash -c "compose_service_json zitadel | python3 -c \"import sys,json;print(json.load(sys.stdin)['image'])\"" + [ "$status" -eq 0 ] + [ "$output" = "ghcr.io/zitadel/zitadel:v4.15.0" ] +} + +@test "zitadel-db: is its own postgres, not the stack's mariadb" { + run bash -c "compose_service_json zitadel-db | python3 -c \"import sys,json;print(json.load(sys.stdin)['image'])\"" + [ "$status" -eq 0 ] + [ "$output" = "postgres:16-alpine" ] +} + +@test "zitadel-proxy: default published port is 8090, not 8080" { + run bash -c "compose_service_json zitadel-proxy | python3 -c \" +import sys,json +p=json.load(sys.stdin)['ports'][0] +print(f\\\"{p['published']}:{p['target']}\\\")\"" + [ "$status" -eq 0 ] + [ "$output" = "8090:80" ] +} + +@test "zitadel: publishes no host port — the proxy is the only entry point" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +print(len(json.load(sys.stdin).get('ports') or []))\"" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "zitadel: EXTERNALPORT tracks the proxy's published port under an override" { + run bash -c "ZITADEL_PORT=9099 compose_service_json zitadel-proxy | python3 -c \" +import sys,json +print(json.load(sys.stdin)['ports'][0]['published'])\"" + [ "$status" -eq 0 ] + [ "$output" = "9099" ] + run bash -c "ZITADEL_PORT=9099 compose_service_json zitadel | python3 -c \" +import sys,json +print(json.load(sys.stdin)['environment']['ZITADEL_EXTERNALPORT'])\"" + [ "$status" -eq 0 ] + [ "$output" = "9099" ] +} + +@test "zitadel: the v2 login URLs follow ZITADEL_PORT too" { + run bash -c "ZITADEL_PORT=9099 compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +print(e['ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI'])\"" + [ "$status" -eq 0 ] + [ "$output" = "http://localhost:9099/ui/v2/login" ] +} + +@test "zitadel: both postgres SSL modes are disabled" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +print(e['ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE'], e['ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE'])\"" + [ "$status" -eq 0 ] + [ "$output" = "disable disable" ] +} + +@test "zitadel: the machine-user block that produces the PAT is complete" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +keys=['ZITADEL_FIRSTINSTANCE_PATPATH', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME', + 'ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE'] +print('ok' if all(e.get(k) for k in keys) else 'missing') +print(e['ZITADEL_FIRSTINSTANCE_PATPATH'])\"" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "ok" ] + [ "${lines[1]}" = "/zitadel-data/automation-user.pat" ] +} + +@test "zitadel: Login V2 is required, matching production" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +print(json.load(sys.stdin)['environment']['ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED'])\"" + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "zitadel: the login-client block that produces the v2 UI token is complete" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json +e=json.load(sys.stdin)['environment'] +keys=['ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH', + 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME', + 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME', + 'ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE'] +print('ok' if all(e.get(k) for k in keys) else 'missing') +print(e['ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH'])\"" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "ok" ] + [ "${lines[1]}" = "/zitadel-data/login-client.pat" ] +} + +@test "zitadel-login: consumes the login-client PAT and is version-pinned to the backend" { + run bash -c "compose_service_json zitadel-login | python3 -c \" +import sys,json +s=json.load(sys.stdin) +print(s['image']) +print(s['environment']['ZITADEL_SERVICE_USER_TOKEN_FILE'])\"" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "ghcr.io/zitadel/zitadel-login:v4.15.0" ] + [ "${lines[1]}" = "/zitadel-data/login-client.pat" ] + + # Mixing v2 UI and backend versions is unsupported upstream. + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json;print(json.load(sys.stdin)['image'])\"" + [ "$output" = "ghcr.io/zitadel/zitadel:v4.15.0" ] +} + +@test "zitadel: master key is exactly 32 characters" { + run bash -c "compose_service_json zitadel | python3 -c \" +import sys,json,re +c=json.load(sys.stdin)['command'] +c=' '.join(c) if isinstance(c,list) else c +m=re.search(r'--masterkey\s+\\\"?([^\\\" ]+)', c) +print(len(m.group(1)) if m else 'nomatch')\"" + [ "$status" -eq 0 ] + [ "$output" = "32" ] +} + +@test "zitadel: runs as uid 0 so the host can read the PAT" { + run bash -c "compose_service_json zitadel | python3 -c \"import sys,json;print(json.load(sys.stdin).get('user',''))\"" + [ "$status" -eq 0 ] + [ "$output" = "0" ] +} + +@test "env example: AUTH_ZITADEL_ISSUER matches the compose default port" { + port=$(compose_service_json zitadel-proxy | python3 -c " +import sys,json +print(json.load(sys.stdin)['ports'][0]['published'])") + run grep -E "^AUTH_ZITADEL_ISSUER=http://localhost:${port}$" .env.local.example + [ "$status" -eq 0 ] +} + +@test "env example: local dev does not point at the production Zitadel" { + [ -f .env.local.example ] + run grep -E "^AUTH_ZITADEL_ISSUER=.*auth\.catholicdigitalcommons\.org" .env.local.example + [ "$status" -ne 0 ] +} + +@test "env example: AUTH_ZITADEL_ORG_ID is present for lib/auth.ts to read" { + run grep -E "^AUTH_ZITADEL_ORG_ID=" .env.local.example + [ "$status" -eq 0 ] +} + +@test "env example: comment-block ZITADEL_ISSUER/INTERNAL_URL restatements match the compose default port" { + port=$(compose_service_json zitadel-proxy | python3 -c " +import sys,json +print(json.load(sys.stdin)['ports'][0]['published'])") + run grep -E "^# # ZITADEL_ISSUER=http://localhost:${port}$" .env.local.example + [ "$status" -eq 0 ] + run grep -E "^# # ZITADEL_INTERNAL_URL=http://127\.0\.0\.1:${port}$" .env.local.example + [ "$status" -eq 0 ] +} + +@test "README: cdcf-infra env var examples match the compose default port" { + port=$(compose_service_json zitadel-proxy | python3 -c " +import sys,json +print(json.load(sys.stdin)['ports'][0]['published'])") + run grep -E "^ZITADEL_ISSUER=http://localhost:${port}$" README.md + [ "$status" -eq 0 ] + run grep -E "^ZITADEL_INTERNAL_URL=http://127\.0\.0\.1:${port}$" README.md + [ "$status" -eq 0 ] +}