diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d015ba --- /dev/null +++ b/.dockerignore @@ -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__ diff --git a/.env.example b/.env.example index d38ec40..da7db74 100644 --- a/.env.example +++ b/.env.example @@ -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= @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eac0bc3..12d941f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,4 +69,4 @@ jobs: persist-credentials: false - name: shellcheck - run: shellcheck scripts/deploy/*.sh + run: shellcheck scripts/*.sh scripts/deploy/*.sh diff --git a/.gitignore b/.gitignore index 1f6e05a..0f793a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ __pycache__/ *.pyc .venv/ -.env +.env* +!.env.example .coverage .coverage.* coverage.xml @@ -10,3 +11,7 @@ htmlcov/ coverage.xml junit.xml htmlcov/ + +.zitadel-data/ +.stack-out/ +docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b55e472 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 6e80504..d1bf833 100644 --- a/README.md +++ b/README.md @@ -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 | | `root@martyrology.localhost` / `RootPassword1!` | +| OpenFGA API | | Bearer `OPENFGA_PRESHARED_KEY` from `.env` | +| Adminer | | server `db`, user `postgres`, password `postgres` | +| Mailpit | | — | + +`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 +``` + +**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. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1aae7ec --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..fbf3d10 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..aed88d8 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/0001_baseline.py b/alembic/versions/0001_baseline.py new file mode 100644 index 0000000..c88561c --- /dev/null +++ b/alembic/versions/0001_baseline.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66afbdd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,259 @@ +# Martyrology local development stack — INFRA ONLY. +# +# The API itself is NOT here. Run it on the host: +# uvicorn martyrology_api.app:create_app --factory --reload +# +# This mirrors LiturgicalCalendarAPI's stack, which likewise runs its API on the +# host and containerizes only the infrastructure. The fully containerized stack +# lives in the martyrology-frontend repo. +# +# Deliberate divergence from cdcf-infra production: there is no zitadel-login +# service and no nginx proxy. Nothing here performs an interactive browser +# sign-in — the API only ever calls /oauth/v2/introspect — so Zitadel is served +# directly and Login V2 is switched off. The frontend repo's stack is the one +# that mirrors production's single-origin topology. +# +# Bring-up: see README.md → "Local development stack". + +name: martyrology-infra + +services: + db: + image: postgres:17 + restart: unless-stopped + environment: + PGUSER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + # -h 127.0.0.1 is load-bearing, not decoration: with no -h, pg_isready + # defaults to the local UNIX socket, which the entrypoint's temporary + # init server (started with listen_addresses='' to run + # docker-entrypoint-initdb.d scripts) also accepts on. A socket-only + # check can therefore report healthy before the real server is + # listening on TCP, racing openfga-migrate/api-migrate/zitadel, which + # all connect over the network as `db:5432`. Forcing TCP via loopback + # ensures the check only passes once the real server is up. + test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1"] + interval: 10s + timeout: 30s + retries: 5 + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data:rw + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + networks: + - martyrology + + zitadel: + image: ghcr.io/zitadel/zitadel:v4.15.0 + restart: unless-stopped + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}"' + environment: + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: ${ZITADEL_PORT:-8080} + ZITADEL_EXTERNALSECURE: false + ZITADEL_TLS_ENABLED: false + + ZITADEL_DATABASE_POSTGRES_HOST: db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # No Login V2 in this stack — there is no frontend to log in to, and the + # v2 UI would have nothing routing to it. Zitadel's built-in console + # login is used for the admin console. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: false + + # The automation PAT setup-zitadel.sh authenticates with. Written into + # the bind-mounted ./.zitadel-data/ so the host-run script can read it. + # The filename matches setup-zitadel.sh's ZITADEL_PAT_FILE convention. + 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' + + ZITADEL_FIRSTINSTANCE_ORG_NAME: "Martyrology" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1! + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false + + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: mailpit:1025 + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: false + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: noreply@martyrology.localhost + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: Martyrology + + ZITADEL_LOG_LEVEL: info + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + user: "0" + volumes: + - ./.zitadel-data:/zitadel-data:delegated + ports: + - "127.0.0.1:${ZITADEL_PORT:-8080}:8080" + networks: + - martyrology + depends_on: + db: + condition: service_healthy + mailpit: + condition: service_started + + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + ports: + - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" + networks: + - martyrology + + openfga-migrate: + image: openfga/openfga:v1.15.1 + command: migrate + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + networks: + - martyrology + restart: "no" + depends_on: + db: + condition: service_healthy + + openfga: + image: openfga/openfga:v1.15.1 + command: run + restart: unless-stopped + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + # Preshared auth is REQUIRED, not a hardening choice. Settings.authz_enabled + # is `bool(api_url and store_id and api_token)`, so a tokenless OpenFGA + # leaves MARTYROLOGY_OPENFGA_API_TOKEN empty, authz disabled, and every + # check denied — while the stack reports perfectly healthy. + OPENFGA_AUTHN_METHOD: preshared + OPENFGA_AUTHN_PRESHARED_KEYS: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + # No OPENFGA_PLAYGROUND_ENABLED / port 3001 here: OpenFGA v1.15.1 panics + # at startup ("the playground only supports authn method 'none'") when + # the Playground is enabled alongside preshared auth. Preshared auth is + # required (see above), so the Playground cannot be offered in this + # stack — confirmed empirically, not assumed. + healthcheck: + test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:8081"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 10s + ports: + - "127.0.0.1:${OPENFGA_HTTP_PORT:-8083}:8080" + - "127.0.0.1:${OPENFGA_GRPC_PORT:-8084}:8081" + networks: + - martyrology + depends_on: + openfga-migrate: + condition: service_completed_successfully + + # Creates the Martyrology store, uploads the authorization model, and seeds + # the 8 governed_by + 3 on_platform structural tuples. + # + # The model is CLONED from cdcf-infra rather than vendored into this repo: + # auth/models/Martyrology{,.tuples}.json is the authoritative copy production + # uploads, and a second copy here would drift silently. cdcf-infra is public, + # so this works from a bare clone with no sibling checkouts. + # + # Idempotent: setup-openfga.sh reads the store's existing tuples first and + # writes only the difference. A second run reports everything already present. + authz-seed: + image: alpine:3.21 + restart: "no" + environment: + CDCF_INFRA_REF: "${CDCF_INFRA_REF:-main}" + OPENFGA_PRESHARED_KEY: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + entrypoint: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache bash curl jq git >/dev/null + rm -rf /tmp/cdcf-infra + git clone --depth 1 --branch "$$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git /tmp/cdcf-infra + cd /tmp/cdcf-infra/auth + cat > .env.local < **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:** Stand up local Zitadel + OpenFGA infrastructure for Martyrology in two Docker Compose stacks — an infra-only one in `martyrology-api` and a fully containerized one in `martyrology-frontend` — so the OIDC, authorization and restricted-texts paths become verifiable without production. + +**Architecture:** `martyrology-api/docker-compose.yml` runs Postgres, Zitadel, OpenFGA, Mailpit and Adminer for a host-run `uvicorn`. `martyrology-frontend/docker-compose.yml` runs the same plus `zitadel-login`, an nginx proxy giving Zitadel a single origin, and containers for both applications — building from GitHub refs by default, with a gitignored override that repoints builds at local sibling checkouts. The authoritative OpenFGA model is cloned from `cdcf-infra` rather than vendored. + +**Tech Stack:** Docker Compose, Postgres 17, Zitadel v4.15.0, OpenFGA v1.15.1, nginx:alpine, Python 3.12 + uv + Alembic + SQLAlchemy, Node 24 + Next.js 16 standalone. + +**Spec:** `docs/superpowers/specs/2026-08-04-local-development-stack-design.md` + +## Global Constraints + +- **Image versions are pinned to production's**, never `:latest`: `ghcr.io/zitadel/zitadel:v4.15.0`, `ghcr.io/zitadel/zitadel-login:v4.15.0`, `openfga/openfga:v1.15.1`, `postgres:17`, `nginx:alpine`, `adminer:latest`, `axllent/mailpit:latest`, `alpine:3.21`. +- **Ports reuse LiturgicalCalendar's numbers** (spec D5): Postgres `5432`, Zitadel `8080`, OpenFGA HTTP `8083`, OpenFGA gRPC `8084`, OpenFGA Playground `3001`, Adminer `8088`, Mailpit `8025`, API `8000`, frontend `3000`. Port `8081` is deliberately unused. All published ports bind `127.0.0.1` only. + > **2026-08-04 correction:** the Playground port never shipped. OpenFGA v1.15.1 panics at startup when the Playground is enabled alongside the preshared auth this stack requires (see the spec's D5 note and §3 "Resolved at implementation"). This bullet is left as originally planned for the historical record. +- **The frontend's published port is `3000` and cannot change.** `cdcf-infra`'s `--target local` registers `http://localhost:3000/api/auth/callback/zitadel` as the OIDC redirect URI. +- **OpenFGA runs with `OPENFGA_AUTHN_METHOD=preshared` in both stacks.** `Settings.authz_enabled` requires a non-empty `openfga_api_token`; a tokenless OpenFGA makes the whole stack fail closed while reporting healthy. +- **Compose project names differ**: `martyrology-infra` (API repo) and `martyrology` (frontend repo), so the two stacks never share volumes. +- **`MARTYROLOGY_ZITADEL_INTERNAL_URL` is transport-only.** It must never influence `auth_enabled`, which keys off `zitadel_issuer` alone. +- Python `>=3.12`; Node `>=24`. +- The `Martyrology` OpenFGA store holds exactly **11** structural tuples: 8 `governed_by` + 3 `on_platform`. +- Every commit is GPG-signed (`git commit -S`). Never bypass signing. +- Work in `martyrology-api` on branch `feat/local-dev-stack`; in `martyrology-frontend` on branch `feat/local-dev-stack`. + +## File Structure + +**`martyrology-api`** (Tasks 1–8) + +| File | Responsibility | +|---|---| +| `src/martyrology_api/config.py` | Adds `zitadel_internal_url`, `database_url` | +| `src/martyrology_api/auth.py` | Introspection targets the internal URL | +| `src/martyrology_api/app.py` | Passes the new setting through | +| `alembic.ini`, `alembic/env.py`, `alembic/versions/` | Migration contract; no tables yet | +| `Dockerfile`, `.dockerignore` | API image, consumed by the frontend stack and CI | +| `scripts/init-db.sql` | Roles + databases for zitadel, openfga, martyrology | +| `docker-compose.yml` | Minimal stack | +| `.env.example` | Stack knobs | +| `scripts/setup-stack.sh` | Provisions Zitadel, discovers OpenFGA IDs, writes `.env` | +| `scripts/grant-superuser.sh` | One-shot superuser tuple write | +| `scripts/smoke.sh` | Bring-up invariants | + +**`martyrology-frontend`** (Tasks 9–13) + +| File | Responsibility | +|---|---| +| `Dockerfile`, `.dockerignore` | Next.js standalone image | +| `docker/nginx/zitadel.local.conf` | Single-origin routing + localhost CSP | +| `docker-compose.yml` | Full stack | +| `docker-compose.override.example.yml` | Local sibling builds + private texts | +| `.env.example` | Stack knobs incl. Auth.js vars | +| `scripts/{setup-stack,grant-superuser,smoke}.sh` | Full-stack variants | + +--- + +## Task 1: `MARTYROLOGY_ZITADEL_INTERNAL_URL` + +**Files:** +- Modify: `src/martyrology_api/config.py` +- Modify: `src/martyrology_api/auth.py:18-108` +- Modify: `src/martyrology_api/app.py:31-35` +- Test: `tests/test_auth.py`, `tests/test_config.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Settings.zitadel_internal_url: str` (default `""`); `Authenticator.__init__(issuer, client_id, client_secret, project_id="", internal_url="", cache_ttl=300, cache_max=10_000, transport=None)`; attribute `Authenticator.internal_url: str`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_auth.py`: + +```python +def mock_transport_recording(seen: list[str], sub: str): + def handler(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json={"active": True, "sub": sub}) + + return httpx.MockTransport(handler) + + +@pytest.mark.asyncio +async def test_internal_url_is_used_for_introspection(): + seen: list[str] = [] + a = Authenticator( + "https://auth.example", + "cid", + "sec", + internal_url="http://zitadel:8080", + transport=mock_transport_recording(seen, "u1"), + ) + ident = await a.identity("tok-internal") + assert ident is not None + assert ident.subject == "u1" + assert seen == ["http://zitadel:8080/oauth/v2/introspect"] + + +@pytest.mark.asyncio +async def test_internal_url_defaults_to_issuer_and_strips_trailing_slash(): + seen: list[str] = [] + a = Authenticator( + "https://auth.example/", "cid", "sec", transport=mock_transport_recording(seen, "u2") + ) + await a.identity("tok-default") + assert seen == ["https://auth.example/oauth/v2/introspect"] + + +@pytest.mark.asyncio +async def test_internal_url_does_not_resurrect_auth_when_issuer_is_empty(): + # Transport-only override: an internal URL must never make a + # deliberately-disabled authenticator start answering. + a = Authenticator("", "cid", "sec", internal_url="http://zitadel:8080") + assert await a.identity("tok-no-issuer") is None +``` + +Append to `tests/test_config.py`: + +```python +def test_zitadel_internal_url_defaults_empty_and_does_not_affect_posture(): + s = Settings(_env_file=None) # pyright: ignore[reportCallIssue] + assert s.zitadel_internal_url == "" + + s2 = Settings( # pyright: ignore[reportCallIssue] + _env_file=None, zitadel_internal_url="http://zitadel:8080" + ) + assert s2.zitadel_internal_url == "http://zitadel:8080" + assert s2.auth_enabled is False +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_auth.py -k internal_url tests/test_config.py -k internal_url -v` +Expected: FAIL — `TypeError: Authenticator.__init__() got an unexpected keyword argument 'internal_url'`, and `AttributeError`/validation error for `zitadel_internal_url`. + +- [ ] **Step 3: Add the setting** + +In `src/martyrology_api/config.py`, immediately after the `zitadel_project_id` line: + +```python + # Transport-only override for the introspection endpoint. Empty = use + # zitadel_issuer. Set when the browser-facing issuer is not reachable from + # inside the API process: in Docker `localhost` is the container's own + # loopback, and behind Plesk nginx terminates upstream. This is NEVER an + # auth-posture input — `auth_enabled` still keys off zitadel_issuer alone. + zitadel_internal_url: str = "" +``` + +- [ ] **Step 4: Use it for introspection** + +In `src/martyrology_api/auth.py`, add the parameter to `Authenticator.__init__` after `project_id`: + +```python + project_id: str = "", + internal_url: str = "", +``` + +and set the attribute immediately after `self.project_id = project_id`: + +```python + # Where introspection is actually sent. `issuer` stays the public, + # browser-facing value asserted in the `iss` claim; only the transport + # target moves. + self.internal_url = (internal_url or issuer).rstrip("/") +``` + +In `identity()`, change the POST URL only — leave the `if not self.issuer: return None` guard exactly as it is: + +```python + f"{self.internal_url}/oauth/v2/introspect", +``` + +- [ ] **Step 5: Pass it through at the call site** + +In `src/martyrology_api/app.py`, the `Authenticator(...)` construction becomes: + +```python + app.state.authenticator = Authenticator( + settings.zitadel_issuer, + settings.zitadel_client_id, + settings.zitadel_client_secret, + settings.zitadel_project_id, + settings.zitadel_internal_url, + ) +``` + +- [ ] **Step 6: Run the full suite** + +Run: `pytest -q` +Expected: PASS, including the pre-existing `tests/test_auth.py` cases that construct `Authenticator` with three positional arguments. + +- [ ] **Step 7: Document it** + +In `.env.example`, immediately after the `MARTYROLOGY_ZITADEL_PROJECT_ID` block: + +```bash +# 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= +``` + +- [ ] **Step 8: Lint, type-check and commit** + +```bash +ruff check src tests && ruff format --check src tests && pyright +git add src/martyrology_api/config.py src/martyrology_api/auth.py src/martyrology_api/app.py tests/test_auth.py tests/test_config.py .env.example +git commit -S -m "Add MARTYROLOGY_ZITADEL_INTERNAL_URL for introspection transport" +``` + +--- + +## Task 2: Database URL setting and Alembic scaffold + +**Files:** +- Modify: `pyproject.toml` +- Modify: `src/martyrology_api/config.py` +- Create: `alembic.ini`, `alembic/env.py`, `alembic/script.py.mako`, `alembic/versions/0001_baseline.py` +- Test: `tests/test_migrations.py`, `tests/test_config.py` + +**Interfaces:** +- Consumes: `Settings` from Task 1. +- Produces: `Settings.database_url: str` (default `""`); an Alembic tree with exactly one head, revision id `0001_baseline`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_migrations.py`: + +```python +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + +ROOT = Path(__file__).resolve().parents[1] + + +def _script_directory() -> ScriptDirectory: + return ScriptDirectory.from_config(Config(str(ROOT / "alembic.ini"))) + + +def test_alembic_tree_has_exactly_one_head(): + # A second head means two migrations claim the same parent — `alembic + # upgrade head` then fails at deploy time rather than here. + assert len(_script_directory().get_heads()) == 1 + + +def test_baseline_revision_exists(): + revisions = {r.revision for r in _script_directory().walk_revisions()} + assert "0001_baseline" in revisions +``` + +Append to `tests/test_config.py`: + +```python +def test_database_url_defaults_empty(): + s = Settings(_env_file=None) # pyright: ignore[reportCallIssue] + assert s.database_url == "" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_migrations.py tests/test_config.py::test_database_url_defaults_empty -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'alembic'`. + +- [ ] **Step 3: Add the dependencies** + +In `pyproject.toml`, extend `[project].dependencies`: + +```toml +dependencies = [ + "fastapi>=0.140.0", + "uvicorn>=0.51.0", + "pydantic>=2.13.4", + "pydantic-settings>=2.3", + "httpx2>=2.9.1", + "alembic>=1.14", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.2", +] +``` + +Then run `uv sync --extra dev` to refresh `uv.lock` and the virtualenv. + +- [ ] **Step 4: Add the setting** + +In `src/martyrology_api/config.py`, after `zitadel_internal_url`: + +```python + # Postgres DSN for the `martyrology` database. Empty = no database + # configured; nothing in the API reads it yet. It exists so the + # permission-request and notification subsystem lands as migrations + # without a compose change. See the local-development-stack design, D9. + database_url: str = "" +``` + +- [ ] **Step 5: Create the Alembic tree** + +`alembic.ini`: + +```ini +[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 +``` + +`alembic/env.py`: + +```python +"""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() +``` + +`alembic/script.py.mako`: + +```mako +"""${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"} +``` + +`alembic/versions/0001_baseline.py`: + +```python +"""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 +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pytest tests/test_migrations.py tests/test_config.py -v` +Expected: PASS. + +- [ ] **Step 7: Verify Alembic runs against a real database** + +```bash +docker run --rm -d --name mig-check -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:17 +sleep 5 +MARTYROLOGY_DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:55432/postgres \ + uv run alembic upgrade head +MARTYROLOGY_DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:55432/postgres \ + uv run alembic current +docker rm -f mig-check +``` + +Expected: `alembic current` prints `0001_baseline (head)`. + +- [ ] **Step 8: Document and commit** + +Add to `.env.example`, after the internal-URL block: + +```bash +# Postgres DSN for the `martyrology` database. Empty outside the docker stack. +# MARTYROLOGY_DATABASE_URL=postgresql+psycopg://martyrology:martyrology@localhost:5432/martyrology +``` + +```bash +ruff check src tests && pyright && pytest -q +git add pyproject.toml uv.lock src/martyrology_api/config.py alembic alembic.ini tests/test_migrations.py tests/test_config.py .env.example +git commit -S -m "Add the martyrology database setting and an Alembic baseline" +``` + +--- + +## Task 3: API Dockerfile + +**Files:** +- Create: `Dockerfile`, `.dockerignore`, `scripts/init-db.sql` + +**Interfaces:** +- Consumes: the Alembic tree from Task 2. +- Produces: image `martyrology-api:latest` — serves on `:8000`, contains `/app/alembic`, `/app/scripts/init-db.sql`, `/data/crmedr`, `/data/clbdr`; env defaults `MARTYROLOGY_CRMEDR_PATH=/data/crmedr`, `MARTYROLOGY_CLBDR_PATH=/data/clbdr`, `MARTYROLOGY_DATA_PATH=/app/data/editions`. + +- [ ] **Step 1: Write `scripts/init-db.sql`** + +```sql +-- Bootstrap for the local development stack's Postgres. +-- +-- Runs once, on first initialisation of an empty postgres_data volume, via +-- /docker-entrypoint-initdb.d. Creates roles and databases only; no application +-- DDL lives here. Table DDL for the `martyrology` database belongs in +-- alembic/versions/ and is applied by the api-migrate service. +-- +-- Zitadel creates its own database from the admin credentials it is given, so +-- only the openfga and martyrology databases are created here. + +CREATE ROLE openfga WITH LOGIN PASSWORD 'openfga_secure_password'; +CREATE DATABASE openfga OWNER openfga; + +CREATE ROLE martyrology WITH LOGIN PASSWORD 'martyrology_secure_password'; +CREATE DATABASE martyrology OWNER martyrology; +``` + +- [ ] **Step 2: Write `.dockerignore`** + +``` +# 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 +scripts/__pycache__ +``` + +- [ ] **Step 3: Write the `Dockerfile`** + +```dockerfile +# 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-slim AS build + +# Pinned refs for the two data repositories. Override at build time with +# --build-arg when a specific data revision is needed. +ARG CRMEDR_REF=main +ARG CLBDR_REF=main + +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. +RUN git clone --depth 1 --branch "$CRMEDR_REF" \ + https://github.com/CatholicOS/crmedr.git /data/crmedr && \ + git clone --depth 1 --branch "$CLBDR_REF" \ + https://github.com/CatholicOS/clbdr.git /data/clbdr && \ + rm -rf /data/crmedr/.git /data/clbdr/.git + + +FROM python:3.12-slim AS main + +WORKDIR /app + +COPY --from=build /app/.venv /app/.venv +COPY --from=build /data /data +COPY src ./src +COPY data ./data +COPY alembic ./alembic +COPY alembic.ini ./ +COPY 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 + +EXPOSE 8000 + +CMD ["uvicorn", "martyrology_api.app:create_app", "--factory", \ + "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 4: Build the image** + +Run: `docker build -t martyrology-api:latest .` +Expected: build succeeds; the two `git clone` lines report cloning `crmedr` and `clbdr`. + +- [ ] **Step 5: Verify the container serves and carries its payload** + +```bash +docker run --rm -d --name mr-api-check -p 18000:8000 martyrology-api:latest +sleep 4 +curl -sf http://127.0.0.1:18000/healthz && echo " <- healthz OK" +curl -sf http://127.0.0.1:18000/api/v1/editions | head -c 200 && echo +docker exec mr-api-check ls /app/scripts/init-db.sql /app/alembic.ini /data/clbdr/data/editions.json +docker rm -f mr-api-check +``` + +Expected: `/healthz` returns 200; `/api/v1/editions` returns JSON; all three `ls` paths exist. A failure of `Registry.load` would have crashed the container before `/healthz` could answer, so a 200 here proves the cloned data repos are wired correctly. + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile .dockerignore scripts/init-db.sql +git commit -S -m "Add the API Dockerfile and database bootstrap SQL" +``` + +--- + +## Task 4: Minimal stack — Postgres, Zitadel, Mailpit, Adminer + +**Files:** +- Create: `docker-compose.yml`, `.env.example` additions, `.gitignore` additions + +**Interfaces:** +- Consumes: `scripts/init-db.sql` from Task 3. +- Produces: compose project `martyrology-infra` with services `db`, `zitadel`, `mailpit`, `adminer`; a host directory `./.zitadel-data/` containing `automation-user.pat`. + +- [ ] **Step 1: Extend `.gitignore`** + +``` +.zitadel-data/ +.stack-out/ +docker-compose.override.yml +``` + +- [ ] **Step 2: Write `docker-compose.yml`** + +```yaml +# Martyrology local development stack — INFRA ONLY. +# +# The API itself is NOT here. Run it on the host: +# uvicorn martyrology_api.app:create_app --factory --reload +# +# This mirrors LiturgicalCalendarAPI's stack, which likewise runs its API on the +# host and containerizes only the infrastructure. The fully containerized stack +# lives in the martyrology-frontend repo. +# +# Deliberate divergence from cdcf-infra production: there is no zitadel-login +# service and no nginx proxy. Nothing here performs an interactive browser +# sign-in — the API only ever calls /oauth/v2/introspect — so Zitadel is served +# directly and Login V2 is switched off. The frontend repo's stack is the one +# that mirrors production's single-origin topology. +# +# Bring-up: see README.md → "Local development stack". + +name: martyrology-infra + +services: + db: + image: postgres:17 + restart: unless-stopped + environment: + PGUSER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 30s + retries: 5 + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data:rw + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init.sql:ro + networks: + - martyrology + + zitadel: + image: ghcr.io/zitadel/zitadel:v4.15.0 + restart: unless-stopped + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}"' + environment: + ZITADEL_EXTERNALDOMAIN: localhost + ZITADEL_EXTERNALPORT: 8080 + ZITADEL_EXTERNALSECURE: false + ZITADEL_TLS_ENABLED: false + + ZITADEL_DATABASE_POSTGRES_HOST: db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + # No Login V2 in this stack — there is no frontend to log in to, and the + # v2 UI would have nothing routing to it. Zitadel's built-in console + # login is used for the admin console. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: false + + # The automation PAT setup-zitadel.sh authenticates with. Written into + # the bind-mounted ./.zitadel-data/ so the host-run script can read it. + # The filename matches setup-zitadel.sh's ZITADEL_PAT_FILE convention. + 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' + + ZITADEL_FIRSTINSTANCE_ORG_NAME: "Martyrology" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1! + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false + + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: mailpit:1025 + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: false + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: noreply@martyrology.localhost + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: Martyrology + + ZITADEL_LOG_LEVEL: info + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + user: "0" + volumes: + - ./.zitadel-data:/zitadel-data:delegated + ports: + - "127.0.0.1:${ZITADEL_PORT:-8080}:8080" + networks: + - martyrology + depends_on: + db: + condition: service_healthy + mailpit: + condition: service_started + + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + ports: + - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" + networks: + - martyrology + + adminer: + image: adminer:latest + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: lucas-sandery + ports: + - "127.0.0.1:${ADMINER_PORT:-8088}:8080" + networks: + - martyrology + depends_on: + - db + +networks: + martyrology: + driver: bridge + +volumes: + postgres_data: + driver: local +``` + +- [ ] **Step 3: Write `.env.example` stack section** + +Append to `.env.example`: + +```bash +# --- 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 +ZITADEL_PORT=8080 +MAILPIT_PORT=8025 +ADMINER_PORT=8088 +OPENFGA_HTTP_PORT=8083 +OPENFGA_GRPC_PORT=8084 +OPENFGA_PLAYGROUND_PORT=3001 + +# 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 +``` + +- [ ] **Step 4: Bring the stack up** + +```bash +cp -n .env.example .env +docker compose up -d +docker compose ps +``` + +Expected: `db` and `zitadel` reach `healthy`; `mailpit` and `adminer` are `running`. + +- [ ] **Step 5: Verify the databases and the PAT** + +```bash +docker compose exec -T db psql -U postgres -tAc \ + "SELECT datname FROM pg_database WHERE datname IN ('zitadel','openfga','martyrology') ORDER BY 1" +test -s ./.zitadel-data/automation-user.pat && echo "PAT written" +curl -sf http://localhost:8080/.well-known/openid-configuration | head -c 120 && echo +``` + +Expected: three database names printed (`martyrology`, `openfga`, `zitadel`); `PAT written`; discovery JSON served. + +- [ ] **Step 6: Commit** + +```bash +git add docker-compose.yml .env.example .gitignore +git commit -S -m "Add the minimal stack: Postgres, Zitadel, Mailpit, Adminer" +``` + +--- + +## Task 5: OpenFGA and the `authz-seed` one-shot + +**Files:** +- Modify: `docker-compose.yml` + +**Interfaces:** +- Consumes: the `db` and network from Task 4; `OPENFGA_PRESHARED_KEY` from `.env`. +- Produces: services `openfga-migrate`, `openfga`, `authz-seed`; an OpenFGA store named `Martyrology` holding 11 structural tuples, reachable at `http://openfga:8080` inside the network and `http://localhost:8083` from the host. + +- [ ] **Step 1: Add the services to `docker-compose.yml`** + +Insert before the `adminer` service: + +```yaml + openfga-migrate: + image: openfga/openfga:v1.15.1 + command: migrate + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + networks: + - martyrology + restart: "no" + depends_on: + db: + condition: service_healthy + + openfga: + image: openfga/openfga:v1.15.1 + command: run + restart: unless-stopped + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + # Preshared auth is REQUIRED, not a hardening choice. Settings.authz_enabled + # is `bool(api_url and store_id and api_token)`, so a tokenless OpenFGA + # leaves MARTYROLOGY_OPENFGA_API_TOKEN empty, authz disabled, and every + # check denied — while the stack reports perfectly healthy. + OPENFGA_AUTHN_METHOD: preshared + OPENFGA_AUTHN_PRESHARED_KEYS: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + OPENFGA_PLAYGROUND_ENABLED: "${OPENFGA_PLAYGROUND_ENABLED:-false}" + healthcheck: + test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:8081"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 10s + ports: + - "127.0.0.1:${OPENFGA_HTTP_PORT:-8083}:8080" + - "127.0.0.1:${OPENFGA_GRPC_PORT:-8084}:8081" + - "127.0.0.1:${OPENFGA_PLAYGROUND_PORT:-3001}:3000" + networks: + - martyrology + depends_on: + openfga-migrate: + condition: service_completed_successfully + + # Creates the Martyrology store, uploads the authorization model, and seeds + # the 8 governed_by + 3 on_platform structural tuples. + # + # The model is CLONED from cdcf-infra rather than vendored into this repo: + # auth/models/Martyrology{,.tuples}.json is the authoritative copy production + # uploads, and a second copy here would drift silently. cdcf-infra is public, + # so this works from a bare clone with no sibling checkouts. + # + # Idempotent: setup-openfga.sh reads the store's existing tuples first and + # writes only the difference. A second run reports everything already present. + authz-seed: + image: alpine:3.21 + restart: "no" + environment: + CDCF_INFRA_REF: "${CDCF_INFRA_REF:-main}" + OPENFGA_PRESHARED_KEY: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + entrypoint: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache bash curl jq git >/dev/null + rm -rf /tmp/cdcf-infra + git clone --depth 1 --branch "$$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git /tmp/cdcf-infra + cd /tmp/cdcf-infra/auth + cat > .env.local <` followed by `✓ Wrote 11 new structural tuple(s) (11 declared in file)`. + +- [ ] **Step 3: Verify the store contents** + +```bash +KEY=$(grep '^OPENFGA_PRESHARED_KEY=' .env | cut -d= -f2) +STORE=$(curl -sf -H "Authorization: Bearer $KEY" http://localhost:8083/stores \ + | jq -r '.stores[] | select(.name=="Martyrology") | .id') +echo "store: $STORE" +curl -sf -X POST "http://localhost:8083/stores/$STORE/read" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{}' | jq '.tuples | length' +``` + +Expected: a store ID is printed, and the tuple count is `11`. + +- [ ] **Step 4: Verify idempotency** + +Run: `docker compose up -d --force-recreate authz-seed && docker compose logs --tail=5 authz-seed` +Expected: `All 11 structural tuple(s) already present — nothing to write`. + +- [ ] **Step 5: Determine whether the Playground works with preshared auth** + +```bash +OPENFGA_PLAYGROUND_ENABLED=true docker compose up -d --force-recreate openfga +sleep 5 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/playground +docker compose logs --tail=20 openfga +``` + +If OpenFGA refuses to start or the Playground 404s, remove the three Playground lines (`OPENFGA_PLAYGROUND_ENABLED`, the `3001` port mapping, `OPENFGA_PLAYGROUND_PORT` in `.env.example`) and add a comment saying preshared auth precludes it — the spec anticipates this outcome. Otherwise leave it as an opt-in default-off knob. Record which happened in the commit message. + +- [ ] **Step 6: Commit** + +```bash +git add docker-compose.yml .env.example +git commit -S -m "Add OpenFGA and seed the Martyrology store from cdcf-infra" +``` + +--- + +## Task 6: `api-migrate` one-shot + +**Files:** +- Modify: `docker-compose.yml` + +**Interfaces:** +- Consumes: `db` from Task 4; the Alembic tree from Task 2; the `Dockerfile` from Task 3. +- Produces: service `api-migrate`, which leaves the `martyrology` database stamped at `0001_baseline`. + +- [ ] **Step 1: Add the service** + +Insert after `authz-seed` in `docker-compose.yml`: + +```yaml + # One-shot Alembic runner for the `martyrology` database. + # + # scripts/init-db.sql (run by db on first init) is bootstrap-only: roles and + # empty databases. Application-table DDL lives in alembic/versions/ and is + # applied here. Today that is a single baseline revision creating nothing — + # the service exists so the permission-request and notification subsystem + # lands as migrations without a compose change. + # + # Re-runnable and a no-op when up to date. Rebuild with + # `docker compose up -d --build api-migrate` so newly-pulled migrations land + # in the image before it runs. + api-migrate: + build: . + image: martyrology-api:latest + command: ["alembic", "upgrade", "head"] + environment: + MARTYROLOGY_DATABASE_URL: postgresql+psycopg://martyrology:martyrology_secure_password@db:5432/martyrology + networks: + - martyrology + restart: "no" + depends_on: + db: + condition: service_healthy +``` + +- [ ] **Step 2: Run it** + +```bash +docker compose up -d --build api-migrate +docker compose logs api-migrate +``` + +Expected: `Running upgrade -> 0001_baseline, Baseline`. + +- [ ] **Step 3: Verify the stamp** + +```bash +docker compose run --rm --entrypoint alembic api-migrate current +``` + +Expected: `0001_baseline (head)`. + +- [ ] **Step 4: Verify it is a no-op on a second run** + +Run: `docker compose up -d --force-recreate api-migrate && docker compose logs --tail=5 api-migrate` +Expected: no `Running upgrade` line; the container exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml +git commit -S -m "Add the api-migrate one-shot Alembic service" +``` + +--- + +## Task 7: `setup-stack.sh` and `grant-superuser.sh` + +**Files:** +- Create: `scripts/setup-stack.sh`, `scripts/grant-superuser.sh` + +**Interfaces:** +- Consumes: the running stack from Tasks 4–6; `./.zitadel-data/automation-user.pat`. +- Produces: `.env` gains `MARTYROLOGY_ZITADEL_ISSUER`, `MARTYROLOGY_ZITADEL_INTERNAL_URL`, `MARTYROLOGY_ZITADEL_CLIENT_ID`, `MARTYROLOGY_ZITADEL_CLIENT_SECRET`, `MARTYROLOGY_ZITADEL_PROJECT_ID`, `MARTYROLOGY_OPENFGA_API_URL`, `MARTYROLOGY_OPENFGA_STORE_ID`, `MARTYROLOGY_OPENFGA_MODEL_ID`, `MARTYROLOGY_OPENFGA_API_TOKEN`. + +- [ ] **Step 1: Write `scripts/setup-stack.sh`** + +```bash +#!/usr/bin/env bash +# +# setup-stack.sh — provision the local Zitadel and discover the OpenFGA IDs, +# then write both into .env. +# +# Phase 2 of the three-phase bring-up (see README.md → "Local development +# stack"). The store ID, model ID, client ID and client secret are all +# GENERATED at provisioning time, so they cannot be committed; this script +# captures them. +# +# ⚠ The client secret is emitted ONCE, by the run that creates the app. +# Zitadel's ListApplications API does not return secrets, so a re-run against +# an existing app cannot recover it. If .env is lost, rotate in the console: +# Martyrology Org → Projects → MartyrologyAPI → Apps → Regenerate Client Secret +# +# Usage: ./scripts/setup-stack.sh --update-env + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +UPDATE_ENV=0 +[[ "${1:-}" == "--update-env" ]] && UPDATE_ENV=1 +if [[ $UPDATE_ENV -eq 0 ]]; then + echo "Usage: $0 --update-env" >&2 + exit 64 +fi + +ENV_FILE=".env" +PAT_FILE="./.zitadel-data/automation-user.pat" +ZITADEL_PORT="$(grep -E '^ZITADEL_PORT=' "$ENV_FILE" | cut -d= -f2 || true)" +ZITADEL_PORT="${ZITADEL_PORT:-8080}" +OPENFGA_HTTP_PORT="$(grep -E '^OPENFGA_HTTP_PORT=' "$ENV_FILE" | cut -d= -f2 || true)" +OPENFGA_HTTP_PORT="${OPENFGA_HTTP_PORT:-8083}" +PRESHARED_KEY="$(grep -E '^OPENFGA_PRESHARED_KEY=' "$ENV_FILE" | cut -d= -f2)" +CDCF_INFRA_REF="$(grep -E '^CDCF_INFRA_REF=' "$ENV_FILE" | cut -d= -f2 || true)" +CDCF_INFRA_REF="${CDCF_INFRA_REF:-main}" + +ISSUER="http://localhost:${ZITADEL_PORT}" +WORKDIR=".stack-out" +mkdir -p "$WORKDIR" + +# --- wait for Zitadel ----------------------------------------------------- +echo "Waiting for Zitadel at $ISSUER ..." +for _ in $(seq 1 60); do + if curl -sf "$ISSUER/.well-known/openid-configuration" >/dev/null; then break; fi + sleep 2 +done +curl -sf "$ISSUER/.well-known/openid-configuration" >/dev/null \ + || { echo "Zitadel never became ready" >&2; exit 1; } +[[ -s "$PAT_FILE" ]] || { echo "PAT not found at $PAT_FILE" >&2; exit 1; } + +# --- clone or refresh cdcf-infra ----------------------------------------- +INFRA_DIR="$WORKDIR/cdcf-infra" +if [[ -d "$INFRA_DIR/.git" ]]; then + git -C "$INFRA_DIR" fetch --quiet origin "$CDCF_INFRA_REF" + git -C "$INFRA_DIR" checkout --quiet "FETCH_HEAD" +else + git clone --quiet --depth 1 --branch "$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git "$INFRA_DIR" +fi + +# --- provision Zitadel ---------------------------------------------------- +# ZITADEL_PAT_FILE must be absolute: setup-zitadel.sh runs from auth/. +cat > "$INFRA_DIR/auth/.env.local" <&2; exit 1; } +[[ -n "$PROJECT_ID" ]] || { echo "No project ID in provisioner output" >&2; exit 1; } + +# --- discover the OpenFGA IDs -------------------------------------------- +# Queried from the API rather than parsed out of setup-openfga.sh's output: +# the store already exists (authz-seed created it), and an API read is stable +# where output parsing is not. +FGA="http://localhost:${OPENFGA_HTTP_PORT}" +STORE_ID="$(curl -sf -H "Authorization: Bearer $PRESHARED_KEY" "$FGA/stores" \ + | jq -r '.stores[] | select(.name=="Martyrology") | .id' | head -1)" +[[ -n "$STORE_ID" ]] || { echo "No Martyrology store found at $FGA" >&2; exit 1; } + +MODEL_ID="$(curl -sf -H "Authorization: Bearer $PRESHARED_KEY" \ + "$FGA/stores/$STORE_ID/authorization-models?page_size=1" \ + | jq -r '.authorization_models[0].id')" +[[ -n "$MODEL_ID" && "$MODEL_ID" != "null" ]] \ + || { echo "No authorization model in store $STORE_ID" >&2; exit 1; } + +# --- write .env ----------------------------------------------------------- +set_env() { + local key="$1" value="$2" + if grep -qE "^${key}=" "$ENV_FILE"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE" + fi +} + +set_env MARTYROLOGY_ZITADEL_ISSUER "$ISSUER" +set_env MARTYROLOGY_ZITADEL_INTERNAL_URL "$ISSUER" +set_env MARTYROLOGY_ZITADEL_CLIENT_ID "$CLIENT_ID" +set_env MARTYROLOGY_ZITADEL_PROJECT_ID "$PROJECT_ID" +set_env MARTYROLOGY_OPENFGA_API_URL "$FGA" +set_env MARTYROLOGY_OPENFGA_STORE_ID "$STORE_ID" +set_env MARTYROLOGY_OPENFGA_MODEL_ID "$MODEL_ID" +set_env MARTYROLOGY_OPENFGA_API_TOKEN "$PRESHARED_KEY" + +if [[ -n "$CLIENT_SECRET" ]]; then + set_env MARTYROLOGY_ZITADEL_CLIENT_SECRET "$CLIENT_SECRET" + echo "✓ Client secret captured (one-time emit)." +else + echo "⚠ No client secret emitted — the app already existed." >&2 + echo " Existing MARTYROLOGY_ZITADEL_CLIENT_SECRET in .env left untouched." >&2 + grep -qE '^MARTYROLOGY_ZITADEL_CLIENT_SECRET=.+' "$ENV_FILE" \ + || echo " .env has NO secret. Rotate it in the Zitadel console." >&2 +fi + +echo +echo "✓ .env updated. Restart the API to pick up the new values." +``` + +Make it executable: `chmod +x scripts/setup-stack.sh`. + +- [ ] **Step 2: Write `scripts/grant-superuser.sh`** + +```bash +#!/usr/bin/env bash +# +# grant-superuser.sh — write the platform:martyrology superuser tuple. +# +# Out-of-band by design, exactly as in production. The API's +# /api/v1/admin/permissions endpoint fixes its object type to governance_body, +# so platform: tuples are structurally unreachable through it — otherwise any +# body admin could mint themselves a superuser. Every superuser grant, not just +# the first, is made this way. +# +# The `sub` only exists after that account has signed in once, which is why +# this cannot be folded into setup-stack.sh. +# +# Usage: ./scripts/grant-superuser.sh +# Revoke: ./scripts/grant-superuser.sh --revoke + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +SUB="${1:-}" +[[ -n "$SUB" ]] || { echo "Usage: $0 [--revoke]" >&2; exit 64; } +OP="writes" +[[ "${2:-}" == "--revoke" ]] && OP="deletes" + +ENV_FILE=".env" +API_URL="$(grep -E '^MARTYROLOGY_OPENFGA_API_URL=' "$ENV_FILE" | cut -d= -f2-)" +STORE_ID="$(grep -E '^MARTYROLOGY_OPENFGA_STORE_ID=' "$ENV_FILE" | cut -d= -f2)" +TOKEN="$(grep -E '^MARTYROLOGY_OPENFGA_API_TOKEN=' "$ENV_FILE" | cut -d= -f2)" + +for v in API_URL STORE_ID TOKEN; do + [[ -n "${!v}" ]] || { echo "$v missing from $ENV_FILE — run setup-stack.sh first" >&2; exit 1; } +done + +curl -sS --fail-with-body -X POST "$API_URL/stores/$STORE_ID/write" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"$OP\":{\"tuple_keys\":[{\"user\":\"user:$SUB\",\"relation\":\"superuser\",\"object\":\"platform:martyrology\"}]}}" + +echo +echo "✓ $OP superuser tuple for user:$SUB" +``` + +Make it executable: `chmod +x scripts/grant-superuser.sh`. + +- [ ] **Step 3: Run the provisioner** + +```bash +./scripts/setup-stack.sh --update-env +grep -E '^MARTYROLOGY_(ZITADEL|OPENFGA)_' .env +``` + +Expected: all nine keys present and non-empty, including `MARTYROLOGY_ZITADEL_CLIENT_SECRET`. + +- [ ] **Step 4: Verify idempotency and the secret warning** + +Run: `./scripts/setup-stack.sh --update-env` +Expected: succeeds; prints `⚠ No client secret emitted — the app already existed.`; the existing `.env` secret is unchanged (`grep MARTYROLOGY_ZITADEL_CLIENT_SECRET .env` shows the same value as Step 3). + +- [ ] **Step 5: Verify the API accepts the configuration** + +```bash +set -a; . ./.env; set +a +uv run uvicorn martyrology_api.app:create_app --factory --port 8000 & +sleep 4 +curl -sf http://localhost:8000/healthz && echo " <- healthz OK" +kill %1 +``` + +Expected: 200, and **no** `OpenFGA is partially configured` warning in the uvicorn log — that warning firing means one of the four OpenFGA values did not land. + +- [ ] **Step 6: Verify the superuser grant** + +```bash +# Any sub works for this check; the tuple write does not validate existence. +./scripts/grant-superuser.sh 000000000000000000 +KEY=$(grep '^OPENFGA_PRESHARED_KEY=' .env | cut -d= -f2) +STORE=$(grep '^MARTYROLOGY_OPENFGA_STORE_ID=' .env | cut -d= -f2) +curl -sf -X POST "http://localhost:8083/stores/$STORE/check" \ + -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ + -d '{"tuple_key":{"user":"user:000000000000000000","relation":"can_read_texts","object":"edition:martyrologium_romanum_2004"}}' +./scripts/grant-superuser.sh 000000000000000000 --revoke +``` + +Expected: `{"allowed":true}` — proving the `platform → on_platform → admin → editor → reader → can_read_texts` chain resolves. The revoke leaves the store clean. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/setup-stack.sh scripts/grant-superuser.sh +git commit -S -m "Add local stack provisioning and superuser grant scripts" +``` + +--- + +## Task 8: Minimal-stack smoke test and README + +**Files:** +- Create: `scripts/smoke.sh` +- Modify: `README.md` + +**Interfaces:** +- Consumes: everything from Tasks 4–7. +- Produces: `scripts/smoke.sh`, exit 0 on a healthy stack. + +- [ ] **Step 1: Write `scripts/smoke.sh`** + +```bash +#!/usr/bin/env bash +# +# smoke.sh — assert the bring-up invariants a compose file can get wrong. +# +# Not a substitute for pytest: this checks wiring, not behaviour. Run it after +# `setup-stack.sh --update-env` with the API running on the host. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +set -a; . ./.env; set +a + +PASS=0; FAIL=0; SKIP=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL+1)); } +skip() { printf ' ~ %s\n' "$1"; SKIP=$((SKIP+1)); } + +API="http://localhost:${API_PORT:-8000}" +FGA="${MARTYROLOGY_OPENFGA_API_URL:-http://localhost:8083}" +ISSUER="${MARTYROLOGY_ZITADEL_ISSUER:-http://localhost:8080}" + +echo "1. Zitadel discovery" +curl -sf "$ISSUER/.well-known/openid-configuration" | jq -e '.issuer' >/dev/null \ + && ok "discovery served at $ISSUER" || bad "no discovery document at $ISSUER" + +echo "2. OpenFGA structural tuples" +COUNT=$(curl -sf -X POST "$FGA/stores/$MARTYROLOGY_OPENFGA_STORE_ID/read" \ + -H "Authorization: Bearer $MARTYROLOGY_OPENFGA_API_TOKEN" \ + -H "Content-Type: application/json" -d '{}' | jq '.tuples | length') +[[ "$COUNT" == "11" ]] && ok "11 structural tuples" || bad "expected 11 tuples, got ${COUNT:-none}" + +echo "3. Alembic is at head" +CUR=$(docker compose run --rm --entrypoint alembic api-migrate current 2>/dev/null | tr -d '\r') +grep -q '(head)' <<<"$CUR" && ok "alembic current is at head" || bad "alembic not at head: $CUR" + +echo "4. API health" +curl -sf "$API/healthz" >/dev/null && ok "GET /healthz 200" || bad "GET /healthz failed" + +echo "5. Anonymous read of a restricted edition is redacted" +# "no such edition" and "redacted" are BOTH 200-shaped to a careless check, so +# distinguish them: absent edition => skip, present => must be redacted. +BODY=$(curl -sf "$API/api/v1/elogia/edition/martyrologium_romanum_2004/01/02" 2>/dev/null) +if [[ -z "$BODY" ]]; then + skip "martyrologium_romanum_2004 not attached (martyrology-texts not mounted)" +else + ACCESS=$(jq -r '.metadata.access // empty' <<<"$BODY") + TEXT=$(jq -r '.elogia[0].text // "null"' <<<"$BODY") + [[ "$ACCESS" == "restricted-texts" && "$TEXT" == "null" ]] \ + && ok "access=restricted-texts with text=null" \ + || bad "expected redaction, got access=$ACCESS text=$TEXT" +fi + +echo +printf 'passed %d, failed %d, skipped %d\n' "$PASS" "$FAIL" "$SKIP" +[[ $FAIL -eq 0 ]] +``` + +Make it executable: `chmod +x scripts/smoke.sh`. + +- [ ] **Step 2: Run it** + +```bash +set -a; . ./.env; set +a +uv run uvicorn martyrology_api.app:create_app --factory --port 8000 & +sleep 4 +./scripts/smoke.sh +kill %1 +``` + +Expected: assertions 1–4 pass; assertion 5 skips (this repo's `data/editions` holds only the two public-domain editions). Exit code 0. + +- [ ] **Step 3: Add the README section** + +Insert after the existing development instructions in `README.md`: + +````markdown +## 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. 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 | | `root@martyrology.localhost` / `RootPassword1!` | +| OpenFGA API | | Bearer `OPENFGA_PRESHARED_KEY` from `.env` | +| Adminer | | server `db`, user `postgres`, password `postgres` | +| Mailpit | | — | + +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 +``` + +**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. +```` + +- [ ] **Step 4: Commit and open the PR** + +```bash +git add scripts/smoke.sh README.md +git commit -S -m "Add the minimal-stack smoke test and document the bring-up" +git push -u origin feat/local-dev-stack +gh pr create --title "Local development stack: infra-only compose for martyrology-api" \ + --body "Implements Tasks 1-8 of docs/superpowers/plans/2026-08-04-local-development-stack.md. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Task 9: Frontend Dockerfile + +**Repo:** `martyrology-frontend`, branch `feat/local-dev-stack` + +**Files:** +- Create: `Dockerfile`, `.dockerignore` + +**Interfaces:** +- Consumes: `next.config.ts`, which already sets `output: "standalone"`. +- Produces: image `martyrology-frontend:latest`, serving on `:3000`, honouring `API_BASE` at runtime. + +- [ ] **Step 1: Write `.dockerignore`** + +``` +node_modules +.next +.git +.env +.env.* +!.env.example +coverage +*.tsbuildinfo +docker-compose.override.yml +``` + +- [ ] **Step 2: Write the `Dockerfile`** + +```dockerfile +# martyrology-frontend image. +# +# next.config.ts already sets output: "standalone" for the Plesk deploy, which +# is exactly what a lean container wants: a self-contained server.js plus a +# pruned node_modules. +# +# NOTE: this is a PRODUCTION image. It does not hot-reload from a bind mount. +# For frontend iteration, stop this service and run `npm run dev` on the host — +# port 3000 is then free and the registered OIDC callback still matches. + +FROM node:24-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:24-slim AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:24-slim AS main +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +- [ ] **Step 3: Build and run** + +```bash +docker build -t martyrology-frontend:latest . +docker run --rm -d --name mr-fe-check -p 13000:3000 martyrology-frontend:latest +sleep 5 +curl -sf -o /dev/null -w '%{http_code}\n' http://127.0.0.1:13000/ +docker rm -f mr-fe-check +``` + +Expected: build succeeds; the request returns `200`. + +- [ ] **Step 4: Commit** + +```bash +git add Dockerfile .dockerignore +git commit -S -m "Add the frontend Dockerfile" +``` + +--- + +## Task 10: Full stack — infrastructure and the single-origin proxy + +**Repo:** `martyrology-frontend` + +**Files:** +- Create: `docker-compose.yml`, `docker/nginx/zitadel.local.conf`, `.env.example` +- Modify: `.gitignore` + +**Interfaces:** +- Consumes: image `martyrology-api:latest` (Task 3) for `db-init`. +- Produces: compose project `martyrology` with `db-init`, `db`, `zitadel`, `zitadel-login`, `zitadel-proxy`, `mailpit`, `adminer`; Zitadel reachable on the single origin `http://localhost:8080`, with the v2 login UI at `/ui/v2/login`. + +- [ ] **Step 1: Extend `.gitignore`** + +``` +.zitadel-data/ +.stack-out/ +docker-compose.override.yml +``` + +- [ ] **Step 2: Write `docker/nginx/zitadel.local.conf`** + +```nginx +# Local-development copy of cdcf-infra's auth/nginx/zitadel.conf. +# +# ⚠ This is a SECOND COPY of a file whose comments carry real reasoning. Only +# the Content-Security-Policy differs — the routing half must stay in step with +# the original at: +# https://github.com/CatholicOS/cdcf-infra/blob/main/auth/nginx/zitadel.conf +# +# Why the CSP differs: production's connect-src allowlists the CDCF and LitCal +# origins. It does not include this stack's origins, so reusing it verbatim +# would block the local frontend's post-login RSC prefetch. The mechanism is +# unchanged — proxy_hide_header strips the upstream header, because multi-CSP +# semantics are intersection and appending alone cannot widen anything. +# +# Routing: +# /ui/v2/login* -> zitadel-login:3000 +# everything else -> zitadel:8080 + +upstream zitadel_backend { + server zitadel:8080; +} + +upstream zitadel_login { + server zitadel-login:3000; +} + +server { + listen 80 default_server; + server_name _; + + client_max_body_size 10m; + + location /ui/v2/login { + proxy_pass http://zitadel_login; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # http, not https: this stack terminates nothing and runs over plain + # HTTP, matching ZITADEL_EXTERNALSECURE=false. + proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ''; + + 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 http://localhost:8080; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' http://zitadel:8080; frame-ancestors 'none'; object-src 'none'" always; + } + + location / { + proxy_pass http://zitadel_backend; + proxy_http_version 1.1; + proxy_set_header Host $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 $host; + proxy_request_buffering off; + proxy_buffering off; + } +} +``` + +- [ ] **Step 3: Write the infrastructure half of `docker-compose.yml`** + +```yaml +# Martyrology full development stack. +# +# Mirrors cdcf-infra production topology: Zitadel and the v2 login UI behind a +# single nginx origin, with image versions pinned to production's. That +# fidelity is the point — this stack exists to verify an OIDC flow that will +# run against cdcf-infra, and redirect URIs, issuer discovery and CSP are +# exactly what a two-origin issuer gets wrong. +# +# Builds from GitHub refs by default so a bare clone stands the whole system up. +# For local sibling checkouts: +# cp docker-compose.override.example.yml docker-compose.override.yml +# +# Bring-up: see README.md → "Local development stack". +# +# Port 8081 is deliberately unused: login v2 sits behind the proxy at +# :8080/ui/v2/login rather than on its own origin. + +name: martyrology + +services: + # Extracts init-db.sql from the API image, so the database bootstrap has one + # source of truth and no copy is maintained in this repo. + db-init: + image: martyrology-api:latest + build: https://github.com/CatholicOS/martyrology-api.git#main + entrypoint: ["cp", "/app/scripts/init-db.sql", "/init/01-init.sql"] + volumes: + - db_init_scripts:/init + restart: "no" + + db: + image: postgres:17 + restart: unless-stopped + environment: + PGUSER: postgres + POSTGRES_PASSWORD: postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 30s + retries: 5 + ports: + - "127.0.0.1:${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data:rw + - db_init_scripts:/docker-entrypoint-initdb.d:ro + networks: + - martyrology + depends_on: + db-init: + condition: service_completed_successfully + + # Publishes NOTHING. zitadel-proxy owns the :8080 origin. + zitadel: + image: ghcr.io/zitadel/zitadel:v4.15.0 + restart: unless-stopped + command: 'start-from-init --masterkey "${ZITADEL_MASTERKEY:-MasterkeyNeedsToHave32Characters}"' + environment: + ZITADEL_EXTERNALDOMAIN: localhost + # Describes the PROXY's published port, which is the origin browsers and + # OIDC clients see. + ZITADEL_EXTERNALPORT: 8080 + ZITADEL_EXTERNALSECURE: false + ZITADEL_TLS_ENABLED: false + + ZITADEL_DATABASE_POSTGRES_HOST: db + ZITADEL_DATABASE_POSTGRES_PORT: 5432 + ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel + ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: postgres + ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable + ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel + ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel + ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable + + ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH: /zitadel-data/login-client.pat + 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' + + # Single origin: the login UI lives under the proxy, not on its own port. + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED: true + ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI: http://localhost:8080/ui/v2/login + ZITADEL_OIDC_DEFAULTLOGINURLV2: http://localhost:8080/ui/v2/login/login?authRequest= + ZITADEL_OIDC_DEFAULTLOGOUTURLV2: http://localhost:8080/ui/v2/login/logout?post_logout_redirect= + + 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' + + ZITADEL_FIRSTINSTANCE_ORG_NAME: "Martyrology" + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: root + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: RootPassword1! + ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: false + + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_HOST: mailpit:1025 + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_USER: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_SMTP_PASSWORD: "" + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_TLS: false + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROM: noreply@martyrology.localhost + ZITADEL_DEFAULTINSTANCE_SMTPCONFIGURATION_FROMNAME: Martyrology + + ZITADEL_LOG_LEVEL: info + healthcheck: + test: ["CMD", "/app/zitadel", "ready"] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s + user: "0" + volumes: + - ./.zitadel-data:/zitadel-data:delegated + networks: + - martyrology + depends_on: + db: + condition: service_healthy + mailpit: + condition: service_started + + zitadel-login: + image: ghcr.io/zitadel/zitadel-login:v4.15.0 + restart: unless-stopped + environment: + # Reaches the backend over the docker network, not through the proxy. + ZITADEL_API_URL: http://zitadel:8080 + NEXT_PUBLIC_BASE_PATH: /ui/v2/login + ZITADEL_SERVICE_USER_TOKEN_FILE: /zitadel-data/login-client.pat + EMAIL_VERIFICATION: true + user: "0" + volumes: + - ./.zitadel-data:/zitadel-data:ro + networks: + - martyrology + depends_on: + zitadel: + condition: service_healthy + restart: false + + zitadel-proxy: + image: nginx:alpine + restart: unless-stopped + volumes: + - ./docker/nginx/zitadel.local.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - "127.0.0.1:${ZITADEL_PORT:-8080}:80" + networks: + - martyrology + depends_on: + - zitadel + - zitadel-login + + mailpit: + image: axllent/mailpit:latest + restart: unless-stopped + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + ports: + - "127.0.0.1:${MAILPIT_PORT:-8025}:8025" + networks: + - martyrology + + adminer: + image: adminer:latest + restart: unless-stopped + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: lucas-sandery + ports: + - "127.0.0.1:${ADMINER_PORT:-8088}:8080" + networks: + - martyrology + depends_on: + - db + +networks: + martyrology: + driver: bridge + +volumes: + postgres_data: + driver: local + db_init_scripts: + driver: local +``` + +- [ ] **Step 4: Write `.env.example`** + +```bash +# Frontend runtime +API_BASE=http://localhost:8000 + +# --- Local development stack (docker compose) --------------------------- +DB_PORT=5432 +ZITADEL_PORT=8080 +MAILPIT_PORT=8025 +ADMINER_PORT=8088 +OPENFGA_HTTP_PORT=8083 +OPENFGA_GRPC_PORT=8084 +OPENFGA_PLAYGROUND_PORT=3001 +API_PORT=8000 +# Fixed by cdcf-infra: --target local registers +# http://localhost:3000/api/auth/callback/zitadel as the OIDC redirect URI. +FRONTEND_PORT=3000 + +# Must be EXACTLY 32 characters. Generate with: openssl rand -hex 16 +ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters + +# REQUIRED: an empty MARTYROLOGY_OPENFGA_API_TOKEN disables authorization +# entirely while the stack still reports healthy. +OPENFGA_PRESHARED_KEY=local-dev-preshared-key + +CDCF_INFRA_REF=main +MARTYROLOGY_API_REF=main +MARTYROLOGY_FRONTEND_REF=main + +# Auth.js — written by ./scripts/setup-stack.sh --update-env +AUTH_URL=http://localhost:3000 +# Generate with: openssl rand -base64 32 +AUTH_SECRET= +AUTH_ZITADEL_ID= +AUTH_ZITADEL_SECRET= +``` + +- [ ] **Step 5: Bring it up and verify the single origin** + +```bash +cp -n .env.example .env +docker compose up -d +sleep 20 +curl -sf http://localhost:8080/.well-known/openid-configuration | jq -r '.issuer' +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/ui/v2/login/login +curl -sI http://localhost:8080/ui/v2/login/login | grep -i content-security-policy +``` + +Expected: issuer is `http://localhost:8080`; the login path returns 200 or 3xx (not 404 — a 404 means the proxy is routing to the backend); the CSP header's `connect-src` contains `http://localhost:3000`. + +- [ ] **Step 6: Commit** + +```bash +git add docker-compose.yml docker/nginx/zitadel.local.conf .env.example .gitignore +git commit -S -m "Add the full stack's infrastructure and single-origin Zitadel proxy" +``` + +--- + +## Task 11: Full stack — OpenFGA, migrations, and the two application services + +**Repo:** `martyrology-frontend` + +**Files:** +- Modify: `docker-compose.yml` + +**Interfaces:** +- Consumes: the network and `db` from Task 10. +- Produces: services `openfga-migrate`, `openfga`, `authz-seed`, `api-migrate`, `martyrology-api` (`:8000`), `martyrology-frontend` (`:3000`). + +- [ ] **Step 1: Add OpenFGA, the seeder, and the migrator** + +Insert before `adminer`. These three are identical to the API repo's stack except that `api-migrate` reuses `martyrology-api:latest` rather than building: + +```yaml + openfga-migrate: + image: openfga/openfga:v1.15.1 + command: migrate + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + networks: + - martyrology + restart: "no" + depends_on: + db: + condition: service_healthy + + openfga: + image: openfga/openfga:v1.15.1 + command: run + restart: unless-stopped + environment: + OPENFGA_DATASTORE_ENGINE: postgres + OPENFGA_DATASTORE_URI: postgres://openfga:openfga_secure_password@db:5432/openfga?sslmode=disable + # Required — see the API repo's compose for why a tokenless OpenFGA + # silently disables authorization. + OPENFGA_AUTHN_METHOD: preshared + OPENFGA_AUTHN_PRESHARED_KEYS: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + OPENFGA_PLAYGROUND_ENABLED: "${OPENFGA_PLAYGROUND_ENABLED:-false}" + healthcheck: + test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=localhost:8081"] + interval: 10s + timeout: 30s + retries: 5 + start_period: 10s + ports: + - "127.0.0.1:${OPENFGA_HTTP_PORT:-8083}:8080" + - "127.0.0.1:${OPENFGA_GRPC_PORT:-8084}:8081" + - "127.0.0.1:${OPENFGA_PLAYGROUND_PORT:-3001}:3000" + networks: + - martyrology + depends_on: + openfga-migrate: + condition: service_completed_successfully + + authz-seed: + image: alpine:3.21 + restart: "no" + environment: + CDCF_INFRA_REF: "${CDCF_INFRA_REF:-main}" + OPENFGA_PRESHARED_KEY: "${OPENFGA_PRESHARED_KEY:-local-dev-preshared-key}" + entrypoint: + - /bin/sh + - -c + - | + set -eu + apk add --no-cache bash curl jq git >/dev/null + rm -rf /tmp/cdcf-infra + git clone --depth 1 --branch "$$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git /tmp/cdcf-infra + cd /tmp/cdcf-infra/auth + cat > .env.local </dev/null + rm -rf /tmp/auth && cp -r /cdcf-infra/auth /tmp/auth + cd /tmp/auth + cat > .env.local <&2; exit 1; } +``` + +3. Write the Auth.js block, after the existing `set_env` calls: + +```bash +set_env AUTH_URL "http://localhost:${FRONTEND_PORT:-3000}" +set_env AUTH_ZITADEL_ISSUER "$ISSUER" +set_env AUTH_ZITADEL_ID "$AUTH_ID" + +# AUTH_SECRET is ours to generate, not Zitadel's to emit. Generate once and +# keep it: regenerating invalidates every existing session cookie. +if ! grep -qE '^AUTH_SECRET=.+' "$ENV_FILE"; then + set_env AUTH_SECRET "$(openssl rand -base64 32)" +fi + +if [[ -n "$AUTH_SECRET_VAL" ]]; then + set_env AUTH_ZITADEL_SECRET "$AUTH_SECRET_VAL" + echo "✓ Frontend client secret captured (one-time emit)." +else + echo "⚠ No frontend client secret emitted — the app already existed." >&2 + grep -qE '^AUTH_ZITADEL_SECRET=.+' "$ENV_FILE" \ + || echo " .env has NO frontend secret. Rotate it in the Zitadel console." >&2 +fi +``` + +Then `chmod +x scripts/setup-stack.sh scripts/grant-superuser.sh`. + +- [ ] **Step 2: Write `scripts/smoke.sh`** + +```bash +#!/usr/bin/env bash +# +# smoke.sh — full-stack bring-up invariants. +# +# Checks wiring, not behaviour. Run after `setup-stack.sh --update-env` and a +# `docker compose up -d --force-recreate` of the application services. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +set -a; . ./.env; set +a + +PASS=0; FAIL=0; SKIP=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL+1)); } +skip() { printf ' ~ %s\n' "$1"; SKIP=$((SKIP+1)); } + +API="http://localhost:${API_PORT:-8000}" +FE="http://localhost:${FRONTEND_PORT:-3000}" +FGA="${MARTYROLOGY_OPENFGA_API_URL:-http://localhost:8083}" +ISSUER="http://localhost:${ZITADEL_PORT:-8080}" + +echo "1. Zitadel discovery on the single origin" +[[ "$(curl -sf "$ISSUER/.well-known/openid-configuration" | jq -r '.issuer')" == "$ISSUER" ]] \ + && ok "issuer is $ISSUER" || bad "discovery missing or issuer mismatch" + +echo "2. OpenFGA structural tuples" +COUNT=$(curl -sf -X POST "$FGA/stores/$MARTYROLOGY_OPENFGA_STORE_ID/read" \ + -H "Authorization: Bearer $MARTYROLOGY_OPENFGA_API_TOKEN" \ + -H "Content-Type: application/json" -d '{}' | jq '.tuples | length') +[[ "$COUNT" == "11" ]] && ok "11 structural tuples" || bad "expected 11 tuples, got ${COUNT:-none}" + +echo "3. Alembic is at head" +CUR=$(docker compose run --rm --entrypoint alembic api-migrate current 2>/dev/null | tr -d '\r') +grep -q '(head)' <<<"$CUR" && ok "alembic current is at head" || bad "alembic not at head: $CUR" + +echo "4. API health" +curl -sf "$API/healthz" >/dev/null && ok "GET /healthz 200" || bad "GET /healthz failed" + +echo "5. Anonymous read of a restricted edition is redacted" +BODY=$(curl -sf "$API/api/v1/elogia/edition/martyrologium_romanum_2004/01/02" 2>/dev/null) +if [[ -z "$BODY" ]]; then + skip "martyrologium_romanum_2004 not attached (no override / no martyrology-texts)" +else + ACCESS=$(jq -r '.metadata.access // empty' <<<"$BODY") + TEXT=$(jq -r '.elogia[0].text // "null"' <<<"$BODY") + [[ "$ACCESS" == "restricted-texts" && "$TEXT" == "null" ]] \ + && ok "access=restricted-texts with text=null" \ + || bad "expected redaction, got access=$ACCESS text=$TEXT" +fi + +echo "6. Login V2 is served through the proxy" +CODE=$(curl -s -o /dev/null -w '%{http_code}' "$ISSUER/ui/v2/login/login") +[[ "$CODE" != "404" && -n "$CODE" ]] \ + && ok "/ui/v2/login/login -> $CODE" \ + || bad "/ui/v2/login/login returned 404 — proxy is routing to the backend" + +echo "7. Auth.js provider" +PROVIDERS=$(curl -sf "$FE/api/auth/providers" 2>/dev/null) +if [[ -z "$PROVIDERS" ]]; then + # Auth.js is introduced by the OIDC login-client plan, not by this stack. + skip "no /api/auth/providers — Auth.js not yet wired into the frontend" +else + jq -e '.zitadel' >/dev/null <<<"$PROVIDERS" \ + && ok "zitadel provider registered" || bad "zitadel missing from providers" +fi + +echo +printf 'passed %d, failed %d, skipped %d\n' "$PASS" "$FAIL" "$SKIP" +[[ $FAIL -eq 0 ]] +``` + +`chmod +x scripts/smoke.sh`. + +- [ ] **Step 3: Run the whole bring-up from scratch** + +```bash +docker compose down -v +rm -rf .zitadel-data .stack-out +cp -n .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.sh +``` + +Expected: assertions 1–4 and 6 pass; assertion 5 skips without the override; assertion 7 skips (Auth.js is not in the frontend yet — it arrives with the OIDC login-client plan). Exit code 0. + +- [ ] **Step 4: Add the README section** + +````markdown +## Local development stack + +Runs 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. Ports match LiturgicalCalendar's stack, so only +one of the two can run at a time. + +```bash +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.sh +``` + +Then sign in at , find your `sub` in the Zitadel console +(Martyrology Org → Users → your user → ID), and grant yourself platform +superuser: + +```bash +./scripts/grant-superuser.sh +``` + +| Service | URL | Credentials | +| --- | --- | --- | +| Frontend | | — | +| API | | — | +| Zitadel console | | `root@martyrology.localhost` / `RootPassword1!` | +| OpenFGA API | | Bearer `OPENFGA_PRESHARED_KEY` from `.env` | +| Adminer | | server `db`, user `postgres`, password `postgres` | +| Mailpit | | — | + +### Building from local checkouts + +By default every service builds from its GitHub ref, so a bare clone stands the +whole system up. To build from sibling checkouts instead: + +```bash +cp docker-compose.override.example.yml docker-compose.override.yml +docker compose up -d --build +``` + +The 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. + +### Iterating on the frontend + +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: + +```bash +docker compose stop martyrology-frontend +npm run dev +``` + +Port 3000 is then free and the registered OIDC callback still matches. + +### Gotchas + +- **The OIDC client secrets are emitted once.** `setup-stack.sh` captures them + into `.env` on the run that creates each app; a re-run cannot recover them. + If `.env` is lost, regenerate in the Zitadel console. +- **`OPENFGA_PRESHARED_KEY` is required.** The API's `authz_enabled` is false + when its token is empty, which denies every authorization check while the + stack reports healthy. +- **Port 3000 is fixed.** `cdcf-infra` registers + `http://localhost:3000/api/auth/callback/zitadel` for `--target local`. +```` + +- [ ] **Step 5: Commit and open the PR** + +```bash +git add scripts/setup-stack.sh scripts/grant-superuser.sh scripts/smoke.sh README.md +git commit -S -m "Add full-stack provisioning, smoke test and documentation" +git push -u origin feat/local-dev-stack +gh pr create --title "Local development stack: full containerized stack" \ + --body "Implements Tasks 9-13 of martyrology-api's docs/superpowers/plans/2026-08-04-local-development-stack.md. + +Depends on the martyrology-api PR landing first — db-init, api-migrate and martyrology-api all build the API image, which needs its Dockerfile on main. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Task 14: Un-stale the OIDC login-client plan + +**Repo:** `cdcf-infra`, branch `docs/unstale-oidc-tasks` + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md:398-423` + +**Interfaces:** +- Consumes: the working stack from Tasks 1–13. +- Produces: a revised stale-notice block naming the local stack as the verification target. + +- [ ] **Step 1: Replace the stale-notice block** + +Replace the blockquote at lines 398–423 with: + +```markdown +> ## Tasks 3-7: local verification now runs against the local stack +> +> **Added 2026-08-03, resolved 2026-08-04.** These tasks were written assuming a +> localhost Zitadel client existed. It does — but in the *local* Zitadel, not the +> production one, which is the spec's D3 rather than a departure from it. +> +> Two things landed since: +> +> - **`--provision-martyrology-frontend` is target-aware** (PR #23). `--target local` +> registers `http://localhost:3000/api/auth/callback/zitadel` with `devMode=true` +> against a local Zitadel; `--target production` is byte-identical to before. +> - **The local stack exists** — see `martyrology-api`'s +> `docs/superpowers/specs/2026-08-04-local-development-stack-design.md`, and the +> bring-up in `martyrology-frontend`'s README. +> +> So the affected steps are performed as written, against +> `http://localhost:3000` with the local stack running, taking +> `AUTH_ZITADEL_ID` / `AUTH_ZITADEL_SECRET` from the `.env` that +> `./scripts/setup-stack.sh --update-env` writes: +> +> - **Task 3 Step 9**, **Task 5 Step 8** — run against the local stack. +> - **Task 6 Step 2** — "the dev value" means the local stack's `AUTH_SECRET`, +> which `setup-stack.sh` generates. Production's must differ. +> - **Task 7 Steps 1, 3, 5** — there is **one app per instance**, not two apps in +> one instance. The handoff table lists the production app; local sign-in is +> documented as a property of the local stack. Do not claim "both apps live in +> the MartyrologyAPI project" of a single Zitadel. +> +> The code and unit tests in Tasks 3, 4 and 5 were never affected — they mock the +> session and never contact Zitadel. +``` + +- [ ] **Step 2: Verify no other text contradicts it** + +Run: `grep -n "stale\|No such client exists\|localhost client" docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md` +Expected: only the revised block and the Global Constraints note at line 23, which remains correct — there is still no localhost client in the *production* Zitadel. + +- [ ] **Step 3: Commit and open the PR** + +```bash +git add docs/superpowers/plans/2026-08-03-martyrology-oidc-login-client.md +git commit -S -m "Point Tasks 3-7's local verification at the local stack" +git push -u origin docs/unstale-oidc-tasks +gh pr create --title "Un-stale Tasks 3-7 of the Martyrology OIDC plan" \ + --body "The local stack these tasks were waiting on now exists. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +## Execution order + +Tasks 1–8 (`martyrology-api`) must land before Tasks 9–13, because `db-init`, +`api-migrate` and `martyrology-api` all build the API image from `main`. Task 14 +requires both stacks working. + +## Deferred to its own spec + +The permission-request and notification subsystem — submission, review, +accept/reject, revoke, notifications — is **not** in this plan. It lands as +Alembic migrations plus routes on the contract Task 2 and Task 6 establish, and +requires no compose change. diff --git a/docs/superpowers/specs/2026-08-04-local-development-stack-design.md b/docs/superpowers/specs/2026-08-04-local-development-stack-design.md new file mode 100644 index 0000000..c040e54 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-local-development-stack-design.md @@ -0,0 +1,314 @@ +# Local development stack — design + +**Date:** 2026-08-04 +**Repos in scope:** `CatholicOS/martyrology-api`, `CatholicOS/martyrology-frontend` +**Reference implementation:** `Liturgical-Calendar/LiturgicalCalendarAPI` + `LiturgicalCalendarFrontend` docker stacks +**Production this mirrors:** `CatholicOS/cdcf-infra` → `auth/docker-compose.prod.yml` + +--- + +## 1. Problem + +Martyrology has no local identity or authorization infrastructure. Every flow that +touches Zitadel or OpenFGA is currently verifiable only against production, and two +concrete things are blocked by that: + +1. **Tasks 3–7 of `cdcf-infra`'s `2026-08-03-martyrology-oidc-login-client` plan are + flagged stale.** Six verification steps assume a localhost OIDC client that + deliberately does not exist in the production Zitadel (the plan's D3). The plan + itself names the resolution: *"verify against the local stack once it exists."* + +2. **`martyrology-api`'s local `.env` disables its own licensing model to stay + usable.** It sets `MARTYROLOGY_RESTRICTED_EDITIONS=` with the comment: *"with no + Zitadel/OpenFGA configured, authz fails closed and you could never see the texts + you legitimately hold."* The workaround is correct given no local authz, but it + means the redaction path is never exercised outside production. + +A local stack removes both. It also gives the forthcoming permission-request and +notification subsystem (§9) somewhere to run. + +## 2. Decisions + +| # | Decision | Rationale | +|---|---|---| +| **D1** | The **full** stack mirrors `cdcf-infra` production topology: single origin behind an nginx proxy, image versions pinned to production's. | The stack exists to verify an OIDC flow that will run against `cdcf-infra`. Redirect URIs, issuer discovery and CSP are exactly what differs between a one-origin and a two-origin issuer; a local pass under a different topology would prove less than it appears to. | +| **D2** | The **minimal** stack omits `zitadel-login` and the proxy, and serves Zitadel directly on `:8080` with `LOGINV2_REQUIRED: false`. | Nothing in the API repo performs an interactive browser sign-in; the API only ever calls `/oauth/v2/introspect`. A login UI with no frontend to log into is inventory without a purpose. This is a deliberate, documented divergence from D1, not an oversight. | +| **D3** | The OpenFGA model and tuples come from a **clone of `cdcf-infra`** performed by a one-shot `authz-seed` service; the local override bind-mounts `../cdcf-infra` instead. | `auth/models/Martyrology{,.tuples}.json` is the authoritative copy that production uploads. Vendoring a second copy into either repo would drift silently. `cdcf-infra` is public, so the default path works from a bare clone with no siblings. | +| **D4** | `martyrology-api` gains a `Dockerfile`, used by the **frontend's** stack and CI but **not** by the API's own stack. The API dev loop stays `uvicorn --factory --reload` on the host. | LitCal's precedent: its minimal stack is infra-only and the API runs on the host via `composer start`. Keeps the Dockerfile out of the everyday API edit loop. | +| **D5** | Ports reuse LitCal's numbers (`5432`, `8080`, `8083/8084`, `8088`, `8025`, `8000`, `3000`). | The two compose files read almost line-for-line the same, which is the point of mirroring. Consequence: only one stack runs at a time. (No OpenFGA Playground port: see §3 — `v1.15.1` refuses to start the Playground alongside the preshared auth this stack requires, so the Playground was dropped and never got a port.) | +| **D6** | Generated IDs reach the containers via LitCal's **two-phase `--update-env`**: `up` → provision → `up --force-recreate`. | The store ID, model ID, client IDs and client secrets are all generated at provisioning time. A `.env` file is inspectable when something is wrong, which matters when one of the values is a one-time-emit secret. | +| **D7** | `martyrology-api` gains **`MARTYROLOGY_ZITADEL_INTERNAL_URL`** (empty default → falls back to `zitadel_issuer`), used for introspection only. | `localhost:8080` must stay the browser-facing issuer and the `iss` claim, but inside the API container `localhost` is its own loopback. This is LitCal's `ZITADEL_INTERNAL_URL` precedent for the API specifically, and it is useful in production too, where Plesk's nginx makes the same round trip. | +| **D8** | The API image **clones `crmedr` and `clbdr` at pinned refs** rather than `COPY vendor/`. | `app.py:27` calls `Registry.load(crmedr_path, clbdr_path)` at startup and `registry.py` reads four files from them unconditionally — the API cannot start without them. `vendor/texts` is a **private** submodule, so a recursive clone of a GitHub build context would fail for anyone without access to it. Cloning the two public repos explicitly sidesteps the question. | +| **D9** | The permission-request / notification subsystem is **a separate spec**. This design lands only its infrastructure contract: a `martyrology` database, Alembic, and an `api-migrate` one-shot service. | They share nothing but a Postgres connection. Combining them produces a spec covering two subsystems, which is the kind that gets partially implemented and then diverges from its own plan. | + +### Prerequisite already satisfied + +`cdcf-infra` PR #23 (merged 2026-08-04) makes `--provision-martyrology-frontend` +select its origin by `--target`: `local` → `http://localhost:3000` with +`devMode=true`, `production` → `https://romanmartyrology.com` with +`devMode=false`, `staging` → skips with a warning. No further `cdcf-infra` change +is required by this design. + +**This pins the frontend container's published port to 3000.** Moving it later +requires a matching `cdcf-infra` change. + +## 3. Architecture + +| | `martyrology-api/docker-compose.yml` | `martyrology-frontend/docker-compose.yml` | +|---|---|---| +| Compose project | `martyrology-infra` | `martyrology` | +| Purpose | Infra the host-run API talks to | The whole system, containerized | +| API | **absent** — `uvicorn` on the host | container, `martyrology-api:latest`, `:8000` | +| Frontend | absent | container, `:3000` | +| Zitadel | direct on `:8080`, no login v2, no proxy | behind `zitadel-proxy` on `:8080`, login v2 at `/ui/v2/login` | +| Builds from | local context only | GitHub refs by default; override repoints at siblings | + +### Minimal stack services + +`db` · `zitadel` · `mailpit` · `openfga-migrate` → `openfga` · `authz-seed` · +`api-migrate` · `adminer`. + +Images pinned to production's versions — `zitadel:v4.15.0`, `openfga:v1.15.1`, +`postgres:17` — not `:latest`. + +- **`db`** — `scripts/init-db.sql` creates roles and databases for `zitadel`, + `openfga` and `martyrology`. +- **`zitadel`** — `ZITADEL_EXTERNALDOMAIN: localhost`, `ZITADEL_EXTERNALPORT: 8080`, + `ZITADEL_EXTERNALSECURE: false`, `LOGINV2_REQUIRED: false` (D2), SMTP → `mailpit`. + Its data dir is bind-mounted to a gitignored `./.zitadel-data/`, with + `ZITADEL_FIRSTINSTANCE_PATPATH: /zitadel-data/automation-user.pat`, so the + host-run `setup-zitadel.sh` can read the PAT it authenticates with. +- **`authz-seed`** — one-shot `alpine` + `bash`/`curl`/`jq`/`git`. Clones + `cdcf-infra` at `${CDCF_INFRA_REF:-main}` and runs + `auth/setup-openfga.sh --target local`, + which creates the `Martyrology` store, uploads the model, and seeds the eight + `governed_by` plus three `on_platform` tuples. Idempotent by the script's own + read-then-write-the-difference logic. +- **`api-migrate`** — `build: .`, runs `alembic upgrade head` against the + `martyrology` database. Ships with a baseline migration and no tables; it exists + so the subsystem in §9 lands as migrations without touching compose. + +### OpenFGA must run with preshared auth — in both stacks + +Not a preference. `Settings.authz_enabled` is: + +```python +return bool(self.openfga_api_url and self.openfga_store_id and self.openfga_api_token) +``` + +An OpenFGA running `OPENFGA_AUTHN_METHOD: none` — LitCal's dev-stack default — +leaves `MARTYROLOGY_OPENFGA_API_TOKEN` empty, so `authz_enabled` is **False**, so +every `Authz.check` returns `False` and the entire stack fails closed. It would +come up healthy and verify nothing. + +Both stacks therefore run OpenFGA with `OPENFGA_AUTHN_METHOD=preshared` and +`OPENFGA_AUTHN_PRESHARED_KEYS=${OPENFGA_PRESHARED_KEY}`, with the same value set +as `MARTYROLOGY_OPENFGA_API_TOKEN` and consumed by `setup-openfga.sh`, which +already requires `OPENFGA_PRESHARED_KEY` in its env file. This also matches +production, which uses a preshared key. + +**Resolved at implementation:** the Playground cannot be enabled alongside +preshared auth on `v1.15.1` — it panics at startup. The Playground is dropped +(no `OPENFGA_PLAYGROUND_PORT` in either repo's `.env.example`) and the store is +inspected via `curl` instead — the same way production is. + +### Full stack services + +The minimal set plus `db-init`, `zitadel-login`, `zitadel-proxy`, +`martyrology-api`, `martyrology-frontend`. + +- **`db-init`** — `image: martyrology-api:latest`, extracts `scripts/init-db.sql` + into a volume mounted at `/docker-entrypoint-initdb.d`. One source of truth for + the DB bootstrap, taken from the image rather than duplicated in this repo. +- **`zitadel`** publishes nothing. **`zitadel-proxy`** (`nginx:alpine`) publishes + `127.0.0.1:8080:80` and routes `/ui/v2/login*` → `zitadel-login:3000`, + everything else → `zitadel:8080`. `ZITADEL_EXTERNALPORT: 8080` therefore + describes the proxy. **Port 8081 is unused in this stack.** +- **`martyrology-api`** — `MARTYROLOGY_ZITADEL_ISSUER=http://localhost:8080`, + `MARTYROLOGY_ZITADEL_INTERNAL_URL=http://localhost:${ZITADEL_PORT}` (D7) — **not** + `http://zitadel:8080`, which is precisely what does not work: Zitadel resolves + the instance from the Host header, not the network path, and `Host: + zitadel:8080` matches no registered domain ("Instance not found"). The fix + routes container → host → proxy instead, so the request presents `Host: + localhost:${ZITADEL_PORT}` — the same origin the browser uses — which is the + inverse of D7's original "avoid the round trip through the host" rationale, + not an application of it. + `MARTYROLOGY_OPENFGA_API_URL=http://openfga:8080`. +- **`martyrology-frontend`** — `extra_hosts: - "localhost:host-gateway"`, matching + `litcal-frontend` exactly. Auth.js needs server-side discovery and the browser + redirect to agree on one origin, so `localhost:8080` must resolve from inside + the container to the published proxy port. + +### The nginx conf is a local copy, and that has a cost + +`docker/nginx/zitadel.local.conf` in `martyrology-frontend`, **not** a mount of +`cdcf-infra`'s `auth/nginx/zitadel.conf`. Production's `connect-src` allowlist +names the CDCF and LitCal origins and would block `http://localhost:3000`. + +Four things differ, not one: the Content-Security-Policy; `$zitadel_host` (a +normalized `$http_host`) in place of `$host` on the Host / X-Forwarded-Host +headers; the `map{}` block that normalization depends on; and +`X-Forwarded-Proto`, which is `https` upstream (production terminates TLS in +front of it) and `http` here. The routing half — where each path is +proxied — is otherwise stable. The local copy must say so explicitly and cite +the original, because this is a genuine second copy of a file whose comments +carry real reasoning about why each divergence exists. + +## 4. Changes to `martyrology-api` + +| Artifact | Notes | +|---|---| +| `Dockerfile`, `.dockerignore` | Multi-stage, `uv` install, `python:3.12-slim` runtime, `CMD uvicorn … 0.0.0.0:8000`. Clones `crmedr` and `clbdr` at pinned refs (D8). Includes `data/editions`, `scripts/init-db.sql`, `alembic/`. | +| `docker-compose.yml` | Minimal stack (§3). | +| `scripts/init-db.sql` | Roles + databases for `zitadel`, `openfga`, `martyrology`. | +| `alembic/`, `alembic.ini` | Baseline migration only. | +| `pyproject.toml` | Adds `alembic`, `sqlalchemy`, `psycopg`. | +| `config.py` | Adds `database_url` and `zitadel_internal_url`. | +| `auth.py` | Introspection targets `zitadel_internal_url or zitadel_issuer`. Token validation still asserts the public `zitadel_issuer`. | +| `.env.example` | Documents `MARTYROLOGY_DATABASE_URL` and `MARTYROLOGY_ZITADEL_INTERNAL_URL`. | +| `scripts/setup-stack.sh` | Provisioning wrapper (§6). Invokes `--create-org Martyrology --provision-martyrology` only — the minimal stack has no frontend app to provision. | +| `scripts/grant-superuser.sh` | One-shot `platform:martyrology` superuser tuple write. | +| `scripts/smoke.sh` | Bring-up invariants (§7), minimal-stack subset. | + +Each repo carries **its own** `setup-stack.sh`, `grant-superuser.sh` and +`smoke.sh`. They are near-identical and deliberately not shared: each provisions +a different Zitadel instance with a different set of actions, and a shared script +would have to branch on which stack invoked it. The frontend's copies are listed +in §5. + +`zitadel_internal_url` defaults to `""` and falls back to `zitadel_issuer`, so +existing deployments and the test suite are unaffected. `auth_enabled` and +`authz_enabled` are untouched — the internal URL is a transport detail, never a +posture input. + +## 5. Changes to `martyrology-frontend` + +`Dockerfile` (Next.js standalone output, node ≥24, `:3000`), `.dockerignore`, +`docker-compose.yml`, `docker-compose.override.example.yml`, +`docker/nginx/zitadel.local.conf`, `.env.example` additions +(`AUTH_SECRET`, `AUTH_URL`, `AUTH_ZITADEL_ID`, `AUTH_ZITADEL_SECRET`), and a +`.gitignore` entry for `docker-compose.override.yml`. + +Plus its own `scripts/setup-stack.sh` (which additionally invokes +`--provision-martyrology-frontend`), `scripts/grant-superuser.sh`, and +`scripts/smoke.sh` (the full assertion set, §7). + +### The override + +Committed as `docker-compose.override.example.yml`, copied to a gitignored +`docker-compose.override.yml`: + +- `db-init`, `api-migrate` and `martyrology-api` all get + `build: context: ../martyrology-api` **together**. Overriding only one leaves + `docker compose up --build` rebuilding `martyrology-api:latest` from GitHub via + another service and clobbering the local build — the trap LitCal's override + comments call out by name. +- `../martyrology-api/src` `:ro`; `../crmedr` and `../clbdr` `:ro` over the + image's cloned copies. +- **`../martyrology-texts/data/editions` `:ro`**, with `MARTYROLOGY_DATA_PATH` + extended to include it. This is what makes the restricted-texts path real. It + works only under the override — `martyrology-texts` is private, so the + GitHub-default stack necessarily serves the two public-domain editions only. +- `authz-seed` bind-mounts `../cdcf-infra` instead of cloning. + +**Frontend iteration is not a bind mount.** A Next.js production image will not +hot-reload from one. The documented loop is `docker compose stop +martyrology-frontend` and `npm run dev` on the host: port 3000 is then free and +the registered callback still matches. + +## 6. Bring-up + +Run from **`martyrology-frontend/`** — this is the full stack. The minimal stack +is the same sequence run from `martyrology-api/`, minus the frontend service in +step 4 and minus step 5, followed by `uvicorn martyrology_api.app:create_app +--factory --reload` on the host. + +```bash +cp .env.example .env # ports, masterkey, preshared key +docker compose up -d # infra; authz-seed creates + seeds the store +./scripts/setup-stack.sh --update-env # provisions Zitadel, writes IDs into .env +docker compose up -d --force-recreate martyrology-api martyrology-frontend +# sign in once at http://localhost:3000, then: +./scripts/grant-superuser.sh +``` + +`setup-stack.sh` waits for Zitadel healthy, clones or reuses `cdcf-infra`, writes +a `.env.local` for the provisioners (`ZITADEL_ISSUER=http://localhost:8080`, +`ZITADEL_INTERNAL_URL=http://localhost:${ZITADEL_PORT}` — the same +browser-facing origin as the issuer, not a container-internal address; see +§3's `MARTYROLOGY_ZITADEL_INTERNAL_URL` correction for why — +`ZITADEL_PAT_FILE=./.zitadel-data/automation-user.pat`, plus the OpenFGA values), +runs `--create-org Martyrology --provision-martyrology +--provision-martyrology-frontend`, and writes the emitted IDs back into `.env`. + +Two properties of this sequence are load-bearing: + +- **Step 5 cannot be automated away.** The superuser tuple keys on a Zitadel `sub` + that does not exist until that account has signed in once. +- **The one-time secret hazard applies locally too.** Both client secrets are + emitted once, by the run that creates the app. `--update-env` must capture them + on that run; losing `.env` means rotating in the Zitadel console, not re-reading. + +## 7. Verification + +A compose stack is not unit-testable, so acceptance is a `scripts/smoke.sh` +asserting the invariants a compose file can actually get wrong: + +| # | Assertion | Minimal | Full | +|---|---|---|---| +| 1 | Zitadel healthy; `/.well-known/openid-configuration` served at `http://localhost:8080` | ✅ | ✅ | +| 2 | The `Martyrology` store holds **11** structural tuples (8 `governed_by` + 3 `on_platform`) | ✅ | ✅ | +| 3 | `alembic current` matches `alembic heads` on the `martyrology` database | ✅ | ✅ | +| 4 | `GET /healthz` on the API returns 200 | ✅ | ✅ | +| 5 | An anonymous read of `martyrologium_romanum_2004` returns `metadata.access = "restricted-texts"` with `text: null` | ✅ | ✅ | +| 6 | `http://localhost:8080/ui/v2/login` is served through the proxy | — | ✅ | +| 7 | `GET /api/auth/providers` on the frontend lists `zitadel` | — | ✅ | + +Assertion 5 holds in both stacks and in both data configurations: without +`martyrology-texts` mounted the edition is absent and the assertion is skipped +rather than passed silently — the smoke script must distinguish those two +outcomes, since "no such edition" and "redacted" are the same 200 to a careless +check. + +Existing `pytest` suites in both repos are untouched by this design. + +What the stack then makes verifiable by hand, which is its actual purpose: + +- **The stale plan's Task 3 Step 9 and Task 5 Step 8** — local sign-in, revised to + target the local stack. +- **Task 3's "client authentication method" checkpoint** — whether Auth.js sends + credentials as form fields (`client_secret_post`, what the app is provisioned + as) or falls back to HTTP Basic. The local app is created by the same code path + with the same `OIDC_AUTH_METHOD_TYPE_POST`, so the answer transfers. +- **The restricted-texts read**, with `martyrology-texts` mounted — which also + retires the `MARTYROLOGY_RESTRICTED_EDITIONS=` workaround in the local `.env`. +- **The open grant-path question** in `cdcf-infra`'s Martyrology handoff. A local + store makes `can_read_texts` resolution inspectable instead of deduced. + +Curation writes (`MARTYROLOGY_LOCAL_GIT_ROOT` against a mounted checkout) are +reachable in this stack but are not an acceptance criterion of it. + +## 8. Risks + +| Risk | Mitigation | +|---|---| +| `zitadel.local.conf` drifts from `cdcf-infra`'s original. | Four things differ (CSP, `$zitadel_host` Host-header normalization, the `map{}` block it depends on, `X-Forwarded-Proto`); the local copy cites the original and says which lines are intentionally divergent. | +| The GitHub-default full stack cannot exercise restricted texts. | Accepted and documented. `martyrology-texts` is private; the override path is the supported way to reach it. | +| Pinned `crmedr`/`clbdr` refs in the Dockerfile go stale. | The override bind-mounts host siblings over them, so local work is never blocked by a stale pin. | +| Only one stack runs at a time (D5). | Accepted. Documented in both READMEs. | + +## 9. Out of scope + +- **The permission-request / notification subsystem** — request submission, + review, accept/reject, revoke, and notifications, modelled on LitCal's + `access_requests` / `audit_log`. Its own brainstorm → spec → plan (D9). It lands + as Alembic migrations plus routes and requires no compose change. +- **Issue #20 for LitCal and CDCF.** `LiturgicalCalendarFrontend` is a single app + holding production and staging origins together, so making its URLs + target-dependent under a shared name would strip a working production callback. + That decision stays with the issue. +- **The production CSP allowlist.** `cdcf-infra`'s `auth/nginx/zitadel.conf` + lists the CDCF and LitCal origins in `connect-src` but not + `https://romanmartyrology.com`. A real gap, fixed in `cdcf-infra`, not here. +- **Any staging target.** Martyrology has no staging deployment; + `--target staging` skips with a warning by design. diff --git a/pyproject.toml b/pyproject.toml index f949e79..e68bfc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ dependencies = [ "pydantic>=2.13.4", "pydantic-settings>=2.3", "httpx2>=2.9.1", + "alembic>=1.14", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.2", ] [project.optional-dependencies] diff --git a/scripts/grant-superuser.sh b/scripts/grant-superuser.sh new file mode 100755 index 0000000..85c9735 --- /dev/null +++ b/scripts/grant-superuser.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# grant-superuser.sh — write the platform:martyrology superuser tuple. +# +# SIBLING NOTE: byte-for-byte the same tool as martyrology-frontend's +# scripts/grant-superuser.sh — both write to the exact same OpenFGA store and +# object (superuser is a platform-wide grant, not scoped to either app). +# Duplicated rather than shared for the same reason as setup-stack.sh (no +# submodule/package relationship between the two repos). If you change this +# file, apply the same fix to martyrology-frontend's copy, and vice versa. +# +# Out-of-band by design, exactly as in production. The API's +# /api/v1/admin/permissions endpoint fixes its object type to governance_body, +# so platform: tuples are structurally unreachable through it — otherwise any +# body admin could mint themselves a superuser. Every superuser grant, not just +# the first, is made this way. +# +# The `sub` only exists after that account has signed in once, which is why +# this cannot be folded into setup-stack.sh. +# +# OpenFGA does not validate that a sub corresponds to a real user — a +# transposed digit silently grants superuser to a nonexistent identity while +# the intended person still cannot do anything, with nothing to surface the +# mistake. So this script always prints exactly what it is about to do +# before writing, and asks for confirmation unless --yes/-y is passed. +# +# Usage: ./scripts/grant-superuser.sh [--revoke] [--yes|-y] +# Revoke: ./scripts/grant-superuser.sh --revoke + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +usage() { echo "Usage: $0 [--revoke] [--yes|-y]" >&2; exit 64; } + +SUB="${1:-}" +[[ -n "$SUB" ]] || usage +shift || true + +OP="writes" +ASSUME_YES=0 +for arg in "$@"; do + case "$arg" in + --revoke) OP="deletes" ;; + --yes|-y) ASSUME_YES=1 ;; + *) usage ;; + esac +done + +ENV_FILE=".env" +API_URL="$(grep -E '^MARTYROLOGY_OPENFGA_API_URL=' "$ENV_FILE" | cut -d= -f2- || true)" +STORE_ID="$(grep -E '^MARTYROLOGY_OPENFGA_STORE_ID=' "$ENV_FILE" | cut -d= -f2- || true)" +TOKEN="$(grep -E '^MARTYROLOGY_OPENFGA_API_TOKEN=' "$ENV_FILE" | cut -d= -f2- || true)" + +for v in API_URL STORE_ID TOKEN; do + [[ -n "${!v}" ]] || { echo "$v missing from $ENV_FILE — run setup-stack.sh first" >&2; exit 1; } +done + +OP_LABEL="grant" +[[ "$OP" == "deletes" ]] && OP_LABEL="revoke" + +# Announce the exact effect before writing — the minimum defense against a +# mistyped sub, since OpenFGA will happily accept one that names no one. +echo "About to $OP_LABEL superuser:" +echo " user: user:$SUB" +echo " object: platform:martyrology" +echo " store: $STORE_ID" +echo " api: $API_URL" + +if [[ $ASSUME_YES -eq 0 ]]; then + # Read from the controlling terminal, not stdin — stdin may be + # redirected (e.g. piped input), in which case a plain `read` would + # silently consume that instead of prompting, and either hang or + # auto-answer from unrelated data. `-r /dev/tty` only checks the + # device node's permission bits, which can be true even with no + # controlling terminal attached (open then fails with ENXIO) — so + # actually attempt to open it and check THAT, not just the bits. + # + # The open attempt is confined to a subshell: a bare `exec 3/dev/null) for the REST OF THE SCRIPT, not just this + # attempt — on the success path that silently swallows all later + # stderr, including "Aborted." and a genuine failing `curl`, which + # would then look identical to success. The subshell's `2>/dev/null` + # only suppresses the ENXIO probe's own diagnostic and evaporates when + # the subshell exits either way; the actual read below opens + # /dev/tty fresh, scoped to that one command, so fd 2 in this shell is + # never touched. + if ! ( exec 3/dev/null; then + echo "No controlling terminal to confirm on — pass --yes/-y to proceed non-interactively." >&2 + exit 1 + fi + REPLY="" + read -r -p "Proceed? [y/N] " REPLY < /dev/tty + case "$REPLY" in + [yY]|[yY][eE][sS]) ;; + *) echo "Aborted." >&2; exit 1 ;; + esac +fi + +# Built with jq rather than string interpolation: a sub containing a quote, +# backslash, or newline would otherwise produce malformed or reshaped JSON. +BODY="$(jq -n --arg op "$OP" --arg sub "$SUB" \ + '{($op): {tuple_keys: [{user: ("user:" + $sub), relation: "superuser", object: "platform:martyrology"}]}}')" + +# --connect-timeout/--max-time bound this single call so an unreachable +# OpenFGA fails fast instead of hanging on curl's own defaults. +curl -sS --fail-with-body --connect-timeout 5 --max-time 15 -X POST "$API_URL/stores/$STORE_ID/write" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" + +echo +echo "✓ $OP_LABEL superuser tuple for user:$SUB" diff --git a/scripts/init-db.sql b/scripts/init-db.sql new file mode 100644 index 0000000..0c7f0ba --- /dev/null +++ b/scripts/init-db.sql @@ -0,0 +1,19 @@ +-- Bootstrap for the local development stack's Postgres. +-- +-- Runs once, on first initialisation of an empty postgres_data volume, via +-- /docker-entrypoint-initdb.d. Creates roles and databases only; no application +-- DDL lives here. Table DDL for the `martyrology` database belongs in +-- alembic/versions/ and is applied by the api-migrate service. +-- +-- Zitadel creates its own database from the admin credentials it is given, so +-- only the openfga and martyrology databases are created here. +-- +-- The passwords below are placeholders for an ephemeral local-development +-- volume only. They are committed to source control and must never be +-- pointed at a persisted or network-reachable Postgres instance. + +CREATE ROLE openfga WITH LOGIN PASSWORD 'openfga_secure_password'; +CREATE DATABASE openfga OWNER openfga; + +CREATE ROLE martyrology WITH LOGIN PASSWORD 'martyrology_secure_password'; +CREATE DATABASE martyrology OWNER martyrology; diff --git a/scripts/setup-stack.sh b/scripts/setup-stack.sh new file mode 100755 index 0000000..1154b58 --- /dev/null +++ b/scripts/setup-stack.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# +# setup-stack.sh — provision the local Zitadel and discover the OpenFGA IDs, +# then write both into .env. +# +# SIBLING NOTE: martyrology-frontend's scripts/setup-stack.sh is a near- +# duplicate of this file (same provisioning wait loop, cdcf-infra clone, +# capture-file handling, and set_env; it additionally provisions a frontend +# OIDC app and AUTH_SECRET, which this repo has no equivalent of). Duplicated +# rather than shared because the two repos have no submodule/package +# relationship and these scripts run on the host before any container +# exists — see martyrology-frontend's +# .superpowers/sdd/2026-08-04-local-development-stack/task-13-report.md for +# the full reasoning. If you change the shared parts of this file, apply the +# same fix to martyrology-frontend's copy, and vice versa. +# +# Phase 2 of the three-phase bring-up (see README.md → "Local development +# stack"). The store ID, model ID, client ID and client secret are all +# GENERATED at provisioning time, so they cannot be committed; this script +# captures them. +# +# ⚠ The client secret is emitted ONCE, by the run that creates the app. +# Zitadel's ListApplications API does not return secrets, so a re-run against +# an existing app cannot recover it. If .env is lost, rotate in the console: +# Martyrology Org → Projects → MartyrologyAPI → Apps → Regenerate Client Secret +# +# Usage: ./scripts/setup-stack.sh --update-env + +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +UPDATE_ENV=0 +[[ "${1:-}" == "--update-env" ]] && UPDATE_ENV=1 +if [[ $UPDATE_ENV -eq 0 ]]; then + echo "Usage: $0 --update-env" >&2 + exit 64 +fi + +ENV_FILE=".env" +[[ -r "$ENV_FILE" ]] || { echo "$ENV_FILE not found or unreadable — copy .env.example to .env first" >&2; exit 1; } +PAT_FILE="./.zitadel-data/automation-user.pat" +ZITADEL_PORT="$(grep -E '^ZITADEL_PORT=' "$ENV_FILE" | cut -d= -f2- || true)" +ZITADEL_PORT="${ZITADEL_PORT:-8080}" +OPENFGA_HTTP_PORT="$(grep -E '^OPENFGA_HTTP_PORT=' "$ENV_FILE" | cut -d= -f2- || true)" +OPENFGA_HTTP_PORT="${OPENFGA_HTTP_PORT:-8083}" +PRESHARED_KEY="$(grep -E '^OPENFGA_PRESHARED_KEY=' "$ENV_FILE" | cut -d= -f2- || true)" +[[ -n "$PRESHARED_KEY" ]] || { echo "OPENFGA_PRESHARED_KEY missing from $ENV_FILE" >&2; exit 1; } +CDCF_INFRA_REF="$(grep -E '^CDCF_INFRA_REF=' "$ENV_FILE" | cut -d= -f2- || true)" +CDCF_INFRA_REF="${CDCF_INFRA_REF:-main}" + +ISSUER="http://localhost:${ZITADEL_PORT}" +WORKDIR=".stack-out" +mkdir -p "$WORKDIR" + +# --- wait for Zitadel ----------------------------------------------------- +# --connect-timeout/--max-time bound EACH attempt so an unreachable host +# (wrong port, container not started) fails fast instead of hanging on +# curl's own defaults; the retry loop above already bounds total attempts +# (60x2s), and Zitadel's genuinely slow first boot is accommodated by +# retrying, not by a long per-attempt timeout. +CURL_TIMEOUT=(--connect-timeout 5 --max-time 15) +echo "Waiting for Zitadel at $ISSUER ..." +for _ in $(seq 1 60); do + if curl -sf "${CURL_TIMEOUT[@]}" "$ISSUER/.well-known/openid-configuration" >/dev/null; then break; fi + sleep 2 +done +curl -sf "${CURL_TIMEOUT[@]}" "$ISSUER/.well-known/openid-configuration" >/dev/null \ + || { echo "Zitadel never became ready" >&2; exit 1; } +[[ -s "$PAT_FILE" ]] || { echo "PAT not found at $PAT_FILE" >&2; exit 1; } + +# --- clone or refresh cdcf-infra ----------------------------------------- +INFRA_DIR="$WORKDIR/cdcf-infra" +if [[ -d "$INFRA_DIR/.git" ]]; then + git -C "$INFRA_DIR" fetch --quiet origin "$CDCF_INFRA_REF" + git -C "$INFRA_DIR" checkout --quiet "FETCH_HEAD" +else + git clone --quiet --depth 1 --branch "$CDCF_INFRA_REF" \ + https://github.com/CatholicOS/cdcf-infra.git "$INFRA_DIR" +fi + +# --- provision Zitadel ---------------------------------------------------- +# ZITADEL_PAT_FILE must be absolute: setup-zitadel.sh runs from auth/. +cat > "$INFRA_DIR/auth/.env.local" < "$OUT") +chmod 600 "$OUT" +( + cd "$INFRA_DIR/auth" + ./setup-zitadel.sh --target local \ + --create-org Martyrology \ + --provision-martyrology +) | tee "$OUT" + +# The handoff block prints `KEY=value` lines, some with a trailing comment. +# Colours are suppressed automatically because stdout is a pipe. +val() { sed -n "s/^$1=\([^ ]*\).*/\1/p" "$OUT" | head -1; } + +CLIENT_ID="$(val MARTYROLOGY_ZITADEL_CLIENT_ID)" +CLIENT_SECRET="$(val MARTYROLOGY_ZITADEL_CLIENT_SECRET)" +PROJECT_ID="$(val ZITADEL_PROJECT_ID)" + +# Parsed — the capture file's only reason to exist is gone, and it is the +# one place a plaintext copy of the one-time secret could otherwise survive +# indefinitely at rest. Remove it now rather than leaving even a +# permission-protected copy around. +rm -f "$OUT" + +[[ -n "$CLIENT_ID" ]] || { echo "No client ID in provisioner output" >&2; exit 1; } +[[ -n "$PROJECT_ID" ]] || { echo "No project ID in provisioner output" >&2; exit 1; } + +# --- discover the OpenFGA IDs -------------------------------------------- +# Queried from the API rather than parsed out of setup-openfga.sh's output: +# the store already exists (authz-seed created it), and an API read is stable +# where output parsing is not. +# +# Both substitutions below end in `|| true`. Under `set -euo pipefail`, a bare +# `STORE_ID="$(curl -sf ... | jq ...)"` aborts the whole script AT THE +# ASSIGNMENT the instant curl fails (OpenFGA down, wrong preshared key, +# connection refused) — before the "No Martyrology store found" guard two +# lines down, the one purpose-built to name the likely causes, ever gets to +# run. `|| true` lets a failed pipeline fall through to an empty STORE_ID +# instead, so the guard actually fires in the cases it exists for. +# +# This is also the only network read in the script with no retry (Zitadel +# above gets 60x2s). `docker compose up -d` does not wait for authz-seed — +# a `restart: "no"` one-shot — to finish, so a run of this script can +# legitimately land before the store has been seeded yet. Poll briefly +# rather than failing on that ordinary race. +FGA="http://localhost:${OPENFGA_HTTP_PORT}" +STORE_ID="" +for _ in $(seq 1 15); do + STORE_ID="$(curl -sf "${CURL_TIMEOUT[@]}" -H "Authorization: Bearer $PRESHARED_KEY" "$FGA/stores" \ + | jq -r '.stores[] | select(.name=="Martyrology") | .id' | head -1 || true)" + [[ -n "$STORE_ID" ]] && break + sleep 2 +done +[[ -n "$STORE_ID" ]] || { + echo "No Martyrology store found at $FGA" >&2 + echo " Likely causes: OpenFGA is not up, OPENFGA_PRESHARED_KEY in .env" >&2 + echo " doesn't match the running stack's, or authz-seed hasn't finished" >&2 + echo " seeding the store yet — check 'docker compose logs authz-seed'." >&2 + exit 1 +} + +MODEL_ID="$(curl -sf "${CURL_TIMEOUT[@]}" -H "Authorization: Bearer $PRESHARED_KEY" \ + "$FGA/stores/$STORE_ID/authorization-models?page_size=1" \ + | jq -r '.authorization_models[0].id' || true)" +[[ -n "$MODEL_ID" && "$MODEL_ID" != "null" ]] \ + || { echo "No authorization model in store $STORE_ID" >&2; exit 1; } + +# --- write .env ----------------------------------------------------------- +# Values come from Zitadel/OpenFGA and may contain arbitrary punctuation +# (secrets especially). A sed `s|^KEY=.*|KEY=VALUE|` splice would treat `&` +# in VALUE as "the whole matched line" (silent corruption, not an error) and +# `|` would break the delimiter — so the key/value are passed through the +# environment into awk instead, matched by literal prefix (no regex, no +# replacement-string metacharacters), never interpolated into program text. +# Written to a temp file and renamed in rather than edited in place, so a +# key that isn't present is appended exactly once and one that is present +# is replaced exactly where it stood. +set_env() { + local key="$1" value="$2" + local tmp + tmp="$(mktemp "$(dirname "$ENV_FILE")/.env.XXXXXX")" + SET_ENV_KEY="$key" SET_ENV_VALUE="$value" awk ' + BEGIN { + key = ENVIRON["SET_ENV_KEY"] + value = ENVIRON["SET_ENV_VALUE"] + prefix = key "=" + found = 0 + } + { + if (!found && substr($0, 1, length(prefix)) == prefix) { + print prefix value + found = 1 + } else { + print + } + } + END { + if (!found) print prefix value + } + ' "$ENV_FILE" > "$tmp" + # mv replaces $ENV_FILE with the temp file's own mode, so re-assert 600 + # on the temp file before the swap rather than trusting it survives. + chmod 600 "$tmp" + mv "$tmp" "$ENV_FILE" +} + +set_env MARTYROLOGY_ZITADEL_ISSUER "$ISSUER" +set_env MARTYROLOGY_ZITADEL_INTERNAL_URL "$ISSUER" +set_env MARTYROLOGY_ZITADEL_CLIENT_ID "$CLIENT_ID" +set_env MARTYROLOGY_ZITADEL_PROJECT_ID "$PROJECT_ID" +set_env MARTYROLOGY_OPENFGA_API_URL "$FGA" +set_env MARTYROLOGY_OPENFGA_STORE_ID "$STORE_ID" +set_env MARTYROLOGY_OPENFGA_MODEL_ID "$MODEL_ID" +set_env MARTYROLOGY_OPENFGA_API_TOKEN "$PRESHARED_KEY" + +if [[ -n "$CLIENT_SECRET" ]]; then + set_env MARTYROLOGY_ZITADEL_CLIENT_SECRET "$CLIENT_SECRET" + echo "✓ Client secret captured (one-time emit)." +else + echo "⚠ No client secret emitted — the app already existed." >&2 + echo " Existing MARTYROLOGY_ZITADEL_CLIENT_SECRET in .env left untouched." >&2 + grep -qE '^MARTYROLOGY_ZITADEL_CLIENT_SECRET=.+' "$ENV_FILE" \ + || echo " .env has NO secret. Rotate it in the Zitadel console." >&2 +fi + +# Belt and suspenders: set_env's mv already leaves $ENV_FILE at 600 (the +# temp file's own mode), but assert it explicitly — $ENV_FILE now holds a +# live client secret and must never be group/world-readable. +chmod 600 "$ENV_FILE" + +echo +echo "✓ .env updated. Restart the API to pick up the new values." diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..2efb2bb --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# smoke.sh — assert the bring-up invariants a compose file can get wrong. +# +# Not a substitute for pytest: this checks wiring, not behaviour. Run it after +# `setup-stack.sh --update-env` with the API running on the host. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1 +# A missing/unreadable .env must fail here, with a clear cause, rather than +# surfacing later as an opaque unbound-variable error on +# MARTYROLOGY_OPENFGA_STORE_ID under `set -u`. +[[ -r ./.env ]] || { echo ".env not found or unreadable — run setup-stack.sh --update-env first" >&2; exit 1; } +set -a +# .env is generated by setup-stack.sh, gitignored, and not present at lint +# time — nothing for shellcheck to follow. +# shellcheck disable=SC1091 +. ./.env +set +a + +PASS=0; FAIL=0; SKIP=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL+1)); } +skip() { printf ' ~ %s\n' "$1"; SKIP=$((SKIP+1)); } + +# The API is not containerized in this repo (see README.md -> "Local +# development stack") — it always runs on the host under uvicorn's default +# port, with no MARTYROLOGY_PORT-style override for local dev (that variable +# only exists for the VPS deploy; see scripts/deploy/setup-vps-deploy-user.sh). +API="http://localhost:8000" +FGA="${MARTYROLOGY_OPENFGA_API_URL:-http://localhost:8083}" +ISSUER="${MARTYROLOGY_ZITADEL_ISSUER:-http://localhost:${ZITADEL_PORT:-8080}}" + +# --connect-timeout/--max-time bound EVERY curl below so an unreachable +# service fails fast instead of hanging on curl's own (much longer) defaults. +# By this point in the bring-up (see README.md → "Local development stack") +# Zitadel's slow first boot is already behind setup-stack.sh's own retry +# loop, so a generous but finite per-call timeout is safe here. +CURL_TIMEOUT=(--connect-timeout 5 --max-time 15) + +echo "1. Zitadel discovery" +DISCOVERY_ISSUER=$(curl -sf "${CURL_TIMEOUT[@]}" "$ISSUER/.well-known/openid-configuration" | jq -r '.issuer') +if [[ "$DISCOVERY_ISSUER" == "$ISSUER" ]]; then + ok "issuer is $ISSUER" +else + bad "discovery missing or issuer mismatch: expected $ISSUER, got ${DISCOVERY_ISSUER:-none}" +fi + +echo "2. OpenFGA structural tuples" +# Mirrors Authz.read_tuples's request shape (page_size 100, follow +# continuation_token) rather than a single unpaginated read. The 8 +# governed_by + 3 on_platform structural tuples span multiple objects, so +# this deliberately does not filter by tuple_key.object the way +# read_tuples's per-object calls do — it enumerates the whole store, same as +# before. What changes is the assertion: grant-superuser.sh's documented +# workflow adds a platform:martyrology#superuser tuple, making 12 a normal +# post-bring-up count, not a failure — so assert AT LEAST 11 rather than +# exactly 11. +COUNT=0 +TOKEN="" +for _ in $(seq 1 10); do + # No "tuple_key" key at all (not even `{}`): OpenFGA's Read API 400s on + # an empty tuple_key object ("object type field is required ... cannot + # be empty") but accepts a request that omits it altogether, returning + # every tuple in the store — confirmed against a running OpenFGA. + PAYLOAD='{"page_size":100}' + if [[ -n "$TOKEN" ]]; then + PAYLOAD=$(jq -n --arg t "$TOKEN" '{page_size:100, continuation_token:$t}') + fi + PAGE=$(curl -sf "${CURL_TIMEOUT[@]}" -X POST "$FGA/stores/$MARTYROLOGY_OPENFGA_STORE_ID/read" \ + -H "Authorization: Bearer $MARTYROLOGY_OPENFGA_API_TOKEN" \ + -H "Content-Type: application/json" -d "$PAYLOAD") || { COUNT=""; break; } + COUNT=$((COUNT + $(jq '.tuples | length' <<<"$PAGE"))) + TOKEN=$(jq -r '.continuation_token // empty' <<<"$PAGE") + [[ -n "$TOKEN" ]] || break +done +# If the loop above exhausted its 10-iteration cap while TOKEN is still +# non-empty, pagination did not finish — OpenFGA said there was more to +# read. COUNT is a partial sum in that case; treat it the same as the +# curl-failure branch above rather than let a partial read satisfy >= 11. +[[ -n "$TOKEN" ]] && COUNT="" +if [[ -n "$COUNT" && "$COUNT" -ge 11 ]]; then + ok "$COUNT structural tuples (>= 11)" +else + bad "expected at least 11 tuples, got ${COUNT:-none}" +fi + +echo "3. Alembic is at head" +# Merge stderr into the capture: a failing `docker compose run` (e.g. the +# image not built, or the container erroring before alembic runs) previously +# had its cause discarded by 2>/dev/null, leaving only an empty/unhelpful +# "alembic not at head:" message. +CUR=$(docker compose run --rm --entrypoint alembic api-migrate current 2>&1 | tr -d '\r') +if grep -q '(head)' <<<"$CUR"; then + ok "alembic current is at head" +else + bad "alembic not at head: $CUR" +fi + +echo "4. API health" +if curl -sf "${CURL_TIMEOUT[@]}" "$API/healthz" >/dev/null; then + ok "GET /healthz 200" +else + bad "GET /healthz failed" +fi + +echo "5. Anonymous read of a restricted edition is redacted" +# `curl -sf` yields an empty body for ANY non-2xx status, collapsing "no such +# edition" (404 — legitimately skippable when martyrology-texts isn't +# attached) and a broken redaction path (403/500 — a real failure) into the +# same "not attached" skip. Capture the status code instead (appended after a +# newline, then split off) so only a 404 skips; every other non-2xx is +# reported as a failure with the code attached. +RESP=$(curl -s "${CURL_TIMEOUT[@]}" -w '\n%{http_code}' \ + "$API/api/v1/elogia/edition/martyrologium_romanum_2004/01/02" 2>/dev/null) +CODE=$(tail -n1 <<<"$RESP") +BODY=$(sed '$d' <<<"$RESP") +if [[ "$CODE" == "404" ]]; then + skip "martyrologium_romanum_2004 not attached (martyrology-texts not mounted)" +elif [[ "$CODE" != "200" ]]; then + bad "expected 200 or 404, got $CODE" +else + ACCESS=$(jq -r '.metadata.access // empty' <<<"$BODY") + TEXT=$(jq -r '.elogia[0].text // "null"' <<<"$BODY") + if [[ "$ACCESS" == "restricted-texts" && "$TEXT" == "null" ]]; then + ok "access=restricted-texts with text=null" + else + bad "expected redaction, got access=$ACCESS text=$TEXT" + fi +fi + +echo +printf 'passed %d, failed %d, skipped %d\n' "$PASS" "$FAIL" "$SKIP" +[[ $FAIL -eq 0 ]] diff --git a/src/martyrology_api/app.py b/src/martyrology_api/app.py index 56baf3d..55166ee 100644 --- a/src/martyrology_api/app.py +++ b/src/martyrology_api/app.py @@ -33,6 +33,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: settings.zitadel_client_id, settings.zitadel_client_secret, settings.zitadel_project_id, + settings.zitadel_internal_url, ) app.state.authz = Authz( settings.openfga_api_url, diff --git a/src/martyrology_api/auth.py b/src/martyrology_api/auth.py index b5085ac..9f6d06e 100644 --- a/src/martyrology_api/auth.py +++ b/src/martyrology_api/auth.py @@ -23,6 +23,7 @@ def __init__( client_id: str, client_secret: str, project_id: str = "", + internal_url: str = "", cache_ttl: int = 300, cache_max: int = 10_000, transport: httpx.AsyncBaseTransport | None = None, @@ -31,6 +32,10 @@ def __init__( self.client_id = client_id self.client_secret = client_secret self.project_id = project_id + # Where introspection is actually sent. `issuer` stays the public, + # browser-facing value asserted in the `iss` claim; only the transport + # target moves. + self.internal_url = (internal_url or issuer).rstrip("/") self.cache_ttl = cache_ttl self.cache_max = cache_max self._transport = transport @@ -71,7 +76,7 @@ async def identity(self, token: str) -> Identity | None: try: async with httpx.AsyncClient(transport=self._transport) as client: resp = await client.post( - f"{self.issuer}/oauth/v2/introspect", + f"{self.internal_url}/oauth/v2/introspect", data={"token": token}, auth=(self.client_id, self.client_secret), ) diff --git a/src/martyrology_api/config.py b/src/martyrology_api/config.py index 60d1d6a..6bde399 100644 --- a/src/martyrology_api/config.py +++ b/src/martyrology_api/config.py @@ -23,6 +23,19 @@ class Settings(BaseSettings): zitadel_client_secret: str = "" zitadel_project_id: str = "" + # Transport-only override for the introspection endpoint. Empty = use + # zitadel_issuer. Set when the browser-facing issuer is not reachable from + # inside the API process: in Docker `localhost` is the container's own + # loopback, and behind Plesk nginx terminates upstream. This is NEVER an + # auth-posture input — `auth_enabled` still keys off zitadel_issuer alone. + zitadel_internal_url: str = "" + + # Postgres DSN for the `martyrology` database. Empty = no database + # configured; nothing in the API reads it yet. It exists so the + # permission-request and notification subsystem lands as migrations + # without a compose change. See the local-development-stack design, D9. + database_url: str = "" + openfga_api_url: str = "" openfga_store_id: str = "" openfga_model_id: str = "" diff --git a/tests/test_auth.py b/tests/test_auth.py index aa446ec..602c97a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -194,3 +194,45 @@ async def test_no_project_id_configured_yields_no_roles(): ident = await _auth(claims, project_id="").identity("t") assert ident is not None assert ident.roles == frozenset() + + +def mock_transport_recording(seen: list[str], sub: str): + def handler(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json={"active": True, "sub": sub}) + + return httpx.MockTransport(handler) + + +@pytest.mark.asyncio +async def test_internal_url_is_used_for_introspection(): + seen: list[str] = [] + a = Authenticator( + "https://auth.example", + "cid", + "sec", + internal_url="http://zitadel:8080", + transport=mock_transport_recording(seen, "u1"), + ) + ident = await a.identity("tok-internal") + assert ident is not None + assert ident.subject == "u1" + assert seen == ["http://zitadel:8080/oauth/v2/introspect"] + + +@pytest.mark.asyncio +async def test_internal_url_defaults_to_issuer_and_strips_trailing_slash(): + seen: list[str] = [] + a = Authenticator( + "https://auth.example/", "cid", "sec", transport=mock_transport_recording(seen, "u2") + ) + await a.identity("tok-default") + assert seen == ["https://auth.example/oauth/v2/introspect"] + + +@pytest.mark.asyncio +async def test_internal_url_does_not_resurrect_auth_when_issuer_is_empty(): + # Transport-only override: an internal URL must never make a + # deliberately-disabled authenticator start answering. + a = Authenticator("", "cid", "sec", internal_url="http://zitadel:8080") + assert await a.identity("tok-no-issuer") is None diff --git a/tests/test_config.py b/tests/test_config.py index 5426357..dbe9521 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,3 +26,20 @@ def test_authz_enabled_requires_a_token(): on = Settings(_env_file=None, **base, openfga_api_token="k") # pyright: ignore[reportCallIssue] assert off.authz_enabled is False assert on.authz_enabled is True + + +def test_zitadel_internal_url_defaults_empty_and_does_not_affect_posture(): + s = Settings(_env_file=None) # pyright: ignore[reportCallIssue] + assert s.zitadel_internal_url == "" + + s2 = Settings( + _env_file=None, # pyright: ignore[reportCallIssue] + zitadel_internal_url="http://zitadel:8080", + ) + assert s2.zitadel_internal_url == "http://zitadel:8080" + assert s2.auth_enabled is False + + +def test_database_url_defaults_empty(): + s = Settings(_env_file=None) # pyright: ignore[reportCallIssue] + assert s.database_url == "" diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..f7f4abe --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + +ROOT = Path(__file__).resolve().parents[1] + + +def _script_directory() -> ScriptDirectory: + return ScriptDirectory.from_config(Config(str(ROOT / "alembic.ini"))) + + +def test_alembic_tree_has_exactly_one_head(): + # A second head means two migrations claim the same parent — `alembic + # upgrade head` then fails at deploy time rather than here. + assert len(_script_directory().get_heads()) == 1 + + +def test_baseline_revision_exists(): + revisions = {r.revision for r in _script_directory().walk_revisions()} + assert "0001_baseline" in revisions diff --git a/uv.lock b/uv.lock index 69660c6..33161dc 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "alembic" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/01/a48dab7827ac4421272399f7ed9a2ec17edd12c8bcde4417bd7b6821b71a/alembic-1.19.0.tar.gz", hash = "sha256:6487c612fc719dcfa22b17d2dd5b2b458929641e6aa2f0b65b135727f5e6d501", size = 2069906, upload-time = "2026-08-04T18:57:04.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/79/59ab6f1fe72eee229de91aa393225389a85df12a0fbbbfa264efcf6d7872/alembic-1.19.0-py3-none-any.whl", hash = "sha256:cf839d3849116aab3cc047e09c6968b9bd6b2fde61b6bb7c1e97352fe5503580", size = 265738, upload-time = "2026-08-04T18:57:06.219Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -148,6 +162,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/7f/121bf6fb72e845013a83f96e697bbd24c6fbe41503fff8c6f83f02e300ae/fastapi-0.140.5-py3-none-any.whl", hash = "sha256:82e7a988a801d8d71e712cb7d12ebb5c87c2e74bccb104f5741cce46a742f806", size = 131034, upload-time = "2026-07-27T16:02:53.555Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -278,15 +347,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, ] +[[package]] +name = "mako" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/b2/3c025fc185ce755ab39dd88b1971ba9f4bec0a897bda0acc29b6e7676153/mako-1.4.0.tar.gz", hash = "sha256:61aac9619e1325fa6e7be2f8dd463caef61247b3cd32a41a8692684548269ce3", size = 409686, upload-time = "2026-08-04T18:20:35.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/db/1f950cb923f6ba523aa46143d8417d932261952dea635563e52ab4bce885/mako-1.4.0-py3-none-any.whl", hash = "sha256:49473fd7145702c7b6f5c61ada3967c42ced4b1d27fb24deb43d61ffee2e8fca", size = 83630, upload-time = "2026-08-04T18:20:36.653Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "martyrology-api" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi" }, { name = "httpx2" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "sqlalchemy" }, { name = "uvicorn" }, ] @@ -302,9 +449,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.14" }, { name = "fastapi", specifier = ">=0.140.5" }, { name = "httpx2", specifier = ">=2.9.1" }, { name = "openapi-spec-validator", marker = "extra == 'dev'", specifier = ">=0.7" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-settings", specifier = ">=2.3" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, @@ -312,6 +461,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.22" }, + { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", specifier = ">=0.51.0" }, ] provides-extras = ["dev"] @@ -386,6 +536,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, + { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, + { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" }, + { url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" }, + { url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, + { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -766,6 +974,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -809,6 +1058,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uvicorn" version = "0.51.0"