Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,28 @@ 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 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=<path-to>/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=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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,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`) and is required for local sign-in. `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
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,64 @@ 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 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
[`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=<path-to>/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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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 rm cdcf-website_zitadel_db_data
```

Confirm the actual volume name first with `docker volume ls | grep zitadel`
— the `cdcf-website_` prefix comes from the Compose project name and may
differ if you've overridden it. Changing `ZITADEL_PORT` means updating
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
`AUTH_ZITADEL_ISSUER` in `.env.local` and the two `cdcf-infra` URLs above to
match.

### Development

#### Full Stack (Docker)
Expand Down
91 changes: 91 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,96 @@ 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"
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

wordpress:
image: wordpress:beta-7.0-php8.4-apache
restart: unless-stopped
Expand Down Expand Up @@ -147,3 +237,4 @@ volumes:
db_data:
redis_data:
wordpress_data:
zitadel_db_data:
Loading