Efren Yevale Varela пре 4 дана
родитељ
комит
74cb5b38fa
12 измењених фајлова са 397 додато и 314 уклоњено
  1. 4 2
      .github/workflows/docs.yml
  2. 5 1
      AGENTS.md
  3. 63 289
      README.md
  4. 6 1
      docs/CONTRIBUTING.md
  5. 2 2
      docs/DEPLOYMENT.md
  6. 3 3
      docs/OPERATIONS.md
  7. 218 0
      docs/QUICKSTART.md
  8. 80 0
      docs/README.md
  9. 8 8
      docs/RUNBOOK.md
  10. 2 2
      docs/SECURITY.md
  11. 4 4
      scripts/check_doc_links.py
  12. 2 2
      src/cli/tests.rs

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

@@ -1,7 +1,7 @@
 # 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
 # links are validated) so the job needs no network beyond checkout/setup.
 #
@@ -15,6 +15,7 @@ on:
     paths:
       - "README.md"
       - "AGENTS.md"
+      - "SECURITY.md"
       - "docs/**"
       - "scripts/check_doc_links.py"
       - ".github/workflows/docs.yml"
@@ -22,6 +23,7 @@ on:
     paths:
       - "README.md"
       - "AGENTS.md"
+      - "SECURITY.md"
       - "docs/**"
       - "scripts/check_doc_links.py"
       - ".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`.
 - Redis keys, TTLs, durability, subject purge: `docs/REDIS.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.
 
 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,
 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
-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
-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
+cargo build --locked
 cargo run --bin d-oauth-server -- config check
 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
-```
-
-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
 ```
 
-Check it is alive and ready:
-
 ```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 .
-```
-
-## 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
-  CLI.
+  CLI or a migration Job ([RUNBOOK.md](docs/RUNBOOK.md)).
 - 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
   `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
 

+ 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
 quality gates, module conventions, the test-driven workflow, commit style, and
 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
 
@@ -309,6 +310,10 @@ the relevant guide in the same commit:
 - Redis keys, TTLs, or topology: `docs/REDIS.md`.
 - Image, manifests, or probes: `docs/DEPLOYMENT.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).
 
 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
 tradeoff for a simple, low-ceremony admin flow; treat the tag as a convenience
 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`
 result). A rollout with no matching record is an unverified deploy: stop and
 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.
 - `.dockerignore` for the build context.
 - `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/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

+ 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
 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
-`RUNBOOK.md`.
+[RUNBOOK.md](RUNBOOK.md).
 
 ### Initial access tokens (DCR)
 
@@ -752,7 +752,7 @@ survives a Redis loss.
 
 - First install: `migrate`, then `admin bootstrap`, then complete the forced
   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
   entitlements; `user create` for users.
 - Key rotation: overlap the pepper for at least the refresh lifetime. Rotate the
@@ -780,4 +780,4 @@ survives a Redis loss.
   `totp rewrap`.
 - `src/crypto/field.rs` for the PII field codec and blind index.
 - `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
 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
 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
 
@@ -89,7 +89,7 @@ admin-gated actions deny it until both steps complete.
 - [ ] Create a named second admin:
       `d-oauth-server user create --admin --email <name>@example.com`
 - [ ] 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
       `/run/secrets/bootstrap-password` material and revoke any copy of a
       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
 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`
 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
       every blind index, verify with `d-oauth-server pii verify`, and only then
       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,
       then re-submit it through RFC 7592 `PUT /register/{client_id}` to mint a
       new secret and a new registration token:
@@ -174,7 +174,7 @@ correlation. No step below writes a secret to a log.
       both dependencies up:
       `d-oauth-server healthcheck [--url http://127.0.0.1:8081/health/ready]`
       (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
       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
 `supply-chain.yml` `merge` job signed (step `Keyless sign (cosign, OIDC)`).
 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
   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
 `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
-[RUNBOOK.md](../RUNBOOK.md).
+[RUNBOOK.md](RUNBOOK.md).
 
 ## 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/middleware/` for redaction, rate limiting, and security headers.
 - `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.
 
 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.
 
 Usage:
@@ -27,7 +27,7 @@ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
 EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "tel:", "data:")
 
 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"))
 
 

+ 2 - 2
src/cli/tests.rs

@@ -1413,8 +1413,8 @@ async fn admin_bootstrap_migrate_seeds_no_admin() -> Result<(), TestError> {
 
 #[test]
 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 [
     "admin bootstrap --email",
     "--password-file",