Local development stack: full containerized stack - #16
Conversation
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>
|
Warning Review limit reached
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 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. 📝 WalkthroughWalkthroughThe 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. ChangesLocal stack
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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (11)
.dockerignore.env.example.gitignoreDockerfileREADME.mddocker-compose.override.example.ymldocker-compose.ymldocker/nginx/zitadel.local.confscripts/grant-superuser.shscripts/setup-stack.shscripts/smoke.sh
| 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 |
There was a problem hiding this comment.
🗄️ 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"
doneRepository: 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
fiRepository: 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) }")
PYRepository: 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")
PYRepository: 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)
PYRepository: 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.ymlRepository: 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.
| 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 | ||
|
|
There was a problem hiding this comment.
🎯 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
}
doneRepository: 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:
- 1: https://github.com/compose-spec/compose-spec/blob/e8db8022c0b2e3d5eb007d629ff684cbe49a17a4/spec.md
- 2: [BUG] bind.create_host_path: false is ignored and missing host path is still auto-created docker/compose#13602
- 3: https://docs.docker.com/engine/storage/bind-mounts/
- 4: introduce create_host_path compose-spec/compose-spec#163
- 5: [BUG] Volumes: Creates directory instead of file (type: bind argument doesnt work?) docker/compose#9955
- 6: [BUG] Possible regression creating host paths by default for bind mounts docker/compose#12797
- 7: fix: check Docker Engine version for create_host_path: false support docker/compose#13605
- 8: Validate bind source for create_host_path false docker/compose#13889
🌐 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:
- 1: Error: invalid mount config for type "bind": bind source path does not exist docker/docs#4709
- 2: https://devopsaitoolkit.com/blog/docker-error-invalid-mount-config-for-type-bind/
- 3: docker/docs@e4b70a5
- 4: Mounting a nonexistent host path, even as readonly, creates a directory on the host moby/moby#13121
- 5: Permission denied mounting a volume via docker run in a NON existing folder in the host docker/docs#19934
- 6: https://stackoverflow.com/questions/71218694/docker-bind-mount-error-while-creating-mount-source-path-permission-denied
- 7: https://docs.docker.com/engine/storage/bind-mounts/
- 8: Using bind mounts with nonexistent directory leads to error docker/docs#10088
- 9: https://errornotes.dev/en/errors/docker/fix-bind-source-path-does-not-exist-error-when-mounting-volumes-in-docker
- 10: https://github.com/docker/docs/blob/main/content/manuals/engine/storage/bind-mounts.md
🌐 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:
- 1: https://stackoverflow.com/questions/67968300/docker-compose-bind-source-path-does-not-exist
- 2: [BUG] bind.create_host_path: false is ignored and missing host path is still auto-created docker/compose#13602
- 3: https://github.com/compose-spec/compose-spec/blob/e8db8022c0b2e3d5eb007d629ff684cbe49a17a4/spec.md
- 4: [BUG] Volumes: Creates directory instead of file (type: bind argument doesnt work?) docker/compose#9955
- 5: Using long syntax for bind mount does create inexistent folders docker/compose#9057
- 6: [BUG] Bind mounts for which source path does not exists fails on 2.35.0 docker/compose#12735
- 7: https://github.com/docker/docs/blob/main/content/manuals/engine/storage/bind-mounts.md
- 8: https://docs.docker.com/engine/storage/bind-mounts/
🌐 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:
- 1: https://docs.docker.com/get-started/docker-concepts/running-containers/sharing-local-files/
- 2: https://docs.docker.com/reference/cli/docker/container/run/
- 3: Mounting a nonexistent host path, even as readonly, creates a directory on the host moby/moby#13121
- 4: https://docs.docker.com/engine/storage/bind-mounts/
- 5: https://docs.docker.com/engine/containers/run/
🌐 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:
- 1: https://github.com/compose-spec/compose-spec/blob/e8db8022c0b2e3d5eb007d629ff684cbe49a17a4/spec.md
- 2: [BUG] Volumes: Creates directory instead of file (type: bind argument doesnt work?) docker/compose#9955
- 3: [BUG] bind.create_host_path: false is ignored and missing host path is still auto-created docker/compose#13602
- 4: https://docs.docker.com/engine/storage/bind-mounts/
- 5: https://forums.docker.com/t/short-vs-long-volume-declaration-syntax-behavior/82730
- 6: https://stackoverflow.com/questions/67968300/docker-compose-bind-source-path-does-not-exist
- 7: [BUG] Bind mounts for which source path does not exists fails on 2.35.0 docker/compose#12735
- 8: [BUG] "bind source path does not exist" on v2.24.0 docker/compose#11345
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.
| 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 |
There was a problem hiding this comment.
🩺 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/nullRepository: 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
doneRepository: 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.
…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>
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
README.mddocker-compose.override.example.ymlscripts/grant-superuser.shscripts/setup-stack.shscripts/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
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>
Implements Tasks 9-13 of
martyrology-api'sdocs/superpowers/plans/2026-08-04-local-development-stack.md.Depends on CatholicOS/martyrology-api#29 landing first —
db-init,api-migrateandmartyrology-apiall build that repo's image, which needs its Dockerfile onmainfor the GitHub-default path to work.What this adds
The full stack: everything the API repo's infra stack has, plus
zitadel-login, an nginxzitadel-proxy, and containers for both applications. It mirrorscdcf-infraproduction 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.ymlrepoints at local siblings and attaches the privatemartyrology-textsrepo.Two findings worth knowing about
Zitadel resolves instances by Host header.
zitadel-loginfailed permanently with "Instance not found" because its backend calls presentedHost: zitadel:8080, which matches no registered domain. Production already solves this withCUSTOM_REQUEST_HEADERS; that is now carried across. The same defect then hit the API's introspection — and sinceAuthenticatorbuilds its request with httpx and has no Host override,MARTYROLOGY_ZITADEL_INTERNAL_URLhad to point at the public origin viaextra_hosts, proven with a real token.extra_hosts: host-gatewaydepends 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 a127.0.0.1-bound socket refuses. Documented in the compose header and.env.examplewith 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 ofmartyrologium_romanum_2004returnsaccess: restricted-textswith all 15 elogiatext: null— attached and redacted through real OpenFGA. That path is reachable only via the override, sincemartyrology-textsis private.Notes for review
/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.martyrology-api's, with reciprocalSIBLING NOTEheaders 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.confis a second copy of production's, diverging in four documented ways. The header enumerates them as drift control.npm run dev.cdcf-infracode registering the OIDC callback.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests