Skip to content

Local development stack: full containerized stack - #16

Merged
JohnRDOrazio merged 15 commits into
mainfrom
feat/local-dev-stack
Aug 4, 2026
Merged

Local development stack: full containerized stack#16
JohnRDOrazio merged 15 commits into
mainfrom
feat/local-dev-stack

Conversation

@JohnRDOrazio

@JohnRDOrazio JohnRDOrazio commented Aug 4, 2026

Copy link
Copy Markdown
Member

Implements Tasks 9-13 of martyrology-api's docs/superpowers/plans/2026-08-04-local-development-stack.md.

Depends on CatholicOS/martyrology-api#29 landing firstdb-init, api-migrate and martyrology-api all build that repo's image, which needs its Dockerfile on main for the GitHub-default path to work.

What this adds

The full stack: everything the API repo's infra stack has, plus zitadel-login, an nginx zitadel-proxy, and containers for both applications. It mirrors cdcf-infra production topology — Zitadel and its v2 login UI behind a single origin — because that is what the OIDC flow will actually run against, 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; docker-compose.override.example.yml repoints at local siblings and attaches the private martyrology-texts repo.

Two findings worth knowing about

Zitadel resolves instances by Host header. zitadel-login failed permanently with "Instance not found" because its backend calls presented Host: zitadel:8080, which matches no registered domain. Production already solves this with CUSTOM_REQUEST_HEADERS; that is now carried across. The same defect then hit the API's introspection — and since Authenticator builds its request with httpx and has no Host override, MARTYROLOGY_ZITADEL_INTERNAL_URL had to point at the public origin via extra_hosts, proven with a real token.

extra_hosts: host-gateway depends on Docker Desktop. It resolves to the vpnkit gateway, which bridges the host loopback; on native Linux Docker it is the ordinary bridge IP, which a 127.0.0.1-bound socket refuses. Documented in the compose header and .env.example with a concrete remedy. A redesign was rejected — it would trade a real security property for a platform we do not develop on.

Verified running

13 services up; API serving 8 editions; frontend 200 with its server-side proxy reaching the API over the docker network; OpenFGA seeded with exactly 11 structural tuples; Alembic at head.

scripts/smoke.sh: 6 passed / 0 failed / 1 skipped, exit 0. With the override applied, an anonymous read of martyrologium_romanum_2004 returns access: restricted-texts with all 15 elogia text: null — attached and redacted through real OpenFGA. That path is reachable only via the override, since martyrology-texts is private.

Notes for review

  • Smoke assertion 7 (/api/auth/providers) skips by design — Auth.js is not wired in yet; it arrives with the OIDC login-client plan this stack exists to unblock. The script distinguishes skip from pass rather than conflating them.
  • The provisioning scripts are deliberate near-duplicates of martyrology-api's, with reciprocal SIBLING NOTE headers in both repos. Alternatives (shared submodule, sourcing from a sibling) were analysed and rejected: these run on the host before any container exists, and coupling bring-up to a sibling checkout would break the no-siblings-required default path.
  • docker/nginx/zitadel.local.conf is a second copy of production's, diverging in four documented ways. The header enumerates them as drift control.
  • The image is a production build with no hot-reload; the documented dev loop stops the container and runs npm run dev.
  • Port 3000 is fixed by merged cdcf-infra code registering the OIDC callback.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a complete Docker-based local development stack with frontend, API, database, authentication, authorization, email testing, and administration tools.
    • Added guided setup utilities for authentication, authorization, and local superuser access.
    • Added a production-ready frontend container image and local reverse-proxy configuration.
  • Documentation

    • Expanded prerequisites, setup instructions, service endpoints, configuration guidance, and development workflows.
    • Added environment configuration examples and a Compose override for local repository builds.
  • Tests

    • Added a full-stack smoke check covering authentication, authorization, migrations, API health, and content access.

JohnRDOrazio and others added 13 commits August 4, 2026 14:49
Review found the final stage had no USER directive, so the container ran
as uid 0. node:24.19.0-slim ships an unprivileged node user (uid 1000)
for this purpose. Added --chown=node:node to the COPY --from=build lines
and USER node before CMD.

Verified: output: "standalone" does not copy .next/cache into the
bundle, this app has no ISR and its one server fetch uses cache:
"no-store", so no runtime cache write is exercised. Confirmed by hitting
/, /compare, /review, and the API proxy route as uid 1000 with clean
logs (no EACCES).
Adds db-init, db, zitadel, zitadel-login, zitadel-proxy, mailpit, and adminer
to docker-compose.yml, mirroring cdcf-infra's production topology: Zitadel and
its v2 login UI behind a single nginx origin.

Every externally-advertised Zitadel origin follows ${ZITADEL_PORT:-8080}
(ZITADEL_EXTERNALPORT, the LOGINV2/OIDC v2 URLs, and the proxy's published
port), so moving the published port can never leave Zitadel still advertising
8080 to itself. Container-internal addresses (zitadel:8080, zitadel-login:3000,
ZITADEL_API_URL, Adminer's internal 8080) are left unparameterized.

The nginx conf's CSP connect-src is rendered through nginx's stock envsubst-
on-templates entrypoint so it always matches the configured ZITADEL_PORT,
rather than a literal that could drift from the actual port in use. Also
fixes Host/X-Forwarded-Host to use $http_host instead of $host, since $host
strips the port and was silently truncating Zitadel's issuer on non-default
ports.

No OpenFGA Playground: v1.15.1 panics at startup with the Playground enabled
alongside the mandatory preshared auth, so .env.example omits it entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zitadel resolves the instance for a request from its Host header
(multi-instance routing) matched against registered domains, not from the
network path zitadel-login's ZITADEL_API_URL connects over. Since
zitadel:8080 (correctly left internal) is never a registered domain, every
backend call zitadel-login made to Zitadel failed with "Instance not found",
flipping the container Docker-unhealthy.

Fixed with CUSTOM_REQUEST_HEADERS: 'Host:localhost:${ZITADEL_PORT:-8080}',
matching cdcf-infra production's pattern. Determined the port-qualified form
empirically rather than guessing: both "localhost:8090" and bare "localhost"
resolve the instance, but return different issuers, so the port-qualified
form was chosen to agree with the $http_host fix already forwarding the
port-qualified Host to Zitadel from real browser traffic. Parameterized on
${ZITADEL_PORT:-8080} per Correction 1, never hardcoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FINDING 1: every published port in this stack binds 127.0.0.1 specifically,
so a developer visiting http://127.0.0.1:${ZITADEL_PORT}/... forwards
Host: 127.0.0.1:PORT, which matches no domain registered on the Zitadel
instance and reproduces "Instance not found" on a path none of the prior
verification exercised (it all went through localhost). Fixed with an nginx
map{} that normalizes 127.0.0.1/[::1] to localhost while preserving the port,
used for both Host and X-Forwarded-Host (kept identical deliberately, since
Zitadel's instance resolution was confirmed to read Host alone, and letting
X-Forwarded-Host disagree with Host could reintroduce the same failure via
any code that trusts the former). Not solved by registering 127.0.0.1 as a
second Zitadel instance domain, since that would be provisioned state that
would not survive a volume wipe.

FINDING 2: the stock nginx entrypoint substitutes every container env var
against the template by default, and this template's entire content is nginx
variables using the same $name syntax. Harmless today only by coincidence.
Added NGINX_ENVSUBST_FILTER: '^ZITADEL_PORT$' to zitadel-proxy so only that
variable is ever substituted, regardless of what's added to environment:
later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds openfga-migrate, openfga, authz-seed, api-migrate, martyrology-api and
martyrology-frontend to the compose file, completing the containerized dev
stack. Two corrections to the task brief, both verified empirically against
this stack:

- No OpenFGA Playground: v1.15.1 panics at startup when the Playground is
  enabled alongside OPENFGA_AUTHN_METHOD=preshared, and preshared auth is
  mandatory (Settings.authz_enabled requires a non-empty token). Matches the
  API repo's own compose file.
- martyrology-api's MARTYROLOGY_ZITADEL_INTERNAL_URL cannot be
  http://zitadel:8080: Zitadel resolves the instance from the Host header,
  and "zitadel:8080" is not a registered domain, so introspection fails with
  "Instance not found" (confirmed with a real token against this stack).
  Fixed with extra_hosts: localhost:host-gateway and pointing the internal
  URL at http://localhost:${ZITADEL_PORT}, so the request presents the
  already-registered Host the browser uses, via zitadel-proxy.
…tadel-proxy health

Two Important findings from task 11 review:

1. extra_hosts: host-gateway only reaches zitadel-proxy's 127.0.0.1-bound
   publish because Docker Desktop's vpnkit gateway specially bridges into
   the host's own loopback; native Linux Docker Engine's host-gateway
   resolves to the ordinary bridge gateway instead, which a 127.0.0.1-only
   publish will not accept. Documented in the compose header, .env.example,
   and on both extra_hosts blocks, with a concrete (not implemented) fix for
   native Linux users: widen the relevant publish in a local
   docker-compose.override.yml. Left the 127.0.0.1-only binding intact — no
   portable alternative was found that doesn't reintroduce the same
   accept-non-loopback-traffic problem this stack deliberately avoids.

2. martyrology-api and martyrology-frontend both route every Zitadel
   interaction through zitadel-proxy but only depended on zitadel's health,
   leaving a startup race. Added a zitadel-proxy healthcheck that exercises
   the actual forwarded path (GET /.well-known/openid-configuration through
   proxy_pass, not just the listening socket — a dead upstream 502s), and
   gated both app services on zitadel-proxy: condition: service_healthy. No
   dependency cycle: zitadel-proxy depends only on zitadel and zitadel-login.
Addresses Task 12 review: the warning previously lived only above
db-init:, so a developer trimming the file to services they care about
could delete the block and the warning together. It now lives in the
header (survives any trimming) with a short pointer repeated at each
of the three affected stanzas.
Adapts martyrology-api's setup-stack.sh/grant-superuser.sh for this repo
(provisions the frontend Zitadel app too, captures AUTH_ZITADEL_ID/SECRET,
generates AUTH_SECRET once via openssl and never regenerates it) rather
than sharing code with the sibling repo across a submodule-free boundary:
the scripts run on the host before any container exists, and coupling this
repo's bring-up to a sibling checkout would break the GitHub-default path
that requires none. Duplication is made honest with a header comment in
each script naming its counterpart. Full DRY analysis in
task-13-report.md.

smoke.sh distinguishes skip from pass/fail for the two assertions that are
legitimately absent right now: a restricted edition not being attached
(no override), and Auth.js not yet being wired into the frontend (arrives
with the OIDC login-client plan) — both would otherwise look like a 200
either way.

README corrections per reviewer ruling: no OpenFGA Playground row (v1.15.1
panics under the mandatory preshared auth; points at curl instead), and a
new ZITADEL_PORT gotcha documenting the Windows-host port requirement and
its silent docker-inspect-only failure mode under Docker Desktop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding: all three local-dev-stack scripts shell out heavily to
curl and jq (Zitadel/OpenFGA calls) and setup-stack.sh additionally
shells out to git (cloning cdcf-infra). A missing one surfaces as a bare
"command not found" partway through provisioning with nothing pointing
at the cause — the same lesson martyrology-api's README was corrected
for in its own review round. Re-derived the set directly from the three
scripts rather than trusting the reviewer's list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ale docs

- .dockerignore: switch from allow-all-except to deny-all-then-readmit
  (mirroring martyrology-api's form) — the prior shape let `COPY . .` ship
  .zitadel-data/*.pat (Zitadel admin + login-client PATs, valid to 2030) and
  .stack-out/cdcf-infra (a full git clone) into a build-stage layer. Verified
  by building through the `build` stage and confirming neither path is
  present, and that `next build` still succeeds with the narrowed re-admit
  list (package.json/-lock, tsconfig, next.config.ts, next-env.d.ts,
  postcss/eslint configs, app/components/lib/data/public).
- docker-compose.yml: interpolate MARTYROLOGY_API_REF and
  MARTYROLOGY_FRONTEND_REF into the three `build:` GitHub-ref URLs, matching
  how CDCF_INFRA_REF was already wired. Verified the interpolation via
  `docker compose config`; an actual remote build against
  feat/local-dev-stack fails only because that branch isn't pushed to
  GitHub yet (git ls-remote confirms), not because of the wiring.
- README: redirect the post-bring-up step to the Zitadel console — there is
  no frontend sign-in yet (Auth.js/OIDC login-client lands later; smoke
  assertion 7 already skips for exactly this reason). Also: Node.js 20+ ->
  24+ (matching .nvmrc/package.json/Dockerfile), and generalise two
  machine-specific facts (a literal ZITADEL_PORT=8090 and "this machine's
  .env") into the same generic phrasing martyrology-api's README already
  uses.
- docker/nginx/zitadel.local.conf: "Three deliberate divergences" -> four
  (X-Forwarded-Proto is https upstream, http here); generalise the
  NVIDIA-Broadcast-specific port-conflict aside the same way as the README.
- setup-stack.sh, grant-superuser.sh: cut -d= -f2 -> -f2-, so an
  `openssl rand -base64 32` preshared key (which pads with `=`) isn't
  silently truncated when read back by these scripts.
- smoke.sh: assertion 5 now captures the HTTP status code so a 403/500 from a
  broken redaction path is reported as a failure instead of collapsing into
  the "not attached" skip that only a real 404 should produce. Verified
  end-to-end against the running stack: 6 passed, 0 failed, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…up-stack.sh

The previous fix wave applied this fix to martyrology-api's copy only — a
scoping error, not an intentional split, per the coordinator. Under
`set -euo pipefail`, `STORE_ID="$(curl -sf ... | jq ...)"` aborted the whole
script at the assignment the instant curl failed, before the "No Martyrology
store found" guard — the one purpose-built to name the likely causes — ever
ran. Mirrors the API copy's fix line for line: both OpenFGA lookups end in
`|| true` so the guards can fire, and the store lookup gets the same 15x2s
retry poll, since `docker compose up -d` doesn't wait for authz-seed (a
`restart: "no"` one-shot) to finish seeding the store.

Also names "the OpenFGA store/model discovery" as an explicitly shared part
in both copies' SIBLING NOTE headers — it was previously undeclared despite
being intended to stay identical, which is how this gap went unnoticed.

Re-verified B1 (cut -d= -f2-) and B2 (smoke.sh %{http_code} split) already
landed in both repos' copies; swept both grant-superuser.sh and
setup-stack.sh pairs for any other silent divergence and found none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@JohnRDOrazio, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fa13bb7d-6ed3-41fa-8623-06faebf35577

📥 Commits

Reviewing files that changed from the base of the PR and between fe56256 and 9adb22d.

📒 Files selected for processing (1)
  • scripts/smoke.sh
📝 Walkthrough

Walkthrough

The PR adds a Docker-based local Martyrology stack with PostgreSQL, Zitadel, OpenFGA, nginx, the API, and the frontend. It adds production frontend packaging, local checkout overrides, identity and authorization provisioning scripts, environment management, and full-stack smoke tests.

Changes

Local stack

Layer / File(s) Summary
Build and configuration
.dockerignore, .env.example, .gitignore, Dockerfile, README.md
Adds the production frontend image, local environment settings, ignore rules, Docker build exclusions, prerequisites, and stack operation instructions.
Compose service topology
docker-compose.yml, docker/nginx/zitadel.local.conf
Adds the local PostgreSQL, Zitadel, Login v2, nginx, Mailpit, OpenFGA, API, frontend, and Adminer services with health checks, dependencies, volumes, networking, and proxy routing.
Local checkout overrides
docker-compose.override.example.yml, README.md
Adds sibling-repository builds, local API data mounts, local authorization seeding, and frontend development instructions.
Identity and authorization provisioning
scripts/setup-stack.sh, scripts/grant-superuser.sh
Adds Zitadel provisioning, OpenFGA identifier discovery, atomic .env updates, AUTH_SECRET generation, and superuser tuple management.
Full-stack smoke validation
scripts/smoke.sh
Adds checks for service discovery, authorization tuples, migrations, API health, redaction, Login v2 routing, and Auth.js provider registration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PostgreSQL
  participant Zitadel
  participant LoginV2
  participant Nginx
  participant OpenFGA
  participant API
  participant Frontend

  PostgreSQL->>Zitadel: provide database
  Zitadel->>LoginV2: provide healthy identity service
  LoginV2->>Nginx: serve Login v2
  OpenFGA->>API: provide authorization checks
  Nginx->>API: route OIDC introspection
  API->>Frontend: provide health-checked API
  Frontend->>Nginx: route browser OIDC traffic
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the pull request's main change: adding a full containerized local development stack.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/local-dev-stack

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docker-compose.override.example.yml`:
- Around line 66-74: Update the authz-seed service environment configuration in
docker-compose.override.example.yml to explicitly include OPENFGA_PRESHARED_KEY
in the merged environment, ensuring the variable is available when the startup
script expands it under set -u.
- Around line 47-56: Ensure the Compose setup fails before startup when any
required sibling checkout for the mounts under /app/src, /app/data/texts,
/data/crmedr, or /data/clbdr is missing. Add a preflight validation in the
associated Compose instructions or script, or configure long-syntax bind mounts
with create_host_path: false where supported, while preserving the existing
read-only mounts.
- Around line 23-40: Add image: martyrology-api:latest to the db-init,
api-migrate, and martyrology-api service definitions alongside their existing
build settings, ensuring all local initialization, migration, and API services
use the same API image.

In `@README.md`:
- Around line 156-160: Update the OpenFGA curl example to load and export values
from ./.env before constructing the request, using the shell setup shown in the
review. Keep the existing environment-variable references and curl command
unchanged after the environment-loading statements.

In `@scripts/setup-stack.sh`:
- Around line 62-69: Bound every curl invocation with shared --connect-timeout
and --max-time values: update Zitadel discovery probes in scripts/setup-stack.sh
lines 62-69, OpenFGA polling and model discovery in scripts/setup-stack.sh lines
146-164, the privileged write in scripts/grant-superuser.sh lines 110-113,
discovery and tuple checks in scripts/smoke.sh lines 23-30, the API health check
at lines 37-38, the redaction request at lines 47-48, and Login V2/Auth.js
checks at lines 63-70.

In `@scripts/smoke.sh`:
- Around line 28-31: Replace the total COUNT equality assertion in the smoke
test with validation of the required structural tuple keys, or filter out
optional administrative tuples such as platform:martyrology#superuser before
counting. Preserve failure reporting through bad and ensure the supported
grant-superuser flow does not cause the check to fail.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d80f826-9182-4266-b421-bd61e3335894

📥 Commits

Reviewing files that changed from the base of the PR and between 96b8dfc and 1b828f1.

📒 Files selected for processing (11)
  • .dockerignore
  • .env.example
  • .gitignore
  • Dockerfile
  • README.md
  • docker-compose.override.example.yml
  • docker-compose.yml
  • docker/nginx/zitadel.local.conf
  • scripts/grant-superuser.sh
  • scripts/setup-stack.sh
  • scripts/smoke.sh

Comment on lines +23 to +40
services:
# Part of the three-services group sharing martyrology-api:latest — see the
# ⚠ warning in the header. Keep alongside api-migrate and martyrology-api.
db-init:
build:
context: ../martyrology-api

# Part of the three-services group sharing martyrology-api:latest — see the
# ⚠ warning in the header. Keep alongside db-init and martyrology-api.
api-migrate:
build:
context: ../martyrology-api

# Part of the three-services group sharing martyrology-api:latest — see the
# ⚠ warning in the header. Keep alongside db-init and api-migrate.
martyrology-api:
build:
context: ../martyrology-api

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

cfg="$(mktemp)"
trap 'rm -f "$cfg"' EXIT

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.example.yml \
  config --format json >"$cfg"

for service in db-init api-migrate martyrology-api; do
  image="$(jq -r --arg service "$service" '.services[$service].image // empty' "$cfg")"
  context="$(jq -r --arg service "$service" '.services[$service].build.context // empty' "$cfg")"

  test "$image" = "martyrology-api:latest"
  test "$(basename "$context")" = "martyrology-api"
done

Repository: CatholicOS/martyrology-frontend

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## files"
git ls-files | rg '(^|/)docker-compose(\.override)?\.example?\.ya?ml$|(^|/)docker-compose\.ya?ml$' || true

echo
echo "## docker-compose.override.example.yml relevant lines"
sed -n '1,80p' docker-compose.override.example.yml

echo
echo "## docker-compose.yml relevant service definitions"
if [ -f docker-compose.yml ]; then
  sed -n '1,160p' docker-compose.yml | nl -ba
fi

Repository: CatholicOS/martyrology-frontend

Length of output: 3713


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re, sys

sup = Path('docker-compose.override.example.yml')
base = Path('docker-compose.yml')
print("present:", {p.name:p.exists() for p in (base, sup)})

for path in (base, sup):
    text = path.read_text() if path.exists() else ''
    print(f"\n### {path.name} ({len(text)} bytes)")
    # print service entry blocks with db-init/api-migrate/martyrology-api nearby
    lines = text.splitlines()
    in_svc = None
    indent = None
    for i, line in enumerate(lines, 1):
        if re.match(r'^([a-zA-Z0-9_-]+)(:|$)', line):
            m = re.match(r'^( *\w+)\s*:', line)
            if m:
                in_svc = line.strip().rstrip(':')
                indent = len(line) - len(line.lstrip())
            else:
                in_svc = None
        if indent is not None and (in_svc in {'db-init','api-migrate','martyrology-api'} or re.search(rf'''
            (db-init|api-migrate|martyrology-api)\b
        ''', line)):
            start=max(1,i-3); end=min(len(lines),i+12)
            print(f"[rows {start}-{end}]")
            for j in range(start,end+1):
                print(f"{j:4}: {lines[j-1]}")
    if indent is not None:
        print("[continued until service block end]")

print("\n### regex-parse image/build/context into service order")
def parse_compose_services(text):
    """Very scoped parser: top-level services <indent>+ entries, image/build.context/key values."""
    lines = text.splitlines()
    # find services block
    basespace = min((i for i,line in enumerate(lines) if line.strip() == 'services:' ), default=None)
    if basespace is None:
        basespace = 0
    offset_start = None
    for i in range(basespace+1, len(lines)):
        if lines[i].strip()=='':
            offset_start = None
            continue
        indent = len(lines[i]) - len(lines[i].lstrip())
        if not lines[i].strip().endswith(':'):
            offset_start = None
            continue
        if re.match(r'^[a-zA-Z0-9_-]+$|^[a-zA-Z0-9_-]+$:', lines[i].strip()):
            if offset_start is None:
                offset_start = i
    # walk lines with >= same indent as first service
    out=[]
    cur=None
    entry_spaces=None
    for i in range(offset_start or basespace, len(lines)):
        line=lines[i]
        stripped=line.strip()
        if stripped and not stripped.startswith('#'):
            indent=len(line)-len(line.lstrip())
            is_service = bool(re.match(r'^[a-zA-Z0-9_:._-]+:$', stripped))
            if is_service:
                if cur:
                    out.append((cur, entry_spaces, data))
                cur=stripped[:-1]
                entry_spaces=indent
                data={}
                continue
            if is_service or not stripped.startswith(' ') or indent < entry_spaces:
                continue
            if '=' not in stripped:
                if stripped.count(':')==1 and stripped.endswith(':'):
                    key=stripped[:-1]
                    maybe=next((j for j in range(i+1, min(len(lines),i+4)) if lines[j].strip() and not lines[j].strip().startswith('#')), None)
                    if maybe:
                        subline=lines[maybe]
                        if isinstance(data.get(key), dict):
                            data[key].append(subline.strip())
                        else:
                            data[key]=[subline.strip()]
                    else:
                        data[key]=None
                continue
            k,v=stripped.split('=',1)
            data.setdefault(k,[])
            data[k].append(v.strip())
    out.append((cur, entry_spaces, data))
    return out

services={}
for path in (base,sup):
    text=path.read_text() if path.exists() else ''
    for name, spaces, data in parse_compose_services(text):
        print(f"{path.name} service {name}: image={data.get('image')}; build={data.get('build')}; build.context={(data.get('build') or [{}])[0] if isinstance(data.get('build'), list) else None}")
        s=services.setdefault(name, {}); s[path.name]=data

print("\n### overlay merge by key precedence: override replaces top-level if override has key; build can contain image and context")
merged={}
for name in ['db-init','api-migrate','martyrology-api']:
    d=services.get(name, {})
    base_data=d.get('docker-compose.yml')
    override_data=d.get('docker-compose.override.example.yml')
    merged[name]=base_data.copy() if isinstance(base_data, dict) else {}
    if override_data:
        merged[name].update(override_data)

for name in ['db-init','api-migrate','martyrology-api']:
    data=merged[name]
    image=data.get('image')
    build=data.get('build')
    context=None
    if isinstance(build, dict):
        context=build.get('context')
    elif isinstance(build, (str,list)) and build:
        context=build[-1] if isinstance(build,list) else build
    print(f"\n{name}:")
    print(f"  image={image}")
    print(f"  build={build}")
    print(f"  build.context={context}")
    print(f"  context.basename={ (Path(context).parts[-1] if context else None) }")
PY

Repository: CatholicOS/martyrology-frontend

Length of output: 9525


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## docker-compose.yml db-init/api-migrate/martyrology-api concise blocks"
python3 - <<'PY'
from pathlib import Path
import re

for path in ["docker-compose.yml", "docker-compose.override.example.yml"]:
    print(f"\n### {Path(path).name}")
    lines = Path(path).read_text().splitlines()
    services_start = next((i for i, l in enumerate(lines, 1) if l.strip() == "services:"), None)
    if services_start is None:
        continue
    block_lines = lines[services_start:]
    stack = []
    out = {}
    current = None
    current_indent = None
    data = None
    in_services = False
    for idx, line in enumerate(block_lines, services_start + 1):
        orig = line
        indent = len(line) - len(line.lstrip())
        stripped = line.lstrip()
        if stripped == "services:":
            in_services = True
            continue
        if stripped.startswith("#"):
            print(f"{idx}: {orig}")
            continue
        if not in_services:
            continue
        if not orig.strip():
            continue

        m = re.match(r"^([ \t]*)([A-Za-z0-9_/.-]+):\s*$", orig)
        if m and m.group(2) in {"db-init", "api-migrate", "martyrology-api"}:
            if current and current[0] in {"db-init", "api-migrate", "martyrology-api"}:
                out[current[1]] = current[2]
            current = (m.group(2), idx, [(idx, orig)])
            current_indent = len(m.group(1))
            continue

        if current:
            if line.strip().startswith("#"):
                current[2].append((idx, orig))
                continue
            if indent <= current_indent:
                if current[0] in {"db-init", "api-migrate", "martyrology-api"}:
                    out[current[0]] = current[2]
                current = None
                current_indent = None
                if indent > 0 and m2 := re.match(r"^([ \t]*)([A-Za-z0-9_/.-]+):\s*$", orig):
                    current = (m2.group(2), idx, [(idx, orig)])
                    current_indent = len(m2.group(1))
                continue
            current[2].append((idx, orig))
    if current and current[0] in {"db-init", "api-migrate", "martyrology-api"}:
        out[current[0]] = current[2]

    for name in ["db-init", "api-migrate", "martyrology-api"]:
        entries = out.get(name, [])
        print(f"-- {name} at rows {[x[0] for x in entries][:10]}")
        for row in entries[:60]:
            print(f"{row[0]:4}: {row[1]}")
        if entries:
            lines_used = "\n".join(x[1] for x in entries[:50])
            if re.search(r"^dockerfile:", lines_used, re.M):
                print("  HAS dockerfile directive")
            if re.search(r"^image:", lines_used, re.M):
                print("  HAS image directive")
            if re.search(r"^tags:", lines_used, re.M):
                print("  HAS tags directive")
            if re.search(r"martyrology-api:latest|latest|github.com|git@", lines_used):
                print("  HAS API image/ref context")
PY

echo
echo "## top-level Compose YAML snippets around three services"
python3 - <<'PY'
import yaml
path = "docker-compose.yml"
text = open(path).read()
data = yaml.safe_load(text)
services = data.get("services", {})
for name in ["db-init", "api-migrate", "martyrology-api"]:
    svc = services.get(name) or {}
    print(name, svc.get("image"), "dockerfile" in svc.get("build", {}) if isinstance(svc.get("build"), dict) else "build not dict")
PY

Repository: CatholicOS/martyrology-frontend

Length of output: 419


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## docker-compose.yml relevant service blocks"
python3 - <<'PY'
from pathlib import Path
import re

for path in ["docker-compose.yml", "docker-compose.override.example.yml"]:
    print(f"\n### {path}")
    lines = Path(path).read_text().splitlines()
    services_start = next((i for i, l in enumerate(lines, 1) if l.strip() == "services:"), None)
    if not services_start:
        continue
    current = None
    current_indent = None
    out = {}
    in_services = False
    for idx, orig in enumerate(lines, 1):
        stripped = orig.lstrip()
        if stripped == "services:":
            in_services = True
            continue
        if not in_services:
            continue
        if stripped.startswith("#") or not stripped:
            if current:
                current[2].append((idx, orig))
            continue
        m = re.match(r"^([ \t]*)([A-Za-z0-9_/.-]+):\s*$", orig)
        if m:
            name = m.group(2)
            if name in {"db-init", "api-migrate", "martyrology-api"}:
                if current and current[0] in {"db-init", "api-migrate", "martyrology-api"}:
                    out[current[0]] = current[2]
                current = [name, idx, [(idx, orig)]]
                current_indent = len(m.group(1))
                continue
            if current:
                indent = len(orig) - len(orig.lstrip())
                if indent <= current_indent:
                    if current[0] in {"db-init", "api-migrate", "martyrology-api"}:
                        out[current[0]] = current[2]
                    current = None
                    current_indent = None
                    if indent > 0:
                        current = [name, idx, [(idx, orig)]]
                        current_indent = len(m.group(1))
                else:
                    current[2].append((idx, orig))
        elif current:
            current[2].append((idx, orig))
    if current and current[0] in {"db-init", "api-migrate", "martyrology-api"}:
        out[current[0]] = current[2]

    for name in ["db-init", "api-migrate", "martyrology-api"]:
        entries = out.get(name) or []
        print(f"-- {name}")
        for row in entries[:70]:
            print(f"{row[0]:4}: {row[1]}")
PY

echo
echo "## base YAML service fields parsed by PyYAML"
python3 - <<'PY'
import yaml
from pathlib import Path

path = Path("docker-compose.yml")
data = yaml.safe_load(path.read_text())
services = data.get("services", {})
for name in ["db-init", "api-migrate", "martyrology-api"]:
    svc = services.get(name) or {}
    print(name)
    print("  image:", svc.get("image"))
    print("  tags:", svc.get("tags"))
    print("  build:", svc.get("build"))
    print("  dockerfile:", svc.get("build", {}).get("dockerfile") if isinstance(svc.get("build"), dict) else None)
PY

Repository: CatholicOS/martyrology-frontend

Length of output: 2652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## services block context before parsed services"
python3 - <<'PY'
from pathlib import Path
import re

text = Path("docker-compose.yml").read_text()
m = re.search(r'(?m)^services:\n', text)
print("services_start_line", m.start("services_start") + 1 if m else None)

for name in ["db-init", "api-migrate", "martyrology-api"]:
    idx = text.find(f"\n  {name}:")
    if idx == -1:
        idx = text.find(f"\n    {name}:")
    print(f"\n{name} found at", idx)
    start = text.rfind('\n', 0, idx - 1)
    print(text[start:idx + 60])
PY

echo
echo "## search for service declarations of the three names in docker-compose.yml"
python3 - <<'PY'
from pathlib import Path
import re

text = Path("docker-compose.yml").read_text()
for name in ["db-init", "api-migrate", "martyrology-api"]:
    for rexp in [r'^services:\s*$|[ \t]'+re.escape(name)+r':\s*$', r'^'+re.escape(name)+r':\s*$']:
        pat = re.compile(rexp, re.M)
        print(f"{name} pattern {rexp!r}:")
        for m in pat.finditer(text):
            print(f"  {m.start(0)} chars, line {text.count(chr(10), 0, m.start())+1}, text={text[m.start():m.start()+20]!r}")
PY

echo
echo "## show surrounding lines for likely service declaration block"
sed -n '300,350p' docker-compose.yml
sed -n '385,445p' docker-compose.yml

Repository: CatholicOS/martyrology-frontend

Length of output: 317


Make sure local migration/seed services ship with the API image.

docker-compose.override.example.yml only adds build.context; it does not define image. Add image: martyrology-api:latest to db-init, api-migrate, and martyrology-api so local initialization and migrations cannot run against a different API image or revision.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.override.example.yml` around lines 23 - 40, Add image:
martyrology-api:latest to the db-init, api-migrate, and martyrology-api service
definitions alongside their existing build settings, ensuring all local
initialization, migration, and API services use the same API image.

Comment on lines +47 to +56
volumes:
# :ro — the API never writes to its source tree. The venv is deliberately
# NOT mounted, so the image's installed dependencies are used.
- ../martyrology-api/src:/app/src:ro
- ../martyrology-texts/data/editions:/app/data/texts:ro
# Live data-repo edits. The image clones these at build time; mounting
# host checkouts over them means a stale pinned ref never blocks work.
- ../crmedr:/data/crmedr:ro
- ../clbdr:/data/clbdr:ro

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

for path in \
  ../martyrology-api/src \
  ../martyrology-texts/data/editions \
  ../crmedr \
  ../clbdr
do
  test -d "$path" || {
    printf 'Missing required checkout: %s\n' "$path" >&2
    exit 1
  }
done

Repository: CatholicOS/martyrology-frontend

Length of output: 219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'compose overlay:\n'
cat -n docker-compose.override.example.yml | sed -n '1,90p'

printf '\ncompose base service references containing API/source/data paths:\n'
rg -n "martyrology-api|/app/src|/app/data/texts|/data/crmedr|/data/clbdr|volumes:" -S .

printf '\nrepo files likely related to compose:\n'
git ls-files | rg '(^|/)(docker-compose|compose)(\.yaml|\.yml|\.example\.yml)?$|^docker'

Repository: CatholicOS/martyrology-frontend

Length of output: 12920


🌐 Web query:

Docker Compose v2 bind mount missing host path create host path short syntax long syntax create_host_path false

💡 Result:

In Docker Compose, the behavior of creating a missing host path for bind mounts depends on the syntax used and the create_host_path configuration option. Short Syntax The short syntax (e.g., - ./source:/target) is designed for backward compatibility with legacy docker-compose behavior [1][2]. It automatically creates a directory at the specified host path if it does not exist [1][3]. This behavior is implied and cannot be disabled in the short syntax [1][4]. Long Syntax The long syntax allows for more explicit control over bind mount behavior using the bind configuration object [1][5]. - When using long syntax, create_host_path defaults to true, meaning Docker Compose will still attempt to create the host path if it is missing [6]. - To prevent this automatic creation, you must explicitly set create_host_path: false in your configuration [2][6]: services: myservice: volumes: - type: bind source:./host/path target: /container/path bind: create_host_path: false Important Considerations - Enforcement: Setting create_host_path: false is intended to prevent the automatic creation of the directory [2]. However, users have reported issues where this setting is sometimes ignored or inconsistent depending on the Docker Engine version and Compose implementation [2][6][7]. - Version Requirements: Using create_host_path: false requires Docker Engine v23.0 or later (API v1.42+); using it with older versions may result in the setting being silently ignored [7]. - Reliability: Some users have encountered bugs where the host path is created despite the false setting [2][6]. While there have been ongoing efforts to improve this validation [8], the behavior can vary depending on your specific environment and Docker version [2][7]. If you require strict enforcement, verify that your environment is not subject to known regressions in your specific Compose version [2][6].

Citations:


🌐 Web query:

Docker Engine missing host path bind mount directory created read only container API start

💡 Result:

The behavior you are encountering depends on whether you are using the older -v/--volume flag or the newer --mount flag [1][2]. 1. Legacy Behavior (-v / --volume): If you use the -v or --volume flag and the source path on the host does not exist, the Docker daemon automatically creates it for you as a directory [1][3]. This can lead to unexpected behavior if you intended to mount a file or if the daemon lacks sufficient permissions to create the directory at that location [4][5][6]. 2. Modern Behavior (--mount): If you use the --mount flag, the Docker daemon expects the source path to already exist [2][3]. If it does not, Docker will generate an "invalid mount config" error and refuse to start the container [7][2][8]. To solve the "missing host path" error: * Recommended: Create the directory manually on the host before running the container (e.g., mkdir -p /path/to/dir) [9][2]. * Using --mount with automatic creation: If you are using --mount, you can now add the bind-create-src option to instruct Docker to automatically create the source directory if it does not exist, similar to the legacy -v behavior [7][3]. Regarding the read-only aspect: The read-only status is independent of whether the directory is created automatically. If you specify the ro (or readonly) option in your mount configuration (e.g., -v /host:/container:ro or --mount type=bind,source=/host,target=/container,readonly), the Docker engine ensures the bind mount is mounted as read-only within the container, regardless of how the directory was created [7][10]. If the host directory is created by Docker automatically, it is typically created with default system permissions (often owned by root), which might cause permission issues when your container processes attempt to access it [4][5][6].

Citations:


🌐 Web query:

Docker Compose short bind mount read-only missing source directory starts with empty container directory source

💡 Result:

When using Docker Compose with bind mounts, the behavior regarding missing source directories depends significantly on the syntax used and the specific version of Docker Compose. Short Syntax By default, when using the short syntax (e.g., -./source:/target), Docker Compose automatically creates the directory on the host if it does not exist [1][2][3]. This is a long-standing behavior intended for backward compatibility with legacy docker-compose [2][4][3]. The created directory is typically owned by root on the host [2]. Long Syntax When using the long syntax (specifying type: bind), Docker Compose generally provides more granular control, but behavior can be inconsistent across versions [2][4][5]. - You can explicitly set bind.create_host_path: true or false to control this behavior [1][4]. - Setting create_host_path: false is intended to prevent the automatic creation of a missing host directory [2][4]. However, users have reported cases where this setting is ignored depending on the Docker Compose version and environment, leading to the host directory still being created [2][5]. If you encounter an error stating that the "bind source path does not exist," it is likely because your configuration, environment, or a recent Docker Compose update (such as v2.35.0) is not defaulting to the automatic creation behavior, or the system is enforcing stricter validation [6][2]. Key Considerations - Read-Only Mounts: The readonly (ro) option prevents the container from writing to the mount but does not prevent Docker from attempting to create the directory on the host if it is missing [7][8]. - File vs. Directory: If you intend to bind a file but the source path does not exist, Docker may create a directory instead of a file, which can cause mounting issues [4]. Using the long syntax with explicit configurations is the recommended way to maintain control over these mount definitions [4][5]. If your workflow requires a directory to exist, it is best practice to ensure the source path is present before running docker compose up or to use the long syntax with bind.create_host_path: true to explicitly enable the behavior [1][4].

Citations:


🌐 Web query:

moby create volume bind mount no create host path read only daemon source directory

💡 Result:

Whether Docker automatically creates a host directory when you create a bind mount depends on the flag you use [1]. When using the --volume (or -v) flag, Docker automatically creates the source directory on the host if it does not already exist [2][1]. This occurs regardless of whether the mount is read-only (:ro) or read-write (:rw) [3][2]. If the host path does not exist, Docker creates it as a directory owned by the root user [3]. When using the --mount flag, by default, Docker does not automatically create the source directory [4][1]. If the specified source path does not exist on the daemon host, the Docker daemon returns an error [5]. To enable automatic directory creation when using the --mount flag, you must explicitly include the bind-create-src option [4][5]. Example using --mount with auto-creation: docker run -it --mount type=bind,source=/path/on/host,target=/path/in/container,readonly,bind-create-src busybox In summary: 1. --volume (-v): Automatically creates the missing source directory [2][1]. 2. --mount (default): Does not create the source directory and returns an error [4][1][5]. 3. --mount (with bind-create-src): Automatically creates the source directory [4][5].

Citations:


🌐 Web query:

Docker Compose short syntax bind mount uses Docker -v flag behavior source dir not exist compose up

💡 Result:

When using the short syntax for bind mounts in Docker Compose, if the specified source path on the host does not exist, Docker Compose automatically creates it as a directory [1][2]. This behavior is maintained for backward compatibility with legacy versions of docker-compose [1][3]. In contrast, the long syntax does not automatically create the host path by default; it will return an error if the source directory is missing [4][5]. You can explicitly control this behavior in the long syntax by using the bind.create_host_path option [1][6]: - To enable automatic creation (matching the short syntax behavior): Set create_host_path: true [6]. - To prevent automatic creation (ensuring the build or start fails if the path is missing): Set create_host_path: false [1][2]. Note that bugs have periodically occurred in various versions of Docker Compose regarding the consistency of this behavior (e.g., cases where create_host_path: false was ignored or where bind mounts failed unexpectedly) [7][8][3]. If you encounter unexpected behavior, ensure you are using a recent version of Docker Compose and verify your syntax [3]. Example of the long syntax configuration: services: myservice: image: alpine volumes: - type: bind source:./my-local-dir target: /app bind: create_host_path: false # Set to true to auto-create if missing Finally, be aware that Docker creates the missing host path as a directory (even if you intended to mount a file) if the source does not exist [4][2]. If you need to mount a specific file, ensure the source file already exists on the host to avoid it being created as a directory by Docker [2].

Citations:


Fail fast when required sibling directories are absent.

If a required sibling checkout is missing, Docker Compose short-bind syntax can create an empty host directory that shadows the image data mounted at /app/src, /app/data/texts, /data/crmedr, or /data/clbdr. Add a preflight check in the Compose instructions/script, or replace the short bind mounts with long-syntax binds using create_host_path: false when that setting is supported in the target environment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.override.example.yml` around lines 47 - 56, Ensure the Compose
setup fails before startup when any required sibling checkout for the mounts
under /app/src, /app/data/texts, /data/crmedr, or /data/clbdr is missing. Add a
preflight validation in the associated Compose instructions or script, or
configure long-syntax bind mounts with create_host_path: false where supported,
while preserving the existing read-only mounts.

Comment on lines +66 to +74
set -eu
apk add --no-cache bash curl jq >/dev/null
rm -rf /tmp/auth && cp -r /cdcf-infra/auth /tmp/auth
cd /tmp/auth
cat > .env.local <<EOF
OPENFGA_API_URL=http://openfga:8080
OPENFGA_INTERNAL_URL=http://openfga:8080
OPENFGA_PRESHARED_KEY=$$OPENFGA_PRESHARED_KEY
EOF

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

docker compose \
  -f docker-compose.yml \
  -f docker-compose.override.example.yml \
  config --format json |
  jq -e '
    .services["authz-seed"].environment
    | if type == "object" then has("OPENFGA_PRESHARED_KEY")
      elif type == "array" then any(.[]; startswith("OPENFGA_PRESHARED_KEY="))
      else false end
  ' >/dev/null

Repository: CatholicOS/martyrology-frontend

Length of output: 209


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files | rg '(^|/)(docker-compose(\.override\.example)?\.yml|docker-compose\.yml|\.env(sample)?|\.env\.example)$' || true

printf '\nTarget diff/stat:\n'
git diff --stat || true
git diff -- docker-compose.yml docker-compose.override.example.yml | sed -n '1,220p' || true

printf '\nMerged-relevant references:\n'
for f in docker-compose.yml docker-compose.override.example.yml; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    wc -l "$f"
    sed -n '1,140p' "$f"
  fi
done

Repository: CatholicOS/martyrology-frontend

Length of output: 10979


Declare OPENFGA_PRESHARED_KEY for authz-seed.

docker-compose.override.example.yml overrides authz-seed without passing any base environment, and the override script uses set -u before expanding $$OPENFGA_PRESHARED_KEY. Add OPENFGA_PRESHARED_KEY to the merged service environment so the container script does not fail during startup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.override.example.yml` around lines 66 - 74, Update the
authz-seed service environment configuration in
docker-compose.override.example.yml to explicitly include OPENFGA_PRESHARED_KEY
in the merged environment, ensuring the variable is available when the startup
script expands it under set -u.

Comment thread README.md
Comment thread scripts/setup-stack.sh
Comment thread scripts/smoke.sh Outdated
…meouts, doc fixes

The exact-equality tuple count in smoke.sh assertion 2 broke the documented
grant-superuser.sh workflow (12 tuples after granting platform superuser vs.
the hardcoded 11). Filter out administrative (superuser) tuples before
counting and paginate the OpenFGA read, mirroring martyrology-api's
Authz.read_tuples shape instead of an unbounded single-page read.

Also: bound every curl in setup-stack.sh, grant-superuser.sh, and smoke.sh
with --connect-timeout/--max-time so an unreachable host fails fast instead
of hanging; show how to load ./.env before the README's OpenFGA curl example;
and warn in docker-compose.override.example.yml that Docker silently
auto-creates a missing sibling checkout as an empty bind-mount directory
(confirmed empirically — create_host_path: false does not prevent this
outside Swarm).

Two other findings (authz-seed's OPENFGA_PRESHARED_KEY, and the
martyrology-api:latest image tag on db-init/api-migrate/martyrology-api) were
verified already satisfied by Compose's base+override merge via `docker
compose config` — no change needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/smoke.sh`:
- Around line 97-100: Update the status validation in the login V2 curl check so
CODE is accepted only when it is a valid 2xx or 3xx HTTP status; reject
connection failures such as 000 and server errors such as 500. Preserve the
existing ok/bad messages and the /ui/v2/login/login request.
- Around line 46-60: Update the pagination loop around TOKEN and PAGES_OK so
reaching the 50-iteration limit with a non-empty continuation token marks
PAGES_OK as failed. Preserve the existing success path when TOKEN becomes empty
and ensure the final COUNT check cannot pass if pagination stops before the end.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7cbbf2bc-8a24-4881-aeee-7ffc92fbaac0

📥 Commits

Reviewing files that changed from the base of the PR and between 1b828f1 and fe56256.

📒 Files selected for processing (5)
  • README.md
  • docker-compose.override.example.yml
  • scripts/grant-superuser.sh
  • scripts/setup-stack.sh
  • scripts/smoke.sh
🚧 Files skipped from review as they are similar to previous changes (4)
  • scripts/grant-superuser.sh
  • docker-compose.override.example.yml
  • README.md
  • scripts/setup-stack.sh

Comment thread scripts/smoke.sh
Comment thread scripts/smoke.sh
Login V2 check accepted any non-404 status, including curl's "000" on a
connection failure and any 5xx. Restrict to 2xx/3xx plus the expected
live 400 ("no authRequest"), which the previous round's 404 exclusion
was never meant to admit alongside real failures.

The OpenFGA pagination loop could exhaust its 50-iteration cap with
TOKEN still non-empty — meaning more pages existed — and still report
PAGES_OK=1, letting a partial COUNT pass. Mark PAGES_OK failed whenever
the loop ends with a non-empty TOKEN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JohnRDOrazio
JohnRDOrazio merged commit 59f2764 into main Aug 4, 2026
3 checks passed
@JohnRDOrazio
JohnRDOrazio deleted the feat/local-dev-stack branch August 4, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant