Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a6b79c9
Design the local development stack
JohnRDOrazio Aug 4, 2026
45f83e6
Plan the local development stack implementation
JohnRDOrazio Aug 4, 2026
3b25702
Add MARTYROLOGY_ZITADEL_INTERNAL_URL for introspection transport
JohnRDOrazio Aug 4, 2026
088813d
Add the martyrology database setting and an Alembic baseline
JohnRDOrazio Aug 4, 2026
90dfe24
Add the API Dockerfile and database bootstrap SQL
JohnRDOrazio Aug 4, 2026
e8b7a26
Pin Dockerfile base image and data repo refs; scope init-db.sql crede…
JohnRDOrazio Aug 4, 2026
b87c37e
Add the minimal stack: Postgres, Zitadel, Mailpit, Adminer
JohnRDOrazio Aug 4, 2026
5c6e0d3
Fix ZITADEL_PORT override: parameterize EXTERNALPORT to match the pub…
JohnRDOrazio Aug 4, 2026
115fa58
Add OpenFGA and seed the Martyrology store from cdcf-infra
JohnRDOrazio Aug 4, 2026
2436671
Add the api-migrate one-shot Alembic service
JohnRDOrazio Aug 4, 2026
cb50bfd
Fix api-migrate tag collision and .env.example password mismatch
JohnRDOrazio Aug 4, 2026
fd5b218
Add local stack provisioning and superuser grant scripts
JohnRDOrazio Aug 4, 2026
ac6291b
Fix review findings in stack provisioning scripts
JohnRDOrazio Aug 4, 2026
e277eb3
Fix fd-2 leak in grant-superuser.sh's terminal-open check
JohnRDOrazio Aug 4, 2026
4a3215c
Add the minimal-stack smoke test and document the bring-up
JohnRDOrazio Aug 4, 2026
07a0e8b
Broaden .env gitignore to cover all variants; document host prerequis…
JohnRDOrazio Aug 4, 2026
5fd7e46
Remove phantom python3 prerequisite from README
JohnRDOrazio Aug 4, 2026
f3e6d87
Add reciprocal SIBLING NOTE headers pointing at martyrology-frontend
JohnRDOrazio Aug 4, 2026
545512b
Fix local-dev-stack review findings: unreachable diagnostics, cut tru…
JohnRDOrazio Aug 4, 2026
f608f50
Merge remote-tracking branch 'origin/main' into feat/local-dev-stack
JohnRDOrazio Aug 4, 2026
a6798ec
Address CodeRabbit PR #29 review findings
JohnRDOrazio Aug 4, 2026
68664ca
Fix pagination guard in smoke.sh (sibling of frontend PR #16 round 2 …
JohnRDOrazio Aug 4, 2026
d257669
Bound every curl in scripts/*.sh with connect/max-time timeouts
JohnRDOrazio Aug 4, 2026
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
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Deny-all, then re-admit exactly what the image needs. `vendor/` is excluded
# on purpose: vendor/texts is a PRIVATE submodule, and crmedr/clbdr are cloned
# in the build instead (see the Dockerfile).
*
!pyproject.toml
!uv.lock
!src
!data
!alembic
!alembic.ini
!scripts
**/__pycache__
39 changes: 39 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ MARTYROLOGY_ZITADEL_CLIENT_SECRET=
# Empty = the roles claim cannot be built, so every curation write 403s
# missing-role for every principal, even a correctly-configured issuer.
MARTYROLOGY_ZITADEL_PROJECT_ID=
# Transport-only: where to send introspection when the public issuer is not
# reachable from inside the API process (docker networks, reverse proxies).
# Empty = use MARTYROLOGY_ZITADEL_ISSUER. Does not affect auth_enabled.
MARTYROLOGY_ZITADEL_INTERNAL_URL=

# Postgres DSN for the `martyrology` database. Empty outside the docker stack.
# MARTYROLOGY_DATABASE_URL=postgresql+psycopg://martyrology:martyrology_secure_password@localhost:5432/martyrology

# OpenFGA (empty = authz disabled: fail closed)
MARTYROLOGY_OPENFGA_API_URL=
Expand All @@ -32,3 +39,35 @@ MARTYROLOGY_LOCAL_GIT_ROOT=
# MARTYROLOGY_DATA_PATH=/opt/martyrology/current/data/editions:/opt/martyrology/current/data/texts
# MARTYROLOGY_CRMEDR_PATH=/opt/martyrology/current/data/crmedr
# MARTYROLOGY_CLBDR_PATH=/opt/martyrology/current/data/clbdr

# --- Local development stack (docker compose) ---------------------------
# Copy to .env before `docker compose up -d`. Ports match LiturgicalCalendar's
# stack, so only one of the two can run at a time.
DB_PORT=5432
# Override in your local .env (never here) if something else on the host
# already holds 8080 — ZITADEL_PORT drives both the published port and
# Zitadel's externally-advertised issuer/OIDC URLs, so overriding it keeps
# the discovery document correct at the new port. Under Docker Desktop on
# WSL2, the port must be free on the *Windows* host, not just inside WSL:
# `ss`/`netstat` run from within WSL cannot see Windows-side listeners, so a
# port can look free in WSL while a Windows app already owns it.
ZITADEL_PORT=8080
MAILPIT_PORT=8025
ADMINER_PORT=8088
OPENFGA_HTTP_PORT=8083
OPENFGA_GRPC_PORT=8084
# No OPENFGA_PLAYGROUND_PORT: OpenFGA v1.15.1 refuses to start the Playground
# alongside OPENFGA_AUTHN_METHOD=preshared (required — see docker-compose.yml),
# so this stack does not offer it.

# Must be EXACTLY 32 characters. Generate with: openssl rand -hex 16
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters

# OpenFGA preshared key. REQUIRED, not optional: Settings.authz_enabled is
# false when MARTYROLOGY_OPENFGA_API_TOKEN is empty, which silently denies
# every authorization check while the stack reports healthy.
OPENFGA_PRESHARED_KEY=local-dev-preshared-key

# Ref of CatholicOS/cdcf-infra the authz-seed service clones for the OpenFGA
# model and tuples.
CDCF_INFRA_REF=main
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,4 @@ jobs:
persist-credentials: false

- name: shellcheck
run: shellcheck scripts/deploy/*.sh
run: shellcheck scripts/*.sh scripts/deploy/*.sh
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
__pycache__/
*.pyc
.venv/
.env
.env*
!.env.example
.coverage
.coverage.*
coverage.xml
Expand All @@ -10,3 +11,7 @@ htmlcov/
coverage.xml
junit.xml
htmlcov/

.zitadel-data/
.stack-out/
docker-compose.override.yml
76 changes: 76 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# martyrology-api image.
#
# Consumed by martyrology-frontend's full docker stack and by CI. The API
# repo's own compose stack is infra-only — local API development runs
# `uvicorn --factory --reload` on the host, so this image is never in that
# edit loop. See docs/superpowers/specs/2026-08-04-local-development-stack-design.md, D4.

FROM python:3.12.13-slim AS build

# Pinned refs for the two data repositories — these SHAs are the commits this
# repo's own vendor/ submodules record, so the image and vendor/ agree. To
# bump the data revision intentionally, pass a new SHA with --build-arg.
ARG CRMEDR_REF=51740e79584f64940f9e3f98615b000ef5f77e92
ARG CLBDR_REF=ecb147b47b47368fbdefeb2074c5770ebb7c8f9d

RUN apt-get update -y && \
apt-get install -y --no-install-suggests --no-install-recommends \
git ca-certificates && \
rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.9.6 /uv /usr/local/bin/uv

WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
RUN uv sync --frozen --no-dev

# app.py calls Registry.load(crmedr_path, clbdr_path) at startup and
# registry.py reads four files from them unconditionally — the API cannot boot
# without these. They are CLONED rather than COPYed from vendor/ because
# vendor/texts is a PRIVATE submodule: a recursive clone of a GitHub build
# context would fail for anyone without access to it.
#
# CRMEDR_REF/CLBDR_REF are commit SHAs, not branch names, so `git clone
# --branch` can't be used (it only accepts refs GitHub advertises, not
# arbitrary SHAs). init+fetch+checkout fetches the exact commit instead.
RUN git init /data/crmedr && \
git -C /data/crmedr remote add origin https://github.com/CatholicOS/crmedr.git && \
git -C /data/crmedr fetch --depth 1 origin "$CRMEDR_REF" && \
git -C /data/crmedr checkout FETCH_HEAD && \
git init /data/clbdr && \
git -C /data/clbdr remote add origin https://github.com/CatholicOS/clbdr.git && \
git -C /data/clbdr fetch --depth 1 origin "$CLBDR_REF" && \
git -C /data/clbdr checkout FETCH_HEAD && \
rm -rf /data/crmedr/.git /data/clbdr/.git


FROM python:3.12.13-slim AS main

WORKDIR /app

RUN useradd -m -u 1000 martyrology

COPY --from=build --chown=martyrology:martyrology /app/.venv /app/.venv
COPY --from=build --chown=martyrology:martyrology /data /data
# Load-bearing, not redundant with the copied .venv: `uv sync` in the build
# stage produced an editable install whose .pth file points at the literal
# path /app/src, so this WORKDIR/COPY pair must keep matching the build
# stage's or every import breaks silently at first boot.
COPY --chown=martyrology:martyrology src ./src
COPY --chown=martyrology:martyrology data ./data
COPY --chown=martyrology:martyrology alembic ./alembic
COPY --chown=martyrology:martyrology alembic.ini ./
COPY --chown=martyrology:martyrology scripts/init-db.sql ./scripts/init-db.sql

ENV PATH="/app/.venv/bin:$PATH" \
MARTYROLOGY_CRMEDR_PATH=/data/crmedr \
MARTYROLOGY_CLBDR_PATH=/data/clbdr \
MARTYROLOGY_DATA_PATH=/app/data/editions

USER martyrology

EXPOSE 8000

CMD ["uvicorn", "martyrology_api.app:create_app", "--factory", \
"--host", "0.0.0.0", "--port", "8000"]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,67 @@ Note: the bare `/elogia/01/01` path resolves (by default) to the 2004 editio typ
The API surface, response model, auth and curation design are specified in
[docs/superpowers/specs/2026-07-22-martyrology-api-v1-design.md](docs/superpowers/specs/2026-07-22-martyrology-api-v1-design.md).

## Local development stack

Brings up Zitadel and OpenFGA locally so auth and authorization can be
exercised without production. The API itself is **not** containerized here —
run it on the host. The fully containerized stack lives in
[`martyrology-frontend`](https://github.com/CatholicOS/martyrology-frontend).

Requires Docker with Compose v2, `curl`, `jq`, and `git`. `scripts/smoke.sh`
shells out to `curl` and `jq` (all of its JSON parsing goes through `jq`);
`scripts/setup-stack.sh` additionally needs `git` (it clones `cdcf-infra` on
the host). A missing `jq` otherwise surfaces as a bare "command not found"
rather than anything actionable. Ports match LiturgicalCalendar's stack, so
only one of the two can run at a time.

```bash
cp .env.example .env # 1. stack knobs
docker compose up -d # 2. infra; the store is seeded automatically
./scripts/setup-stack.sh --update-env # 3. provision Zitadel, write IDs into .env
set -a; . ./.env; set +a # 4. run the API against it
uvicorn martyrology_api.app:create_app --factory --reload
./scripts/smoke.sh # 5. verify
```

| Service | URL | Credentials |
| --- | --- | --- |
| 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> | — |

`ZITADEL_PORT` in `.env` overrides the issuer origin (default 8080) when
something on the host already holds that port. Under Docker Desktop on WSL2,
the port must be free on the **Windows** host, not just inside WSL — `ss`/
`netstat` run from within WSL cannot see Windows-side listeners, and Docker
Desktop fails a conflicting publish *silently* (compose reports healthy;
only `docker inspect` reveals the empty port binding). To inspect the
OpenFGA store directly, use `curl` against the API (as `scripts/smoke.sh`
does) rather than a UI — there is no Playground in this stack (see below).

To grant yourself platform superuser (after signing in once, so a `sub`
exists — find it under Martyrology Org → Users → your user → ID):

```bash
./scripts/grant-superuser.sh <your-sub>
```

**The OIDC client secret is emitted once.** `setup-stack.sh` captures it into
`.env` on the run that creates the app; a re-run cannot recover it. If `.env`
is lost, regenerate the secret in the Zitadel console.

**`OPENFGA_PRESHARED_KEY` is required, not optional.** `Settings.authz_enabled`
is false when `MARTYROLOGY_OPENFGA_API_TOKEN` is empty, which denies every
authorization check while the stack reports healthy.

**There is no OpenFGA Playground.** OpenFGA v1.15.1 panics at startup
("the playground only supports authn method 'none'") when the Playground is
enabled alongside preshared auth, and preshared auth is non-negotiable here
(`Settings.authz_enabled` requires a non-empty token). Inspect the store with
`curl` against the OpenFGA API instead — the same way production is
inspected.

## Licensing

The code in this repository is licensed under Apache-2.0. The eulogy texts of the 2004 editions are **not** part of this repository and are not redistributable; should an agreement with the rights holders be reached, texts could be served publicly without changing this architecture.
38 changes: 38 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
54 changes: 54 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Alembic environment.

The DSN comes from MARTYROLOGY_DATABASE_URL rather than alembic.ini so the
one-shot `api-migrate` compose service and a developer shell configure it the
same way. There is no target metadata yet: this tree exists to establish the
migration contract, and autogenerate is deliberately not wired up until the
permission-request subsystem introduces models.
"""

import os
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

DATABASE_URL = os.environ.get("MARTYROLOGY_DATABASE_URL", "")
if DATABASE_URL:
config.set_main_option("sqlalchemy.url", DATABASE_URL)

target_metadata = None


def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
23 changes: 23 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
23 changes: 23 additions & 0 deletions alembic/versions/0001_baseline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Baseline — establishes the migration contract, creates no tables.

martyrology-api has no application tables yet. This revision exists so that a
fresh `alembic upgrade head` succeeds against an empty `martyrology` database
and stamps a version, which is what the `api-migrate` compose service asserts.
The permission-request and notification subsystem adds real tables on top.

Revision ID: 0001_baseline
Revises:
"""

revision = "0001_baseline"
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Loading