Przeglądaj źródła

Updated documentation

Efren Yevale Varela 4 dni temu
rodzic
commit
74cb5b38fa

+ 4 - 2
.github/workflows/docs.yml

@@ -1,7 +1,7 @@
 # Documentation quality gate (task 48).
 # Documentation quality gate (task 48).
 #
 #
-# Runs the local link checker over README.md, AGENTS.md, and docs/*.md so a
-# stale file or heading reference fails CI. This is the "markdown-link-check
+# Runs the local link checker over README.md, AGENTS.md, SECURITY.md, and
+# docs/*.md so a stale file or heading reference fails CI. This is the "markdown-link-check
 # equivalent" named by the task; it is deliberately offline (only relative
 # equivalent" named by the task; it is deliberately offline (only relative
 # links are validated) so the job needs no network beyond checkout/setup.
 # links are validated) so the job needs no network beyond checkout/setup.
 #
 #
@@ -15,6 +15,7 @@ on:
     paths:
     paths:
       - "README.md"
       - "README.md"
       - "AGENTS.md"
       - "AGENTS.md"
+      - "SECURITY.md"
       - "docs/**"
       - "docs/**"
       - "scripts/check_doc_links.py"
       - "scripts/check_doc_links.py"
       - ".github/workflows/docs.yml"
       - ".github/workflows/docs.yml"
@@ -22,6 +23,7 @@ on:
     paths:
     paths:
       - "README.md"
       - "README.md"
       - "AGENTS.md"
       - "AGENTS.md"
+      - "SECURITY.md"
       - "docs/**"
       - "docs/**"
       - "scripts/check_doc_links.py"
       - "scripts/check_doc_links.py"
       - ".github/workflows/docs.yml"
       - ".github/workflows/docs.yml"

+ 5 - 1
AGENTS.md

@@ -164,7 +164,11 @@ updates the relevant guide in the same commit:
 - Schema and migrations, disabled-user re-check: `docs/DATABASE.md`.
 - Schema and migrations, disabled-user re-check: `docs/DATABASE.md`.
 - Redis keys, TTLs, durability, subject purge: `docs/REDIS.md`.
 - Redis keys, TTLs, durability, subject purge: `docs/REDIS.md`.
 - Image, manifests, probes: `docs/DEPLOYMENT.md`.
 - Image, manifests, probes: `docs/DEPLOYMENT.md`.
-- CLI and runbooks: `docs/OPERATIONS.md`, `RUNBOOK.md`.
+- CLI and runbooks: `docs/OPERATIONS.md`, `docs/RUNBOOK.md`.
+- First-run path (development environment, bootstrap order, PKCE walkthrough):
+  `docs/QUICKSTART.md`.
+- A new, renamed, or removed guide, or a changed audience: the `docs/README.md`
+  index and the table in the root `README.md`.
 - This file, when the module map or rules change.
 - This file, when the module map or rules change.
 
 
 A change to the deployment surface (a new listener, mount, dependency class, or
 A change to the deployment surface (a new listener, mount, dependency class, or

+ 63 - 289
README.md

@@ -8,331 +8,105 @@ includes the host `localhost` only when `allow_localhost_redirect` is set).
 
 
 It implements the authorization-code, refresh-token, client-credentials,
 It implements the authorization-code, refresh-token, client-credentials,
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
-client registration, introspection, and revocation. Tokens are opaque and
-hashed at rest by default; a global enable switch plus a per-client `jwt`
-access-token format mints RFC 9068 `at+jwt` tokens verified against the AS
-JWKS. All
-durable domain state is in PostgreSQL and all ephemeral state is in Redis, so
-replicas are interchangeable.
+client registration, client ID metadata documents, introspection, and
+revocation. Tokens are opaque and hashed at rest by default; a global enable
+switch plus a per-client `jwt` access-token format mints RFC 9068 `at+jwt`
+tokens verified against the AS JWKS. All durable domain state is in PostgreSQL
+and all ephemeral state is in Redis, so replicas are interchangeable.
+Administration is CLI plus declarative configuration: there is no HTTP admin
+API and no default credential.
 
 
-- Protocol and deltas: [docs/OAUTH.md](docs/OAUTH.md)
-- Threat model and token design: [docs/SECURITY.md](docs/SECURITY.md)
-- Resource-server contract (RFC 9728): [docs/RS-CONTRACT.md](docs/RS-CONTRACT.md)
-- Vulnerability reporting policy: [SECURITY.md](SECURITY.md)
-- Module layout and request flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
-- Every configuration key: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
-- Schema and migrations: [docs/DATABASE.md](docs/DATABASE.md)
-- Redis keys and durability: [docs/REDIS.md](docs/REDIS.md)
-- Container and cluster: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
-- Admin CLI and runbooks: [docs/OPERATIONS.md](docs/OPERATIONS.md)
-- Resource-server onboarding runbook (RFC 9728): [docs/OPERATIONS.md](docs/OPERATIONS.md#resource-server-onboarding-rfc-9728)
-- Contributor guide: [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md)
-- Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md)
-- Admin bootstrap procedure: [RUNBOOK.md](RUNBOOK.md)
+## Run it
 
 
-## Prerequisites
-
-- Rust stable, as pinned by `rust-toolchain.toml`.
-- PostgreSQL 16 or newer with the `citext` extension available (the shipped
-  `compose.yaml` pins `postgres:18-alpine`).
-- Redis 8 or newer, with persistence enabled and eviction off.
-- A TLS certificate and key for the public listener.
-- Docker, for the container image, for the shared test Postgres and Redis in
-  `compose.test.yaml`, and for the handful of suites that still use
-  `testcontainers` because they need Redis nodes of their own (the keep-list is
-  under [Tests](#tests)).
-- `cargo-nextest` for the test suite:
-  `cargo install cargo-nextest --locked`.
-
-## Build
+A self-contained local stack, from the repository root. `compose.yaml`
+generates a throwaway TLS certificate and dev-only key files, applies
+migrations exactly once, and starts one replica against its own Postgres and
+Redis:
 
 
 ```bash
 ```bash
-cargo build --locked
-cargo build --locked --release
+docker compose -f compose.yaml up --build
+docker compose -f compose.yaml port app 8080   # host port of the TLS listener
 ```
 ```
 
 
-## Quick start
-
-The server reads configuration from `D_OAUTH_*` environment variables. Export a
-development configuration (values here are placeholders; see
-[docs/CONFIGURATION.md](docs/CONFIGURATION.md) for every key). The ports below
-are the local-dev defaults (Postgres `5432`, Redis `6379`); the shared test
-services use `5433`/`6380` (see Tests).
+The listener uses that self-signed certificate, so probe it with `-k`. The
+discovery document advertises the configured issuer (`https://localhost:8080`),
+independent of the random host port:
 
 
 ```bash
 ```bash
-export D_OAUTH_ISSUER=https://localhost:8080
-export D_OAUTH_ENVIRONMENT=development
-export D_OAUTH_BIND_ADDR=0.0.0.0:8080
-export D_OAUTH_HEALTH_ADDR=0.0.0.0:8081
-export D_OAUTH_TLS__CERT_PATH=/path/to/tls.crt
-export D_OAUTH_TLS__KEY_PATH=/path/to/tls.key
-export D_OAUTH_DATABASE__URL='postgres://oauth:password@127.0.0.1:5432/oauth?sslmode=disable'
-export D_OAUTH_REDIS__URL=redis://127.0.0.1:6379
-export D_OAUTH_REDIS__DURABILITY=waitaof
-export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random-00'
-export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
-export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
+curl -sk https://127.0.0.1:<host-port>/.well-known/oauth-authorization-server | jq .
 ```
 ```
 
 
-Development relaxes the database TLS requirement. Production requires
-`sslmode=verify-full` (unless `database.require_verify_full` is set false) and an
-`issuer` on `https` (no trailing slash, non-empty authority, no whitespace).
-
-Validate the configuration, then apply the schema once:
+From a checkout, against a Postgres and a Redis of your own, with the
+development environment exported as in
+[QUICKSTART.md](docs/QUICKSTART.md#development-configuration):
 
 
 ```bash
 ```bash
+cargo build --locked
 cargo run --bin d-oauth-server -- config check
 cargo run --bin d-oauth-server -- config check
 cargo run --bin d-oauth-server -- migrate
 cargo run --bin d-oauth-server -- migrate
-```
-
-Bootstrap the first administrator. It is created in a forced-change state and
-must change its password and enroll TOTP before it gains authority:
-
-```bash
 cargo run --bin d-oauth-server -- admin bootstrap --email admin@example.com
 cargo run --bin d-oauth-server -- admin bootstrap --email admin@example.com
-```
-
-Apply the scope catalog, redirect URIs, and entitlements from a JSON file
-idempotently. The file format is in [docs/OPERATIONS.md](docs/OPERATIONS.md):
-
-```bash
-cargo run --bin d-oauth-server -- config sync --file ./oauth-config.json
-```
-
-Run the server. It serves TLS on `bind_addr` and plaintext health on
-`health_addr`; it never migrates:
-
-```bash
 cargo run --bin d-oauth-server -- serve
 cargo run --bin d-oauth-server -- serve
 ```
 ```
 
 
-Check it is alive and ready:
-
 ```bash
 ```bash
 curl -s http://127.0.0.1:8081/health/live
 curl -s http://127.0.0.1:8081/health/live
 curl -s http://127.0.0.1:8081/health/ready
 curl -s http://127.0.0.1:8081/health/ready
-curl -sk https://localhost:8080/.well-known/oauth-authorization-server | jq .
-```
-
-## Authorization-code and PKCE walkthrough
-
-The flow is: start at `/authorize`, log in and consent, receive a code on the
-redirect, then redeem it at `/token` with the PKCE verifier.
-
-1. Generate a verifier and its `S256` challenge:
-
-```bash
-VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64)
-CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
-```
-
-2. Visit the authorization endpoint in a browser. A confidential client must
-   use PAR or a signed request object; a public client may use the query string:
-
-```
-https://localhost:8080/authorize?response_type=code&client_id=example-client&redirect_uri=https://app.example.com/callback&scope=orders:read&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256
-```
-
-3. Log in, approve consent, and read the `code` from the redirect.
-
-4. Redeem the code at the token endpoint:
-
-```bash
-curl -sk https://localhost:8080/token \
-  -d grant_type=authorization_code \
-  -d code="$CODE" \
-  -d redirect_uri=https://app.example.com/callback \
-  -d client_id=example-client \
-  -d code_verifier="$VERIFIER"
-```
-
-5. A DPoP-bound public client (`dpop_bound_access_tokens: true`) must present
-   a DPoP proof with the token request; an unbound public client (the
-   default) mints `Bearer` tokens with no proof. A confidential client
-   authenticates with its secret or an mTLS certificate.
-
-Rollout note: unbound public clients become bearer-capable on upgrade.
-Operators wanting DPoP-mandatory public clients register them with
-`dpop_bound_access_tokens: true`.
-
-The `POST /token` dispatcher and the `/register*` DCR routes (RFC 7591/RFC
-7592) are wired, so step 4 redeems a code end to end; a client can also be
-created through `POST /register`. See [docs/OAUTH.md](docs/OAUTH.md) for the
-full grant behavior.
-
-## Tests
-
-The authoritative suite. The integration and end-to-end tests connect to the
-shared test Postgres and Redis from `compose.test.yaml` (test defaults:
-Postgres on `127.0.0.1:5433`, Redis on `127.0.0.1:6380` — not the dev
-`5432`/`6379`), so start the services first and
-tear them down with `down -v`:
-
-```bash
-docker compose -f compose.test.yaml up -d
-cargo nextest run --all-targets
-cargo test --doc
-docker compose -f compose.test.yaml down -v
-```
-
-Override the endpoints with `D_OAUTH_TEST_POSTGRES_URL` and
-`D_OAUTH_TEST_REDIS_URL`. Each test gets its own freshly created database and
-unique Redis key prefixes, so tests run in parallel and nothing flushes shared
-state. Killing the suite mid-run no longer orphans containers: the lifecycle
-tests still `rm` theirs on drop.
-
-Only these suites use `testcontainers`; every other unit and integration suite
-uses the shared `compose.test.yaml` services:
-
-- `src/test_guard.rs` and `src/state/test_support.rs`: the shared container
-  helpers the keep-list below is built on.
-- `tests/resilience.rs`: stop and restart lifecycle.
-- `src/state/tests_topology.rs`: Sentinel, Cluster, and `requirepass` topology.
-- The `--appendfsync always` refresh-token suite:
-  `src/protocol/token/grants/refresh_token/tests.rs` and
-  `src/protocol/token/grants/refresh_token/tests/failover.rs`.
-- The two Redis durability tests in `src/store/tests_store.rs`
-  (`store_persistence_survives_restart` and `store_durability_barrier_modes`).
-- `dcr_manage_token_survives_redis_flush` in
-  `src/protocol/dcr/manage/tests_delete.rs`, which starts a private Redis for
-  the flush.
-
-`tests/topology.rs` is separate: it spawns in-process `redis-test`
-Sentinel/Cluster fixtures that need a real `redis-server` binary. Docker must
-be running. To iterate on one area, filter by name:
-
-```bash
-cargo nextest run -E 'test(refresh_rotation)'
-cargo nextest run -E 'test(admin_bootstrap)'
-```
-
-Quality gates:
-
-```bash
-cargo fmt --check
-cargo clippy --all-targets -- -D warnings
-cargo nextest run --all-targets
-cargo test --doc
-cargo audit
-cargo deny check
-python3 scripts/check_doc_links.py
 ```
 ```
 
 
-Coverage is a separate blocking gate. It runs every target under
-`cargo llvm-cov` against the shared services and checks the global (89.0),
-security (91.0), and per-module floors in `scripts/coverage_floors.json`:
+That is the short version. The full first-run path, including creating a
+client and minting an access token with PKCE, is
+[QUICKSTART.md](docs/QUICKSTART.md).
 
 
-```bash
-cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
-python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
-```
-
-CI also builds the 14 fuzz targets, which none of the gates above compile: the
-original seven plus `jwt_access_verify`, `cimd_document`, `mtls_cert`,
-`params_form`, `ssrf_url`, `totp_input`, and `pkce`. Run it when a change adds
-or reshapes a type or function a target constructs:
-
-```bash
-cd fuzz && cargo fuzz build --target x86_64-unknown-linux-gnu
-```
-
-## Container
-
-Build both architectures with the bake definition, then run the image. The
-image is non-root, has no shell, and expects a read-only root filesystem:
-
-```bash
-# Both architectures (linux/amd64 + linux/arm64)
-docker buildx bake image
-
-# Single architecture (no arm64 QEMU emulation needed)
-docker buildx bake image --set image.platform=linux/amd64
+## Documentation
 
 
-docker run --rm -p 8080:8080 --read-only \
-  --env-file ./oauth.env \
-  -v /path/to/tls:/certs:ro \
-  d-oauth-server:latest
-```
+Comprehensive guides live in [`docs/`](docs/README.md), whose index maps every
+guide by audience and reading order.
 
 
-Building `linux/arm64` on an amd64 host needs QEMU binfmt
-(`docker run --privileged --rm tonistiigi/binfmt --install arm64`); see
-[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
+| I want to... | Start with |
+| --- | --- |
+| Run it, end to end, with a token | [QUICKSTART.md](docs/QUICKSTART.md) |
+| Look up a configuration key | [CONFIGURATION.md](docs/CONFIGURATION.md) |
+| Administer a live install | [OPERATIONS.md](docs/OPERATIONS.md), [RUNBOOK.md](docs/RUNBOOK.md) |
+| Deploy it to a cluster | [DEPLOYMENT.md](docs/DEPLOYMENT.md) |
+| Integrate an OAuth client | [OAUTH.md](docs/OAUTH.md) |
+| Protect an API (resource server) | [RS-CONTRACT.md](docs/RS-CONTRACT.md) |
+| Understand or change the code | [ARCHITECTURE.md](docs/ARCHITECTURE.md), [CONTRIBUTING.md](docs/CONTRIBUTING.md) |
+| Review the security properties | [SECURITY.md](docs/SECURITY.md) |
+| Inspect the schema or the Redis keys | [DATABASE.md](docs/DATABASE.md), [REDIS.md](docs/REDIS.md) |
+| See what changed in a release | [CHANGELOG.md](docs/CHANGELOG.md) |
 
 
-The container `HEALTHCHECK` uses the `healthcheck` subcommand, which probes the
-plaintext health listener without a shell or curl. See
-[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
+To report a vulnerability, use the [reporting policy](SECURITY.md), not a guide.
 
 
-For a local multi-replica proof, `compose.yaml` runs three app replicas
-against one Postgres and one Redis. It is the local
-analogue of the cluster manifests, and proves that a token issued by one replica
-is introspectable via another; see
-[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
+## Before production
 
 
-```bash
-docker compose -f compose.yaml up --scale app=3
-```
-
-Cluster deployment uses a separate migration Job, a PodDisruptionBudget, and
-probes on the health listener. In-process mTLS requires L4/TCP passthrough at
-the load balancer, because an L7 ingress that terminates TLS strips the client
-certificate.
-
-## Security caveats
-
-- OAuth 2.1 only. Do not point an OAuth 2.0 client that needs implicit or
-  password grant at this server.
-- The server targets `draft-ietf-oauth-v2-1-16` (published 3 September 2026;
-  an Internet-Draft, not an RFC):
-  https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1. There are no
-  open gaps against that draft; see [docs/OAUTH.md](docs/OAUTH.md).
 - There is no default credential and no HTTP admin API. Provision through the
 - There is no default credential and no HTTP admin API. Provision through the
-  CLI.
+  CLI or a migration Job ([RUNBOOK.md](docs/RUNBOOK.md)).
 - The token pepper, TOTP encryption key, and PII field-encryption key are
 - The token pepper, TOTP encryption key, and PII field-encryption key are
-  required; losing the pepper invalidates every live opaque token, and losing
-  the PII key makes the sealed directory unreadable.
-- Master keys (pepper, TOTP, and PII) must each be at least 32 bytes; shorter
-  values are refused at load (`WeakKeyMaterial`).
-- `user disable` purges the account's Redis sessions, tokens, grants, codes,
-  device records, and lockout counters before flipping `users.disabled`; that
-  user's live tokens then introspect inactive and refuse refresh
-  (`invalid_grant`).
-- Production requires `issuer` on `https` and, unless
+  required, must each be at least 32 bytes (`WeakKeyMaterial` otherwise), and
+  are live secrets: losing the pepper invalidates every opaque token, and losing
+  the PII key makes the sealed directory unreadable. Mount them as files, not
+  environment values ([DEPLOYMENT.md](docs/DEPLOYMENT.md#secret-material-as-files)).
+- Production requires an `https` issuer and, unless
   `database.require_verify_full` is set false, a database URL with
   `database.require_verify_full` is set false, a database URL with
   `sslmode=verify-full`.
   `sslmode=verify-full`.
-- Redis must run with `noeviction` and persistence. The server asserts this at
-  startup and refuses to start otherwise, unless `redis.noeviction_required` or
-  `redis.aof_required` is set false (both default `true`).
-- When Postgres or Redis is unavailable, security endpoints return `503` rather
-  than failing open.
-- CORS is empty by default. Browser clients need an explicit origin allowlist.
-- Never commit a secret, a token, or key material. See
-  [docs/SECURITY.md](docs/SECURITY.md).
+- Redis must run with `noeviction` and persistence; the server asserts both at
+  startup and refuses to start otherwise
+  ([REDIS.md](docs/REDIS.md#the-durability-barrier)).
+- Security endpoints fail closed: when Postgres or Redis is unavailable they
+  return `503` rather than failing open. CORS is empty by default, so browser
+  clients need an explicit origin allowlist.
 
 
-## CLI pointers
+The threat model and the full invariant list are in
+[SECURITY.md](docs/SECURITY.md).
 
 
-- Scope catalog: `scope create|list|enable|disable|delete`. Scopes are
-  runtime-managed and start empty.
-- Redirect URIs: `redirect-uri create|list|enable|disable|delete` and
-  `client attach-redirect-uri|detach-redirect-uri|redirect-uris`.
-  `redirect-uri create|enable|disable|delete` and
-  `client attach-redirect-uri|detach-redirect-uri` accept
-  `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
-  the return targets for `POST /logout` (`GET /logout` is read-only).
-- Resource indicators: `resource-server create|list|enable|disable|delete`
-  manage the RFC 8707 resource-server catalog (exact identifiers, no wildcard).
-- Access-token format: `client set-access-token-format --client-id <id>
-  --format opaque|jwt` is the operator-only opt-in to JWT access tokens;
-  dynamic registration always stores `opaque`.
-- Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
-- Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
-- Declarative apply: `config sync --file` and `config check`.
-- PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
-  plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
-- DCR initial access:
-  `initial-access-token issue --uses <n> --ttl <seconds> [--label <text>] [--show-secret]`.
-- Utilities: `keygen` and `healthcheck`.
+## Tests
 
 
-Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
+`cargo nextest run --all-targets` is the authoritative suite, backed by the
+shared Postgres and Redis from `compose.test.yaml`; `cargo test --doc` covers
+the doctests. The quality gates, the coverage floors, the fuzzing targets, and
+the exact commands are in [CONTRIBUTING.md](docs/CONTRIBUTING.md#quality-gates),
+with the measured baseline in
+[TEST-COVERAGE.md](docs/TEST-COVERAGE.md).
 
 
 ## License
 ## License
 
 

+ 6 - 1
docs/CONTRIBUTING.md

@@ -8,7 +8,8 @@ are enforced by CI, so a change that skips them will not merge.
 This guide is for anyone editing the Rust source. It covers prerequisites, the
 This guide is for anyone editing the Rust source. It covers prerequisites, the
 quality gates, module conventions, the test-driven workflow, commit style, and
 quality gates, module conventions, the test-driven workflow, commit style, and
 the documentation contract. Operators looking to run the server should start
 the documentation contract. Operators looking to run the server should start
-with the [README](../README.md) and [OPERATIONS.md](OPERATIONS.md).
+with [QUICKSTART.md](QUICKSTART.md) and [OPERATIONS.md](OPERATIONS.md), and
+find any other guide through [the documentation index](README.md).
 
 
 ## Prerequisites
 ## Prerequisites
 
 
@@ -309,6 +310,10 @@ the relevant guide in the same commit:
 - Redis keys, TTLs, or topology: `docs/REDIS.md`.
 - Redis keys, TTLs, or topology: `docs/REDIS.md`.
 - Image, manifests, or probes: `docs/DEPLOYMENT.md`.
 - Image, manifests, or probes: `docs/DEPLOYMENT.md`.
 - A CLI command or runbook step: `docs/OPERATIONS.md`.
 - A CLI command or runbook step: `docs/OPERATIONS.md`.
+- The first-run path (development environment, bootstrap order, PKCE
+  walkthrough): `docs/QUICKSTART.md`.
+- A new, renamed, or removed guide, or a change to an audience: the
+  `docs/README.md` index and the table in the root `README.md`.
 - Anything at all: the module map in [AGENTS.md](../AGENTS.md).
 - Anything at all: the module map in [AGENTS.md](../AGENTS.md).
 
 
 Then run the link checker, which CI also runs:
 Then run the link checker, which CI also runs:

+ 2 - 2
docs/DEPLOYMENT.md

@@ -609,7 +609,7 @@ two nodes can briefly run different builds mid-rollout, and a later re-apply
 can pull a newer image than the one you verified. That is the deliberate
 can pull a newer image than the one you verified. That is the deliberate
 tradeoff for a simple, low-ceremony admin flow; treat the tag as a convenience
 tradeoff for a simple, low-ceremony admin flow; treat the tag as a convenience
 reference, not an immutable pin. Record the rollout in
 reference, not an immutable pin. Record the rollout in
-[RUNBOOK.md](../RUNBOOK.md) (deploy provenance entry: the digest the tag
+[RUNBOOK.md](RUNBOOK.md) (deploy provenance entry: the digest the tag
 resolved to, the `sbom.spdx.json` / `trivy.sarif` run, and the `cosign verify`
 resolved to, the `sbom.spdx.json` / `trivy.sarif` run, and the `cosign verify`
 result). A rollout with no matching record is an unverified deploy: stop and
 result). A rollout with no matching record is an unverified deploy: stop and
 re-verify.
 re-verify.
@@ -711,7 +711,7 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready
 - `docker-bake.hcl` for the multi-arch targets.
 - `docker-bake.hcl` for the multi-arch targets.
 - `.dockerignore` for the build context.
 - `.dockerignore` for the build context.
 - `deny.toml` for the supply-chain policy.
 - `deny.toml` for the supply-chain policy.
-- `RUNBOOK.md` for the admin bootstrap Job.
+- [RUNBOOK.md](RUNBOOK.md) for the admin bootstrap Job.
 - `.github/workflows/ci.yml` for the quality and documentation gates.
 - `.github/workflows/ci.yml` for the quality and documentation gates.
 - `.github/workflows/supply-chain.yml` for the image pipeline: per-arch native
 - `.github/workflows/supply-chain.yml` for the image pipeline: per-arch native
   build (linux/amd64 on `ubuntu-24.04`, linux/arm64 on `ubuntu-24.04-arm`) with
   build (linux/amd64 on `ubuntu-24.04`, linux/arm64 on `ubuntu-24.04-arm`) with

+ 3 - 3
docs/OPERATIONS.md

@@ -192,7 +192,7 @@ neither yields a value — a generated 256-bit password that prints exactly once
 and only to a TTY (a non-TTY stdout with a generated password is an error, so
 and only to a TTY (a non-TTY stdout with a generated password is an error, so
 the secret can never leak into job logs). A `--password-file` must not be
 the secret can never leak into job logs). A `--password-file` must not be
 world-readable (use mode `0600`). The full replacement procedure is in
 world-readable (use mode `0600`). The full replacement procedure is in
-`RUNBOOK.md`.
+[RUNBOOK.md](RUNBOOK.md).
 
 
 ### Initial access tokens (DCR)
 ### Initial access tokens (DCR)
 
 
@@ -752,7 +752,7 @@ survives a Redis loss.
 
 
 - First install: `migrate`, then `admin bootstrap`, then complete the forced
 - First install: `migrate`, then `admin bootstrap`, then complete the forced
   password change and TOTP enrollment, then create a named admin. See
   password change and TOTP enrollment, then create a named admin. See
-  `RUNBOOK.md`.
+  [RUNBOOK.md](RUNBOOK.md).
 - Routine provisioning: `config sync` for scopes, redirect URIs, and
 - Routine provisioning: `config sync` for scopes, redirect URIs, and
   entitlements; `user create` for users.
   entitlements; `user create` for users.
 - Key rotation: overlap the pepper for at least the refresh lifetime. Rotate the
 - Key rotation: overlap the pepper for at least the refresh lifetime. Rotate the
@@ -780,4 +780,4 @@ survives a Redis loss.
   `totp rewrap`.
   `totp rewrap`.
 - `src/crypto/field.rs` for the PII field codec and blind index.
 - `src/crypto/field.rs` for the PII field codec and blind index.
 - `src/middleware/audit.rs` for the HTTP audit events.
 - `src/middleware/audit.rs` for the HTTP audit events.
-- `RUNBOOK.md` for the bootstrap and replacement procedure.
+- [RUNBOOK.md](RUNBOOK.md) for the bootstrap and replacement procedure.

+ 218 - 0
docs/QUICKSTART.md

@@ -0,0 +1,218 @@
+# Quick start
+
+Get a development instance running on `https://localhost:8080`, bootstrap the
+first administrator, and mint a token through the authorization-code flow with
+PKCE. The whole path takes one terminal, one Postgres, one Redis, and one TLS
+certificate pair.
+
+## Purpose
+
+For a first-time operator or integrator: the shortest correct route from a
+checkout to a working authorization server, and the one walkthrough that proves
+the grant machinery end to end. It is deliberately a tutorial, not a reference:
+every key, command, and endpoint is documented in the guides linked from
+[the documentation index](README.md).
+
+## Prerequisites
+
+- Rust stable, as pinned by `rust-toolchain.toml`.
+- PostgreSQL 16 or newer with the `citext` extension available (the shipped
+  `compose.yaml` pins `postgres:18-alpine`).
+- Redis 8 or newer, with persistence enabled and eviction off. The server
+  asserts both at startup and refuses to start otherwise unless
+  `redis.noeviction_required` or `redis.aof_required` is set false.
+- A TLS certificate and key for the public listener. If you have none yet, build
+  a throwaway pair with the private-CA steps in
+  [DEPLOYMENT.md](DEPLOYMENT.md#generate-a-private-ca-and-a-server-certificate).
+- Docker, only if you want the container image or the local multi-replica
+  `compose.yaml` stack. Contributing (the test suite, the shared test services,
+  `cargo-nextest`) is covered in [CONTRIBUTING.md](CONTRIBUTING.md).
+
+## Build
+
+```bash
+cargo build --locked
+cargo build --locked --release
+```
+
+The build is offline-safe for SQL: the `query!` macros compile against the
+committed `.sqlx/` cache. Regenerate that cache when a query or a migration
+changes; [CONTRIBUTING.md](CONTRIBUTING.md#build) has the commands.
+
+## Development configuration
+
+The server reads configuration from `D_OAUTH_*` environment variables plus an
+optional file named by `D_OAUTH_CONFIG`. Export a development configuration
+(values here are placeholders; [CONFIGURATION.md](CONFIGURATION.md) lists every
+key, its default, and its validation rule).
+
+```bash
+export D_OAUTH_ISSUER=https://localhost:8080
+export D_OAUTH_ENVIRONMENT=development
+export D_OAUTH_BIND_ADDR=0.0.0.0:8080
+export D_OAUTH_HEALTH_ADDR=0.0.0.0:8081
+export D_OAUTH_TLS__CERT_PATH=/path/to/tls.crt
+export D_OAUTH_TLS__KEY_PATH=/path/to/tls.key
+export D_OAUTH_DATABASE__URL='postgres://oauth:password@127.0.0.1:5432/oauth?sslmode=disable'
+export D_OAUTH_REDIS__URL=redis://127.0.0.1:6379
+export D_OAUTH_REDIS__DURABILITY=waitaof
+export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random-00'
+export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
+export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
+```
+
+The three master keys must each be at least 32 bytes, and each is a live
+secret: losing the pepper invalidates every opaque token, and losing the PII key
+makes the sealed directory unreadable. Use the output of `openssl rand -base64
+32` for each placeholder; in a cluster, mount them as files through the
+`*_FILE` variants instead of the environment
+([DEPLOYMENT.md](DEPLOYMENT.md#secret-material-as-files)).
+
+Development relaxes the database TLS requirement. Production requires
+`sslmode=verify-full` (unless `database.require_verify_full` is set false) and an
+`issuer` on `https` (no trailing slash, non-empty authority, no whitespace).
+
+The ports above are the local-dev defaults (Postgres `5432`, Redis `6379`). The
+shared test services use `5433`/`6380` and are documented in
+[CONTRIBUTING.md](CONTRIBUTING.md#database-backed-tests).
+
+## Apply the schema and bootstrap the first admin
+
+Validate the configuration, then apply the schema once:
+
+```bash
+cargo run --bin d-oauth-server -- config check
+cargo run --bin d-oauth-server -- migrate
+```
+
+`config check` prints `config ok: issuer=...` and exits 0 without touching
+Postgres or Redis; it prints the first validation error and exits 1 otherwise.
+`serve` never migrates, so `migrate` is a separate, once-per-deployment step.
+
+Bootstrap the first administrator. It is created in a forced-change state and
+must change its password and enroll TOTP before it gains authority:
+
+```bash
+cargo run --bin d-oauth-server -- admin bootstrap --email admin@example.com
+```
+
+The full replacement procedure (change the password, enroll TOTP, capture
+recovery codes, create a named admin, disable the bootstrap account) is in
+[RUNBOOK.md](RUNBOOK.md).
+
+Apply the scope catalog, redirect URIs, resource servers, and entitlements from
+a JSON file, idempotently:
+
+```bash
+cargo run --bin d-oauth-server -- config sync --file ./oauth-config.json
+```
+
+The file format is in
+[OPERATIONS.md](OPERATIONS.md#config-sync-file-format). Scopes start empty, so an
+authorization request naming a scope that `config sync` did not create is
+rejected.
+
+## Run and probe
+
+```bash
+cargo run --bin d-oauth-server -- serve
+```
+
+The process serves TLS on `bind_addr` and plaintext health on `health_addr`.
+Check it is alive, ready, and advertising metadata:
+
+```bash
+curl -s http://127.0.0.1:8081/health/live
+curl -s http://127.0.0.1:8081/health/ready
+curl -sk https://localhost:8080/.well-known/oauth-authorization-server | jq .
+```
+
+Readiness is fail-closed: it reports ready only when Postgres, Redis, and the
+schema version all agree with the binary. Any other outcome is
+`503 {"status":"not_ready"}`.
+
+## Authorization-code and PKCE walkthrough
+
+The flow is: start at `/authorize`, log in and consent, receive a code on the
+redirect, then redeem it at `/token` with the PKCE verifier.
+
+1. Generate a verifier and its `S256` challenge:
+
+```bash
+VERIFIER=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-64)
+CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
+```
+
+2. Visit the authorization endpoint in a browser. A confidential client must
+   use PAR or a signed request object; a public client may use the query string:
+
+```
+https://localhost:8080/authorize?response_type=code&client_id=example-client&redirect_uri=https://app.example.com/callback&scope=orders:read&state=xyz&code_challenge=$CHALLENGE&code_challenge_method=S256
+```
+
+3. Log in, approve consent, and read the `code` from the redirect.
+
+4. Redeem the code at the token endpoint:
+
+```bash
+curl -sk https://localhost:8080/token \
+  -d grant_type=authorization_code \
+  -d code="$CODE" \
+  -d redirect_uri=https://app.example.com/callback \
+  -d client_id=example-client \
+  -d code_verifier="$VERIFIER"
+```
+
+5. A DPoP-bound public client (`dpop_bound_access_tokens: true`) must present
+   a DPoP proof with the token request; an unbound public client (the
+   default) mints `Bearer` tokens with no proof. A confidential client
+   authenticates with its secret or an mTLS certificate.
+
+The client in step 2 must exist. Create one through `POST /register` (RFC 7591;
+see [OAUTH.md](OAUTH.md#dynamic-client-registration)) or use an
+operator-registered client from [OPERATIONS.md](OPERATIONS.md). Both the
+`POST /token` dispatcher and the `/register*` DCR routes are wired, so step 4
+redeems a code end to end.
+
+## Edge cases
+
+- `serve` never applies migrations. A replica whose schema version is behind the
+  binary reports not ready and is pulled from service; a second `migrate` is a
+  no-op.
+- Unbound public clients become bearer-capable on upgrade. Operators wanting
+  DPoP-mandatory public clients register them with
+  `dpop_bound_access_tokens: true`.
+- The health listener is plaintext and separate from the TLS listener. It is
+  probed inside the pod network only; never publish it.
+- `config sync` is an upsert, not a delete: re-running the same file reports
+  `config sync: no changes`, and an unchanged row does not bump `updated_at`
+  ([OPERATIONS.md](OPERATIONS.md#declarative-config)).
+- A missing `D_OAUTH_CONFIG` file is not an error; the file is optional.
+- A generated bootstrap password prints only to a TTY. On a non-TTY stdout the
+  command refuses rather than risking the secret in job logs; use
+  `--password-file`.
+
+## Operational runbook
+
+- Validate an environment without starting the server:
+  `d-oauth-server config check`.
+- Liveness: `GET http://127.0.0.1:8081/health/live` returns `200 {"status":"live"}`
+  without touching a dependency.
+- Readiness: `GET http://127.0.0.1:8081/health/ready` returns `200 {"status":"ready"}`
+  only with Postgres and Redis answering; otherwise `503`.
+- The container `HEALTHCHECK` runs `d-oauth-server healthcheck`, which speaks raw
+  HTTP/1.1 to the health listener, so an image with no shell works
+  ([DEPLOYMENT.md](DEPLOYMENT.md#the-container-image)).
+- Day-two administration (users, scopes, redirect URIs, key rotation, backup) is
+  [OPERATIONS.md](OPERATIONS.md).
+
+## File and directory references
+
+- `src/main.rs` for the thin binary entry point; `src/cli.rs` for the command
+  surface used above.
+- `src/serve.rs` for listener bootstrap, `src/state/` for readiness.
+- `compose.yaml` for the local multi-replica stack (one Postgres, one Redis,
+  three replicas); `compose.test.yaml` for the shared test services.
+- `.sqlx/` for the offline query cache the locked build uses.
+- [CONFIGURATION.md](CONFIGURATION.md) for every key in the block above.
+- [RUNBOOK.md](RUNBOOK.md) for admin bootstrap replacement and breach response.

+ 80 - 0
docs/README.md

@@ -0,0 +1,80 @@
+# Documentation
+
+Everything an operator, an integrator, a resource-server team, and a contributor
+needs, one guide per subject. The [project README](../README.md) is the short
+version: it states what the server is and links here.
+
+## Purpose
+
+This index tells you which guide answers your question and in which order to
+read them. Each guide is self-contained on its subject and authoritative for
+it; where two touch, the guide that owns the behavior wins and the other links
+to it.
+
+## Start here
+
+- **Run it locally.** [QUICKSTART.md](QUICKSTART.md) takes a checkout to a
+  server on `https://localhost:8080`, an administrator, and a token. Then
+  [CONFIGURATION.md](CONFIGURATION.md) for the keys and
+  [OPERATIONS.md](OPERATIONS.md) for the command surface.
+- **Deploy it.** [DEPLOYMENT.md](DEPLOYMENT.md) covers the image, the cluster
+  contract, the reverse proxy, and supply-chain verification;
+  [RUNBOOK.md](RUNBOOK.md) is the on-call procedure for the admin bootstrap and
+  for a full-database disclosure.
+- **Integrate a client.** [OAUTH.md](OAUTH.md) for endpoints, grants, PKCE,
+  error codes, metadata, and CIMD; [QUICKSTART.md](QUICKSTART.md) for the
+  end-to-end authorization-code walkthrough; [OPERATIONS.md](OPERATIONS.md) for
+  MCP client registration and DCR access tokens.
+- **Build a resource server.** [RS-CONTRACT.md](RS-CONTRACT.md) is written so an
+  independent resource-server team can implement discovery, the challenge, token
+  enforcement, and caching from that document alone.
+- **Change the code.** [ARCHITECTURE.md](ARCHITECTURE.md) for the module map and
+  request lifecycle, then [CONTRIBUTING.md](CONTRIBUTING.md) for the gates,
+  [DATABASE.md](DATABASE.md) and [REDIS.md](REDIS.md) for the two stores,
+  [SECURITY.md](SECURITY.md) for the invariants, and
+  [TEST-COVERAGE.md](TEST-COVERAGE.md) for the measured baseline.
+
+## Guide index
+
+| Guide | Read it when you are... | It answers |
+| --- | --- | --- |
+| [QUICKSTART.md](QUICKSTART.md) | running it for the first time | what do I type to get a server and a token? |
+| [CONFIGURATION.md](CONFIGURATION.md) | building an environment | what is this key, its default, and its validation rule? |
+| [OPERATIONS.md](OPERATIONS.md) | administering a live install | which command, with which flags, in which order? |
+| [RUNBOOK.md](RUNBOOK.md) | on call | how do I bootstrap and replace an admin, or respond to a database disclosure? |
+| [DEPLOYMENT.md](DEPLOYMENT.md) | shipping to a cluster | what does the image contain and what does the runtime require? |
+| [ARCHITECTURE.md](ARCHITECTURE.md) | reading the source | where does this live, and what happens per request? |
+| [OAUTH.md](OAUTH.md) | integrating a client | which endpoint, grant, scope, error code, and metadata field? |
+| [RS-CONTRACT.md](RS-CONTRACT.md) | protecting an API | how does an RFC 9728 resource server validate a token? |
+| [SECURITY.md](SECURITY.md) | reviewing or auditing | what is the threat model and which properties are enforced? |
+| [DATABASE.md](DATABASE.md) | changing a table or query | what is the schema, the migration lifecycle, and how is PII stored? |
+| [REDIS.md](REDIS.md) | changing ephemeral state | which key namespace, TTL, and durability rule applies? |
+| [CONTRIBUTING.md](CONTRIBUTING.md) | opening a change | build, gates, coverage, fuzzing, conventions, doc contract |
+| [TEST-COVERAGE.md](TEST-COVERAGE.md) | judging test evidence | what is the measured coverage and how do I reproduce it? |
+| [CHANGELOG.md](CHANGELOG.md) | checking a release | what changed, and does it break my client? |
+| [../SECURITY.md](../SECURITY.md) | reporting a vulnerability | where do I send it, and what is the disclosure policy? |
+| [../AGENTS.md](../AGENTS.md) | briefing an AI agent or a new maintainer | the module map and the repository rules |
+
+## Conventions
+
+Every guide follows the same shape, so you can jump to the part you need:
+a `## Purpose` paragraph, the body, `## Edge cases`, `## Operational runbook`,
+and `## File and directory references`.
+
+Documentation is part of a change, not a follow-up. The rules that bind a code
+change to a guide live in
+[CONTRIBUTING.md](CONTRIBUTING.md#documentation-contract), and the repository
+rules live in [../AGENTS.md](../AGENTS.md). Relative links are validated by CI,
+so run the same check before opening a change:
+
+```bash
+python3 scripts/check_doc_links.py
+```
+
+## File and directory references
+
+- [../README.md](../README.md) for the short project landing page.
+- `docs/` for the guides above; this file is its index.
+- `scripts/check_doc_links.py` for the link check CI runs.
+- [../AGENTS.md](../AGENTS.md) for the module map and the documentation contract
+  summarized above.

+ 8 - 8
RUNBOOK.md → docs/RUNBOOK.md

@@ -5,12 +5,12 @@ replace that bootstrap account immediately, and how to respond to a full
 database disclosure. There is no HTTP bootstrap endpoint: provisioning is
 database disclosure. There is no HTTP bootstrap endpoint: provisioning is
 CLI/Job only.
 CLI/Job only.
 
 
-> Task 48 owns `SECURITY.md`; this file is the task-47 operator runbook.
-> The threat model and the replace-immediately rationale live there.
+> [SECURITY.md](SECURITY.md) owns the threat model and the replace-immediately
+> rationale; this file is the operator procedure.
 
 
 This file covers admin bootstrap and breach response only. Onboarding a resource
 This file covers admin bootstrap and breach response only. Onboarding a resource
 server (RFC 9728) is a separate operator runbook in
 server (RFC 9728) is a separate operator runbook in
-[docs/OPERATIONS.md](docs/OPERATIONS.md#resource-server-onboarding-rfc-9728).
+[OPERATIONS.md](OPERATIONS.md#resource-server-onboarding-rfc-9728).
 
 
 ## Prerequisites
 ## Prerequisites
 
 
@@ -89,7 +89,7 @@ admin-gated actions deny it until both steps complete.
 - [ ] Create a named second admin:
 - [ ] Create a named second admin:
       `d-oauth-server user create --admin --email <name>@example.com`
       `d-oauth-server user create --admin --email <name>@example.com`
 - [ ] Log in as the named admin and verify full access.
 - [ ] Log in as the named admin and verify full access.
-- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`. `user disable` purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counter before flipping the Postgres `disabled` flag, and fails closed (leaving the user enabled) if Redis is unavailable. Records with no subject (PAR, pending, replay markers, rotation map, TOTP replay, rate-limit counters) are untouched. See docs/OPERATIONS.md.
+- [ ] Disable the bootstrap account: `d-oauth-server user disable --email ops@example.com`. `user disable` purges the account's Redis sessions, tokens, grants, codes, device records, and lockout counter before flipping the Postgres `disabled` flag, and fails closed (leaving the user enabled) if Redis is unavailable. Records with no subject (PAR, pending, replay markers, rotation map, TOTP replay, rate-limit counters) are untouched. See [OPERATIONS.md](OPERATIONS.md).
 - [ ] Rotate/expire the bootstrap secret: delete the mounted
 - [ ] Rotate/expire the bootstrap secret: delete the mounted
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       generated one-time password.
       generated one-time password.
@@ -113,7 +113,7 @@ and TOTP-enrolled outcomes are emitted by the login flow itself.
 
 
 Use this when a PostgreSQL dump, backup, or read replica has left your control
 Use this when a PostgreSQL dump, backup, or read replica has left your control
 on unknown terms. The blast radius and the rationale for the order below are in
 on unknown terms. The blast radius and the rationale for the order below are in
-[docs/SECURITY.md](docs/SECURITY.md); this is the operator checklist. Preserve
+[SECURITY.md](SECURITY.md); this is the operator checklist. Preserve
 evidence first: capture the audit stream and keep the `X-Request-Id`
 evidence first: capture the audit stream and keep the `X-Request-Id`
 correlation. No step below writes a secret to a log.
 correlation. No step below writes a secret to a log.
 
 
@@ -146,7 +146,7 @@ correlation. No step below writes a secret to a log.
       `d-oauth-server pii rotate` to re-encrypt every `_enc` value and recompute
       `d-oauth-server pii rotate` to re-encrypt every `_enc` value and recompute
       every blind index, verify with `d-oauth-server pii verify`, and only then
       every blind index, verify with `d-oauth-server pii verify`, and only then
       retire the old key. See
       retire the old key. See
-      [docs/OPERATIONS.md](docs/OPERATIONS.md#pii-and-totp-key-rotation).
+      [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
 - [ ] **4. Rotate every confidential client secret.** Read the current metadata,
 - [ ] **4. Rotate every confidential client secret.** Read the current metadata,
       then re-submit it through RFC 7592 `PUT /register/{client_id}` to mint a
       then re-submit it through RFC 7592 `PUT /register/{client_id}` to mint a
       new secret and a new registration token:
       new secret and a new registration token:
@@ -174,7 +174,7 @@ correlation. No step below writes a secret to a log.
       both dependencies up:
       both dependencies up:
       `d-oauth-server healthcheck [--url http://127.0.0.1:8081/health/ready]`
       `d-oauth-server healthcheck [--url http://127.0.0.1:8081/health/ready]`
       (readiness; liveness is `/health/live` as in
       (readiness; liveness is `/health/live` as in
-      [docs/OPERATIONS.md](docs/OPERATIONS.md)).
+      [OPERATIONS.md](OPERATIONS.md)).
       A dependency outage during recovery must yield `503`, never a fail-open
       A dependency outage during recovery must yield `503`, never a fail-open
       success.
       success.
 
 
@@ -192,7 +192,7 @@ correlation. No step below writes a secret to a log.
 Every rollout ships the signed `ghcr.io/syavne/d-oauth-server` image the
 Every rollout ships the signed `ghcr.io/syavne/d-oauth-server` image the
 `supply-chain.yml` `merge` job signed (step `Keyless sign (cosign, OIDC)`).
 `supply-chain.yml` `merge` job signed (step `Keyless sign (cosign, OIDC)`).
 Nothing ships from an unverified local tarball. The full path is in
 Nothing ships from an unverified local tarball. The full path is in
-[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#deploy-path-use-the-ghcrio-image).
+[DEPLOYMENT.md](DEPLOYMENT.md#deploy-path-use-the-ghcrio-image).
 
 
 - [ ] Verify the signature before applying (the `latest` tag resolves to the
 - [ ] Verify the signature before applying (the `latest` tag resolves to the
   current digest at verify time; signatures are cosign v3 OCI 1.1 referrers, so
   current digest at verify time; signatures are cosign v3 OCI 1.1 referrers, so

+ 2 - 2
docs/SECURITY.md

@@ -766,7 +766,7 @@ The seven security endpoints stay fail-closed throughout. A Postgres or Redis
 outage while responding returns `503 temporarily_unavailable` with
 outage while responding returns `503 temporarily_unavailable` with
 `Retry-After: 1`, never a fail-open success; do not relax the policy to keep
 `Retry-After: 1`, never a fail-open success; do not relax the policy to keep
 service up mid-rotation. The operator checklist with concrete commands is in
 service up mid-rotation. The operator checklist with concrete commands is in
-[RUNBOOK.md](../RUNBOOK.md).
+[RUNBOOK.md](RUNBOOK.md).
 
 
 ## Edge cases
 ## Edge cases
 
 
@@ -791,4 +791,4 @@ service up mid-rotation. The operator checklist with concrete commands is in
 - `src/config/policy.rs` for the fail-closed policy.
 - `src/config/policy.rs` for the fail-closed policy.
 - `src/middleware/` for redaction, rate limiting, and security headers.
 - `src/middleware/` for redaction, rate limiting, and security headers.
 - `src/cli/admin.rs` for bootstrap.
 - `src/cli/admin.rs` for bootstrap.
-- `RUNBOOK.md` for the operator runbook (admin bootstrap, full-database-disclosure response).
+- [RUNBOOK.md](RUNBOOK.md) for the operator runbook (admin bootstrap, full-database-disclosure response).

+ 4 - 4
scripts/check_doc_links.py

@@ -2,9 +2,9 @@
 """Local documentation link checker for d-OAuth-Server.
 """Local documentation link checker for d-OAuth-Server.
 
 
 This is the "markdown-link-check" equivalent named by task 48. It validates
 This is the "markdown-link-check" equivalent named by task 48. It validates
-every relative Markdown link in the root README.md, root AGENTS.md, and the
-docs/*.md guides, so CI fails when a doc points at a file or heading that no
-longer exists. External http(s), mailto:, and tel: targets are ignored: CI has
+every relative Markdown link in the root README.md, root AGENTS.md, root
+SECURITY.md, and the docs/*.md guides, so CI fails when a doc points at a file
+or heading that no longer exists. External http(s), mailto:, and tel: targets are ignored: CI has
 no network dependency and the RFC references are stable public URLs.
 no network dependency and the RFC references are stable public URLs.
 
 
 Usage:
 Usage:
@@ -27,7 +27,7 @@ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
 EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
 EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
 
 
 REPO_ROOT = Path(__file__).resolve().parent.parent
 REPO_ROOT = Path(__file__).resolve().parent.parent
-DEFAULT_FILES = [REPO_ROOT / "README.md", REPO_ROOT / "AGENTS.md"]
+DEFAULT_FILES = [REPO_ROOT / "README.md", REPO_ROOT / "AGENTS.md", REPO_ROOT / "SECURITY.md"]
 DEFAULT_FILES += sorted((REPO_ROOT / "docs").glob("*.md"))
 DEFAULT_FILES += sorted((REPO_ROOT / "docs").glob("*.md"))
 
 
 
 

+ 2 - 2
src/cli/tests.rs

@@ -1413,8 +1413,8 @@ async fn admin_bootstrap_migrate_seeds_no_admin() -> Result<(), TestError> {
 
 
 #[test]
 #[test]
 fn admin_bootstrap_runbook_matches_cli() -> Result<(), TestError> {
 fn admin_bootstrap_runbook_matches_cli() -> Result<(), TestError> {
-  let runbook =
-    std::fs::read_to_string("RUNBOOK.md").map_err(|_| TestError::Missing("RUNBOOK.md"))?;
+  let runbook = std::fs::read_to_string("docs/RUNBOOK.md")
+    .map_err(|_| TestError::Missing("docs/RUNBOOK.md"))?;
   for command in [
   for command in [
     "admin bootstrap --email",
     "admin bootstrap --email",
     "--password-file",
     "--password-file",