Files
plainpages/compose.yml
lilleman f189f88942 §7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).
2026-06-19 14:48:27 +02:00

173 lines
6.5 KiB
YAML

# Base / production config. Run alone with: docker compose -f compose.yml up
# Plain `docker compose up` also merges compose.override.yml for development.
services:
web:
build: .
ports:
- "3000:3000"
# Explicit behaviour toggles (the app is environment-agnostic — see AGENTS.md).
# Supply COOKIE_SECRET / CSRF_SECRET via env; REQUIRE_SECURE_SECRETS refuses dev throwaways.
environment:
CACHE_TEMPLATES: "true"
REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
depends_on:
bootstrap:
condition: service_completed_successfully
kratos:
condition: service_healthy
keto:
condition: service_healthy
hydra:
condition: service_healthy
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer.
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
# init/init.sql creates one database per service. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production.
postgres:
image: postgres:18.4-alpine3.23
environment:
POSTGRES_USER: ${POSTGRES_USER:-ory}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
POSTGRES_DB: ory
volumes:
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
- pgdata:/var/lib/postgresql # PG18+: mount the parent, not /data (version-subdir layout)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ory} -d ory"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
# Ory Kratos — identity & self-service auth. Config + schema in ory/kratos/; DSN is
# its own `kratos` DB (init.sql), POSTGRES_* via env in prod.
kratos-migrate:
image: oryd/kratos:v26.2.0
depends_on:
postgres:
condition: service_healthy
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/kratos?sslmode=disable
volumes:
- ./ory/kratos:/etc/config/kratos:ro
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
restart: on-failure
kratos:
image: oryd/kratos:v26.2.0
depends_on:
kratos-migrate:
condition: service_completed_successfully
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/kratos?sslmode=disable
volumes:
- ./ory/kratos:/etc/config/kratos:ro
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
# Ory Keto — authorization (ReBAC); OPL model in ory/keto/namespaces.keto.ts. DSN is
# its own `keto` DB (init.sql). web calls its read/write APIs (config.ts).
keto-migrate:
image: oryd/keto:v26.2.0
depends_on:
postgres:
condition: service_healthy
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable
volumes:
- ./ory/keto:/etc/config/keto:ro
command: -c /etc/config/keto/keto.yml migrate up -y
restart: on-failure
keto:
image: oryd/keto:v26.2.0
depends_on:
keto-migrate:
condition: service_completed_successfully
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/keto?sslmode=disable
volumes:
- ./ory/keto:/etc/config/keto:ro
command: serve -c /etc/config/keto/keto.yml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
# One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
# cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
# read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap:
build: .
depends_on:
kratos:
condition: service_healthy
keto:
condition: service_healthy
environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Roles granted to the demo admin: `admin` + the reference plugin's tokens (so it works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin,scheduling:read,scheduling:write}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434
volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/bootstrap.ts
# Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
# error must give up, not loop forever and hang `web` (gates on completion).
restart: "on-failure:5"
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
hydra-migrate:
image: oryd/hydra:v26.2.0
depends_on:
postgres:
condition: service_healthy
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable
volumes:
- ./ory/hydra:/etc/config/hydra:ro
command: -c /etc/config/hydra/hydra.yml migrate sql -e --yes
restart: on-failure
hydra:
image: oryd/hydra:v26.2.0
depends_on:
hydra-migrate:
condition: service_completed_successfully
environment:
DSN: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/hydra?sslmode=disable
volumes:
- ./ory/hydra:/etc/config/hydra:ro
command: serve all -c /etc/config/hydra/hydra.yml
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4444/health/ready"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
volumes:
pgdata: