Browse Source

Initial commit

Efren Yevale Varela 1 month ago
commit
7c50a23515
100 changed files with 13282 additions and 0 deletions
  1. 5 0
      .cargo/audit.toml
  2. 15 0
      .cargo/mutants.toml
  3. 1 0
      .codegraph
  4. 16 0
      .dockerignore
  5. 168 0
      .github/workflows/ci.yml
  6. 43 0
      .github/workflows/docs.yml
  7. 143 0
      .github/workflows/supply-chain.yml
  8. 8 0
      .gitignore
  9. 9 0
      .sqlx/.gitkeep
  10. 15 0
      .sqlx/query-03ce703b5b1f844445c7f6c4d552f92f542385a13bb37806af3082fbe9976196.json
  11. 15 0
      .sqlx/query-051fc39f1d1dcda37f4582cffcd8ffa1ec04e2e0529093cdda95c25e8ec65ec7.json
  12. 52 0
      .sqlx/query-08a6ccf70a7aff1a7a119951dfc0a960df5e0c49dabfc923da9cf47c6e6a64ec.json
  13. 21 0
      .sqlx/query-0934c5625c0291e68553a4a8e79018ca61933789ffe18478150618ce7dc38c89.json
  14. 40 0
      .sqlx/query-0ed2b9b91b8c1630e806e6eaf9d49aa0fc33a8e90932bdc8f0eb5d5d1fc0b44d.json
  15. 14 0
      .sqlx/query-12c7b3445ea1efd723431ae145594c35715c652f3f5c441ecbfbe569326730ab.json
  16. 112 0
      .sqlx/query-1371e4448a989ff9f81cfb639d43bb037b1053522fbd49213476b60637e7fa96.json
  17. 16 0
      .sqlx/query-1539e0e8909a271c3b25148492a78fec7ce749db65ef69236461e4b1e368ab65.json
  18. 15 0
      .sqlx/query-2873f7f33ad19537444e232b1bc33821c010bca47db4b5b88ed21aa62b8b93db.json
  19. 256 0
      .sqlx/query-2f7dbb4684015950066d04850a4b0adea06bcf90e85e61b575b0f0efecdc2d06.json
  20. 14 0
      .sqlx/query-4037d743e3683a32cd7ae9b06f8acc080e0279f890d9e92a1b9c426f140e24e5.json
  21. 269 0
      .sqlx/query-40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244.json
  22. 112 0
      .sqlx/query-4faaddbb8edc3fd5fcc8e3f9e1b3ea741d9f7fb92611f35122046116e0412b3e.json
  23. 256 0
      .sqlx/query-53a221512394ee4da5eaf3b522989ed45e278f4aa1c097c3acf21c8abe413742.json
  24. 116 0
      .sqlx/query-56a179c3ef725c5ffe95f7c71f2e5f195072bcbc139737eab10b83a50e6c72a1.json
  25. 15 0
      .sqlx/query-57b2b923585c45409fa03f643d1afaaee2c9f19b686b89255fe31706798a9a4c.json
  26. 53 0
      .sqlx/query-5806efa2899b31394c5c19362edf0fe92565ba08bb78435727f4acc331c11506.json
  27. 112 0
      .sqlx/query-5c4899738b7b454659330a8c2775c8641061a76368ad3b253f08d5d7536c81ab.json
  28. 113 0
      .sqlx/query-69308568afaa5e9fab1dd59db7b40ca7323832855f4ac20607293134cfb3202e.json
  29. 273 0
      .sqlx/query-6bc0248569d7b6b4d3fd1f7f57fb21cba3b74bfded63c3287b06db8160a07b24.json
  30. 99 0
      .sqlx/query-714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1.json
  31. 256 0
      .sqlx/query-7375e8cf53fa01c8de3545d3ee0678bf0825c71f2a2938ce1a290ad5a23ef5c9.json
  32. 23 0
      .sqlx/query-7429d99993b9ae8a06bd9334eab8f76fe188ef3fbe75a0b616118d37e65f22ae.json
  33. 15 0
      .sqlx/query-879bb901f5d8cfd90276ce795bce23135b242a9cbb396ee408c2cbdd789f0b00.json
  34. 95 0
      .sqlx/query-915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41.json
  35. 50 0
      .sqlx/query-91cc42267af5fcbafc6f2049e844ef0e32cd60783f0f89fe90bd8bd2c8238311.json
  36. 14 0
      .sqlx/query-92060f3b75340d2d5a61056948f670713ca01afb9db6e505e21eb6b69607940c.json
  37. 15 0
      .sqlx/query-950fe57b83c689b6e2b7620c13a8b216fbf1953b899a93380d681d6389db56de.json
  38. 28 0
      .sqlx/query-9e921d5530a9579d88288b6fff12ffa5c867ddc89a374f3fc43b8f5cff3e93a8.json
  39. 24 0
      .sqlx/query-9f1ce8a65af49b77d0964941ba1ba353ee4377a982f46150bffbb68854c43506.json
  40. 24 0
      .sqlx/query-a035a5f82aabf067f01152a83712e5825fc7684b66e76733f7020c4f26bf5ab6.json
  41. 15 0
      .sqlx/query-a767bfb9fc4258b18a5faa7e541431839b6072177df6258a3e87d8c0cc9ee9c0.json
  42. 28 0
      .sqlx/query-ab9fb2cb6a51106a9c699731a354a7f21c20466a75fa340a790d20bcc2707f78.json
  43. 15 0
      .sqlx/query-abb5d49d09041d7f1323b2b6a87b3eb42f9751cace175752ac150566f2088fc7.json
  44. 16 0
      .sqlx/query-aefa3bd4c9b03e36fac13098d7bc5c73cbc9b3ebe29901e9b687987654dcb904.json
  45. 24 0
      .sqlx/query-b039c8a8561567cbeaaecb1f301d10e11591ee32111f643867890a36c042cc91.json
  46. 14 0
      .sqlx/query-bb513ae7c858d859eb1dba88f62a6fd7a9cc270b99c1d91fdcbe4f16d1d3bc66.json
  47. 15 0
      .sqlx/query-c2a8b45b293d186a3d6effe485cf64b1793189de27c0438e4105b9a55be2f0d6.json
  48. 28 0
      .sqlx/query-c3a96671e955f06bfea324b7e2e00c7cfc2f218b8fbd69d589cff788d6d50c58.json
  49. 28 0
      .sqlx/query-cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426.json
  50. 14 0
      .sqlx/query-cd7cc6d3d2341b48b74ac61c5ffeaa77505c000a8b239d985d54dc8772bf9e34.json
  51. 50 0
      .sqlx/query-e2ca75b500c3eedae4dae7b523e371f11ec759cac2ef35ad669c9b0d068bffd6.json
  52. 14 0
      .sqlx/query-e4d6b37b0fe328f653420642aa735bc9d3ab7bfefeeeb2bc1641e63341bb4a54.json
  53. 16 0
      .sqlx/query-f69fc462b7b018428c5b9551903a4655de48b2e2583b7239c2723a92aaf023b3.json
  54. 15 0
      .sqlx/query-fcee997c33301069193a4fe30391283cc4a6fd72a77b7073efda86195ce98bf6.json
  55. 135 0
      AGENTS.md
  56. 4952 0
      Cargo.lock
  57. 84 0
      Cargo.toml
  58. 82 0
      Dockerfile
  59. 5 0
      LICENSE.md
  60. 252 0
      README.md
  61. 86 0
      RUNBOOK.md
  62. 149 0
      compose.yaml
  63. 53 0
      deny.toml
  64. 29 0
      deploy/k8s/configmap.yaml
  65. 145 0
      deploy/k8s/deployment.yaml
  66. 29 0
      deploy/k8s/kustomization.yaml
  67. 88 0
      deploy/k8s/migrate-job.yaml
  68. 8 0
      deploy/k8s/namespace.yaml
  69. 18 0
      deploy/k8s/pdb.yaml
  70. 32 0
      deploy/k8s/secret.yaml
  71. 24 0
      deploy/k8s/service.yaml
  72. 25 0
      deploy/k8s/tls-secret.example.yaml
  73. 24 0
      docker-bake.hcl
  74. 156 0
      docs/ARCHITECTURE.md
  75. 112 0
      docs/CHANGELOG.md
  76. 251 0
      docs/CONFIGURATION.md
  77. 156 0
      docs/CONTRIBUTING.md
  78. 218 0
      docs/DATABASE.md
  79. 364 0
      docs/DEPLOYMENT.md
  80. 363 0
      docs/OAUTH.md
  81. 276 0
      docs/OPERATIONS.md
  82. 189 0
      docs/REDIS.md
  83. 294 0
      docs/SECURITY.md
  84. 260 0
      docs/TODO.md
  85. 5 0
      fuzz/.gitignore
  86. 37 0
      fuzz/Cargo.toml
  87. 22 0
      fuzz/fuzz_targets/dcr_json.rs
  88. 80 0
      fuzz/fuzz_targets/jar_claims.rs
  89. 24 0
      fuzz/fuzz_targets/token_form.rs
  90. 7 0
      fuzz/rust-toolchain.toml
  91. 4 0
      migrations/.gitkeep
  92. 47 0
      migrations/1791072000_oauth_clients.sql
  93. 92 0
      migrations/1791158400_users.sql
  94. 7 0
      migrations/1791244800_client_dpop_bound.sql
  95. 13 0
      migrations/1791331200_redirect_uri_kind.sql
  96. 3 0
      rust-toolchain.toml
  97. 2 0
      rustfmt.toml
  98. 103 0
      scripts/check_doc_links.py
  99. 260 0
      scripts/check_manifests.py
  100. 485 0
      src/cli.rs

+ 5 - 0
.cargo/audit.toml

@@ -0,0 +1,5 @@
+# `cargo audit` policy (task 42). Mirrors the `deny.toml` triage: the only
+# known advisory has no patched release and does not apply here (RSA
+# verify-only, never decryption). Re-evaluate when rsa ships a fix.
+[advisories]
+ignore = ["RUSTSEC-2023-0071"]

+ 15 - 0
.cargo/mutants.toml

@@ -0,0 +1,15 @@
+# cargo-mutants budget for the scheduled mutation job (task 42).
+#
+# Scoped to the pure, container-free security modules so the weekly CI leg
+# needs no Docker: scope parsing, redirect matching, PKCE, and the token
+# primitives. The time budget lives in CI (`.github/workflows/ci.yml` runs
+# `cargo mutants --timeout 1800 --test-tool nextest` with a 60-minute step
+# backstop). Task 43 scopes `--file <module>` runs over the
+# security-critical modules against the fast test subset.
+examine_globs = [
+  "src/crypto/ct.rs",
+  "src/crypto/pkce.rs",
+  "src/crypto/tokens.rs",
+  "src/protocol/scope.rs",
+  "src/protocol/redirect_repo.rs",
+]

+ 1 - 0
.codegraph

@@ -0,0 +1 @@
+/home/efren/.omo/codegraph/projects/d-OAuth-f19e5cacc1aaeaad

+ 16 - 0
.dockerignore

@@ -0,0 +1,16 @@
+# Keep the build context small; the target dir and VCS metadata must never
+# reach the builder. `.sqlx/` and `Cargo.lock` are REQUIRED in the context
+# (offline, --locked build), so they are deliberately NOT ignored.
+!.env.example
+*.orig
+*.rej
+.codegraph/
+.env
+.env.*
+.git/
+.omo/
+.opencode/
+__pycache__/
+fuzz/
+scripts/__pycache__/
+target/

+ 168 - 0
.github/workflows/ci.yml

@@ -0,0 +1,168 @@
+# Main quality gate (task 42).
+#
+# Per-PR legs (all must be green):
+#   fmt, clippy, audit, deny, sqlx-prepare-check, nextest, doc-tests,
+#   fuzz-build (nightly leg inside `fuzz/`, so the pinned stable
+#   `rust-toolchain.toml` at the root is never violated).
+# Scheduled legs (not per-PR):
+#   mutants (weekly budgeted mutation run over the pure security modules).
+#
+# Must NOT gate on miri for tokio/sqlx/redis; no lint suppressions anywhere.
+
+name: ci
+
+on:
+  push:
+    branches: ["main"]
+  pull_request: {}
+  schedule:
+    # Weekly budgeted mutation run (Sunday 03:00 UTC).
+    - cron: "0 3 * * 0"
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+
+env:
+  CARGO_TERM_COLOR: always
+  SQLX_OFFLINE: "true"
+
+jobs:
+  fmt:
+    name: fmt
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: rustfmt
+      - name: Check formatting
+        run: cargo fmt --check
+
+  clippy:
+    name: clippy
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+        with:
+          components: clippy
+      - name: Clippy (all targets, warnings denied)
+        run: cargo clippy --all-targets -- -D warnings
+
+  audit:
+    name: audit
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Install cargo-audit
+        run: cargo install cargo-audit --locked
+      - name: Audit advisories
+        run: cargo audit
+
+  deny:
+    name: deny
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Install cargo-deny
+        run: cargo install cargo-deny --locked
+      - name: Check supply chain (ring-only TLS enforced in deny.toml)
+        run: cargo deny check
+
+  sqlx-prepare-check:
+    name: sqlx-prepare-check
+    runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgres:16
+        env:
+          POSTGRES_USER: postgres
+          POSTGRES_PASSWORD: sqlxcheck
+          POSTGRES_DB: postgres
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U postgres"
+          --health-interval 5s
+          --health-timeout 5s
+          --health-retries 10
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Install sqlx-cli
+        run: cargo install sqlx-cli --locked --version 0.9.0 --no-default-features --features postgres,rustls
+      - name: Apply migrations to the check database
+        env:
+          DATABASE_URL: postgres://postgres:sqlxcheck@127.0.0.1:5432/postgres
+        run: cargo sqlx migrate run
+      - name: Fail if the committed .sqlx cache is stale
+        env:
+          DATABASE_URL: postgres://postgres:sqlxcheck@127.0.0.1:5432/postgres
+        run: cargo sqlx prepare --check --workspace
+
+  doc-links:
+    name: doc-links
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - name: Check documentation links (markdown-link-check equivalent)
+        run: python3 scripts/check_doc_links.py
+
+  nextest:
+    name: nextest
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Install cargo-nextest
+        uses: taiki-e/install-action@v2
+        with:
+          tool: nextest
+      - name: Install redis-server (in-process Sentinel/Cluster fixtures)
+        run: sudo apt-get update && sudo apt-get install -y redis-server
+      - name: Full test suite (testcontainers provide Postgres/Redis)
+        run: cargo nextest run --all-targets
+
+  doc-tests:
+    name: doc-tests
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Documentation tests
+        run: cargo test --doc
+
+  fuzz-build:
+    name: fuzz-build
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: fuzz
+    steps:
+      - uses: actions/checkout@v4
+      # The `fuzz/rust-toolchain.toml` in the working directory selects
+      # nightly via rustup override; the root stable pin is untouched.
+      - name: Install cargo-fuzz (nightly, per fuzz/rust-toolchain.toml)
+        run: cargo install cargo-fuzz --locked
+      - name: Build all fuzz targets
+        run: cargo fuzz build
+
+  mutants:
+    name: mutants
+    # Scheduled budgeted job only: never blocks a PR.
+    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    timeout-minutes: 60
+    steps:
+      - uses: actions/checkout@v4
+      - uses: dtolnay/rust-toolchain@stable
+      - name: Install cargo-mutants
+        run: cargo install cargo-mutants --locked
+      - name: Budgeted mutation run (see .cargo/mutants.toml)
+        run: cargo mutants --timeout 1800 --test-tool nextest

+ 43 - 0
.github/workflows/docs.yml

@@ -0,0 +1,43 @@
+# 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
+# equivalent" named by the task; it is deliberately offline (only relative
+# links are validated) so the job needs no network beyond checkout/setup.
+#
+# Task 42 owns the main CI workflow. This job is intentionally a separate
+# workflow file so it can be added to that pipeline as a required step
+# (`docs` job name) without a conflict, and it also runs standalone.
+name: docs
+
+on:
+  push:
+    paths:
+      - "README.md"
+      - "AGENTS.md"
+      - "docs/**"
+      - "scripts/check_doc_links.py"
+      - ".github/workflows/docs.yml"
+  pull_request:
+    paths:
+      - "README.md"
+      - "AGENTS.md"
+      - "docs/**"
+      - "scripts/check_doc_links.py"
+      - ".github/workflows/docs.yml"
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+
+jobs:
+  link-check:
+    name: docs link-check
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - name: Check documentation links
+        run: python3 scripts/check_doc_links.py

+ 143 - 0
.github/workflows/supply-chain.yml

@@ -0,0 +1,143 @@
+# Supply-chain gate (task 46). Coordinates with task 42's `ci.yml`.
+#
+# Per-PR legs: `manifest-lint` (kustomize build + scripts/check_manifests.py,
+# including the L7-vs-L4 mTLS pairing check).
+# Per-push-to-main legs: multi-arch `docker buildx bake` with SBOM +
+# provenance, trivy/grype gating HIGH/CRITICAL, keyless cosign signing, and
+# digest pinning of `deploy/k8s/kustomization.yaml` (strict lint re-run).
+# Scheduled legs (weekly rebuild, Monday 04:00 UTC): rebuild + rescan + resign
+# so base-image digests advance even without code changes.
+#
+# The L7 mTLS constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
+# here; it is explicitly NOT exercised end-to-end in CI.
+
+name: supply-chain
+
+on:
+  push:
+    branches: ["main"]
+  pull_request: {}
+  schedule:
+    # Weekly rebuild (Monday 04:00 UTC).
+    - cron: "0 4 * * 1"
+  workflow_dispatch: {}
+
+permissions:
+  contents: read
+
+env:
+  REGISTRY_IMAGE: ghcr.io/d-oauth/d-oauth-server
+  SQLX_OFFLINE: "true"
+
+jobs:
+  manifest-lint:
+    name: manifest-lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - name: Install pyyaml + kustomize
+        run: |
+          pip install pyyaml
+          curl -sSfL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz \
+            | tar -xzf - kustomize && sudo mv kustomize /usr/local/bin/
+      - name: Kustomize build
+        run: kustomize build deploy/k8s
+      - name: Manifest policy lint (probes, PDB, no-baked-secrets, L7-vs-L4 mTLS)
+        run: python3 scripts/check_manifests.py
+
+  build:
+    name: build
+    if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      id-token: write
+    outputs:
+      digest: ${{ steps.bake.outputs.digest }}
+    steps:
+      - uses: actions/checkout@v4
+      - uses: docker/setup-qemu-action@v3
+      - uses: docker/setup-buildx-action@v3
+      - uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+      - name: Multi-arch bake with SBOM + provenance
+        id: bake
+        run: |
+          TAG="sha-${GITHUB_SHA::12}"
+          echo "tag=${TAG}" >> "${GITHUB_OUTPUT}"
+          docker buildx bake image \
+            --set "image.tags=${REGISTRY_IMAGE}:${TAG}" \
+            --set image.output="type=registry" \
+            --sbom=true \
+            --provenance=mode=max \
+            --metadata-file /tmp/bake-metadata.json
+          python3 -c "
+          import json
+          meta = json.load(open('/tmp/bake-metadata.json'))
+          node = meta['image']['containerimage.digest']
+          open('${GITHUB_OUTPUT}', 'a').write(f'digest={node}\n')
+          "
+      - name: SBOM (syft, committed as artifact)
+        uses: anchore/sbom-action@v0
+        with:
+          image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
+          artifact-name: sbom.spdx.json
+      - name: Scan (trivy, gate HIGH/CRITICAL)
+        uses: aquasecurity/trivy-action@0.24.0
+        with:
+          image-ref: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
+          format: sarif
+          output: trivy.sarif
+          severity: HIGH,CRITICAL
+          exit-code: "1"
+      - name: Scan (grype, gate high/critical)
+        uses: anchore/scan-action@v4
+        with:
+          image: ${{ env.REGISTRY_IMAGE }}@${{ steps.bake.outputs.digest }}
+          fail-build: true
+          severity-cutoff: high
+      - uses: sigstore/cosign-installer@v3
+      - name: Keyless sign (cosign, OIDC)
+        env:
+          COSIGN_YES: "true"
+        run: |
+          cosign sign "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}"
+          cosign attest --predicate /tmp/bake-metadata.json \
+            "${REGISTRY_IMAGE}@${{ steps.bake.outputs.digest }}"
+
+  pin-digest:
+    name: pin-digest
+    needs: [build]
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.x"
+      - name: Pin kustomization to the built digest + strict lint
+        env:
+          DIGEST: ${{ needs.build.outputs.digest }}
+        run: |
+          pip install pyyaml
+          python3 - "${DIGEST}" <<'EOF'
+          import sys
+          from pathlib import Path
+          import yaml
+          digest = sys.argv[1]
+          assert digest.startswith("sha256:"), f"bad digest: {digest}"
+          path = Path("deploy/k8s/kustomization.yaml")
+          doc = yaml.safe_load(path.read_text())
+          for img in doc.get("images", []):
+              if img.get("name") == "ghcr.io/d-oauth/d-oauth-server":
+                  img.pop("newTag", None)
+                  img["digest"] = digest
+          path.write_text(yaml.safe_dump(doc, sort_keys=False))
+          EOF
+          python3 scripts/check_manifests.py --strict-digest

+ 8 - 0
.gitignore

@@ -0,0 +1,8 @@
+!.env.example
+.env
+.env.*
+.omo/
+.opencode/
+/target
+__pycache__/
+scripts/__pycache__/

+ 9 - 0
.sqlx/.gitkeep

@@ -0,0 +1,9 @@
+SQLx offline query cache (`.sqlx/`) is committed so
+`SQLX_OFFLINE=true cargo check` works without a live database.
+
+Generated with `cargo sqlx prepare` against a live testcontainers
+database; CI runs `cargo sqlx prepare --check`.
+
+This placeholder keeps the directory committed while the cache is empty
+(todo 1 scaffolding has no queries yet; the first query-bearing todo
+populates it).

+ 15 - 0
.sqlx/query-03ce703b5b1f844445c7f6c4d552f92f542385a13bb37806af3082fbe9976196.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users\n          SET totp_recovery_code_hashes = $2::text::jsonb, updated_at = now()\n        WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "03ce703b5b1f844445c7f6c4d552f92f542385a13bb37806af3082fbe9976196"
+}

+ 15 - 0
.sqlx/query-051fc39f1d1dcda37f4582cffcd8ffa1ec04e2e0529093cdda95c25e8ec65ec7.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE scopes SET enabled = $2, updated_at = now() WHERE name = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Bool"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "051fc39f1d1dcda37f4582cffcd8ffa1ec04e2e0529093cdda95c25e8ec65ec7"
+}

+ 52 - 0
.sqlx/query-08a6ccf70a7aff1a7a119951dfc0a960df5e0c49dabfc923da9cf47c6e6a64ec.json

@@ -0,0 +1,52 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT name, description, enabled FROM scopes WHERE name = $1",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "name"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "enabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "08a6ccf70a7aff1a7a119951dfc0a960df5e0c49dabfc923da9cf47c6e6a64ec"
+}

+ 21 - 0
.sqlx/query-0934c5625c0291e68553a4a8e79018ca61933789ffe18478150618ce7dc38c89.json

@@ -0,0 +1,21 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(SELECT 1 FROM users WHERE is_admin AND NOT disabled) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": []
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "0934c5625c0291e68553a4a8e79018ca61933789ffe18478150618ce7dc38c89"
+}

+ 40 - 0
.sqlx/query-0ed2b9b91b8c1630e806e6eaf9d49aa0fc33a8e90932bdc8f0eb5d5d1fc0b44d.json

@@ -0,0 +1,40 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT password_hash, disabled\n         FROM users\n        WHERE email = $1::text::citext",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "password_hash",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "password_hash"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "disabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "disabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false
+    ]
+  },
+  "hash": "0ed2b9b91b8c1630e806e6eaf9d49aa0fc33a8e90932bdc8f0eb5d5d1fc0b44d"
+}

+ 14 - 0
.sqlx/query-12c7b3445ea1efd723431ae145594c35715c652f3f5c441ecbfbe569326730ab.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users\n          SET totp_secret_enc = NULL, totp_enabled = false, updated_at = now()\n        WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "12c7b3445ea1efd723431ae145594c35715c652f3f5c441ecbfbe569326730ab"
+}

+ 112 - 0
.sqlx/query-1371e4448a989ff9f81cfb639d43bb037b1053522fbd49213476b60637e7fa96.json

@@ -0,0 +1,112 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'authorize' ORDER BY ru.uri",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false
+    ]
+  },
+  "hash": "1371e4448a989ff9f81cfb639d43bb037b1053522fbd49213476b60637e7fa96"
+}

+ 16 - 0
.sqlx/query-1539e0e8909a271c3b25148492a78fec7ce749db65ef69236461e4b1e368ab65.json

@@ -0,0 +1,16 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE oauth_clients SET client_secret_hash = $2, client_secret_expires_at = $3, updated_at = now() WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text",
+        "Timestamptz"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "1539e0e8909a271c3b25148492a78fec7ce749db65ef69236461e4b1e368ab65"
+}

+ 15 - 0
.sqlx/query-2873f7f33ad19537444e232b1bc33821c010bca47db4b5b88ed21aa62b8b93db.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users\n            SET totp_recovery_code_hashes = $2::text::jsonb, updated_at = now()\n          WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "2873f7f33ad19537444e232b1bc33821c010bca47db4b5b88ed21aa62b8b93db"
+}

+ 256 - 0
.sqlx/query-2f7dbb4684015950066d04850a4b0adea06bcf90e85e61b575b0f0efecdc2d06.json

@@ -0,0 +1,256 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE id = $1",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "2f7dbb4684015950066d04850a4b0adea06bcf90e85e61b575b0f0efecdc2d06"
+}

+ 14 - 0
.sqlx/query-4037d743e3683a32cd7ae9b06f8acc080e0279f890d9e92a1b9c426f140e24e5.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM redirect_uris WHERE id = $1 AND NOT EXISTS (SELECT 1 FROM client_redirect_uris WHERE redirect_uri_id = $1)",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "4037d743e3683a32cd7ae9b06f8acc080e0279f890d9e92a1b9c426f140e24e5"
+}

+ 269 - 0
.sqlx/query-40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244.json

@@ -0,0 +1,269 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE oauth_clients SET client_name = $2, grant_types = $3, response_types = $4, scope = $5, token_endpoint_auth_method = $6, jwks = $7, jwks_uri = $8, tls_client_auth_subject_dn = $9, tls_client_auth_cert_thumbprint = $10, request_uris = $11, contacts = $12, software_id = $13, dpop_bound_access_tokens = $14, updated_at = now() WHERE client_id = $1 AND active = true RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text",
+        "TextArray",
+        "TextArray",
+        "Text",
+        "Text",
+        "Jsonb",
+        "Text",
+        "Text",
+        "Text",
+        "Jsonb",
+        "Jsonb",
+        "Text",
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "40cfcf7198a65db344c84765f67d8bc6b89088132aa9a0c5b66e0b35487c6244"
+}

+ 112 - 0
.sqlx/query-4faaddbb8edc3fd5fcc8e3f9e1b3ea741d9f7fb92611f35122046116e0412b3e.json

@@ -0,0 +1,112 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT ru.id, ru.uri, ru.kind, ru.enabled, ru.description, ru.created_by, ru.created_at, ru.updated_at FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 ORDER BY ru.uri",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false
+    ]
+  },
+  "hash": "4faaddbb8edc3fd5fcc8e3f9e1b3ea741d9f7fb92611f35122046116e0412b3e"
+}

+ 256 - 0
.sqlx/query-53a221512394ee4da5eaf3b522989ed45e278f4aa1c097c3acf21c8abe413742.json

@@ -0,0 +1,256 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "53a221512394ee4da5eaf3b522989ed45e278f4aa1c097c3acf21c8abe413742"
+}

+ 116 - 0
.sqlx/query-56a179c3ef725c5ffe95f7c71f2e5f195072bcbc139737eab10b83a50e6c72a1.json

@@ -0,0 +1,116 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO redirect_uris (id, uri, kind, description, created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, now(), now()) ON CONFLICT (uri, kind) DO UPDATE SET updated_at = now() RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Text",
+        "Text",
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false
+    ]
+  },
+  "hash": "56a179c3ef725c5ffe95f7c71f2e5f195072bcbc139737eab10b83a50e6c72a1"
+}

+ 15 - 0
.sqlx/query-57b2b923585c45409fa03f643d1afaaee2c9f19b686b89255fe31706798a9a4c.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users SET totp_enabled = $2, updated_at = now() WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Bool"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "57b2b923585c45409fa03f643d1afaaee2c9f19b686b89255fe31706798a9a4c"
+}

+ 53 - 0
.sqlx/query-5806efa2899b31394c5c19362edf0fe92565ba08bb78435727f4acc331c11506.json

@@ -0,0 +1,53 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO scopes (name, description, enabled, created_at, updated_at)\n       VALUES ($1, $2, true, now(), now())\n       ON CONFLICT (name) DO UPDATE\n         SET description = EXCLUDED.description, updated_at = now()\n       RETURNING name, description, enabled",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "name"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "enabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "5806efa2899b31394c5c19362edf0fe92565ba08bb78435727f4acc331c11506"
+}

+ 112 - 0
.sqlx/query-5c4899738b7b454659330a8c2775c8641061a76368ad3b253f08d5d7536c81ab.json

@@ -0,0 +1,112 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, uri, kind, enabled, description, created_by, created_at, updated_at FROM redirect_uris WHERE ($1 OR enabled = true) ORDER BY uri",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false
+    ]
+  },
+  "hash": "5c4899738b7b454659330a8c2775c8641061a76368ad3b253f08d5d7536c81ab"
+}

+ 113 - 0
.sqlx/query-69308568afaa5e9fab1dd59db7b40ca7323832855f4ac20607293134cfb3202e.json

@@ -0,0 +1,113 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE redirect_uris SET enabled = $2, updated_at = now() WHERE id = $1 RETURNING id, uri, kind, enabled, description, created_by, created_at, updated_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "kind",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "kind"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "created_by",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_by"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      false,
+      false
+    ]
+  },
+  "hash": "69308568afaa5e9fab1dd59db7b40ca7323832855f4ac20607293134cfb3202e"
+}

+ 273 - 0
.sqlx/query-6bc0248569d7b6b4d3fd1f7f57fb21cba3b74bfded63c3287b06db8160a07b24.json

@@ -0,0 +1,273 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO oauth_clients (id, client_id, client_secret_hash, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, registration_access_token_hash, dpop_bound_access_tokens, created_at, updated_at) VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), now()) RETURNING id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Text",
+        "Timestamptz",
+        "Text",
+        "TextArray",
+        "TextArray",
+        "Text",
+        "Text",
+        "Jsonb",
+        "Text",
+        "Text",
+        "Text",
+        "Jsonb",
+        "Jsonb",
+        "Text",
+        "Text",
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "6bc0248569d7b6b4d3fd1f7f57fb21cba3b74bfded63c3287b06db8160a07b24"
+}

+ 99 - 0
.sqlx/query-714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1.json

@@ -0,0 +1,99 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO users\n         (id, email, password_hash, is_admin, must_change_password,\n          password_changed_at, created_at, updated_at)\n       VALUES ($1, $2::text::citext, $3, $4, $5,\n               CASE WHEN $5 THEN NULL ELSE now() END, now(), now())\n       RETURNING id, email::text AS \"email!\", is_admin, must_change_password,\n                 disabled, totp_enabled, password_changed_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "email!",
+        "type_info": "Text",
+        "origin": "Expression"
+      },
+      {
+        "ordinal": 2,
+        "name": "is_admin",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "is_admin"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "must_change_password",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "must_change_password"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "disabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "disabled"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "totp_enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "totp_enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "password_changed_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "password_changed_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Text",
+        "Bool",
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      null,
+      false,
+      false,
+      false,
+      false,
+      true
+    ]
+  },
+  "hash": "714904ff3cb3d812603a8daf505e556a38be72cc0b1388fb6b5929033b8c28f1"
+}

+ 256 - 0
.sqlx/query-7375e8cf53fa01c8de3545d3ee0678bf0825c71f2a2938ce1a290ad5a23ef5c9.json

@@ -0,0 +1,256 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, tls_client_auth_subject_dn, tls_client_auth_cert_thumbprint, request_uris, contacts, software_id, dpop_bound_access_tokens, active, created_at, updated_at FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Bool"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "7375e8cf53fa01c8de3545d3ee0678bf0825c71f2a2938ce1a290ad5a23ef5c9"
+}

+ 23 - 0
.sqlx/query-7429d99993b9ae8a06bd9334eab8f76fe188ef3fbe75a0b616118d37e65f22ae.json

@@ -0,0 +1,23 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT totp_recovery_code_hashes::text AS hashes\n         FROM users WHERE id = $1 FOR UPDATE",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "hashes",
+        "type_info": "Text",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "7429d99993b9ae8a06bd9334eab8f76fe188ef3fbe75a0b616118d37e65f22ae"
+}

+ 15 - 0
.sqlx/query-879bb901f5d8cfd90276ce795bce23135b242a9cbb396ee408c2cbdd789f0b00.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM user_scopes WHERE user_id = $1 AND scope_name = $2",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "879bb901f5d8cfd90276ce795bce23135b242a9cbb396ee408c2cbdd789f0b00"
+}

+ 95 - 0
.sqlx/query-915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41.json

@@ -0,0 +1,95 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, email::text AS \"email!\", is_admin, must_change_password,\n              disabled, totp_enabled, password_changed_at\n         FROM users\n        WHERE email = $1::text::citext",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "email!",
+        "type_info": "Text",
+        "origin": "Expression"
+      },
+      {
+        "ordinal": 2,
+        "name": "is_admin",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "is_admin"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "must_change_password",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "must_change_password"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "disabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "disabled"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "totp_enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "totp_enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "password_changed_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "password_changed_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      null,
+      false,
+      false,
+      false,
+      false,
+      true
+    ]
+  },
+  "hash": "915a3721e78651ff39ca47db647cc1282ad0ddc8b508257bb17c3e4f049b4e41"
+}

+ 50 - 0
.sqlx/query-91cc42267af5fcbafc6f2049e844ef0e32cd60783f0f89fe90bd8bd2c8238311.json

@@ -0,0 +1,50 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT name, description, enabled FROM scopes ORDER BY name",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "name"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "enabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": []
+    },
+    "nullable": [
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "91cc42267af5fcbafc6f2049e844ef0e32cd60783f0f89fe90bd8bd2c8238311"
+}

+ 14 - 0
.sqlx/query-92060f3b75340d2d5a61056948f670713ca01afb9db6e505e21eb6b69607940c.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM scopes\n        WHERE name = $1\n          AND NOT EXISTS (SELECT 1 FROM user_scopes WHERE scope_name = $1)",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "92060f3b75340d2d5a61056948f670713ca01afb9db6e505e21eb6b69607940c"
+}

+ 15 - 0
.sqlx/query-950fe57b83c689b6e2b7620c13a8b216fbf1953b899a93380d681d6389db56de.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users\n          SET totp_secret_enc = $2, totp_enabled = true, updated_at = now()\n        WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Bytea"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "950fe57b83c689b6e2b7620c13a8b216fbf1953b899a93380d681d6389db56de"
+}

+ 28 - 0
.sqlx/query-9e921d5530a9579d88288b6fff12ffa5c867ddc89a374f3fc43b8f5cff3e93a8.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT client_secret_hash FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "client_secret_hash",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_hash"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      true
+    ]
+  },
+  "hash": "9e921d5530a9579d88288b6fff12ffa5c867ddc89a374f3fc43b8f5cff3e93a8"
+}

+ 24 - 0
.sqlx/query-9f1ce8a65af49b77d0964941ba1ba353ee4377a982f46150bffbb68854c43506.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS(\n         SELECT 1 FROM user_scopes WHERE user_id = $1 AND scope_name = $2\n       ) AS \"exists!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "exists!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "9f1ce8a65af49b77d0964941ba1ba353ee4377a982f46150bffbb68854c43506"
+}

+ 24 - 0
.sqlx/query-a035a5f82aabf067f01152a83712e5825fc7684b66e76733f7020c4f26bf5ab6.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS ( SELECT 1 FROM scopes AS s WHERE s.name = $2 AND s.enabled = true AND EXISTS ( SELECT 1 FROM oauth_clients AS c WHERE c.client_id = $1 AND c.active = true AND $2 = ANY(string_to_array(c.scope, ' ')) ) ) AS \"allowed!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "allowed!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "a035a5f82aabf067f01152a83712e5825fc7684b66e76733f7020c4f26bf5ab6"
+}

+ 15 - 0
.sqlx/query-a767bfb9fc4258b18a5faa7e541431839b6072177df6258a3e87d8c0cc9ee9c0.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM client_redirect_uris WHERE client_id = $1 AND redirect_uri_id = $2",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "a767bfb9fc4258b18a5faa7e541431839b6072177df6258a3e87d8c0cc9ee9c0"
+}

+ 28 - 0
.sqlx/query-ab9fb2cb6a51106a9c699731a354a7f21c20466a75fa340a790d20bcc2707f78.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT registration_access_token_hash FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "registration_access_token_hash",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "registration_access_token_hash"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      true
+    ]
+  },
+  "hash": "ab9fb2cb6a51106a9c699731a354a7f21c20466a75fa340a790d20bcc2707f78"
+}

+ 15 - 0
.sqlx/query-abb5d49d09041d7f1323b2b6a87b3eb42f9751cace175752ac150566f2088fc7.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users SET is_admin = $2, updated_at = now() WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Bool"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "abb5d49d09041d7f1323b2b6a87b3eb42f9751cace175752ac150566f2088fc7"
+}

+ 16 - 0
.sqlx/query-aefa3bd4c9b03e36fac13098d7bc5c73cbc9b3ebe29901e9b687987654dcb904.json

@@ -0,0 +1,16 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO user_scopes (user_id, scope_name, granted_at, granted_by)\n       VALUES ($1, $2, now(), $3)\n       ON CONFLICT (user_id, scope_name) DO NOTHING",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "aefa3bd4c9b03e36fac13098d7bc5c73cbc9b3ebe29901e9b687987654dcb904"
+}

+ 24 - 0
.sqlx/query-b039c8a8561567cbeaaecb1f301d10e11591ee32111f643867890a36c042cc91.json

@@ -0,0 +1,24 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT EXISTS ( SELECT 1 FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.uri = $2) AS \"allowed!\"",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "allowed!",
+        "type_info": "Bool",
+        "origin": "Expression"
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": [
+      null
+    ]
+  },
+  "hash": "b039c8a8561567cbeaaecb1f301d10e11591ee32111f643867890a36c042cc91"
+}

+ 14 - 0
.sqlx/query-bb513ae7c858d859eb1dba88f62a6fd7a9cc270b99c1d91fdcbe4f16d1d3bc66.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "DELETE FROM client_redirect_uris WHERE client_id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "bb513ae7c858d859eb1dba88f62a6fd7a9cc270b99c1d91fdcbe4f16d1d3bc66"
+}

+ 15 - 0
.sqlx/query-c2a8b45b293d186a3d6effe485cf64b1793189de27c0438e4105b9a55be2f0d6.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users\n          SET password_hash = $2,\n              must_change_password = false,\n              password_changed_at = now(),\n              updated_at = now()\n        WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "c2a8b45b293d186a3d6effe485cf64b1793189de27c0438e4105b9a55be2f0d6"
+}

+ 28 - 0
.sqlx/query-c3a96671e955f06bfea324b7e2e00c7cfc2f218b8fbd69d589cff788d6d50c58.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT scope_name FROM user_scopes\n        WHERE user_id = $1 ORDER BY scope_name",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "scope_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "user_scopes",
+            "name": "scope_name"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "c3a96671e955f06bfea324b7e2e00c7cfc2f218b8fbd69d589cff788d6d50c58"
+}

+ 28 - 0
.sqlx/query-cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT ru.uri FROM client_redirect_uris AS cru JOIN redirect_uris AS ru ON ru.id = cru.redirect_uri_id WHERE cru.client_id = $1 AND ru.enabled = true AND ru.kind = 'post_logout' ORDER BY ru.uri",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "redirect_uris",
+            "name": "uri"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "cd2fda70d5b4529d5d119855fa893a33de822b67b97c186e142c2c2acbb66426"
+}

+ 14 - 0
.sqlx/query-cd7cc6d3d2341b48b74ac61c5ffeaa77505c000a8b239d985d54dc8772bf9e34.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE users SET disabled = true, updated_at = now() WHERE id = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "cd7cc6d3d2341b48b74ac61c5ffeaa77505c000a8b239d985d54dc8772bf9e34"
+}

+ 50 - 0
.sqlx/query-e2ca75b500c3eedae4dae7b523e371f11ec759cac2ef35ad669c9b0d068bffd6.json

@@ -0,0 +1,50 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT name, description, enabled FROM scopes\n        WHERE enabled ORDER BY name",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "name"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "scopes",
+            "name": "enabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": []
+    },
+    "nullable": [
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "e2ca75b500c3eedae4dae7b523e371f11ec759cac2ef35ad669c9b0d068bffd6"
+}

+ 14 - 0
.sqlx/query-e4d6b37b0fe328f653420642aa735bc9d3ab7bfefeeeb2bc1641e63341bb4a54.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE oauth_clients SET active = false, updated_at = now() WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "e4d6b37b0fe328f653420642aa735bc9d3ab7bfefeeeb2bc1641e63341bb4a54"
+}

+ 16 - 0
.sqlx/query-f69fc462b7b018428c5b9551903a4655de48b2e2583b7239c2723a92aaf023b3.json

@@ -0,0 +1,16 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO client_redirect_uris (client_id, redirect_uri_id, attached_at, attached_by) VALUES ($1, $2, now(), $3) ON CONFLICT (client_id, redirect_uri_id) DO NOTHING",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Uuid",
+        "Uuid"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "f69fc462b7b018428c5b9551903a4655de48b2e2583b7239c2723a92aaf023b3"
+}

+ 15 - 0
.sqlx/query-fcee997c33301069193a4fe30391283cc4a6fd72a77b7073efda86195ce98bf6.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE oauth_clients SET registration_access_token_hash = $2, updated_at = now() WHERE client_id = $1 AND active = true",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "fcee997c33301069193a4fe30391283cc4a6fd72a77b7073efda86195ce98bf6"
+}

+ 135 - 0
AGENTS.md

@@ -0,0 +1,135 @@
+# AGENTS.md
+
+Guidance for AI assistants working in this repository. Read this first; the
+guides under `docs/` carry the depth.
+
+## What this is
+
+An OAuth 2.1-only authorization server in Rust. Durable domain state is in
+PostgreSQL; all ephemeral state is in Redis. The crate is one library
+(`src/lib.rs`) plus a thin binary (`src/main.rs`). No HTTP admin API; the CLI is
+the admin surface.
+
+## Module map and where to add what
+
+| Add a... | To this module | Notes |
+| --- | --- | --- |
+| Configuration key or validation rule | `src/config.rs`, `src/config/` | Add to the raw serde struct, then validate in the matching submodule. Update [docs/CONFIGURATION.md](docs/CONFIGURATION.md). |
+| Key-ring behavior | `src/keys.rs` | Versioned ids only; never print key material. |
+| Crypto primitive (hash, compare, PKCE, TOTP) | `src/crypto/` | Constant-time for secret compares. |
+| JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
+| Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
+| Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |
+| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). |
+| Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
+| Cross-cutting HTTP concern | `src/middleware/` | Mind the layer order in `src/routes.rs`. |
+| Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
+| Listener or shutdown behavior | `src/serve.rs`, `src/tls.rs` | Update [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
+| Health or topology behavior | `src/state/` | Readiness is fail-closed. |
+| CLI command or flag | `src/cli.rs`, `src/cli/` | Match the flag exactly in [docs/OPERATIONS.md](docs/OPERATIONS.md). |
+| Error code or HTTP mapping | `src/error.rs` | Add the variant and its status together. |
+
+If a change spans modules, put the logic in the module that owns the behavior
+and call into it from the others.
+
+## Strict Rust rules
+
+- Plain, idiomatic Rust. Edition 2024. 2-space indent (`rustfmt.toml`).
+- `#![forbid(unsafe_code)]` is on the crate. Never add `unsafe`.
+- No `unwrap`, `expect`, or `panic` in request paths. Clippy denies them.
+- Typed errors with `thiserror`. Map a dependency failure to the right
+  client-safe error, do not stringify it.
+- Use `tokio` and `axum` idioms: `async` handlers, `tower` layers, no blocking
+  work on the runtime thread.
+- Secrets use `secrecy` and `zeroize`. A secret-holding type must not print the
+  secret in `Debug`.
+- Use constant-time comparison (`subtle`) for any secret or token compare.
+- No business logic in `main.rs`.
+- Keep files focused; split by responsibility rather than growing a module.
+- Import grouping is `StdExternalCrate`.
+
+## Security checklist
+
+Before finishing any change, confirm:
+
+- [ ] Secret and token comparisons are constant time.
+- [ ] No secret, token, password, or OTP in a log, error body, metric label, or
+      evidence file.
+- [ ] Security endpoints fail closed: a dependency outage yields `503`, never a
+      fail-open success and never a `500` or panic.
+- [ ] A code, PAR handle, or device code is single-use and consumed atomically.
+- [ ] DPoP and `private_key_jwt` `jti` replay markers are written with `SET NX`.
+- [ ] Redirect URI match is exact, with the RFC 8252 loopback exception only.
+- [ ] PKCE is `S256` only; `plain` is rejected; a missing verifier cannot
+      downgrade.
+- [ ] Scope is intersected, never widened on refresh.
+- [ ] Only hashed or encrypted secrets are stored; no raw token at rest.
+- [ ] A sender-constrained grant re-verifies a fresh proof on every use.
+- [ ] `private_key_jwt` is treated as authentication only, never as a binding.
+- [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
+      origin.
+
+## TDD workflow
+
+Red, green, refactor. Write the failing test first and confirm it fails for the
+right reason.
+
+```bash
+cargo nextest run -E 'test(<behavior_name>)'   # red, then green
+```
+
+Use real Postgres and Redis through testcontainers for integration. Prefer a
+real object or an in-memory fake over a mock. Assert the observable contract.
+
+Quality gates for any change:
+
+```bash
+cargo fmt --check
+cargo clippy --all-targets -- -D warnings
+cargo nextest run --all-targets
+cargo test --doc
+python3 scripts/check_doc_links.py
+```
+
+When a query or migration is added or changed, regenerate and commit the
+offline cache:
+
+```bash
+cargo sqlx prepare --workspace
+```
+
+## File-scope discipline
+
+- A delegated or subagent change runs only the narrowest name-filtered test for
+  the touched code, for example `cargo nextest run -E 'test(admin_bootstrap)'`.
+  Never the full suite, and never repo-wide setup.
+- The primary agent runs the full authoritative suite after integration:
+  `cargo nextest run --all-targets` followed by `cargo test --doc`.
+
+## Documentation contract
+
+Documentation is part of the change, not a follow-up. Any behavior change
+updates the relevant guide in the same commit:
+
+- OAuth flow, grants, error codes: `docs/OAUTH.md`.
+- Threat model, attack surface, tokens, sender constraints, admin runbook:
+  `docs/SECURITY.md`.
+- Modules, lifecycle, topology: `docs/ARCHITECTURE.md`.
+- Config keys and defaults: `docs/CONFIGURATION.md`.
+- Schema and migrations: `docs/DATABASE.md`.
+- Redis keys, TTLs, durability: `docs/REDIS.md`.
+- Image, manifests, probes: `docs/DEPLOYMENT.md`.
+- CLI and runbooks: `docs/OPERATIONS.md`, `RUNBOOK.md`.
+- This file, when the module map or rules change.
+
+A change to the deployment surface (a new listener, mount, dependency class, or
+runtime setting) updates the attack-surface section in `docs/SECURITY.md`.
+
+A command in a doc must match the real CLI. Then run the link check:
+
+```bash
+python3 scripts/check_doc_links.py
+```
+
+The CI `doc-links` job runs the same check, so a stale file or heading
+reference fails the build.

+ 4952 - 0
Cargo.lock

@@ -0,0 +1,4952 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "arcstr"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
+
+[[package]]
+name = "argon2"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures 0.3.1",
+ "password-hash",
+]
+
+[[package]]
+name = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
+[[package]]
+name = "askama"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4"
+dependencies = [
+ "askama_macros",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "glob",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "askama_macros"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b"
+dependencies = [
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583"
+dependencies = [
+ "rustc-hash",
+ "serde",
+ "serde_derive",
+ "unicode-ident",
+ "winnow",
+]
+
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.21",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure 0.13.2",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "astral-tokio-tar"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463"
+dependencies = [
+ "futures-core",
+ "libc",
+ "portable-atomic",
+ "rustc-hash",
+ "rustix",
+ "tokio",
+ "tokio-stream",
+ "xattr",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-extra"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+ "gloo-timers",
+ "tokio",
+]
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base32"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bollard"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4"
+dependencies = [
+ "async-stream",
+ "base64 0.22.1",
+ "bitflags 2.13.2",
+ "bollard-buildkit-proto",
+ "bollard-stubs",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "home",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-named-pipe",
+ "hyper-rustls",
+ "hyper-util",
+ "hyperlocal",
+ "log",
+ "num",
+ "pin-project-lite",
+ "rand 0.9.5",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "serde_urlencoded",
+ "thiserror 2.0.21",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "tonic",
+ "tower-service",
+ "url",
+ "winapi",
+]
+
+[[package]]
+name = "bollard-buildkit-proto"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad"
+dependencies = [
+ "prost",
+ "prost-types",
+ "tonic",
+ "tonic-prost",
+ "ureq",
+]
+
+[[package]]
+name = "bollard-stubs"
+version = "1.52.1-rc.29.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066"
+dependencies = [
+ "base64 0.22.1",
+ "bollard-buildkit-proto",
+ "bytes",
+ "prost",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "time",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "config"
+version = "0.15.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
+dependencies = [
+ "async-trait",
+ "convert_case",
+ "json5",
+ "pathdiff",
+ "ron",
+ "rust-ini",
+ "serde-untagged",
+ "serde_core",
+ "serde_json",
+ "toml",
+ "winnow",
+ "yaml-rust2",
+]
+
+[[package]]
+name = "console"
+version = "0.16.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc16"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "d-oauth"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "askama",
+ "axum",
+ "axum-extra",
+ "backon",
+ "base32",
+ "base64 0.23.1",
+ "base64ct",
+ "clap",
+ "config",
+ "hmac 0.13.0",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "insta",
+ "jsonwebtoken",
+ "oauth2",
+ "password-hash",
+ "proptest",
+ "rand 0.10.3",
+ "redis",
+ "redis-test",
+ "reqwest",
+ "ring",
+ "rustls",
+ "rustls-pki-types",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "sqlx",
+ "subtle",
+ "testcontainers",
+ "testcontainers-modules",
+ "thiserror 2.0.21",
+ "time",
+ "tokio",
+ "tokio-rustls",
+ "totp-rs",
+ "tower",
+ "tower-http 0.7.1",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+ "validator",
+ "x509-parser",
+ "zeroize",
+]
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint 0.4.8",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid 0.9.6",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid 0.10.2",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
+[[package]]
+name = "docker_credential"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2 0.10.9",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf 0.12.4",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+dependencies = [
+ "cfg-if",
+ "core_detect",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "ferroid"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
+dependencies = [
+ "portable-atomic",
+ "rand 0.10.3",
+ "web-time",
+]
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "flume"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap 2.14.2",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac 0.12.1",
+]
+
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac 0.13.0",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-named-pipe"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf"
+dependencies = [
+ "hex",
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-timeout"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
+dependencies = [
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "hyperlocal"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
+dependencies = [
+ "hex",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "insta"
+version = "1.49.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67d3d2e287e4b86c10b3f3b641033d1f89b74bdb39d05f34952e2b9a6fe21cd"
+dependencies = [
+ "console",
+ "once_cell",
+ "similar",
+ "tempfile",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jiff"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json5"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c"
+dependencies = [
+ "serde",
+ "ucd-trie",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "11.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
+dependencies = [
+ "base64 0.22.1",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "hmac 0.12.1",
+ "js-sys",
+ "p256",
+ "p384",
+ "pem",
+ "rand 0.8.8",
+ "rsa",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "signature",
+ "simple_asn1",
+ "zeroize",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
+dependencies = [
+ "cfg-if",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.8",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http",
+ "rand 0.8.8",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2 0.10.9",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "parse-display"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a"
+dependencies = [
+ "parse-display-derive",
+ "regex",
+ "regex-syntax",
+]
+
+[[package]]
+name = "parse-display-derive"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "regex",
+ "regex-syntax",
+ "structmeta",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
+dependencies = [
+ "getrandom 0.4.3",
+ "phc",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phc"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
+dependencies = [
+ "base64ct",
+ "ctutils",
+ "getrandom 0.4.3",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proptest"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+dependencies = [
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.13.2",
+ "num-traits",
+ "rand 0.9.5",
+ "rand_chacha 0.9.0",
+ "rand_xorshift",
+ "regex-syntax",
+ "rusty-fork",
+ "tempfile",
+ "unarray",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.3",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.21",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_xorshift"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+dependencies = [
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "redis"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
+dependencies = [
+ "arc-swap",
+ "arcstr",
+ "async-lock",
+ "backon",
+ "bytes",
+ "cfg-if",
+ "combine",
+ "crc16",
+ "futures-channel",
+ "futures-util",
+ "itoa",
+ "log",
+ "num-bigint 0.5.1",
+ "percent-encoding",
+ "pin-project-lite",
+ "rand 0.10.3",
+ "rustls",
+ "rustls-native-certs",
+ "ryu",
+ "sha1_smol",
+ "socket2",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "url",
+ "webpki-roots",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "redis-test"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "804d36862e4323b69f96440cbb13c9894fc90176abdeaf91264e21d5d77f6aca"
+dependencies = [
+ "rand 0.9.5",
+ "redis",
+ "socket2",
+ "tempfile",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http 0.6.11",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac 0.12.1",
+ "subtle",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "ron"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
+dependencies = [
+ "bitflags 2.13.2",
+ "once_cell",
+ "serde",
+ "serde_derive",
+ "typeid",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid 0.9.6",
+ "digest 0.10.7",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rust-ini"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "rusty-fork"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
+dependencies = [
+ "fnv",
+ "quick-error",
+ "tempfile",
+ "wait-timeout",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
+dependencies = [
+ "base64 0.23.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.2",
+ "jiff",
+ "schemars 0.9.0",
+ "schemars 1.2.2",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint 0.4.8",
+ "num-traits",
+ "thiserror 2.0.21",
+ "time",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "cfg-if",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.16.1",
+ "hashlink",
+ "indexmap 2.14.2",
+ "log",
+ "memchr",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "smallvec",
+ "thiserror 2.0.21",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "uuid",
+ "webpki-roots",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3"
+dependencies = [
+ "cfg-if",
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn 2.0.119",
+ "thiserror 2.0.21",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27"
+dependencies = [
+ "bitflags 2.13.2",
+ "byteorder",
+ "bytes",
+ "crc",
+ "digest 0.11.3",
+ "dotenvy",
+ "either",
+ "futures-core",
+ "futures-util",
+ "generic-array",
+ "log",
+ "percent-encoding",
+ "serde",
+ "sha1",
+ "sha2 0.11.0",
+ "sqlx-core",
+ "thiserror 2.0.21",
+ "time",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
+dependencies = [
+ "atoi",
+ "base64 0.22.1",
+ "bitflags 2.13.2",
+ "byteorder",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf 0.13.0",
+ "hmac 0.13.0",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "rand 0.10.3",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror 2.0.21",
+ "time",
+ "tracing",
+ "uuid",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c"
+dependencies = [
+ "atoi",
+ "flume",
+ "form_urlencoded",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "sqlx-core",
+ "thiserror 2.0.21",
+ "time",
+ "tracing",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "structmeta"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "structmeta-derive",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "structmeta-derive"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "testcontainers"
+version = "0.27.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e"
+dependencies = [
+ "astral-tokio-tar",
+ "async-trait",
+ "bollard",
+ "bytes",
+ "docker_credential",
+ "either",
+ "etcetera",
+ "ferroid",
+ "futures",
+ "http",
+ "itertools",
+ "log",
+ "memchr",
+ "parse-display",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "thiserror 2.0.21",
+ "tokio",
+ "tokio-stream",
+ "tokio-util",
+ "url",
+]
+
+[[package]]
+name = "testcontainers-modules"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357"
+dependencies = [
+ "testcontainers",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+dependencies = [
+ "indexmap 2.14.2",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tonic"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
+dependencies = [
+ "async-trait",
+ "axum",
+ "base64 0.22.1",
+ "bytes",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-timeout",
+ "hyper-util",
+ "percent-encoding",
+ "pin-project",
+ "socket2",
+ "sync_wrapper",
+ "tokio",
+ "tokio-stream",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tonic-prost"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
+dependencies = [
+ "bytes",
+ "prost",
+ "tonic",
+]
+
+[[package]]
+name = "totp-rs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1"
+dependencies = [
+ "base32",
+ "constant_time_eq",
+ "hmac 0.13.0",
+ "rand 0.10.3",
+ "serde",
+ "sha1",
+ "sha2 0.11.0",
+ "zeroize",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "indexmap 2.14.2",
+ "pin-project-lite",
+ "slab",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "http",
+ "http-body",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "serde",
+ "serde_json",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-serde",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "unarray"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+dependencies = [
+ "base64 0.23.1",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf8-zero",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "validator"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81"
+dependencies = [
+ "idna",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "url",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "rusticata-macros",
+ "thiserror 2.0.21",
+ "time",
+]
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
+
+[[package]]
+name = "yaml-rust2"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
+dependencies = [
+ "arraydeque",
+ "encoding_rs",
+ "hashlink",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure 0.14.0",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure 0.14.0",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

+ 84 - 0
Cargo.toml

@@ -0,0 +1,84 @@
+[package]
+name = "d-oauth"
+version = "0.1.0"
+edition = "2024"
+description = "OAuth 2.1-only authorization server"
+license = "MIT OR Apache-2.0"
+
+[lib]
+name = "d_oauth"
+path = "src/lib.rs"
+
+[[bin]]
+name = "d-oauth-server"
+path = "src/main.rs"
+
+[dependencies]
+argon2 = "0.6.0"
+askama = "0.16.1"
+axum = "0.8"
+axum-extra = { version = "0.12.6", features = ["cookie"] }
+backon = "1.6.0"
+base32 = "0.5.1"
+base64 = "0.23.1"
+base64ct = "1.8.3"
+clap = { version = "4.6.7", features = ["derive"] }
+config = "0.15.27"
+hmac = "0.13.0"
+hyper = "1.11.1"
+hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
+jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] }
+password-hash = "0.6.1"
+rand = { version = "0.10.3", features = ["std_rng"] }
+redis = { version = "1.7.1", features = ["tokio-comp", "connection-manager", "sentinel", "cluster-async", "tls-rustls", "tokio-rustls-comp", "tls-rustls-webpki-roots"] }
+# Task 22: the JAR validator fetches pre-registered `request_uri` objects
+# through an SSRF-guarded client (https-only, no redirects, size/timeout
+# caps). Pinned spec mirrors the existing dev entry; the dev copy is kept
+# so test-only users of reqwest keep compiling.
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "json"] }
+ring = "0.17"
+rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] }
+rustls-pki-types = "1.15.1"
+secrecy = "0.10.3"
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+sha2 = "0.11.0"
+sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "migrate", "macros", "uuid", "time", "tls-rustls-ring-webpki"] }
+subtle = "2.6.1"
+thiserror = "2.0.21"
+time = "0.3.55"
+tokio = { version = "1.53.2", features = ["full", "macros", "rt-multi-thread", "net", "signal", "time"] }
+tokio-rustls = { version = "0.26.6", default-features = false, features = ["ring", "tls12", "logging"] }
+totp-rs = "6.0.0"
+tower = { version = "0.5.3", features = ["util", "timeout", "limit", "load-shed"] }
+tower-http = { version = "0.7.1", features = ["cors", "limit"] }
+tracing = "0.1.44"
+tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] }
+uuid = { version = "1.27.0", features = ["v4", "v7"] }
+validator = "0.21.0"
+x509-parser = "0.18.1"
+zeroize = "1.9.0"
+
+[dev-dependencies]
+http-body-util = "0.1.5"
+insta = "1.49.0"
+oauth2 = { version = "5.0.0", default-features = false, features = ["reqwest", "rustls-tls"] }
+proptest = "1.11.0"
+redis-test = "1.0.4"
+testcontainers = "0.27"
+testcontainers-modules = "0.15.0"
+tower = { version = "0.5.3", features = ["util"] }
+
+[lints.rust]
+unsafe_code = "forbid"
+
+[lints.clippy]
+unwrap_used = "deny"
+expect_used = "deny"
+panic = "deny"
+
+[profile.release]
+opt-level = 3
+lto = "thin"
+codegen-units = 1
+strip = "symbols"

+ 82 - 0
Dockerfile

@@ -0,0 +1,82 @@
+# Minimal static-musl image for the d-OAuth OAuth 2.1 authorization server.
+#
+# 4 stages: chef -> planner -> builder -> runtime.
+# - `chef`: pinned rust toolchain + cargo-chef + musl targets. No DB needed:
+#   `SQLX_OFFLINE=true` builds against the committed `.sqlx/` query cache.
+# - `planner`: captures the dependency recipe so dependency layers cache.
+# - `builder`: cooks dependencies, then builds the locked, stripped,
+#   fully-static musl binary (rustls `ring` provider only; no
+#   aws-lc-sys/OpenSSL anywhere in the graph).
+# - `runtime`: pinned-digest distroless `static-debian12:nonroot` (no shell,
+#   no package manager, CA bundle + tzdata + nonroot /etc/passwd entry
+#   included). Only the stripped binary is copied in.
+#
+# BuildKit cache mounts keep the cargo registry/git cache and the target
+# directory warm across rebuilds without baking them into any layer.
+
+# Pinned bases (multi-arch index digests; buildx selects per-platform):
+# - rust 1.99-bookworm (matches rust-toolchain.toml stable; rustc 1.99.0)
+# - distroless static-debian12:nonroot (UID:GID 65532, no shell)
+ARG RUST_BASE=docker.io/library/rust:1.99-bookworm@sha256:59037199c44290f2befcdd58dcc540164763fc296950255aaefeef096a1866b0
+ARG RUNTIME_BASE=gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
+
+FROM ${RUST_BASE} AS chef
+WORKDIR /app
+# Reproducible builds: fixed epoch (overridden by bake) and no incremental.
+ARG SOURCE_DATE_EPOCH=0
+ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
+  SQLX_OFFLINE=true \
+  CARGO_INCREMENTAL=0 \
+  CARGO_NET_RETRY=10
+RUN cargo install cargo-chef --locked --version 0.1.71 \
+  && apt-get update \
+  && apt-get install -y --no-install-recommends musl-tools \
+  && rm -rf /var/lib/apt/lists/* \
+  && rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
+
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+FROM chef AS builder
+ARG TARGETARCH
+COPY --from=planner /app/recipe.json recipe.json
+# Cook (compile) third-party dependencies only: this layer reuses the cache
+# unless Cargo.toml/Cargo.lock change.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git \
+    --mount=type=cache,target=/app/target \
+    sh -c 'if [ "$TARGETARCH" = "arm64" ]; then export RUST_TARGET=aarch64-unknown-linux-musl; else export RUST_TARGET=x86_64-unknown-linux-musl; fi && \
+    rustup target add "$RUST_TARGET" && \
+    cargo chef cook --locked --release --target "$RUST_TARGET" --recipe-path recipe.json --bin d-oauth-server'
+COPY . .
+# Locked, offline release build of the server binary only. The target dir is
+# a cache mount, so the binary is copied out to /app/dist in the same layer.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git \
+    --mount=type=cache,target=/app/target \
+    sh -c 'if [ "$TARGETARCH" = "arm64" ]; then export RUST_TARGET=aarch64-unknown-linux-musl; else export RUST_TARGET=x86_64-unknown-linux-musl; fi && \
+    rustup target add "$RUST_TARGET" && \
+    cargo build --locked --release --target "$RUST_TARGET" --bin d-oauth-server && \
+    mkdir -p /app/dist && \
+    cp "/app/target/$RUST_TARGET/release/d-oauth-server" /app/dist/d-oauth-server'
+
+FROM ${RUNTIME_BASE} AS runtime
+# Only the stripped static binary (release profile: lto thin, strip symbols).
+COPY --from=builder /app/dist/d-oauth-server /d-oauth-server
+# Non-root, exec-form entrypoint (no shell in the image to interpret one).
+USER 65532:65532
+# Public TLS listener (default bind_addr 0.0.0.0:8080). The plaintext health
+# listener (default 0.0.0.0:8081, pod IP) is intentionally NOT published: it
+# is probed by kubelet/HEALTHCHECK from inside the pod network only.
+EXPOSE 8080
+# Shell-less self-probe: the `healthcheck` subcommand speaks raw HTTP/1.1
+# over loopback to the plaintext health listener (no shell, no TLS trust).
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/d-oauth-server", "healthcheck"]
+ENTRYPOINT ["/d-oauth-server", "serve"]
+LABEL org.opencontainers.image.title="d-oauth-server" \
+  org.opencontainers.image.description="OAuth 2.1-only authorization server (static musl, non-root, no shell)" \
+  org.opencontainers.image.licenses="MIT OR Apache-2.0" \
+  org.opencontainers.image.source="https://github.com/d-oauth/d-oauth" \
+  org.opencontainers.image.revision="unknown" \
+  org.opencontainers.image.vendor="d-oauth"

+ 5 - 0
LICENSE.md

@@ -0,0 +1,5 @@
+Copyright 2026 Syavne.Net
+
+Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

+ 252 - 0
README.md

@@ -0,0 +1,252 @@
+# d-OAuth
+
+An OAuth 2.1-only authorization server written in Rust. It is deliberately not
+backwards compatible with OAuth 2.0: PKCE is mandatory, the implicit and
+resource-owner-password grants do not exist, bearer-in-query is rejected, and
+redirect URIs match exactly.
+
+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. All durable domain state is in PostgreSQL and all ephemeral
+state is in Redis, so replicas are interchangeable.
+
+- Protocol and deltas: [docs/OAUTH.md](docs/OAUTH.md)
+- Threat model and token design: [docs/SECURITY.md](docs/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)
+- Contributor guide: [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md)
+- Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md)
+- Admin bootstrap procedure: [RUNBOOK.md](RUNBOOK.md)
+
+## Prerequisites
+
+- Rust stable, as pinned by `rust-toolchain.toml`.
+- PostgreSQL 14 or newer with the `citext` extension available.
+- Redis 7.2 or newer, with persistence enabled and eviction off.
+- A TLS certificate and key for the public listener.
+- Docker, for the container image and for the integration tests that use
+  testcontainers.
+- `cargo-nextest` for the test suite:
+  `cargo install cargo-nextest --locked`.
+
+## Build
+
+```bash
+cargo build --locked
+cargo build --locked --release
+```
+
+## 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).
+
+```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'
+export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random'
+```
+
+Development relaxes the database TLS requirement. Production requires
+`sslmode=verify-full` and a non-localhost `issuer`.
+
+Validate the configuration, then apply the schema once:
+
+```bash
+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 public client must present a DPoP proof with the token request; a
+   confidential client authenticates with its secret or an mTLS certificate.
+
+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:
+
+```bash
+cargo nextest run --all-targets
+cargo test --doc
+```
+
+The integration and end-to-end tests start real Postgres and Redis containers,
+so 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 audit
+cargo deny check
+python3 scripts/check_doc_links.py
+```
+
+## 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
+
+docker run --rm -p 8080:8080 --read-only \
+  --env-file ./oauth.env \
+  -v /path/to/tls:/certs:ro \
+  d-oauth-server:latest
+```
+
+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).
+
+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).
+
+For a local multi-replica proof, the deployment assets provide a compose file
+that 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. If your checkout does not include the compose
+file yet, it ships with the cluster manifests; see
+[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md):
+
+```bash
+docker compose 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. Known gaps
+  against that draft are listed in [docs/OAUTH.md](docs/OAUTH.md).
+- There is no default credential and no HTTP admin API. Provision through the
+  CLI.
+- The token pepper and TOTP encryption key are required; losing the pepper
+  invalidates every live opaque token.
+- Production requires `issuer` on `https` and 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.
+- 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).
+
+## CLI pointers
+
+- 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`. Each accepts
+  `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
+  the return targets for `GET /logout`.
+- Users and entitlements: `user create|enroll-totp|disable|grant-scope|revoke-scope|scopes`.
+- Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
+- Declarative apply: `config sync --file` and `config check`.
+- DCR initial access: `initial-access-token issue`.
+- Utilities: `keygen` and `healthcheck`.
+
+Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
+
+## License
+
+MIT OR Apache-2.0. See [LICENSE.md](LICENSE.md).

+ 86 - 0
RUNBOOK.md

@@ -0,0 +1,86 @@
+# Admin Bootstrap Operator Runbook
+
+How to create the first administrator of a fresh `d-oauth` install, and how
+to replace that bootstrap account immediately. 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.
+
+## Prerequisites
+
+- Migrations applied exactly once via the migration Job (never per replica):
+  `d-oauth-server migrate`
+- Zero enabled admins exist (a migrated database seeds zero users).
+
+## 1. Bootstrap the first admin
+
+Interactive (password read from stdin, never argv, never logged):
+
+```sh
+d-oauth-server admin bootstrap --email ops@example.com
+```
+
+Non-interactive / cluster Job (mounted secret file):
+
+```sh
+d-oauth-server admin bootstrap --email ops@example.com \
+  --password-file /run/secrets/bootstrap-password
+```
+
+When neither a file nor stdin yields a password, the command generates a
+256-bit password and prints it exactly once, and only to a TTY. If stdout is
+not a TTY the command refuses instead of risking the secret in Job logs:
+re-run attached to a terminal or supply `--password-file`.
+
+A second `admin bootstrap` refuses while any enabled admin exists:
+
+```sh
+d-oauth-server admin bootstrap --email second@example.com
+# error: already exists: an administrator already exists (1); ...
+```
+
+The override requires explicit `--force`, prints a loud warning to stderr,
+and emits an audited `admin.bootstrap.force` event. Prefer routine
+provisioning instead:
+
+```sh
+d-oauth-server admin bootstrap --email second@example.com --force
+d-oauth-server user create --admin --email named-admin@example.com
+```
+
+## 2. Replace the bootstrap admin immediately
+
+The bootstrap account starts with `must_change_password=true` and
+`totp_enabled=false`. Its first login yields a restricted session that can
+only change the password and enroll TOTP — `/authorize`, `/oauth/*`, and
+admin-gated actions deny it until both steps complete.
+
+- [ ] Log in as the bootstrap admin; you land on `/login/change-password`.
+- [ ] Change the password (minimum 12 characters). This clears
+      `must_change_password` and stamps `password_changed_at`.
+- [ ] Enroll TOTP at `/login/totp/setup` and confirm with an authenticator
+      code. The session is re-issued with full authority only now.
+- [ ] Capture the recovery codes shown once at enrollment; store them offline.
+- [ ] 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`
+- [ ] Rotate/expire the bootstrap secret: delete the mounted
+      `/run/secrets/bootstrap-password` material and revoke any copy of a
+      generated one-time password.
+
+## 3. Audit events
+
+On the `audit` target (never carrying secrets): `admin.bootstrap.created`
+and `admin.count.changed` on success, `admin.bootstrap.refused` on refusal,
+`admin.bootstrap.force` on a forced override. First-login, password-changed,
+and TOTP-enrolled outcomes are emitted by the login flow itself.
+
+## 4. What never happens
+
+- No default or hardcoded admin credential ships; `migrate` seeds zero users
+  and `serve` never creates one.
+- The bootstrap password is never accepted via argv, never logged, and never
+  stored (only its Argon2id hash reaches Postgres).
+- Bootstrap is CLI/Job only; no HTTP endpoint can create an admin.

+ 149 - 0
compose.yaml

@@ -0,0 +1,149 @@
+# Local multi-replica validation (task 46).
+#
+# Three stateless app replicas against ONE Postgres + ONE Redis — the local
+# analogue of `deploy/k8s`. Proves shared-state behavior: a token issued by
+# one replica is usable/introspectable via another, and migrations apply
+# exactly once via the one-shot `migrate` service (never per replica).
+#
+#   docker compose up --build --scale app=3
+#   docker compose port app 8080   # per-replica host ports (assigned randomly)
+#
+# Validation sketch (public listener is TLS with a throwaway self-signed
+# cert, so use -sk; `<port-N>` from `docker compose port`):
+#
+#   curl -sk https://127.0.0.1:<port-1>/.well-known/oauth-authorization-server
+#   # complete an authorization-code+PKCE flow against replica 1, then
+#   # introspect the token against replica 2 — shared Redis makes it work.
+#
+# Host ports are assigned RANDOMLY per replica (short-syntax `"8080"`), so
+# `--scale app=3` never collides. The health listener (8081) is exposed, not
+# published — same rule as the cluster Service.
+#
+# mTLS is OFF here (no `D_OAUTH_TLS__CLIENT_CA_PATH`): the L4-passthrough
+# constraint is a cluster concern, documented in docs/DEPLOYMENT.md.
+# Secrets below are DEV-ONLY placeholders; cluster secrets are never baked.
+
+name: d-oauth
+
+services:
+  db:
+    image: postgres:16
+    environment:
+      POSTGRES_USER: oauth
+      POSTGRES_PASSWORD: oauth-dev-only
+      POSTGRES_DB: oauth
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U oauth -d oauth"]
+      interval: 5s
+      timeout: 3s
+      retries: 20
+
+  redis:
+    image: redis:7
+    # The server asserts this contract at startup: noeviction + AOF.
+    command: ["redis-server", "--appendonly", "yes", "--maxmemory-policy", "noeviction"]
+    volumes:
+      - redisdata:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "ping"]
+      interval: 5s
+      timeout: 3s
+      retries: 20
+
+  # One-shot: throwaway self-signed server cert for the local TLS listener.
+  gencerts:
+    image: alpine:3.21
+    volumes:
+      - certs:/certs
+    command:
+      - sh
+      - -ec
+      - |
+        apk add --no-cache openssl >/dev/null
+        if [ ! -f /certs/tls.crt ]; then
+          openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
+            -keyout /certs/tls.key -out /certs/tls.crt \
+            -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
+        fi
+        chown 65532:65532 /certs/tls.crt /certs/tls.key
+        chmod 600 /certs/tls.key
+        chmod 644 /certs/tls.crt
+
+  # Exactly-once migration (sqlx advisory lock). Replicas never migrate.
+  migrate:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: d-oauth-server:local
+    entrypoint: ["/d-oauth-server"]
+    command: ["migrate"]
+    depends_on:
+      gencerts:
+        condition: service_completed_successfully
+      db:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+    environment:
+      D_OAUTH_ISSUER: https://localhost:8080
+      D_OAUTH_ENVIRONMENT: development
+      D_OAUTH_BIND_ADDR: 0.0.0.0:8080
+      D_OAUTH_HEALTH_ADDR: 0.0.0.0:8081
+      D_OAUTH_TLS__CERT_PATH: /certs/tls.crt
+      D_OAUTH_TLS__KEY_PATH: /certs/tls.key
+      D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
+      D_OAUTH_REDIS__URL: redis://redis:6379
+      D_OAUTH_REDIS__DURABILITY: waitaof
+      D_OAUTH_TOKEN_PEPPER: dev-only-pepper-ROTATE-IN-PROD-000000
+      D_OAUTH_TOTP_ENCRYPTION_KEY: dev-only-totp-key-ROTATE-IN-PROD-000
+    volumes:
+      - certs:/certs:ro
+
+  app:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: d-oauth-server:local
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+      db:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+    environment:
+      D_OAUTH_ISSUER: https://localhost:8080
+      D_OAUTH_ENVIRONMENT: development
+      D_OAUTH_BIND_ADDR: 0.0.0.0:8080
+      D_OAUTH_HEALTH_ADDR: 0.0.0.0:8081
+      D_OAUTH_TLS__CERT_PATH: /certs/tls.crt
+      D_OAUTH_TLS__KEY_PATH: /certs/tls.key
+      D_OAUTH_DATABASE__URL: postgres://oauth:oauth-dev-only@db:5432/oauth?sslmode=disable
+      D_OAUTH_REDIS__URL: redis://redis:6379
+      D_OAUTH_REDIS__DURABILITY: waitaof
+      D_OAUTH_TOKEN_PEPPER: dev-only-pepper-ROTATE-IN-PROD-000000
+      D_OAUTH_TOTP_ENCRYPTION_KEY: dev-only-totp-key-ROTATE-IN-PROD-000
+    expose:
+      - 8080
+      - 8081
+    # Random host ports per replica: `--scale app=3` never collides.
+    ports:
+      - "8080"
+      - "8081"
+    read_only: true
+    # Shell-less self-probe: no shell or curl in the distroless image.
+    healthcheck:
+      test: ["CMD", "/d-oauth-server", "healthcheck"]
+      interval: 5s
+      timeout: 3s
+      retries: 20
+      start_period: 10s
+    volumes:
+      - certs:/certs:ro
+
+volumes:
+  pgdata:
+  redisdata:
+  certs:

+ 53 - 0
deny.toml

@@ -0,0 +1,53 @@
+# Supply-chain gates for the OAuth 2.1 authorization server (task 42).
+#
+# Ring-only TLS: `openssl-sys`, `aws-lc-sys`, and `native-tls` must never enter
+# the graph (see the `bans` deny list below). `cargo deny check` runs in CI.
+
+[advisories]
+db-urls = ["https://github.com/rustsec/advisory-db"]
+yanked = "deny"
+# RUSTSEC-2023-0071 (rsa Marvin timing): no patched release exists upstream
+# ("No safe upgrade is available"); this crate uses RSA for signature
+# verification only (JWK-selected `DecodingKey`), never RSA decryption, so
+# the padding-timing oracle does not apply. Re-evaluate when rsa ships a fix.
+ignore = [
+  { id = "RUSTSEC-2023-0071", reason = "unpatched upstream; RSA verify-only, never decryption" },
+]
+
+[bans]
+multiple-versions = "warn"
+wildcards = "deny"
+highlight = "all"
+# Ring-only TLS policy: no OpenSSL, no AWS-LC, no platform TLS anywhere in
+# the graph (main or dev). A duplicated dependency that pulls one of these
+# in fails CI until the duplicate is removed.
+deny = [
+  { crate = "openssl-sys", reason = "ring-only TLS: no OpenSSL anywhere in the graph" },
+  { crate = "aws-lc-sys", reason = "ring-only TLS: no AWS-LC anywhere in the graph" },
+  { crate = "native-tls", reason = "ring-only TLS: no platform TLS anywhere in the graph" },
+]
+
+[licenses]
+confidence-threshold = 0.93
+allow = [
+  "MIT",
+  "Apache-2.0",
+  "ISC",
+  "BSD-2-Clause",
+  "BSD-3-Clause",
+  "CC0-1.0",
+  "BSL-1.0",
+  "Zlib",
+  "OpenSSL",
+  "Unicode-3.0",
+  "Unicode-DFS-2016",
+  "MPL-2.0",
+  # Mozilla root-store data (via webpki-roots); OSI-approved data license.
+  "CDLA-Permissive-2.0",
+]
+exceptions = []
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]

+ 29 - 0
deploy/k8s/configmap.yaml

@@ -0,0 +1,29 @@
+# Non-secret server settings (task 46).
+#
+# Every key maps to a `D_OAUTH_*` environment variable read by
+# `Config::load` (prefix `D_OAUTH`, nested separator `__`). Secret material
+# (database/redis URLs, token pepper, TOTP key) lives in `secret.yaml` and
+# is NEVER added here.
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: d-oauth-config
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+data:
+  D_OAUTH_ISSUER: "https://auth.example.com"
+  D_OAUTH_ENVIRONMENT: "production"
+  D_OAUTH_BIND_ADDR: "0.0.0.0:8080"
+  D_OAUTH_HEALTH_ADDR: "0.0.0.0:8081"
+  D_OAUTH_TLS__CERT_PATH: "/certs/tls.crt"
+  D_OAUTH_TLS__KEY_PATH: "/certs/tls.key"
+  # Client-CA enables in-process mTLS verification. While set, the cluster
+  # MUST use L4/TCP passthrough (NLB gateway / TLSRoute); an L7 ingress that
+  # terminates TLS strips the client certificate. See docs/DEPLOYMENT.md and
+  # scripts/check_manifests.py, which rejects the forbidden pairing.
+  D_OAUTH_TLS__CLIENT_CA_PATH: "/certs/client-ca.crt"
+  # Single-Redis topology takes a plain URL; the contract the server asserts
+  # at startup is noeviction + AOF, configured on the Redis side.
+  D_OAUTH_REDIS__DURABILITY: "waitaof"

+ 145 - 0
deploy/k8s/deployment.yaml

@@ -0,0 +1,145 @@
+# Stateless multi-replica Deployment (task 46).
+#
+# - 3 replicas sharing one Postgres + one Redis: no replica holds state
+#   another needs (tokens live in Redis under `{state}`-tagged keys).
+# - `serve` NEVER migrates; the separate `migrate-job.yaml` owns the schema.
+#   Readiness (`/health/ready`) compares `_sqlx_migrations` against the
+#   binary's embedded migrations, so new replicas stay out of traffic until
+#   the Job has applied the schema (expand/contract keeps N-1 serving).
+# - Probes hit the PLAINTEXT pod-IP health listener (`health_addr`,
+#   default `0.0.0.0:8081`); the public listener stays TLS-only.
+# - `terminationGracePeriodSeconds: 45` covers the server's 1s propagation
+#   grace + 30s drain budget (`src/serve.rs`: PROPAGATION_GRACE, DRAIN_DEADLINE).
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: d-oauth-server
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+spec:
+  replicas: 3
+  strategy:
+    type: RollingUpdate
+    rollingUpdate:
+      # Keep every old replica serving while the migration Job + new
+      # replicas converge (expand/contract upgrades).
+      maxUnavailable: 0
+      maxSurge: 1
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: d-oauth-server
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: d-oauth-server
+        app.kubernetes.io/part-of: d-oauth
+    spec:
+      terminationGracePeriodSeconds: 45
+      securityContext:
+        runAsNonRoot: true
+        runAsUser: 65532
+        runAsGroup: 65532
+        seccompProfile:
+          type: RuntimeDefault
+      containers:
+        - name: server
+          # Release pinning: CI resolves this tag to an immutable digest
+          # (`kustomize edit set image ...@sha256:<digest>`) before rollout;
+          # see `.github/workflows/supply-chain.yml` and `kustomization.yaml`.
+          image: ghcr.io/d-oauth/d-oauth-server:latest
+          imagePullPolicy: IfNotPresent
+          ports:
+            - name: public-tls
+              containerPort: 8080
+              protocol: TCP
+            - name: health
+              containerPort: 8081
+              protocol: TCP
+          envFrom:
+            - configMapRef:
+                name: d-oauth-config
+          env:
+            - name: D_OAUTH_DATABASE__URL
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: database-url
+            - name: D_OAUTH_REDIS__URL
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: redis-url
+            - name: D_OAUTH_TOKEN_PEPPER
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: token-pepper
+            - name: D_OAUTH_TOTP_ENCRYPTION_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: totp-encryption-key
+          # Liveness is dependency-free by construction (`/health/live`
+          # touches no Postgres/Redis), so a dependency outage never
+          # triggers a restart storm — readiness absorbs it instead.
+          startupProbe:
+            httpGet:
+              path: /health/live
+              port: 8081
+            failureThreshold: 30
+            periodSeconds: 2
+          livenessProbe:
+            httpGet:
+              path: /health/live
+              port: 8081
+            periodSeconds: 10
+            timeoutSeconds: 2
+            failureThreshold: 3
+          # Fail-closed: 503 while Postgres/Redis are unreachable OR the
+          # `_sqlx_migrations` version does not match the binary (i.e. the
+          # migrate Job has not run yet on a fresh/rolling deploy).
+          readinessProbe:
+            httpGet:
+              path: /health/ready
+              port: 8081
+            periodSeconds: 5
+            timeoutSeconds: 2
+            failureThreshold: 3
+            successThreshold: 1
+          resources:
+            requests:
+              cpu: 100m
+              memory: 128Mi
+            limits:
+              cpu: "1"
+              memory: 512Mi
+          securityContext:
+            allowPrivilegeEscalation: false
+            readOnlyRootFilesystem: true
+            capabilities:
+              drop:
+                - ALL
+          volumeMounts:
+            - name: certs
+              mountPath: /certs
+              readOnly: true
+      volumes:
+        - name: certs
+          secret:
+            secretName: d-oauth-tls
+            defaultMode: 0o400
+      topologySpreadConstraints:
+        - maxSkew: 1
+          topologyKey: topology.kubernetes.io/zone
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app.kubernetes.io/name: d-oauth-server
+        - maxSkew: 1
+          topologyKey: kubernetes.io/hostname
+          whenUnsatisfiable: ScheduleAnyway
+          labelSelector:
+            matchLabels:
+              app.kubernetes.io/name: d-oauth-server

+ 29 - 0
deploy/k8s/kustomization.yaml

@@ -0,0 +1,29 @@
+# Kustomize entrypoint (task 46).
+#
+# Validate: `kustomize build deploy/k8s`
+# Dry-run against a cluster: `kubectl apply --dry-run=server -k deploy/k8s`
+#
+# Release pinning: CI resolves the floating tags below to immutable digests
+# (`kustomize edit set image d-oauth-server=ghcr.io/...@sha256:<digest>`)
+# after `docker buildx bake` pushes, so a rollout can never float. The
+# committed file intentionally carries tags; the pinned build is what ships.
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: d-oauth
+resources:
+  - namespace.yaml
+  - configmap.yaml
+  - secret.yaml
+  - service.yaml
+  - deployment.yaml
+  - migrate-job.yaml
+  - pdb.yaml
+images:
+  - name: ghcr.io/d-oauth/d-oauth-server
+    newName: ghcr.io/d-oauth/d-oauth-server
+    newTag: latest
+# NOTE: no Ingress resource is shipped on purpose. In-process mTLS
+# verification needs L4/TCP passthrough (NLB gateway / TLSRoute); an L7
+# ingress that terminates TLS strips the client certificate. Adding an
+# Ingress while `D_OAUTH_TLS__CLIENT_CA_PATH` is set fails
+# `scripts/check_manifests.py`. See docs/DEPLOYMENT.md.

+ 88 - 0
deploy/k8s/migrate-job.yaml

@@ -0,0 +1,88 @@
+# Exactly-once schema migration Job (task 46).
+#
+# Run BEFORE the Deployment rolls (CI applies the Job, waits for completion,
+# then rolls the Deployment). `migrate` applies forward-only sqlx migrations
+# under Postgres advisory lock, so concurrent starts serialize and the schema
+# is applied exactly once — replicas MUST NOT migrate (they share one
+# database; per-replica migration races and double-applies). The Deployment's
+# readiness gate (`_sqlx_migrations` version match) keeps new replicas out of
+# traffic until this Job succeeds.
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: d-oauth-migrate
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+spec:
+  backoffLimit: 3
+  activeDeadlineSeconds: 300
+  ttlSecondsAfterFinished: 3600
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/name: d-oauth-server
+        app.kubernetes.io/part-of: d-oauth
+        app.kubernetes.io/component: migrate
+    spec:
+      restartPolicy: Never
+      securityContext:
+        runAsNonRoot: true
+        runAsUser: 65532
+        runAsGroup: 65532
+        seccompProfile:
+          type: RuntimeDefault
+      containers:
+        - name: migrate
+          # Same pinning rule as the Deployment: CI resolves to a digest.
+          image: ghcr.io/d-oauth/d-oauth-server:latest
+          imagePullPolicy: IfNotPresent
+          # Overrides the image ENTRYPOINT (["/d-oauth-server","serve"]).
+          command: ["/d-oauth-server", "migrate"]
+          envFrom:
+            - configMapRef:
+                name: d-oauth-config
+          env:
+            - name: D_OAUTH_DATABASE__URL
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: database-url
+            - name: D_OAUTH_REDIS__URL
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: redis-url
+            - name: D_OAUTH_TOKEN_PEPPER
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: token-pepper
+            - name: D_OAUTH_TOTP_ENCRYPTION_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: d-oauth-secrets
+                  key: totp-encryption-key
+          resources:
+            requests:
+              cpu: 50m
+              memory: 64Mi
+            limits:
+              cpu: 500m
+              memory: 256Mi
+          securityContext:
+            allowPrivilegeEscalation: false
+            readOnlyRootFilesystem: true
+            capabilities:
+              drop:
+                - ALL
+          volumeMounts:
+            - name: certs
+              mountPath: /certs
+              readOnly: true
+      volumes:
+        - name: certs
+          secret:
+            secretName: d-oauth-tls
+            defaultMode: 0o400

+ 8 - 0
deploy/k8s/namespace.yaml

@@ -0,0 +1,8 @@
+# Namespace for the OAuth 2.1 authorization server (task 46).
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth

+ 18 - 0
deploy/k8s/pdb.yaml

@@ -0,0 +1,18 @@
+# PodDisruptionBudget: voluntary disruptions keep quorum (task 46).
+#
+# 3 replicas with `minAvailable: 2`: drains, node upgrades, and rolling
+# deploys may take at most one replica at a time, so an
+# authorization-code+PKCE flow always has a quorum serving it.
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: d-oauth-server
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+spec:
+  minAvailable: 2
+  selector:
+    matchLabels:
+      app.kubernetes.io/name: d-oauth-server

+ 32 - 0
deploy/k8s/secret.yaml

@@ -0,0 +1,32 @@
+# App secrets (task 46). PLACEHOLDERS ONLY — never commit real values.
+#
+# Production rule: this file's keys are replaced by the secret manager
+# (external-secrets / sealed-secrets / vault agent). The manifests only ever
+# reference keys via `secretKeyRef`; `scripts/check_manifests.py` fails the
+# build if a value still looks like a placeholder reached a release, and
+# fails if any secret-looking value appears in a ConfigMap, Deployment, or
+# Job field.
+#
+# To provision: `kubectl create secret generic d-oauth-secrets -n d-oauth
+#   --from-literal=database-url='postgres://...' ...` (or the external-secret
+# equivalent), then delete this placeholder file from the applied set.
+apiVersion: v1
+kind: Secret
+metadata:
+  name: d-oauth-secrets
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+type: Opaque
+stringData:
+  # Full Postgres URL. Production requires `sslmode=verify-full`.
+  database-url: "REPLACE_ME__postgres-url-with-sslmode-verify-full"
+  # Redis single-topology URL (no password embedded for cluster use; prefer
+  # the password-file mechanism when auth is enabled).
+  redis-url: "REPLACE_ME__redis-url"
+  # 256-bit server-side pepper for opaque-token hashing. Losing it invalidates
+  # every live token; rotate per docs/OPERATIONS.md.
+  token-pepper: "REPLACE_ME__32-bytes-of-random"
+  # 256-bit key encrypting TOTP secrets at rest.
+  totp-encryption-key: "REPLACE_ME__32-bytes-of-random"

+ 24 - 0
deploy/k8s/service.yaml

@@ -0,0 +1,24 @@
+# ClusterIP Service for the public TLS listener only (task 46).
+#
+# Port 8080 is the TLS public listener. The plaintext health listener (8081)
+# is intentionally NOT exposed here: kubelet probes reach it via the pod IP,
+# and external traffic must never hit it. mTLS deployments sit behind an
+# L4/TCP passthrough (NLB gateway / TLSRoute), never an L7 ingress that
+# terminates TLS — see docs/DEPLOYMENT.md.
+apiVersion: v1
+kind: Service
+metadata:
+  name: d-oauth-server
+  namespace: d-oauth
+  labels:
+    app.kubernetes.io/name: d-oauth-server
+    app.kubernetes.io/part-of: d-oauth
+spec:
+  type: ClusterIP
+  selector:
+    app.kubernetes.io/name: d-oauth-server
+  ports:
+    - name: public-tls
+      port: 8080
+      targetPort: 8080
+      protocol: TCP

+ 25 - 0
deploy/k8s/tls-secret.example.yaml

@@ -0,0 +1,25 @@
+# TLS + client-CA material (task 46). EXAMPLE ONLY — not in kustomization.
+#
+# The Deployment mounts the `d-oauth-tls` Secret read-only at `/certs`.
+# Provision it with cert-manager (preferred) or create it by hand:
+#
+#   kubectl create secret generic d-oauth-tls -n d-oauth \
+#     --from-file=tls.crt=./tls.crt \
+#     --from-file=tls.key=./tls.key \
+#     --from-file=client-ca.crt=./client-ca.crt
+#
+# `client-ca.crt` is REQUIRED while `D_OAUTH_TLS__CLIENT_CA_PATH` is set in
+# the ConfigMap (in-process mTLS verification). Omit the file AND unset the
+# env key together if mTLS is not used. Never bake this material into the
+# image; `scripts/check_manifests.py` fails on PEM blocks inside manifests.
+# ---
+# apiVersion: v1
+# kind: Secret
+# metadata:
+#   name: d-oauth-tls
+#   namespace: d-oauth
+# type: kubernetes.io/tls
+# stringData:
+#   tls.crt: "<server certificate PEM>"
+#   tls.key: "<server private key PEM>"
+#   client-ca.crt: "<client CA bundle PEM>"

+ 24 - 0
docker-bake.hcl

@@ -0,0 +1,24 @@
+# Bake definition: multi-arch static image (task 45).
+# Usage: `docker buildx bake` (both arches) or
+#   `docker buildx bake image --set image.platform=linux/amd64` (one arch).
+variable "TAG" {
+  default = "latest"
+}
+
+variable "SOURCE_DATE_EPOCH" {
+  default = "0"
+}
+
+group "default" {
+  targets = ["image"]
+}
+
+target "image" {
+  dockerfile = "Dockerfile"
+  context    = "."
+  platforms  = ["linux/amd64", "linux/arm64"]
+  tags       = ["d-oauth-server:${TAG}"]
+  args = {
+    SOURCE_DATE_EPOCH = "${SOURCE_DATE_EPOCH}"
+  }
+}

+ 156 - 0
docs/ARCHITECTURE.md

@@ -0,0 +1,156 @@
+# Architecture
+
+The d-OAuth authorization server is a single Rust process with two listeners:
+a TLS public listener for OAuth traffic and a plaintext health-only listener
+for probes. All durable domain state lives in PostgreSQL and all ephemeral
+state lives in Redis. There is no in-process state that a second replica would
+need, so replicas are interchangeable.
+
+## Purpose
+
+This guide explains how the code is laid out, what happens to a request from
+socket to response, and where the Postgres/Redis boundary sits. Use it to find
+the module that owns a behavior before you change it.
+
+## Module layout
+
+The crate is one library (`src/lib.rs`) plus a thin binary (`src/main.rs`). The
+binary parses the CLI and maps the result to an exit code; all behavior lives in
+the library.
+
+| Module | Owns |
+| --- | --- |
+| `src/config.rs`, `src/config/` | Layered configuration load from `D_OAUTH_*` env vars and an optional file, then fail-fast validation. See [CONFIGURATION.md](CONFIGURATION.md). |
+| `src/keys.rs` | The versioned `KeyRing`: token peppers and the TOTP encryption key, each under a key id. |
+| `src/crypto/` | Constant-time comparison, PKCE, opaque token mint/hash, TOTP, and password/client-secret hashing. |
+| `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
+| `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
+| `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
+| `src/protocol/` | OAuth endpoints and grants. See [OAUTH.md](OAUTH.md). |
+| `src/ui/` | Server-rendered login, consent, and device pages (Askama templates). |
+| `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
+| `src/routes.rs` | Router assembly and the middleware order. |
+| `src/serve.rs`, `src/tls.rs` | Listener bootstrap and the custom TLS accept loop. |
+| `src/state.rs`, `src/state/` | `AppState`, the topology-aware Redis connection, health endpoints. |
+| `src/cli.rs`, `src/cli/` | The operator command tree. See [OPERATIONS.md](OPERATIONS.md). |
+| `src/error.rs` | `AppError`: the client-safe OAuth error type and its HTTP mapping. |
+
+Every behavior belongs to exactly one module. When a change spans modules, the
+new logic goes in the one that owns the behavior; the others call into it.
+
+## Request lifecycle
+
+A public request enters through the TLS listener and passes through one
+`tower` stack before reaching a handler. The order, from outermost to innermost,
+is fixed in `build_router_with_options` in `src/routes.rs`:
+
+1. Request id: mints or propagates `X-Request-Id` and opens the redacted
+   `http_request` tracing span. Tracing and redaction live here.
+2. Proxy-header stripping: when no `trusted_proxy` is configured, forged
+   `x-forwarded-tls-client-*` headers are removed before any handler can read
+   them. Without a configured proxy, proxy evidence is never trusted.
+3. CORS: answers preflights from the explicit allowlist. Absent when the
+   allowlist is empty, which is the default.
+4. Rate limit: enforces the per-IP and per-client budgets. Health and metadata
+   have no rule and pass through; those handlers fail closed on their own.
+5. Body limit: caps request bodies at 2 MiB.
+6. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
+   with `frame-ancestors 'none'`, HSTS on the TLS listener, and
+   `Cache-Control: no-store` on every response except `/.well-known/…` metadata.
+
+A handler that needs a dependency takes `AppState` and calls a repository or the
+store. Errors are converted to `AppError`, which maps to an OAuth error body and
+the correct HTTP status. A dependency outage on a security path maps to `503`
+with `Retry-After`; it never fails open.
+
+## The two listeners
+
+- The public listener binds `bind_addr` (default `0.0.0.0:8080`) and speaks TLS
+  only. It is driven by a custom `hyper-util` plus `tokio-rustls` accept loop
+  in `src/tls.rs`, not `axum::serve`, because the loop must inject the verified
+  client-certificate chain into request extensions for mTLS.
+- The health listener binds `health_addr` (default `0.0.0.0:8081`) in
+  plaintext and serves exactly two routes, `/health/live` and `/health/ready`.
+  Every OAuth route returns 404 there by construction. It is not exposed
+  through the `Service` or an ingress; only the pod network probes it.
+
+The health address must not be loopback. Kubelet probes originate from the node
+network namespace and cannot reach pod-loopback, so `serve` refuses a loopback
+`health_addr` at startup.
+
+## Postgres and Redis split
+
+The split is strict and drives the whole design:
+
+- PostgreSQL holds durable domain state only: `oauth_clients`, `users`,
+  `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`, plus sqlx's
+  `_sqlx_migrations`. Nothing is written to Postgres per request. A failed login
+  does not touch Postgres; the counter lives in Redis.
+- Redis holds every short-lived value: authorization codes, access and refresh
+  tokens, grant chains, sessions, PAR request URIs, device codes, DCR
+  initial-access tokens, DPoP and `private_key_jwt` replay markers, and
+  rate-limit counters.
+
+The consequence is that a Redis flush loses ephemeral state but not clients,
+users, scopes, or redirect URIs. Registration access tokens are the one token
+kind stored as a hash in Postgres, so client management survives a Redis loss.
+
+## Topology layer
+
+`AppState.redis` is the `RedisConn` enum (`src/state/redis.rs`), not a concrete
+connection type. One enum lets single-node, Sentinel, and Cluster share every
+call site:
+
+- Single: one `redis::aio::ConnectionManager`.
+- Sentinel: a hand-built master re-resolution loop, because the driver's
+  Sentinel support does not follow a promoted master across a failover.
+- Cluster: native slot refresh, so `MOVED` and `ASK` redirects recover without
+  a restart.
+
+Atomic multi-key operations use Lua scripts keyed under the `{state}` hash tag,
+so they stay on a single slot. See [REDIS.md](REDIS.md).
+
+## Security properties
+
+- The public listener is TLS-only; `issuer` is validated as absolute `https`.
+- Secrets stay in `secrecy`/`zeroize` types and never reach a `Debug` output,
+  a log, or an error body.
+- Dependency failure on a security path fails closed with `503`.
+- Middleware order puts request identity and redaction before any handler, and
+  strips untrusted proxy headers before they could be read.
+- The health listener cannot reach OAuth handlers, so a probe port cannot
+  become a second, unauthenticated API surface.
+
+## Edge cases
+
+- A mTLS client certificate terminates at an L7 ingress that strips it. In that
+  case in-process verification fails; use L4/TCP passthrough or the explicit
+  trusted-proxy mode. See [DEPLOYMENT.md](DEPLOYMENT.md).
+- `/metadata` reads the enabled scope catalog from Postgres on every request, so
+  a CLI catalog mutation is visible immediately on every replica; when Postgres
+  is down it returns `503` rather than advertising an empty or unknown scope
+  list.
+- Migrations are applied out of band. A replica whose schema version is behind
+  the binary reports not ready and is pulled from service.
+
+## Operational runbook
+
+- Liveness: `GET http://<pod-ip>:8081/health/live` returns `200 {"status":"live"}`
+  without touching a dependency.
+- Readiness: `GET http://<pod-ip>:8081/health/ready` returns `200 {"status":"ready"}`
+  only when Postgres and Redis answer and the schema version matches the binary.
+  Any other outcome is `503 {"status":"not_ready"}`.
+- On `SIGTERM` the process flips readiness to `503` first, waits a one second
+  propagation grace, drains in-flight requests under a 30 second deadline, then
+  closes the pools and exits 0.
+- Tracing is JSON to stdout. Use the `X-Request-Id` value to correlate a request
+  with its logs.
+
+## File and directory references
+
+- `src/lib.rs` for the authoritative module list.
+- `src/routes.rs` for endpoint inventory and middleware order.
+- `src/serve.rs` for listener bootstrap and shutdown semantics.
+- `src/state.rs`, `src/state/health.rs`, `src/state/redis.rs`,
+  `src/state/sentinel.rs` for state, health, and topology.
+- `src/tls.rs` for the TLS accept loop.

+ 112 - 0
docs/CHANGELOG.md

@@ -0,0 +1,112 @@
+# Changelog
+
+All notable changes to the d-OAuth authorization server are recorded here.
+
+The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+While the version stays below `1.0.0`, a minor bump may carry a breaking change.
+
+## [Unreleased]
+
+### Added
+
+- `GET /logout` (a server-specific session endpoint, not an OAuth 2.1 or OIDC
+  feature) ends the browser session so a different user can sign in. It destroys
+  the session and expires the cookie, then renders a neutral page or redirects to
+  the flow's registered `post_logout` URI. It accepts no query parameters: the
+  redirect target is frozen in session meta when the authorization flow is bound
+  and used only while the owning client is active and still registers that exact
+  URI (exact-string match, with the RFC 8252 loopback exception). There is no
+  `post_logout_redirect_uri` parameter, no `id_token_hint`, and no
+  `end_session_endpoint` in metadata.
+- The `redirect_uris` catalog gains a `kind` column (`authorize` | `post_logout`);
+  existing rows backfill to `authorize`. `redirect-uri create|enable|disable|delete`
+  and `client attach|detach-redirect-uri` accept `--kind`, and `config sync` file
+  entries accept a `kind` field. A client may register at most one enabled
+  `post_logout` URI.
+
+### Fixed
+
+- `GET /.well-known/oauth-authorization-server` reads the enabled scope catalog
+  from Postgres on every request. A scope added, enabled, or disabled via the
+  CLI now appears in `scopes_supported` immediately on every replica, instead of
+  only after a restart; the previous in-process cache was never invalidated by
+  the out-of-process CLI mutation.
+- Responses on the OAuth surface are now `Cache-Control: no-store`
+  (+ `Pragma: no-cache`) except `/.well-known/…` metadata, so authorization and
+  consent pages, token bodies, request handles, and errors are never cached by
+  a browser, the back/forward cache, or an intermediary. Token-endpoint error
+  responses now carry `no-store`, closing a documented gap against
+  draft-ietf-oauth-v2-1-16 §3.2.3.
+- `GET /login?request_id=…` rebinds an already-authenticated session to the
+  fresh authorization request instead of redirecting on the session's stale
+  pending binding.
+
+## [0.1.0] - 2026-10-03
+
+The first release. This is an OAuth 2.1-only authorization server, not a
+backwards-compatible OAuth 2.0 server. The deltas are deliberate: see
+[OAUTH.md](OAUTH.md) for the exact differences.
+
+### Added
+
+- Authorization-code grant with mandatory PKCE `S256`; `plain` and missing
+  challenges are rejected. Implicit and resource-owner-password grants do not
+  exist.
+- Refresh-token grant with rotation, tombstones, and reuse detection. Rotation
+  is idempotent under a deterministic `rotation_request_id`, so a retried
+  request returns the same tokens instead of double-rotating.
+- `client_credentials`, device-code (RFC 8628, with the user-facing `/device`
+  page), and policy-gated token-exchange (RFC 8693) grants.
+- Opaque access and refresh tokens: 256 bits of CSPRNG output, base64url
+  encoded, stored only as a keyed HMAC-SHA256 hash under a versioned pepper.
+  No raw token ever reaches Postgres, Redis, a log, or an error body.
+- Dynamic client registration (RFC 7591) and management (RFC 7592), protected
+  by a one-time initial-access token; open registration is off by default.
+  Registration access tokens are hashed in Postgres so client management
+  survives a Redis loss.
+- Sender-constrained tokens: DPoP (RFC 9449) and mTLS (RFC 8705), plus PAR
+  (RFC 9126) and JAR (RFC 9101). `private_key_jwt` is client authentication
+  only and is never treated as a sender constraint.
+- Introspection (RFC 7662) and revocation (RFC 7009), including grant-chain
+  cascade revocation.
+- RFC 8414 authorization-server metadata with `scopes_supported` served from
+  the enabled `scopes` table. The document carries no `jwks_uri`: the server
+  signs no artifact of its own.
+- Resource-owner login with Argon2id, TOTP (RFC 6238, plus recovery codes and
+  a used-code replay cache), server-side Redis sessions, and server-rendered
+  login/consent pages with CSP and CSRF protection.
+- Runtime-managed scope catalog and redirect-URI catalog. Scope reduction is
+  enforced as `requested ∩ client-registered ∩ user-entitled ∩ enabled`.
+- Operator CLI for every administrative action: `migrate`, `serve`, `user`,
+  `scope`, `redirect-uri`, `client`, `config sync`, `config check`,
+  `admin bootstrap`, `initial-access-token issue`, `keygen`, and `healthcheck`.
+  There is no HTTP admin API and no default credential.
+- Admin bootstrap that creates exactly one admin under an advisory lock in a
+  single transaction, with a forced password change and TOTP enrollment before
+  the account gains authority.
+- Fail-closed dependency handling: a Postgres or Redis outage returns
+  `503 temporarily_unavailable` with `Retry-After` on security paths, never a
+  fail-open success and never a `500` or panic.
+- A minimal multi-arch container image (static musl, non-root UID 65532, no
+  shell) and a `healthcheck` subcommand that probes the plaintext health
+  listener without a shell or curl.
+- Redis topologies from day one: single node, Sentinel (with hand-built master
+  re-resolution), and Cluster (with native slot refresh). Multi-key atomic
+  operations stay on one hash slot through the `{state}` hash tag.
+
+### Security
+
+- `#![forbid(unsafe_code)]` across the crate; `unwrap`, `expect`, and `panic`
+  are denied by lint in request paths.
+- Opaque hash-at-rest token storage with a versioned key ring for the token
+  pepper and the TOTP encryption key.
+- Rate limiting per IP and per client, with counters in a keyspace separated
+  from token state so counters can never evict tokens.
+- Security headers: CSP with `frame-ancestors 'none'`, `X-Content-Type-Options`,
+  `Referrer-Policy`, and HSTS on the TLS listener.
+- No wildcard CORS. Browser public clients require an explicit origin
+  allowlist.
+
+[Unreleased]: https://example.invalid/d-oauth/compare/v0.1.0...HEAD
+[0.1.0]: https://example.invalid/d-oauth/releases/tag/v0.1.0

+ 251 - 0
docs/CONFIGURATION.md

@@ -0,0 +1,251 @@
+# Configuration
+
+The server is configured from `D_OAUTH_*` environment variables plus an optional
+file named by `D_OAUTH_CONFIG`. Configuration is validated at load time and the
+process refuses to start on an insecure or inconsistent setting.
+
+## Purpose
+
+This guide lists every configuration key, its default, and its validation rule,
+so an operator can build a correct environment without reading the source. The
+loader lives in `src/config.rs` and its submodules.
+
+## How loading works
+
+`Config::load` builds a layered source:
+
+1. An environment source with prefix `D_OAUTH`, prefix separator `_`, and
+   nesting separator `__`, lowercased and parsed with type coercion.
+2. An optional file from `D_OAUTH_CONFIG` (YAML or TOML by extension). It is
+   not required.
+3. Two conventional secret-file overrides: `DATABASE_PASSWORD_FILE` sets
+   `database.password_file`, and `REDIS_PASSWORD_FILE` sets
+   `redis.password_file`.
+
+The environment spelling of a nested key is `D_OAUTH_` followed by the path
+with every `.` replaced by `__`, uppercased. For example:
+
+| YAML path | Environment variable |
+| --- | --- |
+| `database.url` | `D_OAUTH_DATABASE__URL` |
+| `redis.connect_timeout_secs` | `D_OAUTH_REDIS__CONNECT_TIMEOUT_SECS` |
+| `ttl.access_secs` | `D_OAUTH_TTL__ACCESS_SECS` |
+| `failure_policy.request_timeout_secs` | `D_OAUTH_FAILURE_POLICY__REQUEST_TIMEOUT_SECS` |
+| `tls.cert_path` | `D_OAUTH_TLS__CERT_PATH` |
+
+A secret may be given inline or as a mounted file. Inline wins when both are
+set. Mounted files are read and trimmed of trailing newlines.
+
+## Top-level keys
+
+| Key | Default | Rule |
+| --- | --- | --- |
+| `issuer` | required | Absolute `https` URL with no trailing slash and no whitespace in the authority. |
+| `bind_addr` | `0.0.0.0:8080` | A socket address. The TLS public listener. |
+| `health_addr` | `0.0.0.0:8081` | A socket address, never loopback. The plaintext health listener. |
+| `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
+| `cookie_domain` | none (host-only) | Optional cookie domain. |
+| `cookie_secure` | `true` | Adds the `Secure` attribute. Set `false` only for localhost testing. |
+| `trusted_proxy` | none | An IP. When set, that proxy's forwarded client-cert and client-IP headers are honored. When unset, those headers are stripped. |
+| `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
+| `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
+| `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is refused. |
+| `allow_token_exchange` | `false` | Enables the token-exchange grant. |
+| `token_exchange_allowlist` | empty | Rules of `{caller, subject, scopes[]}` for token exchange. |
+
+## TLS keys
+
+| Key | Default | Rule |
+| --- | --- | --- |
+| `tls.cert_path` | required | PEM certificate chain for the public listener. |
+| `tls.key_path` | required | PEM private key matching the chain. |
+| `tls.client_ca_path` | none | Optional client-CA bundle for in-process mTLS peer verification. |
+
+## Database keys
+
+Production requires `sslmode=verify-full` in `database.url` unless
+`require_verify_full` is set to false.
+
+| Key | Default |
+| --- | --- |
+| `database.url` | required |
+| `database.password_file` | none |
+| `database.root_cert_path` | none (`sslrootcert`) |
+| `database.max_connections` | `15` |
+| `database.min_connections` | `2` |
+| `database.acquire_timeout_secs` | `3` |
+| `database.idle_timeout_secs` | `300` |
+| `database.max_lifetime_secs` | `1800` |
+| `database.statement_timeout_secs` | `3` |
+| `database.lock_timeout_secs` | `1` |
+| `database.idle_in_transaction_timeout_secs` | `30` |
+| `database.application_name` | `oauth21-server` |
+| `database.require_verify_full` | `true` |
+
+`max_connections` must be greater than zero and at least `min_connections`.
+
+## Redis keys
+
+Exactly one topology is selected: `url` for single node, `nodes` plus
+`master_name` for Sentinel, or `seeds` for Cluster. Setting more than one, or
+none, is rejected as ambiguous.
+
+| Key | Default | Notes |
+| --- | --- | --- |
+| `redis.url` | none | Single-node `redis://` or `rediss://` URL. |
+| `redis.nodes` | `[]` | Sentinel `host:port` addresses. |
+| `redis.master_name` | none | Required for Sentinel. |
+| `redis.seeds` | `[]` | Cluster seed `host:port` addresses. |
+| `redis.sentinel_auth` | none | Sentinel node password. |
+| `redis.data_auth` | none | Sentinel data-node password. |
+| `redis.auth` | none | Cluster password. |
+| `redis.tls` | `false` | TLS for Sentinel and Cluster connections. |
+| `redis.connect_timeout_secs` | `3` | Connection timeout. |
+| `redis.response_timeout_secs` | `2` | Per-command response timeout. |
+| `redis.retry_budget` | `6` | Reconnect attempts. |
+| `redis.retry_min_delay_ms` | `100` | Minimum backoff. |
+| `redis.retry_max_delay_ms` | `5000` | Maximum backoff. |
+| `redis.durability` | required | `wait`, `waitaof`, or `appendfsync_always`. See [REDIS.md](REDIS.md). |
+| `redis.noeviction_required` | `true` | Requires `maxmemory-policy noeviction` at startup. |
+| `redis.aof_required` | `true` | Requires `appendonly yes` at startup. |
+| `redis.token_key_prefix` | `oauth:{state}:` | Must contain the `{state}` hash tag. |
+| `redis.ratelimit_key_prefix` | `oauth:{rl}:` | Must contain the `{rl}` hash tag and differ from the token prefix. |
+| `redis.root_cert_path` | none | Root certificate for `rediss://` endpoints. |
+| `redis.password_file` | none | Mounted password for the data node. |
+
+Durability must match the topology. Single node requires `waitaof` or
+`appendfsync_always`; replicated topologies require `wait`. A mismatch is
+rejected.
+
+## Key-ring keys
+
+| Key | Default | Notes |
+| --- | --- | --- |
+| `token_pepper` | none | Inline token-pepper secret. Required unless `token_pepper_file` is set. |
+| `token_pepper_file` | none | Mounted token-pepper secret. |
+| `token_pepper_id` | `v1` | Version id stored with the hash so peppers can rotate. |
+| `totp_encryption_key` | none | Inline AES-256-GCM key for TOTP secrets. Required to enroll TOTP. |
+| `totp_encryption_key_file` | none | Mounted TOTP key. |
+| `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
+
+The token pepper is required: without it the key ring cannot be built and the
+server refuses to start.
+
+## Failure policy keys
+
+Security endpoints are always fail-closed. Setting `open` on any of them is a
+load-time error.
+
+| Key | Default |
+| --- | --- |
+| `failure_policy.default_mode` | `closed` |
+| `failure_policy.endpoints` | the seven security endpoints mapped to `closed` |
+| `failure_policy.retryable` | `io`, `timeout`, `connection_reset`, `temporarily_unavailable`, `pool_timed_out` |
+| `failure_policy.retry_budget` | `3` |
+| `failure_policy.request_timeout_secs` | `10` |
+
+The security endpoints are `authorize`, `token`, `introspect`, `revoke`, `par`,
+`device_authorization`, and `register`.
+
+## Lifetime and hashing keys
+
+| Key | Default |
+| --- | --- |
+| `ttl.access_secs` | `300` |
+| `ttl.refresh_secs` | `2592000` (30 days) |
+| `ttl.code_secs` | `60` |
+| `ttl.par_secs` | `90` |
+| `ttl.device_secs` | `600` |
+| `ttl.dcr_initial_access_secs` | `86400` (24 hours) |
+| `ttl.session_secs` | `43200` (12 hours) |
+| `argon2.memory_kib` | `19456` (19 MiB) |
+| `argon2.iterations` | `2` |
+| `argon2.parallelism` | `1` |
+
+Every TTL must be non-zero. Argon2 memory must be at least 8192 KiB, with a
+non-zero iteration count and parallelism.
+
+## Rate-limit keys
+
+| Key | Default |
+| --- | --- |
+| `rate_limit.enabled` | `true` |
+| `rate_limit.window_secs` | `60` |
+| `rate_limit.per_ip` | `120` |
+| `rate_limit.per_client` | `300` |
+| `rate_limit.login_per_ip` | `20` |
+| `rate_limit.token_per_client` | `60` |
+
+The window must be non-zero.
+
+## Scope registry and token exchange
+
+`scope_registry` is an optional declarative input applied only by `config sync`.
+It is never required to be non-empty. Each entry is `{name, description?,
+allowed_grants?, allowed_client_classes?}`. The name must match the RFC 6749
+scope-token grammar, may not be `openid`, and may not exceed 128 characters.
+
+`token_exchange_allowlist` entries are `{caller, subject, scopes[]}`; caller and
+subject may not be empty.
+
+## The timeout ladder
+
+The four database and request timeouts must satisfy:
+
+```
+lock_timeout < statement_timeout <= acquire_timeout < request_timeout
+```
+
+With the defaults that is `1s < 3s <= 3s < 10s`. A violation is a load-time
+error, which prevents a request from outliving a pool slot or a lock outliving
+the statement.
+
+## dcr_redirect_policy
+
+- `catalog`: only redirect URIs present in the runtime catalog may be
+  registered. This is the default.
+- `catalog_loopback`: catalog URIs plus dynamic loopback URIs (RFC 8252).
+- `open`: any redirect URI may be registered. This is an explicit opt-in and is
+  not the default.
+
+## Security properties
+
+- No secret is ever printed. `Config`'s `Debug` impl redacts the database URL,
+  Redis URLs, and every password.
+- A production database URL without `sslmode=verify-full` is refused.
+- A Redis topology that does not satisfy the durability contract is refused at
+  load and re-checked live at startup.
+- `trusted_proxy` is unset by default, so forwarded client-cert headers from an
+  untrusted hop are stripped before any handler reads them.
+- CORS and open registration are off by default.
+
+## Edge cases
+
+- `D_OAUTH_CONFIG` pointing at a missing file is not an error; the file is
+  optional.
+- An inline `token_pepper` of only whitespace is treated as absent.
+- `scope delete` and `redirect-uri delete` disable a referenced row instead of
+  deleting it. See [OPERATIONS.md](OPERATIONS.md).
+- A deliberate mismatch of `dcr_redirect_policy = open` and mTLS is a deployment
+  error, not a load error: open registration lets a caller register an arbitrary
+  redirect URI.
+
+## Operational runbook
+
+Validate any environment before starting the server:
+
+```bash
+D_OAUTH_ISSUER=https://auth.example.com cargo run --bin d-oauth-server -- config check
+```
+
+`config check` prints `config ok: issuer=...` and exits 0 on success, or prints
+the first validation error and exits 1. It does not connect to Postgres or
+Redis.
+
+## File and directory references
+
+- `src/config.rs` for the loader and `ConfigError`.
+- `src/config/build.rs` for defaults and cross-field validation.
+- `src/config/db.rs`, `src/config/redis.rs`, `src/config/types.rs`,
+  `src/config/policy.rs`, `src/config/scope.rs` for the per-area keys.
+- `src/config/tests.rs` for the asserted defaults.

+ 156 - 0
docs/CONTRIBUTING.md

@@ -0,0 +1,156 @@
+# Contributing
+
+How to build, test, and change the d-OAuth authorization server. The rules here
+are enforced by CI, so a change that skips them will not merge.
+
+## Purpose
+
+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).
+
+## Prerequisites
+
+- The Rust toolchain pinned in `rust-toolchain.toml` (stable, with `rustfmt`
+  and `clippy`). `rustup` installs it automatically on first build.
+- [`cargo-nextest`](https://nexte.st/) for the test suite:
+  `cargo install cargo-nextest --locked`.
+- Docker for the integration tests, which start real Postgres and Redis
+  containers through `testcontainers`.
+- `sqlx-cli` only when you add or change a SQL query or a migration:
+  `cargo install sqlx-cli --locked --features runtime-tokio,tls-rustls-ring`.
+
+The crate uses edition 2024 and the rustfmt settings in `rustfmt.toml`
+(2-space indent, `StdExternalCrate` import grouping).
+
+## Build
+
+```bash
+cargo build --locked            # debug
+cargo build --locked --release  # release (thin LTO, stripped)
+```
+
+The build is offline-safe for SQL: with `SQLX_OFFLINE=true` the `query!` macros
+compile against the committed `.sqlx/` cache, so no database is needed at build
+time. The Docker build relies on exactly this.
+
+## Quality gates
+
+Run all of these before opening a change. CI runs the same set.
+
+```bash
+cargo fmt --check
+cargo clippy --all-targets -- -D warnings
+cargo nextest run --all-targets
+cargo test --doc
+cargo audit
+cargo deny check
+```
+
+`cargo nextest run` is the authoritative test entry point. It runs unit,
+integration (testcontainers), and end-to-end tests. `cargo test --doc` runs the
+doc examples separately because nextest does not cover doctests.
+
+### Narrow scope while developing
+
+While iterating on one area, filter to just that area:
+
+```bash
+cargo nextest run -E 'test(refresh_rotation)'
+cargo nextest run -E 'test(admin_bootstrap)'
+```
+
+A delegated or subagent change runs only the narrowest filter that covers the
+touched code. The full suite runs once, at integration time.
+
+### Database-backed tests
+
+Integration tests start their own Postgres and Redis containers and apply the
+embedded migrations. They need a working Docker daemon, no more. Migrations are
+never run by the test process against a shared database.
+
+## Module conventions
+
+The crate is one library (`src/lib.rs`) plus a thin binary (`src/main.rs`).
+Every module has a single responsibility:
+
+| Area | Path | Owns |
+| --- | --- | --- |
+| Configuration | `src/config.rs`, `src/config/` | Layered env/file load and fail-fast validation. |
+| Keys | `src/keys.rs` | Versioned `KeyRing` for the token pepper and TOTP encryption key. |
+| Crypto primitives | `src/crypto/` | Constant-time compare, PKCE, token mint/hash, TOTP, secret hashing. |
+| JOSE | `src/jose/` | DPoP, JAR, `private_key_jwt` verification, JWK parsing. |
+| Postgres repositories | `src/db/` | The six domain tables. |
+| Redis store | `src/store/` | Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |
+| Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
+| Server-rendered UI | `src/ui/` | Login, consent, and device pages. |
+| Middleware | `src/middleware/` | Request id, tracing/redaction, rate limit, security headers, audit. |
+| Routing and serving | `src/routes.rs`, `src/serve.rs`, `src/tls.rs` | Router assembly and the two listeners. |
+| Operator CLI | `src/cli.rs`, `src/cli/` | Every administrative command. |
+
+Rules that apply everywhere:
+
+- Plain, idiomatic Rust. `#![forbid(unsafe_code)]` is on the crate.
+- No `unwrap`, `expect`, or `panic` in request paths. They are denied by the
+  clippy lints in `Cargo.toml`.
+- Errors are typed with `thiserror`; map dependency failures to the correct
+  client-safe error rather than stringifying them.
+- Secrets use `secrecy` and `zeroize`. A type that holds a secret must not have
+  a `Debug` impl that prints it.
+- Constant-time comparison (`subtle`) for any secret or token comparison.
+- Keep `main.rs` free of business logic.
+- No file grows past roughly 250 pure lines without a good reason; split by
+  responsibility instead.
+
+## Test-driven workflow
+
+Every behavior change follows red, green, refactor:
+
+1. Red: write a failing test that names the behavior and run it. Confirm it
+   fails for the reason you expect, not because of a typo or a missing import.
+2. Green: write the smallest change that makes the test pass.
+3. Refactor: restructure with the test green as the safety net.
+
+Prefer a real object or an in-memory fake over a mock. Use `testcontainers` for
+Postgres and Redis. Assert the observable contract, not an implementation dump.
+
+## Commit style
+
+One commit per logical change, in conventional-commit form:
+
+```
+feat(token): add token-exchange grant
+fix(store): fail closed when the durability barrier is missed
+docs(agents): document the module map
+test(resilience): cover Redis restart recovery
+```
+
+The subject is imperative and lowercase after the type. The body explains why,
+and links the evidence file when the task process requires one. Never commit a
+secret, a real token, or key material.
+
+## Documentation contract
+
+The docs are part of the code, not an afterthought. Any behavior change updates
+the relevant guide in the same commit:
+
+- OAuth flow, grants, or error codes: `docs/OAUTH.md`.
+- Threat model, tokens, sender constraints, or the admin runbook:
+  `docs/SECURITY.md`.
+- Module layout or request lifecycle: `docs/ARCHITECTURE.md`.
+- A config key or default: `docs/CONFIGURATION.md`.
+- Schema or migration behavior: `docs/DATABASE.md`.
+- Redis keys, TTLs, or topology: `docs/REDIS.md`.
+- Image, manifests, or probes: `docs/DEPLOYMENT.md`.
+- A CLI command or runbook step: `docs/OPERATIONS.md`.
+- Anything at all: the module map in [AGENTS.md](../AGENTS.md).
+
+Then run the link checker, which CI also runs:
+
+```bash
+python3 scripts/check_doc_links.py
+```
+
+A command in a doc must match the real CLI. If you change a flag, change the doc
+in the same commit.

+ 218 - 0
docs/DATABASE.md

@@ -0,0 +1,218 @@
+# Database
+
+PostgreSQL holds durable domain state only. Every ephemeral value lives in
+Redis, so no request writes a per-request row to Postgres.
+
+## Purpose
+
+This guide describes the schema, the indexes and constraints, the migration
+lifecycle, and the operational rules for applying migrations. It is the
+reference for anyone changing a table or a query.
+
+## The six tables
+
+Migrations create exactly six domain tables plus sqlx's `_sqlx_migrations`
+bookkeeping table. They create schema only and seed zero rows, so a fresh
+install starts empty by design.
+
+### oauth_clients
+
+One row per registered client (RFC 7591 / RFC 7592).
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | `uuid` | Primary key. |
+| `client_id` | `text` | Unique table constraint, and the FK target for `client_redirect_uris`. |
+| `client_secret_hash` | `text` | Argon2id hash, never a raw secret. |
+| `client_secret_expires_at` | `timestamptz` | Optional expiry. |
+| `client_id_issued_at` | `timestamptz` | Issue time. |
+| `client_name` | `text` | Display name. |
+| `grant_types` | `text[]` | Registered grant types. |
+| `response_types` | `text[]` | Registered response types. |
+| `scope` | `text` | Registered scope string. |
+| `token_endpoint_auth_method` | `text` | One of the supported methods. |
+| `jwks` | `jsonb` | Optional inline JWKS. |
+| `jwks_uri` | `text` | Optional JWKS URL (fetched with the SSRF guard). |
+| `tls_client_auth_subject_dn` | `text` | RFC 8705 subject DN binding. |
+| `tls_client_auth_cert_thumbprint` | `text` | RFC 8705 `x5t#S256` binding. |
+| `request_uris` | `jsonb` | Pre-registered JAR request URIs. |
+| `contacts` | `jsonb` | Metadata only. |
+| `software_id` | `text` | Metadata only. |
+| `active` | `bool` | Soft disable, default true. |
+| `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
+| `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
+
+Two partial unique indexes enforce that a client certificate authenticates at
+most one client:
+
+- `ux_oauth_clients_tls_subject_dn` on `tls_client_auth_subject_dn` where it is
+  not null.
+- `ux_oauth_clients_tls_cert_thumbprint` on `tls_client_auth_cert_thumbprint`
+  where it is not null.
+
+The `UNIQUE (client_id)` is a table constraint, not merely an index, because
+Postgres accepts only a unique constraint as a foreign-key target.
+
+### users
+
+One row per resource owner.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | `uuid` | Primary key. |
+| `email` | `citext` | Case-insensitive email-as-username, unique. |
+| `password_hash` | `text` | Argon2id PHC string. |
+| `totp_secret_enc` | `bytea` | AES-256-GCM encrypted TOTP secret, never plaintext. |
+| `totp_enabled` | `bool` | Default false. |
+| `totp_recovery_code_hashes` | `jsonb` | Hashes of recovery codes. |
+| `is_admin` | `bool` | Default false. Gates privileged actions. |
+| `must_change_password` | `bool` | Default false; true for a bootstrapped admin. |
+| `password_changed_at` | `timestamptz` | Stamped on password change. |
+| `disabled` | `bool` | Default false. |
+| `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
+
+There are deliberately no failed-login or lockout columns. Those counters are
+security-ephemeral and live in Redis.
+
+### scopes
+
+The runtime-managed scope catalog.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `name` | `text` | Primary key. |
+| `description` | `text` | Default empty. |
+| `enabled` | `bool` | Default true. |
+| `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
+
+The enabled rows are the single source of truth for `scopes_supported` in the
+RFC 8414 metadata document, read on each discovery request.
+
+### user_scopes
+
+Per-user scope entitlements. New users start with none.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `user_id` | `uuid` | FK to `users(id)`, cascade delete. |
+| `scope_name` | `text` | FK to `scopes(name)`. |
+| `granted_at` | `timestamptz` | Grant time. |
+| `granted_by` | `uuid` | Optional granting admin. |
+
+Composite primary key `(user_id, scope_name)`.
+
+### redirect_uris
+
+The runtime-managed redirect-URI catalog. `uri` is stored normalized-exact and
+is unique per `(uri, kind)`.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | `uuid` | Primary key. |
+| `uri` | `text` | Unique per `kind`. |
+| `kind` | `text` | `authorize` (default) or `post_logout`. |
+| `enabled` | `bool` | Default true. |
+| `description` | `text` | Default empty. |
+| `created_by` | `uuid` | Optional creator. |
+| `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
+
+`kind` separates the two browser landings: `authorize` URIs are valid
+authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
+are bare navigation targets used only by `GET /logout`. Both kinds are matched
+with the same exact-string + RFC 8252 loopback rule.
+
+### client_redirect_uris
+
+The join between a client and a catalog redirect URI.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `client_id` | `text` | FK to `oauth_clients(client_id)`, cascade delete. |
+| `redirect_uri_id` | `uuid` | FK to `redirect_uris(id)`, cascade delete. |
+| `attached_at` | `timestamptz` | Attachment time. |
+| `attached_by` | `uuid` | Optional attacher. |
+
+Composite primary key `(client_id, redirect_uri_id)`.
+
+## The citext extension
+
+The `users` migration runs `CREATE EXTENSION IF NOT EXISTS citext`. This makes
+email comparison case-insensitive, so `A@x.com` and `a@x.com` collide and there
+is one account per email regardless of case. Installing `citext` requires
+superuser or trusted-extension privileges, so the migration role must have
+them, or an operator must install the extension out of band before migrating.
+
+## Migration lifecycle
+
+- Migrations are versioned, forward-only sqlx migrations compiled into the
+  binary by `sqlx::migrate!("./migrations")`. They are tracked in
+  `_sqlx_migrations`.
+- They are applied by the `migrate` subcommand, which runs
+  `sqlx::Migrator::run` under sqlx's Postgres advisory lock. Concurrent
+  migration runs serialize on that lock, so a cluster Job cannot double-apply.
+- `serve` never migrates. Startup only reads the schema. Readiness returns 503
+  until every embedded migration version is present in `_sqlx_migrations`.
+- A checksum mismatch or a dirty migration fails fast rather than guessing.
+- Each file is deterministic with no down path. There is no `DROP` and no
+  rollback.
+
+In a cluster, run migrations from a dedicated Job, never from every replica.
+
+## Expand and contract
+
+Rolling upgrades keep version N-1 and N replicas serving at once. A migration
+must be backward compatible:
+
+1. Expand: add the new column or table in a way the old code ignores.
+2. Deploy: roll out the new code that uses it.
+3. Contract: remove the old column or table only after no old replica remains.
+
+A migration that drops or renames a column in one step breaks the old replicas
+during the rollout. Do not do it.
+
+## Security properties
+
+- Only hashed or encrypted secrets are stored: client secrets in
+  `oauth_clients`, passwords in `users`, TOTP secrets encrypted in `users`.
+  Raw tokens are never rows here.
+- The `registration_access_token_hash` column is the one token hash in
+  Postgres, so RFC 7592 client management survives a Redis loss.
+- Foreign keys with `ON DELETE CASCADE` keep the join tables consistent, and a
+  scope referenced by `user_scopes` is disabled rather than deleted.
+
+## Edge cases
+
+- Deleting a scope or redirect URI that is still referenced does not cascade
+  away the reference. The CLI disables the catalog row instead, and
+  `scope delete` falls back to disable when `user_scopes` references it.
+- A client with a `tls_client_auth_subject_dn` or a thumbprint that another
+  client already uses is rejected by the partial unique index.
+- `citext` must exist before the `users` migration runs.
+
+## Operational runbook
+
+Apply migrations once, before rolling out replicas:
+
+```bash
+D_OAUTH_DATABASE__URL='postgres://oauth@db.example.com/oauth?sslmode=verify-full' \
+  cargo run --bin d-oauth-server -- migrate
+```
+
+The command prints `applied N migration(s)`. Run it as a cluster Job and let
+readiness gate the Deployment. To confirm a fresh install, check that the six
+tables exist and are empty:
+
+```sql
+SELECT table_name FROM information_schema.tables
+WHERE table_schema = 'public' ORDER BY table_name;
+SELECT version, success FROM _sqlx_migrations ORDER BY version;
+```
+
+## File and directory references
+
+- `migrations/1791072000_oauth_clients.sql` for `oauth_clients`.
+- `migrations/1791158400_users.sql` for the other five tables and `citext`.
+- `src/db/` for the repositories.
+- `src/state.rs` for `MIGRATOR`.
+- `src/cli/ops.rs` for the `migrate` command.
+- `src/state/health.rs` for the readiness schema check.

+ 364 - 0
docs/DEPLOYMENT.md

@@ -0,0 +1,364 @@
+# Deployment
+
+The server ships as a minimal static image and runs as interchangeable
+replicas in front of external Postgres and Redis. This guide covers the image,
+the cluster contract, graceful shutdown, the mTLS network constraint, and the
+supply-chain gates.
+
+## Purpose
+
+For an operator deploying the server: what the image contains, what the runtime
+expects, and which constraints are enforced rather than advisory.
+
+## The container image
+
+The image is built by the four-stage `Dockerfile` in the repository root:
+
+1. `chef`: a pinned Rust base plus `cargo-chef` and the two musl targets.
+2. `planner`: captures the dependency recipe so dependency layers cache.
+3. `builder`: cooks dependencies, then builds the locked, stripped, static musl
+   binary. `SQLX_OFFLINE=true` builds against the committed `.sqlx/` cache, so
+   the build needs no database.
+4. `runtime`: a pinned-digest distroless `static-debian12:nonroot` base. It has
+   no shell and no package manager. Only the stripped binary is copied in.
+
+Properties the image guarantees:
+
+- Static musl binary (`ldd` reports not a dynamic executable). The rustls
+  `ring` provider is used, with no OpenSSL and no `aws-lc-sys` in the graph.
+- Non-root `USER 65532:65532`.
+- Exec-form `ENTRYPOINT ["/d-oauth-server", "serve"]`, so no shell interprets
+  it.
+- `EXPOSE 8080` for the TLS public listener. The plaintext health listener
+  (default `0.0.0.0:8081`) is intentionally not published; it is probed inside
+  the pod network only.
+- `HEALTHCHECK` runs `["/d-oauth-server", "healthcheck"]`, which speaks raw
+  HTTP/1.1 over loopback to the health listener. This works without a shell or
+  curl.
+- Reproducible: `SOURCE_DATE_EPOCH` is set, and the base images are pinned by
+  digest.
+
+Build with `docker buildx bake`, which reads `docker-bake.hcl`. The `image`
+target builds both `linux/amd64` and `linux/arm64` by default. To build a single
+architecture, override the platform:
+
+```bash
+# Both architectures (linux/amd64 + linux/arm64)
+docker buildx bake image
+
+# Single architecture
+docker buildx bake image --set image.platform=linux/amd64
+```
+
+Building `linux/arm64` on an amd64 host requires QEMU binfmt emulation. Docker
+Desktop bundles it; a bare Docker Engine does not. When it is missing, the arm64
+leg fails fast with `exec /bin/sh: exec format error`, because BuildKit pulls
+the real arm64 base image but the kernel cannot execute its arm64 binaries.
+Register the handler once:
+
+```bash
+docker run --privileged --rm tonistiigi/binfmt --install arm64
+```
+
+The registration lives in `binfmt_misc` and does not survive a reboot; make it
+durable with a `--restart=always` binfmt container or the distro's
+`qemu-user-static` and `binfmt-support` packages. A single-architecture build,
+such as `--set image.platform=linux/amd64`, needs no emulation and is the fast
+local option. The full arm64 build runs every compile under emulation, so it is
+noticeably slower than the amd64 build.
+
+Then run the image:
+
+```bash
+docker run --rm -p 8080:8080 --read-only d-oauth-server:latest
+```
+
+The container needs its configuration from the environment, its TLS material
+mounted read-only, and reachable Postgres and Redis. It holds no state itself,
+so a read-only root filesystem is expected.
+
+## TLS-terminating reverse proxy (HAProxy, Caddy)
+
+The server always terminates TLS on `bind_addr`; there is no plaintext mode.
+When a reverse proxy already holds the public certificate and terminates client
+TLS (L7), point the proxy's HTTPS upstream at the server's TLS listener. The
+server keeps its own certificate for the proxy-to-app hop, but that certificate
+can be private, because only the proxy sees it. Keep verification on: rather
+than disabling it, trust a private CA so the proxy verifies both the backend
+chain and its hostname.
+
+This L7 model cannot carry a client certificate, so it does not support
+in-process client-certificate mTLS. For that, use L4/TCP passthrough or the
+explicit trusted-proxy mode described below. Everything else, including DPoP,
+PAR, and JAR, works unchanged.
+
+### Generate a private CA and a server certificate
+
+Create a small internal CA, then issue the server a `serverAuth` leaf whose SAN
+matches the name the proxy verifies. Save this extension file next to the
+certificates as `d-oauth.ext`:
+
+```text
+basicConstraints=critical,CA:FALSE
+keyUsage=critical,digitalSignature,keyEncipherment
+extendedKeyUsage=serverAuth
+subjectAltName=DNS:d-oauth-backend,DNS:d-oauth.internal
+```
+
+```bash
+# 1. Private internal CA. Keep internal-ca.key offline or in a secret manager.
+openssl genrsa -out internal-ca.key 4096
+openssl req -x509 -new -key internal-ca.key -sha256 -days 3650 \
+  -subj "/CN=d-oauth internal CA" \
+  -addext "basicConstraints=critical,CA:TRUE" \
+  -addext "keyUsage=critical,keyCertSign,cRLSign" \
+  -out internal-ca.crt
+
+# 2. Server key and CSR. The SAN below, not the CN, is what the proxy verifies.
+openssl genrsa -out d-oauth.key 2048
+openssl req -new -key d-oauth.key -subj "/CN=d-oauth-backend" -out d-oauth.csr
+
+# 3. Sign the leaf with the internal CA.
+openssl x509 -req -in d-oauth.csr \
+  -CA internal-ca.crt -CAkey internal-ca.key -CAcreateserial \
+  -days 825 -sha256 -extfile d-oauth.ext -out d-oauth.crt
+
+chmod 0400 d-oauth.key
+```
+
+Mount `d-oauth.crt` and `d-oauth.key` read-only into the container at `/certs`
+and set:
+
+```bash
+D_OAUTH_ISSUER=https://oauth.example.com
+D_OAUTH_TLS__CERT_PATH=/certs/tls.crt
+D_OAUTH_TLS__KEY_PATH=/certs/tls.key
+```
+
+Give the proxy `internal-ca.crt` so it can verify the backend. Confirm the leaf
+before wiring the proxy:
+
+```bash
+openssl x509 -in d-oauth.crt -noout -ext subjectAltName -dates
+openssl s_client -connect d-oauth-backend:8080 \
+  -CAfile internal-ca.crt -verify_hostname d-oauth-backend </dev/null
+```
+
+### HAProxy
+
+Terminate the public certificate on the frontend; verify the internal CA and the
+SAN on the backend. Never use `verify none`, and never point `ca-file` at
+`@system-ca`: the file must contain only the internal CA, so no public root can
+be substituted to impersonate the backend.
+
+```haproxy
+frontend public-in
+  mode http
+  bind :443 ssl crt /etc/haproxy/certs/public-combined.pem alpn h2,http/1.1
+  http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
+  default_backend d_oauth
+
+backend d_oauth
+  mode http
+  # `check` performs a TCP connect plus a TLS handshake; readiness is the
+  # in-pod probe on the plaintext health listener (see the runbook).
+  server app1 10.0.0.10:8080 ssl verify required ca-file /etc/haproxy/certs/internal-ca.crt verifyhost d-oauth-backend sni str(d-oauth-backend) check
+```
+
+`public-combined.pem` is the public leaf, its intermediates, and its private key
+concatenated into one file. Validate with
+`haproxy -c -f /etc/haproxy/haproxy.cfg`.
+
+### Caddy
+
+Caddy obtains and renews the public certificate automatically. Point the
+upstream at the server over HTTPS and trust only the internal CA:
+
+```caddy
+oauth.example.com {
+  reverse_proxy https://d-oauth-backend:8080 {
+    transport http {
+      tls
+      tls_trust_pool file /etc/caddy/internal-ca.crt
+      tls_server_name d-oauth-backend
+    }
+  }
+}
+```
+
+Verification is on by default for an HTTPS upstream; `tls_trust_pool` replaces
+the system roots with the internal CA, and `tls_server_name` pins the verified
+name. Do not add `tls_insecure_skip_verify`; that is the only switch that
+disables it. If the upstream address is an IP, keep `tls_server_name`, because
+the address alone is not a verifiable name. Validate with
+`caddy validate --config Caddyfile`.
+
+### Proxy hardening
+
+- Never disable backend verification: no `verify none` in HAProxy, no
+  `tls_insecure_skip_verify` in Caddy, no `proxy_ssl_verify off` in nginx.
+- Trust only the private CA. Do not append system roots, and pin the backend
+  hostname (`verifyhost` / `tls_server_name`) so a different name under the same
+  CA is still rejected.
+- Keep the server's TLS port on the private network. Publish only the proxy; the
+  app's `8080` must never be reachable from the host or the internet.
+- Mount the key read-only at mode `0400`, readable by the runtime UID `65532`.
+- Set `D_OAUTH_ISSUER` to the public `https://` URL; the app never sees the
+  public hostname otherwise.
+- Add HSTS at the proxy and keep modern TLS versions and ciphers on the public
+  listener.
+- Rotate by replacing the mounted material and performing a rolling restart;
+  `serve` has no hot reload. Rotate before expiry and check with
+  `openssl x509 -in d-oauth.crt -noout -checkend 2592000`.
+
+## The cluster contract
+
+A correct Deployment has these properties:
+
+- Stateless replicas, three or more. Every replica shares the same external
+  Postgres and Redis, so no replica holds state another needs.
+- `runAsNonRoot`, `runAsUser: 65532`, `readOnlyRootFilesystem`, a
+  `seccompProfile` of `RuntimeDefault`, and `capabilities.drop: ["ALL"]`.
+- Resource requests and limits.
+- A `startupProbe` and `livenessProbe` on `/health/live`, and a
+  `readinessProbe` on `/health/ready`, all an HTTP GET against the plaintext
+  health port. The public listener stays TLS-only.
+- `terminationGracePeriodSeconds` greater than the drain deadline. The server
+  uses a one second readiness propagation grace and a bounded drain below the
+  grace budget.
+- A `PodDisruptionBudget` with `minAvailable: 2`, and
+  `topologySpreadConstraints` so replicas spread across nodes.
+- A `Service`, a `ConfigMap` for non-secret settings, and a `Secret` for
+  secret material. Secrets are mounted or injected, never baked into the image.
+- Client-CA material mounted read-only if in-process mTLS is used.
+
+### Migrations run once
+
+Migrations are applied by a separate Job, never by the replicas. `serve` does
+not migrate. The Job runs the `migrate` subcommand under sqlx's advisory lock,
+so concurrent starts serialize and the schema is applied exactly once. The
+Deployment readiness gate waits for the Job: a replica whose binary embeds a
+migration that is not yet in `_sqlx_migrations` reports not ready.
+
+Migrations must be expand/contract so version N-1 and N replicas serve during a
+rollout. See [DATABASE.md](DATABASE.md).
+
+### Multi-replica validation
+
+A local compose file is the intended way to prove shared-state behavior: three
+app replicas against one Postgres and one Redis, completing an
+authorization-code flow whose token issued by one replica is introspectable via
+another. The compose file is the local analogue of the cluster manifests.
+
+## Graceful shutdown
+
+On `SIGTERM` the server:
+
+1. Flips `/health/ready` to `503` immediately, so the endpoint is removed
+   before connections close.
+2. Waits a one second propagation grace so the kubelet observes it.
+3. Drains in-flight requests under a bounded deadline.
+4. Closes the pools and exits 0.
+
+A `terminationGracePeriodSeconds` shorter than the drain deadline causes
+in-flight requests to be cut off. Keep it above the budget.
+
+## mTLS and the L7 constraint
+
+In-process client-certificate verification needs the peer certificate to reach
+the application. That requires L4/TCP passthrough at the load balancer
+(a network load balancer gateway or a `TLSRoute`). An L7 ingress that terminates
+TLS strips the client certificate, so in-process mTLS cannot work behind it.
+
+Two correct options:
+
+- Use L4/TCP passthrough so the server terminates TLS and sees the client
+  certificate.
+- Use the explicit trusted-proxy mode: terminate at a proxy you control, have it
+  forward a verified client-cert representation, and set `trusted_proxy` to that
+  proxy's address. When `trusted_proxy` is unset, forwarded client-cert headers
+  are stripped.
+
+This constraint is documented here and is not claimed to be exercised end to end
+in CI. CI validates the manifests for the forbidden pairing of an L7 ingress
+with client-cert auth. For a worked HAProxy and Caddy example that terminates
+public TLS and re-encrypts to the backend while keeping certificate verification
+on, see the TLS-terminating reverse proxy section above.
+
+## Supply chain
+
+The image pipeline is gated on:
+
+- Multi-arch build with an SBOM and provenance attestation.
+- A vulnerability scan (`trivy` or `grype`) that fails on HIGH and CRITICAL.
+- Keyless signing with `cosign`.
+- Digest pinning, with a periodic rebuild so base digests advance.
+- `cargo deny check` in CI, which enforces the ring-only TLS policy in
+  `deny.toml`.
+
+## Security properties
+
+- No shell and no package manager in the runtime image narrows the attack
+  surface to the binary.
+- Non-root with a read-only root filesystem.
+- Secrets and CA material are mounted, never baked into the image or manifests.
+- Health routes are unreachable from the public listener, so the probe port is
+  not a second API surface.
+- Liveness never touches a dependency, so a dependency outage does not trigger
+  a restart storm.
+
+## Edge cases
+
+- A root or privileged pod violates the policy and must be rejected.
+- An L7-terminated ingress with client-cert auth cannot carry the cert; use
+  passthrough or trusted-proxy mode.
+- Booting three replicas without the migration Job lets readiness block them
+  until the schema matches.
+- A replica that has no shared state is a bug: token state is in Redis, not in
+  the process.
+
+## Operational runbook
+
+- Roll out a new image by updating the Deployment digest; the migration Job
+  runs first.
+- Probe commands inside the pod network:
+
+```bash
+wget -qO- http://<pod-ip>:8081/health/live
+wget -qO- http://<pod-ip>:8081/health/ready
+```
+
+- The container healthcheck, usable without a shell:
+
+```bash
+d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
+```
+
+- On a rolling upgrade, watch that the PDB keeps at least two replicas ready
+  and that readiness recovers after the drain.
+
+## File and directory references
+
+- `Dockerfile` for the four stages and runtime contract.
+- `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.
+- `.github/workflows/ci.yml` for the quality and documentation gates.
+- `.github/workflows/supply-chain.yml` for the image pipeline: multi-arch
+  bake with SBOM + provenance, trivy/grype HIGH/CRITICAL gates, keyless
+  cosign signing, digest pinning of `deploy/k8s/kustomization.yaml`, and the
+  weekly rebuild.
+- `deploy/k8s/` for the cluster manifests: `namespace.yaml`, `configmap.yaml`
+  (non-secret settings only), `secret.yaml` (placeholder keys, replaced by the
+  secret manager), `service.yaml` (public TLS port only, never the health
+  port), `deployment.yaml` (3 stateless replicas, probes on the plaintext
+  health listener, PDB + topology spread), `migrate-job.yaml` (exactly-once
+  schema migration, never per replica), `pdb.yaml` (`minAvailable: 2`),
+  `kustomization.yaml` (CI pins the image digest here at release), and
+  `tls-secret.example.yaml` (how to provision `/certs`, never applied).
+  No Ingress is shipped: mTLS requires L4/TCP passthrough.
+- `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
+  one-shot `migrate`, random host ports so `--scale app=3` never collides).
+- `scripts/check_manifests.py` for the manifest policy lint, including the
+  L7-ingress-with-client-cert-auth rejection.

+ 363 - 0
docs/OAUTH.md

@@ -0,0 +1,363 @@
+# OAuth
+
+This is an OAuth 2.1-only authorization server. It deliberately does not
+implement the OAuth 2.0 behaviors that 2.1 removes. The server core is the
+grant machinery under `src/protocol/`.
+
+## Specification reference
+
+This server targets **`draft-ietf-oauth-v2-1-16`**, "The OAuth 2.1
+Authorization Framework".
+
+- **Published:** 3 September 2026.
+- **URL:** https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1
+- **Versioned text:**
+  https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-16.txt
+
+This is an Internet-Draft (work in progress), not a published RFC. It may be
+updated, replaced, or obsoleted by other documents at any time, and citing it
+here does not imply endorsement. The version above is the one this
+implementation was reviewed against.
+
+The draft consolidates RFC 6749 (OAuth 2.0), RFC 6750 (Bearer Token Usage),
+RFC 8252 (OAuth 2.0 for Native Apps), RFC 9700 (Security Best Current
+Practice), and the Browser-Based Apps draft.
+
+## Purpose
+
+For a protocol implementer or an integrator: the exact deltas from OAuth 2.0 and
+the section citation behind each, the endpoints, the five grants and their
+parameters, PKCE, redirect matching, and the error codes.
+
+## Mandatory OAuth 2.1 deltas from 2.0
+
+The 2.1 draft consolidates the OAuth 2.0 core with the security best current
+practice in RFC 9700. This server enforces every one of these:
+
+| Delta | Behavior here | Reference |
+| --- | --- | --- |
+| PKCE is mandatory for every authorization-code flow | A request without `code_challenge` is rejected; only `S256` is accepted. | draft-ietf-oauth-v2-1-16, RFC 9700 §2.1.1 |
+| The `plain` PKCE method is removed | `code_challenge_method=plain` is rejected. | RFC 7636 §4.3, RFC 9700 §2.1.1 |
+| The implicit grant is removed | Only `response_type=code` exists; there is no token response. | draft-ietf-oauth-v2-1-16, RFC 9700 |
+| The resource-owner-password grant is removed | There is no `grant_type=password` code path. | draft-ietf-oauth-v2-1-16 |
+| Bearer credentials in the URI query are rejected | Access tokens are presented in the `Authorization` header. | RFC 6750 §2 |
+| Redirect URIs are matched exactly | No prefix or wildcard match; one loopback exception. | RFC 9700 §2.1, RFC 3986 §6.2.1 |
+| Public-client refresh tokens rotate | Rotation on every use, with tombstone reuse detection. | RFC 9700 §2.2.2, §4.14 |
+| Authorization codes are single-use and short-lived | A code is consumed atomically and capped at 60 seconds. | RFC 9700 §4.5.3 |
+| `state` is supported and echoed | The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
+| No open redirect | The redirect URI must be registered, attached, and enabled. | RFC 9700 §4.1.3 |
+
+## Known gaps against draft-ietf-oauth-v2-1-16
+
+An internal audit found the gaps below against the draft cited above. They are
+listed as work "to revise", not as defects that make the server unusable. Each
+entry names the draft section, what the implementation does today, and the
+change to make.
+
+1. **Token endpoint requires `redirect_uri`**: draft-16 §4.1.3 removed
+   `redirect_uri` from the token request, and §10.2 says the back-compat mode
+   must allow it, not require it. The implementation rejects a token request
+   with an empty `redirect_uri` (`invalid_request`), which can break pure-2.1
+   clients. To revise: accept-but-ignore it (or make it a per-client opt-in),
+   never require it.
+
+2. **Authorization error redirects omit `iss`**: draft-16 §4.1.2.1 and
+   §7.15.1 make `iss` required on the authorization response, including errors.
+   The success redirect includes `iss`, but the error redirect (`error`,
+   `error_description`, `state`) does not. To revise: add `iss` to the error
+   redirect.
+
+3. **Missing `response_type` returns a direct error page instead of a redirect**:
+   once `client_id` and `redirect_uri` are validated, draft-16 §4.1.1 and
+   §4.1.2.1 require the error to be delivered via redirect. To revise: move the
+   absent-`response_type` case to the redirect path.
+
+4. **Rotated refresh-token scope narrows instead of staying identical**:
+   draft-16 §4.3.3 requires the new refresh token's scope to be identical to the
+   presented one, while §4.3.1 permits requesting a narrower scope. To revise:
+   keep the new refresh token's scope identical and narrow only the access
+   token, or document the deviation explicitly.
+
+5. **No pinned TLS 1.2 minimum**: draft-16 §1.5 and §7.14 require TLS 1.2 or
+   above. The server relies on rustls defaults rather than asserting a minimum
+   protocol version. To revise: assert the minimum version explicitly and test
+   it.
+
+Two lower-priority SHOULD-level notes:
+
+- The consent success redirect uses `302` where the draft prefers `303`
+  (draft-16 §7.5.4).
+- There is no reverse-DNS `.` check for a private-use URI scheme
+  (draft-16 §2.3.1).
+
+## Endpoints
+
+All endpoints are on the TLS public listener. The plaintext health listener
+mounts only `/health/live` and `/health/ready`.
+
+| Method | Path | Reference | Status |
+| --- | --- | --- | --- |
+| GET | `/.well-known/oauth-authorization-server` | RFC 8414 | Wired |
+| GET | `/authorize` | RFC 6749 §4.1.2.1, RFC 9700 §4.1.3 | Wired |
+| POST | `/par` | RFC 9126 §2.1 | Wired |
+| POST | `/token` | RFC 6749 §4.1.3, RFC 8628 §3.4, RFC 8693 §2 | Wired |
+| POST | `/device_authorization` | RFC 8628 §3.1 | Wired |
+| GET, POST | `/device` | RFC 8628 §3.3 | Wired |
+| GET, POST | `/login` | RFC 9700 §4.7.1 | Wired |
+| GET, POST | `/logout` | server-specific | Wired |
+| GET, POST | `/consent` | RFC 9700 §4.7.1 | Wired |
+| POST | `/introspect` | RFC 7662 §2.1 | Wired |
+| POST | `/revoke` | RFC 7009 §2.1 | Wired |
+| POST | `/register` | RFC 7591 §2 | Wired |
+| GET, PUT, DELETE | `/register/{client_id}` | RFC 7592 §2 | Wired |
+
+`GET /logout` and `POST /logout` are a server-specific session endpoint, not a
+feature defined by OAuth 2.1 or OIDC. They destroy the authorization-server
+session and expire the `session_id` cookie. `GET /logout` takes **no query
+parameters** and issues no client-supplied redirect, so a forced navigation
+cannot cause an open redirect; the only redirect it may issue is to a
+`post_logout` URI that was **registered for the client and frozen in session
+meta when the authorization flow was bound**. It redirects there only when the
+owning client is still active and still registers that exact URI (exact-string
+match, with the RFC 8252 loopback exception); otherwise it renders a neutral
+signed-out page. `POST /logout` clears the session and redirects to `GET /login`.
+This is what lets a client end the browser SSO session so a different user can
+sign in. There is no `post_logout_redirect_uri` parameter, no `id_token_hint`,
+and no `end_session_endpoint` in metadata.
+
+The `POST /token` handler parses the form once, authenticates the client
+through the shared `client_auth` dispatch, verifies the fresh sender proof,
+dispatches on `grant_type` to the matching grant, and shapes the RFC 6749 §5.1
+JSON response; an unknown `grant_type` is `unsupported_grant_type`. The DCR
+routes map onto `protocol::dcr::register_handler` and
+`protocol::dcr::manage::{read,update,delete}_handler` (RFC 7591 / RFC 7592).
+
+## The five grants
+
+The advertised set is `authorization_code`, `refresh_token`,
+`client_credentials`, `urn:ietf:params:oauth:grant-type:device_code`, and
+`urn:ietf:params:oauth:grant-type:token-exchange`. DCR may register only
+`authorization_code`, `refresh_token`, and `device_code`; `client_credentials`
+is never registered for a dynamically created client.
+
+### authorization_code
+
+Reference: RFC 6749 §4.1.3, RFC 9700 §4.5.3, RFC 7636, RFC 9449 §6.
+
+Parameters: `code`, `redirect_uri`, `code_verifier`, `client_id`,
+`client_secret` or `client_assertion_type` with `client_assertion`, and a DPoP
+proof when the code is DPoP-bound.
+
+Behavior: the code is consumed exactly once. The client, redirect URI, PKCE
+verifier, and sender proof are all checked before minting. The token is minted
+under the code's grant chain. A refresh token is issued only if the client is
+registered for the refresh grant.
+
+### refresh_token
+
+Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
+
+Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
+narrow but never widen), and a freshly verified sender proof.
+
+Behavior: rotation on every use. The presented token is tombstoned with a
+`rotated_to` member. Presenting a rotated token again is reuse: the whole grant
+family is revoked and the request fails with `invalid_grant`. A lost reply does
+not double-rotate, because the rotation is keyed by a deterministic request id
+and a retry returns the same tokens.
+
+### client_credentials
+
+Reference: RFC 6749 §4.4, RFC 9449 §6, RFC 8705 §3.
+
+Parameters: `client_id` (confidential clients only), optional `requested_scope`,
+and a sender proof when the client is sender-constrained.
+
+Behavior: no user and no subject. The granted scope is the requested scope
+intersected with the client's registered scope. No refresh token is issued.
+
+### device_code
+
+Reference: RFC 8628 §3.1 through §3.5.
+
+Authorization: `POST /device_authorization` returns a device code, a user code,
+a verification URI, an expiry, and a polling interval. The user completes
+`GET /device` and `POST /device` and approves or denies.
+
+Polling: `grant_type=urn:ietf:params:oauth:grant-type:device_code` with
+`device_code`. Before approval the response is `authorization_pending`; polling
+faster than the interval returns `slow_down`; denial returns `access_denied`;
+expiry returns `expired_token`. On approval the token is minted with the scope
+recomputed against the approving user's entitlements.
+
+### token_exchange
+
+Reference: RFC 8693 §2 through §4.1.
+
+Parameters: `subject_token`, `subject_token_type` (an access-token type),
+optional `actor_token` and `actor_token_type`, optional
+`requested_token_type`, optional `requested_scope`, and a sender proof that
+matches the subject token's confirmation, not merely the caller's.
+
+Behavior: disabled by default and enabled with `allow_token_exchange`. The
+exchange must be on the explicit `(caller, subject, scopes)` allowlist. The
+granted scope is the subject's scope intersected with the caller's registered
+scope, the subject's entitlements, the enabled catalog, and the allowlist, and
+it can only narrow. Chained exchange is rejected.
+
+## PKCE
+
+Reference: RFC 7636, RFC 9700 §2.1.1.
+
+- `S256` is the only accepted `code_challenge_method`. `plain` is never
+  advertised and never accepted.
+- The verifier is 43 to 128 characters from `ALPHA / DIGIT / - . _ ~`.
+- The challenge is exactly 43 base64url characters without padding.
+- The verifier is checked against the challenge at redemption with a
+  constant-time comparison.
+- A challenge without a verifier, a verifier without a challenge, and neither
+  are all rejected. The verifier cannot be omitted to downgrade the request.
+- RFC 7636 Appendix B test vector is covered by a unit test.
+
+## Redirect URI matching
+
+Reference: RFC 3986 §6.2.1, RFC 8252 §7.3, RFC 9700 §2.1.
+
+- Matching is exact Simple String Comparison: scheme, host, port, path, and
+  query must match byte for byte. There is no decoding, no case folding, and no
+  prefix or wildcard match.
+- The one exception is a native-app loopback URI. A registered
+  `http://127.0.0.1` or `http://[::1]` matches the same scheme, host, and path
+  on any port, and the request must carry no userinfo, query, or fragment.
+- `localhost` is never treated as a loopback host.
+- A registered URI with userinfo, query, or fragment is never eligible for the
+  loopback exception.
+
+### Error-delivery matrix
+
+Before a redirect URI is validated, the server must not redirect anywhere. It
+returns a `400` HTML page with no `Location` header for: absent or unknown
+`client_id`, a JAR/PAR resolution failure, a `client_id` binding mismatch, and a
+missing or unregistered `redirect_uri`.
+
+After the redirect URI is validated, errors are delivered on the redirect as
+`302` with `error`, `error_description`, and the echoed `state`. This covers
+`unsupported_response_type`, `invalid_scope`, and the PKCE failures.
+
+## Error codes
+
+Errors use the RFC 6749 §5.2 body `{error, error_description}`.
+
+| Code | HTTP | Typical causes |
+| --- | --- | --- |
+| `invalid_request` | 400 | Missing or duplicated parameter; PAR misuse; JAR misuse. |
+| `invalid_client` | 401 with `WWW-Authenticate` for Basic or Bearer, else 400 | Bad client authentication; ambiguous mTLS certificate. |
+| `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
+| `invalid_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
+| `unsupported_grant_type` | 400 | A `grant_type` outside the five. |
+| `unsupported_response_type` | 302 error redirect | A `response_type` other than `code`. |
+| `unauthorized_client` | 400 | A client that may not use the requested grant. |
+| `invalid_request_object` | 400 | Malformed, unsigned, mis-bound, or conflicting JAR request object. |
+| `invalid_dpop_proof` | 400 | Missing or invalid DPoP proof; replayed `jti`. |
+| `invalid_redirect_uri` | 400 | DCR redirect URI not in the catalog or otherwise ineligible. |
+| `invalid_client_metadata` | 400 | DCR metadata that violates the rules. |
+| `access_denied` | 400 | Resource-owner denial; RFC 7592 token mismatch. |
+| `authorization_pending` | 400 | Device flow before approval. |
+| `slow_down` | 400 | Device polling faster than the interval. |
+| `expired_token` | 400 | Device code or code expired. |
+| `temporarily_unavailable` | 503 with `Retry-After: 1` | Postgres or Redis is unavailable on a security path. |
+| `server_error` | 500 | A masked internal failure. |
+
+The server never fails open on a security path: a dependency outage yields
+`503`, never a silent success.
+
+## Sender constraints
+
+Every sender-constrained grant requires a freshly verified proof of possession
+on every use.
+
+| Client class | Binding |
+| --- | --- |
+| Public | DPoP mandatory. A missing proof is a hard `invalid_grant`. |
+| Confidential | mTLS and/or DPoP; the confirmation reports both when both are proven. |
+
+`private_key_jwt` is client authentication only. It produces no confirmation and
+never satisfies sender constraint. For token exchange, the proof must match the
+subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
+and the confirmation propagation rule.
+
+## Metadata
+
+`GET /.well-known/oauth-authorization-server` returns the RFC 8414 document.
+The only state-dependent member is `scopes_supported`, read from the enabled
+`scopes` table on every request. The catalog is mutated out of process by the
+CLI, so a per-request read makes a change visible immediately on every replica
+with no restart. When Postgres is unavailable the endpoint returns `503` rather
+than advertising an unknown list.
+
+The document carries no `jwks_uri`: the server signs no artifact of its own.
+`code_challenge_methods_supported` advertises only `S256`.
+`authorization_response_iss_parameter_supported` is true, so the authorization
+response carries `iss` (RFC 9207).
+
+## JAR and PAR
+
+Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
+
+- At `/authorize`, a confidential client must use either a PAR-issued
+  `request_uri` or a signed `request` object. A public client may use the query
+  string.
+- A signed request object must be signed, must have `iss` equal to `client_id`,
+  must target the issuer as `aud`, and must not use `none` or a symmetric
+  algorithm. Conflicts between the object and the query string are rejected.
+- A `request_uri` is a one-time, client-bound PAR handle or an exactly
+  pre-registered URI. It is fetched only with the SSRF guard: https only, no
+  redirects, global IPs only, size and time capped.
+
+## Security properties
+
+- A code or device code is single-use and consumed atomically.
+- A rotation is idempotent and reuse is tombstoned and detected.
+- A redirect is matched exactly, and errors before redirect validation do not
+  redirect.
+- The confirmation claim propagates to every newly minted token.
+- A dependency outage fails closed.
+
+## Edge cases
+
+- A refresh request that omits `scope` keeps the prior scope; a request that
+  asks for a scope the client was not granted fails.
+- A second authorization code made from the same request is a distinct code;
+  each is single-use.
+- A DPoP proof outside the `iat` freshness window, or with a replayed `jti`, is
+  rejected.
+- A pre-7.2 Redis has no `WAITAOF`; the server then requires instance-wide
+  `appendfsync always`, or fails closed.
+
+## Operational runbook
+
+- Confirm the advertised capabilities:
+
+```bash
+curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .
+```
+
+- A code flow starts with `GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256`.
+- Introspect a token: `POST /introspect` with `token` and client authentication.
+- Revoke a token: `POST /revoke` with `token` and an optional `token_type_hint`.
+- If every security endpoint returns `503`, check `/health/ready` and the
+  Postgres and Redis connections.
+
+## File and directory references
+
+- `src/protocol/authorize.rs` for authorization validation and the error matrix.
+- `src/protocol/code.rs` for code issuance and the success redirect.
+- `src/protocol/token/` for the grants and `TokenService`.
+- `src/protocol/par.rs`, `src/protocol/device.rs`, `src/protocol/introspect.rs`,
+  `src/protocol/revoke.rs` for the other endpoints.
+- `src/protocol/dcr.rs`, `src/protocol/dcr/manage.rs` for registration.
+- `src/protocol/metadata.rs` for the RFC 8414 document.
+- `src/crypto/pkce.rs` for PKCE.
+- `src/protocol/redirect_repo.rs` for redirect matching.
+- `src/jose/` for DPoP, JAR, and `private_key_jwt`.
+- `src/error.rs` for the error-to-HTTP mapping.

+ 276 - 0
docs/OPERATIONS.md

@@ -0,0 +1,276 @@
+# Operations
+
+Administration is CLI plus declarative configuration only. There is no HTTP
+admin API, no default credential, and nothing is auto-created at startup. This
+is the de-facto admin interface and the runbook for its day-two tasks.
+
+## Purpose
+
+For an operator: the full command surface, the declarative `config sync` file,
+the audit stream, key and certificate rotation, and backup and restore.
+
+## The admin model
+
+- The `migrate` subcommand owns the schema and is the only writer of it.
+- The `serve` subcommand serves OAuth and never migrates and never provisions.
+- Every mutation (scopes, users, entitlements, redirect URIs, client
+  attachments) is audited on the `audit` tracing target.
+- Secrets are read from stdin, a file, or the environment, never from argv. A
+  secret is printed only when the operator asked for a one-time value.
+
+## Command reference
+
+The binary is `d-oauth-server` (`cargo run --bin d-oauth-server -- <command>`).
+Exit codes: `0` success, `1` command failure, `2` usage error.
+
+### serve and migrate
+
+```bash
+d-oauth-server serve
+d-oauth-server migrate
+d-oauth-server config check
+```
+
+- `serve` loads config, connects Postgres and Redis, asserts the Redis
+  durability contract, and runs the TLS public listener plus the plaintext
+  health listener. It never migrates. It drains on `SIGTERM`.
+- `migrate` applies forward-only migrations under sqlx's advisory lock and
+  prints `applied N migration(s)`.
+- `config check` validates configuration and prints `config ok: issuer=...`.
+
+### Users
+
+```bash
+d-oauth-server user create --email <email> [--admin] [--password-stdin] [--password-file <path>]
+d-oauth-server user enroll-totp --email <email>
+d-oauth-server user disable --email <email>
+d-oauth-server user grant-scope --email <email> --scope <scope>
+d-oauth-server user revoke-scope --email <email> --scope <scope>
+d-oauth-server user scopes --email <email>
+```
+
+- `create` reads the password from stdin by default or from `--password-file`.
+  The two sources are mutually exclusive. `--admin` sets `is_admin`. A created
+  user always starts with `must_change_password = true`.
+- `enroll-totp` generates an encrypted TOTP secret and prints the secret and the
+  `otpauth://` URI exactly once. Register the secret in an authenticator now.
+- `grant-scope` requires the scope to exist in the catalog.
+- `revoke-scope` is idempotent.
+
+### Scopes
+
+```bash
+d-oauth-server scope create <name> [--description <text>]
+d-oauth-server scope list
+d-oauth-server scope enable <name>
+d-oauth-server scope disable <name>
+d-oauth-server scope delete <name>
+```
+
+`create` upserts: it creates a scope or updates its description. `delete`
+deletes a scope that nothing references, and disables it when `user_scopes`
+references it, so entitlements are not silently destroyed. The enabled scope
+catalog is what `/metadata` advertises as `scopes_supported`.
+
+### Redirect URIs and client attachments
+
+```bash
+d-oauth-server redirect-uri create <uri> [--kind authorize|post_logout] [--description <text>]
+d-oauth-server redirect-uri list
+d-oauth-server redirect-uri enable <uri> [--kind authorize|post_logout]
+d-oauth-server redirect-uri disable <uri> [--kind authorize|post_logout]
+d-oauth-server redirect-uri delete <uri> [--kind authorize|post_logout]
+d-oauth-server client attach-redirect-uri --client-id <id> --uri <uri> [--kind authorize|post_logout]
+d-oauth-server client detach-redirect-uri --client-id <id> --uri <uri> [--kind authorize|post_logout]
+d-oauth-server client redirect-uris --client-id <id>
+```
+
+A redirect URI is a global catalog entry; a client attaches to it. `/authorize`
+matches only the client's attached and enabled `authorize` set. Detaching or
+disabling a URI stops future redirects to it; already issued tokens are
+unaffected. Deleting a referenced URI disables it instead.
+
+`kind` selects the URI's role. `authorize` (the default) is an
+authorization-response callback. `post_logout` is a bare return target used only
+by `GET /logout` to send the browser back to the client after the session ends.
+A client may register at most one enabled `post_logout` URI; `GET /logout`
+redirects only to it, and only while the URI is enabled and the client active.
+
+### Declarative config
+
+```bash
+d-oauth-server config sync --file <path>
+d-oauth-server config check
+```
+
+`config sync` applies a JSON file idempotently. A second identical run reports
+`config sync: no changes`. See the format below.
+
+### Admin bootstrap
+
+```bash
+d-oauth-server admin bootstrap --email <email> [--password-file <path>] [--force]
+```
+
+Creates the first admin under an advisory lock in one transaction. It refuses
+while any enabled admin exists unless `--force` is given (a loud warning and an
+audit event). The full replacement procedure is in `RUNBOOK.md`.
+
+### Initial access tokens (DCR)
+
+```bash
+d-oauth-server initial-access-token issue --uses <n> --ttl <seconds> [--label <text>]
+```
+
+Mints a one-time-use DCR initial-access token. The raw token is printed once;
+only its keyed hash and remaining-use budget are stored. The token is required
+when `allow_open_registration` is false.
+
+### Key generation and health
+
+```bash
+d-oauth-server keygen --alg es256
+d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
+```
+
+`keygen` emits an ES256 keypair for test fixtures and client-side DPoP keys. It
+is never an authorization-server signing key; the server publishes no
+`jwks_uri`. `healthcheck` probes the health listener over raw HTTP and exits 0
+or 1; it is the container `HEALTHCHECK`.
+
+## config sync file format
+
+The file is JSON. Every section is optional and defaults to empty.
+
+```json
+{
+  "scopes": [
+    { "name": "orders:read", "description": "Read orders", "enabled": true }
+  ],
+  "redirect_uris": [
+    { "uri": "https://app.example.com/callback", "enabled": true },
+    { "uri": "https://app.example.com/signed-out", "kind": "post_logout", "enabled": true }
+  ],
+  "user_scopes": [
+    { "email": "operator@example.com", "scope": "orders:read" }
+  ],
+  "client_redirect_uris": [
+    { "client_id": "example-client", "uri": "https://app.example.com/callback" },
+    { "client_id": "example-client", "uri": "https://app.example.com/signed-out", "kind": "post_logout" }
+  ]
+}
+```
+
+Scope names must match the RFC 6749 scope-token grammar. `openid` is reserved.
+Redirect URIs must be absolute and are matched exactly at `/authorize` (or, for
+`kind: post_logout`, at `GET /logout`). `kind` defaults to `authorize`.
+
+Apply and re-apply:
+
+```bash
+d-oauth-server config sync --file ./oauth-config.json
+d-oauth-server config sync --file ./oauth-config.json
+```
+
+The first reports the counts of what it created; the second reports no changes.
+Upserts use `IS DISTINCT FROM`, so an unchanged row does not bump `updated_at`.
+
+## Audit events
+
+CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
+`operation`, and a `subject`. The operations are `migrate`, `config.sync`,
+`user.create`, `user.enroll_totp`, `user.disable`, `user.grant_scope`,
+`user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
+`scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
+`redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
+`client.detach_redirect_uri`, `initial_access_token.issue`, and the
+`admin.bootstrap.*` family.
+
+HTTP events on the same target include `login.success`, `login.failure`,
+`consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and
+`dcr.register`. No event carries a token, password, OTP, or secret.
+
+Tracing is JSON. Route it to your log pipeline and alert on
+`token.refresh_reuse_detected` and repeated `login.failure`.
+
+## Key and certificate rotation
+
+### Token pepper
+
+Opaque token hashes are stored under a version id, default `v1`, set by
+`token_pepper_id`. The key ring resolves a stored id newest-first across a
+retained chain, so verification keeps working while an old pepper is retained.
+The current loader supplies one active pepper; to rotate without invalidating
+live tokens, retain the previous key id for at least the refresh lifetime
+(`ttl.refresh_secs`, default 30 days) before removing it. New tokens are always
+minted under the active pepper.
+
+### TOTP encryption key
+
+The encrypted TOTP blob carries its key id, and the ring can resolve a retained
+TOTP key by id. Rotate by making the new key active and retaining the old key
+until every user has re-enrolled or until existing secrets are re-encrypted.
+
+### TLS certificate
+
+`serve` loads the certificate chain and key once at startup. Rotate by
+replacing the mounted material and performing a rolling restart; there is no
+hot reload, by design.
+
+## Backup and restore
+
+Postgres holds all durable state, so a normal backup covers it:
+
+```bash
+pg_dump --format=custom "$DATABASE_URL" > oauth.dump
+pg_restore --clean --if-exists --dbname "$DATABASE_URL" oauth.dump
+```
+
+Restore into a migrated database, then run `migrate` to confirm the schema
+version matches the binary. Redis is ephemeral and need not be backed up: codes,
+tokens, sessions, and replay markers are re-created as clients re-authenticate,
+and a Redis loss does not lose clients, users, scopes, or redirect URIs.
+Registration access token hashes are in Postgres, so DCR client management also
+survives a Redis loss.
+
+## Security properties
+
+- No HTTP admin surface and no default credential.
+- Every mutation is audited.
+- Bootstrap refuses a second admin without `--force`, which is loud and
+  audited.
+- A restricted session (forced password change or missing TOTP) cannot reach
+  `/authorize`, the OAuth endpoints, or admin-gated actions.
+- Secrets stay out of argv, logs, and audit events.
+
+## Edge cases
+
+- Deleting a referenced scope or redirect URI disables it rather than leaving a
+  dangling foreign key.
+- A second `config sync` is a no-op; a changed field is an update, not a new
+  row.
+- Rotating the token pepper without retaining the old key invalidates every
+  live opaque token. Plan the overlap.
+- `admin bootstrap` with a non-TTY stdout and no password source refuses rather
+  than print a generated password into job logs.
+
+## Operational runbook
+
+- First install: `migrate`, then `admin bootstrap`, then complete the forced
+  password change and TOTP enrollment, then create a named admin. See
+  `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.
+- Backup: `pg_dump` Postgres; Redis is not backed up.
+- Health: liveness `GET /health/live`, readiness `GET /health/ready` on the
+  plaintext health listener.
+
+## File and directory references
+
+- `src/cli.rs` for the command tree and flags.
+- `src/cli/ops.rs`, `src/cli/user.rs`, `src/cli/scope.rs`,
+  `src/cli/redirect.rs`, `src/cli/sync.rs`, `src/cli/admin.rs`,
+  `src/cli/secret.rs` for implementations.
+- `src/middleware/audit.rs` for the HTTP audit events.
+- `RUNBOOK.md` for the bootstrap and replacement procedure.

+ 189 - 0
docs/REDIS.md

@@ -0,0 +1,189 @@
+# Redis
+
+Redis holds every short-lived value: authorization codes, access and refresh
+tokens, grant chains, sessions, PAR request URIs, device codes, DCR
+initial-access tokens, replay markers, and rate-limit counters. No raw token is
+ever a Redis key or value; keys are keyed hashes and values hold only derived
+state.
+
+## Purpose
+
+This guide covers the key namespaces and their TTLs, the hash-tag scheme that
+keeps atomic operations on one Cluster slot, the tombstone and durability
+model, and the three supported topologies.
+
+## Key namespaces
+
+The token prefix defaults to `oauth:{state}:` and the rate-limit prefix to
+`oauth:{rl}:`. Both are configurable but must keep their hash tags and must
+differ.
+
+| Key | Contents | TTL |
+| --- | --- | --- |
+| `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s) |
+| `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
+| `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
+| `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
+| `oauth:{state}:grant:<id>:tokens` | Set of member token hashes for cascade revoke. | Lives with the grant |
+| `oauth:{state}:session:<id>` | Server-side login session. | `ttl.session` (12h) |
+| `oauth:{state}:session:<id>:meta` | Session sidecar: `auth_time`, CSRF secret, bound `request_id`, and the bound post-logout client/URI. | `ttl.session` |
+| `oauth:{state}:par:<id>` | PAR `request_uri` record. | `ttl.par` (90s) |
+| `oauth:{state}:pending:<id>` | Validated pending-auth record. | Short, request-scoped |
+| `oauth:{state}:device:<hash>` | Device-code record. | `ttl.device` (600s) |
+| `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
+| `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
+| `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
+| `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Assertion window |
+| `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
+| `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label). | Rate-limit window |
+| `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. | Time step window |
+| `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
+
+`<hash>` is always a keyed hash. Tokens are opaque, so the server can look a
+record up by re-hashing the presented value but cannot recover the raw value
+from storage.
+
+## Hash tags and single-slot atomicity
+
+Every token-state key carries the `{state}` hash tag, so one Lua operation that
+touches several keys lands on a single Cluster slot. Rate-limit counters carry
+`{rl}`, which puts them in a different slot group and a different shard from
+token state. This is what keeps two promises:
+
+- A multi-key token operation never fails with `CROSSSLOT`.
+- Counters can never evict tokens, because the two keyspaces are separate and
+  Redis eviction is off entirely (`noeviction`).
+
+The server asserts both properties at startup and refuses a prefix that lacks
+its tag.
+
+## Rotation, tombstones, and idempotency
+
+Refresh rotation is a Lua compare-and-set keyed by a deterministic
+`rotation_request_id`, derived as an HMAC of the pepper over the old refresh
+token hash. The script returns one of:
+
+- `ROTATED`: the refresh was valid and new tokens were written.
+- `REPLAY`: the same request id already completed, so the same minted tokens
+  are returned. A lost reply does not cause a double rotation.
+- `REUSE`: the presented token was already rotated. Its tombstone records the
+  member it rotated to. Reuse also cascades revocation of the whole grant
+  family.
+- `ABSENT`: unknown token.
+- `MISMATCH`: issued to a different client.
+
+The old refresh record is not deleted. It becomes a tombstone with a `used`
+flag and a `rotated_to` member, kept for the refresh lifetime so reuse stays
+detectable. The new tokens are minted before the script runs, so a crash during
+the write cannot mint a token that was never stored.
+
+## The durability barrier
+
+A rotation, tombstone, or replay-marker write is followed by a durability
+barrier issued as a separate command after the write returns and before any
+token is returned to the client. It is never run inside a Lua script or
+`MULTI`/`EXEC`, where `WAIT` and `WAITAOF` do not block.
+
+| Mode | Topology | Barrier |
+| --- | --- | --- |
+| `wait` | Sentinel or Cluster | `WAIT 1 <timeout>`; the write must be acknowledged by at least one replica. |
+| `waitaof` | Single node | `WAITAOF 1 0 <timeout>`; the local AOF must be flushed. Falls back to the `appendfsync always` assertion on servers older than Redis 7.2. |
+| `appendfsync_always` | Single node | Assert `CONFIG GET appendfsync` is `always`. This is instance-wide, not per-write-path. |
+
+A barrier below the required level fails closed with `503`, mapped from
+`StoreError::Durability`. The server-side wait leaves 500 ms of headroom under
+the client response timeout so an expired barrier surfaces as a clean failure
+rather than an ambiguous timeout.
+
+The start of a claim: with the barrier active, a failover cannot resurrect a
+rotated token or lose a replay marker. Any residual window only exists where
+neither barrier nor `appendfsync always` is active, which the configuration
+refuses for a single node.
+
+## Topologies
+
+Exactly one topology is selected at load time.
+
+### Single
+
+One node from `redis.url`, using the driver's `ConnectionManager`. Durability
+must be `waitaof` or `appendfsync_always`; `wait` is rejected because there are
+no replicas.
+
+### Sentinel
+
+A set of `host:port` nodes plus `master_name`. The driver's built-in Sentinel
+support does not reliably follow a promoted master after a failover, so the
+server hand-builds master re-resolution: when the current master fails, it asks
+the Sentinels for the new master and reconnects. Durability is `wait`.
+
+### Cluster
+
+A set of seed nodes. The server uses native slot refresh, so `MOVED` and `ASK`
+redirects recover without a restart. Multi-key token operations stay single-slot
+through the `{state}` tag. Durability is `wait`.
+
+## Error classification and retries
+
+Errors are classified as `Transient` or `Permanent`.
+
+- Transient: I/O errors, timeouts, dropped connections, and `LOADING`,
+  `TRYAGAIN`, `MASTERDOWN`, `CLUSTERDOWN`, `MOVED`, `ASK`, `NOSCRIPT`.
+- Permanent: `OOM` under noeviction, `READONLY`, auth failures, `CROSSSLOT`,
+  type errors, and anything unrecognized.
+
+Only idempotent reads and `SET NX` or idempotent Lua compare-and-set are
+retried, and only on transient errors, under a bounded budget with jittered
+backoff. Non-idempotent operations such as single-use consumes and `INCR` are
+never retried. On an indeterminate post-`EXEC` result the caller re-reads the
+rotation mapping or the record instead of replaying the write.
+
+## Security properties
+
+- Only keyed hashes are stored. A Redis dump does not reveal a usable token.
+- `noeviction` and AOF are required and asserted at startup.
+- Token state and rate-limit counters are separated, so a counter cannot evict
+  a token.
+- Replay markers are written atomically with `SET NX`, so a replayed `jti` is
+  rejected.
+- A failed durability barrier fails the request closed rather than returning a
+  weakly durable token.
+
+## Edge cases
+
+- A Redis flush loses every ephemeral value but not clients, users, scopes, or
+  redirect URIs. Clients registered through DCR can still be managed, because
+  the registration access token hash is in Postgres.
+- A rotation after a failover does not double-rotate: the mapping key resolves
+  the retry to the original minted tokens.
+- A `CROSSSLOT` error fails closed. It is never worked around by splitting an
+  atomic operation across slots.
+
+## Operational runbook
+
+Configure Redis with persistence and eviction off. A minimal single-node
+configuration looks like:
+
+```conf
+appendonly yes
+appendfsync everysec
+maxmemory-policy noeviction
+min-replicas-to-write 1
+min-replicas-max-lag 10
+```
+
+For a single node without replicas, use `appendfsync always` or Redis 7.2+ so
+`WAITAOF` is available, and set `redis.durability = waitaof`.
+
+On startup the server runs `PING`, checks `maxmemory-policy`, and checks
+`appendonly`. A violation is a startup failure, not a warning. To inspect a key
+family, use `SCAN` with the prefix; never use `KEYS` in a request path.
+
+## File and directory references
+
+- `src/store/mod.rs` for the key layout, Lua scripts, and the barrier.
+- `src/store/tokens.rs`, `src/store/grants.rs`, `src/store/codes.rs`,
+  `src/store/sessions.rs`, `src/store/par.rs`, `src/store/device.rs`,
+  `src/store/dcr.rs`, `src/store/ratelimit.rs` for the per-family records.
+- `src/config/redis.rs` for topology, durability, and prefix validation.
+- `src/state/redis.rs`, `src/state/sentinel.rs` for connection management.

+ 294 - 0
docs/SECURITY.md

@@ -0,0 +1,294 @@
+# Security
+
+This guide states the threat model, the token and key design, the sender
+constraint model, the fail-closed policy, and the admin replacement procedure.
+The implementation is the authority; the citations point at it.
+
+## Purpose
+
+For a security reviewer and an operator: what the server protects, how, and what
+to do when something goes wrong. The standards behind the design are RFC 9700
+(BCP 240) and `draft-ietf-oauth-v2-1-16` (published 3 September 2026; an
+Internet-Draft, not an RFC, at
+https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
+
+## Threat model
+
+### Assets
+
+- Opaque access and refresh tokens, and the authorization codes and device codes
+  that yield them.
+- Client secrets, user passwords, TOTP secrets, and DCR initial-access tokens.
+- The token pepper and TOTP encryption keys.
+- The integrity of the authorization decision: which client may act for which
+  user with which scopes.
+
+### Adversaries
+
+- A network attacker who can read and modify traffic. TLS and sender
+  constraints address this.
+- A malicious or compromised client that tries to escalate scope, replay a
+  code, or replay a token.
+- A stolen token used from another key or certificate.
+- An operator or job that could leak a secret through logs, argv, or an error
+  body.
+- A forged reverse-proxy header claiming a client certificate.
+
+### Trust boundaries
+
+- The TLS public listener is the only route to OAuth handlers.
+- The plaintext health listener carries no OAuth routes.
+- Postgres holds durable domain state; Redis holds ephemeral state.
+- Forwarded client-cert and client-IP headers are trusted only from the
+  configured `trusted_proxy`; otherwise they are stripped before any handler
+  reads them.
+
+## Attack surface
+
+This section records the container and host attack surface: how an attacker could
+reach the container or the host through the image, the controls already in place,
+and the residual risk. It is durable threat-model material, not a backlog; update
+it whenever the deployment surface changes (a new listener, mount, dependency
+class, or runtime setting).
+
+| Vector | Existing controls | Residual risk |
+| --- | --- | --- |
+| Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims | Fuzzing is not exhaustive. |
+| Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
+| Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
+| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database | An attacker with code execution reads env vars directly (see `docs/TODO.md` item 7). |
+| Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
+| Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
+| Image/registry compromise | Digest pinning and cosign verification in CI | The base manifest still uses a mutable `:latest` tag that CI rewrites to a digest before rollout (see `docs/TODO.md` item 8). |
+
+Last reviewed: 2026-10-04. The mitigations to close the residual risks are
+tracked in `docs/TODO.md`.
+
+## Toplevel rules
+
+- `#![forbid(unsafe_code)]` across the crate.
+- `unwrap`, `expect`, and `panic` are denied by lint in request paths.
+- A raw token, secret, password, OTP, or client secret never appears in
+  Postgres, Redis, logs, an error body, or an evidence file.
+- Secret comparisons are constant time.
+
+## Browser and intermediary caching
+
+- Every response except `/.well-known/…` metadata carries
+  `Cache-Control: no-store` (+ `Pragma: no-cache` for legacy clients). An
+  authorization page, consent page, token body, request handle, or error is
+  therefore never reused by a browser, the back/forward cache, or a shared
+  intermediary.
+- This covers the `GET /login` and `GET /consent` pages (which carry a
+  session-bound CSRF token and the bound `request_id`), the `/authorize`
+  redirects and error responses, the token endpoint (success and error), PAR,
+  device authorization, DCR, introspection, and revocation.
+- OAuth metadata discovery is intentionally left cacheable; a metadata handler
+  that fails closed returns a `503` error, which is itself `no-store`.
+
+## Opaque tokens, hashed at rest
+
+- A token is at least 256 bits from a CSPRNG, base64url encoded.
+- Only a keyed HMAC-SHA256 hash is persisted, under a versioned pepper key id.
+- The newest pepper mints; verification retries across retained key ids,
+  newest-first and bounded.
+- A raw token is never a storage key or a stored value.
+- A storage dump therefore yields no usable token.
+
+## Rotation, tombstones, and replay
+
+- Refresh rotation is a Lua compare-and-set, keyed by a deterministic
+  `rotation_request_id` derived from the old refresh token hash and the pepper.
+- The old record becomes a tombstone with `used` and `rotated_to`, kept for the
+  refresh lifetime.
+- Reuse of a rotated token is detected, revokes the whole grant family, and
+  emits `token.refresh_reuse_detected`.
+- A lost post-write reply is resolved by re-reading the rotation mapping, so a
+  retry returns the same tokens and never double-rotates.
+- The write is followed by a topology-appropriate durability barrier before any
+  token is returned. A barrier below the required level fails closed.
+
+See [REDIS.md](REDIS.md) for the barrier modes.
+
+## Sender constraints
+
+Reference: RFC 9449 (DPoP), RFC 8705 (mTLS).
+
+Every sender-constrained grant requires a freshly verified proof on every use.
+
+| Client class | Required binding |
+| --- | --- |
+| Public | DPoP only. A missing proof fails with `invalid_grant`. |
+| Confidential | mTLS and/or DPoP. The confirmation records each proven component. |
+
+- `cnf` propagates to every newly minted token.
+- A stored `cnf` alone never mints: the proof is re-verified per use.
+- For token exchange, the proof must match the subject token's confirmation,
+  not merely the caller's.
+- `private_key_jwt` is client authentication only. It produces no confirmation
+  and never satisfies sender constraint. This is deliberate.
+- mTLS identity is a client certificate subject DN (`tls_client_auth`) or a
+  certificate thumbprint (`self_signed_tls_client_auth`), each unique per the
+  partial indexes in the schema.
+- A DPoP proof is rejected if it is malformed, oversized, uses an unsupported
+  algorithm, has the wrong `typ`, or has a bad or replayed `jti`, `htm`, `htu`,
+  `iat`, or `ath`.
+
+## PAR and JAR
+
+Reference: RFC 9126, RFC 9101.
+
+- A confidential client at `/authorize` must use a PAR `request_uri` or a signed
+  `request` object. This is mandatory, not optional.
+- A JAR request object must be signed; `none` and symmetric algorithms are
+  rejected. `iss` must equal `client_id`, `aud` must be the issuer, and conflicts
+  with the query string are rejected.
+- A `request_uri` is one-time and client-bound, or exactly pre-registered, and
+  is fetched only through the SSRF guard (https only, no redirects, global IPs
+  only, size and time capped).
+- PAR credentials are read from the header and body only, never the query.
+
+## Key ring and rotation
+
+- One versioned `KeyRing` holds the token-pepper chain and the TOTP-encryption
+  chain, newest first.
+- Opaque token hashes carry the pepper key id; the TOTP blob carries its key id.
+- Rotating the pepper requires retaining the old key for at least the refresh
+  lifetime so live tokens still verify.
+- `Debug` prints key ids only, never key material.
+- There is no authorization-server JWT-signing key and no `jwks_uri`. The
+  `keygen` command emits keys for test fixtures and client-side DPoP only.
+
+## Fail-closed policy
+
+The seven security endpoints (`authorize`, `token`, `introspect`, `revoke`,
+`par`, `device_authorization`, `register`) are always fail-closed. An attempt to
+configure one as fail-open is a load-time error.
+
+- A Postgres or Redis outage returns `503 temporarily_unavailable` with
+  `Retry-After: 1`, never a fail-open success.
+- Only idempotent operations are retried, under a bounded budget. A
+  non-idempotent token operation is never blindly replayed.
+- Readiness reports `503` while a dependency is down; liveness stays `200`
+  because it touches no dependency.
+- A cold scope cache with Postgres down returns `503` rather than an empty list.
+
+## Replay and single-use
+
+- An authorization code is consumed atomically, exactly once.
+- A PAR `request_uri` is consumed atomically.
+- A device code is consumed atomically on approval.
+- DPoP and `private_key_jwt` `jti` values are recorded with `SET NX`, so a
+  replayed proof or assertion is rejected.
+- A TOTP code is recorded in a replay cache for its time-step window.
+
+## Input and output safety
+
+- Redirect URI matching is exact, with the RFC 8252 loopback exception only.
+  There are no wildcards and no prefix matches.
+- PKCE is `S256` only. A verifier cannot be omitted to downgrade.
+- Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
+  enabled`, and refresh can only narrow.
+- The login, consent, and device pages set a strict CSP with
+  `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
+  no open redirect.
+- `GET /logout` is a server-specific session endpoint (not OAuth 2.1/OIDC
+  defined). It destroys the session and clears the cookie, then renders a
+  neutral page or redirects to a `post_logout` URI. It accepts **no query
+  parameters**: the target is read only from session meta, frozen when the
+  authorization flow was bound, and used only if the owning client is still
+  active and still registers that exact URI (exact-string match + RFC 8252
+  loopback). A forced navigation to `/logout` can therefore only end a session
+  and, at most, send the browser to that client's own pre-registered URI — never
+  to an attacker target. There is no `post_logout_redirect_uri` parameter and no
+  `id_token_hint`.
+- Failed-login and lockout counters live in Redis, not Postgres, so a login
+  flood does not write rows.
+- Rate limiting is per IP and per client. Counters live in a Redis keyspace
+  separated from token state.
+- CORS is empty by default and never a wildcard.
+- DCR is protected by a one-time initial-access token; open registration is off
+  by default.
+- Secrets are read from stdin, a file, or the environment, never argv, and are
+  printed only for an explicit one-time value.
+
+## Admin bootstrap and replace immediately
+
+No default admin credential ships. `migrate` seeds zero users and `serve` never
+creates one. The first admin is created by `admin bootstrap`, which runs inside a
+single transaction under an advisory lock, so two concurrent bootstraps cannot
+both create an admin. Once any enabled admin exists, a second bootstrap refuses
+unless `--force` is given, which prints a loud warning and emits
+`admin.bootstrap.force`.
+
+The bootstrap admin starts with `must_change_password = true` and
+`totp_enabled = false`. Its first login yields a restricted session that can only
+change the password and enroll TOTP; `/authorize`, the OAuth endpoints, and
+admin-gated actions deny it until both complete.
+
+Replace it immediately:
+
+- [ ] Log in as the bootstrap admin and change the password (minimum 12
+      characters). This clears `must_change_password` and stamps
+      `password_changed_at`.
+- [ ] Enroll TOTP and confirm with an authenticator code; the session is
+      re-issued with full authority only now.
+- [ ] Capture the recovery codes shown once and store them offline.
+- [ ] Create a named second admin:
+      `d-oauth-server user create --admin --email <name>@example.com`.
+- [ ] Verify the named admin has full access.
+- [ ] Disable the bootstrap account:
+      `d-oauth-server user disable --email <bootstrap>@example.com`.
+- [ ] Rotate or expire the bootstrap secret: delete the mounted password file
+      and revoke any generated one-time password.
+
+The step-by-step command runbook lives in `RUNBOOK.md`. The split is deliberate:
+`RUNBOOK.md` is the task-47 operator procedure, and this section is the task-48
+security rationale and checklist.
+
+## Detecting an incident
+
+- `token.refresh_reuse_detected` means a rotated refresh token was replayed.
+  Treat the grant family as compromised.
+- Repeated `login.failure` for one subject means a credential-stuffing attempt.
+- `admin.bootstrap.force` means an admin was created despite an existing one.
+- A spike in `503` on security endpoints means a dependency is down; the server
+  is failing closed, not open.
+
+## Operational runbook
+
+1. Rotate the suspect credential: disable the user, revoke the client, or
+   rotate the client secret.
+2. Revoke the affected tokens. Reuse detection already cascades the family.
+3. If the token pepper may be compromised, rotate it and accept that tokens
+   minted under the old pepper stop verifying once it is removed.
+4. If a TLS key is compromised, replace the mounted material and roll the
+   replicas (there is no hot reload).
+5. Preserve logs. The `X-Request-Id` header correlates a request to its logs;
+   audit events never contain a secret.
+
+## Edge cases
+
+- A Redis flush loses ephemeral tokens but not clients, users, scopes, or
+  redirect URIs. DCR client management survives because its token hash is in
+  Postgres.
+- An L7 ingress that terminates TLS strips the client certificate; in-process
+  mTLS then fails. Use L4 passthrough or trusted-proxy mode.
+- A trusted proxy that is misconfigured to a broad address trusts forged
+  headers. Keep `trusted_proxy` as narrow as possible.
+- A public client that loses its DPoP key cannot use its tokens; there is no
+  bearer fallback for a sender-constrained grant.
+
+## File and directory references
+
+- `src/crypto/` for token minting, hashing, TOTP, and constant-time compare.
+- `src/keys.rs` for the key ring.
+- `src/protocol/token/types.rs` for the sender-constraint matrix.
+- `src/jose/dpop.rs`, `src/jose/jar.rs`,
+  `src/protocol/client_auth/assertion.rs` for proof verification.
+- `src/protocol/mtls.rs` for mTLS matching.
+- `src/store/mod.rs`, `src/store/tokens.rs` for rotation and tombstones.
+- `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 bootstrap procedure.

+ 260 - 0
docs/TODO.md

@@ -0,0 +1,260 @@
+# TODO
+
+This is a backlog of deferred hardening work. Each item is a candidate to be
+planned and implemented later; none of them is a current defect that makes the
+server unusable. The items came out of a threat-model review that asked one
+question: if an attacker walks off with a full dump of the PostgreSQL database,
+what can they still read or crack, and what should we do about it?
+
+This document only records the gaps. It does not describe anything that is
+already implemented, and it does not propose code changes here.
+
+## What a database dump exposes today
+
+The baseline is better than a naive server's, because secrets are stored hashed
+or encrypted, and the key material lives outside the database. The table below
+is the full accounting.
+
+| Data | Protection at rest | If the dump leaks |
+| --- | --- | --- |
+| User passwords | Argon2id PHC, salted (`src/db/user_repo.rs`) | Offline cracking only. |
+| Client secrets | Argon2id PHC (`src/db/client_repo.rs`) | Offline cracking only. |
+| TOTP secrets | AES-256-GCM under `SHA256(totp_key)` (`src/crypto/totp.rs`) | Safe only if the key is not in the dump. |
+| TOTP recovery codes | Argon2id PHC (`src/db/user_repo/totp.rs`) | Offline cracking only. |
+| DCR registration access tokens | Keyed HMAC hash (`src/db/client_repo.rs`) | Safe; the pepper is not in the database. |
+| Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
+| `jwks` (client public keys) | Plaintext | Public by design. |
+| `tls_client_auth_subject_dn` and cert thumbprints | Plaintext | Identifier disclosure. |
+| `is_admin`, `disabled`, `must_change_password`, timestamps | Plaintext | Metadata disclosure. |
+
+The critical dependency is outside the database. The token pepper and the TOTP
+encryption key are supplied through environment variables or mounted files
+(`src/config/raw.rs`, `src/config/build.rs`), never through a table. A pure
+database dump therefore does not yield them, and every hashed or encrypted
+column above stays opaque. This separation is the single most important
+protection the server has today, and the gaps below are mostly about not
+depending on it alone.
+
+## Gaps to plan and implement
+
+Each item names the risk, the change to plan, and the files it would touch. The
+list is deliberately short and ordered by value.
+
+### 1. Application-level encryption of PII columns
+
+- **Risk.** Emails, client names, contacts, and redirect URIs are plaintext. A
+  dump leaks the full user and client directory, which enables phishing,
+  credential stuffing against other services, and mapping of who talks to whom.
+- **Proposed change.** Encrypt these columns with AES-256-GCM under a KeyRing
+  key. Add a blind index (a keyed HMAC of the normalized value) for the `email`
+  lookup so case-insensitive uniqueness still works.
+- **Files.** `src/db/user_repo.rs`, `src/db/client_repo.rs`,
+  `src/db/redirect_repo.rs`, `src/keys.rs`, `migrations/`.
+
+### 2. Strengthen TOTP key derivation
+
+- **Risk.** The TOTP encryption key is derived with a bare `SHA256(totp_key)`
+  (`src/crypto/totp.rs`) and no KDF stretching. If the key file leaks alongside
+  the dump, every TOTP secret falls immediately. A low-entropy passphrase is
+  cheap to brute-force when a single hash is all that stands in the way.
+- **Proposed change.** Derive the AES key with Argon2id or HKDF from a
+  high-entropy key, so a leaked passphrase costs an attacker real work.
+- **Files.** `src/crypto/totp.rs`, `src/cli/secret.rs`.
+
+### 3. Envelope encryption with per-row DEKs
+
+- **Risk.** One static key encrypts every TOTP secret. There is no per-row data
+  key, so there is no way to rotate the master key without re-encrypting the
+  entire table in one pass, and a single key compromise exposes all rows.
+- **Proposed change.** Give each row a data encryption key wrapped by the master
+  key, which makes rotation incremental and limits a single leak's blast radius.
+- **Files.** `src/crypto/totp.rs`, `src/keys.rs`.
+
+### 4. KMS/HSM-backed master key
+
+- **Risk.** The master keys are plain environment variables or files. Where they
+  are stored, who can read them, and whether they ever end up in the same backup
+  as the database is undocumented and unenforced.
+- **Proposed change.** Document, and optionally support, a master key held in a
+  KMS or HSM. At a minimum, document the hard requirement that the key never
+  lives with the database backup.
+- **Files.** `src/config/`, `docs/DEPLOYMENT.md`, `docs/SECURITY.md`.
+
+### 5. Breach-response runbook
+
+- **Risk.** There is no documented procedure for what to rotate after a dump, or
+  in what order. The [SECURITY.md](SECURITY.md) operational runbook covers an
+  incident in general terms but not a full database disclosure.
+- **Proposed change.** Document the blast radius and the rotation order: rotate
+  the token pepper first so all opaque tokens die, then rotate the TOTP key so
+  users re-enroll, then rotate client secrets, then rotate DCR registration
+  tokens.
+- **Files.** `docs/SECURITY.md`, `RUNBOOK.md`.
+
+### 6. Database access hardening
+
+- **Risk.** There is no tamper-evidence or audit trail for database access, and
+  no guidance on encrypted backups or Postgres-level encryption such as TDE or
+  `pgcrypto`. A dump could be taken by a read-only job and leave no trace.
+- **Proposed change.** Document the controls and, where they fit, implement them.
+- **Files.** `docs/DATABASE.md`, `docs/DEPLOYMENT.md`.
+
+## Container and host hardening
+
+The attack surface and the existing controls are documented in
+[SECURITY.md](SECURITY.md); this section lists only the mitigations to close the
+residual risks.
+
+This section covers a second threat-model question: if an attacker is already
+inside the image's trust boundary, how could they reach the container or the
+host, and how should we reduce that risk? The image is already hardened: it runs
+as non-root UID 65532, uses a read-only root filesystem, drops all capabilities,
+sets `allowPrivilegeEscalation: false` and `seccompProfile: RuntimeDefault`,
+ships no shell, builds a static musl binary from digest-pinned bases, and
+produces an SBOM, provenance, and a cosign signature in CI. The remaining vectors
+are therefore narrow, and none of them is a current defect. This section lists
+the mitigations to implement.
+
+### Mitigations to plan and implement
+
+### 7. Move secrets from environment variables to files
+
+- **Risk.** Environment variables are readable by the process
+  (`/proc/self/environ`), appear in `docker inspect`, are inherited by children,
+  and leak into logs and crash dumps; an attacker with code execution reads the
+  token pepper and TOTP key directly.
+- **Proposed change.** Mount the pepper and TOTP key as files (tmpfs, mode
+  `0400`, owned by 65532) and use the already-supported
+  `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` variables
+  (`src/config/raw.rs`, `src/config/build.rs`).
+- **Files.** `deploy/k8s/deployment.yaml`, `deploy/k8s/secret.yaml`,
+  `compose.yaml`, `docs/DEPLOYMENT.md`, `docs/CONFIGURATION.md`.
+
+### 8. Pin the image by digest in the committed manifest
+
+- **Risk.** The base manifest uses a mutable `:latest` tag with
+  `imagePullPolicy: IfNotPresent`; a compromised registry tag could be pulled if
+  the CI digest-rewrite step does not run.
+- **Proposed change.** Commit the digest-pinned image reference.
+- **Files.** `deploy/k8s/deployment.yaml`, `deploy/k8s/kustomization.yaml`.
+
+### 9. Add a NetworkPolicy
+
+- **Risk.** Unrestricted pod egress allows SSRF-to-metadata and data
+  exfiltration.
+- **Proposed change.** Restrict egress to Postgres and Redis only, and ingress
+  to the Service.
+- **Files.** `deploy/k8s/` (new `networkpolicy.yaml`), `docs/DEPLOYMENT.md`.
+
+### 10. Disable the service-account token
+
+- **Risk.** The app never calls the Kubernetes API, so the auto-mounted service
+  account token is pure attack surface.
+- **Proposed change.** Set `automountServiceAccountToken: false` on the pod.
+- **Files.** `deploy/k8s/deployment.yaml`.
+
+### 11. Strengthen runtime isolation
+
+- **Risk.** A kernel or runtime escape is possible after code execution.
+- **Proposed change.** Consider a sandboxed runtime class (gVisor/Kata) and a
+  custom seccomp allowlist instead of `RuntimeDefault`.
+- **Files.** `deploy/k8s/deployment.yaml`, `docs/DEPLOYMENT.md`.
+
+### 12. Continuous fuzzing and dependency review
+
+- **Risk.** CI-only fuzzing and `cargo deny` do not catch a zero-day in a
+  trusted crate.
+- **Proposed change.** Run the fuzz targets continuously and add
+  `cargo vet`/`cargo crev` review.
+- **Files.** `.github/workflows/`, `fuzz/`, `deny.toml`.
+
+### 13. Restrict and audit container exec
+
+- **Risk.** `docker exec` / `kubectl exec` into the running container bypasses
+  every image control.
+- **Proposed change.** Restrict who may exec and audit it.
+- **Files.** `docs/OPERATIONS.md`, `docs/DEPLOYMENT.md`.
+
+## Frontend usability
+
+The server-rendered pages (`src/ui/`) are intentionally minimal unstyled HTML
+with no JavaScript (the page CSP sets `script-src 'none'`). This section lists
+the usability work to make them presentable without weakening that posture:
+any styling must keep the CSP and the login CSRF tokens intact.
+
+### 14. Prettify frontend screens
+
+- **Problem.** The login, consent, device, password-change, and TOTP pages are
+  bare HTML. They work but look unfinished, which undermines trust in a
+  security-critical flow.
+- **Proposed change.** Add a small shared stylesheet served from the binary
+  (same-origin, no external assets, no inline scripts) with a clean, readable
+  layout for every page: consistent headings, readable forms, visible error
+  states (`role="alert"` preserved), and a responsive width. Keep zero
+  JavaScript so `script-src 'none'` still holds; if any inline `<style>` is
+  used, scope the CSP with a hash rather than `unsafe-inline`.
+- **Files.** `src/ui/` (all page templates), `src/routes.rs` (middleware order
+  if a static-asset route is added), `docs/ARCHITECTURE.md` (if the CSP
+  changes).
+
+### 15. Add QR code generation for TOTP enrollment
+
+- **Problem.** TOTP setup shows the `otpauth://` URI as text
+  (`src/crypto/totp.rs`), so enrolling means hand-typing the secret into the
+  authenticator app. Users expect to scan a QR code.
+- **Proposed change.** Render the enrollment secret as a QR code
+  server-side (e.g. the `qrcode` crate emitting SVG embedded in the page),
+  so no client-side script is needed and `script-src 'none'` still holds.
+  Keep showing the secret in text form as a fallback, alongside the recovery
+  codes already displayed once at enrollment.
+- **Files.** `src/crypto/totp.rs` (URI builder), `src/ui/login.rs` (TOTP setup
+  template), `Cargo.toml`/`Cargo.lock` (new dependency, must pass
+  `cargo deny check`).
+
+## HTTP response hardening
+
+### 16. Make the HTTP cache policy explicit on every endpoint
+
+- **Risk.** `Cache-Control: no-store` is applied at a single choke point on the
+  public router (`src/middleware/security_headers.rs`), with `/.well-known/…`
+  metadata exempt, so it covers every OAuth endpoint today. The policy is
+  nevertheless implicit: metadata discovery is merely left unmarked and its
+  caching relies on browser heuristics, the plaintext health listener never
+  passes through the layer, and a handler exercised off-router would carry no
+  header. A new route mounted outside `build_router_with_options` would silently
+  miss the policy.
+- **Proposed change.** Make the policy explicit and complete: give
+  `/.well-known/oauth-authorization-server` a positive `Cache-Control` (for
+  example `public, max-age=…`) so metadata caching is intentional and tunable;
+  mark the health probes `no-store` (or mount the security-header layer on the
+  health listener); and consider `Vary: Cookie` on the authenticated pages as
+  defense in depth. Keep metadata cacheable; do not extend `no-store` to it.
+- **Files.** `src/middleware/security_headers.rs`, `src/routes.rs`,
+  `src/serve.rs`, `src/state/health.rs`, `docs/SECURITY.md`.
+
+## Priority
+
+Ranked by value against effort:
+
+- **High value, low effort:** items 1, 2, 5.
+- **Medium:** items 3, 4.
+- **Lower, mostly operational:** items 6 and 16.
+- **Container/host hardening:** items 7, 8, 9, 10 are high value and low effort;
+  items 11, 12, 13 are medium/operational.
+
+Recommendation: implement 1, 2, 5, 7, 8, 9, and 10 first. Item 1 closes the
+largest plaintext disclosure, item 2 removes an easy offline win if the key file
+leaks, item 5 is mostly writing down a procedure the team already needs, and
+items 7 through 10 are high-value, low-effort container and host controls.
+
+## Open questions
+
+- Is the token pepper and TOTP key supplied through environment variables,
+  mounted files, or a secrets manager in the target deployment? This decides
+  whether envelope encryption (item 3) is worth building at all.
+- Should PII encryption (item 1) be mandatory or gated behind configuration?
+- Should the pepper and TOTP key move to file-based secrets now, or wait for the
+  envelope-encryption work (item 3)?
+- Is a sandboxed runtime class (gVisor/Kata) available in the target cluster?
+- Should metadata discovery carry an explicit positive `Cache-Control`, or is
+  leaving it to browser heuristics acceptable (item 16)?

+ 5 - 0
fuzz/.gitignore

@@ -0,0 +1,5 @@
+target/
+corpus/
+artifacts/
+coverage/
+Cargo.lock

+ 37 - 0
fuzz/Cargo.toml

@@ -0,0 +1,37 @@
+[package]
+name = "d-oauth-fuzz"
+version = "0.0.0"
+publish = false
+edition = "2024"
+
+[package.metadata]
+cargo-fuzz = true
+
+# Standalone workspace: the fuzz crate is never built by the pinned stable
+# toolchain at the repo root (`cargo nextest run --all-targets` ignores it).
+# It builds only under the nightly leg in CI (`fuzz/rust-toolchain.toml`).
+[workspace]
+
+[dependencies]
+base64 = "0.23"
+libfuzzer-sys = "0.4"
+serde_json = "1"
+time = "0.3"
+uuid = "1"
+
+[dependencies.d-oauth]
+path = ".."
+
+# Explicit binaries: nightly cargo rejects `cargo build --bins` (which
+# `cargo fuzz build` issues) when the manifest declares no targets.
+[[bin]]
+name = "token_form"
+path = "fuzz_targets/token_form.rs"
+
+[[bin]]
+name = "dcr_json"
+path = "fuzz_targets/dcr_json.rs"
+
+[[bin]]
+name = "jar_claims"
+path = "fuzz_targets/jar_claims.rs"

+ 22 - 0
fuzz/fuzz_targets/dcr_json.rs

@@ -0,0 +1,22 @@
+//! Fuzz dynamic client registration JSON (`RegisterRequest` deserialization).
+//!
+//! The DCR body must never panic the deserializer on any input; unknown
+//! fields are ignored and client-asserted secrets are never honored (the
+//! server mints every credential it returns).
+
+#![no_main]
+
+use d_oauth::protocol::dcr::RegisterRequest;
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(value) = serde_json::from_slice::<serde_json::Value>(data) else {
+    return;
+  };
+  let Ok(request) = serde_json::from_value::<RegisterRequest>(value) else {
+    return;
+  };
+  // Deserialization accepted the body; the request carries no server-minted
+  // credential surface (`client_secret`, ids, timestamps are absent by type).
+  let _ = request.redirect_uris.is_some() || request.scope.is_some();
+});

+ 80 - 0
fuzz/fuzz_targets/jar_claims.rs

@@ -0,0 +1,80 @@
+//! Fuzz the JAR (request object) claims envelope plus the PAR/JAR
+//! mandatory-rule dispatch.
+//!
+//! The target splits the input into a faux `token_endpoint_auth_method` and a
+//! compact-JWT-shaped claims envelope. The envelope parser (three
+//! base64url segments, JSON header/payload objects) must never panic;
+//! `require_par_or_jar` pins the confidential-client dispatch. Full
+//! signature verification needs keys and stays under unit/proptest cover.
+
+#![no_main]
+
+use d_oauth::db::Client;
+use d_oauth::jose::jar::require_par_or_jar;
+use libfuzzer_sys::fuzz_target;
+
+fn envelope_invariants(compact: &str) {
+  let mut parts = compact.split('.');
+  let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) =
+    (parts.next(), parts.next(), parts.next(), parts.next())
+  else {
+    return;
+  };
+  if sig_b64.is_empty() {
+    return;
+  }
+  use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+  let Ok(header_raw) = URL_SAFE_NO_PAD.decode(header_b64) else {
+    return;
+  };
+  let Ok(payload_raw) = URL_SAFE_NO_PAD.decode(payload_b64) else {
+    return;
+  };
+  let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_raw) else {
+    return;
+  };
+  let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&payload_raw) else {
+    return;
+  };
+  // Header and payload are JSON objects; a request object names its
+  // algorithm and carries claims. Presence (not validity) is the envelope
+  // property the fuzzer pins.
+  let _ = header.is_object() && payload.is_object();
+}
+
+fn client_with_method(method: &str) -> Client {
+  Client {
+    id: uuid::Uuid::nil(),
+    client_id: "fuzz-client".to_owned(),
+    client_secret_expires_at: None,
+    client_id_issued_at: time::OffsetDateTime::UNIX_EPOCH,
+    client_name: "fuzz".to_owned(),
+    grant_types: vec!["authorization_code".to_owned()],
+    response_types: vec!["code".to_owned()],
+    scope: String::new(),
+    token_endpoint_auth_method: method.to_owned(),
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: None,
+    tls_client_auth_cert_thumbprint: None,
+    request_uris: None,
+    contacts: None,
+    software_id: None,
+    active: true,
+    created_at: time::OffsetDateTime::UNIX_EPOCH,
+    updated_at: time::OffsetDateTime::UNIX_EPOCH,
+  }
+}
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let (method, compact) = match text.split_once('\n') {
+    Some((method, compact)) => (method, compact),
+    None => ("none", text),
+  };
+  envelope_invariants(compact);
+  let client = client_with_method(method);
+  let _ = require_par_or_jar(&client);
+});

+ 24 - 0
fuzz/fuzz_targets/token_form.rs

@@ -0,0 +1,24 @@
+//! Fuzz the opaque token-form parser (`TokenHash::parse_storage_string`).
+//!
+//! The parser must never panic on any input; an accepted value must
+//! re-render byte-identically (parse-print stability).
+
+#![no_main]
+
+use d_oauth::crypto::tokens::TokenHash;
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let Ok(parsed) = TokenHash::parse_storage_string(text) else {
+    return;
+  };
+  // Accepted values re-render to the exact input (canonical form only).
+  let rendered = parsed.to_storage_string();
+  let Ok(reparsed) = TokenHash::parse_storage_string(&rendered) else {
+    return;
+  };
+  debug_assert_eq!(parsed, reparsed);
+});

+ 7 - 0
fuzz/rust-toolchain.toml

@@ -0,0 +1,7 @@
+# cargo-fuzz project layout (task 42). Fuzzing needs a nightly toolchain:
+# the root `rust-toolchain.toml` stays pinned to stable, and this file
+# overrides the toolchain for the `fuzz/` directory only (rustup applies the
+# nearest `rust-toolchain.toml` to the working directory). CI runs
+# `cargo fuzz build` with `fuzz/` as its working directory.
+[toolchain]
+channel = "nightly"

+ 4 - 0
migrations/.gitkeep

@@ -0,0 +1,4 @@
+Placeholder so `sqlx::migrate!()` compiles before todos 8/9 land.
+
+sqlx ignores files that are not `<VERSION>_<DESCRIPTION>.sql`; this file
+is never treated as a migration. Todos 8/9 add the real migrations here.

+ 47 - 0
migrations/1791072000_oauth_clients.sql

@@ -0,0 +1,47 @@
+-- RFC 7591 dynamic client registration / RFC 7592 client management.
+--
+-- Forward-only: deterministic, no DROP / down path. Redirect URIs are
+-- deliberately NOT stored on this row; they live in the `redirect_uris`
+-- catalog plus the `client_redirect_uris` join (todo 9).
+--
+-- The `UNIQUE (client_id)` table constraint (not merely a unique index) is
+-- required because `client_redirect_uris.client_id` references this column,
+-- and PostgreSQL only accepts a unique *constraint* as a foreign-key target.
+
+CREATE TABLE oauth_clients (
+  id                              uuid        NOT NULL,
+  client_id                       text        NOT NULL,
+  client_secret_hash              text        NULL,
+  client_secret_expires_at        timestamptz NULL,
+  client_id_issued_at             timestamptz NOT NULL,
+  client_name                     text        NOT NULL,
+  grant_types                     text[]      NOT NULL,
+  response_types                  text[]      NOT NULL,
+  scope                           text        NOT NULL,
+  token_endpoint_auth_method      text        NOT NULL,
+  jwks                            jsonb       NULL,
+  jwks_uri                        text        NULL,
+  tls_client_auth_subject_dn      text        NULL,
+  tls_client_auth_cert_thumbprint text        NULL,
+  request_uris                    jsonb       NULL,
+  contacts                        jsonb       NULL,
+  software_id                     text        NULL,
+  active                          bool        NOT NULL DEFAULT true,
+  registration_access_token_hash  text        NULL,
+  created_at                      timestamptz NOT NULL,
+  updated_at                      timestamptz NOT NULL,
+  CONSTRAINT oauth_clients_pkey PRIMARY KEY (id),
+  CONSTRAINT ux_oauth_clients_client_id UNIQUE (client_id)
+);
+
+-- A client certificate (subject DN or SHA-256 thumbprint) authenticates at
+-- most one client; NULL means "not enrolled" and is unconstrained, so a
+-- partial unique index is required (todo 21 relies on the ambiguity being
+-- rejected at write time).
+CREATE UNIQUE INDEX ux_oauth_clients_tls_subject_dn
+  ON oauth_clients (tls_client_auth_subject_dn)
+  WHERE tls_client_auth_subject_dn IS NOT NULL;
+
+CREATE UNIQUE INDEX ux_oauth_clients_tls_cert_thumbprint
+  ON oauth_clients (tls_client_auth_cert_thumbprint)
+  WHERE tls_client_auth_cert_thumbprint IS NOT NULL;

+ 92 - 0
migrations/1791158400_users.sql

@@ -0,0 +1,92 @@
+-- Users, scope catalog, per-user entitlements, redirect-URI catalog, and
+-- per-client URI attachments (plan Scope line 31 / line 33).
+--
+-- Forward-only: deterministic, no DROP / down path. This migration creates
+-- schema ONLY and seeds ZERO rows: the scope catalog, users, and redirect URIs
+-- are all created at runtime via CLI / `config sync` / `admin bootstrap` /
+-- DCR. Two deliberate omissions:
+--   * no failed-login/lockout columns: those counters are security-ephemeral
+--     and live in Redis (todo 11), never Postgres;
+--   * the TOTP secret is stored ENCRYPTED as `bytea` (todo: TOTP encryption
+--     key from the KeyRing), never as a plaintext column.
+--
+-- `users.id` PK and `oauth_clients.client_id` (task 8) are the FK targets for
+-- the join tables below, so this migration's version is later than 1791072000.
+
+-- Case-insensitive email-as-username requires the `citext` type. The extension
+-- is created here (idempotently) because this is the first migration that needs
+-- it; PostgreSQL requires superuser/trusted-extension privileges to install it.
+CREATE EXTENSION IF NOT EXISTS citext;
+
+CREATE TABLE users (
+  id                        uuid        NOT NULL,
+  email                     citext      NOT NULL,
+  password_hash             text        NOT NULL,
+  totp_secret_enc           bytea       NULL,
+  totp_enabled              bool        NOT NULL DEFAULT false,
+  totp_recovery_code_hashes jsonb       NULL,
+  is_admin                  bool        NOT NULL DEFAULT false,
+  must_change_password      bool        NOT NULL DEFAULT false,
+  password_changed_at       timestamptz NULL,
+  disabled                  bool        NOT NULL DEFAULT false,
+  created_at                timestamptz NOT NULL,
+  updated_at                timestamptz NOT NULL,
+  CONSTRAINT users_pkey PRIMARY KEY (id),
+  -- citext comparison makes this case-insensitive: `A@x.com` and `a@x.com`
+  -- collide, so one account per email regardless of case.
+  CONSTRAINT ux_users_email UNIQUE (email)
+);
+
+-- Runtime-managed scope catalog, created EMPTY (todo 9 has no seed rows).
+-- The enabled rows are the single source of truth for `scopes_supported`.
+CREATE TABLE scopes (
+  name        text        NOT NULL,
+  description text        NOT NULL DEFAULT '',
+  enabled     bool        NOT NULL DEFAULT true,
+  created_at  timestamptz NOT NULL,
+  updated_at  timestamptz NOT NULL,
+  CONSTRAINT scopes_pkey PRIMARY KEY (name)
+);
+
+-- Per-user entitlements. New users start with none (explicit grants only).
+CREATE TABLE user_scopes (
+  user_id    uuid        NOT NULL,
+  scope_name text        NOT NULL,
+  granted_at timestamptz NOT NULL,
+  granted_by uuid        NULL,
+  CONSTRAINT user_scopes_pkey PRIMARY KEY (user_id, scope_name),
+  CONSTRAINT fk_user_scopes_user FOREIGN KEY (user_id)
+    REFERENCES users(id) ON DELETE CASCADE,
+  CONSTRAINT fk_user_scopes_scope FOREIGN KEY (scope_name)
+    REFERENCES scopes(name)
+);
+
+-- Runtime-managed redirect-URI catalog. `uri` is stored normalized-exact and
+-- is globally unique; clients attach to entries via `client_redirect_uris`.
+-- Redirect URIs attach to clients, never to users.
+CREATE TABLE redirect_uris (
+  id          uuid        NOT NULL,
+  uri         text        NOT NULL,
+  enabled     bool        NOT NULL DEFAULT true,
+  description text        NOT NULL DEFAULT '',
+  created_by  uuid        NULL,
+  created_at  timestamptz NOT NULL,
+  updated_at  timestamptz NOT NULL,
+  CONSTRAINT redirect_uris_pkey PRIMARY KEY (id),
+  CONSTRAINT ux_redirect_uris_uri UNIQUE (uri)
+);
+
+-- Join between a client and a catalog redirect URI. Composite PK makes an
+-- attachment unique; the client FK needs a unique CONSTRAINT (task 8 provides
+-- `ux_oauth_clients_client_id`, not merely a unique index).
+CREATE TABLE client_redirect_uris (
+  client_id       text        NOT NULL,
+  redirect_uri_id uuid        NOT NULL,
+  attached_at     timestamptz NOT NULL,
+  attached_by     uuid        NULL,
+  CONSTRAINT client_redirect_uris_pkey PRIMARY KEY (client_id, redirect_uri_id),
+  CONSTRAINT fk_client_redirect_uris_client FOREIGN KEY (client_id)
+    REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
+  CONSTRAINT fk_client_redirect_uris_uri FOREIGN KEY (redirect_uri_id)
+    REFERENCES redirect_uris(id) ON DELETE CASCADE
+);

+ 7 - 0
migrations/1791244800_client_dpop_bound.sql

@@ -0,0 +1,7 @@
+-- RFC 9449 §5 DPoP-bound access tokens: persist the registration opt-in.
+--
+-- Forward-only: deterministic, no DROP / down path. The default false keeps
+-- every existing client unconstrained until it re-registers or is updated.
+
+ALTER TABLE oauth_clients
+  ADD COLUMN dpop_bound_access_tokens boolean NOT NULL DEFAULT false;

+ 13 - 0
migrations/1791331200_redirect_uri_kind.sql

@@ -0,0 +1,13 @@
+-- Distinguish authorization-response redirect URIs from post-logout return
+-- URIs in the shared catalog. Both kinds use the same exact-match + RFC 8252
+-- loopback validation, so they live in one table rather than a parallel one.
+--
+-- Existing rows predate post-logout support and are all authorization-response
+-- URIs, so they backfill to 'authorize' via the column default.
+ALTER TABLE redirect_uris
+  ADD COLUMN kind text NOT NULL DEFAULT 'authorize';
+
+ALTER TABLE redirect_uris
+  DROP CONSTRAINT ux_redirect_uris_uri,
+  ADD CONSTRAINT ux_redirect_uris_uri_kind UNIQUE (uri, kind),
+  ADD CONSTRAINT ck_redirect_uris_kind CHECK (kind IN ('authorize', 'post_logout'));

+ 3 - 0
rust-toolchain.toml

@@ -0,0 +1,3 @@
+[toolchain]
+channel = "stable"
+components = ["rustfmt", "clippy"]

+ 2 - 0
rustfmt.toml

@@ -0,0 +1,2 @@
+tab_spaces = 2
+group_imports = "StdExternalCrate"

+ 103 - 0
scripts/check_doc_links.py

@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Local documentation link checker for d-OAuth.
+
+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
+no network dependency and the RFC references are stable public URLs.
+
+Usage:
+  python3 scripts/check_doc_links.py            # scan the default set
+  python3 scripts/check_doc_links.py README.md  # scan explicit files
+
+Exit code 0 when every relative link resolves, 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+# Inline Markdown links only: [text](target). The docs use no reference-style
+# links, so a simpler parser is enough and avoids a Markdown dependency.
+LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
+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 += sorted((REPO_ROOT / "docs").glob("*.md"))
+
+
+def slugify(text: str) -> str:
+    """GitHub-style heading slug: lowercase, drop punctuation, spaces to '-'.
+
+    Fenced code blocks are stripped by the caller, so inline code backticks
+    may still appear; they are removed here. Trailing duplicate-numbering
+    suffixes are not modeled because the docs use unique headings.
+    """
+    text = text.replace("`", "")
+    text = re.sub(r"[^\w\s-]", "", text.lower())
+    return re.sub(r"\s+", "-", text).strip("-")
+
+
+def headings_of(path: Path) -> set[str]:
+    """Every heading slug in a Markdown file, ignoring fenced code blocks."""
+    slugs: set[str] = set()
+    in_fence = False
+    for line in path.read_text(encoding="utf-8").splitlines():
+        stripped = line.lstrip()
+        if stripped.startswith("```"):
+            in_fence = not in_fence
+            continue
+        if in_fence:
+            continue
+        match = HEADING_RE.match(line)
+        if match:
+            slugs.add(slugify(match.group(2)))
+    return slugs
+
+
+def check_file(path: Path, problems: list[str]) -> None:
+    if not path.exists():
+        problems.append(f"missing doc: {path.relative_to(REPO_ROOT)}")
+        return
+    text = path.read_text(encoding="utf-8")
+    for target in LINK_RE.findall(text):
+        if target.startswith(EXTERNAL_PREFIXES):
+            continue
+        if target.startswith("#"):
+            anchor = target[1:]
+            if anchor and slugify(anchor) not in headings_of(path):
+                problems.append(f"{path.relative_to(REPO_ROOT)}: missing anchor #{anchor}")
+            continue
+        file_part, _, anchor = target.partition("#")
+        resolved = (path.parent / file_part).resolve()
+        if not resolved.exists():
+            problems.append(f"{path.relative_to(REPO_ROOT)}: broken link -> {target}")
+            continue
+        if anchor and resolved.suffix == ".md":
+            if slugify(anchor) not in headings_of(resolved):
+                problems.append(
+                    f"{path.relative_to(REPO_ROOT)}: missing anchor #{anchor} in {file_part}"
+                )
+
+
+def main(argv: list[str]) -> int:
+    files = [Path(a) for a in argv[1:]] if len(argv) > 1 else DEFAULT_FILES
+    problems: list[str] = []
+    for path in files:
+        check_file(path if path.is_absolute() else REPO_ROOT / path, problems)
+    if problems:
+        print("doc link check FAILED:")
+        for problem in problems:
+            print(f"  - {problem}")
+        return 1
+    print(f"doc link check passed: {len(files)} file(s) scanned")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))

+ 260 - 0
scripts/check_manifests.py

@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+"""Cluster-manifest policy lint (task 46).
+
+Validates `deploy/k8s` (and `compose.yaml` lightly) against the cluster
+contract WITHOUT a live cluster:
+
+- Deployment: 3 replicas, non-root 65532, read-only rootfs, RuntimeDefault
+  seccomp, ALL capabilities dropped, requests+limits, startup/liveness on
+  `/health/live` + readiness on `/health/ready` via httpGet against the
+  plaintext health port (8081), `terminationGracePeriodSeconds >= 45`,
+  topology spread, and NO per-replica migration.
+- Migration Job: exactly one, runs `migrate`, `restartPolicy: Never`.
+- PDB: `minAvailable: 2`.
+- Service: never exposes the health port (8081).
+- No baked secrets: no PEM blocks in manifests; `REPLACE_ME` placeholders
+  only inside `secret.yaml`; no secret-shaped keys in the ConfigMap.
+- L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
+  in-process client-cert auth is enabled (`D_OAUTH_TLS__CLIENT_CA_PATH`
+  set, or client-cert Ingress annotations) FAILS. mTLS needs L4/TCP
+  passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode.
+  This constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
+  here; it is explicitly NOT exercised end-to-end in CI.
+
+Usage:
+  python3 scripts/check_manifests.py [--manifest-dir deploy/k8s]
+                                     [--strict-digest]
+
+`--strict-digest` (used by supply-chain CI after the pin step) additionally
+requires every workload image to be digest-pinned (`@sha256:<64 hex>`).
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+import yaml
+
+HEALTH_PORT = 8081
+PUBLIC_PORT = 8080
+TERMINATION_GRACE_MIN = 45
+SECRET_NAME_RE = re.compile(r"(?i)(pepper|password|secret|private[_-]?key|totp)")
+PEM_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN CERTIFICATE-----")
+DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
+HIGH_ENTROPY_VALUE_RE = re.compile(r"^[A-Za-z0-9+/=_-]{32,}$")
+
+CLIENT_CERT_ANNOTATIONS = (
+  "nginx.ingress.kubernetes.io/auth-tls-verify-client",
+  "nginx.ingress.kubernetes.io/auth-tls-secret",
+  "haproxy.org/ssl-verify-client",
+  "traefik.ingress.kubernetes.io/router.tls.options",
+)
+
+
+class Failures:
+  def __init__(self) -> None:
+    self.items: list[str] = []
+
+  def add(self, msg: str) -> None:
+    self.items.append(msg)
+
+  def ok(self) -> bool:
+    return not self.items
+
+
+def load_manifests(manifest_dir: Path) -> tuple[list[dict], Failures]:
+  fails = Failures()
+  docs: list[dict] = []
+  files = sorted(manifest_dir.glob("*.yaml"))
+  if not files:
+    fails.add(f"no manifests found in {manifest_dir}")
+    return docs, fails
+  for path in files:
+    if path.name.endswith(".example.yaml"):
+      continue
+    try:
+      loaded = list(yaml.safe_load_all(path.read_text()))
+    except yaml.YAMLError as exc:
+      fails.add(f"{path.name}: invalid YAML: {exc}")
+      continue
+    for doc in loaded:
+      if doc:
+        doc["_source"] = path.name
+        docs.append(doc)
+  return docs, fails
+
+
+def by_kind(docs: list[dict], kind: str) -> list[dict]:
+  return [d for d in docs if d.get("kind") == kind]
+
+
+def check_pem_and_placeholders(docs: list[dict], fails: Failures) -> None:
+  for doc in docs:
+    src = doc.get("_source", "?")
+    text = yaml.safe_dump({k: v for k, v in doc.items() if not k.startswith("_")})
+    if PEM_RE.search(text):
+      fails.add(f"{src}: baked key material (PEM block) in a manifest")
+    if src != "secret.yaml" and "REPLACE_ME" in text:
+      fails.add(f"{src}: placeholder belongs only in secret.yaml")
+
+
+def check_configmap(doc: dict, fails: Failures) -> None:
+  data = doc.get("data") or {}
+  for key in data:
+    if SECRET_NAME_RE.search(key):
+      fails.add(f"configmap: secret-shaped key {key!r} must live in the Secret")
+  if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
+    pass  # mTLS on: L7 check below becomes load-bearing.
+
+
+def mtls_enabled(docs: list[dict]) -> bool:
+  for doc in by_kind(docs, "ConfigMap"):
+    data = doc.get("data") or {}
+    if (data.get("D_OAUTH_TLS__CLIENT_CA_PATH") or "").strip():
+      return True
+  return False
+
+
+def check_deployment(doc: dict, fails: Failures, strict_digest: bool) -> None:
+  name = doc.get("metadata", {}).get("name", "?")
+  spec = doc.get("spec", {})
+  if spec.get("replicas") != 3:
+    fails.add(f"deployment/{name}: replicas must be 3")
+  tpl = spec.get("template", {}).get("spec", {})
+  if tpl.get("terminationGracePeriodSeconds", 0) < TERMINATION_GRACE_MIN:
+    fails.add(f"deployment/{name}: terminationGracePeriodSeconds must be >= 45")
+  pod_sc = tpl.get("securityContext", {})
+  if pod_sc.get("runAsNonRoot") is not True:
+    fails.add(f"deployment/{name}: runAsNonRoot must be true")
+  if pod_sc.get("runAsUser") != 65532:
+    fails.add(f"deployment/{name}: runAsUser must be 65532")
+  if (pod_sc.get("seccompProfile") or {}).get("type") != "RuntimeDefault":
+    fails.add(f"deployment/{name}: seccompProfile.type must be RuntimeDefault")
+  if not tpl.get("topologySpreadConstraints"):
+    fails.add(f"deployment/{name}: topologySpreadConstraints required")
+  for container in tpl.get("containers", []):
+    cname = container.get("name", "?")
+    csc = container.get("securityContext", {})
+    if csc.get("readOnlyRootFilesystem") is not True:
+      fails.add(f"deployment/{name}/{cname}: readOnlyRootFilesystem must be true")
+    if "ALL" not in (csc.get("capabilities") or {}).get("drop", []):
+      fails.add(f"deployment/{name}/{cname}: capabilities.drop must include ALL")
+    res = container.get("resources", {})
+    if not res.get("requests") or not res.get("limits"):
+      fails.add(f"deployment/{name}/{cname}: requests and limits required")
+    args = " ".join(
+      str(a)
+      for a in (
+        list(container.get("command", [])) + list(container.get("args", []))
+      )
+    )
+    if "migrate" in args:
+      fails.add(f"deployment/{name}/{cname}: replicas must never migrate")
+    probes = {
+      "startupProbe": ("/health/live", True),
+      "livenessProbe": ("/health/live", True),
+      "readinessProbe": ("/health/ready", True),
+    }
+    for probe_name, (path, _) in probes.items():
+      probe = container.get(probe_name) or {}
+      http_get = probe.get("httpGet") or {}
+      if http_get.get("path") != path or http_get.get("port") != HEALTH_PORT:
+        fails.add(
+          f"deployment/{name}/{cname}: {probe_name} must httpGet "
+          f"{path} on port {HEALTH_PORT} (plaintext health listener)"
+        )
+    image = container.get("image", "")
+    if strict_digest and not DIGEST_RE.search(image):
+      fails.add(f"deployment/{name}/{cname}: image must be digest-pinned")
+
+
+def check_job(doc: dict, fails: Failures, strict_digest: bool) -> None:
+  name = doc.get("metadata", {}).get("name", "?")
+  spec = doc.get("spec", {}).get("template", {}).get("spec", {})
+  if spec.get("restartPolicy") != "Never":
+    fails.add(f"job/{name}: restartPolicy must be Never")
+  containers = spec.get("containers", [])
+  if not any(
+    "migrate" in " ".join(str(a) for a in (list(c.get("command", [])) + list(c.get("args", []))))
+    for c in containers
+  ):
+    fails.add(f"job/{name}: exactly one migrate Job must run `/d-oauth-server migrate`")
+  for container in containers:
+    if strict_digest and not DIGEST_RE.search(container.get("image", "")):
+      fails.add(f"job/{name}: image must be digest-pinned")
+
+
+def check_pdb(doc: dict, fails: Failures) -> None:
+  if doc.get("spec", {}).get("minAvailable") != 2:
+    fails.add("poddisruptionbudget: minAvailable must be 2")
+
+
+def check_service(doc: dict, fails: Failures) -> None:
+  ports = doc.get("spec", {}).get("ports", [])
+  exposed = {p.get("port") for p in ports} | {p.get("targetPort") for p in ports}
+  if HEALTH_PORT in exposed:
+    fails.add("service: must not expose the plaintext health port 8081")
+
+
+def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
+  """An L7 ingress that terminates TLS strips the client certificate."""
+  ingresses = by_kind(docs, "Ingress")
+  if not ingresses:
+    return
+  client_auth = mtls_enabled(docs)
+  for ing in ingresses:
+    name = ing.get("metadata", {}).get("name", "?")
+    annotations = (ing.get("metadata", {}) or {}).get("annotations", {}) or {}
+    terminates = bool((ing.get("spec", {}) or {}).get("tls"))
+    wants_client_cert = client_auth or any(a in annotations for a in CLIENT_CERT_ANNOTATIONS)
+    if terminates and wants_client_cert:
+      fails.add(
+        f"ingress/{name}: L7 TLS termination strips the client certificate "
+        "while in-process client-cert auth is enabled — use L4/TCP "
+        "passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode"
+      )
+
+
+def main() -> int:
+  parser = argparse.ArgumentParser()
+  parser.add_argument("--manifest-dir", default="deploy/k8s")
+  parser.add_argument("--strict-digest", action="store_true")
+  args = parser.parse_args()
+
+  fails = Failures()
+  docs, load_fails = load_manifests(Path(args.manifest_dir))
+  fails.items.extend(load_fails.items)
+
+  kinds = {d.get("kind") for d in docs}
+  for required in ("Deployment", "Service", "ConfigMap", "Secret", "Job", "PodDisruptionBudget"):
+    kind_name = "Job" if required == "Job" else required
+    if required not in kinds:
+      fails.add(f"missing required kind: {required} ({kind_name})")
+
+  check_pem_and_placeholders(docs, fails)
+  for doc in by_kind(docs, "ConfigMap"):
+    check_configmap(doc, fails)
+  for doc in by_kind(docs, "Deployment"):
+    check_deployment(doc, fails, args.strict_digest)
+  for doc in by_kind(docs, "Job"):
+    check_job(doc, fails, args.strict_digest)
+  for doc in by_kind(docs, "PodDisruptionBudget"):
+    check_pdb(doc, fails)
+  for doc in by_kind(docs, "Service"):
+    check_service(doc, fails)
+  check_l7_mtls(docs, fails)
+
+  if fails.ok():
+    print(f"manifest lint: OK ({len(docs)} documents, strict-digest={args.strict_digest})")
+    return 0
+  print(f"manifest lint: {len(fails.items)} failure(s):")
+  for item in fails.items:
+    print(f"  - {item}")
+  return 1
+
+
+if __name__ == "__main__":
+  sys.exit(main())

+ 485 - 0
src/cli.rs

@@ -0,0 +1,485 @@
+//! Operator CLI: `serve`, `migrate`, `user`, `scope`, `redirect-uri`,
+//! `client`, `config`, `admin`, `initial-access-token`, `keygen`, `healthcheck`.
+//!
+//! Administration is CLI + declarative config only (no HTTP admin API). No
+//! default credential ships and nothing is auto-created at startup: users,
+//! scopes, redirect URIs, and initial-access tokens exist only because an
+//! operator asked for them. Passwords, TOTP secrets, and tokens are read from
+//! stdin/env/file, **never argv**, and a secret is printed only when the
+//! operator explicitly asked for a one-time value (a new user's password must
+//! be supplied; `user enroll-totp` prints the enrollment secret once;
+//! `initial-access-token issue` prints the token once).
+//!
+//! `migrate` is forward-only and applies `sqlx::migrate!` under sqlx's own
+//! Postgres advisory lock; `serve` never migrates. Mutations are audited on the
+//! dedicated `audit` tracing target.
+//!
+//! Storage: the database repository modules are owned by sibling tasks, so the
+//! CLI (which owns operator provisioning end-to-end) uses runtime `sqlx`
+//! queries against the migrated schema rather than `query!` macros, keeping the
+//! command surface independent of the `.sqlx` cache. See
+//! `.omo/evidence/task-14-rust-oauth21-server.md`.
+
+use std::path::PathBuf;
+
+use clap::{Parser, Subcommand, ValueEnum};
+
+mod admin;
+mod context;
+mod ops;
+mod redirect;
+mod scope;
+mod secret;
+mod sync;
+mod user;
+
+#[cfg(test)]
+mod test_support;
+#[cfg(test)]
+mod tests;
+
+pub use context::CliError;
+
+/// ES256 is the only `keygen` algorithm (P-256 / SHA-256).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+pub enum KeyAlg {
+  /// ECDSA P-256 with SHA-256 (`ES256`).
+  Es256,
+}
+
+/// `d-oauth-server` operator command tree.
+#[derive(Debug, Parser)]
+#[command(
+  name = "d-oauth-server",
+  version,
+  about = "OAuth 2.1-only authorization server operator CLI",
+  propagate_version = true
+)]
+pub struct Cli {
+  /// Subcommand to run.
+  #[command(subcommand)]
+  pub command: Command,
+}
+
+/// Top-level operator commands.
+#[derive(Debug, Subcommand)]
+pub enum Command {
+  /// Run the HTTP server. Never applies migrations.
+  Serve,
+  /// Apply forward-only migrations under sqlx's advisory lock.
+  Migrate,
+  /// Manage admin-provisioned users.
+  User {
+    /// User subcommand.
+    #[command(subcommand)]
+    command: UserCommand,
+  },
+  /// Manage the runtime scope catalog.
+  Scope {
+    /// Scope subcommand.
+    #[command(subcommand)]
+    command: ScopeCommand,
+  },
+  /// Manage the runtime redirect-URI catalog.
+  RedirectUri {
+    /// Redirect-URI subcommand.
+    #[command(subcommand)]
+    command: RedirectUriCommand,
+  },
+  /// Manage per-client redirect-URI attachments.
+  Client {
+    /// Client subcommand.
+    #[command(subcommand)]
+    command: ClientCommand,
+  },
+  /// Validate configuration or apply a declarative file.
+  Config {
+    /// Config subcommand.
+    #[command(subcommand)]
+    command: ConfigCommand,
+  },
+  /// Bootstrap operations (admin bootstrap lands in a later task).
+  Admin {
+    /// Admin subcommand.
+    #[command(subcommand)]
+    command: AdminCommand,
+  },
+  /// Issue a one-time DCR initial-access token.
+  InitialAccessToken {
+    /// Initial-access-token subcommand.
+    #[command(subcommand)]
+    command: InitialAccessTokenCommand,
+  },
+  /// Generate an ES256 keypair for test fixtures / client DPoP keys only.
+  ///
+  /// This is NEVER an authorization-server signing key: the server publishes
+  /// no JWKS and signs no AS artifact.
+  Keygen {
+    /// Key algorithm (ES256 only).
+    #[arg(long, value_enum, default_value = "es256")]
+    alg: KeyAlg,
+  },
+  /// Self-probe `/health/live` over HTTP/loopback; exits 0 (live) or 1.
+  ///
+  /// Used as the container `HEALTHCHECK` because a minimal image has no
+  /// shell or curl.
+  Healthcheck {
+    /// Health URL (loopback by default).
+    #[arg(long, default_value = "http://127.0.0.1:8081/health/live")]
+    url: String,
+  },
+}
+
+/// `user` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum UserCommand {
+  /// Create a user; password comes from stdin or a file, never argv.
+  Create {
+    /// User email (case-insensitive).
+    #[arg(long)]
+    email: String,
+    /// Grant the `is_admin` flag.
+    #[arg(long)]
+    admin: bool,
+    /// Read the password from stdin (default when `--password-file` is absent).
+    #[arg(long)]
+    password_stdin: bool,
+    /// Read the password from a file (trailing newline trimmed).
+    #[arg(long, value_name = "PATH")]
+    password_file: Option<PathBuf>,
+  },
+  /// Enroll TOTP: generates, encrypts, and stores a secret, printing it once.
+  EnrollTotp {
+    /// User email.
+    #[arg(long)]
+    email: String,
+  },
+  /// Disable a user account.
+  Disable {
+    /// User email.
+    #[arg(long)]
+    email: String,
+  },
+  /// Grant a catalog scope entitlement to a user.
+  GrantScope {
+    /// User email.
+    #[arg(long)]
+    email: String,
+    /// Scope name (must exist in the catalog).
+    #[arg(long)]
+    scope: String,
+  },
+  /// Revoke a catalog scope entitlement from a user.
+  RevokeScope {
+    /// User email.
+    #[arg(long)]
+    email: String,
+    /// Scope name.
+    #[arg(long)]
+    scope: String,
+  },
+  /// List a user's scope entitlements.
+  Scopes {
+    /// User email.
+    #[arg(long)]
+    email: String,
+  },
+}
+
+/// `scope` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum ScopeCommand {
+  /// Create a scope (or update its description when it exists).
+  Create {
+    /// Scope name.
+    name: String,
+    /// Optional human description.
+    #[arg(long)]
+    description: Option<String>,
+  },
+  /// List the scope catalog.
+  List,
+  /// Enable a scope.
+  Enable {
+    /// Scope name.
+    name: String,
+  },
+  /// Disable a scope.
+  Disable {
+    /// Scope name.
+    name: String,
+  },
+  /// Delete a scope (referenced scopes are disabled instead).
+  Delete {
+    /// Scope name.
+    name: String,
+  },
+}
+
+/// `redirect-uri` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum RedirectUriCommand {
+  /// Create a redirect URI (or update its description when it exists).
+  Create {
+    /// Absolute redirect URI.
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+    /// Optional human description.
+    #[arg(long)]
+    description: Option<String>,
+  },
+  /// List the redirect-URI catalog.
+  List,
+  /// Enable a redirect URI.
+  Enable {
+    /// Redirect URI.
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+  },
+  /// Disable a redirect URI.
+  Disable {
+    /// Redirect URI.
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+  },
+  /// Delete a redirect URI (referenced URIs are disabled instead).
+  Delete {
+    /// Redirect URI.
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+  },
+}
+
+/// `client` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum ClientCommand {
+  /// Attach a catalog redirect URI to a client.
+  AttachRedirectUri {
+    /// OAuth client identifier.
+    #[arg(long)]
+    client_id: String,
+    /// Redirect URI from the catalog.
+    #[arg(long)]
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+  },
+  /// Detach a catalog redirect URI from a client.
+  DetachRedirectUri {
+    /// OAuth client identifier.
+    #[arg(long)]
+    client_id: String,
+    /// Redirect URI.
+    #[arg(long)]
+    uri: String,
+    /// Catalog kind: `authorize` (default) or `post_logout`.
+    #[arg(long, default_value = "authorize", value_parser = ["authorize", "post_logout"])]
+    kind: String,
+  },
+  /// List a client's attached redirect URIs.
+  RedirectUris {
+    /// OAuth client identifier.
+    #[arg(long)]
+    client_id: String,
+  },
+}
+
+/// `config` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum ConfigCommand {
+  /// Apply a declarative file idempotently (scopes, redirect URIs, entitlements).
+  Sync {
+    /// YAML/JSON declarative file.
+    #[arg(long, value_name = "PATH")]
+    file: PathBuf,
+  },
+  /// Validate configuration; exits non-zero when invalid.
+  Check,
+}
+
+/// `admin` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum AdminCommand {
+  /// Bootstrap the first admin. Refuses while any enabled admin exists
+  /// unless `--force` is given (loud warning + audit event).
+  Bootstrap {
+    /// Admin email.
+    #[arg(long)]
+    email: String,
+    /// Read the password from a file (else stdin; empty stdin generates one).
+    #[arg(long, value_name = "PATH")]
+    password_file: Option<PathBuf>,
+    /// Create another admin even though one already exists (audited).
+    #[arg(long)]
+    force: bool,
+  },
+}
+
+/// `initial-access-token` subcommands.
+#[derive(Debug, Subcommand)]
+pub enum InitialAccessTokenCommand {
+  /// Issue an initial-access token with a bounded use budget.
+  Issue {
+    /// Registration calls this token authorizes.
+    #[arg(long)]
+    uses: u64,
+    /// Lifetime in seconds.
+    #[arg(long)]
+    ttl: u64,
+    /// Operator label for audit (never a secret).
+    #[arg(long, default_value = "cli")]
+    label: String,
+  },
+}
+
+/// Parse and execute the CLI, returning the process exit code.
+///
+/// `--help`/`--version` print and return `0`; an unknown command prints usage
+/// to stderr and returns `2`; a command failure prints a clear message to
+/// stderr and returns `1`.
+pub async fn main_entry() -> i32 {
+  let cli = match Cli::try_parse() {
+    Ok(cli) => cli,
+    Err(error) => {
+      let code = if error.use_stderr() { 2 } else { 0 };
+      let _ = error.print();
+      return code;
+    }
+  };
+  match run(cli).await {
+    Ok(code) => code,
+    Err(error) => {
+      eprintln!("error: {error}");
+      1
+    }
+  }
+}
+
+/// Execute a parsed command.
+///
+/// # Errors
+/// Returns [`CliError`] for any configuration, database, store, or I/O
+/// failure; the caller maps that to a non-zero exit code.
+pub async fn run(cli: Cli) -> Result<i32, CliError> {
+  match cli.command {
+    Command::Serve => {
+      ops::serve().await?;
+      Ok(0)
+    }
+    Command::Migrate => {
+      ops::migrate().await?;
+      Ok(0)
+    }
+    Command::User { command } => {
+      let context = context::Context::from_env().await?;
+      run_user(&context, command).await?;
+      Ok(0)
+    }
+    Command::Scope { command } => {
+      let context = context::Context::from_env().await?;
+      scope::run(&context, command).await?;
+      Ok(0)
+    }
+    Command::RedirectUri { command } => {
+      let context = context::Context::from_env().await?;
+      redirect::run_uri(&context, command).await?;
+      Ok(0)
+    }
+    Command::Client { command } => {
+      let context = context::Context::from_env().await?;
+      redirect::run_client(&context, command).await?;
+      Ok(0)
+    }
+    Command::Config { command } => match command {
+      ConfigCommand::Check => {
+        ops::config_check()?;
+        Ok(0)
+      }
+      ConfigCommand::Sync { file } => {
+        let report = sync::run(&file).await?;
+        if report.is_noop() {
+          println!("config sync: no changes");
+        } else {
+          println!("{report}");
+        }
+        Ok(0)
+      }
+    },
+    Command::Admin { command } => {
+      run_admin(command).await?;
+      Ok(0)
+    }
+    Command::InitialAccessToken { command } => {
+      run_initial_access_token(command).await?;
+      Ok(0)
+    }
+    Command::Keygen { alg } => {
+      ops::keygen(alg)?;
+      Ok(0)
+    }
+    Command::Healthcheck { url } => ops::healthcheck(&url).await,
+  }
+}
+
+async fn run_user(context: &context::Context, command: UserCommand) -> Result<(), CliError> {
+  match command {
+    UserCommand::Create {
+      email,
+      admin,
+      password_stdin,
+      password_file,
+    } => {
+      let password = context::read_secret(password_stdin, password_file.as_deref())?;
+      user::create(context, &email, admin, &password).await
+    }
+    UserCommand::EnrollTotp { email } => {
+      let enrollment = user::enroll_totp(context, &email).await?;
+      println!("totp secret (shown once): {}", enrollment.secret);
+      println!("otpauth uri: {}", enrollment.uri);
+      Ok(())
+    }
+    UserCommand::Disable { email } => user::disable(context, &email).await,
+    UserCommand::GrantScope { email, scope } => user::grant_scope(context, &email, &scope).await,
+    UserCommand::RevokeScope { email, scope } => user::revoke_scope(context, &email, &scope).await,
+    UserCommand::Scopes { email } => {
+      let scopes = user::list_scopes(context, &email).await?;
+      if scopes.is_empty() {
+        println!("{email} has no scope entitlements");
+      } else {
+        for scope in scopes {
+          println!("{scope}");
+        }
+      }
+      Ok(())
+    }
+  }
+}
+
+async fn run_admin(command: AdminCommand) -> Result<(), CliError> {
+  match command {
+    AdminCommand::Bootstrap {
+      email,
+      password_file,
+      force,
+    } => ops::admin_bootstrap(&email, password_file.as_deref(), force).await,
+  }
+}
+
+async fn run_initial_access_token(command: InitialAccessTokenCommand) -> Result<(), CliError> {
+  match command {
+    InitialAccessTokenCommand::Issue { uses, ttl, label } => {
+      let token = ops::issue_initial_access_token(uses, ttl, &label).await?;
+      println!("initial access token (shown once): {token}");
+      Ok(())
+    }
+  }
+}

Some files were not shown because too many files changed in this diff