Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 22 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path-to>/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
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
34 changes: 31 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -353,3 +357,27 @@ gh run view <run-id> --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.

<!--
Everything below is written and re-added by `next dev` (see
node_modules/next/dist/server/lib/generate-agent-files.js). It opens with its
own H1, which trips MD025/single-title in a file that already has one, so
`npm run lint:md` fails on every tree where the dev server has run — and on
CLAUDE.md / GEMINI.md too, since both are symlinks to this file.

The rule is disabled from here to EOF rather than repo-wide, so MD025 still
protects every other document. The directive sits ABOVE the BEGIN marker on
purpose: the generator rewrites only the span between its markers, preserving
content before and after, so this comment survives regeneration.
-->
<!-- markdownlint-disable MD025 -->

<!-- BEGIN:nextjs-agent-rules -->

# 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.

<!-- END:nextjs-agent-rules -->
138 changes: 138 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path-to>/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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 <name-from-the-listing>
```

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)
Expand Down
Loading