A curation website for the Roman Martyrology data pipeline: it lets a curator
compare how a canonical eulogy ID is placed across editions, and review
(accept/reject/edit) a proposed change-set of ID corrections against the real,
live eulogy text served by the martyrology-api.
It contains no copyrighted texts at rest — everything it displays comes either from the bundled public CRMEDR registry snapshot (IDs, placement, multilingual subject headwords) or is fetched live from the API for the actual eulogy text.
/compare— pick two editions; see every physical day (month/day) with rows aligned by canonical ID. Deprecated-only IDs render RED, non-deprecated-only IDs render GREEN, and IDs that this edition places on a different physical day than the registry's canonical day (a "cross-day" placement) get a badge./review— load a bundled change-set (e.g.deprecated-id-normalization), see each operation (rename/delete/merge/…) alongside the live 1749 Latin text for the affected ID(s), and Accept / Reject / Edit each one. Decisions persist inlocalStorage(reload-safe) and can be exported as a newcrmedr-changeset/v1JSON file withdecision/editedfilled in.
- Node.js 24+ (see
.nvmrc;package.json'senginesand theDockerfileagree) - A running instance of
martyrology-api(see that repo's README forpip install -e '.[dev]'anduvicorn martyrology_api.app:create_app --factory --reload). By default it serves the public-domain editions (1749, 1914, and the old English translations) — no private data repository needed to develop against. - A sibling checkout of
crmedrat../crmedr(only needed to regenerate the bundled registry snapshot / import a new change-set — the app itself does not readcrmedrat runtime). - Docker with Compose v2,
curl,jq,git, andopenssl(only needed for the full local development stack below — see that section for what each one is used for).
npm install
cp .env.example .env.env sets API_BASE, the upstream URL the server-side proxy
(app/api/mr/[...path]/route.ts) forwards to — default http://localhost:8000.
Both scripts read the sibling ../crmedr checkout and write into this repo;
re-run them whenever the registry or the correction manifest changes upstream.
# Rebuild data/registry-snapshot.json from ../crmedr's canonical registry
# (data/martyrology_ids.json) + i18n subject files (i18n/{la,it,en}.json).
npm run snapshot-registry
# Convert ../crmedr/data/deprecated_id_corrections.json into a
# crmedr-changeset/v1 file under public/changesets/, and regenerate
# public/changesets/index.json (the manifest the Review page's change-set
# picker fetches).
npm run import-changesetBoth accept optional positional args — see the top of each script under
scripts/ for the exact CLI (source path, output name, base edition).
npm run devThen open:
/compare and eulogy text in /review require a reachable martyrology-api
(API_BASE); if it's unreachable, the proxy returns a 502 with an
"API unreachable" title and the UI shows a clear banner instead of a blank
page or crash.
Note on the 2004 edition: the default/bare edition path
(/api/v1/elogia/... without an explicit edition) resolves to the 2004
editio typica altera, whose text lives in a private data repository that is
attached only at deployment time. Against a public-only clone of
martyrology-api (the normal dev setup), requests for that edition return an
honest 404 — use the public-domain editions instead, e.g.
martyrologium_romanum_1749, martyrologium_romanum_1914_la,
martyrologium_romanum_1914_en_unofficial.
The crmedr-changeset/v1 schema (rename / delete / merge operations, each
carrying class/confidence/incipit/reasoning plus a curator
decision/edited slot) is specified in
docs/superpowers/specs/2026-07-28-martyrology-curation-frontend-mvp-design.md
under "The change-set schema — crmedr-changeset/v1". The TypeScript types and
converter/loader live in lib/changeset.ts and scripts/import-changeset.mjs.
npm test # vitest
npx tsc --noEmit # typecheck
npm run build # production buildRuns the whole system — Zitadel, OpenFGA, Postgres, the API and this frontend —
in Docker. Mirrors cdcf-infra production topology: Zitadel and its v2 login UI
share one origin behind an nginx proxy, with image versions pinned to
production's.
Requires Docker with Compose v2, curl, jq, git, and openssl — the
provisioning and smoke scripts shell out to all four (curl/jq to talk to
Zitadel and OpenFGA, git to clone cdcf-infra, openssl to generate
AUTH_SECRET); a missing one otherwise surfaces as a bare "command not found"
partway through provisioning, with nothing pointing at the cause. Ports match
LiturgicalCalendar's stack, so only one of the two can run at a time.
cp .env.example .env
docker compose up -d
./scripts/setup-stack.sh --update-env
docker compose up -d --force-recreate martyrology-api martyrology-frontend
./scripts/smoke.shThere is no sign-in on the frontend yet — that arrives with the OIDC
login-client plan (see smoke assertion 7, which skips until then). Until it
does, create/find your user in the Zitadel console
(http://localhost:8080/ui/console, Martyrology Org → Users), copy its sub
(→ your user → ID), and grant yourself platform superuser:
./scripts/grant-superuser.sh <your-sub>| Service | URL | Credentials |
|---|---|---|
| Frontend | http://localhost:3000 | — |
| API | http://localhost:8000 | — |
| Zitadel console | http://localhost:8080/ui/console | root@martyrology.localhost / RootPassword1! |
| OpenFGA API | http://localhost:8083 | Bearer OPENFGA_PRESHARED_KEY from .env |
| Adminer | http://localhost:8088 | server db, user postgres, password postgres |
| Mailpit | http://localhost:8025 | — |
There is no OpenFGA Playground in this stack — v1.15.1 panics at startup when
the Playground is enabled alongside the preshared auth this stack requires.
Inspect a store's tuples with curl instead, e.g.:
set -a; . ./.env; set +a # loads MARTYROLOGY_OPENFGA_API_URL/STORE_ID/API_TOKEN
curl -s -X POST "$MARTYROLOGY_OPENFGA_API_URL/stores/$MARTYROLOGY_OPENFGA_STORE_ID/read" \
-H "Authorization: Bearer $MARTYROLOGY_OPENFGA_API_TOKEN" \
-H "Content-Type: application/json" -d '{}' | jqBy default every service builds from its GitHub ref, so a bare clone stands the whole system up. To build from sibling checkouts instead:
cp docker-compose.override.example.yml docker-compose.override.yml
docker compose up -d --buildThe override also mounts ../martyrology-texts, which is the only way the
restricted-texts path becomes exercisable — that repo is private, so the
GitHub-default stack serves the two public-domain editions only.
This image is a production Next.js build and does not hot-reload from a bind mount. Stop the container and use the dev server:
docker compose stop martyrology-frontend
npm run devPort 3000 is then free and the registered OIDC callback still matches.
- The OIDC client secrets are emitted once.
setup-stack.shcaptures them into.envon the run that creates each app (the API app and the frontend app are provisioned — and can be created — independently); a re-run cannot recover a secret for an app that already existed. If.envis lost, regenerate in the Zitadel console. AUTH_SECRETis generated once and never rotated by this script. Regenerating it invalidates every existing session cookie, sosetup-stack.shonly ever writes it when.envdoesn't already have one.OPENFGA_PRESHARED_KEYis required. The API'sauthz_enabledis false when its token is empty, which denies every authorization check while the stack reports healthy.ZITADEL_PORToverrides the issuer origin — every OIDC client in the stack follows it, but the port still has to actually be free. Override it in your local.env(never in.env.example) when something on the host already holds 8080. On Docker Desktop for Windows/WSL2, the published port is bound on the Windows host, not just inside WSL —ss/netstatrun inside WSL will not show a Windows-side process holding it. A conflicting publish fails silently:docker compose psreports the proxy healthy, and onlydocker inspect martyrology-zitadel-proxy-1reveals the port binding never actually took (an emptyHostIp/HostPort). If Zitadel discovery or login is unreachable despite a healthyzitadel-proxy, checkdocker inspectbefore anything else.- Port 3000 is fixed.
cdcf-infraregistershttp://localhost:3000/api/auth/callback/zitadelfor--target local.
Production runs at romanmartyrology.com on the
same Plesk-managed VPS as the API, under the Plesk Node.js extension
(Phusion Passenger). .github/workflows/deploy.yml deploys on
release: published (or gh workflow run deploy.yml --ref main): it builds the
output: "standalone" bundle, ships it over scp, unpacks it into the vhost,
and restarts Passenger by touching tmp/restart.txt. Nothing is installed on
the VPS — standalone bundles its own pruned node_modules.
API_BASE resolves in two layers: the workflow writes vars.API_BASE into the
bundle as the shipped default, and a Custom environment variable set in the
Plesk Node.js panel overrides it live without a redeploy.
Required repository configuration, and the one-time Plesk setup, are listed in
docs/superpowers/specs/2026-08-02-frontend-deployment-design.md.
That document also records why Passenger is used here while the API uses a
systemd unit, and why the deployed site cannot show the restricted 2004 text.