Layman manual
This manual covers day-to-day operation of the PDHC Contract Manager: starting, stopping, backing up, configuring authentication, running the consent reconciler, testing, and recovering from common failures.
The service is all-Docker — the database, API, and web SPA all run as containers under one Docker Compose project. On the host you need:
docker-compose binary. start.sh prefers /opt/homebrew/bin/docker-compose, then docker-compose, then the docker compose v2 plugin.localhost. All three containers bind to 127.0.0.1 only (never 0.0.0.0) — external traffic reaches the service exclusively through the reverse proxy.The .env file lives at ./app/.env and is read by Docker Compose, which passes the values into the api and db containers. A committed .env.example documents the shape; .env itself is gitignored and filled in by the operator on the server.
The list below reflects the actual variables read by config.py, main.py, and docker-compose.yml.
Required in .env:
POSTGRES_PASSWORD — Postgres superuser password. Compose uses ${POSTGRES_PASSWORD:?…} for both the db container and the api container's DATABASE_URL, so the stack refuses to start if it is unset.Required by the app, but supplied automatically by Compose (override only if you know why):
DATABASE_URL — SQLAlchemy URL. config.py treats it as mandatory (getenv_required); Compose constructs it as postgresql+psycopg://<user>:<password>@db:5432/<db>.JWT_SECRET_KEY — signing key for the local JWTs the callback mints. Mandatory in config.py; Compose defaults it to dev-secret-change-me. Change it before any real use.Database naming (optional, defaulted):
POSTGRES_DB — default contracts.POSTGRES_USER — default contracts.Authentication / SSO (see §4):
AUTH_DISABLED — default false. When true, SSO is bypassed and the local /auth/login + bootstrap admin path becomes active. config.py refuses to boot with AUTH_DISABLED=true unless FLASK_ENV=development — this is a dev-only escape hatch, never for the server.FLASK_ENV — default production. Must be development for AUTH_DISABLED=true to be accepted.SSO_BASE_URL — default https://sso.pdhc.se.SSO_CLIENT_ID / SSO_CLIENT_SECRET — this service's SSO client credentials (the _CONTRACT pair on sso.pdhc). Empty by default; token validation returns 401 without them.SSO_CALLBACK_URL — default https://contract.pdhc.se/api/v1/auth/callback.PUBLIC_WEB_URL — default https://contract.pdhc.se. Where the callback redirects the browser after minting a token (/?sso_token=…).Service-to-service integration:
INTERNAL_SERVICE_KEY — shared secret for X-Service-Key on the internal scope endpoint, for PAT auto-provisioning to request.pdhc, and as the fallback key for IPS consent calls. Empty by default; the internal scope endpoint rejects all callers when it is unset.
It must equal request.pdhc's INTERNAL_SERVICE_KEY
PAT auto-provisioning is validated by request.pdhc's own
@requires_service_key gate, and the same key guards three further
internal routes there. The value therefore belongs to request, and
contract must be aligned to it — not the reverse; changing request's
would break its other callers.
When the two drift, POST /internal/auto-provision-pat returns
401 {"error":"unauthorized"} and nothing surfaces it: main.py
logs resp.status_code at info() without checking it, so the
failure appears as an ordinary informational line and the loop moves
on. Only RequestException is warned about, which makes a network
failure louder than an auth failure.
The downstream symptom is confusing rather than obvious — an external
provider is told "Token not found", and the natural assumption is
that the provider is misconfigured. Found live on 2026-10-08 (#802):
both keys set, both 64 characters, different values.
Check without printing either value:
bash
for c in contract-api-1 request_pdhc_app; do
v=$(docker exec "$c" printenv INTERNAL_SERVICE_KEY)
echo "$c len=${#v} sha256=$(printf %s "$v" | shasum -a 256 | cut -c1-12)"
done
After changing it, bring the container up with docker-compose up -d.
docker restart keeps create-time environment and would appear to
do nothing.
REQUEST_BASE_URL — request.pdhc base, default http://localhost:9060. Target for PAT auto-provisioning.IPS_BASE_URL — ips.pdhc base. Empty by default, which makes consent emission a no-op (local dev / standalone install).IPS_API_KEY — key for IPS consent calls; optional. When absent, the emitter falls back to INTERNAL_SERVICE_KEY.PLAN_BASE_URL — plan.pdhc base, default https://plan.pdhc.se. Used to verify scope concepts exist.Validation strictness (both default true):
STRICT_SIGNER_VALIDATION — when true, a signer[] pointing at a non-existent Patient/User/Practitioner rejects the write with 400. Set false in local dev to write without IPS/SSO running.STRICT_SCOPE_CONCEPTS — when true, every concept GUID in term[] must exist in plan.pdhc; a plan.pdhc outage refuses the write with 503. Set false in local dev.Miscellaneous:
READ_RATE_LIMIT — public read rate limit, default 100 per hour.CORS_ORIGINS — allowed CORS origins, default * (restrict to https://contract.pdhc.se in production).DB_WAIT_TIMEOUT_S — seconds the API waits for Postgres readiness on boot, default 30.LIMITER_STORAGE_URI — flask-limiter backend, default memory://.APP_VERSION — reported in /health, default dev.Note:
BOOTSTRAP_ADMIN_USERNAME/BOOTSTRAP_ADMIN_PASSWORDexist indocker-compose.yml, but the bootstrap admin is only seeded whenAUTH_DISABLED=true(i.e. dev only). See §4.3.
From the repository root:
./start.sh
start.sh is deliberately conservative — it does no kill -9 on ports (the header says so explicitly; docker compose down handles teardown). In order, it:
docker-compose binary (prefers /opt/homebrew/bin/docker-compose).docker info); on failure it points you at restart_all.sh and exits.docker compose down to stop the previous containers.db service, waits, and if pg_isready succeeds runs pg_dumpall -U contracts | gzip into ./db_backups/contracts_<UTC-timestamp>.sql.gz, keeping the 10 most recent dumps.docker compose up -d --build — detached, rebuilding images. This is what picks up code changes (COPY . . bakes source into the image; a restart without --build would run stale code).http://localhost:9021/health up to 30 times (2 s apart) and prints the service URLs.The three containers exposed:
db — PostgreSQL 16 on 127.0.0.1:9020api — Flask API on 127.0.0.1:9021web — nginx SPA + docs on 127.0.0.1:9022Because start.sh runs the stack detached, Ctrl+C does nothing — there is no foreground process to interrupt. Stop the service with Compose:
cd app && docker compose downcd app && docker compose logs -fcd app && docker compose down -v
docker compose down -vdeletes theapp_contracts_pgdatavolume and all contract data. Take a backup first (§3), and never run it on the server without explicit authorization (CLAUDE.md §14).
start.sh already snapshots the DB on every start into ./db_backups/. For an on-demand dump/restore:
With the stack running:
docker exec -t $(docker compose -f app/docker-compose.yml ps -q db) \
pg_dump -U contracts -d contracts > backup_$(date +%Y%m%d_%H%M%S).sql
cat backup_YYYYMMDD_HHMMSS.sql | docker exec -i \
$(docker compose -f app/docker-compose.yml ps -q db) \
psql -U contracts -d contracts
For a clean restore, run docker compose down -v first to drop the existing volume, then start the stack and pipe in the backup.
Production always runs with AUTH_DISABLED=false. Users authenticate through sso.pdhc; the Contract Manager never holds their password.
The flow:
GET /api/v1/auth/login. The service stores a random CSRF state in the session and redirects to the SSO login page (SSO_BASE_URL/login?next=<callback>&state=<state>).GET /api/v1/auth/callback?token=…&state=….state (CSRF), then validates the token by calling SSO's /api/auth/me/service with the X-SSO-Client-Id / X-SSO-Client-Secret headers. This returns the access blob.must_change_password, the user is bounced to SSO_BASE_URL/change-password and no local token is minted. After clearing it at SSO, a second login lands here with the flag off.PUBLIC_WEB_URL/?sso_token=<jwt>. The SPA reads the token from the query string.GET /api/v1/auth/me returns the current user's claims; GET /api/v1/auth/logout clears the session.
What a user sees. There is no login page. Opening a protected page redirects
straight to sso.pdhc and returns the user signed in, at the page they asked for
— the same behaviour as every other PDHC frontend. The public pages (/,
/contracts, /docs, /api) stay readable without signing in.
A sign-in card appears only when the handshake fails — it carries the
sso_error text and a retry button. If a user reports seeing it, the message on
it is the diagnosis; read that before checking anything else.
Every page carries the platform ribbon PDHC / Contracts in the top left;
PDHC returns to https://www.pdhc.se/services.html.
AUTH_DISABLED=true (dev only)
GET /api/v1/auth/login redirects to the SPA's error surface saying auth is
disabled, rather than returning JSON. The SPA navigates to that URL by
itself now, and a JSON body there would be displayed raw in the browser
with no way back.
Roles come from the SSO access blob, not from a local user table:
is_su_admin == true → adminuser_type == "professional" → adminadmin can create/update/delete contracts and manage users; reader is read-only (functionally the same as anonymous, but identified). Write endpoints require the admin role.
The local POST /auth/login (username/password against the users table, bcrypt) and the BOOTSTRAP_ADMIN_* seeding are only active when AUTH_DISABLED=true, which config.py refuses to boot outside FLASK_ENV=development. With SSO enabled, POST /auth/login returns 400 with a pointer to /api/v1/auth/login.
So on a normal (production-shaped) install:
/admin/users endpoints exist but are only reachable with an admin JWT, which in practice means SSO-minted.flask reconcile-consents)When a contract in a lifecycle status is written, the service emits or revokes matching PatientConsent rows on ips.pdhc (see the architecture doc §3 and §6). That emission is best-effort: if IPS is briefly unreachable, the write still succeeds and the consent silently drops.
The reconciler is the recovery path. It lives on this service as a Flask CLI command:
docker exec $(docker compose -f app/docker-compose.yml ps -q api) \
flask reconcile-consents
It walks every ContractRecord in a grant or revoke status, re-calls the (idempotent) emitter/revoker, and prints a one-line summary:
reconcile-consents checked=<n> grants_re_emitted=<n> revokes_re_called=<n> \
grant_attempts=<n> revoke_attempts=<n> errors=<n>
On the macmini this runs hourly via cron (#246). A clean DB reports grants_re_emitted=0 revokes_re_called=0; a second run within the same window does ~zero work.
The backend suite runs in-process against an in-memory SQLite database — no Docker required — provided a local Python venv is set up under app/backend/:
cd app/backend
python -m pytest tests/ -v
If a test-results convention is used, outputs follow ./results/<ISO-8601-UTC>_results/.
The unit suite cannot tell you whether this service can still talk to the four
it depends on, and in this repo it demonstrably did not: all 24 of the signer
resolution tests passed for months while the code called a route that does not
exist with a header ips does not read. Every one of them patched
requests.get and asserted on the status code it had itself chosen, so the URL
and the headers were inputs the mock discarded.
./deploy/smoke_siblings.sh --remote --patient-guid <a-really-assigned-patient>
Read-only. It changes nothing, on any service, and says so at the end.
Everything that writes is behind --for-real, which additionally needs
--provider-org-guid and --contract-guid and names the PAT it minted.
It runs inside contract-api-1, because that is the only place the real
configuration resolves — IPS_BASE_URL is host.docker.internal:9040, which
means nothing from a laptop — and it builds each request with this service's
own client helpers, so a bug in a helper is a bug in the smoke's request
too. That is deliberate: #800 was a bug inside _ips_headers, and a smoke that
composed its own headers would have passed against it.
How to read the output:
| result | meaning |
|---|---|
| 404 on an absent-guid probe | PASS — the credential was accepted, the row simply is not there |
| 401 / 403 | our credential or header name is wrong; the sibling is up and refused us |
| 5xx | the sibling errored; check its logs, and whether our request is malformed |
| UNREACHABLE | network or base URL, not credentials |
--patient-guid matters more than it looks. Without it every ips check is
satisfied by a 404 — including the 404 that a wrong route produces for every
guid. Only a patient that really exists distinguishes "the route is wrong"
from "that guid is absent".
POSTGRES_PASSWORD must be setCompose uses ${POSTGRES_PASSWORD:?…}. Set POSTGRES_PASSWORD in app/.env (§1.2).
AUTH_DISABLED=true requires FLASK_ENV=developmentA stale .env shipped AUTH_DISABLED=true to a production-shaped install. Either remove AUTH_DISABLED (defaults to false → SSO) or, for genuine local dev, also set FLASK_ENV=development.
If a container fails to bind:
lsof -i :9020-9022
start.sh does not free ports for you (by design). Stop whatever owns the port, or cd app && docker compose down to clear a previous instance of this stack.
If the API exits with "Database not ready after waiting":
DB_WAIT_TIMEOUT_S (e.g. 60).docker compose -f app/docker-compose.yml logs db.password authentication failed)If the db container reports (healthy) but the api container fails auth, the Postgres password hash predates the current .env (the postgres password is only applied on first volume init; the trust rule masks it for local clients). Reset it via the trust side door:
docker exec $(docker compose -f app/docker-compose.yml ps -q db) \
psql -h 127.0.0.1 -U contracts -d contracts \
-c "ALTER USER contracts WITH PASSWORD '<POSTGRES_PASSWORD from .env>';"
docker restart $(docker compose -f app/docker-compose.yml ps -q api)
See CLAUDE.md §9 for the full write-up.
422 scope_concept_missing — a concept GUID in term[] doesn't exist in plan.pdhc. Fix the reference or create the concept.503 scope_validation_unavailable — plan.pdhc is unreachable and STRICT_SCOPE_CONCEPTS=true. Restore plan.pdhc, or set the flag false in local dev.400 signer_unresolved — a signer[] reference doesn't resolve and STRICT_SIGNER_VALIDATION=true (which is the default, and is unset in production, so it is on).
Before blaming the reference, check that the probe can authenticate.
Between the introduction of this check and 2026-10-08 the message was
almost always wrong: the resolver called an ips route that does not exist
with a header ips does not read, so every Patient/<guid> signer failed,
including ones that were perfectly valid (#800). The error says "not found
in IPS", which reads as a statement about the patient.
The distinction that separates the two causes:
| probing a deliberately absent guid returns | means |
|---|---|
| 404 | the credential was accepted — the reference really is missing, so fix the reference |
| 401 / 403 | our credential or header name is wrong — this is not the patient's fault |
./deploy/smoke_siblings.sh --remote --patient-guid <a-real-guid> makes
that call for you and says which it is. Relaxing
STRICT_SIGNER_VALIDATION is a local-dev convenience, not a fix: in
production it would accept signers that point at nothing.