diff --git a/.config/nextest.toml b/.config/nextest.toml index febdae11..22238dfa 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -21,17 +21,51 @@ slow-timeout = { period = "60s", terminate-after = 3 } [profile.ci.junit] path = "junit.xml" -# The container group is **empty as of `S-C59`**, and the declaration stays. +# The container group holds the adapter suites that stand up a real backing service. # # It existed for the retired Salvo auth crate, whose integration tests spun up shared Postgres # and Valkey containers and shared a tracing `Once` — running them concurrently was the main -# flakiness source, so the group was pinned to one thread with retries as the backstop. The -# rebuilt server has no container test at all: every port has a deterministic in-memory adapter -# and Kynos's `TestClient` drives a built service in-process, which is why `mise run test-rust` -# now needs no podman for anything above the storage ports. +# flakiness source, so the group was pinned to one thread with retries as the backstop. It was +# then empty for the length of the Kynos rebuild, kept rather than deleted because the first +# real adapter would need exactly it and rediscovering the one-thread rule by watching CI flake +# is the expensive way to learn it. Every port still has a deterministic in-memory adapter and +# Kynos's `TestClient` drives a built service in-process, which is why `mise run test-rust` needs +# no podman — the container suites below are env-gated and pass as skipped without their variable. # -# The group is kept rather than deleted because the first Postgres or Valkey *adapter* will need -# exactly it, and rediscovering the one-thread rule by watching CI flake is the expensive way to -# learn it. An empty test group costs nothing. +# The Postgres adapters (#402) are that first adapter. Every container-backed case lives under a +# test module named `postgres_conformance`, so the filterset below is mechanical rather than a +# list somebody has to remember to extend — a new port's suite joins the group by being named +# like the others. Each of those cases starts its **own** container (nextest runs a process per +# test, so a shared one would buy nothing), which is why one thread matters: five Postgres +# instances racing to bind ports and warm up is the flakiness the group exists to prevent. +# +# **These tests skip themselves without `CAPSULE_TEST_POSTGRES=1`**, printing one line each that +# says so — see `capsule_server::postgres::testing`. The default `cargo nextest run` is green on +# a machine with no container runtime, which is the acceptance gap design/module-map.md sets. +# +# `capsule-server/tests/valkey.rs` (#403) is the second, and joins by binary rather than by test +# name because the whole binary is container-backed. It runs the store and counter conformance +# suites against a `valkey/valkey` container when `CAPSULE_TEST_VALKEY=1`. One thread, for the +# same reason and one more: each test starts its own container and the eviction view is one +# global sorted set. [test-groups.containers] max-threads = 1 + +[[profile.default.overrides]] +filter = 'test(postgres_conformance)' +test-group = 'containers' + +[[profile.ci.overrides]] +filter = 'test(postgres_conformance)' +test-group = 'containers' +# A container that loses a port race or is still warming up is the transient failure the `ci` +# profile's retries exist for; nextest reports flaky separately from failed, so a retry that +# succeeds is still visible. + +[[profile.default.overrides]] +filter = 'package(capsule-server) and binary(valkey)' +test-group = 'containers' + +[[profile.ci.overrides]] +filter = 'package(capsule-server) and binary(valkey)' +test-group = 'containers' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 46c0e979..a10c10ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,16 +11,22 @@ updates: directory: "/capsule-web" schedule: interval: "weekly" + # The workspace root: `Cargo.toml` and `Cargo.lock` live here and cover every member. This + # pointed at `/capsule-api` until the Salvo tree moved to `legacy-review/` in `S-C59`, so it + # had been watching a directory that no longer exists — and therefore watching nothing. - package-ecosystem: "cargo" - directory: "/capsule-api" - schedule: - interval: "weekly" - - package-ecosystem: "docker" - directory: "/capsule-api" + directory: "/" schedule: interval: "weekly" + # The server's local service images (Postgres, Valkey). Same story: `/capsule-api/compose.yaml` + # went with the Salvo tree, and `capsule-server/compose.yaml` is the live file (issue #401). + # + # There is no `docker` entry any more. It watched `/capsule-api/Containerfile`, and no + # Containerfile exists anywhere in the active tree — an OCI image for the rebuilt server is + # not written yet, and an ecosystem pointed at an absent file is a permanent dashboard error + # rather than a dependency update. Add it back in the change that adds the Containerfile. - package-ecosystem: "docker-compose" - directory: "/capsule-api" + directory: "/capsule-server" schedule: interval: "weekly" - package-ecosystem: "github-actions" diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index cc44e179..771ab755 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -53,13 +53,6 @@ jobs: working_directory: capsule-swift install_args: tuist xcbeautify - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc; the macOS image does not carry it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Rust FFI xcframework run: bash capsule-swift/Scripts/build-rust-ffi.sh @@ -123,13 +116,6 @@ jobs: working_directory: capsule-swift install_args: tuist xcbeautify - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc; the macOS image does not carry it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Build Rust FFI xcframework run: bash capsule-swift/Scripts/build-rust-ffi.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef44697e..d243fba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,20 +51,51 @@ jobs: - 'hk.pkl' - '.cargo/**' - 'capsule-server/**' - - 'capsule-wire/**' - 'capsule-i18n/**' - 'capsule-cli/**' + - 'capsule-e2e/**' - 'capsule-core/**' - 'capsule-sdk/**' + - 'capsule-core-ffi/**' + - 'capsule-wasm/**' - 'xtask/**' + # `check-rust` reads the catalogs directly: `i18n-check` and + # `i18n-guard` are `cargo run -p xtask -- i18n …` over `locales/`, + # so a catalog-only change must still pay the Rust gate. The + # `swift` filter already lists it for the same reason. + # + # `i18n-check` compares `locales/` against its *generated* outputs, + # and drift is drift whichever side moved — a hand-edit to a + # generated catalog is the case it exists to catch. `check-rust` is + # the only task that runs it and the `rust` job is the only job that + # runs `check-rust`, so these four globs are what makes the CI gate + # at least as wide as the local one — hk.pkl's `i18n-check` step + # lists the same generated targets (the two remaining entries of + # that glob, `capsule-i18n/src/**` and `xtask/src/i18n.rs`, are + # already covered here by `capsule-i18n/**` and `xtask/**`). Keep + # the two in step. + - 'locales/**' + - 'capsule-web/src/i18n/messages/**' + - 'capsule-swift/Generated/**' + - 'capsule-android/src/androidMain/res/values*/strings.xml' - '.github/workflows/ci.yml' web: - 'capsule-web/**' - 'mise.toml' - 'mise-tasks/**' - '.github/workflows/ci.yml' + # Every artifact the docs build reads, not just `capsule-docs/**` — the + # generator turns two committed description artifacts into the `/reference/` + # pages, so a filter that names only the site lets a stale reference page + # publish on a change to the surface it describes. See + # design/developer-docs.md, "Artifacts cross the boundary, not toolchains". docs: - 'capsule-docs/**' + - 'capsule-cli/cli-surface.json' + - 'capsule-server/openapi.json' + # `capsule-docs/biome.jsonc` extends the root one, so a change there can fail + # this job's format and lint steps from outside `capsule-docs/**`. + - 'biome.jsonc' - 'mise.toml' - 'mise-tasks/**' - '.github/workflows/ci.yml' @@ -81,11 +112,32 @@ jobs: - 'capsule-docs/endpoint-census-allowlist.txt' - 'capsule-docs/planned-modules.txt' - 'capsule-server/openapi.json' + # No check reads this one today; it is here because the cross-links check + # resolves any repo-relative path a document names, and the reference prose + # now names this artifact. Cheap: the job installs nothing. + - 'capsule-cli/cli-surface.json' - 'capsule-*/src/**' - '**/*.md' - '**/*.mdx' - 'LICENSE' - 'NOTICE' + # For the `roadmap` check, which arrives with `#422` together with + # the ROADMAP.md it reads — neither is in the tree at this commit. + # It will resolve that file's rows against the manifests below, + # which declare what actually exists, so a pull request that adds + # or removes a package without touching Markdown must still run the + # job — otherwise the gate would be advisory for exactly the change + # it exists to catch. Listed ahead of the check so the filter is + # correct the moment `#422` lands; until then these globs only + # widen when `docs-truth` runs, which is harmless. + - 'Cargo.toml' + - 'settings.gradle.kts' + - 'capsule-swift/Project.swift' + - '**/Package.swift' + - '**/package.json' + - '**/pyproject.toml' + - '.gitmodules' + - 'legacy-review/**' - 'mise.toml' - '.github/workflows/ci.yml' vision: @@ -240,13 +292,6 @@ jobs: tier2: true steps: - uses: actions/checkout@v4 - # capsule-sdk's build script compiles the capsule.sync.v1 proto with - # prost-build, which shells out to protoc. The hosted images do not carry - # it, so every leg of this matrix fails in the SDK build script without it. - - name: Install protoc - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install Rust toolchain run: | rustup toolchain install @@ -470,7 +515,11 @@ jobs: # Single aggregated status check. Mark THIS ("CI / required") as the required # check in branch protection — path-filtered jobs report "skipped", which this - # treats as a pass. rust-test is non-blocking and intentionally excluded. + # treats as a pass. Every job in `needs` is verified below: the loop covers the + # eleven gates (`rust-test` included — it became blocking with `S-C59`, see the + # note on that job), and `changes` is checked ahead of it under a stricter rule, + # because "skipped" is only trustworthy when the filter that skipped it ran. + # A job in `needs` but absent from both checks is a gate that cannot fail. required: name: required if: always() @@ -479,7 +528,21 @@ jobs: steps: - name: Verify required jobs succeeded run: | - for r in "${{ needs.commit-lint.result }}" "${{ needs.rust.result }}" "${{ needs.rust-cross.result }}" \ + # `changes` must have SUCCEEDED, not merely "not failed". Every filtered + # job's `if:` reads needs.changes.outputs.*; if that job dies (a checkout + # hiccup, a paths-filter error) those outputs are empty, so every gate + # evaluates false and reports "skipped" — and `if: always()` still runs + # this one. Treating those skips as passes would green-light a merge with + # zero toolchain gates executed, which is the failure mode this whole job + # exists to prevent. It is the one `needs` entry that cannot be skipped: + # unlike `commit-lint` (push events) or the filtered gates, it has no `if:`. + if [ "${{ needs.changes.result }}" != "success" ]; then + echo "The path-filter job did not succeed (result: ${{ needs.changes.result }})." + echo "Every filtered gate would report skipped, so this aggregate proves nothing." + exit 1 + fi + for r in "${{ needs.commit-lint.result }}" "${{ needs.rust.result }}" \ + "${{ needs.rust-test.result }}" "${{ needs.rust-cross.result }}" \ "${{ needs.web.result }}" \ "${{ needs.docs.result }}" "${{ needs.docs-truth.result }}" "${{ needs.vision.result }}" \ "${{ needs.markdown.result }}" "${{ needs.kotlin.result }}" "${{ needs.swift.result }}"; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3d3865..2ce7108d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,16 @@ name: Release # Fires when a release commit (`chore(release): vX.Y.Z`, produced by prepare-release.yml -# and merged via its PR) lands on master. It builds the `capsule` CLI for each target and -# publishes a GitHub Release. `gh release create` also creates the tag, so the whole -# build+publish happens in this one run — no PAT or tag-push re-trigger needed. +# and merged via its PR) lands on master. It builds the `capsule` CLI and the `capsule-server` +# binary for each target and publishes a GitHub Release. `gh release create` also creates the +# tag, so the whole build+publish happens in this one run — no PAT or tag-push re-trigger +# needed. +# +# Both binaries ride in the one per-target archive rather than two: an operator running a +# self-hosted deployment wants the server and the CLI that talks to it at the same version, and +# two downloads is two chances to mix versions. The server is Unix-only here — Windows is +# already best-effort for the CLI, and adding a server build to a job that is allowed to fail +# would make "did the Windows CLI ship" harder to answer, not easier. on: push: branches: [master] @@ -41,7 +48,7 @@ jobs: fi build: - name: Build capsule (${{ matrix.target }}) + name: Build binaries (${{ matrix.target }}) needs: detect if: ${{ needs.detect.outputs.release == 'true' }} runs-on: ${{ matrix.os }} @@ -74,8 +81,11 @@ jobs: uses: Swatinem/rust-cache@v2 with: key: release-${{ matrix.target }} - - name: Build release binary + - name: Build the CLI run: cargo build -p capsule-cli --release --target ${{ matrix.target }} + - name: Build the server + if: runner.os != 'Windows' + run: cargo build -p capsule-server --release --target ${{ matrix.target }} - name: Package (unix) if: runner.os != 'Windows' shell: bash @@ -84,6 +94,11 @@ jobs: dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}" mkdir -p "$dist" cp "target/${{ matrix.target }}/release/capsule" "$dist/" + cp "target/${{ matrix.target }}/release/capsule-server" "$dist/" + # The operator's starting point: every setting the server reads, with what it defaults + # to and why. A release without it is a binary that refuses to start and an operator + # reading GitHub to find out which variables it wanted. + cp capsule-server/.env.example "$dist/" cp README.md LICENSE NOTICE CHANGELOG.md "$dist/" tar -czf "${dist}.tar.gz" "$dist" echo "ASSET=${dist}.tar.gz" >> "$GITHUB_ENV" diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index d8c96d94..9e285c96 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -26,6 +26,16 @@ // submodules, so without this the gate passes there and fails on any dev machine // that has run `git submodule update`. "rawshift/**", + // Generated reference pages (capsule-docs/scripts/gen-reference.mjs). Gitignored + // build output whose formatting comes from the generator, not from a contributor: + // a finding here is fixed in the generator, and the file it points at may not + // exist on the machine reading the report. + "capsule-docs/src/content/docs/reference/cli/**", + "capsule-docs/src/content/docs/reference/api/**", + // The same two directories reached through the repo-root `docs` symlink, which + // this glob walker follows (unlike the docs-truth walk, which skips symlinks). + "docs/reference/cli/**", + "docs/reference/api/**", "CLAUDE.md", // symlink to AGENTS.md "CHANGELOG.md" // generated by convco; not hand-formatted ] diff --git a/AGENTS.md b/AGENTS.md index ecdfc94a..23964236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ - The public server surface is Kynos REST/OpenAPI only. Do not reintroduce Salvo, GraphQL, or gRPC. The served document is **OpenAPI 3.2**: enabling Kynos's `openapi32` feature does not by itself produce one — `capsule-server` pins it with `openapi_as(SpecVersion::V3_2)`. Never emit or commit a 3.1 or 3.0 contract. - Generate clients with Spargen from the checked-in Kynos OpenAPI contract. Do not use Progenitor. Everything that parses or serializes is generated — every body, every typed parameter, and the byte-serving endpoints. Only *orchestration over* generated calls is hand-written, and the resumable upload state machine (`S-D1`) is the whole of it; do not hand-write a second parser. -- Rawshift is the intended owner of media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media` once Rawshift stabilizes. Neither exists today: Rawshift is a pinned submodule and not a workspace dependency, and `capsule-core::media` has no body to write until it is one — so nothing in Capsule decodes media right now. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). +- Rawshift owns media decoding, metadata extraction, and derivative generation, consumed through `capsule-core::media`, which now exists and is wired: `rawshift-image` **0.1.1 from crates.io** — a registry dependency, never the pinned submodule — behind the `media` feature that `native` implies, covering still detection, decode, EXIF orientation, metadata normalisation and the JXL thumbnail tier, with every enabled codec pure Rust (no C links, so the mobile cross-builds stay clean), and absent from the `wasm32-unknown-unknown` sealing build. Every format with no codec in the build is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap: HEIC/AVIF/RAW/WebP decode, lossy-JXL and AVIF encode, the preview tier, and all video derivatives stay deferred behind the system library, assembler, or (for WebP) the upstream aarch64 fix each needs. Capsule imports **Chromahash 0.7.1** directly, never through Rawshift, and LQIP encode/decode lives in its own `capsule-core::lqip` module (slice `S-B14`) so one implementation serves the import pipeline, the FFI, and `capsule-wasm`. **ThumbHash is retired**: neither the `thumbhash` crate nor the npm `thumbhash` package may be reintroduced. Contract: [Thumbnails — LQIP](capsule-docs/src/content/docs/design/thumbnails.md#lqip). - Blob storage and resumable encrypted upload remain Capsule-owned behind narrow, arbitrary-backend ports. Do not add `object_store` or generic CAS/transfer crates without revisiting the security contract. -- Keep authentication state and upload-session state as separate Capsule ports with PostgreSQL, `redis-rs`, and in-memory adapters. Do not introduce a generic TTL/CAS abstraction. +- Keep authentication state and upload-session state as separate Capsule ports. Their adapters are **`redis-rs` and in-memory**, not PostgreSQL: [Filesystem — Server](capsule-docs/src/content/docs/design/filesystem/server.md) rejects a Postgres-resident session table outright — it "would be a second implementation of the same contract; Capsule ships exactly one" — and records the Postgres-instead-of-Valkey fallback as considered and rejected, because emulating TTL and expiry in SQL is the generic TTL abstraction the module map declines to introduce. PostgreSQL is the adapter for the **durable** ports: the asset index, the account cluster, the device-cohort map, the quota ledger and the rest of the library's records. Do not introduce a generic TTL/CAS abstraction. - `legacy-review/` is non-buildable reference material. Restore code only after defining its contract and automated tests against the decisions above. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba6c7808..42be811d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,9 +23,35 @@ hk install # wires up the git hooks Run tasks with `mise run ` — `mise tasks` lists them all (plain name = auto-fix, `-check` suffix = verify-only). The pre-commit hook auto-formats and **stages** your -changes; pre-push runs the format/lint checks plus the test suite; and `convco` validates -every commit message as a [Conventional Commit](https://www.conventionalcommits.org). The -same checks run on `pre-push` and on every PR in CI. +changes; `convco` validates every commit message as a +[Conventional Commit](https://www.conventionalcommits.org); and pre-push runs the +format/lint checks for every toolchain except the FFI crate's, the Rust and web test +suites, and the cheap boundary gates — `i18n-check`, `i18n-guard`, `architecture-check` +and `license-check`. + +Pre-push is a fast subset of CI, not the whole of it. What it leaves to CI: the Kotlin, +Swift and docs test suites; `lint-check-ffi`, the one lint no hook runs; and the gates +that cost minutes of fresh compilation — `openapi-check-kynos`, `translate-readme-check`, +the `build-*` steps, `gen-bindings` and `verify-examples`. + +To run what CI runs before you open a pull request, use the per-toolchain entrypoints, +each of which maps 1:1 to a CI job: + +```sh +mise run check-rust # fmt, clippy, i18n, boundaries, licences, builds, bindings +mise run test-rust # the test job — blocking, and not part of check-rust +mise run check-web # format, lint, bun test, bundle +mise run check-docs # format, lint, test, build +mise run check-docs-truth # every name the docs claim resolves in the tree +mise run check-kotlin # ktlint + detekt +mise run check-swift # format, lint, unit tests, iPhone UI sweep (macOS only) +mise run check-vision # format + lint +mise run check-md # markdownlint +``` + +The one CI job with no local entrypoint is `rust-cross`: it cross-compiles for Android, +Windows and linux-arm64 on their own runners, so reproducing it locally means the +individual `build-*` recipes and the matching toolchains. > **Coming from the old `just` + `lefthook` setup?** Re-run `mise install && hk install` > (hk overwrites the stale `.git/hooks` that called lefthook). The `justfile` is gone — diff --git a/Cargo.lock b/Cargo.lock index af416a67..d66bb520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -178,6 +193,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[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.5.3" @@ -244,6 +274,22 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -257,6 +303,17 @@ dependencies = [ "tokio", ] +[[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" @@ -334,6 +391,58 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[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", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -343,7 +452,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link 0.2.1", @@ -367,6 +476,12 @@ 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" @@ -390,7 +505,7 @@ checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" dependencies = [ "autocfg", "libm", - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", "serde", @@ -485,6 +600,83 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bollard" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.0", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "chrono", + "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.4", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.20", + "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 0.14.4", + "prost-types 0.14.4", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "chrono", + "prost 0.14.4", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -515,6 +707,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -559,6 +781,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -590,7 +818,7 @@ checksum = "6b5271031022835ee8c7582fe67403bd6cb3d962095787af7921027234bab5bf" name = "capsule-cli" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capitalize", "capsule-cli-entity", "capsule-cli-migration", @@ -605,7 +833,7 @@ dependencies = [ "eyre", "futures", "humansize", - "indexmap", + "indexmap 2.14.0", "jiff", "nanoid", "sea-orm", @@ -657,7 +885,7 @@ dependencies = [ "hex", "hkdf", "hmac", - "indexmap", + "indexmap 2.14.0", "jiff", "kamadak-exif", "ml-dsa", @@ -668,6 +896,7 @@ dependencies = [ "openmls_memory_storage 0.5.0", "openmls_traits 0.5.0", "p256", + "rawshift-image", "rusqlite", "rustix", "serde", @@ -708,6 +937,27 @@ dependencies = [ "uniffi", ] +[[package]] +name = "capsule-e2e" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "capsule-cli", + "capsule-cli-migration", + "capsule-core", + "capsule-sdk", + "capsule-server", + "jiff", + "kynos", + "sea-orm", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "capsule-i18n" version = "0.1.0" @@ -720,7 +970,7 @@ dependencies = [ name = "capsule-sdk" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", @@ -748,47 +998,58 @@ dependencies = [ name = "capsule-server" version = "0.1.0" dependencies = [ - "base64", + "argon2", + "base64 0.22.1", "bytes", "capsule-core", "capsule-i18n", "capsule-sdk", - "capsule-wire", + "capsule-server-migration", "clap", "color-eyre", "http-body-util", "jiff", "jsonwebtoken", "kynos", + "rcgen", + "redis", + "reqwest", "ring", + "rustls", + "sea-orm", "secrecy", "serde", "serde_json", "subtle", + "tempfile", + "testcontainers", + "testcontainers-modules", "thiserror 2.0.20", "tokio", + "tokio-rustls", "totp-rs", "tracing", + "tracing-subscriber", "uuid", ] [[package]] -name = "capsule-wasm" +name = "capsule-server-migration" version = "0.1.0" dependencies = [ - "base64", - "capsule-core", - "hex", - "uuid", - "wasm-bindgen", + "sea-orm-migration", + "tokio", ] [[package]] -name = "capsule-wire" +name = "capsule-wasm" version = "0.1.0" dependencies = [ - "serde", - "serde_json", + "base64 0.22.1", + "capsule-core", + "hex", + "uuid", + "wasm-bindgen", ] [[package]] @@ -1002,6 +1263,12 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1017,6 +1284,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "concurrent-queue" version = "2.5.0" @@ -1057,6 +1338,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[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" @@ -1107,6 +1398,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1232,8 +1532,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -1249,17 +1559,41 @@ dependencies = [ "syn 2.0.117", ] +[[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.3", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -1417,6 +1751,17 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -1429,6 +1774,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[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" @@ -1569,6 +1920,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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.1" @@ -1580,6 +1941,16 @@ dependencies = [ "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 = "eyre" version = "0.6.12" @@ -1619,6 +1990,23 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "ferroid" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand 0.9.4", + "web-time", +] + [[package]] name = "ff" version = "0.13.1" @@ -1657,6 +2045,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -1912,6 +2311,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1971,7 +2380,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2081,7 +2490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" dependencies = [ "hax-lib-macros", - "num-bigint", + "num-bigint 0.4.6", "num-traits", ] @@ -2317,6 +2726,20 @@ dependencies = [ "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.9" @@ -2333,13 +2756,26 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[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.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2356,6 +2792,21 @@ dependencies = [ "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" @@ -2495,12 +2946,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "img-parts" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19734e3c43b2a850f5889c077056e47c874095f2d87e853c7c41214ae67375f0" +dependencies = [ + "bytes", + "crc32fast", + "miniz_oxide 0.8.9", +] + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[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.0" @@ -2612,6 +3085,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + [[package]] name = "js-sys" version = "0.3.77" @@ -2681,7 +3160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "pem", @@ -2692,6 +3171,184 @@ dependencies = [ "zeroize", ] +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "rayon", + "rayon-core", + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "k256" version = "0.13.4" @@ -2756,7 +3413,7 @@ dependencies = [ "jsonschema", "kynos-macros", "kynos-openapi", - "matchit", + "matchit 0.9.2", "percent-encoding", "serde", "serde_json", @@ -2784,7 +3441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ce3286ca0f45019fd229a2a467a2d0ddeb074843614f09fee9b7370a0cb3eb9" dependencies = [ "http", - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "thiserror 2.0.20", @@ -3115,6 +3772,20 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "little_exif" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122" +dependencies = [ + "brotli", + "crc", + "log", + "miniz_oxide 0.8.9", + "paste", + "quick-xml", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -3156,6 +3827,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "matchit" version = "0.9.2" @@ -3234,6 +3911,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.1" @@ -3354,7 +4041,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-complex", "num-integer", "num-iter", @@ -3372,6 +4059,16 @@ dependencies = [ "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" @@ -3446,7 +4143,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -3680,6 +4377,12 @@ dependencies = [ "tls_codec", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -3791,6 +4494,31 @@ dependencies = [ "windows-link 0.2.1", ] +[[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.117", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -3802,6 +4530,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -3814,7 +4548,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3840,7 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.14.0", ] [[package]] @@ -3880,13 +4614,33 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ - "base64", + "base64 0.22.1", "oid", "picky-asn1", "picky-asn1-der", "serde", ] +[[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.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4077,7 +4831,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -4093,8 +4857,8 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", - "prost-types", + "prost 0.13.5", + "prost-types 0.13.5", "regex", "syn 2.0.117", "tempfile", @@ -4113,13 +4877,35 @@ dependencies = [ "syn 2.0.117", ] +[[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.117", +] + [[package]] name = "prost-types" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" dependencies = [ - "prost", + "prost 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost 0.14.4", ] [[package]] @@ -4142,6 +4928,21 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4307,7 +5108,35 @@ dependencies = [ name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rawshift-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d93e32ed40baedf3ad0a731690f6af5b58b6067af097c9fd3deb6e03080be9b" + +[[package]] +name = "rawshift-image" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471a16d2c1b56ee8288ba90f675e96c7513839aadac88f440c1643b815e227c3" +dependencies = [ + "gif", + "img-parts", + "jpeg-encoder", + "jxl-oxide", + "little_exif", + "rawshift-core", + "rayon", + "thiserror 2.0.20", + "tiff", + "tracing", + "zune-core", + "zune-jpeg", + "zune-jpegxl", + "zune-png", +] [[package]] name = "rayon" @@ -4341,6 +5170,37 @@ dependencies = [ "yasna", ] +[[package]] +name = "redis" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint 0.5.1", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-rustls", + "tokio-util", + "url", + "xxhash-rust", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4457,7 +5317,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -4678,6 +5538,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -4686,6 +5547,27 @@ dependencies = [ "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-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -4728,6 +5610,39 @@ dependencies = [ "winapi-util", ] +[[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" @@ -4886,7 +5801,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", @@ -4949,6 +5864,29 @@ 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.0", + "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" @@ -5012,6 +5950,17 @@ dependencies = [ "zmij", ] +[[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.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5042,6 +5991,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling 0.24.1", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5053,6 +6035,12 @@ dependencies = [ "digest 0.10.7", ] +[[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" @@ -5157,6 +6145,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -5169,7 +6163,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "thiserror 2.0.20", "time", @@ -5219,7 +6213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5baebe002620b367fea87f77f911a9de77031f7d9072d8da238624df2a7b413" dependencies = [ "camino", - "indexmap", + "indexmap 2.14.0", "jsonschema", "prettyplease", "proc-macro2", @@ -5295,7 +6289,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -5309,7 +6303,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap", + "indexmap 2.14.0", "log", "memchr", "once_cell", @@ -5375,7 +6369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", @@ -5422,14 +6416,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.0", "byteorder", "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", @@ -5441,7 +6435,7 @@ dependencies = [ "log", "md-5", "memchr", - "num-bigint", + "num-bigint 0.4.6", "once_cell", "rand 0.8.6", "rust_decimal", @@ -5514,6 +6508,29 @@ 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.117", +] + +[[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.117", +] + [[package]] name = "strum" version = "0.26.3" @@ -5650,6 +6667,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testcontainers" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a81ec0158db5fbb9831e09d1813fe5ea9023a2b5e6e8e0a5fe67e2a820733629" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e75e78ff453128a2c7da9a5d5a3325ea34ea214d4bf51eab3417de23a4e5147" +dependencies = [ + "testcontainers", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -5708,6 +6764,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5866,7 +6936,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5908,7 +6978,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -5922,7 +6992,7 @@ version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.3", @@ -5949,6 +7019,46 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[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 0.14.4", + "tonic", +] + [[package]] name = "totp-rs" version = "5.7.1" @@ -5973,11 +7083,15 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6156,7 +7270,7 @@ dependencies = [ "bytes", "clap", "geometry-rs", - "prost", + "prost 0.13.5", "prost-build", "tzf-rel", ] @@ -6242,7 +7356,7 @@ dependencies = [ "glob", "goblin", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "once_cell", "serde", "tempfile", @@ -6274,7 +7388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6317,7 +7431,7 @@ checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "tempfile", "uniffi_internal_macros", ] @@ -6356,6 +7470,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6366,6 +7507,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -6374,6 +7516,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[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" @@ -6580,7 +7728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -6606,7 +7754,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.13.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -6657,6 +7805,12 @@ dependencies = [ "nom", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -7129,7 +8283,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -7160,7 +8314,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.13.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -7179,7 +8333,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -7230,8 +8384,9 @@ dependencies = [ name = "xtask" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "capsule-core", + "capsule-i18n", "eyre", "hex", "regex", @@ -7242,6 +8397,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yaml-rust2" version = "0.11.0" @@ -7385,8 +8546,57 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zune-jpegxl" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zune-png" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a321146329f7617ba0a5b26982cba45b7ce78163135aba78be25850aefcea80" +dependencies = [ + "zune-core", + "zune-inflate", +] diff --git a/Cargo.toml b/Cargo.toml index 12b28f90..de8b57c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,11 +5,12 @@ members = [ "capsule-cli/migration", "capsule-core", "capsule-core-ffi", + "capsule-e2e", "capsule-i18n", "capsule-sdk", "capsule-wasm", "capsule-server", - "capsule-wire", + "capsule-server/migration", "xtask", ] # capsule-sdk's REST client is generated at build time by spargen from the committed @@ -20,9 +21,11 @@ default-members = [ "capsule-cli/migration", "capsule-core", "capsule-core-ffi", + "capsule-e2e", "capsule-i18n", + "capsule-sdk", "capsule-server", - "capsule-wire", + "capsule-server/migration", ] resolver = "3" @@ -72,6 +75,14 @@ jiff = { version = "0.2", features = ["serde"] } jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } nanoid = "0.4.0" redis = { version = "1.2.2", features = ["tokio-comp", "connection-manager"] } +# The HTTP client. `capsule-sdk` is the sanctioned client network path and `capsule-server`'s +# OIDC relying party (slice `S-N1`) is the one server egress: discovery, JWKS and the token +# exchange against an identity provider. rustls only, per the TLS row in design/dependencies.md; +# `json` for the provider's documents. The SDK enables `stream` and `multipart` on top. +reqwest = { version = "0.12.28", default-features = false, features = [ + "json", + "rustls-tls", +] } ring = "0.17.14" sea-orm = { version = "1.1.20" } sea-orm-migration = { version = "1.1.20", features = [ diff --git a/NOTICE b/NOTICE index 8f65dff5..5ea11538 100644 --- a/NOTICE +++ b/NOTICE @@ -96,10 +96,10 @@ their published upstream releases. Source for each is available from crates.io and from the upstream repository named in its own package metadata. base64urlsafedata, colored, hpke-rs, hpke-rs-crypto, hpke-rs-libcrux, - hpke-rs-rust-crypto, option-ext, uniffi, uniffi_bindgen, uniffi_core, - uniffi_internal_macros, uniffi_macros, uniffi_meta, uniffi_pipeline, - uniffi_udl, webauthn-attestation-ca, webauthn-rs, webauthn-rs-core, - webauthn-rs-proto + hpke-rs-rust-crypto, option-ext, rawshift-core, rawshift-image, uniffi, + uniffi_bindgen, uniffi_core, uniffi_internal_macros, uniffi_macros, + uniffi_meta, uniffi_pipeline, uniffi_udl, webauthn-attestation-ca, + webauthn-rs, webauthn-rs-core, webauthn-rs-proto -------------------------------------------------------------------------------- Conjunctive-license components diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..90b4f1df --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,100 @@ +# Roadmap + +The **package-level** view of Capsule: one row for every package the repository declares, in +every toolchain, with the state it is actually in today. + +[`SLICES.md`](SLICES.md) stays the slice-level tracker and is the authority on any slice's +status. This file never restates one. The `Open slices` column cites ids so a reader can get +from a package to the work outstanding against it; the status of that work is read there, not +here. A slice appears against the package whose tree its deliverable lands in, so a slice that +spans a client and a server leg is listed against both halves only where both are genuinely +owed. + +`mise run check-docs-truth` runs a `roadmap` check over this file. It resolves every row +against the manifests themselves — `Cargo.toml`, `settings.gradle.kts`, +`capsule-swift/Project.swift`, the `Package.swift`/`package.json`/`pyproject.toml` package +roots, `locales/`, `.gitmodules`, and `legacy-review/*/` — so adding a package to the tree +fails the gate until this file gains a row for it. Every `Gate` cell must name a real `mise` +task and every cited slice id must have a detail block in `SLICES.md`. + +## States + +A closed set. A row's state is a claim about the package, not about the programme. + +- `frozen` — ships, contract settled, no open slices. Only defect fixes land. +- `stabilizing` — live and inside a `mise run check-*` gate, with the contract still moving. + Open slices refine it. +- `rebuilding` — the shipped surface is quarantined under `legacy-review/` and is being + re-landed on a replacement. +- `blocked` — cannot start. A named dependency outside this package gates it. +- `deferred` — in scope and deliberately unscheduled. Post-v1. +- `review-only` — non-buildable reference material. No gate, no build. +- `excluded` — in the tree but outside the shipped build: a submodule, a conditionally + compiled target, or research material. Any gate such a row names is format and lint only, + and the `Notes` cell says so. + +## Packages + +| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `capsule-core` | cargo | The offline crypto data plane, catalog, signed sidecars, import pipeline, LQIP, media decode, and the OpenMLS authority | stabilizing | mise run test-rust | [Module Map](capsule-docs/src/content/docs/design/module-map.md) | `S-B1`, `S-B5` | Finish the public-API freeze (#399) and bind a library to a server account (#467) | 26 public modules; the 8 ungated ones (`cbor`, `crypto`, `drop`, `sharing`, `validation`, `lqip`, `client_build`, `derivative_format`) are the `--no-default-features` sealing surface and are gated by `build-check-wasm`. **Not `frozen`:** #399 converted most modules to `pub(crate) mod` + `pub use` but ten sites still pair `pub mod` with a re-export of the same items, so ~110 types resolve at two paths (#480). `media` landed on `rawshift-image` 0.1.1, so stills decode and `lqip` has a producer The 814 unit cases run there; `mise run build-check-wasm` is what proves the ungated sealing surface still compiles for `wasm32-unknown-unknown`. | +| `capsule-core-ffi` | cargo | The app umbrella staticlib and the `capsule_core_ffi` uniffi namespace | frozen | mise run build-ffi | [Module Map — Client Boundaries](capsule-docs/src/content/docs/design/module-map.md#client-boundaries) | — | Holds; defect fixes only | Flat surface, no public modules: `Catalog`, `GatedView`, `LocalAuthGate`, the four records, `LqipPlaceholder`/`render_lqip`, `init_logging`. Audited on the integration head — every public item is uniffi-annotated, so there is no dead surface and no duplicated path. Links `capsule-sdk`'s uniffi surface so one Rust library carries both namespaces an app consumes Also gated by `mise run lint-check-ffi` and `mise run gen-bindings`, which asserts every verb each binding must export. | +| `capsule-sdk` | cargo | Session, upload, sync, recovery, federation-pull and protocol-version orchestration over the spargen-generated REST client | stabilizing | mise run test-rust | [API Surfaces](capsule-docs/src/content/docs/design/api-surfaces.md) | `S-D9`, `S-E3`, `S-N2` | Bind a library to a server account so the push path works against a real server (#467) | Every operation now carries the protocol handshake as a typed header (`S-D2`/#404), and one shared `reqwest` client backs them all. The escrow route, the 401 refresh-and-replay, the OIDC login leg and the federated pull all land here. **The push ladder is proven against the in-process router only**: driving it against a registered account needs #467 The 208 cases run there; `mise run openapi-check-kynos` gates the contract this crate generates its client from. | +| `capsule-server` | cargo | The Kynos REST/OpenAPI application, its binary, and the committed `capsule-server/openapi.json` contract | stabilizing | mise run test-rust | [Module Map — Server Modules](capsule-docs/src/content/docs/design/module-map.md#server-modules) | `S-C8`, `S-C47`, `S-C49`, `S-E2` | Fill the remaining durable ports so a durable deployment can boot (#446) | One binary with `serve`/`gen-openapi`/`gc`/`purge`/`scrub`, real configuration, and Postgres and Valkey adapters that pass the same conformance suites as the in-memory doubles. **`serve --memory` runs; a durable `serve` does not.** The `Durable` arm demands `DATABASE_URL`, opens the pool, checks the schema and proves Valkey answers `PING`, then refuses naming #446, because five durable ports have an adapter on neither side. Refusing beats coming up holding state it would lose on restart The 2382 cases run there and need no container; `mise run openapi-check-kynos` gates the committed contract. The adapter tiers are opt-in: `CAPSULE_TEST_POSTGRES=1` and `CAPSULE_TEST_VALKEY=1`, run over `--lib --test valkey` because the parity suites live in `#[cfg(test)]` modules inside `src/**` that a `--test` selection misses. | +| `capsule-wasm` | cargo | The browser boundary — share-link client-side open, guest-drop sealing, and LQIP decode | frozen | mise run build-check-wasm | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | — | Holds; defect fixes only | Flat surface, no public modules; every public item is `#[wasm_bindgen]`. Audited clean — no duplicated path. `decodeLqip`/`WasmLqipImage` landed with `S-B14` and are the one export `capsule-web` does not yet import, so the Rust half is done and the viewer half is not Also reached by `mise run check-web`, which builds the artifact `capsule-web` loads. | +| `capsule-i18n` | cargo | The generated Rust catalog bundle, the runtime formatter, and the `error.*` code contract | frozen | mise run i18n-check | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | Holds; defect fixes only | One public module (`plural`) plus `Bundle`, `Value`, `negotiate`, `supported_locales`, `error_codes`. Audited clean for duplicated paths; `format_message`/`format_message_in` have no caller outside the crate — every consumer goes through `Bundle::format` (#480). ICU plurals evaluate as of `S-I7`; `select`/`offset:` are still refused. Generated from `locales/` by `mise run i18n`; `i18n-check` fails on drift Also gated by `mise run i18n-guard`, which is what catches a raw ICU string reaching a user. | +| `capsule-cli` | cargo | The `capsule` binary — local library commands plus auth, sync, push, import and cull | stabilizing | mise run cli-surface-check | [Clients](capsule-docs/src/content/docs/design/clients.md) | `S-Q1`, `S-Q2`, `S-Q3`, `S-Q4` | Recovery and enrollment against a server account (#467) | Help text comes from the catalogs (`cli.help.*`) and the command tree is a committed artifact a gate diffs. There is a server to reach now — `mise run serve-memory` — so the networked commands are exercisable; the recovery ones still need #467 That gate diffs the committed `capsule-cli/cli-surface.json`; the behaviour behind it runs in `mise run test-rust`. | +| `capsule-cli/entity` | cargo | The CLI's five sea-orm entities — `album`, `asset`, `profile`, `sync_cursor`, `synced_asset` | stabilizing | `mise run check-rust` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Follows `capsule-cli` (#413) | The one place `chrono` is permitted, as the sea-orm column type; convert at the entity boundary | +| `capsule-cli/migration` | cargo | sea-orm migrations for that store | stabilizing | `mise run check-rust` | [migration/README](capsule-cli/migration/README.md) | — | Follows `capsule-cli` (#413) | Schema changes land here before the entity crate sees them | +| `capsule-server/migration` | cargo | sea-orm migrations for the server's Postgres schema | stabilizing | `mise run check-rust` | [Filesystem — Server](capsule-docs/src/content/docs/design/filesystem/server.md) | — | Follows `capsule-server` (#446) | Its own binary, `capsule-server-migration up`, run once per deployment before the replicas roll. **`capsule-server` dev-depends on it and never links it**: `sea-orm-migration` pulls sea-orm with `with-chrono`, which a normal dependency would push past the `cargo tree -i chrono -e no-dev` gate. That is why `serve` reads `seaql_migrations` and refuses a schema it was not built for instead of migrating | +| `capsule-e2e` | cargo | The bounded end-to-end surface — the real SDK and library driven over TCP against the real server composition root | stabilizing | mise run test-rust | [Module Map — E2E Test Surface](capsule-docs/src/content/docs/design/module-map.md) | `S-Q1`, `S-Q2` | Un-ignore the six cases #467 blocks | Not a shipped artifact. Adds no external crate — every dependency is already in `Cargo.lock`. Dev-depends on `capsule-core`'s `test-support` feature so the caller-chosen Argon2 cost stays out of production builds. **Six of fourteen cases are `#[ignore]`d against #467**: they drive the real push path, which a library cannot do against a registered account until it can adopt that account's id Fourteen cases, six currently ignored. | +| `xtask` | cargo | Repository automation — `architecture-check` (including the workspace-dependency rules), `i18n`, `i18n-guard` and `translate-readme` | stabilizing | `mise run check-rust` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | — | Guard-detector repair (#394, #414) | Not a shipped artifact; it is what makes several gates in `mise.toml` real. Licence enforcement is not one of them: `mise run license-check` is `cargo deny` over `deny.toml` | +| `capsule-android` | gradle | The Android application — Compose UI over the Kotlin core | stabilizing | `mise run check-kotlin` | [Clients](capsule-docs/src/content/docs/design/clients.md) | — | Make the build green (#389) | **It does not compile.** `CapsuleApp.kt` imports `di`, `initKoin`, `ListViewModel` and `DetailViewModel`, none of which exist anywhere in the Kotlin tree. Not `blocked`: what is missing is inside this package, not a dependency outside it. `check-kotlin` is ktlint and detekt only and passes, because it never compiles against the FFI — which is how this stayed red since 2026-08-22 (#389) | +| `capsule-core-kotlin` | gradle | The standalone Kotlin harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — software Ed25519, software P-256, StrongBox | stabilizing | `mise run check-kotlin` | [capsule-core-kotlin/README](capsule-core-kotlin/README.md) | — | StrongBox proven on a device runner | Smoke tests only, and the self-hosted device lane that would exercise StrongBox is unprovisioned | +| `capsule-core-swift` | swiftpm | The standalone SwiftPM harness over `capsule-core`'s uniffi bindings, plus the `HardwareSigner` references — Secure Enclave signing and key agreement, and their software fallbacks | stabilizing | `mise run check-swift` | [capsule-core-swift/README](capsule-core-swift/README.md) | `S-P6` | Secure-Enclave wiring into the app (`S-P6`) | `check-swift` formats and lints this package; `mise run test-swift` drives the Tuist workspace only, so its own `swift test` suite is in no gate | +| `CapsuleFoundation` | tuist | Value types, logging and utilities. No dependencies | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | The root of the Apple module graph; every other target depends on it | +| `CapsuleDomain` | tuist | The display and domain value types, shaped as structural mirrors of the uniffi records they will be generated from. No I/O, no platform, no strings | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Mirrors hold across the FFI swap (`S-U19`) | Deliberately FFI-free so the mocked graph builds with no Rust toolchain | +| `CapsulePorts` | tuist | The async protocol seams, one per capability. Feature modules import only this for data, so swapping the mock adapters for the real uniffi ones is a change in the composition root and nowhere else | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Move the six `FeatureAuth` ports here (#391) | Six ports are declared inside `FeatureAuth` today, which is what #391 corrects | +| `CapsuleNavigation` | tuist | One `Route` vocabulary, a router with a stack per section, deep links, and the menu-command table. No SwiftUI views, which is what lets the three shells share it | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Every live sidebar row reaches a real screen | `SidebarItem.memories` and `.duplicates` are live rows whose screens are still scaffolds | +| `CapsuleMock` | tuist | The in-memory implementation of every port, and the app's only data source while the Rust core is rebuilt | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Reachable scenario selection (#392) | `MockScenarioSelection` is write-only, so about thirty screens have no way in | +| `CapsuleDiagnostics` | tuist | MetricKit crash and performance collection, consent, breadcrumbs, redacted bug-report bundles, and an opt-in self-hosted uploader | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleCatalog` | tuist | The catalog contract, its Swift-native models, and the in-memory reference implementation. No Rust core, which is what makes the mock lane possible | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P5` | Sync-apply renders into the local catalog (`S-P5`) | Written so the generated-type half can be swapped in without any screen naming a generated type | +| `CapsuleCatalogFFI` | tuist | The Rust-backed half — the generated uniffi glue for both namespaces, the record conversions, and the error mapping onto the FFI-free `CatalogError` | excluded | — | [capsule-swift/README](capsule-swift/README.md) | `S-P8` | A behavioural FFI harness that flips `S-D9` | Present only under `TUIST_FFI=1`, so the default graph builds from a clean checkout with no cross-compile; format and lint reach it only when it is generated | +| `ManagedStore` | tuist | The Swift filesystem layer, hashing, and the import pipeline | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Imports route through `ImportPort` (#390) | The picker importer writes this store directly, so picker imports never reach the timeline | +| `AssetKit` | tuist | The asset-provider abstraction over PhotoKit and the managed store | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleTestSupport` | tuist | Test-only mocks and fixtures, linked only by unit-test targets | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Covers the suites still owed by lane U | Test-only; no product code depends on it | +| `ImagePipeline` | tuist | Image decode, downsample, cache and prefetch | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Holds as the client's floor | — | +| `CapsuleUI` | tuist | The design system and shared components — virtualized timeline geometry, the shared photo grid, and the one UIKit/AppKit island every grid is built on. Depends on `CapsuleDomain` and deliberately not on `CapsulePorts`: it renders domain states and must never be able to fetch one | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Accessibility inside the gate (#393) | The accessibility audit fails on most surfaces and is outside `check-swift` | +| `FeatureTimeline` | tuist | The timeline root, its sectioning and view model, selection chrome and the picker-driven library importer | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Culling review (`S-U7` remainder) | The uniform grid, pinch zoom and the zoom transition landed; culling review is outstanding | +| `FeatureViewer` | tuist | The asset viewer, its page view and info panel, and the shareable-asset surface | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Provenance and verdict detail (`S-U8` remainder) | The info panel, caption editing and the `.viewer` route landed | +| `FeatureAlbums` | tuist | The albums root, album detail, and their view models | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U9` | The smart-album screen and predicate builder | Index and detail landed over the mock ports; members and policy editors are outstanding | +| `FeatureSearch` | tuist | The search root, its filters and view model, and the clustered places map | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U10` | People index and cluster screens | Search and the clustered map landed; map granularity is still fixed | +| `FeatureTransfer` | tuist | Transfers, quota, storage reclamation, sync status, and the per-asset custody receipt | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U12` | The documented ladders and triage detail | Every screen landed and is routed; not all of the documented behaviour is built | +| `FeatureAuth` | tuist | Onboarding, sign-in, the first-device enrollment ceremony, recovery, and the device and session ledger | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P2`, `S-P3`, `S-U13`, `S-U23` | A real auth service over Keychain (`S-P2`) | Built over `Preview*` doubles; both onboarding steps are scaffolds | +| `FeatureSharing` | tuist | Share links, the guest-drop inbox, LAN peering, federation and moderation | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U14`, `S-U22` | Inbound link redemption (`S-U22`) | Share detail is outstanding; the `https` deep-link parser lands `/s/` and `/u/` on a scaffold | +| `FeatureSettings` | tuist | The eighteen-section settings tree — a grouped list on iOS, a tabbed Settings window on macOS | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | — | Federation, advanced and about sections | Fifteen of eighteen sections landed; the Advanced mock-scenario switcher does not exist | +| `FeatureImport` | tuist | The photo-import pipeline: source picker, scan, plan confirmation, execution and history | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-P4`, `S-U11` | The import→seal→upload bridge (`S-P4`) | Per-run detail is outstanding, and `S-P4` waits on `S-P2`/`S-P3` | +| `FeatureCollections` | tuist | Collections home — albums, media types, places and utilities, including the SR1-gated hidden view | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U20`, `S-U21` | Memories and duplicate review | `HiddenView` sits behind the SR1 local-auth gate, whose seam is a port | +| `Capsule` | tuist | The composition root — the thin app target shared by macOS, iOS and iPadOS | stabilizing | `mise run check-swift` | [capsule-swift/README](capsule-swift/README.md) | `S-U19` | Swap the mock adapter for the SDK one (`S-U19`) | `AppEnvironment.swift` is the single file where lane P and lane U meet | +| `capsule-docs` | bun | The Starlight documentation site, the design docs it publishes, and the `docs-truth` checks | stabilizing | `mise run check-docs` | [Developer Docs](capsule-docs/src/content/docs/design/developer-docs.md) | `S-Z8`, `S-Z9`, `S-Z10` | Generate the reference section (#415) | `mise run check-docs-truth` is deliberately outside both `check-docs` and `check-rust`; it needs no toolchain | +| `capsule-web` | bun | The read-only browser client — library viewing, the share viewer, and the guest drop it seals through `capsule-wasm` | stabilizing | `mise run check-web` | [Web Upload](capsule-docs/src/content/docs/design/web-upload.md) | `S-Q5` | Live-browser smokes (`S-Q5`, #409) | It cannot enroll a device, upload an asset or edit metadata — a browser holds neither the hardware-bound nor the write-tier key. Every screen renders its empty state; there is no server to reach until #401 | +| `locales` | catalog | The canonical ICU MessageFormat catalogs — thirteen locales, the config and the schema | stabilizing | `mise run i18n-check` | [i18n](capsule-docs/src/content/docs/design/i18n.md) | — | Human review of the seeded entries | Roughly 350 machine-seeded entries across twelve locales are flagged in the `context` field and await review | +| `capsule-vision` | python | Experimental computer-vision work — training and export scripts, and notebooks exploring model capabilities | excluded | `mise run check-vision` | [AI](capsule-docs/src/content/docs/design/ai.md) | — | Post-v1 | Explicitly not all productionized. Format and lint only — `check-vision` runs no notebook and no test, and nothing here ships in a client | +| `rawshift` | submodule | RAW decode, metadata extraction and derivative generation, in-house and out-of-tree | excluded | — | [Dependencies](capsule-docs/src/content/docs/design/dependencies.md) | — | A workspace dependency `capsule-core::media` can consume | A pinned submodule, not a workspace dependency; CI does not check it out and no gate here descends into it | +| `legacy-review/server-salvo` | review-bucket | The retired Salvo server, kept as the contract the Kynos rebuild must reproduce | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-server` reaches parity | Non-buildable reference material; nothing in the workspace links it | +| `legacy-review/sdk-progenitor` | review-bucket | The retired Progenitor SDK | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once the spargen client covers it | Non-buildable reference material | +| `legacy-review/media-pipeline` | review-bucket | The retired `capsule_core::media` decode and derivative stack | review-only | — | [legacy-review/README](legacy-review/README.md) | — | Deleted once `capsule-core::media` lands on Rawshift (#410) | Non-buildable reference material; taking the decoder with it is why every still import is a `DeferredNoCodec` today | + +## Deferred register + +In scope, deliberately unscheduled. These are not packages, so they carry no row above; they +are listed here so `deferred` means something a reader can check. + +| Item | Owner docs | State | Notes | +| --- | --- | --- | --- | +| Tethered camera import over PTP/IP | [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md) | deferred | `S-B9`; the `ptpip-rs` crate does not exist yet | +| iCloud and Immich importers | [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md) | deferred | `S-B7` and `S-B8`, both behind the Takeout adapter that landed | +| Passkey authentication | [Authentication](capsule-docs/src/content/docs/design/authentication.md) | deferred | Six Salvo operations that were in no document and always answered `CredentialNotFound`; dropped from v1 in `S-C56` | +| Live mDNS peering | [Peering](capsule-docs/src/content/docs/design/peering.md) | deferred | `S-E3` lands the in-process half; discovery on a real network is post-v1 | +| Native RTL layout | [i18n](capsule-docs/src/content/docs/design/i18n.md) | deferred | The catalogs and the twelve locales landed in `S-I2`; per-platform RTL layout did not | +| A browser MLS surface | [MLS Resilience](capsule-docs/src/content/docs/design/mls-resilience.md) | deferred | The `libcrux` provider has no `wasm32` target, so the `mls` feature is host-only | diff --git a/SLICES.md b/SLICES.md index ce7d3266..dbb2f321 100644 --- a/SLICES.md +++ b/SLICES.md @@ -9,16 +9,25 @@ both halves of the current programme: the code 2026-08-21) plus everything needed to exercise the iOS app against a server end to end. +**This file tracks slices; [`ROADMAP.md`](ROADMAP.md) tracks packages.** Read this one for +what a piece of work is and where it stands; read that one for what state a package is in, +which gate covers it, and which slices are open against it. `ROADMAP.md` cites slice ids and +never restates a status, and `mise run check-docs-truth` fails if a citation there has no +detail block here. + It also absorbs the **post-teardown verdict**: the previous Salvo server, the Progenitor SDK and the standalone media crate are review material, and the replacement server is one **Kynos** REST/OpenAPI application. That verdict is accepted and final. -**`S-C59` executed it**, and narrowed it on evidence. Three `legacy-review/` buckets remain — -`server-salvo`, `sdk-progenitor`, `media-pipeline` — and the fourth, `core-import-media`, is -**gone**: it quarantined `capsule-core::exif` and the import executor's cancellation and progress -halves, and this branch has since rebuilt all three, live and tested. Quarantining a stale twin of -a working module is the opposite of what quarantine is for, so those stay in `capsule-core` and -the bucket went. See [`S-C59`](#s-c59--the-salvo-tree-leaves-the-workspace). +**`S-C59` executed it**, and narrowed it on evidence. Three `legacy-review/` buckets sit in the +tree: `server-salvo`, `sdk-progenitor`, and `media-pipeline`. A fourth, `core-import-media`, was +a stale twin rather than a quarantine — it held a snapshot of `capsule-core::exif` and the +import executor's cancellation and progress halves, all three of which this branch had already +rebuilt, live and tested, so those stay in `capsule-core`. Quarantining a stale twin of a working +module is the opposite of what quarantine is for. **`S-C59` recorded that bucket as deleted +before it actually was**: the directory outlived that record until +[#423](https://github.com/Capsulsaurus/Capsule/issues/423) deleted it. See +[`S-C59`](#s-c59--the-salvo-tree-leaves-the-workspace). Because a slice can now be honest in one tree and dishonest in the other, every row carries an **Area**. Read `Status` through `Area`, never on its own. @@ -44,15 +53,18 @@ an **Area**. Read `Status` through `Area`, never on its own. | Area | Meaning | | --- | --- | -| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its media/exif trees, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | -| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live (`S-C59`). | +| `ACTIVE` | The whole surface survives the teardown (`capsule-core` minus its `media` tree, `capsule-core-ffi`/`-swift`/`-kotlin`, the apps, `capsule-cli` local paths, `capsule-web` local paths, `locales/`, `xtask`, the docs site). Implementable against the live workspace today and unaffected by the Kynos rebuild. | +| `RETIRED` | The target sits in a `legacy-review/` bucket — `server-salvo` (the whole Salvo tree), `sdk-progenitor`, or `media-pipeline` (`capsule_core::media` and its lifecycle adapter). The deliverable must be re-landed on the replacement: Kynos for the server, the Rawshift-backed pipeline for media, the spargen SDK for the client. **`capsule_core::exif` and `import/{executor_cancellation, progress}.rs` are not on this list**, against the original teardown: this branch rebuilt them and they are live, and the `core-import-media` bucket that sat beside them as a stale twin was deleted rather than quarantined ([#423](https://github.com/Capsulsaurus/Capsule/issues/423), `S-C59`). | | `MIXED` | Both: a surviving `capsule-core`/client/app half that ships and stays, and a server, SDK-wire, or media half that must be re-landed. | **Status — read through Area.** - On an `ACTIVE` row, `Status` means what it always meant. -- On a `MIXED` row, `Status` describes **the surviving half only**. The retiring half is - owed to the rebuild by construction; it is not a separate `Owed →` pointer. +- On a `MIXED` row, `Status` describes **the surviving half only**. Where the retiring half + has no slice of its own, it is owed to the rebuild by construction and is not a separate + `Owed →` pointer. Where a *named* slice carries it, say so: the row is `done*` and `Owed + →` points at that slice, exactly as it would on any other area. A remainder with a live + home is not "owed by construction" — it is owed to something a reader can go and read. - On a `RETIRED` row, `done` is not available. An implemented `RETIRED` slice reverts to `ready`, and its detail block records that it **landed in code that is still live in this workspace today** — the contract is proven, the deliverable re-scopes onto the @@ -85,15 +97,24 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: four membership ceremonies, minted-and-distributed write-tier keys, Welcome/history delivery, durable group persistence, tombstone-plus-fork upgrade ceremony, re-keying, `ReconcileOutcome` reconciliation. -- **Import/media**: thumbnail/LQIP + video-derivative generation over injected - per-platform encoder seams, signed `DerivativeManifest` chains; signed-path import - executor; streaming import; staged uploads (tier ladder); Takeout source adapter - (`SourceAdapter` trait); planner determinism suite. Derivative byte-encoding is a - per-platform SDK seam — CLI-path derivative generation is inert without an injected - encoder (by design, thumbnails.md). **Area caveat:** the decode/derivative half lives in - `capsule-core::{media, exif}`, which is `RETIRED` territory; the executor, planner, +- **Import/media**: signed `DerivativeManifest` chains; signed-path import executor; + streaming import; staged uploads (tier ladder); Takeout source adapter (`SourceAdapter` + trait); planner determinism suite; LQIP on Chromahash 0.7.1 in `capsule-core::lqip` + (`S-B14`). Derivative byte-encoding is a per-platform SDK seam — CLI-path derivative + generation is inert without an injected encoder (by design, thumbnails.md). + **Correction, `S-C59`:** the decode half went to `legacy-review/media-pipeline` with + `capsule-core::media`, so **there is no image decoder in the workspace at all** and every + still import is a `DeferredNoCodec` — no thumbnail, no preview, and no LQIP producer, since + nothing can hand `capsule-core::lqip` an `RgbaImage` (`S-B1`, `S-B13`, `S-B14`; #410). + `capsule-core::exif` is **not** retired: it was rebuilt and is live. The executor, planner, scanner, importers, streaming, and staged scheduler survive. -- **Key-free server** (`capsule-api`, Salvo): hardened chunked upload (invariants 1–15 + +- **Key-free server** — *the contract, not a running binary.* This inventory describes + `capsule-api` (Salvo), which `S-C59` moved to `legacy-review/server-salvo`. It is kept + here because it is what the Kynos rebuild must reproduce, and because thirty-seven of its + documented operations became fifty-nine on the replacement. **Nothing in this bullet ships + today**: `capsule-server` is a surface with a committed OpenAPI 3.2 document and a test + suite over the real router, and has no binary, no configuration loading and no Postgres or + Valkey adapter (#401–#403). What it covered: hardened chunked upload (invariants 1–15 + strictness table, testcontainer-proven), `capsule.sync.v1` gRPC feed (+ gRPC-web), `/albums/{id}/ops` lifecycle writes, content-addressed blob serving at the 65,536-B stride, storage verification + custody receipts + signed attestation @@ -107,12 +128,16 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: found while reproducing it: the login never demanded a confirmed second factor (`S-C55`), and the passkey surface could not authenticate at all (`S-C56`). "Real and testcontainer-tested" was true of the code and not of the capability.* -- **SDK/clients**: session store + auto refresh, hand-written upload/sync clients, - spargen-generated typed REST client from committed `openapi.json`, verify-before- - destroy + receipt gate, adverse-network engine, LAN peering (in-process), recovery - cadence, CLI auth/register/status/sync/list/push/demo (E2E cases 1–3), web guest-drop + - share-viewer (wasm), aggregated federated albums, uniffi FFI for catalog + SDK user - flows. +- **SDK/clients**: session store + auto refresh, the hand-written resumable upload client, + the REST sync consumer, the spargen-generated typed REST client from the committed + `capsule-server/openapi.json`, verify-before-destroy + receipt gate, adverse-network + engine, LAN peering (in-process), recovery cadence, CLI + auth/register/status/sync/list/push/import/cull/demo, web guest-drop + share-viewer + (wasm), aggregated federated albums, uniffi FFI for catalog + SDK user flows. + **Correction:** the earlier claim that the CLI covers **E2E cases 1–3** was a claim about + the commands, not about a run. `capsule demo` is offline by design — it drives the local + library and touches no server — and the networked commands have nothing to reach until + `capsule-server` has a binary. The three cases are owed to `S-Q1` (#409). - **Legacy retired**: GraphQL, plaintext proto/entities/import-executor gone. - **Cohesion floor** (2026-08-21, wave-0 ground clearing): `lifecycle.rs` (3501 LOC, reaching 17 of 24 sibling modules) split into a `lifecycle/` module of twelve sibling files plus @@ -137,34 +162,42 @@ tree). Everything the v1 campaign shipped is the floor wave 2 stands on: ## Sequencing — build then retire -The teardown verdict is final; the **order** is not "retire, then rebuild". It is -**build, then retire**. - -- The Salvo server (`capsule-api/**`), `capsule-sdk`, and the in-repo media stack are - **still live in this workspace** and stay that way until the Kynos rebuild reaches - parity. `legacy-review/`'s own charter is that code leaves quarantine only once its - replacement contract and tests exist; retiring first would leave the tree with no - server, no CLI network commands, and no end-to-end test for the whole rebuild. -- `xtask architecture-check` is **adopted and reporting-only**. It reports **63 - violations** today (`mise run architecture-check`), and that list *is* the rebuild - worklist: implicit workspace packages, retired dependencies, buildable manifests under - `legacy-review/`, and stale component references. -- The retirement of `capsule-api/**`, `capsule-core/src/media`, `capsule-core/src/exif`, - and `import/{executor_cancellation,progress}.rs` into `legacy-review/` happens in **one - future commit**, once Kynos reaches parity — and `architecture-check` joins `check-rust` - in that same commit. Until then it is a report, not a gate (the rationale is duplicated - in `mise.toml` next to the task so nobody re-wires it early). -- **Kynos is a git dependency, not a crates.io release.** Pin it at rev - `6513109b5725a3e0713808de0eaee6b4b74281e3`; it is not published, so a version - requirement will not resolve. -- **`capsule-sdk` is replacement-in-progress, not review material.** It already satisfies - most of `legacy-review/sdk-progenitor/REVIEW.md`'s stated replacement contract: - spargen-generated from a checked-in OpenAPI 3.1 document, token refresh / upload / sync / - recovery / protocol-version orchestration kept **outside** generated code, no - `generate_openapi.sh`, no Progenitor macros. Two things are owed: its **gRPC sync half - re-fronted on REST**, and its **schema sourced from Kynos** rather than from the Salvo - `gen_openapi` binary. Slices whose target is the SDK are marked `RETIRED` because their - wire contract is re-sourced — not because the crate is being thrown away. +The teardown verdict was final and the **order** was not "retire, then rebuild" but +**build, then retire**. `S-C59` executed the retirement; this section records the state it +left, which is what everything below is sequenced against. + +- **The Salvo tree is gone from the workspace.** `capsule-api/**` is + `legacy-review/server-salvo/`, `capsule_core::media` is `legacy-review/media-pipeline/`, + and `salvo`, `tonic`, `prost`, `async-graphql`, `webauthn-rs` and — with the last of them + — `openssl` left the **manifests**. Not all of them left the dependency *graph*, and the + difference is the difference between what `architecture-check` proves and what it does + not: it reads declared dependencies, so `Cargo.lock` still resolves `prost v0.13.5` + transitively through `tzf-rs`, which `capsule-core` takes for timezone lookup. Nothing + declares it and nothing calls it as a wire format. The `rustls`-only rule holds with no + exception, declared or transitive. + The consequences are real and are named rather than hidden: there is no server binary and + no image decoder in the workspace (#401, #410). +- **`xtask architecture-check` is a gate, not a report.** It runs inside `mise run + check-rust` (`mise.toml`) and reports **0** violations; the 63 it reported at adoption + were the rebuild worklist and are discharged. Adding a retired dependency or a buildable + manifest under `legacy-review/` fails the build. +- **Kynos is a crates.io release.** `Cargo.toml` takes `kynos = { version = "0.1.0", + features = ["openapi32"] }`; it published on 2026-08-29, which fired the repin-on-publish + exit the dependencies doc carried while it was a git dependency. The rev + `6513109b5725a3e0713808de0eaee6b4b74281e3` is history, not a pin. The `openapi32` feature + alone does **not** yield a 3.2 document — `capsule-server` pins the version explicitly + with `openapi_as(SpecVersion::V3_2)` and a test asserts the emitted `openapi` field. +- **`capsule-sdk` is replacement-in-progress, and both of its owed items landed.** It + satisfies `legacy-review/sdk-progenitor/REVIEW.md`'s replacement contract: spargen + generates it from the checked-in OpenAPI **3.2** document, with token refresh, upload, + sync, recovery and protocol-version orchestration kept **outside** generated code, no + `generate_openapi.sh`, no Progenitor macros. The two items this section used to owe are + discharged — the gRPC sync half is re-fronted on REST (`GET /v1/sync` through the + generated client, `S-C60`/`S-D28`; `tonic` and `prost` are out of the manifest), and the + schema is sourced from Kynos (`capsule-sdk/build.rs` reads `capsule-server/openapi.json`, + the one document `mise run openapi-check-kynos` gates). The SDK slices that were marked + `RETIRED` for that reason are therefore `MIXED` now: the client half ships, and the server + half it exercises is the one still being rebuilt. - **The Apple client does not wait for the rebuild.** Lane U builds the whole anticipated UI against in-memory ports, so the client is written, reviewed and tested while @@ -192,10 +225,12 @@ either lane works — only a command reaching `-create-xcframework` or a real de ## Unified Slice Index -All 141 slices — the 74 from the v1 campaign, the 48 from wave 2 (46 indexed plus -`S-C27` and `S-Q6`), and the 19 of lane U, the Apple client's mocked UI. `Lane`, -`Depends on`, and `Size` are the campaign's own metadata; `Owed →` names where a `done*` -row's remainder now lives. +All 205 slices — the 129 from the v1 campaign and wave 2, the 51 the server rebuild added, +the 23 of lane U (the Apple client's mocked UI), and the 2 of the notification lane. Every +indexed row has a detail block and every detail block has a row: `grep -c '^### S-' +SLICES.md` and the row count of the table below are both 205. `Lane`, `Depends on`, and +`Size` are the campaign's own metadata; `Owed →` names where a `done*` row's remainder now +lives. | ID | Slice | Lane | Depends on | Size | Area | Status | Owed → | | --- | --- | --- | --- | --- | --- | --- | --- | @@ -210,26 +245,26 @@ row's remainder now lives. | S-A9 | Add-id counter reseed at `Workspace` open | core-crypto | — | S | ACTIVE | done | | | S-A10 | Durable album-key persistence + library open plumbing | core-crypto | — | L | ACTIVE | done | | | S-A11 | Publish the DEK in the device directory | core-crypto | — | M | ACTIVE | done | | -| S-B1 | Thumbnail/LQIP generation | media/import | — | L | RETIRED | ready | | +| S-B1 | Thumbnail/LQIP generation | media/import | — | L | ACTIVE | done\* | lossy JXL/AVIF encode, preview tier, HEIC/RAW decode → #437; WebP → #444 | | S-B2 | Signed-path import-executor rewrite | media/import | S-B1 | L | MIXED | done\* | durable album keys → `S-A10` | | S-B3 | Streaming import (probe, `total_size`, drive mode) | media/import | S-D1, S-D4 | L | MIXED | done | | | S-B4 | Staged uploads (low-data tier ladder) | media/import | S-C1, S-C2, S-D1 | M | MIXED | done | | -| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | RETIRED | ready | | +| S-B5 | Video derivatives (first-frame still + H.264 preview) | media/import | S-B1 | M | ACTIVE | ready | `rawshift-video` unpublished → #438 | | S-B6 | Google Takeout importer | media/import | S-B2 | M | MIXED | done\* | sidecar-enrichment write → `S-B10` | | S-B7 | iCloud export importer | media/import | S-B6 | M | MIXED | post-v1 | | | S-B8 | Immich importer | media/import | S-B6 | M | MIXED | post-v1 | | | S-B9 | Tethered camera import (PTP/IP) | media/import | S-B2 | L | MIXED | post-v1 | `ptpip-rs` gate | | S-B10 | Takeout metadata → signed sidecar enrichment | media/import | S-A10 | M | ACTIVE | done | four doc rows owed; streaming path → `S-B3`/`S-B11` | -| S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export owed | -| S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | ready | users cannot verify enrichment | +| S-B11 | CLI `import --provider takeout` + real-archive run | media/import | S-B10 | S | ACTIVE | done\* | synthesized archive only; real export → #452 | +| S-B18 | No CLI surface shows what the importer actually wrote | media/import | S-B10 | S | ACTIVE | done | `capsule show`; the guide's sampling step is executable | | S-B12 | Base default-album resolution (`resolve_default_album`) | media/import | — | M | ACTIVE | done | scope-override + source-kind rows → post-v1 | -| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | RETIRED | ready | | -| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | wasm entry point owed to the browser-`lqip` slice | +| S-B13 | Codec stubs → typed `UnsupportedFormat` (no panics) | media/import | — | M | ACTIVE | done | | +| S-B14 | LQIP on Chromahash 0.7.1 in `capsule-core::lqip` | media/import | — | M | ACTIVE | done | | | S-B15 | Importer-formed stacks exist only in the index | media/import | S-D21 | M | ACTIVE | done | rebuild guard kept as pre-`S-B15` compatibility | | S-B16 | Every import stamped by import time, not capture time | media/import | — | S | ACTIVE | done | found by the CLI round-trip test | -| S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | ready | the wrong value is in *signed* bytes | +| S-B17 | Repair capture timestamps written before `S-B16` | media/import | S-B16 | M | ACTIVE | done | `capsule repair capture-time`; dry run by default | | S-C1 | Upload-server hardening (envelope gate + invariants) | server | — | L | RETIRED | done\* | discard worker, asset index and quota not ported | -| S-C2 | Key-free sync feed | server | S-C1 | L | RETIRED | done\* | ported to Kynos REST; Postgres adapter + cursor-key loading owed | +| S-C2 | Key-free sync feed | server | S-C1 | L | RETIRED | done\* | ported to Kynos REST over `S-C37`'s index, now Postgres-backed; cursor-key loading owed | | S-C3 | Storage-verification endpoint | server | S-C35, S-C37 | M | RETIRED | done\* | structural verdict only; the `deep` re-hash → `S-C41`; GC state → `S-C11` | | S-C4 | Share-link serving endpoints | server | S-A5 | M | RETIRED | done\* | the serve-path privacy strip is unimplementable on a key-free server → `S-C50`; limiters → `S-C32` | | S-C5 | Drop store, inbox, atomic adoption | server | S-A6, S-C1, S-C6 | L | RETIRED | done\* | adoption is a two-phase claim, not a transaction; limiters → `S-C32` | @@ -252,11 +287,11 @@ row's remainder now lives. | S-C22 | Structured `duplicate_blob` ref + adopt in OpenAPI | server | S-C37 | S | RETIRED | done\* | server half; adopt endpoint → `S-C5`; undescribed extension → `S-C38` | | S-C23 | `revoke_all_sessions` with master-key proof | server | S-C42 | M | RETIRED | done | `S-C48` closed the access-token window it left open | | S-C24 | Album-upgrade server halves (quiescence/drain/lineage) | server | S-C42 | M-L | RETIRED | done\* | the ceremony's wire vocabulary was `mls`-gated and therefore unreachable; the projection deliberately gets no lineage | -| S-C25 | Album provisioning + UUID album ids (unblocks push) | server | S-C29 | M | RETIRED | done\* | also lands the first real `WriteAuthority`; sharing widens it → `S-C4`/`S-C5` | +| S-C25 | Album provisioning + UUID album ids (unblocks push) | server | S-C29 | M | RETIRED | done\* | also lands the first real `WriteAuthority`; sharing widened it in `S-C51`; the `AlbumStore` Postgres adapter is still owed | | S-C26 | Retire the plaintext album name/description columns | server | S-C25 | S | RETIRED | done | the Kynos schema never declared them; a document tripwire keeps it that way | -| S-C27 | Wire-contract types on plain serde behind an adapter | server | — | M | RETIRED | part 1 done | DTO move → Kynos rebuild; status gaps → `S-C28` | +| S-C27 | Wire-contract types on plain serde behind an adapter | server | — | M | RETIRED | done | part 2 declined by the Kynos port; the crate retired with the Salvo tree | | S-C28 | Publish the statuses the server actually returns | server | S-C27 | S | RETIRED | done\* | auth surface closed; folds into each remaining port | -| S-C29 | The two storage ports + typed ceremony stores | server | S-C27 | L | RETIRED | done\* | Valkey + Postgres adapters owed; counters → `S-C32` | +| S-C29 | The two storage ports + typed ceremony stores | server | S-C27 | L | RETIRED | done\* | Valkey adapters landed (#403); Postgres adapter owed (#402); counters → `S-C32` | | S-C30 | Feed `manifest_cbor` carries the signed manifest | server | S-C1, S-C2 | M | RETIRED | done\* | server half stores and serves verbatim; client producer owed to `S-D1` | | S-C31 | Custody receipt attests a hash of server-invented bytes | server | S-C30 | M | RETIRED | done | the chain position replaces it, and the chain head stops riding a coincidence | | S-C32 | MFA-attempt and rate-limit counters have no port | server | S-C29 | M | RETIRED | done\* | the port lands with three of its consumers; the per-source half needs a trusted client address | @@ -264,9 +299,9 @@ row's remainder now lives. | S-C34 | Nothing gates the Kynos OpenAPI document | server | — | S | RETIRED | done | two documents gated separately until parity | | S-C35 | The blob store port, sharded | server | S-C27 | L | RETIRED | done | wired by `S-C1`, which also found a missing operation | | S-C36 | Kynos's framework rejections carry no `error.*` code | server | S-C33 | M | RETIRED | done | a Capsule interceptor fills the member in; the upstream seam is still the better fix | -| S-C37 | The asset index port, one sequence instead of two | server | S-C27, S-C29 | L | RETIRED | done\* | Postgres adapter owed; absorbs `S-C21` and unblocks `S-C22` | +| S-C37 | The asset index port, one sequence instead of two | server | S-C27, S-C29 | L | RETIRED | done | Postgres adapter landed under the row lock the design rests on; absorbs `S-C21`, unblocks `S-C22` | | S-C38 | Problem extensions are absent from the OpenAPI document | server | S-C34 | M | RETIRED | done\* | `code` is universal and derived; the sixteen other members ride a small table | -| S-C39 | Blob fetch has no read authority, so its `403` is unwritable | server | S-C10 | M | RETIRED | part | the authority lands and owner-scopes the path; the `403` needs a membership fact → `S-C51` | +| S-C39 | Blob fetch has no read authority, so its `403` is unwritable | server | S-C10 | M | RETIRED | done | the authority landed owner-scoped; the `403` landed with `S-C51`'s membership fact | | S-C40 | `awaiting-original` is not observable on the blob path | server | S-C10, S-C37 | M | RETIRED | done | the promise is the open upload session, so it needed no lifetime of its own | | S-C41 | The `deep` re-hash, with the limiter that makes it safe | server | S-C3, S-C32 | M | RETIRED | done\* | coalescing is deliberately absent, and the reason is in the note | | S-C42 | Nothing verifies the device directory's own signature | server | S-C9 | M | RETIRED | done | trust-on-first-publish anchor; unblocks `S-C23` | @@ -278,7 +313,7 @@ row's remainder now lives. | S-C48 | The bearer scheme never reads the session ledger | server | S-C23, S-C29 | M | RETIRED | done\* | fails closed as `401`; the honest `503` needs the seam `S-C36` wants | | S-C49 | Moderation's federated halves have no federation to hang on | server | S-C8, S-C32 | M | RETIRED | blocked | found by `S-C8`; report intake and the blocklist both need the federation layer | | S-C50 | The share-link privacy strip is specified where it cannot run | docs | S-C4 | S | ACTIVE | done | both docs now name the issuing client, with containment as the server's half | -| S-C51 | Server-side album membership, which two authorities are waiting on | server | S-C25, S-C39 | L | RETIRED | blocked | found by `S-C39`; the read `403` and the widening of album *write* access are one missing fact | +| S-C51 | Server-side album membership, which two authorities are waiting on | server | S-C25, S-C39 | L | RETIRED | done | the owner-signed roster is the fact; `PUT /v1/albums/{album_id}/roster`, the write widening, the blob `403` and `GET /v1/sync?album_id=` land together; shared bytes across owners → #462 | | S-C52 | The server keeps one manifest per asset, not the chain it is documented to hold | server | S-C16, S-C43, S-C45 | M | RETIRED | done | retention decided *for*, and scrub check 4 lands with it | | S-C53 | Account creation has no surface on the rebuilt server | server | S-C13 | M | RETIRED | done | registration lands; the unported operations are decided one by one on `S-C54`–`S-C58` | | S-C54 | The profile surface, and a password change that is not a reset | server | S-C53 | M | RETIRED | done | three operations where Salvo had one; the address becomes immutable and `/validate` and password reset are deleted rather than owed | @@ -291,40 +326,40 @@ row's remainder now lives. | S-C61 | The drop passphrase is provisioned and never checked | server | S-C5, S-C60 | S | RETIRED | done | a gated link admitted anyone holding the opaque id; the web client was posting to paths that no longer exist | | S-C62 | The web auth client speaks a surface that is gone | sdk/clients | S-C54, S-C55, S-C56, S-C60 | M | RETIRED | done | passkey and password-reset screens removed, login reads `202`, profile is the four fields the server keeps | | S-C63 | The SDK cannot read a second-factor challenge | sdk/clients | S-C55 | M | RETIRED | done | `login` returns an outcome, not a session; `capsule auth login` prompts for the code | -| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | RETIRED | ready | | -| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | RETIRED | ready | | +| S-D1 | SDK upload client (hand-written, stateful protocol) | sdk/clients | S-C1 | M | MIXED | done\* | E2E case 2 → `S-Q1` (#409) | +| S-D2 | SDK sync/download client + connection-class budget | sdk/clients | S-C2, S-C9 | L | MIXED | done\* | E2E case 3 → `S-Q1` (#409) | | S-D3 | Web guest drop client (WASM) | sdk/clients | S-A6, S-C5 | L | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates | | S-D4 | Verify-before-destroy wiring | sdk/clients | S-C3, S-C15 | M | MIXED | done | | | S-D5 | CLI auth/sync/list | sdk/clients | S-D1, S-D2 | M | MIXED | done | | | S-D6 | Web server gateway (key-free reads) | sdk/clients | S-D2, S-C60 | L | MIXED | done\* | live browser smoke → `S-Q5`; decode boundary → post-v1 | -| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | RETIRED | ready | | -| S-D8 | spargen REST client integration | sdk/clients | — | M | RETIRED | ready | 401-retry-once → `S-D17` | +| S-D7 | SDK auth/session foundation + auto token refresh | sdk/clients | — | M | MIXED | done\* | typed-path 401-retry-once → `S-D17` (#408) | +| S-D8 | spargen REST client integration | sdk/clients | — | M | MIXED | done\* | 401-retry-once → `S-D17` | | S-D9 | capsule-sdk uniffi FFI bindings | sdk/clients | S-F1, S-D7 | M | RETIRED | ready | Swift harness → `S-P8`; Kotlin harness → owed-CI | -| S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | RETIRED | ready | | +| S-D10 | Adverse-network hardening | sdk/clients | S-D1, S-D2 | M | MIXED | done | | | S-D11 | Client cohort emission + devices grouping UI | sdk/clients | S-C13, S-D7 | M | MIXED | done\* | iOS reader → `S-P6`; devices screen → post-v1; device_id → `S-N3` | -| S-D12 | Recovery verification cadence + guided re-wrap | sdk/clients | S-C12 | M | MIXED | done | | +| S-D12 | Recovery verification cadence + guided re-wrap | sdk/clients | S-C12 | M | MIXED | done | `store_escrow` discards `stored_at`/`replaced` → issue #442 | | S-D13 | Culling workflow client UX | sdk/clients | — | M | ACTIVE | done | | | S-D14 | Local-gallery security gates | sdk/clients | — | S | ACTIVE | done | | | S-D15 | Exact client build identification | sdk/clients | — | S | MIXED | done | | | S-D16 | Standalone `capsule cull` command | sdk/clients | S-A10 | S | ACTIVE | done | | -| S-D17 | Typed REST client reactive 401-retry-once | sdk/clients | — | S | RETIRED | ready | | +| S-D17 | Typed REST client reactive 401-retry-once | sdk/clients | — | S | MIXED | done | | | S-D18 | `capsule push` — drive `capsule_sdk::upload` from CLI | sdk/clients | S-A10 | M | MIXED | done | | | S-D19 | Hidden-view DB projection + gate wiring | sdk/clients | — | S | ACTIVE | done | rebuild un-hides → `S-D21` | | S-D21 | Index rebuild loses gated state (two sidecar shapes) | sdk/clients | S-D19 | M | ACTIVE | done | importer stacks → `S-B15`; unsigned migration → `S-D24`; no hidden writer → `S-D25` | | S-D22 | FFI `Catalog` bypasses the SR1 view gates | sdk/clients | S-D19 | S | ACTIVE | done | Swift half landed with `S-I4`; two small items owed | -| S-D23 | Client SQLite schema has no upgrade path | sdk/clients | — | M | ACTIVE | done | typed error at the `open` boundary still owed | -| S-D24 | Migrate unsigned sidecars, then delete the reader | sdk/clients | S-D21 | L | ACTIVE | blocked | needs a design decision first | +| S-D23 | Client SQLite schema has no upgrade path | sdk/clients | — | M | ACTIVE | done | owed typed error landed with `S-D24` | +| S-D24 | Migrate unsigned sidecars, then delete the reader | sdk/clients | S-D21 | L | ACTIVE | done | CLI verb → follow-up issue | | S-D25 | `hidden` has a column, a gate and views but no writer | sdk/clients | S-D19 | S | ACTIVE | done | | -| S-D26 | CLI drops the rotated token pair, forcing re-login | sdk/clients | — | S | MIXED | ready | fix in the REST client, not the old one | +| S-D26 | CLI drops the rotated token pair, forcing re-login | sdk/clients | — | S | MIXED | done | | | S-D27 | The SDK test mock never shuts its listener down | sdk/clients | — | S | ACTIVE | done\* | fixed a real leak; the LEAK signal is partly noise | | S-D28 | The SDK's second transport, and the document it generates from | client SDK | S-D8, S-C2 | L | MIXED | done\* | gRPC retires; the client generates from the Kynos document; four `application/cbor` operations stay hand-written | -| S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | | +| S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | core half landed (`capsule-core::notify` + FFI); native delivery, `notification.*` keys, permission placement owed | | S-D20 | CLI truthfulness pass (status/register/endpoints/flags) | sdk/clients | — | M | MIXED | done | | | S-E1 | Share-link end-to-end serving | fed/sharing | S-C4 | M | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates | -| S-E2 | Federation capabilities + pulls | fed/sharing | S-C2, S-A3 | L | RETIRED | ready | capability gate on the live read method → `S-E5` | +| S-E2 | Federation capabilities + pulls | fed/sharing | S-C2, S-A3 | L | RETIRED | part | the serving half ships: mint/refresh/revoke, the capability arm on `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, per-peer events budget, Postgres ordinal 6; the receiving half (egress worker, re-validation, rejected-hash table) → #476 | | S-E3 | LAN peering | fed/sharing | S-D2, S-C7 | L | RETIRED | ready | live mDNS → post-v1 (peering.md note) | | S-E4 | Aggregated federated albums (album-group view) | fed/sharing | S-E2, S-D2 | L | MIXED | done | cover override rides post-v1 settings doc | -| S-E5 | Federation capability gate on the REST sync surface | fed/sharing | — | M-L | RETIRED | ready | | +| S-E5 | Federation capability gate on the REST sync surface | fed/sharing | — | M-L | RETIRED | done | one `bearer` component, two principals; the peer arm is bound to the capability's album and its member's granted epoch | | S-F1 | uniffi consolidation (0.29 catalog vs 0.31 core) | platform/FFI | — | M | ACTIVE | done | | | S-F2 | Secure Enclave / StrongBox hybrid composition | platform/FFI | S-A4, S-F1 | L | ACTIVE | done\* | Kotlin run → owed-CI | | S-F3 | Xcode/Gradle binding wiring + on-device CI | platform/FFI | S-F2 | L | ACTIVE | done\* | first CI runs + device lanes → owed-CI | @@ -345,12 +380,12 @@ row's remainder now lives. | S-I2 | Official language-set rollout (12 locales + RTL) | i18n | — | L | ACTIVE | done\* | native RTL → post-v1; review → gates | | S-I3 | `xtask translate-readme` + CI drift check | i18n | S-I2 | M | ACTIVE | done | | | S-I4 | Swift interpolated/plural strings + InfoPlist/LAContext | i18n | — | M | ACTIVE | done | forced an ICU→Apple compiler in the generator | -| S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | ready | `i18n-guard` never scanned the CLI | +| S-I5 | The CLI import arm has no `cli.import.*` catalog namespace | i18n | — | M | ACTIVE | done | | | S-I6 | Android ships raw ICU to users; the guard never fires | i18n | — | M | ACTIVE | done | `aapt2` unverified — owed-CI | -| S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done\* | refuses now; evaluating plurals still owed | -| S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | ready | found widening `i18n-guard` | -| S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | ready | | -| S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | blocked | | +| S-I7 | The Rust runtime formatter cannot do ICU plurals | i18n | — | M | ACTIVE | done | plurals evaluated; `select`/`offset:` still refused | +| S-I8 | clap `--help` text is unreachable from the catalogs | i18n | — | S | ACTIVE | done | help is localized via `cli.help.*`; `ValueEnum` variant help stays English | +| S-N1 | OIDC relying party (server) | auth | — | L | RETIRED | done\* | in-process mock IdP stands in for the testcontainer one; durable adapters owed (#460) | +| S-N2 | SDK/CLI OIDC login flows | auth | S-N1 | M | MIXED | part | SDK half landed with `S-N1`; CLI loopback listener + device grant are #461 | | S-N3 | `device_id` on session listing + ceremony cohorts | auth | — | S | RETIRED | done | the wire half lands with `S-C13`; the TOTP ceremony with `S-C55`; passkeys retire on `S-C56` | | S-P1 | `capsule_sdk` FFI workspace verbs | iOS path | S-A10 | L | MIXED | done | feed `manifest_cbor` shape → `S-C30` | | S-P2 | Swift auth service + Keychain + login screen | iOS path | S-P1 | L | MIXED | ready | | @@ -360,10 +395,10 @@ row's remainder now lives. | S-P6 | SE signer wiring into the app + iOS cohort reader | iOS path | S-P1 | M | ACTIVE | ready | | | S-P7 | Dev-server bring-up (task, keys, blob backend, ATS) | iOS path | — | M | MIXED | done | | | S-P8 | Swift behavioral FFI harness (flips S-D9) | iOS path | S-P1, S-P7 | M | MIXED | ready | | -| S-Q1 | Mark/complete E2E cases 2, 3, 11 | e2e | — | S | MIXED | ready | | -| S-Q2 | E2E case 6: backup → fresh-device restore | e2e | — | M | MIXED | ready | | -| S-Q3 | E2E case 7: full lifecycle chain | e2e | — | M | MIXED | ready | | -| S-Q4 | E2E case 12: cross-device enrollment | e2e | — | M | MIXED | ready | | +| S-Q1 | Mark/complete E2E cases 2, 3, 11 | e2e | — | S | MIXED | done\* | 2, 3 in `capsule-e2e`; 11 lands with #447 | +| S-Q2 | E2E case 6: backup → fresh-device restore | e2e | — | M | MIXED | done\* | restore reads; verify → #468; account seam → #467 | +| S-Q3 | E2E case 7: full lifecycle chain | e2e | — | M | MIXED | done | | +| S-Q4 | E2E case 12: cross-device enrollment | e2e | — | M | MIXED | done\* | server leg; client ceremony → #471, #467, #405 | | S-Q5 | Live-browser smokes (gRPC-web, share, drop) | e2e | S-P7 | M | MIXED | ready | | | S-Q6 | E2E case 10: model regen after version bump | e2e | — | M | ACTIVE | done | the case was untestable, not untested | | S-U1 | Domain + ports + mock seam | apple-ui | — | L | ACTIVE | done | | @@ -400,25 +435,34 @@ row's remainder now lives. | S-Z5 | Dead-code removal (exports stub, CLI import planner) | design/docs | — | S | MIXED | done | | | S-Z6 | Developer-docs parity pass | design/docs | — | M | MIXED | done | | | S-Z7 | Developer reference architecture (design) | design/docs | — | S | ACTIVE | done | | -| S-Z8 | Reference shell + CLI reference | design/docs | S-Z7 | M | ACTIVE | ready | | -| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8, S-D8 | M | ACTIVE | blocked | Kynos document → `S-C27`/`S-D8` | -| S-Z10 | SDK / FFI / WASM reference | design/docs | S-Z8 | M | ACTIVE | ready | | +| S-Z8 | Reference shell + CLI reference | design/docs | S-Z7 | M | ACTIVE | done | man pages/completions scoped out | +| S-Z9 | REST reference from the Kynos document | design/docs | S-Z8, S-D8 | M | ACTIVE | done | | +| S-Z10 | SDK / FFI / WASM reference | design/docs | S-Z8 | M | ACTIVE | ready | uniffi has no stable dump — own issue | | S-Z11 | Notification architecture (design) | design/docs | — | S | ACTIVE | done | | **Row counts.** 205 rows — the 129 from the v1 campaign and wave 2, the 51 the server rebuild added, the 23 of lane U, and the 2 of the notification lane. By -area: **87 ACTIVE / 80 RETIRED / 38 MIXED**. By status: -**93 done / 55 done\* / 37 ready / 9 part / 7 blocked / 4 post-v1** -(`S-C8`, `S-C27`, `S-C39`, and `S-U9`–`S-U14` — the table spells these `part` +area: **90 ACTIVE / 71 RETIRED / 44 MIXED**. By status: +**111 done / 62 done\* / 16 ready / 9 part / 3 blocked / 4 post-v1** +(`S-C8`, `S-E2`, `S-N2`, and `S-U9`–`S-U14` — the table spells these `part` and `part 1 done`; they are counted together). +These numbers are **counted from the table below**, not accumulated from the +lanes that changed it. The distinction earned its keep: the twenty lanes of the +2026-09 programme each recorded a row delta of zero and were right to — no lane +added or removed a row — yet every area and status figure above moved, because +what those lanes changed was the state of rows that already existed. A total +that still reconciles is not evidence that a breakdown does. + Lanes are independent by construction; within a lane, "Depends on" is the only -ordering. Seven rows read `blocked`, and only two of them are waiting on code: -`S-N2` behind `S-N1`, and `S-P4` behind `S-P2`/`S-P3`. The rest are waiting on a -decision rather than on an implementation — `S-C47` is a legal question, `S-C49` -and `S-C51` each need a fact the slice that found them could not settle, `S-D24` -needs a design decision, and `S-Z9` needs the Kynos document -(`S-C27`/`S-D8`). `S-P1` landing freed the rest of lane P and `S-U19` with it; +ordering. Three rows read `blocked`, and only one is waiting on code: `S-P4` +behind `S-P2`/`S-P3`. The other two are waiting on a decision rather than on an +implementation — `S-C47` is a legal question, and `S-C49` needs a fact the slice +that found it could not settle. Six rows left this list in one programme: +`S-B1`, `S-B5` and `S-B13` on `rawshift-image` reaching crates.io, `S-C51` and +`S-D24` on the facts their slices were owed, and `S-N2` on `S-N1` landing its +server half — it now reads `part`, not `blocked`, because the SDK leg shipped +with it. `S-P1` landing freed the rest of lane P and `S-U19` with it; lane U was built so the other twenty-two Apple-client slices never waited on that chain in the first place. Everything else that once read `blocked` is startable: `S-A10` and `S-P7` are done (freeing `S-B10`, `S-D16`, `S-P1`, `S-Q5` — of @@ -457,12 +501,12 @@ when" cannot fully pass until the gate lifts. | Library / environment | Status | Gates | | --- | --- | --- | | [`kynos`](https://github.com/getkono/kynos) 0.1.0 | adopted as the replacement server; **published, consumed from crates.io** | Published 2026-08-29, which fired the repin-on-publish exit this row used to carry — the git rev `6513109` is history, not a pin. Taken with the `openapi32` feature, but note that the feature alone does **not** yield a 3.2 document: Kynos emits the lowest version that expresses the API and deliberately refuses to key that on a flag Cargo can unify in from an unrelated crate, so `capsule-server` pins it explicitly via `openapi_as(SpecVersion::V3_2)` and a test asserts the emitted `openapi` field. Gates the whole `RETIRED` rebuild: lane C, `S-E5`, `S-N1`/`S-N3`, and the SDK wire half (`S-D1`, `S-D2`, `S-D7`–`S-D10`, `S-D17`). `S-C27` is its precondition. | -| `spargen` 0.4.0 | adopted; both known gaps **closed**; consuming OpenAPI **3.2** | Bumped 0.1.0 → 0.4.0 on 2026-08-28. Both gaps this row used to record are gone: 0.2.2 added *decode textual and binary responses* and *serialize typed OpenAPI parameters*, so byte serving and object-typed query params lower correctly and the media asset-serve tree **returns to the generated client** — the hand-written byte path is no longer justified by a generator gap. 0.3.0 added *complete OpenAPI 3.1 and 3.2 conformance* plus runtime dependency contracts (which forced minimum bumps of `bytes`, `reqwest`, `serde`, `serde_json`). The API also changed: `Config` split into `Spec`/`Build`, and `Report::outcome` became a method. **0.4 validates the document strictly, and it rejects four operations the Salvo server emits** — see the row below. | -| Salvo-emitted schema | **4 of 37 operations structurally invalid** | Found 2026-08-28 when spargen 0.4 refused them; 0.1.0 accepted them silently, which is the only reason they reached the committed contract. `POST /v1/albums/{album_id}/ops` declares **no responses at all** — the handler returns `()` and picks its status at run time (`StatusCode::from_u16(result.status)`) so an idempotent replay returns stored bytes verbatim, leaving salvo-oapi no return type to describe. `GET /v1/auth/devices/directory/{user_id}`, `GET` and `POST /v1/auth/devices/enroll/channel/{channel_id}` carry a path-template variable and **declare no path parameters**. All four are therefore already uncallable from a typed client — which is *why* the SDK hand-writes `capsule_sdk::directory`. Narrowed with `spargen::omit!` in `capsule-sdk/build.rs` rather than repaired: fixing salvo-oapi annotations is work thrown away, and **Kynos makes both classes unrepresentable** (status is part of the return type; `#[kynos::get(..)]` checks at compile time that the path type's fields are exactly the template's variables). These are acceptance criteria for the Kynos port of `S-C16` and the auth-devices tree, and the hand-written directory client goes with them. | +| `spargen` 0.4.0 | adopted; both known gaps **closed**; consuming OpenAPI **3.2** | Bumped 0.1.0 → 0.4.0 on 2026-08-28. Both gaps this row used to record are gone: 0.2.2 added *decode textual and binary responses* and *serialize typed OpenAPI parameters*, so byte serving and object-typed query params lower correctly and the media asset-serve tree **returns to the generated client** — the hand-written byte path is no longer justified by a generator gap. 0.3.0 added *complete OpenAPI 3.1 and 3.2 conformance* plus runtime dependency contracts (which forced minimum bumps of `bytes`, `reqwest`, `serde`, `serde_json`). The API also changed: `Config` split into `Spec`/`Build`, and `Report::outcome` became a method. **0.4 validates the document strictly**, and four operations are still narrowed with `spargen::omit!` — for a different reason than they used to be; see the row below. | +| Four `spargen::omit!` operations | **narrowed for a media type spargen cannot classify** | The four that used to sit here were structurally invalid Salvo output, and **Kynos makes both of those defects unrepresentable**: status is part of the return type, so `POST /v1/albums/{album_id}/ops` cannot declare no responses, and `#[kynos::get(..)]` checks at compile time that a path type's fields are exactly the template's variables, so the three device routes cannot take an undeclared path parameter. All four are generated now. **Four different operations take their place, for a reason outside the contract** (`S-D28`, `capsule-sdk/build.rs`): spargen's `classify_media` knows JSON, XML, multipart, form-urlencoded, octet-stream, event-stream, NDJSON, JSON sequences and `text/*`, and no `application/cbor`. Capsule serves four operations in that media type — `POST /v1/auth/devices/directory`, `GET /v1/auth/devices/directory/{user_id}`, `POST /v1/albums/{album_id}/upgrade`, `GET /v1/upload/{id}/receipt` — all of them **signed** documents served byte for byte, which is exactly why they are not JSON. Narrowing them is the instruction's own remedy (*narrow the surface, never mutilate the spec*); relabelling them `application/octet-stream` to satisfy a generator would tell every client that a document with a schema it knows is opaque bytes. `capsule_sdk::directory` hand-writes two of them; the other two have no client yet. | | `openmls` 0.8.x | adopted (X-Wing `0x004D`) | The X-Wing codepoint **exists** (`0x004D`) and OpenMLS ships it via libcrux, so `S-X1`–`S-X3` are not blocked and are done. Key serialization surfaces are `test-utils`-gated — persistence rides public fields + ungated codecs; fragile if upstream privatizes (upstream ask filed against openmls). Version pairing is load-bearing (0.8.x ↔ traits/storage 0.5.x ↔ libcrux-crypto 0.3.x). | | `libcrux` provider | no wasm32 target | `mls` feature is host-only; a browser MLS surface would need another provider. | | BD-09 datum fold | **no crate adopted** | Decision 2026-08-21: `S-A8` implements the error-bounded refined BD-09→GCJ-02 inverse **in-house** (~40 LOC, deterministic, unit-testable) rather than taking a dependency for one function. `geocoordinates-rs` is **not** a gate on `S-A7`/`S-A8` and is not planned; the earlier "exact fold from `geocoordinates-rs`" wording is superseded. Display-side lossy conversions remain unscheduled and are not part of either slice. | -| `rawshift` (in-house RAW decode) | stabilizing, unconsumed | Full RAW support in thumbnails/import; `media::image::formats::raw` is the integration stub. Also the target the `RETIRED` media slices (`S-B1`, `S-B5`, `S-B13`) rebuild onto. | +| [`rawshift-image`](https://crates.io/crates/rawshift-image) 0.1.1 | adopted; **published, consumed from crates.io** | Consumed 2026-09-02 by `S-B1`/`S-B13` (#436) as a registry dependency behind `capsule-core`'s `media` feature — not the pinned submodule, which is what this row used to gate on. That closed the gate for the still half: `S-B1` and `S-B13` are `ACTIVE`/`done` and `S-B5` is `ACTIVE`/`ready`. **Three sub-gates remain, and each is a dependency decision rather than this crate stabilizing.** `rawshift-video` is unpublished, so the video half (`S-B5`, first-frame still and the H.264 preview) cannot start — #438. A *lossy* JXL master needs C libjxl, AVIF encode needs `nasm` on every x86_64 build host, and HEIC/AVIF decode need system libheif/libdav1d — #437; every enabled codec today is pure Rust, which is what keeps the mobile cross-builds linking. WebP is recognised but undecodable here: `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 declares `*const c_char`, an E0308 on every aarch64 target — #444. All three are visible as typed `MediaError::UnsupportedFormat` or as per-`(tier, format)` deferrals counted by `ImportExecutionSummary::deferred_format_count()`, never as silent absence. | | `ptpip-rs` (in-house PTP/IP) | repo not created | `S-B9` (post-v1). | | Self-hosted device runners | unprovisioned | The `strongbox-device`/`secure-enclave` CI lanes exist, manual-trigger, inert. Owed-CI items park here: `S-F2` Kotlin run, `S-F3` first Android/iOS CI runs + device lanes, `S-F4` Windows ffi build + clippy + real-TPM smoke, `S-F5` Kotlin ECDH adapter, `S-D9` Kotlin harness. | | swiftformat 0.55 (mise) | **resolved 2026-08-22** | The install was a corrupt app-bundle extraction whose `Info.plist` no longer matched its signature, so the hardened runtime SIGKILLed it (exit 137). `mise uninstall swiftformat@0.55 && mise install` yields a plain 0.55.6 binary that runs. Running it for the first time surfaced a real config conflict — swiftformat's `wrapMultilineStatementBraces` versus swiftlint's `opening_brace` — now resolved by disabling the swiftformat rule. | @@ -695,24 +739,60 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Tier:** Unit + Smoke. **Blocks:** S-B2, S-B5. - **Landed in retired code, and retired 2026-09-01 by `S-C59`:** generation shipped over injected per-platform encoder seams and was green in this workspace, but it lived in - `capsule_core::media` — review material. It is now in `legacy-review/media-pipeline/`, together - with `lifecycle/derivatives.rs`, its only caller. **Re-scoped:** re-land on the Rawshift-backed - pipeline. The signed `DerivativeManifest` chain and the sidecar `lqip` field are `ACTIVE` and - stay — the field stays here, its producer moves to `S-B14`. + `capsule_core::media` — review material. It went to `legacy-review/media-pipeline/`, together + with `lifecycle/derivatives.rs`, its only caller. +- **Re-landed 2026-09-02 on the Rawshift-backed pipeline (issue #410).** `capsule-core::media` + exists again, over **`rawshift-image` 0.1.1 from crates.io** (a registry dependency, not the + pinned submodule) behind a `media` feature that `native` implies and the wasm32 sealing build + excludes. The injected `StillEncoder` seam is **gone**: the per-platform encoder was there + because core linked no codec, and it no longer needs to. What ships: + - `media::{detect,decode,resize,derivative,error}` as private submodules behind one barrel — + the closed `StillFormat` set with a Capsule-owned magic-byte table, the `Decoder` seam with + a pre-decode 128 Mpx budget and an unwind boundary, a deterministic integer area-average + downscale (the crate has no resize, and a derivative's bytes are signed), the closed + `DerivativeFormat` set with the `original` sentinel, and `MediaError`. + - the **thumbnail tier** at 256 px as **JXL** — the table's committed *master* format — signed + and hash-chained through the same two-signature `DerivativeCore::sign` path assets use, and + persisted at the layout the upload bundle reader already reads. The declared q=50 is advisory: + the pure-Rust `zune-jpegxl` backend is lossless, which a test asserts rather than hides. + - **WebP was the first choice and CI refuted it.** `image/webp` is in the format table and + `libwebp` has exactly the q=50 knob, but `rawshift-image`'s WebP module passes `*const i8` + where `libwebp-sys` 0.14.4 declares `*const c_char` — `u8` on aarch64 — so the feature is an + E0308 on every mobile target, in both directions (the module compiles under + `any(webp-decode, webp-encode)`). WebP is therefore a recognised-but-undecodable format here; + the upstream fix is filed as #444. + - **Detection is Capsule's, not the crate's.** `rawshift-image`'s own `detect_standard_format` + gates its HEIC arm on `heic-decode`, so delegating would make the typed refusal for a format + depend on whether it can be decoded — a HEIC would arrive as "not a still" instead of "a + still whose derivatives are backfillable". A test pins agreement between the two tables for + every format both define unconditionally. + - **Every encode passes `MetadataEmbedOptions::none()`.** `rawshift-core`'s default is `all()`, + so a default-configured encode copies the source's EXIF — GPS included — into the thumbnail. + A test demonstrates the leak with the crate's own default and then asserts Capsule's + derivative carries no `EXIF`/`XMP`/`ICCP` chunk and none of the source's GPS rationals. +- **Owed → #437 and #444.** A *lossy* JXL master, the AVIF delivery variant, the preview tier and + HEIC/RAW decode (#437); WebP in both directions (#444). None is blocked on a design question: a + lossy JXL needs C libjxl, AVIF encode needs `nasm` on every x86_64 build host, HEIC/AVIF decode + need system libheif/libdav1d, and WebP needs one upstream cast widened. All are visible today as + typed `MediaError::UnsupportedFormat` or as per-`(tier, format)` deferrals counted by + `ImportExecutionSummary::deferred_format_count()`, never as silent absence. ### S-B2 — Signed-path import-executor rewrite - **Contract:** [Import — Pipeline](capsule-docs/src/content/docs/design/import/pipeline.md). - **Deliverable:** a new executor over Rawshift results and the signed `lifecycle::Workspace` path (signed `SidecarV1` + manifest + provenance + derivatives), - informed by but not restoring `legacy-review/core-import-media/`. + informed by but not restoring the plaintext `core-import-media` review twin deleted in + [#423](https://github.com/Capsulsaurus/Capsule/issues/423). - **Depends on:** S-B1 (derivative generation is the missing input). - **Done when:** an executor import produces `verify_asset`-accepting assets with derivatives; planner determinism suite unchanged. - **Tier:** Unit (planner) + Smoke (executor). - **Landed:** `capsule-core/src/import/executor.rs` is the new signed executor and is - `ACTIVE` — it survives. Its *derivative and EXIF inputs* are `RETIRED`, which is why - the row is `MIXED`. **Owed:** durable album keys → `S-A10` (landed). + `ACTIVE` — it survives. Its *derivative* input is `RETIRED`, which is why the row is + `MIXED`; its **EXIF input is not** (corrected 2026-09-01) — `capsule-core::exif` is live + and tested, as the `RETIRED` legend above records. **Owed:** durable album keys → `S-A10` + (landed). ### S-B3 — Streaming import @@ -761,8 +841,22 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift - **Done when:** a fixture video yields both tiers with signed manifests; the closed-format rejection covers the video rows of the tier table. - **Tier:** Unit + Smoke. -- **Landed in retired code:** ships today behind the injected encoder seam; the transcode - half is `capsule-core::media` and re-scopes onto the Rawshift-backed pipeline. +- **Landed in retired code, and re-scoped 2026-09-02 (issue #410 → #438).** It shipped behind + the injected encoder seam; that seam is gone with `S-B1`'s re-land, so this slice now sits on + the live `capsule-core::media` pipeline and is `ACTIVE` — and still unimplemented. +- **Why it is not just another format.** `rawshift-video` is **not published on crates.io** (the + `rawshift` facade's `video` feature points at an unreleased crate) and the transcode toolchain + — demux, video decode, H.264/AAC encode — touches nothing the still path does. That is the + split from `S-B1`, restated: a distinct dependency decision with its own licence surface, which + `design/licensing.md` already names as the most likely route by which copyleft enters Capsule. +- **What happens today:** a video's bytes sniff to no `media::StillFormat`, so every video import + reports `DerivativeStatus::NotAKnownStill` and carries no thumbnail, preview or LQIP. The + original is still imported signed, encrypted and `verify_asset`-accepting, so this is a + cosmetic gap, not data loss. `content_type` stays extension-derived for video, because + detection has no video half yet. +- **`capsule-core::media::video` is the one remaining `planned-modules.txt` row** for this lane + (`#410` narrowed the `capsule-core::media` row to it), which is what keeps `check-docs-truth` + honest about `capsule-core::media::video::derivative` in `design/licensing.md`. ### S-B6 — Google Takeout importer @@ -779,9 +873,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift a fixture-archive import is deterministic across runs and skips completed work on re-run. - **Tier:** Unit (mapping table, determinism) + Smoke (end-to-end archive import). -- **Landed:** the adapter and trait ship in `import/importers/` and are `ACTIVE`. Only the - EXIF side of the precedence fold rides `capsule-core::exif`, which is `RETIRED`. - **Owed:** sidecar-enrichment write → `S-B10`. +- **Landed:** the adapter and trait ship in `import/importers/` and are `ACTIVE`, and so is + the EXIF side of the precedence fold: it rides `capsule-core::exif`, which the original + teardown listed for retirement and `S-C59` kept — the module is live and tested in + `capsule-core/src/exif/`. **Owed:** sidecar-enrichment write → `S-B10`. ### S-B7 — iCloud export importer (post-v1) @@ -870,6 +965,10 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift check against Google's own item count, real camera EXIF across the device long tail, real HEIC/MP4/Live Photo payloads, or how Google actually encodes non-ASCII filenames. There is no real Takeout archive on this machine and no claim is made about one. +- **The remainder is filed as #452** (2026-09-02, while landing `S-B18`/`S-B17`): the real-archive + run — magnitude against Google's item count, real camera EXIF across the device long tail, real + HEIC/MP4/Live Photo payloads, Google's actual filename encoding, scale — plus the guide's sampling + step, which `S-B18` made executable. The row stays `done*` until that issue closes. ### S-B18 — no CLI surface shows what the importer actually wrote @@ -886,6 +985,19 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift do not exist yet (`S-I5`). - **Done when:** the guide's metadata-sampling step is executable as written. **Tier:** Smoke. +- **Landed 2026-09-02** as a new verb, `capsule show --library `, not an extension of + `capsule match` — `match` reads a *source file* and opens no library, so it was the wrong seam. + The positional takes an asset id **or a hex prefix (≥ 8 characters) of the content hash**, because + nothing `capsule import` prints is an asset id while the guide's spot-hash step already leaves the + user holding source SHA-256s, and Capsule imports bytes unchanged; an ambiguous prefix is refused + with the match count. It prints the signed sidecar's projection — album, content type, hash, + dimensions, capture/import instants, caption, rating, user and AI tags, the fix **with its datum** + and source (a GCJ-02 coordinate stored verbatim must not read as WGS-84), cull flag, hidden, stack + placement, LQIP presence, and the provenance record count — every absent value spelled out as + `(unset)`, every line a `cli.show.*` key. The guide's sampling step is rewritten as an executable + `capsule show` loop and asserted as written in `tests/takeout_import.rs`. No `--json`: a machine + shape would need its own compatibility contract, and the guide needs the human one. + ### S-B12 — Base default-album resolution - **Contract:** [Organization — The Default Album + Scope Grammar](capsule-docs/src/content/docs/design/organization.md) @@ -937,10 +1049,37 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift pins that HEIC and RAW-only originals import and self-verify **without** derivatives, and a planner test guards that undecodable stills are never skipped at plan time; `mise run check-rust` green. **Tier:** Unit. -- **Landed in retired code:** shipped and green on this branch, but the whole surface is - `capsule-core::media`. **Re-scoped:** the uninhabited-stub discipline and the - `is_decodable`/`from_extension` coverage table are the contract the Rawshift-backed - rebuild inherits; `DerivativeStatus` on `ImportOutcome` is `ACTIVE` and stays. +- **Landed in retired code:** shipped and green on this branch, but the whole surface was + `capsule-core::media`. The uninhabited-stub discipline and the `is_decodable`/`from_extension` + coverage table were the contract the Rawshift-backed rebuild had to inherit. +- **Re-landed 2026-09-02 (issue #410), and the shape it inherited changed for the better.** + There are no stubs at all now — uninhabited or otherwise — because there is nothing to stub: + `rawshift-image` either has a codec or it does not, and the coverage table + (`StillFormat::is_decodable` over `SUPPORTED_STILL_FORMATS`) is a *gate checked before any + decoder runs* rather than a property of a type nobody can construct. `rg 'unimplemented!\(|todo!\(' + capsule-core/src/media` is empty by construction. + - `MediaError::UnsupportedFormat { format, op }` carries the `FormatOp` — a build can decode a + format it cannot encode, and the message has to say which half is missing. + - **The two-reason distinction is observable again**, and now rests on the bytes rather than the + extension: a HEIC is `DeferredNoCodec` (recognised, no codec here, backfillable) while a + `.jpg` that is not a JPEG is `DecodeFailed` (a format we do decode, failing on these bytes). + `S-C59` had collapsed both into deferrals and the executor test said so; it asserts the + distinction again. + - **A third reason joined them, per format rather than per asset.** + `StillDerivatives::deferred` records each `(tier, format)` pair with no encoder, and + `ImportExecutionSummary::deferred_format_count()` sums them. A decoded JPEG is `Decoded` with + one generated thumbnail and two deferred formats — the number that falls to zero as #437 + lands, rather than a gap only a doc mentions. + - **No panic can reach an import.** Untrusted bytes go through a pre-decode pixel budget + (`MAX_DECODE_PIXELS`, **128 Mpx** — the bomb is inside the decoder, which works in RGB `u16`, + and the honest peak at that ceiling is ~2.5 GB across the decoder's samples, the + alpha-dropping realloc, the RGBA8 copy and the widening back for the encode. `native` implies + `media`, so that peak lands on a phone as an OOM kill rather than an error, which is why the + ceiling sits ~25% above a 102 Mpx medium-format frame rather than as high as an allocation + bomb would require) and + a `catch_unwind` boundary that maps a third-party decoder's panic to `DecodeFailed`. Both are + tested, the panic case through an injected `Decoder`. +- **Originals always import**, unchanged: codec coverage gates *derivatives*, never *admission*. ### S-B14 — LQIP on Chromahash 0.7.1, in its own module @@ -1001,8 +1140,25 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift 21 bytes — `COMPACT_TIER`'s length — which is the concrete proof that byte length cannot discriminate a stale payload. Both are rejected by `from_bytes` and render as the solid dominant-colour fill, never noise. -- **Owed:** no `wasm_bindgen` export exists. wasm links and compiles the identical encoder, but the - browser has no decrypted `lqip` to decode yet, so the entry point belongs to that slice. +- **The producer and the exports landed 2026-09-02 (issue #410), and the module has callers on + all three surfaces.** `lqip` had been fully tested and entirely unreachable: nothing decoded, so + nothing encoded a placeholder. + - **Producer:** `Workspace::prepare_still` encodes from the **full-resolution, + orientation-applied** frame — not from the thumbnail, because chromahash band-limits on the + read side via `decode_capped` and pre-resizing would silently cap fidelity the format can + carry. The signed sidecar now carries a real 32-byte payload at `format_version` 1. + - **Browser:** `capsule-wasm`'s `decodeLqip` (`WasmLqipImage`) returns packed RGBA the viewer + hands to `putImageData`. The JS boundary is a `map`/`ok_or_else` over a pure helper, because + `JsError` cannot be constructed off-wasm and a host test reaching the error arm would abort + the test binary rather than fail an assertion. + - **Native:** `capsule-core-ffi`'s `render_lqip` → `LqipPlaceholder`. A free function rather + than a `Catalog` method: the `assets` table's `chromahash`/`dominant_color` columns are NULL + and must stay so until `library::rebuild` projects them identically, or a rebuilt index would + disagree with a freshly written one — so it takes the record the caller already holds from the + decrypted sidecar rather than pretending the index has it. + - The cross-surface criterion is asserted rather than assumed: both exports are checked + byte-identical to `Lqip::decode_capped` for a real payload, and both paint the + `dominant_color` fill for an unknown version or a payload `from_bytes` rejects. ### S-B15 — Importer-formed stacks exist only in the index @@ -1080,6 +1236,31 @@ workspace at all**, so every still import is a `DeferredNoCodec` until Rawshift dates, reports correct capture timestamps after the pass, and the pass is a no-op on a library imported after `S-B16`. **Tier:** Unit + Smoke. +- **Landed 2026-09-02** as `capsule repair capture-time --library [--apply] [--limit N]` + over a new `Workspace::set_capture_timestamp(asset_id, jiff::Timestamp)` — one signed + `metadata-update` per asset through `append_lifecycle`, the bundle left in its import-time month + directory, `Workspace::open`'s existing reconciliation keeping the paths resolving. **Dry run is + the default**, unlike `push`/`sync`: those write to a re-drivable server, this appends an + irreversible signed record. **Detection is the importer's own rule**, `resolve_timezone` over + `extract_exif`: a resolvable instant that disagrees with the sidecar is affected; a floating + `DateTimeOriginal` (no `OffsetTimeOriginal`, no fix) resolves to nothing and is skipped rather + than guessed as UTC, exactly as the importer skips it — which is what makes the pass a no-op on a + post-`S-B16` library by construction, and leaves Takeout-folded captures alone. An unreadable + original is reported as such, never as "no EXIF". Each correction is an independent write, so an + interrupted `--apply` leaves completed assets correct and a re-run skips them. +- **Precondition fixed while here:** the live write path indexed `capture_timestamp` from the + in-memory `capture_utc` shard while `rebuild_index` projects it from the sidecar; equal at import, + they part ways at exactly this correction, so a correct repair would have been invisible to the + timeline until a rebuild. `asset_row_from_state` now projects from the sidecar too, and the test + asserts the live row and a rebuilt one name the same corrected instant. +- **Expected side effect:** a corrected asset's sidecar no longer names its month directory, so + every `Workspace::open` logs the reconciliation warning for it. That is the documented drift, not a + fault; the opportunistic rename bundle maintenance describes is not built. +- **Not covered:** an asset whose capture time a user deliberately set to something other than its + EXIF. No such edit surface exists — `set_capture_timestamp` is the first — so the question does + not arise; once one does, the pass must skip assets whose chain carries a capture correction that + was not itself this repair. + ## Lane C — server (key-free surfaces) Area: `RETIRED` throughout. Every slice here targets `capsule-api/**`, which is the @@ -1146,9 +1327,14 @@ Lane D while indexing it `server`; it is filed correctly here, in numeric order. entry rather than a forbidden one. The owner is therefore MAC **input**, not a field beside the MAC, and `another_owners_cursor_is_refused_even_under_the_right_key` is the case that says so. The retired implementation never had the property its own design doc claimed. -- **Owed:** the Postgres adapter behind `S-C37`, and key loading — nothing reads - `SYNC_CURSOR_MAC_KEY` or `JWT_ED25519_DER` yet, so the codec is constructed from a literal at - every call site including the tests. +- **The Postgres adapter behind it landed 2026-09-02 (#402).** The feed reads through + `AssetIndex`, so the whole of what the sync surface needed from Postgres is + `index/postgres.rs`: a page is `WHERE owner_id = $1 AND sync_seq > $2 ORDER BY sync_seq`, and + the `ChangeKind` rule that makes a `Created` for one reader an `Updated` for another is the + same shared `entry_for` both adapters render an entry with. The suite's paging and + monotonicity cases run against Postgres unchanged, which is the point of their being in `src/`. +- **Owed:** key loading — nothing reads `SYNC_CURSOR_MAC_KEY` or `JWT_ED25519_DER` yet, so the + codec is constructed from a literal at every call site including the tests. ### S-C3 — Storage-verification endpoint @@ -2185,9 +2371,10 @@ working on a surface written after it. - **The name refusal is a `422`, not a silent drop.** The body is strict, so a `name` or `description` is refused — a client is told the server will not hold album titles rather than left to assume it did. `S-C26` retires the columns themselves. -- **Owed:** sharing widens "writable" from *owner* to *member*, which is `S-C4`/`S-C5`; until - then an album is writable only by the account it was provisioned to, which is the safe - direction. The Postgres adapter is owed with the rest. +- **Owed:** sharing widens "writable" from *owner* to *member* — landed with `S-C51`, not with + `S-C4`/`S-C5` (a share link is not a member): `album_write_access` is keyed on the caller and + answers a writer on the album's roster with the owner's namespace. The Postgres adapter for + `AlbumStore` is still owed with the rest. [`WriteAuthority`]: #s-c20--ground-invariant-7s-floor-in-the-device-directory @@ -2262,6 +2449,17 @@ working on a surface written after it. crate may not depend on salvo at all) and an adapter crate cannot implement a foreign trait for a foreign type. The structs move when Kynos replaces salvo as the schema source, which is why the "Done when" above stays unmet and this row is not `done`. +- **Landed 2026-09-01 — done by retirement.** Part 2 is **declined, not deferred**: the Kynos + port removed the condition it was waiting on. The 39 `ToSchema` derives retired with the Salvo + tree (`S-C59`) instead of moving, `capsule-api` no longer exists, and the SDK generates from + `capsule-server/openapi.json`, so the "Done when" above — `rg salvo capsule-api/*/src/models` + empty plus a byte-identical `openapi.json` — is **vacuous rather than unmet**. The taxonomy's + live home is `capsule-server`'s `problem`, `limits` and `body` modules, where the status is part + of the return type and `tests/conformance.rs` asserts both directions of the agreement this + extraction existed to keep. `capsule-wire` itself moved to `legacy-review/server-salvo/wire/` + beside the 40 `salvo_responses!` call sites that are its only consumers, its manifest disabled, + and `architecture-check` lists it as a retired dependency so a member cannot declare it again + (ADR-0004). ### S-C28 — Publish the statuses the server actually returns @@ -2330,9 +2528,31 @@ working on a surface written after it. serializable payload, that TTL is a property of the store rather than an argument, and that a session record and its per-user index entry cannot be addressed separately — which is what made the `revoke_all_for_user` over-count unrepresentable rather than fixed twice. -- **Owed:** the Valkey and Postgres adapters. Every port has an in-memory adapter and one shared - conformance suite, so "the double behaves like Valkey" is an assertion rather than an - assumption. Counters were deliberately excluded and became `S-C32`. +- **Landed (#403):** the Valkey adapters — `capsule-server/src/store/valkey.rs` for the five + volatile ports and `counter/valkey.rs` for `S-C32`'s counters — over one multiplexed + `ConnectionManager`, with every multi-key mutation or decide-and-write as one Lua script. + Expiry is decided by the injected `Clock` and written into each record, `PEXPIRE` being only + the collector, so the same conformance suite drives them with a manual clock and no sleeps; + `capsule-server/tests/valkey.rs` runs it against a live container (`CAPSULE_TEST_VALKEY=1`). +- **The cohort map's durable adapter landed (#402).** `CohortStore` is the one port here that is + not Valkey's, and the module's own docs say why: a session store forgets a cohort exactly when + "have I seen this device before?" becomes worth asking, so the map has to outlive the sessions + that carried it. The suite's `Harness` was split for it — `CohortHarness` carries the cohort + map and the time seam, `Harness` extends it with the five volatile stores — because a + Postgres-backed harness would otherwise have to implement five adapters it will never have. + The same change corrected `store/mod.rs`'s claim that three adapters were planned per port, + which was never true of any port here. `#403` had written a Valkey cohort hash as an interim + home; `PostgresCohorts` supersedes it and the Valkey one is kept only so `tests/valkey.rs` can + drive the whole port set on one connection. +- **Both adapters met on the integration head, and the `Durable` boot arm still refuses.** It now + demands `DATABASE_URL`, opens the pool, checks the schema, and then proves Valkey answers + `PING` — and refuses after both, naming `#446`, because five durable ports have an adapter on + neither side. Two contradictory unit tests (each lane asserting its own half was the first + refusal) became one container case, + `the_durable_arm_clears_postgres_and_refuses_on_an_unreachable_valkey`. +- **Owed:** the remaining durable ports (#446). Every port has an in-memory adapter and one + shared conformance suite, so "the double behaves like the real one" is an assertion rather than + an assumption. Counters were deliberately excluded and became `S-C32`. - **Done when:** ✅ the conformance suite passes against the in-memory adapter, case by case and in one pass. **Tier:** Unit. @@ -2545,12 +2765,26 @@ carry a schema, and `S-C48` wants a `503` an `Authenticator` can render. One sea - **Done when:** every adapter passes one conformance suite; an upload becomes visible on the feed of the account that made it and on no other; and no sequence number the index mints is unreachable through paging. **Tier:** Unit + conformance. -- **Landed to the in-memory tier — 2026-08-30 (`done\*`).** `capsule-server/src/index/`, 17 - conformance cases, wired into both the upload path (reserve at create, record at finalize) and - the feed, so "upload it, then read it back" is a test of the server rather than of two - disconnected doubles. **Owed:** the Postgres adapter, which is where the row lock this design - depends on actually lives — the in-memory adapter's mutex stands in for it and proves nothing - about it. +- **Landed to the in-memory tier — 2026-08-30.** `capsule-server/src/index/`, conformance cases + wired into both the upload path (reserve at create, record at finalize) and the feed, so + "upload it, then read it back" is a test of the server rather than of two disconnected doubles. +- **Landed to Postgres — 2026-09-02 (#402), which closes the row it was owed.** + `index/postgres.rs` puts the sequence mint inside the transaction that makes the row readable: + `SELECT … FOR UPDATE` on the asset row, then an upsert on `owner_sequences`, then the state + flip, then `COMMIT`. Never a `SEQUENCE` or a `bigserial` — `nextval` is non-transactional and + hands 5 and 6 to two concurrent finalizations without rolling back, which is the skip window + `S-C21` is about. The in-memory adapter's mutex stood in for that lock and proved nothing about + it; the lock now exists, and both adapters pass one case list. +- **What the shared suite found while the adapter was written.** `AssetIndex::rows` — the + scrub's walk — had no conformance case at all, so two cases were added: that it covers pending, + visible and tombstoned rows and resumes at any page size, and that it orders by the + identifier's own bytes. The second is why the adapter pins `COLLATE "C"`: asset ids are the + manifest's client-chosen `file_id` and are full of punctuation, a glibc PostgreSQL ignores `-` + at the primary collation level, and a cursor handed between two adapters that disagree about + that skips rows. +- **`S-C11`'s remainder is not this row's.** The collector reads this index and writes its marks + to `CollectionStore`, which has no durable adapter yet (#446), so `gc`/`purge`/`scrub` still + require `--memory`. ### S-C38 — problem extensions are absent from the OpenAPI document @@ -2658,8 +2892,13 @@ design/moderation.md states the per-surface rule as *"takedown of known content re-reading a landed, tested contract on an inference is not this slice's to do. Recorded here for whoever owns that question. -- **Done when:** the account unshared from an album receives `403` — **not met**, and blocked on - `S-C51`. ✅ what did land: `another_accounts_live_blob_is_unknown_rather_than_served`, +- **Done when:** the account unshared from an album receives `403` — **met with `S-C51`** + (2026-09-03): `MembershipAuthority` replaces `OwnedAssetAuthority`, `BlobReadAccess::Revoked` + renders `403 error.blob.access_revoked` for an account the membership store holds a revoked + row for, and the authority is still asked first. ✅ `a_former_member_is_told_access_was_revoked`, + `a_former_member_gets_the_403_before_any_policy_refusal`, + `a_never_member_is_indistinguishable_from_an_unknown_address_body_and_headers`, plus what + landed with the `part`: `another_accounts_live_blob_is_unknown_rather_than_served`, `a_strangers_refusal_is_indistinguishable_from_an_unknown_address`, `a_stranger_cannot_tell_a_takedown_from_an_unknown_address`, and the authority's own unit cases. **Tier:** Unit + Integration. @@ -3386,13 +3625,18 @@ than a transcription: Thirty-seven documented operations became **fifty-nine**, and the six that were never documented are accounted for. -**The `core-import-media` bucket is deleted rather than refreshed.** It quarantined -`capsule_core::exif` and the import executor's cancellation and progress halves. All three are -live, tested and *newer* on this branch than the snapshot that quarantined them — which is -exactly the charter's exit condition, met in the other direction. Keeping a stale twin of a -working module beside it is the opposite of what quarantine is for, so the bucket went and those -modules stay. This is a **deliberate narrowing of the teardown's file list**, recorded here -because the plan named those files for the move. +**The `core-import-media` bucket was deleted rather than refreshed.** It had quarantined +`capsule_core::exif` and the import executor's cancellation and progress halves. All three were +live, tested and *newer* on this branch than the snapshot that quarantined them — which is exactly +the charter's exit condition, met in the other direction. Keeping a stale twin of a working module +beside it is the opposite of what quarantine is for, so the modules stayed and the bucket went. +This is a **deliberate narrowing of the teardown's file list**, recorded here because the plan +named those files for the move. **Correction 2026-09-01:** this note originally said the bucket +had gone when it had not — `legacy-review/core-import-media/` was still in the tree, the decision +recorded and never carried out. +[#423](https://github.com/Capsulsaurus/Capsule/issues/423) deleted the directory, reworded the +two `import/pipeline.md` citations, and dropped the `ROADMAP.md` row, in commit +`b51639b1`. **`capsule_core::media` does go, and it takes the decoder with it.** It was the former standalone media crate, gated behind a non-default feature whose only consumer was the equally-gated @@ -3804,6 +4048,23 @@ them was incidental: discovery and pinning, signed report intake with `S-C32`'s rate limit, the blocklist and its enforcement point, and whatever admin authentication the above needs. - **Blocked on:** the federation layer (`S-E2`'s territory) and `S-C32`. **Tier:** Unit + Smoke. +- **Status note (2026-09-09): both halves ship, one question stays open.** The + federation-capability layer landed with `S-E2`, and both of this slice's blocked deliverables + followed. **Report intake** is `POST /v1/federation/reports`: the report carries its own + Ed25519 signature over the canonical CBOR of its other fields, verified against the peer's + key, and the signature is verified **before** the `(reporting_server, reported_user)` budget + is charged, so a third party spoofing `reporting_server` cannot spend a real peer's allowance. + A report nobody signed for is dropped and never queued; an accepted one writes a row an + operator reads through `ModerationStore::pending_reports` and changes nothing about the + reported account. **The blocklist** is `blocked_at` on the peer row and is consulted at mint, + at every presentation, at refresh and at intake; blocking also cuts and publishes every live + grant the peer holds. +- **Still owed here.** Peer keys are **operator-pinned**: this server has no outbound HTTP + client, so nothing fetches or TOFU-pins another server's `server-info`, and the operator + command that would do the pinning cannot be written until the durable boot arm exists (`serve + --memory` forgets what it pinned). The **admin authentication model** this slice names first + is untouched: `pending_reports` is the queue, and reading it over HTTP is what waits. + Blocklist *exchange* stays v2 by the contract. Filed as #476. ### S-C50 — the share-link privacy strip is specified where it cannot run @@ -3850,8 +4111,8 @@ them was incidental: - **Gap** (found 2026-08-31 landing `S-C39`): the server holds no fact about who, other than the owner, may read or write an album. Two separate authorities are pinned to "owner only" by the same absence: - - `OwnedAssetAuthority` cannot render the `403` the download contract describes, because there - is no membership to withdraw; + - `OwnedAssetAuthority` (since replaced by `MembershipAuthority`) cannot render the `403` the + download contract describes, because there is no membership to withdraw; - `ProvisionedAuthority::album_write_access` has answered `Denied` for anything but the owner since `S-C25`, with a comment deferring the widening to `S-C4`/`S-C5` — which landed as **link** and **drop** capabilities and did not add it, correctly: a share link is not a @@ -3865,10 +4126,32 @@ them was incidental: end-to-end encryption exists to avoid. - **Blocked on:** the same signed-capability primitive federation needs, so it should be designed with `S-C49` rather than beside it. +- **Landed 2026-09-03 (#405).** The fact is a **full-roster attestation** the album owner signs + with a non-revoked device in their published device directory — + `capsule_core::crypto::membership::SignedAlbumRoster`, canonical CBOR, strictly monotonic + `roster_version`, non-decreasing `amk_epoch`, removal as absence at a higher version — published + at `PUT /v1/albums/{album_id}/roster` (invariant 33) and held by the `membership` port + (in-memory and Postgres, ordinal 5, one conformance suite). Removal is a *stored* fact: the row + is marked with the version and epoch at which the member vanished, which is what lets the blob + route disclose `403` to a former member and nothing to anyone else. It is a **transport** + control, never a confidentiality one; the server still cannot read the MLS group. + Widened on it: `WriteAuthority::album_write_access` is caller-keyed and admits a writer member + under the owner's namespace (upload, ops; adoption and finalization re-check); `MembershipAuthority` + serves either role and answers `Revoked` → `403 error.blob.access_revoked`; + `GET /v1/sync?album_id=` pages the owner's sequence filtered to the album for its members, with + the cursor bound to `(caller, album)`. Not here: the federation capability path (#406, which + stacks on `MembershipStore`, `CursorScope`, `album_feed_page` and `BlobReadAccess`); rosters + published by non-owner admins; bytes shared across unrelated owners, which `find_reference` still + decides from the first live row (#462). - **Done when:** a member of a shared album reads its blobs through `/v1/blob/{hash}` and writes to it through the upload path; a former member receives `403` on the first and a write refusal on the second; and a non-member remains unable to tell either from an address that does not - exist. **Tier:** Unit + Integration. + exist — **met**: `a_member_of_either_role_reads_the_owners_blobs`, + `a_writer_member_uploads_into_the_owners_album_and_pays_for_it`, + `a_former_member_is_told_access_was_revoked`, + `a_reader_a_former_member_and_a_stranger_get_the_one_album_refusal`, + `a_never_member_is_indistinguishable_from_an_unknown_address_body_and_headers`. + **Tier:** Unit + Integration. ### S-D1 — SDK upload client @@ -3888,11 +4171,20 @@ them was incidental: - **Done when:** the upload doc's client-side Validation bullets pass against a real server; the recovery matrix has a mocked-HTTP test per code; E2E case 2 lives. - **Tier:** Unit + Smoke + E2E case 9. -- **Landed in retired code:** `capsule-sdk/src/upload.rs` is a complete resumable client - today (`create_session`/`upload`/`upload_resuming`/`head`/`list_sessions`) and - `capsule push` drives it end to end. **Re-scoped:** re-point at the Kynos upload - surface and re-source the schema. The stateful algorithm, the bounds, and the recovery - matrix carry over unchanged — they are protocol, not framework. +- **Landed.** `capsule-sdk/src/upload.rs` is a complete resumable client + (`create_session`/`upload`/`upload_resuming`/`head`/`list_sessions`) and `capsule push` + drives it end to end. +- **`MIXED | done`, not `RETIRED | ready` (corrected 2026-09-01).** The row was `RETIRED` + because the SDK's wire contract was re-sourced, not because the crate was review material + (Sequencing). That re-source landed: `capsule-sdk/build.rs` generates from + `capsule-server/openapi.json`, the Kynos document, and the crate's manifest declares no + retired dependency — `prost` still resolves transitively through `capsule-core`'s `tzf-rs`, + which is a timezone table and not a wire format. The client half therefore ships, which is + what `Status` reports on a `MIXED` row; the server half it drives is what is still being + rebuilt (#401, #404). +- **`done*`, not `done` (decision 12).** The row's own "Done when" ends "E2E case 2 lives", + and that case has a named home: `S-Q1` (#409). A remainder a reader can go and read is an + `Owed →` pointer, not something owed by construction. ### S-D2 — SDK sync/download client @@ -3906,10 +4198,16 @@ them was incidental: - **Depends on:** S-C2, S-C9. **Blocks:** S-D5, S-D6, S-E3. - **Done when:** the download-sync doc's client Validation bullets pass; E2E case 3 lives. **Tier:** Unit + Smoke. -- **Landed in retired code:** `SyncConsumer::pull_into` ships against the gRPC feed. - **Re-scoped:** this is the SDK's **gRPC-half re-fronting on REST** — the largest single - piece of SDK rebuild work, and the reason the crate is replacement-in-progress rather - than done. +- **Landed, including the re-front (corrected 2026-09-01).** `SyncConsumer` drove + `capsule.sync.v1.SyncService` over tonic when this row was written, and that was called + "the largest single piece of SDK rebuild work". It landed with `S-C60`/`S-D28`: + `capsule-sdk/src/sync.rs` drives `GET /v1/sync` through the generated REST client, the + opaque server-MAC'd cursor round-trips verbatim, and `tonic`, `tonic-prost` and `prost` + are out of `capsule-sdk/Cargo.toml`. `SyncState`'s anti-rewind and forward-version rules + never depended on the transport and did not move. The client half ships; the feed it reads + is served by the server still being rebuilt. +- **`done*`, not `done` (decision 12).** "Done when" ends "E2E case 3 lives", which `S-Q1` + (#409) carries. ### S-D3 — Web guest drop client @@ -3976,9 +4274,15 @@ them was incidental: - **Done when:** login/refresh/expiry flows round-trip against a dev server; a mocked clock exercises pre-flight refresh + single-flight; `capsule-sdk` stays in every Rust gate. **Tier:** Unit + Smoke. **Blocks:** S-D9, S-D11; S-D5 consumes it. -- **Landed in retired code:** the store, the refresh engine, and the session persistence - ship. **Re-scoped:** re-point at the Kynos auth endpoints. The 401-retry-once half is - still owed on the *typed* path — see `S-D17`. +- **Landed.** The store, the refresh engine, and the session persistence ship, hand-rolled + over `reqwest` (rustls only) against `/v1/auth/{register,login,refresh,logout}` — the + server's own paths, not a retired copy of them. Being outside the generated client is + deliberate and no longer a spargen gap: what lives here is token *orchestration*, which + `ADR-0002` puts outside generated code by contract. +- **`MIXED | done*`, not `RETIRED | ready` (corrected 2026-09-01).** The Kynos re-point is + what the `RETIRED` marking was for, and it landed. The 401-retry-once half is still owed on + the *typed* path, and `S-D17` (#408) carries it — which is what makes this `done*` rather + than `done`, on the same rule `S-D8` was already read by (decision 12). ### S-D8 — spargen REST client integration @@ -3992,10 +4296,13 @@ them was incidental: - **Done when:** the generated client drives the plain request/response surfaces and `AuthenticatedClient` is live over it (it is: see `capsule-sdk/README.md`). - **Tier:** Unit + Smoke. -- **Landed in retired code:** generated from the Salvo server's committed `openapi.json`. - **Re-scoped:** the schema must come from **Kynos**, not from the Salvo `gen_openapi` - binary — that is the second of the SDK's two owed items. -- **Owed:** 401-retry-once → `S-D17`. +- **Landed, from the Kynos document (corrected 2026-09-01).** It generated from the Salvo + server's committed `openapi.json` when this row was written, and the re-source was the + second of the SDK's two owed items. `S-C59` deleted `capsule-sdk/openapi.json` and + `capsule-sdk/build.rs` now reads `../capsule-server/openapi.json` — the one document + `mise run openapi-check-kynos` gates, at OpenAPI **3.2**. There is no second copy and no + window in which the client is generated from a document the server does not serve. +- **Owed:** 401-retry-once → `S-D17`, which is why this is `done*`. ### S-D28 — the SDK's second transport, and the document it generates from @@ -4099,9 +4406,11 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Depends on:** S-D1, S-D2. **Done when:** the networking doc's four Validation bullets pass (mocked-signal class matrix; promotion/demotion; stall-cut-resume with zero duplicate bytes; backoff discipline). **Tier:** Unit + Smoke. -- **Landed in retired code:** the engine ships in `capsule-sdk::net`. **Re-scoped:** it - is transport-shaped, so re-instantiate it over the Kynos fetch/upload/sync paths; the - policy classes and the mocked-signal matrix carry over unchanged. +- **Landed.** The engine ships in `capsule-sdk::net` and the fetch, upload and sync paths + instantiate it. It is transport-shaped, and the transport it was re-scoped onto is the one + in the tree: `capsule-sdk` speaks Kynos REST and nothing else. `MIXED | done` — the policy + classes and the mocked-signal matrix are client-side and proven; what an adverse network + is measured against is a server still being rebuilt. ### S-D11 — Client cohort emission + devices grouping UI @@ -4127,8 +4436,29 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Depends on:** S-C12. **Done when:** the backup doc's cadence Validation bullets pass (mocked clock; stale-cache rule; re-wrap smoke with unchanged blob hashes). - **Tier:** Unit + Smoke. -- **Landed:** the cadence scheduler, the verifier, and the re-wrap are `ACTIVE` core; only +- **Landed:** the verifier is `ACTIVE` core (`capsule-core::backup::verify_recovery_secret`, + reached through `capsule-core::lifecycle::backup`); the cadence scheduler and the guided + re-wrap are in `capsule-sdk::recovery`, whose `cadence` half is deliberately pure and + network-free. **Correction 2026-09-01:** this note used to place all three in core. Only the escrow store/replace calls re-scope. +- **Gap** (found 2026-09-01, issue #408): **the networked half never worked against a real + server.** `capsule-sdk/src/recovery/mod.rs` built `{api_root}/backup/escrow` from a `const` + — the Salvo document's path — and sent it with hand-written `reqwest` calls, while the Kynos + contract serves `GET`/`PUT /v1/auth/escrow`. So enroll, the stale-cache refresh and the + guided re-wrap's escrow replace all failed on a live server while this row read `done`. It + survived `S-D28`'s re-source because a route in a string constant is checked by no gate and + the module's own mock answered whichever path it was handed. +- **Closed:** the two operations are `application/octet-stream` in each direction, which + `spargen` lowers, so both were already generated and neither was narrowed in `build.rs`. + `RecoveryClient` now holds an `AuthenticatedClient` and orchestrates + `fetch_escrow`/`store_escrow`, so the path is a function of the committed document and + cannot drift again. Both in-repo mocks route on `/v1/auth/escrow` and answer `501` off it, + and `capsule-server/tests/sdk_client.rs` asserts the route against the real router from both + ends — what the SDK stored is read back at `/v1/auth/escrow`, and a rotation seeded there is + what the SDK fetches next. +- **Owed:** `store_escrow` logs `stored_at`/`replaced` instead of returning them, so the + stale-cache rule still refreshes on a failed compare rather than on a known-stale timestamp + and `guided_rewrap` cannot tell a first enrollment from a rotation → issue #442. ### S-D13 — Culling workflow client UX @@ -4196,8 +4526,31 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Done when:** a mocked-clock race test passes (expired-at-server, valid-at-client → one refresh, one retry, no loop). **Tier:** Unit. - **Note:** the layer sits above the generated client, so write it once and it survives - the schema re-source; it is `RETIRED` only because the client under it is regenerated - from Kynos. + the schema re-source. That is why the row is `MIXED` rather than `RETIRED`: the client + underneath is regenerated from Kynos, but the layer itself is live code in this workspace + and nothing about it re-scopes. +- **Landed** (2026-09-01, issue #408): `capsule_sdk::client::RefreshOn401`, an + `rest::HttpBackend` wrapping `ReqwestBackend`, installed by `AuthenticatedClient` through + `Client::with_backend`. On a `401` it refreshes once through a new `pub(crate) + Session::refresh_rejected` — `ensure_refreshed(Rejected(stale))`, so the existing + single-flight gate coalesces on the exact token the server refused — and replays the request + once. No generated code is touched and every generated operation is covered at once. + - **Not spargen's `Middleware`:** `Next::run` takes `self` by value and `Next` is neither + `Clone` nor constructible outside the generated runtime, so a middleware cannot send twice. + `RetryBackend` is the precedent followed instead, including its rule that a request whose + `try_clone()` is `None` (a one-shot streaming body) is executed once and never replayed. + - **Exactly once by construction**, not by a loop counter. A refresh that itself fails + surfaces the *server's* `401` rather than a synthesized transport error, so the typed + `Status401` mapping still fires and the caller reads the `error.*` code — which matters + because an unreadable revocation ledger is also rendered as `401`. + - **Proven** by four unit cases in `capsule-sdk/src/client.rs` and, over a socket against + the real router, by `a_token_the_server_stopped_honouring_is_refreshed_and_the_call_replayed` + in `capsule-server/tests/sdk_client.rs`: the server validates `exp` against its injected + clock, so advancing the fixture past `ACCESS_TOKEN_TTL` revokes the access token for real + while the refresh token lives. + - **`capsule_sdk::sync` keeps its own per-call `401` loop**, deliberately: it builds its own + `rest::Client`, supports a static-token mode with no session to refresh, and interleaves + the `401` path with the shared retry engine's transient class. ### S-D18 — `capsule push` @@ -4351,6 +4704,13 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr historical version opens, migrates, and answers every projection. - **Done when:** a v1-created fixture library opens at the current version with every column present and the gated/default projections correct. **Tier:** Unit. +- **Owed item, closed (2026-09-02, alongside `S-D24`):** the migrator's `CatalogTooNew` used to + be flattened into a `SqliteFailure` message by `DatabaseDriver::open` and re-wrapped as + `LibraryError::Db`, then stringified into `LifecycleError::Io` by `Workspace::open`. It is now + typed end to end: `open_library` goes through a crate-private `DatabaseDriver::open_typed` and + returns `LibraryError::CatalogTooNew { found, supported }` with the catalog untouched and the + lock released; `Workspace::open` surfaces it as `LifecycleError::Library(..)`. The public + `DatabaseDriver::open` (consumed by `capsule-core-ffi`) keeps flattening. ### S-D24 — Migrate the unsigned sidecars, then delete the reader @@ -4364,10 +4724,29 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr and stack hints into signed bytes. They converge by **retirement**, not by merging. - **Deliverable:** a one-time migration that rewrites each unsigned sidecar as a `SidecarV1`, after which the compatibility reader and the `AssetSidecar` type are deleted outright. -- **Blocked on a decision, not on code.** An unsigned asset has no provenance chain and no AMK, so - someone must decide what a synthesized `create` manifest is permitted to attest — and whether - such an asset is admitted to a signed library at all or quarantined. Until that is answered the - compatibility read is the honest state, which is why `S-D21` left it in place. +- **The decision (taken 2026-09-02, recorded in the landing PR):** an unsigned asset is + **admitted**, not quarantined-and-stranded. `Workspace::migrate_unsigned_sidecars` — an explicit + verb, never automatic at open and never inside the keyless rebuild — authors a signed `create` + for each legacy record through the same commit every import takes, attesting only what any + import attests: the content hash of the bytes on disk (checked against the legacy `hash_sha256` + first; a mismatch or a missing original is refused, never re-signed), this device, this album, + and now. The legacy id and media bucket are kept, so nothing moves; the legacy bytes are copied + verbatim to `.library/quarantine/{uuid}.cbor` with a `.reason.json` before any signed write; the + whole legacy map rides in the signed sidecar's `_unknown` under `legacy-unsigned-sidecar`, where + the signature covers it (a tripwire test fails if the fold drops a key). Rating, tags, GPS and + capture time are carried into their signed homes; `is_deleted` becomes a signed `delete`; + `stack_hint` groups of two or more get a deterministic RFC 9562 v8 (custom) stack id — + `SHA-256(domain ‖ user_id ‖ "{method}:{key}")`, the construction the default album id uses — + written at create. The verb ends by calling `rebuild_index`. Idempotent and resumable (an + interrupted run's chainless signed sidecar is redone from its quarantine copy). +- **Landed:** `sidecar::{AssetSidecar, StackHint, read_sidecar}` and the FFI codec + (`serialize_sidecar` / `deserialize_sidecar` and their Swift wrapper) are deleted; a + crate-private shape probe (`sidecar::shape`) replaces the fallback in `rebuild_index`, which now + reads one shape and reports an unsigned file instead of indexing it; `Workspace::open` still + succeeds on an un-migrated library and lists the files through `unmigrated_sidecars()`. +- **Still owed:** the CLI verb (`capsule library migrate`) that drives the core verb — filed as a + follow-up so it does not collide with `S-B17`'s CLI edits — and, once no unsigned library + remains, retiring `lifecycle::migrate_unsigned` and the probe's `LegacyUnsigned` arm. - **Done when:** no `AssetSidecar` remains on disk or in the tree, and rebuild has one shape to read. **Tier:** Unit + Integration. @@ -4407,6 +4786,11 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr (`export()` is async), so the shape is run-body-then-persist-then-`?`. - **Done when:** a command that triggers a refresh leaves the rotated pair on disk, and a subsequent command succeeds without re-login. **Tier:** Unit (mock auth server) + Integration. +- **Landed.** `capsule-cli/src/remote.rs` resumes through `resume_session` and writes back + through `checkpoint`, which runs on the way out of every command that used a session — on + the success path and the error path alike, because a refresh that landed before a later + failure still rotated the token. A failed write-back warns rather than failing the command: + the work is done, and the cost is one interactive login. ### S-D27 — the SDK test mock never shuts its listener down @@ -4466,7 +4850,21 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr bullets pass; E2E case 4 lives. **Tier:** Unit + Smoke + E2E case 4. - **Landed in retired code:** capabilities, budgets, and revocation state ship on the Salvo server. **Re-scoped onto Kynos.** -- **Owed:** capability gate on the live method → `S-E5`. +- **Status note (2026-09-09).** The **serving half** ships on Kynos. `capsule-server::federation` + mints an EdDSA-JWT capability under the server's own operational key (the one `server-info` + publishes), records it, refreshes it idempotently on `(peer, jti)`, and revokes it — and the + store **is** the revocation list, so `/.well-known/capsule/revoked-jti` and "is this `jti` + revoked" have one answer. The pull is the existing reads: `GET /v1/sync?album_id=` and + `GET /v1/blob/{hash}` take the capability on the same `bearer` component a session token + rides (`S-E5`). Scope is enforced against the blob's server-visible role, the per-peer + events-per-hour budget rides `CounterStore`, and both stores have in-memory and Postgres + adapters passing one conformance suite (migration ordinal 6). `capsule-sdk::federation` + orchestrates a pull over generated calls only. +- **Owed:** the **receiving** half — the egress worker that fetches on a schedule, invariant-20 + re-validation of what it pulls, per-`(receiving_user, source_peer)` quota, the breadcrumb + index and the soft-fail rejected-hash table — plus bytes/hour and CPU/hour budgets, the error + budget, the circuit breaker and the probation tier, which need a weighted counter this port + does not have. Filed as #476. `error.federation.circuit_open` stays unused until it lands. ### S-E3 — LAN peering @@ -4520,6 +4918,17 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr - **Note:** the verifier itself (`federation::pull::authorize`) is `ACTIVE` core and does not change — this slice is purely about giving it a production caller on the new transport. +- **Status note (2026-09-09): done.** The verifier was rebuilt on Kynos rather than called + from the retired tree, because the retired one has no store behind it. `federation::scheme` + registers a second security scheme under the **same** `bearer` component name and description + as the session scheme — one entry in the document, one credential key in the generated SDK — + and hands the handler a `Principal::{Session, Peer}`. The authenticator asks the session + module first and only then the capability codec, so every existing bearer path is byte-for-byte + what it was; a capability that verifies must also be one this server **recorded**. Coded + refusals are the route's, from the admitted credential: revoked, wrong album, insufficient + scope, blocked peer, over budget. Peer identity is grounded in `federation_peers`, closing + `S-C8`'s note. E2E case 4's server half runs in `capsule-server/tests/federation.rs`, and its + SDK-over-a-socket half in `capsule-server/tests/sdk_client.rs`. ## Lane F — platform / FFI @@ -4778,6 +5187,10 @@ lands on Kynos rather than on Salvo. precondition rather than a parallel cleanup. - **Done when:** `i18n-guard` covers `capsule-cli` and passes, and no import-arm output is a literal. **Tier:** gate. +- **Landed** in `84f719f6`. `locales/en.json` carries fifteen `cli.import.*` keys, and + `xtask/src/i18n_guard.rs` scans `capsule-cli/src` as a fourth surface with its own + Rust-literal detector. The guard half is the durable part: without it the next command is + free to regress. ### S-I6 — Android ships raw ICU to users, and the guard that should stop it never fires @@ -4853,9 +5266,38 @@ lands on Kynos rather than on Salvo. the Android guard whose comment claimed it skipped what it could not translate. Retargeted rather than deleted, with a companion test pinning that release builds still pass through and that the pass-through reproduces the input exactly. -- **Owed:** actually evaluating plurals, which needs CLDR rules in the runtime. That is the cost the - per-platform renderers avoid by compiling ahead of time, and it is why this runtime was the one - left behind. +- **Second half landed (#414).** `capsule_i18n::plural` carries CLDR integer cardinal rules for the + twelve language subtags in `locales/config.json`, and the formatter evaluates `{name, plural, …}` + with `=N` arms, category arms, `#`, and nesting. `Bundle::format` was dropping `self.locale` + before calling the formatter — that was the API gap, and `format_message_in(locale, …)` closes it + while `format_message` keeps its signature and means English. The refusal is **narrowed, not + removed**: `select`, `selectordinal`, `offset:`, a plural whose arms do not parse, and nesting + past 32 levels keep the assertion and the pass-through. A plural whose arms *do* parse but carry + no `other` is the one malformed shape that is **not** passed through: it asserts and renders its + first arm in CLDR order. `xtask i18n` (`xtask/src/i18n.rs:586`) refuses to emit such a message, so + the case is unreachable from the catalogs, and for a hand-written template that slipped past it, + rendering text beats showing a user ICU source. +- **An in-house table, not a crate.** `icu_plurals` would be a genuine new dependency — provider, + data crate, and a `dependencies.md` row — on a crate whose entire dependency list is `serde_json` + and `tracing`, to decide thirteen locales' integer cardinal categories. Licence was not the + discriminator (`Unicode-3.0` is allowlisted); volume was. +- **The table `xtask` already had was the second copy.** `xtask/src/i18n.rs` held its own + selectable-category list, which the moment the runtime gained rules had to agree with them. + Merged: the generator reads `capsule_i18n::plural::selectable`, and a test pins both directions of + every row. Russian is the row worth knowing — it never selects `other` for an *integer*, since its + `other` is for fractions, yet every plural resource must still carry that arm. +- **What the fallback is actually doing.** Every translated plural in `locales/` is still an English + `one`/`other` copy, even in `ar` and `ru`. Falling an absent category back to `other` is therefore + not a nicety — without it, `few` in Russian would render nothing. +- **Three defects found reviewing the change before it landed**, none of them plural-specific: a + stray `{` abandoned the rest of the template, so one unbalanced brace silently blanked every later + argument; recursion was bounded only by the input, so a public formatter could abort the process + on a deeply nested template; and a string count was trimmed for selection but not for `#`, so + `" 1 "` could pick one arm and print another. +- **Owed-CI:** `cargo test --release` is not run anywhere. The three tests that pin *production* + behaviour — a refused construct passes through instead of crashing, a plural with no `other` + renders its first arm, a too-deeply-nested plural passes through — are + `cfg(not(debug_assertions))` and therefore never execute in CI. Filed as #428. ### S-I8 — clap `--help` text is unreachable from the catalogs @@ -4872,6 +5314,21 @@ lands on Kynos rather than on Salvo. contract stops overstating itself. - **Done when:** the design doc states the decision either way. **Tier:** docs or Unit. +- **Landed 2026-09-02 — help is localized.** `capsule_cli::cli::help::localize` walks the built + `clap::Command` tree and replaces every `about`/`long_about`/`help`/`long_help` from keys derived + from the tree — `cli.help..about`, `cli.help..arg.`, `…long_about`/`…long_help` + when the derive gives a distinct long form — through `Bundle::message`, so a missing key leaves + the derive text in place and a partial translation renders a mix, never a raw key. `run()` + applies it under the negotiated bundle; `command_tree()` (`S-Z8`) applies it under an explicitly + pinned `en` bundle, so `cli-surface.json` is locale-proof and byte-unchanged. The gate this + surface needed, since `i18n-guard` cannot see help text: a unit test that every `en` entry equals + the derive text it replaces and that localizing under `en` leaves every rendered page + byte-identical — a doc comment edited without its key fails `cargo test -p capsule-cli`. The + design doc records the decision and the residual: a `ValueEnum` variant's help (`--filter pick`) + stays English, because clap 4 re-words a possible value only by discarding the typed parser. + The twelve non-source locales carry no `cli.help.*` entries yet; translators fill them through + the documented `locales/` flow. + ### S-N1 — OIDC relying party (server) - **Contract:** [Authentication — Design Principles + Choosing an Auth Path](capsule-docs/src/content/docs/design/authentication.md). @@ -4887,6 +5344,22 @@ lands on Kynos rather than on Salvo. green. **Tier:** Unit + Smoke. **Blocks:** S-N2. - **Rebuild note:** unstarted, so there is nothing to re-scope — write it against Kynos directly rather than adding routes to a server that is being replaced. +- **Landed (issue #407):** `capsule-server::auth::oidc` — a pure ID-token validator + (`claims`), discovery with the issuer mix-up defence, a JWKS cache refetched on an + unknown `kid` and floored at one fetch a minute, the `IdentityProvider` port with its + HTTP adapter and a `Disabled` null object, `FederatedAccounts` keyed on + `(issuer, subject)` with no linking by address, and a typed `OidcAuthorizationStore` + ceremony port (single-use `state`, ten-minute TTL). `POST /v1/auth/oidc/authorize` and + `POST /v1/auth/oidc/callback` mount inside the protocol gate and mint sessions through + the password path's `open_session_for`, second factor included; `server-info` publishes + `auth.oidc` or `null`. **Two deviations, recorded:** the testcontainer IdP is an + in-process mock provider on loopback, because `test-rust` runs offline (dex in + `capsule-server/compose.yaml`, `--profile oidc`, is the manual run); and the Valkey + ceremony-store and Postgres federated-account adapters are owed (#460), so + `OIDC_ISSUER` under the durable backends is refused by name and the development + profile's federated accounts hold their own rows. Hand-written over `jsonwebtoken` + rather than `openidconnect` — see the OIDC row in + [Dependencies](capsule-docs/src/content/docs/design/dependencies.md). ### S-N2 — SDK/CLI OIDC login flows @@ -4898,6 +5371,12 @@ lands on Kynos rather than on Salvo. `cohort_hash` rides the ceremony. **Depends on:** S-N1 (**live block**). - **Done when:** `capsule auth login --oidc` round-trips against the dev IdP; mocked-HTTP tests per flow. **Tier:** Unit + Smoke. +- **Part landed (issue #407):** `capsule_sdk::auth::AuthClient::begin_oidc_login` / + `complete_oidc_login` — the two server legs, answering the same `LoginOutcome` a + password login does, with the cohort riding the completing request and the + `error.auth.oidc_*` refusals typed on `AuthError`. **Remainder (#461):** the CLI's + loopback listener and `--oidc` arm, the browser-open policy the docs do not carry, and + the device authorization grant (RFC 8628) with its own ceremony store. ### S-N3 — `device_id` on session listing + ceremony cohorts @@ -5068,11 +5547,19 @@ is why Lane Q shipped with no `Contract:` line at all), and each case's wording normative statement of what the slice must prove. Cases are numbered so code can name the case it covers (`rg "E2E case N"`). -Current registry state: live = 1, 4 (upgraded by `S-E5`), 9, 10 (`S-Q6`); in-process shape -= 5, 8 (server half = `S-C24`), 13; this lane closes the rest. Every case with a server or -SDK leg is **suspended for the duration of the Kynos rebuild** — the module map says so — -so these slices are written to be re-runnable against the replacement rather than pinned to -the current transport. +Current registry state (the module map's status table is the record): landed = 1, 2, 3, 6, +7, 9, 10 (`S-Q6`) and the server legs of 8, 12, 13, all in the `capsule-e2e` crate against the +real composition root over the real SDK and a real library — no container, no env gate; 11 +lands with #447; in-process shape = 5 and the ceremony half of 8; 4 has no route (federation +is post-v1, #406). The earlier note that every server-side case was suspended for the Kynos +rebuild is stale: the rebuilt server is what these cases run against. What still blocks a +case's full wording is a seam in the tree rather than a transport, each filed: the SDK's +directory publish omits the identity-key header (#466), a `Workspace` cannot open as a server +account (#467), the backup artifact carries no album authority (#468), a library's adopt +registers nothing to publish (#469), and there is no cross-sign or safety-code seam (#471). +Two seams closed on the way in and no longer bound a case: the push ladder now ships the +provenance rung and `sync_apply` decodes the record bytes the feed serves (#464, #465), and +the upload policy accepts `image/jxl` (#470). ### S-Q1 — Mark/complete E2E cases 2, 3, 11 @@ -5082,6 +5569,14 @@ the current transport. round trip ≈ case 3, `S-C1` crash-injection ≈ case 11) with explicit `E2E case N` markers, fill whatever the audit finds missing to each case's Module-Map wording. - **Done when:** `rg "E2E case (2|3|11)"` hits a passing named test each. **Tier:** Smoke. +- **Landed** (2026-09-05, #409): case 2 is `capsule-e2e/tests/case_02_import_upload_finalize.rs` + (a real library import → the SDK ladder, index tier through original → every blob byte-equal + at its content address → storage-verify durable → on the feed); case 3's client half is + `capsule-e2e/tests/case_03_sync_pickup.rs` beside the server half in + `capsule-server/tests/sync.rs`; case 11 is `#447`'s named test in + `capsule-server/tests/upload.rs` (an in-memory fault decorator on the index, not + crash-injection). Case 2 imports a 512×512 still, so its T1 is a real JXL thumbnail upload + rather than the byte-free sentinel an 8×8 still gets. ### S-Q2 — E2E case 6: backup → fresh-device restore @@ -5090,6 +5585,12 @@ the current transport. - **Deliverable:** the full chain: backup artifact + server escrow fetch → restore on a fresh workspace (new process, no prior state) → assets decrypt + verify. - **Done when:** the named test passes against testcontainers. **Tier:** Smoke. +- **Landed** (2026-09-05, #409): `capsule-e2e/tests/case_06_backup_restore.rs` — escrow + through the SDK's `RecoveryClient` over the real route, the recovered master key proved to + be the account's by deriving its default album id, the backup restored into a fresh library + that reads the asset byte for byte and walks its chain. No container: the composition root + runs in-process. **Owed:** opening the fresh library *as* the recovered account → #467; + `verify` on the restored asset (the artifact carries no album authority) → #468. ### S-Q3 — E2E case 7: full lifecycle chain @@ -5099,6 +5600,13 @@ the current transport. client + server (composing `S-C16`'s op path with `S-C11`'s GC), asserting feed order and byte deletion honoring grace. - **Done when:** the named test passes. **Tier:** Smoke. +- **Landed** (2026-09-05, #409): `capsule-e2e/tests/case_07_lifecycle.rs` — caption, trash + (30-day floor), restore, re-delete and a zero-day trash, each authored by the real library + and posted as a lifecycle op through the generated client, watched by an incremental feed + reader; `gc::purge_expired` on the operator worker retains the 30-day tombstone and purges + the due one, whose original then serves `Gone`. The chain agrees end to end because the SDK's + own ladder ships the provenance rung the server chains onto — the harness supplies no rung of + its own. ### S-Q4 — E2E case 12: cross-device enrollment @@ -5107,6 +5615,11 @@ the current transport. - **Deliverable:** the server + CLI halves of the cross-device add (code issue/redeem, relay channel, directory update, second device syncs) — the iOS UI half is post-v1. - **Done when:** the named two-client test passes against testcontainers. **Tier:** Smoke. +- **Landed, server leg** (2026-09-05, #409): `capsule-e2e/tests/case_12_enrollment.rs` — + fresh local auth, code issue, redeem, relay and drain in both directions (each payload + delivered once, mailboxes never cross), initiator close, and the MITM abort at the wire. + **Owed:** the client ceremony — B's keys, the safety code, A's cross-sign → #471 and #467; + the MLS join → `S-C51` (#405). ### S-Q5 — Live-browser smokes @@ -5631,31 +6144,52 @@ and all three slices are `done` in `capsule-core`. ### S-Z8 — Reference shell + CLI reference - **Gap:** `/reference/` has an index and nothing under it, and `capsule-cli/README.md` - still defers entirely to `capsule --help`. No `clap_mangen` or `clap_complete` exists - anywhere in the workspace, so there is no man page and no shell completion either. + still defers entirely to `capsule --help`. - **Deliverable:** the reference shell (overview page per section) plus the first real - generated surface. `capsule-cli` gains a command-tree dump with a `--check` mode and - `clap_mangen`/`clap_complete` output; the docs build renders the committed dump into - `/reference/cli/`. The CI `docs` path filter widens to name every artifact the docs - build now reads — without that, a CLI change publishes a stale page without failing - anything. + generated surface. `capsule-cli` gains a command-tree dump with a `--check` mode; the + docs build renders the committed dump into `/reference/cli/`. The CI `docs` path filter + widens to name every artifact the docs build now reads — without that, a CLI change + publishes a stale page without failing anything. +- **Scoped out: man pages and shell completions.** The slice originally named + `clap_mangen` and `clap_complete`. Neither crate appears anywhere in `Cargo.lock`, so + both would owe a row in `design/dependencies.md`, and neither produces the description + artifact the docs build reads — they are *install* artifacts, emitted for a packager, + not a description of the surface. They are a packaging slice, not this one. - **Done when:** `/reference/cli/` renders the full command tree from the committed dump, the `--check` mode fails on a hand-edited dump, and the `docs` path filter names the dump. **Tier:** docs build + the new drift gate. **Depends on:** S-Z7. +- **Landed — verified 2026-09-02.** `capsule_cli::cli::command_tree()` emits the tree, + `capsule-cli/src/bin/gen_cli_surface.rs` writes and `--check`s + `capsule-cli/cli-surface.json`, and `cli-surface-check` runs in `check-rust` beside + `openapi-check-kynos`. `capsule-docs/scripts/gen-reference.mjs` renders it into + `/reference/cli/commands/` as gitignored build output, with the hand-written overview + at `/reference/cli/`. The `docs` filter names both committed artifacts. ### S-Z9 — REST reference from the Kynos document -- **Gap:** `capsule-sdk/openapi.json` is emitted by `capsule-api`'s salvo-oapi binary, and - that server is retired. `capsule-server` exposes `openapi() -> Document` but has one - route ported and no emitter binary, so there is no Kynos document to publish. Rendering - the Salvo-derived file would document a server nothing runs. +- **Gap (rewritten 2026-09-01; the slice is no longer blocked).** It read: the only document + was `capsule-sdk/openapi.json` from `capsule-api`'s salvo-oapi binary, `capsule-server` had + one route ported and no emitter binary, and rendering the Salvo-derived file would document + a server nothing runs. All three are out of date. `capsule-server/src/bin/gen_openapi.rs` + emits `capsule-server/openapi.json` — fifty-nine operations, OpenAPI 3.2, pinned by + `openapi_as(SpecVersion::V3_2)` — `mise run openapi-check-kynos` gates it inside + `check-rust`, and the Salvo copy is deleted. What remains is the docs work itself: no + `/reference/api/` pages are generated from it yet. - **Deliverable:** `/reference/api/` generated from the Kynos document by a Starlight-native OpenAPI generator — not an embedded renderer that mounts its own application, which would forfeit the search index, the link validator, and the site palette. - **Done when:** the committed contract is Kynos-emitted, `openapi-check` gates it, and `/reference/api/` renders every path in it as Starlight pages that Pagefind indexes. - **Tier:** docs build + `openapi-check`. **Depends on:** S-Z8, S-D8 (**live block** — - the schema must come from Kynos, which needs `S-C27`). + **Tier:** docs build + `openapi-check-kynos`. **Depends on:** S-Z8, S-D8 (the block cleared + when `S-C34` landed the Kynos emitter and `openapi-check-kynos`). +- **Landed — verified 2026-09-02.** All 51 paths and 59 operations of + `capsule-server/openapi.json` render across eleven group pages under `/reference/api/`, + from the ordered table in `capsule-docs/scripts/reference-groups.mjs` that + `astro.config.mjs` also builds the sidebar from. The document carries no `tags` on any + operation, so the grouping is hand-curated by path prefix and the generator **fails on + an operation no group claims** — a new endpoint family cannot publish under a heading + nobody chose, and cannot silently fail to publish. A test asserts the bucketed count + equals the declared one. ### S-Z10 — SDK / FFI / WASM reference @@ -5672,6 +6206,24 @@ and all three slices are `done` in `capsule-core`. - **Done when:** each dump has a `--check` in the Rust gate, the three binding pages render from committed dumps, and `/reference/crates/` resolves. **Tier:** docs build + the new drift gates. **Depends on:** S-Z8. +- **Still `ready`, and split out of the S-Z8/S-Z9 delivery.** Neither of its two artifacts + can be produced the way the slice assumes, and the evidence is recorded here so the next + attempt starts from it: + - **uniffi exposes no stable machine-readable dump.** `uniffi_bindgen 0.31.1` — the + pinned version — offers only `generate`, `scaffolding`, and `pipeline`, and `pipeline` + documents itself as inspecting the render pipeline. The one thing resembling a dump, + `print_repr`, prints Rust `{:#?}` `Debug` of `uniffi_meta::Metadata`, which carries no + `serde` derive and no `serde` dependency. A committed artifact today is therefore + either a `Debug` blob with no compatibility promise that churns on every uniffi bump, + or a hand-written mapper over a private IR — the second parser `AGENTS.md` forbids. + The symbol-presence assertions already in `mise-tasks/gen-bindings` enumerate the verbs + and are the honest seed: they assert, they do not emit. + - **The wasm `.d.ts` gate cannot live in `check-rust`.** That gate runs + `build-check-wasm`, two `cargo check`s; the artifact comes from `build-wasm`, which + needs `wasm-bindgen-cli`, installed only by the `web` CI job. Gating it is a + `check-web` change and a web-side artifact, not a fourth entry in the Rust gate. + Filed as its own issue with this evidence. The extension point is the group table in + `capsule-docs/scripts/reference-groups.mjs`: adding a surface is a group plus a renderer. ## Deferred Migrations Register @@ -5682,7 +6234,7 @@ table hides what it would cost. | Migration | Status | Measured cost today | Unblocks when | | --- | --- | --- | --- | -| `salvo` → [`kynos`](https://github.com/getkono/kynos) | **started; the precondition has landed** | The measurement that scoped this row was 648 `salvo` occurrences across 84 files, including 51 `impl Writer` and 41 `EndpointOutRegister` blocks. `S-C27` part 1 has since deleted the mechanical half: **315 occurrences across 86 files, 12 `impl Writer`, 2 `EndpointOutRegister`**, with 40 call sites now expanding from one `salvo_responses!` table each, and `auth/src/models/responses.rs` down from 1440 to 1019 lines. What remains is the part that was never boilerplate: 63 `#[handler]`/`#[endpoint]` route fns, 68 `ToSchema` derives and 68 `Depot` reads. The `ToSchema` derives are exactly why **part 2 is owed to the port rather than to another refactor** — a framework-neutral crate cannot carry that derive (optional deps count against the boundary check) and an adapter cannot implement a foreign trait for a foreign type, so the DTO structs move when the framework does. `architecture-check` reports **63 boundary violations**, which is the rebuild worklist. | Kynos is **published at 0.1.0 and consumed from crates.io**; the git-rev pin this row used to require is retired. `capsule-server` exists with a conformance suite, so the port is incremental from here rather than a cutover. | +| `salvo` → [`kynos`](https://github.com/getkono/kynos) | **started; the precondition has landed** | The measurement that scoped this row was 648 `salvo` occurrences across 84 files, including 51 `impl Writer` and 41 `EndpointOutRegister` blocks. `S-C27` part 1 has since deleted the mechanical half: **315 occurrences across 86 files, 12 `impl Writer`, 2 `EndpointOutRegister`**, with 40 call sites now expanding from one `salvo_responses!` table each, and `auth/src/models/responses.rs` down from 1440 to 1019 lines. What remains is the part that was never boilerplate: 63 `#[handler]`/`#[endpoint]` route fns, 68 `ToSchema` derives and 68 `Depot` reads. The `ToSchema` derives are exactly why **part 2 is owed to the port rather than to another refactor** — a framework-neutral crate cannot carry that derive (optional deps count against the boundary check) and an adapter cannot implement a foreign trait for a foreign type, so the DTO structs move when the framework does. `architecture-check` reported **63 boundary violations** while the Salvo tree was still in the workspace, which was the rebuild worklist. Part 2 is now **declined rather than owed**: the `ToSchema` derives retired with the tree instead of moving, and the framework-free crate that carried the taxonomy went with them to `legacy-review/server-salvo/wire/` (`S-C27`, ADR-0004). | Kynos is **published at 0.1.0 and consumed from crates.io**; the git-rev pin this row used to require is retired. `capsule-server` exists with a conformance suite, so the port is incremental from here rather than a cutover. | | `progenitor` → [`spargen`](https://github.com/getkono/spargen) | **done** | — | Complete. Progenitor is gone from `Cargo.lock` and every manifest; `generate_openapi.sh` was deleted in `2996a13`; spargen is shipped and on crates.io. Open items: spargen's object-typed-query-param lowering (gates table), and re-sourcing the SDK's schema from Kynos rather than the Salvo `gen_openapi` binary (`S-D8`). | | Real image codecs (JXL/AVIF/WebP encode, RAW decode) | **deferred** | Nine format modules are decode/encode stubs; only JPEG and PNG are real. | `rawshift` stabilizes for RAW; the JXL/AVIF/WebP encode half is picked up separately against the thumbnails.md format table. `S-B13` makes the gap a typed `UnsupportedFormat` error and reports it at derivative time (`DerivativeStatus::DeferredNoCodec`, warned + counted per run); originals still import signed and verifiable, so the deferral cannot cause incorrect behaviour — only visibly absent thumbnails. | | Test bootstrap: hand-rolled `docker` CLI → Kynos `TestClient` + the `S-C29` conformance suite | **deferred deliberately; retires rather than migrates** | `capsule-api-testing` is a declared default-member, so its 242 lines compile on every build, and it has **zero consumers** — `rg` for the package name outside itself returns nothing. Its `common.rs` shells out to the `docker` CLI via `std::process::Command` to start Postgres, which is a second container-bootstrap approach competing with the testcontainers six other sites hand-roll; its `schema.rs` is entirely `#[cfg(test)]` tests of sea-orm entity CRUD, and those three tests do run and pass in the workspace suite. | Nothing. This is recorded so it is not re-litigated as slimming: reviving it means teaching six call sites in the retiring Salvo tree to share a fixture, which is thrown away at Stage 7.5, and deleting it now removes the only live coverage of the sea-orm migration path while that path is still in use. It retires **with** `capsule-api`. The replacement needs no container at all — Kynos's `TestClient` drives a built `Service` in-process, and `S-C29`'s shared conformance suite is what lets the in-memory adapter stand in for Valkey. | @@ -5735,6 +6287,28 @@ table hides what it would cost. to a badge, and a refused authorization degrading to a badge without error; a per-platform smoke fires a pre-armed alert with the app terminated; `i18n-guard` passes with the new namespace consumed. **Tier:** unit + smoke. +- **Core half landed — verified 2026-09-01.** `capsule-core::notify` is the whole shared + decision function: `evaluate(&NotifyInput, now)` reports the classes true at an instant, and + `pre_arm_deadlines(&NotifyInput, now)` reports the instant to arm **per class** — keyed per + class because the two pre-armable timers are independent, and collapsing them to one loses the + later alert on a device the app never runs on again. Both are pure with `now` as an argument. + `capsule-sdk::ffi` exports them as free functions (`evaluate_alerts`, `pre_arm_deadlines`, + and `next_alert_deadline` for a single-timer host) with RFC 3339 timestamps, and + `RecoveryCadence::notify_facts` projects the S-D12 scheduler into the recovery half of the + input. The module left `planned-modules.txt`; `notifications.md` no longer calls it planned. +- **Why `ready` and not `done*`.** Every predicate input is caller-supplied, because the core + holds none of the trigger state — no persisted last-sync instant, no client-side quota type, + no quarantine table (a refused sync entry is a per-entry verdict, not a row). So the predicate + is proven and *nothing on a device evaluates it yet*: the remainder is the client half, and it + is the larger half. `next_deadline` is deliberately narrower than `evaluate` for the same + reason the pre-arm rule exists — an armed notification fires with the app not running and + cannot be re-checked on arrival, so a deadline is returned only when the alert is certain to + be true when it gets there. +- **Owed, and where.** Native scheduling and presentation + (`UNUserNotificationCenter`/`AlarmManager`), the `notification.*` catalog keys — blocked on a + live consumer by the i18n guard, which is the client half by construction — the + permission-at-first-use placement, and the per-platform terminated-app smoke. Filed as the + S-D29 client half. ## Post-v1 Register diff --git a/adr/0002-clients-are-generated-from-the-committed-contract.md b/adr/0002-clients-are-generated-from-the-committed-contract.md index 9ef997f3..2165c2f3 100644 --- a/adr/0002-clients-are-generated-from-the-committed-contract.md +++ b/adr/0002-clients-are-generated-from-the-committed-contract.md @@ -35,12 +35,18 @@ serialization versus orchestration**, and it now falls in exactly one place: lower correctly as of 0.2.2, so the byte path is generated and a hand-written one would now be a deliberate second parser rather than a workaround. - `spargen` is a **build-dependency only**. `capsule-sdk/build.rs` lowers the committed - `capsule-sdk/openapi.json` into the typed client at build time; its runtime support is + `capsule-server/openapi.json` into the typed client at build time; its runtime support is embedded into the generated module, so the generator never enters the SDK's runtime tree. - Nothing generated is committed. + Nothing generated is committed. There is one document: the Salvo-era `capsule-sdk/openapi.json` + was deleted in `S-C59`, so no client can be generated from a contract the server does not + serve. - Since 0.3.0 Spargen enforces runtime dependency contracts, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest. Those bump together or generation fails. - Progenitor is retired and listed in `xtask`'s retired-dependency set so it cannot return. -- Four Salvo-emitted operations are narrowed with `spargen::omit!` because they are - structurally invalid. That list shrinks as the Kynos surface replaces them. +- Four operations are narrowed with `spargen::omit!`. The four that carried this line were + structurally invalid Salvo output and are gone — Kynos can express neither defect, and all four + generate. The four in their place are the ones Capsule serves as `application/cbor`, a media + type spargen's `classify_media` does not know; they are signed documents served byte for byte, + which is why they are not JSON. Relabelling them `application/octet-stream` to satisfy the + generator would tell every client that a document with a schema it knows is opaque bytes. diff --git a/adr/0004-capsule-wire-is-retired.md b/adr/0004-capsule-wire-is-retired.md new file mode 100644 index 00000000..e1435426 --- /dev/null +++ b/adr/0004-capsule-wire-is-retired.md @@ -0,0 +1,68 @@ +# ADR-0004 — `capsule-wire` is retired once no member depends on it + +- **Status:** accepted +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Contract:** [API Surfaces](../capsule-docs/src/content/docs/design/api-surfaces.md) +- **Slices:** S-C27, S-C59 + +## Context + +`capsule-wire` exists because the Salvo server's response taxonomy — which outcome carries +which status, which body, which published sentence — lived only as framework trait impls, +written twice per response enum (once to render it, once to document it) with nothing +keeping the two halves in agreement. That made the transport load-bearing: the contract +could not outlive the framework. `S-C27` extracted the taxonomy as plain data +(`ResponseSpec`, `BodyShape`, `WireResponses`) plus the protocol headers, depending on +nothing but `serde`, and generated the framework impls from it. The crate's own module +comment names the goal: "the piece of the server that survives the transport swap +unchanged". + +The transport swapped, and the crate did not survive it in the way the extraction +anticipated. Three facts in the tree say so: + +- **Kynos removes the defect the crate was extracted to fix.** In Kynos the status *is* + part of the return type and there is one declaration, so the two halves cannot disagree. + `capsule-server/tests/conformance.rs` asserts both directions of that agreement against + the emitted document rather than against a hand-kept table. +- **The server owns the taxonomy now.** `capsule-server::problem`, `::limits` and `::body` + carry coded-problem bodies, body-size limits and the header census on every route — the + Server Modules table in `design/module-map.md` lists them there. +- **Nothing links it.** `capsule-server/Cargo.toml` declares `capsule-wire` as a path + dependency, and the only occurrence of `capsule_wire` anywhere in the workspace outside + the crate itself is one prose reference in `capsule-server/src/lib.rs`'s module comment. + Meanwhile `capsule-wire/src/salvo_adapter.rs` — 267 of the crate's 528 lines, over half of + it — generates impls for a framework `S-C59` removed from the workspace. + +## Decision + +`capsule-wire` is retired. The header constants and any part of the taxonomy the server +still needs move into `capsule-server`, which is where the rest of it already lives; the +Salvo adapter goes with the framework it adapts; the crate leaves `[workspace] members` +and `xtask`'s architecture check gains it as a retired dependency so it cannot return. + +The retirement lands when no workspace member depends on it, not before — a crate that is +still in a manifest is still in the build graph, whatever its call sites say. + +## Consequences + +- The protocol-header contract has one home, `capsule-server::problem` and its siblings, + rather than two with an unenforced agreement between them. +- `S-C27` closes as a design that did its job and is no longer needed, rather than as a + design that failed. Extracting the taxonomy is what made the Kynos port a port rather + than a rewrite. +- One fewer workspace member, and one fewer manifest edge that `architecture-check` has to + reason about. +- A future transport swap loses the framework-free layer this crate provided. That is + accepted: Kynos's own contract — one declaration, checked by `conformance.rs` — is a + stronger guarantee than a second crate holding a copy of the table. + +## Considered and rejected + +- **Keeping the crate and deleting only `salvo_adapter.rs`.** That leaves a crate whose + only consumer is a doc comment. A dependency nothing calls is a maintenance cost with no + reader. +- **Moving the taxonomy into `capsule-sdk` so the client owns it.** The taxonomy is a + statement about what the *server* sends. Putting it on the client side would let the two + drift in the direction that matters least. diff --git a/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md b/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md new file mode 100644 index 00000000..2efe671e --- /dev/null +++ b/adr/0005-one-uniffi-namespace-pair-reaches-the-apps.md @@ -0,0 +1,65 @@ +# ADR-0005 — One uniffi namespace pair reaches the apps + +- **Status:** accepted +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Contract:** [Module Map — Client Boundaries](../capsule-docs/src/content/docs/design/module-map.md#client-boundaries) +- **Slices:** S-F1, S-F3, S-D9, S-P1 + +## Context + +Three uniffi namespaces exist in this workspace, and the number is easy to misread as +three things an app links. + +- `capsule_core`, behind `capsule-core`'s `ffi` feature: the crypto `FfiWorkspace` and the + `HardwareSigner` foreign trait. +- `capsule_core_ffi`, in its own crate: the SQLite catalog, the CBOR sidecar, the + `GatedView`/`LocalAuthGate` seam and the single `CatalogError` that crosses the boundary. +- `capsule_sdk`, behind `capsule-sdk`'s `ffi` feature: the networked user flows (`S-D9`) + and the workspace verbs the iOS lane consumes (`S-P1`). + +`S-F1` settled that `capsule_core` and `capsule_core_ffi` are *layered* — distinct crates, +distinct bindings namespaces, one pinned uniffi version — and that they are **never linked +into the same binary**, so their generated scaffolding cannot collide. + +What an app actually links follows from a property of the toolchain rather than from a +preference: two Rust staticlibs cannot share a binary, because each bundles its own `std`. +So any namespace an app needs must ride in one library. `capsule-core-ffi` is that library +(`S-F3`): it is the app umbrella staticlib, and it carries `use capsule_sdk as _;` for the +sole purpose of forcing rustc to keep the SDK's scaffolding and metadata in the archive, +since nothing in the crate calls it — the generated Swift does, over the C ABI. +`capsule-sdk` deliberately does not enable `capsule-core/ffi`, which is what keeps the +`S-F1` never-same-binary invariant intact. + +The Apple graph shows the result: `CapsuleCatalogFFI` compiles +`.ffi/generated/capsule_core_ffi.swift` and `.ffi/generated/capsule_sdk.swift`, and no +other generated namespace. + +## Decision + +An app links exactly the **`capsule_core_ffi` + `capsule_sdk`** namespace pair, delivered +as one staticlib. `capsule_core` is not an app-facing namespace: it is the crypto surface +`capsule-core-ffi` and the harnesses use, and it never shares a binary with `capsule_sdk`. + +## Consequences + +- A new verb an app needs is added to `capsule_core_ffi` or to `capsule_sdk`. Adding a + third namespace to the app's link line is not an option the toolchain leaves open. +- `mise-tasks/gen-bindings` keeps generating `capsule_core` and `capsule_sdk` separately, + from their own compiled cdylibs, and its symbol-presence assertions keep proving that + each surface crossed. Generating them together would violate `S-F1`. +- `capsule-core-ffi`'s `use capsule_sdk as _;` is load-bearing and must not be removed as + an unused import. Without it the linker drops the `capsule_sdk` scaffolding and every + networked verb disappears from the app with no compile error. +- The third namespace is a cost this decision names rather than hides: `capsule_core`'s + crypto surface is reachable only through whatever `capsule_core_ffi` re-exposes. #399 + is where that surface is frozen and the dead parts of it removed. + +## Considered and rejected + +- **One namespace for everything.** It would put the crypto `FfiWorkspace` and the + networked flows in one uniffi surface, which is the collision `S-F1` exists to prevent + and which would make `capsule-core`'s crypto tree an app dependency. +- **Two staticlibs, one per namespace.** Not available: each bundles its own `std`, so the + app would carry two runtimes and the symbols would clash. diff --git a/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md b/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md new file mode 100644 index 00000000..7c46094e --- /dev/null +++ b/adr/0006-the-server-is-a-binary-with-config-and-three-adapters-per-port.md @@ -0,0 +1,80 @@ +# ADR-0006 — The server is one binary with one configuration loader and three adapters per port + +- **Status:** proposed +- **Date:** 2026-09-01 +- **Supersedes:** — +- **Superseded by:** — +- **Slices:** S-C29, S-C59, S-P7 + +## Context + +`capsule-server` is a complete *surface* and not yet a program. `src/bin/` holds one +binary, `gen_openapi.rs`, which builds the router purely to describe it and needs no +database, no Valkey, no key material, no disk and no network. `src/store/` holds the typed +ports and one adapter — `memory.rs`, the deterministic test double — beside the +`conformance.rs` suite every adapter must pass. Nothing anywhere reads `JWT_ED25519_DER`, +`SYNC_CURSOR_MAC_KEY`, `VALKEY_URL` or `DATABASE_URL`: those names appear only in doc +comments describing what a loader will do. `design/development/local-development.md` states +the same thing plainly, and `mise run serve-api` — the one command that used to bring the +stack up — retired with the Salvo binary it launched in `S-C59`. + +So nothing that needs a live server can run: not the CLI's networked commands, not the web +client beyond its empty states, not the iOS lane's end-to-end path, not the bounded E2E +cases. That single absence is the rebuild's critical path, and it is currently described +across a slice note, a design doc and two module comments rather than decided once. + +`S-C29` already settled the *shape* of the state layer, and settled it against the +alternative worth naming. The Salvo server kept session records, the per-user session +index, MFA counters, rate-limit counters and a generic `save_temp_data` / +`get_temp_data` behind one `SessionStorage` trait with a caller-supplied TTL — a +serialize-anything key-value store that four unrelated ceremonies rode, namespaced by +hand-formatted string keys. `AGENTS.md`'s Rust Architecture Decisions refuse exactly that +abstraction, and `S-C29` deleted it rather than porting it: separate typed ports, no +`T: Serialize` anywhere, TTL a property of the store rather than an argument, and boxed +futures so every port stays dyn-compatible and an adapter can be swapped behind +`Arc` without making the server generic over its storage. + +## Decision + +The server is **one binary**, with **one configuration loader**, over the typed ports +`S-C29` defined — `AuthStateStore`, `UploadSessionStore`, `CohortStore`, and the three +ceremony stores (`ChallengeStore`, `EnrollmentStore`, `ChannelStore`) — each with **three +adapters**: PostgreSQL, Valkey through `redis-rs`, and the in-memory double. + +Three adapters are not three deployment modes. Valkey is required and the binary refuses +to boot without it; the in-memory adapter is a test double and never a deployment profile. +Whichever adapter is in play passes the one shared suite in `capsule-server::store::conformance`, +which is what makes "the in-memory double behaves like Valkey" an assertion rather than an +assumption — and what lets the rest of the rebuild be tested without a container. + +No generic TTL or CAS abstraction is introduced to unify them. Blob storage and the +resumable encrypted upload protocol stay Capsule-owned behind narrow ports, and no +`object_store` or generic transfer crate is adopted to hold them. + +## Consequences + +- The configuration loader is the one place secrets enter the process. `JWT_ED25519_DER` + is the root of that set: `sync/cursor.rs` HKDF-derives the cursor MAC key from it when + `SYNC_CURSOR_MAC_KEY` is absent, and `discovery/` derives the rest, so a self-hoster + supplies one value rather than a list. +- A Postgres adapter must satisfy the transactions finalization needs, row locking, + migrations, cancellation, typed error mapping and tracing. A Valkey adapter must satisfy + the atomic compare-and-update and expiry primitives each port requires. Neither may + widen a port to make itself easier to write. +- Refusing to boot without Valkey is a deliberate operational cost. The rejected + alternative — a Postgres fallback that removes Valkey — means emulating TTL and expiry + in SQL, which rebuilds the generic TTL store `S-C29` deleted. +- A serve task returns to `mise`, replacing what `serve-api` did for the Salvo tree. + Everything gated on a live server unblocks with it: `S-P7`'s successor, the E2E cases, + the web client's non-empty states. + +## Considered and rejected + +- **Keeping the server library-only and driving it from tests.** It is what the tree does + today, and it is why nothing outside the crate can exercise the rebuild. A description + and a test harness are not a deployment. +- **One `SessionStorage`-style trait again.** The grab-bag whose deletion `S-C29` is; its + caller-supplied TTL and `T: Serialize` payloads are the two properties the port shape + makes inexpressible. +- **Postgres-only, with TTLs emulated in SQL.** Cheaper to operate and it reintroduces the + generic TTL abstraction as an implementation detail nobody can see. diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 4c874840..3f5a9dee 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1720,6 +1720,75 @@ Swept %1$s rejected asset(s) to trash; recoverable for %2$s day(s). Unknown asset %s in this library. Cull view: %1$s pick, %2$s neutral, %3$s reject. + Authentication commands + Login to Capsule + Account email (prompted when omitted) + Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Logout from Capsule + Create a Capsule account and sign in + Account email (prompted when omitted) + Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Show authentication status + Review a local library: flag assets, filter by flag, sweep rejects to trash + List the assets carrying one flag instead of only counting them + Path to the Capsule library + Clear an asset\'s flag back to the never-flagged default (repeatable) + Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal + Flag an asset as a keeper (repeatable) + Flag an asset for rejection (repeatable) + Retention window, in days, the sweep\'s soft delete stamps + Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses + Run the offline end-to-end data-plane showcase (real cryptography, no network) + A real image/file to import (a small synthetic file is used if omitted) + Working directory for the demo libraries (a temp dir is used if omitted) + Import files into a local Capsule library + Re-import files even if they already exist (duplicate override) + Path to the Capsule library + Move files instead of copying them + Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal + Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired + Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets + Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline + Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class + Manage the local library + Show library information + Path to the library + Create a new Capsule library + Human-readable library name + Directory for the new library + Rebuild the SQLite index from sidecar files + Path to the library + List the assets the sync feed has delivered + Include assets the server has tombstoned (deleted) as well as live ones + Match metadata for current file + Path to the file to match metadata for + Upload a local Capsule library to the server + Report what would be uploaded without opening a single upload session + Re-drive every blob regardless of what the server already holds + Path to the Capsule library to push + Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal + Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly + Repair a local library in place, one signed correction per affected asset + Re-read each original\'s EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update + Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset\'s chain + Path to the Capsule library + Correct at most this many affected assets in one --apply run (the report still covers the whole library); only meaningful with --apply + Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Reset all local CLI data + Reset all data + Reset cache directory + Reset configuration + Reset data directory + A command line interface for Capsule - the photo management platform + Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections + Show what an imported asset\'s signed sidecar records: caption, rating, tags, GPS, timestamps + The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file + Path to the Capsule library + Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal + Show current status + Sync local and remote data + Perform a dry run without making changes + Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries Found %1$s candidate(s) (%2$s file(s) total). Done: %1$s imported, %2$s duplicate(s), %3$s error(s). The import failed: %s @@ -1749,6 +1818,59 @@ Pushing %1$s asset(s) to %2$s… The library holds no assets to push. Everything in this library is already on the server. + Checked %s asset(s) against the EXIF capture time of their originals. + %s: corrected + Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault. + Dry run: nothing was written. Re-run with --apply to correct the affected sidecars. + Correcting %1$s failed: %2$s. Assets corrected before it stay corrected; nothing after it was touched. + Stopped after %s correction(s) because of --limit; re-run to continue. + --limit only bounds what --apply writes; give --apply as well, or drop --limit. + Every capture timestamp agrees with its original\'s EXIF; nothing to repair. + %1$s: recorded %2$s, EXIF says %3$s (off by %4$s s) + %1$s: recorded %2$s (not a timestamp), EXIF says %3$s + Capture-time repair: %1$s affected, %2$s corrected, %3$s already correct, %4$s without a recoverable EXIF instant, %5$s unreadable, %6$s in trash. + Skipped %s asset(s) in trash; restore an asset first if it should be repaired. + %1$s: original could not be read (%2$s); skipped + Repair failed: %s + Album: %s + %1$s matches %2$s assets; give more of the hash, or the full asset id. + Caption: %s + Captured: %s + Content type: %s + Cull flag: %s + Dimensions: %s + Show failed: %s + GPS: %s + GCJ-02 + WGS-84 + derived + EXIF + manual + SHA-256: %s + Asset %s + Hidden: %s + Imported: %s + In trash: %s + %1$s is neither an asset id nor a hex prefix of at least %2$s characters of a content hash. + Placeholder: %s + Provenance: %s signed record(s) + Rating: %s + Stack: %s + member + primary + proxy + AI tags: %s + User tags: %s + No asset in this library matches %s. + %1$s×%2$s + %1$s, %2$s (%3$s, %4$s) + , + no + present + %s/5 + %1$s (%2$s) + (unset) + yes Sync complete: applied %1$s change(s) across %2$s album(s) in %3$s page(s). Dry run: no changes will be persisted. Sync failed: %s @@ -1819,6 +1941,11 @@ Upload only This album could not be registered because its identifier is malformed. This album isn\'t available on your account. + Only a device on the album owner\'s account can publish its roster. + Capsule couldn\'t read that album roster. + That album isn\'t yours, or doesn\'t exist. + The roster you sent is out of step with the one the server holds. + That roster\'s version is too far ahead of the one the server holds. Capsule couldn\'t set up that album. Please try again. This album is already being upgraded. Capsule couldn\'t read that album upgrade request. @@ -1827,6 +1954,14 @@ This account is locked after too many failed sign-in attempts. That is not your current password. Invalid email or password. + An account with that email address already exists here. Sign in with its password instead. + Too many sign-ins are already in progress. Please try again in a moment. + Your identity provider didn\'t accept that sign-in. Try again. + Single sign-on isn\'t set up on this server. + That sign-in can\'t return to this app. + That sign-in has expired. Start again. + Your identity provider\'s answer couldn\'t be verified. + Capsule couldn\'t reach your identity provider just now. Please try again. That password cannot be used. That display name cannot be used. That account no longer exists. @@ -1843,6 +1978,7 @@ There is no two-factor setup waiting to be confirmed. Capsule couldn\'t reach your account just now. Please try again. An account with these details already exists. + You no longer have access to this album. That photo file is no longer available. That photo file isn\'t on the server. The original photo hasn\'t been uploaded from its device yet. @@ -1856,6 +1992,7 @@ That device list couldn\'t be read. This device list is out of date. Capsule will refresh it before continuing. That upload could not be added to the album. + This server is handling too many upload links right now. Please try again shortly. This upload link is full. This upload link is full. Ask for a new one. That part of the upload could not be accepted. It will be retried. @@ -1867,6 +2004,7 @@ This upload link needs its passphrase. Too many attempts. Please wait and try again. Capsule couldn\'t reach the upload service. Please try again. + This server is handling too many enrollment attempts right now. Please try again shortly. This device-add session has ended. Start again. That device code didn\'t work. Generate a new one and try again. Confirm it\'s you on this device to add another device. @@ -1875,15 +2013,22 @@ The recovery backup could not be saved. No recovery backup is saved for this account. Capsule couldn\'t reach the recovery backup. Please try again. + That album couldn\'t be found. This access grant is for a different album. This shared album\'s access has expired. This shared album\'s access could not be verified. + That sharing request isn\'t valid. Access to this shared album has been revoked. This source is temporarily backed off after repeated errors. + That person isn\'t on this album\'s member list. + This server doesn\'t share albums with other servers. + That server isn\'t one this server knows. This source has reached its request limit. Please wait and try again. Capsule couldn\'t read the revocation list. Please try again. This access grant does not cover the requested content. + Capsule couldn\'t reach the federation records. Please try again. Your account is suspended. You can\'t upload or share until it\'s reinstated. + That report isn\'t valid. Too many reports from this source. Please wait and try again. The moderation report could not be verified. This server is blocked from federating with us. @@ -1902,6 +2047,7 @@ Please sign in again. Some of that request didn\'t make sense. Capsule couldn\'t read that content type. + This server is handling too many shared links right now. Please try again shortly. That share link could not be created. Too many attempts. Please wait and try again. Capsule couldn\'t reach that share. Please try again. @@ -1909,6 +2055,7 @@ Too many deep storage checks. Please wait and try again. The storage-verification request was malformed. Capsule couldn\'t check whether your photos are safely stored. Please try again. + You don\'t have access to that album. The sync session is out of date. Capsule will resync from the start. Please sign in again to continue syncing. Capsule could not reach the server to sync. It will try again. diff --git a/capsule-cli/Cargo.toml b/capsule-cli/Cargo.toml index 613286b2..e8bc024b 100644 --- a/capsule-cli/Cargo.toml +++ b/capsule-cli/Cargo.toml @@ -9,6 +9,14 @@ publish.workspace = true name = "capsule" path = "src/main.rs" +# The command-tree description artifact the documentation site is generated from (slice +# `S-Z8`). Declared explicitly rather than left to autodiscovery so the crate's two binaries +# are visible in one place: `capsule` ships, this one only ever runs from `mise run +# cli-surface` / `cli-surface-check`. +[[bin]] +name = "gen_cli_surface" +path = "src/bin/gen_cli_surface.rs" + [lib] name = "capsule_cli" path = "src/lib.rs" diff --git a/capsule-cli/cli-surface.json b/capsule-cli/cli-surface.json new file mode 100644 index 00000000..e6710646 --- /dev/null +++ b/capsule-cli/cli-surface.json @@ -0,0 +1,631 @@ +{ + "about": "A command line interface for Capsule - the photo management platform", + "long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "name": "capsule", + "schema": 1, + "subcommands": [ + { + "about": "Authentication commands", + "name": "auth", + "subcommands": [ + { + "about": "Login to Capsule", + "args": [ + { + "help": "Account email (prompted when omitted)", + "id": "email", + "long": "email", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "EMAIL" + ] + }, + { + "help": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "password_stdin", + "long": "password-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "login" + }, + { + "about": "Logout from Capsule", + "name": "logout" + }, + { + "about": "Create a Capsule account and sign in", + "args": [ + { + "help": "Account email (prompted when omitted)", + "id": "email", + "long": "email", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "EMAIL" + ] + }, + { + "help": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "password_stdin", + "long": "password-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "register" + }, + { + "about": "Show authentication status", + "name": "status" + } + ] + }, + { + "about": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "args": [ + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Flag an asset as a keeper (repeatable)", + "id": "pick", + "long": "pick", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "Clear an asset's flag back to the never-flagged default (repeatable)", + "id": "neutral", + "long": "neutral", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "Flag an asset for rejection (repeatable)", + "id": "reject", + "long": "reject", + "positional": false, + "repeatable": true, + "required": false, + "takes_value": true, + "value_names": [ + "ASSET_ID" + ] + }, + { + "help": "List the assets carrying one flag instead of only counting them", + "id": "filter", + "long": "filter", + "positional": false, + "possible_values": [ + { + "help": "A keeper", + "name": "pick" + }, + { + "help": "Not yet culled either way — the never-flagged default", + "name": "neutral" + }, + { + "help": "Marked for rejection; the set `--sweep` moves to trash", + "name": "reject" + } + ], + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "FLAG" + ] + }, + { + "help": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "id": "sweep", + "long": "sweep", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "default_values": [ + "30" + ], + "help": "Retention window, in days, the sweep's soft delete stamps", + "id": "retain_days", + "long": "retain-days", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "DAYS" + ] + } + ], + "name": "cull" + }, + { + "about": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "args": [ + { + "help": "Working directory for the demo libraries (a temp dir is used if omitted)", + "id": "workdir", + "long": "workdir", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "A real image/file to import (a small synthetic file is used if omitted)", + "id": "image", + "long": "image", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "demo" + }, + { + "about": "Import files into a local Capsule library", + "args": [ + { + "help": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "id": "paths", + "positional": true, + "repeatable": true, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "id": "provider", + "long": "provider", + "positional": false, + "possible_values": [ + { + "help": "A Google Photos export produced by Google Takeout, extracted to disk", + "name": "takeout" + } + ], + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "PROVIDER" + ] + }, + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Move files instead of copying them", + "id": "move", + "long": "move", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Re-import files even if they already exist (duplicate override)", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "id": "push", + "long": "push", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "id": "staged", + "long": "staged", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "import" + }, + { + "about": "Manage the local library", + "name": "library", + "subcommands": [ + { + "about": "Show library information", + "args": [ + { + "help": "Path to the library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "info" + }, + { + "about": "Create a new Capsule library", + "args": [ + { + "help": "Directory for the new library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "default_values": [ + "My Library" + ], + "help": "Human-readable library name", + "id": "name", + "long": "name", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "NAME" + ] + } + ], + "name": "init" + }, + { + "about": "Rebuild the SQLite index from sidecar files", + "args": [ + { + "help": "Path to the library", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "rebuild" + } + ] + }, + { + "about": "List the assets the sync feed has delivered", + "args": [ + { + "help": "Include assets the server has tombstoned (deleted) as well as live ones", + "id": "include_deleted", + "long": "include-deleted", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "list" + }, + { + "about": "Match metadata for current file", + "args": [ + { + "help": "Path to the file to match metadata for", + "id": "path", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + } + ], + "name": "match" + }, + { + "about": "Upload a local Capsule library to the server", + "args": [ + { + "help": "Path to the Capsule library to push", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Report what would be uploaded without opening a single upload session", + "id": "dry_run", + "long": "dry-run", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Re-drive every blob regardless of what the server already holds", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "id": "staged", + "long": "staged", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "push" + }, + { + "about": "Repair a local library in place, one signed correction per affected asset", + "name": "repair", + "subcommands": [ + { + "about": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "args": [ + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "id": "apply", + "long": "apply", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Correct at most this many affected assets in one --apply run (the report still covers the whole library); only meaningful with --apply", + "id": "limit", + "long": "limit", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": true, + "value_names": [ + "COUNT" + ] + } + ], + "name": "capture-time" + } + ] + }, + { + "about": "Reset all local CLI data", + "args": [ + { + "help": "Reset configuration", + "id": "config", + "long": "config", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset data directory", + "id": "data", + "long": "data", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset cache directory", + "id": "cache", + "long": "cache", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Reset all data", + "id": "all", + "long": "all", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "reset" + }, + { + "about": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "args": [ + { + "help": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "id": "asset", + "positional": true, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "ASSET" + ] + }, + { + "help": "Path to the Capsule library", + "id": "library", + "long": "library", + "positional": false, + "repeatable": false, + "required": true, + "takes_value": true, + "value_names": [ + "PATH" + ] + }, + { + "help": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "id": "passphrase_stdin", + "long": "passphrase-stdin", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "show" + }, + { + "about": "Show current status", + "name": "status" + }, + { + "about": "Sync local and remote data", + "args": [ + { + "help": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", + "id": "force", + "long": "force", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + }, + { + "help": "Perform a dry run without making changes", + "id": "dry_run", + "long": "dry-run", + "positional": false, + "repeatable": false, + "required": false, + "takes_value": false + } + ], + "name": "sync" + } + ] +} diff --git a/capsule-cli/src/bin/gen_cli_surface.rs b/capsule-cli/src/bin/gen_cli_surface.rs new file mode 100644 index 00000000..6551eb4b --- /dev/null +++ b/capsule-cli/src/bin/gen_cli_surface.rs @@ -0,0 +1,82 @@ +//! Deterministic dump of the `capsule` command tree (slice `S-Z8`). +//! +//! Serializes [`capsule_cli::cli::command_tree`] to `capsule-cli/cli-surface.json` and, with +//! `--check`, fails when the committed copy is stale. It is the CLI's half of the rule that +//! artifacts cross the toolchain boundary rather than toolchains (`design/developer-docs.md`): +//! the documentation build installs bun and nothing else, so it cannot ask cargo what +//! `capsule --help` says. It reads this file instead, and this binary is what makes a drifted +//! file fail CI. +//! +//! Deliberately the same shape as `capsule-server/src/bin/gen_openapi.rs` — `[FILE]` default, +//! `--check`, byte comparison, trailing newline — because two description artifacts that are +//! refreshed and gated differently are two things to remember instead of one. +//! +//! It needs no database, no library, no key material, no network: `command_tree()` walks a +//! `clap::Command` built from compile-time attributes. That is what lets `--check` run in the +//! Rust check gate beside `openapi-check-kynos`. +//! +//! ## Its output is English on purpose +//! +//! `xtask i18n-guard` scans `capsule-cli/src/**` because the `capsule` binary renders prose to +//! a user in their own language. This binary does not: it runs from `mise run cli-surface` and +//! from CI, is never installed, and its reader is a developer looking at a task's output. +//! `xtask::i18n_guard::NEVER_SCANNED` carves `capsule-cli/src/bin/` out of that root for +//! exactly that reason, so the lines below are plain English and stay that way — routing a +//! build tool's status line through `locales/` would put a string no user can reach into every +//! translation catalog. +//! +//! Usage: +//! - `gen_cli_surface [FILE]` writes the document (default `capsule-cli/cli-surface.json`). +//! - `gen_cli_surface --check [FILE]` fails if the committed document is stale, writing nothing. + +use std::path::PathBuf; + +use clap::Parser; +use color_eyre::eyre::{Context, Result, bail}; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Output path for the command-tree document (relative to the repo root). + #[arg(value_name = "FILE", default_value = "capsule-cli/cli-surface.json")] + output: PathBuf, + + /// Verify the committed document is up to date instead of writing it (CI drift gate). + #[arg(long)] + check: bool, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let cli = Cli::parse(); + + // Pretty-printed with a trailing newline: the artifact is reviewed as a diff, so a + // one-line blob would hide exactly the change a reviewer is there to see. + let mut json = serde_json::to_string_pretty(&capsule_cli::cli::command_tree()) + .wrap_err("serializing the command tree to JSON")?; + json.push('\n'); + + if cli.check { + let committed = std::fs::read_to_string(&cli.output).wrap_err_with(|| { + format!("cannot read committed document at {}", cli.output.display()) + })?; + if committed != json { + bail!( + "command tree at {} is out of sync with the `capsule` argument surface; run \ + `mise run cli-surface` and commit the result", + cli.output.display() + ); + } + println!("command tree is up to date: {}", cli.output.display()); + } else { + if let Some(parent) = cli.output.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("creating {}", parent.display()))?; + } + std::fs::write(&cli.output, &json) + .wrap_err_with(|| format!("writing {}", cli.output.display()))?; + println!("Wrote {}", cli.output.display()); + } + + Ok(()) +} diff --git a/capsule-cli/src/cli/commands.rs b/capsule-cli/src/cli/commands.rs index e499fd84..e9fb28b6 100644 --- a/capsule-cli/src/cli/commands.rs +++ b/capsule-cli/src/cli/commands.rs @@ -127,11 +127,30 @@ pub(crate) enum Commands { #[arg(long, value_name = "DAYS", default_value_t = crate::cull::DEFAULT_RETAIN_DAYS)] retain_days: i64, }, + /// Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps + Show { + /// The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 + /// content hash — the same digest `shasum -a 256` prints for the source file + #[arg(value_name = "ASSET")] + asset: String, + /// Path to the Capsule library + #[arg(long, value_name = "PATH")] + library: PathBuf, + /// Read the library passphrase from stdin instead of prompting, so the command + /// works in scripts and CI where there is no terminal. + #[arg(long)] + passphrase_stdin: bool, + }, /// Manage the local library Library { #[command(subcommand)] command: LibraryCommands, }, + /// Repair a local library in place, one signed correction per affected asset + Repair { + #[command(subcommand)] + command: RepairCommands, + }, /// Run the offline end-to-end data-plane showcase (real cryptography, no network) Demo { /// Working directory for the demo libraries (a temp dir is used if omitted) @@ -203,6 +222,29 @@ pub(crate) enum LibraryCommands { }, } +#[derive(Subcommand, Debug)] +pub(crate) enum RepairCommands { + /// Re-read each original's EXIF capture time and report every asset whose signed capture + /// timestamp disagrees with it; with --apply, correct each one as a signed metadata update + CaptureTime { + /// Path to the Capsule library + #[arg(long, value_name = "PATH")] + library: PathBuf, + /// Read the library passphrase from stdin instead of prompting, so the command + /// works in scripts and CI where there is no terminal. + #[arg(long)] + passphrase_stdin: bool, + /// Write the corrections. Without this flag the pass only reports what it would + /// change; each correction is an irreversible signed record on the asset's chain. + #[arg(long)] + apply: bool, + /// Correct at most this many affected assets in one --apply run (the report still + /// covers the whole library); only meaningful with --apply + #[arg(long, value_name = "COUNT", value_parser = clap::value_parser!(u64).range(1..))] + limit: Option, + }, +} + #[derive(Subcommand, Debug)] pub(crate) enum AuthCommands { /// Create a Capsule account and sign in diff --git a/capsule-cli/src/cli/help.rs b/capsule-cli/src/cli/help.rs new file mode 100644 index 00000000..02ca605f --- /dev/null +++ b/capsule-cli/src/cli/help.rs @@ -0,0 +1,367 @@ +//! Localized `--help` text (slice `S-I8`). +//! +//! `clap`'s derive renders help from doc comments and `#[arg]` attributes — compile-time +//! English with no seam a catalog key could pass through. This module is that seam: a +//! rewriter that walks a built [`Command`] tree and replaces every `about`, `long_about`, +//! `help` and `long_help` with the message the catalog holds under a key derived from the +//! command's position in the tree. The doc comments stay where they are and keep being the +//! source the catalog's `en` entries are checked against, so there is exactly one place the +//! English is authored and one test that proves the catalog has not drifted from it. +//! +//! ## The key grammar +//! +//! One key per help string, derived from the command path and the argument id — the derive +//! field name — so nothing is spelled twice: +//! +//! | Key | Text it replaces | +//! | --- | --- | +//! | `cli.help.root.about` | the root command's `about` | +//! | `cli.help.root.long_about` | the root command's `long_about` | +//! | `cli.help..about` | a subcommand's `about`, e.g. `cli.help.library.init.about` | +//! | `cli.help..long_about` | its `long_about`, when the derive gives it a distinct one | +//! | `cli.help..arg.` | an argument's `help`, e.g. `cli.help.import.arg.library` | +//! | `cli.help..arg..long_help` | its `long_help`, when the derive gives it a distinct one | +//! +//! `` is the dot-joined chain of subcommand names below the root; the root itself is +//! spelled `root`, a name no subcommand may take. +//! +//! ## Two properties the rest of the crate relies on +//! +//! - **A missing key changes nothing.** The look-up is [`Bundle::message`], which reports a +//! miss as `None` rather than as the key, and a miss leaves the derive text in place. So a +//! locale with no `cli.help.*` entries yet renders today's English, never a raw key in a +//! terminal — and a locale with *some* entries renders a mix, which is what partial +//! translation is supposed to look like. +//! - **Under the source locale the tree is unchanged.** Every `en` entry is asserted equal to +//! the derive text it replaces, so `capsule --help` under `LANG=C` is byte-identical to +//! the un-localized rendering, and [`command_tree`](super::command_tree) — which resolves +//! through an explicitly pinned `en` bundle — emits the same `cli-surface.json` it did +//! before help was localized. +//! +//! What this cannot reach is a `ValueEnum` variant's help (`--filter pick` → "A keeper."): +//! `clap` 4.6 re-words a possible value only by replacing the typed `value_parser` with a +//! `PossibleValuesParser`, which would trade typed parsing for a translated word. That gap is +//! recorded in the i18n design doc rather than closed here. + +use clap::{Arg, Command}; + +use crate::i18n::{Bundle, HELP_NAMESPACE}; + +/// The path segment naming the root command in a help key. +pub const ROOT_PATH: &str = "root"; + +/// The key holding a command's one-line description. +#[must_use] +pub fn about_key(path: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.about") +} + +/// The key holding a command's long description (shown by `--help`, not `-h`). +#[must_use] +pub fn long_about_key(path: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.long_about") +} + +/// The key holding an argument's help line. +#[must_use] +pub fn arg_key(path: &str, arg_id: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.arg.{arg_id}") +} + +/// The key holding an argument's long help (shown by `--help`, not `-h`). +#[must_use] +pub fn arg_long_help_key(path: &str, arg_id: &str) -> String { + format!("{HELP_NAMESPACE}.{path}.arg.{arg_id}.long_help") +} + +/// The path of `name` when it is a subcommand of the command at `parent`. +#[must_use] +pub fn child_path(parent: &str, name: &str) -> String { + if parent == ROOT_PATH { + name.to_owned() + } else { + format!("{parent}.{name}") + } +} + +/// Replace every help string in `command` (recursively) with the message `bundle` holds for +/// it, leaving any string whose key the bundle lacks exactly as the derive wrote it. +#[must_use] +pub fn localize(command: Command, bundle: &Bundle) -> Command { + localize_with(command, &|key| bundle.message(key).map(str::to_owned)) +} + +/// [`localize`] over an arbitrary look-up, so the rewriter is testable against a stub that +/// answers for one key and nothing else. +#[must_use] +pub fn localize_with(command: Command, lookup: &dyn Fn(&str) -> Option) -> Command { + localize_at(command, ROOT_PATH, lookup) +} + +fn localize_at(command: Command, path: &str, lookup: &dyn Fn(&str) -> Option) -> Command { + let command = localize_command_text(command, path, lookup); + let command = command.mut_args(|arg| localize_arg(arg, path, lookup)); + command.mut_subcommands(|subcommand| { + let child = child_path(path, subcommand.get_name()); + localize_at(subcommand, &child, lookup) + }) +} + +/// Rewrite `about` / `long_about`. The long form is kept in step with the short one: the +/// derive sets both to the same text for a one-paragraph doc comment, and replacing only the +/// short form would make `-h` speak one language and `--help` another. +fn localize_command_text( + mut command: Command, + path: &str, + lookup: &dyn Fn(&str) -> Option, +) -> Command { + let derive_about = command.get_about().map(ToString::to_string); + let derive_long = command.get_long_about().map(ToString::to_string); + if let Some(about) = lookup(&about_key(path)) { + if derive_long.is_some() && derive_long == derive_about { + command = command.long_about(about.clone()); + } + command = command.about(about); + } + if let Some(long_about) = lookup(&long_about_key(path)) { + command = command.long_about(long_about); + } + command +} + +/// Rewrite `help` / `long_help`, with the same lockstep rule as the command text. +fn localize_arg(mut arg: Arg, path: &str, lookup: &dyn Fn(&str) -> Option) -> Arg { + let id = arg.get_id().as_str().to_owned(); + let derive_help = arg.get_help().map(ToString::to_string); + let derive_long = arg.get_long_help().map(ToString::to_string); + if let Some(help) = lookup(&arg_key(path, &id)) { + if derive_long.is_some() && derive_long == derive_help { + arg = arg.long_help(help.clone()); + } + arg = arg.help(help); + } + if let Some(long_help) = lookup(&arg_long_help_key(path, &id)) { + arg = arg.long_help(long_help); + } + arg +} + +#[cfg(test)] +mod tests { + use clap::CommandFactory; + + use super::*; + use crate::cli::Cli; + + /// Every `(path, command)` pair in the tree, root first. + fn commands(command: &Command, path: &str, out: &mut Vec<(String, Command)>) { + out.push((path.to_owned(), command.clone())); + for subcommand in command.get_subcommands() { + commands(subcommand, &child_path(path, subcommand.get_name()), out); + } + } + + /// `--help` of every command in the tree, rendered in tree order. + fn rendered_help(command: &Command) -> Vec { + let mut all = Vec::new(); + commands(command, ROOT_PATH, &mut all); + all.into_iter() + .map(|(_, mut command)| command.render_long_help().to_string()) + .collect() + } + + /// The invariant `cli-surface.json` and `capsule --help` rest on: the source catalog's + /// entry for every help string is the derive text, verbatim — so localizing under `en` + /// is the identity, and a doc comment edited without its catalog entry (or the reverse) + /// fails here rather than shipping two Englishes. + #[test] + fn every_help_string_has_an_english_catalog_entry_equal_to_the_derive_text() { + let bundle = Bundle::for_locale("en"); + let mut all = Vec::new(); + commands(&Cli::command(), ROOT_PATH, &mut all); + assert!(all.len() > 16, "the whole tree is walked"); + + // The converse: every `cli.help.*` key in the canonical catalog is one the walk + // produces, so a key left behind by a renamed command or argument fails here too. + let catalog: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../locales/en.json"), + ) + .expect("the canonical catalog is readable"), + ) + .expect("the canonical catalog is JSON"); + let mut produced = std::collections::BTreeSet::new(); + for (path, command) in &all { + produced.insert(about_key(path)); + produced.insert(long_about_key(path)); + for arg in command.get_arguments() { + let id = arg.get_id().as_str(); + produced.insert(arg_key(path, id)); + produced.insert(arg_long_help_key(path, id)); + } + } + let dead: Vec<&String> = catalog + .as_object() + .expect("the catalog is an object") + .keys() + .filter(|key| key.starts_with(&format!("{HELP_NAMESPACE}."))) + .filter(|key| !produced.contains(key.as_str())) + .collect(); + assert!( + dead.is_empty(), + "catalog keys no command produces: {dead:?}" + ); + + for (path, command) in &all { + let about = command.get_about().map(ToString::to_string); + assert_eq!( + bundle.message(&about_key(path)), + about.as_deref(), + "`{}` about", + about_key(path) + ); + let long_about = command.get_long_about().map(ToString::to_string); + let distinct_long = long_about.is_some() && long_about != about; + assert_eq!( + bundle.message(&long_about_key(path)), + if distinct_long { + long_about.as_deref() + } else { + None + }, + "`{}` is present exactly when the derive gives a distinct long_about", + long_about_key(path) + ); + + for arg in command.get_arguments() { + let id = arg.get_id().as_str(); + let help = arg.get_help().map(ToString::to_string); + assert_eq!( + bundle.message(&arg_key(path, id)), + help.as_deref(), + "`{}` help", + arg_key(path, id) + ); + let long_help = arg.get_long_help().map(ToString::to_string); + let distinct_long = long_help.is_some() && long_help != help; + assert_eq!( + bundle.message(&arg_long_help_key(path, id)), + if distinct_long { + long_help.as_deref() + } else { + None + }, + "`{}` is present exactly when the derive gives a distinct long_help", + arg_long_help_key(path, id) + ); + } + } + } + + /// The consequence of the invariant above, observed on the rendered output: under the + /// source locale, `--help` of every command is byte-identical to the un-localized one. + #[test] + fn localizing_under_the_source_locale_leaves_every_help_page_byte_identical() { + let plain = rendered_help(&Cli::command()); + let localized = rendered_help(&localize(Cli::command(), &Bundle::for_locale("en"))); + assert_eq!(plain, localized); + assert!(plain.iter().any(|page| page.contains("--library"))); + } + + #[test] + fn a_catalog_message_replaces_the_derive_text_at_its_position_only() { + let localized = localize_with(Cli::command(), &|key| { + (key == about_key("import")).then(|| "XX".to_owned()) + }); + let import = localized + .find_subcommand("import") + .expect("`import` is a subcommand"); + assert_eq!( + import.get_about().map(ToString::to_string).as_deref(), + Some("XX") + ); + // The lockstep rule: a one-paragraph derive comment sets both forms, so both move. + assert_eq!( + import.get_long_about().map(ToString::to_string), + Cli::command() + .find_subcommand("import") + .expect("`import` is a subcommand") + .get_long_about() + .map(|_| "XX".to_owned()) + ); + let push = localized + .find_subcommand("push") + .expect("`push` is a subcommand"); + assert_eq!( + push.get_about().map(ToString::to_string), + Cli::command() + .find_subcommand("push") + .expect("`push` is a subcommand") + .get_about() + .map(ToString::to_string) + ); + } + + #[test] + fn an_argument_message_reaches_the_rendered_help_of_its_command() { + let key = arg_key("import", "library"); + let mut localized = localize_with(Cli::command(), &|k| { + (k == key).then(|| "YY the library".to_owned()) + }); + let page = localized + .find_subcommand_mut("import") + .expect("`import` is a subcommand") + .render_long_help() + .to_string(); + assert!(page.contains("YY the library"), "{page}"); + assert!(!page.contains("Path to the Capsule library"), "{page}"); + // Another command's identical-looking argument is a different key, so it is untouched. + let cull = localized + .find_subcommand_mut("cull") + .expect("`cull` is a subcommand") + .render_long_help() + .to_string(); + assert!(cull.contains("Path to the Capsule library"), "{cull}"); + } + + #[test] + fn a_nested_subcommand_is_keyed_by_its_dotted_path() { + let key = about_key("library.init"); + let localized = localize_with(Cli::command(), &|k| (k == key).then(|| "ZZ".to_owned())); + let init = localized + .find_subcommand("library") + .and_then(|library| library.find_subcommand("init")) + .expect("`library init` is a subcommand"); + assert_eq!( + init.get_about().map(ToString::to_string).as_deref(), + Some("ZZ") + ); + } + + #[test] + fn a_missing_key_leaves_the_derive_text_in_place() { + let plain = rendered_help(&Cli::command()); + let untouched = rendered_help(&localize_with(Cli::command(), &|_| None)); + assert_eq!(plain, untouched); + // A locale with no catalog of its own falls back to the source entries, which the + // invariant test proves are the derive text — so an unknown locale is the identity too. + let unknown = rendered_help(&localize(Cli::command(), &Bundle::for_locale("xx-XX"))); + assert_eq!(plain, unknown); + } + + #[test] + fn the_key_grammar_spells_root_and_nested_paths() { + assert_eq!(about_key(ROOT_PATH), "cli.help.root.about"); + assert_eq!(long_about_key(ROOT_PATH), "cli.help.root.long_about"); + assert_eq!(child_path(ROOT_PATH, "library"), "library"); + assert_eq!(child_path("library", "init"), "library.init"); + assert_eq!( + arg_key("library.init", "path"), + "cli.help.library.init.arg.path" + ); + assert_eq!( + arg_long_help_key("import", "paths"), + "cli.help.import.arg.paths.long_help" + ); + } +} diff --git a/capsule-cli/src/cli/mod.rs b/capsule-cli/src/cli/mod.rs index e3cc16ba..7cb6d66c 100644 --- a/capsule-cli/src/cli/mod.rs +++ b/capsule-cli/src/cli/mod.rs @@ -1,15 +1,524 @@ +//! The `capsule` argument surface, and the machine-readable description of it that the +//! documentation site is generated from. +//! +//! [`Cli`] stays `pub(crate)`: the parsed command is dispatch state, not API. What crosses +//! the crate boundary is [`command_tree`], the description artifact behind +//! `/reference/cli/` (slice `S-Z8`). + pub(crate) mod commands; +pub mod help; -use clap::Parser; +use clap::{Arg, ArgAction, Command, CommandFactory, Parser}; pub(crate) use commands::*; +use serde_json::{Map, Value}; + +use crate::i18n::Bundle; + +/// Every field name the command-tree document uses, named once. +/// +/// The document is hand-built rather than derived from a struct, because its shape is a +/// projection of `clap`'s builder API and no Rust type in this crate has that shape. Naming +/// the keys here is what keeps that from meaning "spelled ad hoc": this block is the +/// vocabulary `capsule-docs/scripts/gen-reference.mjs` reads on the other side, and a typo +/// in a key is a field the generator silently never finds. +mod field { + pub(super) const SCHEMA: &str = "schema"; + pub(super) const NAME: &str = "name"; + pub(super) const ABOUT: &str = "about"; + pub(super) const LONG_ABOUT: &str = "long_about"; + pub(super) const ARGS: &str = "args"; + pub(super) const SUBCOMMANDS: &str = "subcommands"; + pub(super) const ID: &str = "id"; + pub(super) const POSITIONAL: &str = "positional"; + pub(super) const REQUIRED: &str = "required"; + pub(super) const TAKES_VALUE: &str = "takes_value"; + pub(super) const REPEATABLE: &str = "repeatable"; + pub(super) const LONG: &str = "long"; + pub(super) const SHORT: &str = "short"; + pub(super) const VALUE_NAMES: &str = "value_names"; + pub(super) const POSSIBLE_VALUES: &str = "possible_values"; + pub(super) const DEFAULT_VALUES: &str = "default_values"; + pub(super) const HELP: &str = "help"; + pub(super) const LONG_HELP: &str = "long_help"; +} + +/// Schema version of the emitted command-tree document. +/// +/// Bumped only when a consumer must change to keep reading it — adding an optional field is +/// not a bump, renaming or removing one is. `capsule-docs/scripts/gen-reference.mjs` refuses +/// a version it was not written against rather than rendering a half-understood document. +const COMMAND_TREE_SCHEMA: u32 = 1; #[derive(Parser, Debug)] #[command(name = "capsule")] #[command(about = "A command line interface for Capsule - the photo management platform")] #[command( - long_about = "Capsule CLI provides tools for managing your photos and albums:\n• Authentication management\n• Sync local and remote data\n• Check status and list files\n• Manage albums and collections" + // Markdown list markers, not `•`: this text is the root `long_about` in the committed + // command tree, and the reference page renders it as prose. Bullet characters soft-wrap + // into one run-on paragraph there, while `-` renders as the list it already is. A + // terminal shows `-` as a list too, so `capsule --help` loses nothing. + long_about = "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections" )] pub(crate) struct Cli { #[command(subcommand)] pub command: Commands, } + +/// The `capsule` command tree as JSON — the committed description artifact +/// `capsule-cli/cli-surface.json`, emitted by the `gen_cli_surface` binary and rendered +/// into `/reference/cli/` by the documentation build (slice `S-Z8`). +/// +/// # This output is deterministic, and independent of the process locale +/// +/// Both properties are load-bearing, because the artifact is committed and drift-gated: +/// `mise run cli-surface-check` fails on any byte difference, so a value that varies by +/// machine, environment, or run would fail CI on a tree nobody changed. +/// +/// - **Deterministic.** Subcommands are sorted by name rather than emitted in +/// declaration order, so reordering an enum variant cannot churn the artifact. +/// Arguments keep declaration order, which for a `clap` derive is field order, which +/// for a positional *is* its position — sorting them would destroy that meaning. +/// Object keys come out sorted because `serde_json::Map` is a `BTreeMap` here +/// (`preserve_order` is off). Nothing is read from the clock, the filesystem, or the +/// environment. +/// - **Locale-independent.** Help text is localized (slice `S-I8`, [`help::localize`]), and +/// this function resolves it through an explicitly pinned **`en`** bundle — +/// `Bundle::for_locale("en")`, never [`crate::i18n::cli_bundle`], which negotiates +/// `LC_ALL`/`LC_MESSAGES`/`LANG`. The artifact describes one surface in one language; a +/// bundle negotiated from the environment would make `cli-surface-check` pass or fail +/// according to the developer's `LANG`, and the drift gate would stop meaning anything. +/// `help`'s invariant test additionally proves the `en` entries equal the derive text, so +/// pinning `en` yields the same tree the un-localized derive did. `StyledStr`'s `Display` +/// is documented as colour-unaware, so no ANSI escape can leak in from a terminal that +/// supports colour. +/// +/// Localizing the *rendered* help a user sees is a separate concern from describing the +/// surface: the binary's [`crate::run`] applies the same rewriter under the negotiated +/// bundle, and this function does not. +/// +/// The tree describes the surface this crate *declares*. `clap`'s generated `--help` (and +/// `--version`, were one configured) is deliberately absent: [`Command::build`] is not +/// called, so no synthesized argument is described, and the reference page does not repeat +/// `--help` under every command. Hidden commands and arguments are skipped for the same +/// reason they are hidden. +#[must_use] +pub fn command_tree() -> Value { + let mut root = describe_command(&help::localize(Cli::command(), &Bundle::for_locale("en"))); + root.insert( + field::SCHEMA.to_owned(), + Value::from(u64::from(COMMAND_TREE_SCHEMA)), + ); + Value::Object(root) +} + +/// Describe one command and, recursively, its subcommands. +/// +/// Recursion is bounded by the derive: the tree is a finite `enum` nesting, so there is no +/// cycle to guard against and no depth limit to pick. +fn describe_command(command: &Command) -> Map { + let mut out = Map::new(); + out.insert(field::NAME.to_owned(), Value::from(command.get_name())); + + if let Some(about) = command.get_about() { + out.insert(field::ABOUT.to_owned(), Value::from(about.to_string())); + } + // Emitted only when it says something `about` does not, so the artifact does not carry + // the same paragraph twice for every command whose doc comment is one line long. + if let Some(long_about) = command.get_long_about() { + let long_about = long_about.to_string(); + if Some(long_about.as_str()) != command.get_about().map(ToString::to_string).as_deref() { + out.insert(field::LONG_ABOUT.to_owned(), Value::from(long_about)); + } + } + + let args: Vec = command + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .map(|arg| Value::Object(describe_arg(arg))) + .collect(); + if !args.is_empty() { + out.insert(field::ARGS.to_owned(), Value::from(args)); + } + + let mut subcommands: Vec<&Command> = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .collect(); + subcommands.sort_by(|a, b| a.get_name().cmp(b.get_name())); + if !subcommands.is_empty() { + let described: Vec = subcommands + .into_iter() + .map(|subcommand| Value::Object(describe_command(subcommand))) + .collect(); + out.insert(field::SUBCOMMANDS.to_owned(), Value::from(described)); + } + + out +} + +/// Describe one argument: how it is spelled, whether it takes a value, and what the help +/// says about it. +fn describe_arg(arg: &Arg) -> Map { + let mut out = Map::new(); + out.insert(field::ID.to_owned(), Value::from(arg.get_id().as_str())); + out.insert( + field::POSITIONAL.to_owned(), + Value::from(arg.is_positional()), + ); + out.insert( + field::REQUIRED.to_owned(), + Value::from(arg.is_required_set()), + ); + out.insert(field::TAKES_VALUE.to_owned(), Value::from(takes_value(arg))); + out.insert( + field::REPEATABLE.to_owned(), + Value::from(is_repeatable(arg)), + ); + + if let Some(long) = arg.get_long() { + out.insert(field::LONG.to_owned(), Value::from(long)); + } + if let Some(short) = arg.get_short() { + out.insert(field::SHORT.to_owned(), Value::from(short.to_string())); + } + // Both of these are asked only of a value-taking argument, because the derive answers + // them for a flag too and both answers are internal detail rather than surface. A + // `bool` field gets a `value_name` synthesized from its identifier (`PASSWORD_STDIN`) + // that no user may type, and `get_possible_values` reports the `true`/`false` its bool + // parser accepts — rendering either would document `--password-stdin ` + // taking `true` or `false`, which is not the flag `capsule --help` offers. + if takes_value(arg) { + if let Some(names) = arg.get_value_names() { + let names: Vec = names + .iter() + .map(|name| Value::from(name.to_string())) + .collect(); + out.insert(field::VALUE_NAMES.to_owned(), Value::from(names)); + } + + let possible: Vec = arg + .get_possible_values() + .iter() + .filter(|value| !value.is_hide_set()) + .map(|value| { + let mut entry = Map::new(); + entry.insert(field::NAME.to_owned(), Value::from(value.get_name())); + if let Some(help) = value.get_help() { + entry.insert(field::HELP.to_owned(), Value::from(help.to_string())); + } + Value::Object(entry) + }) + .collect(); + if !possible.is_empty() { + out.insert(field::POSSIBLE_VALUES.to_owned(), Value::from(possible)); + } + } + + // `OsStr` here is `clap`'s, which derefs to the standard one. Every default in this + // surface is ASCII, so the lossy conversion is exact; a non-UTF-8 default would be + // unrepresentable in JSON either way. + let defaults: Vec = arg + .get_default_values() + .iter() + .map(|value| Value::from(value.to_string_lossy().into_owned())) + .collect(); + if !defaults.is_empty() { + out.insert(field::DEFAULT_VALUES.to_owned(), Value::from(defaults)); + } + + if let Some(help) = arg.get_help() { + out.insert(field::HELP.to_owned(), Value::from(help.to_string())); + } + if let Some(long_help) = arg.get_long_help() { + let long_help = long_help.to_string(); + if Some(long_help.as_str()) != arg.get_help().map(ToString::to_string).as_deref() { + out.insert(field::LONG_HELP.to_owned(), Value::from(long_help)); + } + } + + out +} + +/// Whether the argument consumes a value (`--library `) rather than being a flag +/// (`--force`). +/// +/// Read from the action rather than from `num_args`, which the derive leaves unset unless +/// a `#[arg(num_args = …)]` says otherwise. `ArgAction` is `#[non_exhaustive]`, so a new +/// value-taking action would arrive here as `false` — visible as a missing placeholder on +/// the reference page, never as a wrong one. +fn takes_value(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Set | ArgAction::Append) +} + +/// Whether the argument may be given more than once (`--pick --pick `). +/// +/// `Count` is unreachable on today's surface — no argument in this CLI is a `-vvv`-style +/// counter — and is matched anyway because it is the other action that means "give this +/// again", and omitting it would make the first counting flag document itself as single-use. +fn is_repeatable(arg: &Arg) -> bool { + matches!(arg.get_action(), ArgAction::Append | ArgAction::Count) + || arg + .get_num_args() + .is_some_and(|range| range.max_values() > 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn subcommand<'a>(parent: &'a Value, name: &str) -> &'a Value { + parent + .get("subcommands") + .and_then(Value::as_array) + .expect("the command has subcommands") + .iter() + .find(|entry| entry.get("name").and_then(Value::as_str) == Some(name)) + .unwrap_or_else(|| panic!("subcommand `{name}` is described")) + } + + fn arg<'a>(command: &'a Value, id: &str) -> &'a Value { + command + .get("args") + .and_then(Value::as_array) + .expect("the command has arguments") + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(id)) + .unwrap_or_else(|| panic!("argument `{id}` is described")) + } + + /// The property the committed artifact and its `--check` gate rest on. A tree that + /// varied between calls would fail `cli-surface-check` on a tree nobody changed. + #[test] + fn the_tree_is_byte_identical_across_calls() { + let first = serde_json::to_string_pretty(&command_tree()).expect("the tree serializes"); + let second = serde_json::to_string_pretty(&command_tree()).expect("the tree serializes"); + assert_eq!(first, second); + } + + #[test] + fn the_tree_carries_its_schema_version() { + assert_eq!( + command_tree().get("schema").and_then(Value::as_u64), + Some(u64::from(COMMAND_TREE_SCHEMA)) + ); + } + + /// No terminal escape may reach a committed file: it would make the artifact depend on + /// whether the emitting shell claimed colour support. + #[test] + fn the_tree_carries_no_terminal_escapes() { + let json = serde_json::to_string(&command_tree()).expect("the tree serializes"); + assert!( + !json.contains('\u{1b}'), + "an ANSI escape reached the description artifact" + ); + } + + #[test] + fn subcommands_are_sorted_by_name() { + let tree = command_tree(); + let names: Vec<&str> = tree + .get("subcommands") + .and_then(Value::as_array) + .expect("the root has subcommands") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted); + assert!(names.contains(&"auth")); + assert!(names.contains(&"import")); + } + + /// Arguments keep declaration order, which for a positional is its position. Sorting + /// them would silently reorder `capsule library init --name`. + #[test] + fn arguments_keep_declaration_order_so_positionals_stay_in_position() { + let tree = command_tree(); + let init = subcommand(subcommand(&tree, "library"), "init"); + let ids: Vec<&str> = init + .get("args") + .and_then(Value::as_array) + .expect("`library init` has arguments") + .iter() + .filter_map(|entry| entry.get("id").and_then(Value::as_str)) + .collect(); + assert_eq!(ids, vec!["path", "name"]); + assert_eq!( + arg(init, "path").get("positional"), + Some(&Value::from(true)) + ); + assert_eq!( + arg(init, "name").get("positional"), + Some(&Value::from(false)) + ); + assert_eq!( + arg(init, "name").get("default_values"), + Some(&Value::from(vec![Value::from("My Library")])) + ); + } + + #[test] + fn a_flag_is_distinguished_from_a_value_taking_option() { + let tree = command_tree(); + let import = subcommand(&tree, "import"); + + let library = arg(import, "library"); + assert_eq!(library.get("takes_value"), Some(&Value::from(true))); + assert_eq!(library.get("long"), Some(&Value::from("library"))); + assert_eq!( + library.get("value_names"), + Some(&Value::from(vec![Value::from("PATH")])) + ); + + let force = arg(import, "force"); + assert_eq!(force.get("takes_value"), Some(&Value::from(false))); + assert_eq!(force.get("repeatable"), Some(&Value::from(false))); + // The derive answers `value_name` and `possible_values` for a flag as well, with + // its own internals: `FORCE` as a placeholder nobody types, and the `true`/`false` + // its bool parser accepts. Describing either would invent a surface. + assert!(force.get("value_names").is_none()); + assert!(force.get("possible_values").is_none()); + } + + #[test] + fn a_repeatable_argument_is_marked_repeatable() { + let tree = command_tree(); + let paths = arg(subcommand(&tree, "import"), "paths"); + assert_eq!(paths.get("repeatable"), Some(&Value::from(true))); + assert_eq!(paths.get("required"), Some(&Value::from(true))); + + let pick = arg(subcommand(&tree, "cull"), "pick"); + assert_eq!(pick.get("repeatable"), Some(&Value::from(true))); + } + + #[test] + fn an_enumerated_value_carries_its_variants() { + let tree = command_tree(); + let provider = arg(subcommand(&tree, "import"), "provider"); + let names: Vec<&str> = provider + .get("possible_values") + .and_then(Value::as_array) + .expect("`--provider` enumerates its values") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(names, vec!["takeout"]); + + let filter = arg(subcommand(&tree, "cull"), "filter"); + let flags: Vec<&str> = filter + .get("possible_values") + .and_then(Value::as_array) + .expect("`--filter` enumerates its values") + .iter() + .filter_map(|entry| entry.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(flags, vec!["pick", "neutral", "reject"]); + } + + /// The property the drift gate rests on that no other test reaches: the artifact is + /// byte-compared, so if any string in it were negotiated from the environment, + /// `cli-surface-check` would pass or fail according to the developer's `LANG`. + /// + /// `LC_ALL` is what `crate::i18n::cli_bundle` reads first, and `tr-TR` is the locale + /// that breaks case-folding implementations, so between them they exercise both the + /// negotiation path and the classic locale-sensitivity trap. `nextest` runs each test in + /// its own process, which is what makes mutating the environment here safe. + #[test] + fn the_tree_is_identical_under_two_different_locales() { + // Captured and put back below. `nextest` gives each test its own process, so this + // cannot reach another test — but a leaked `LC_ALL=tr_TR` would still be visible to + // anything else in *this* process, and a test that changes global state and does not + // change it back is one a reader has to prove harmless every time they see it. + let saved: Vec<(&str, Option)> = ["LC_ALL", "LANG"] + .iter() + .map(|name| (*name, std::env::var(name).ok())) + .collect(); + + let render = |locale: &str| { + // SAFETY: single-threaded test body in a process nextest gives this test alone. + unsafe { + std::env::set_var("LC_ALL", locale); + std::env::set_var("LANG", locale); + } + serde_json::to_string_pretty(&command_tree()).expect("the tree serializes") + }; + let english = render("en_US.UTF-8"); + let turkish = render("tr_TR.UTF-8"); + let japanese = render("ja_JP.UTF-8"); + + for (name, value) in saved { + // SAFETY: as above. + unsafe { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + } + + assert_eq!(english, turkish); + assert_eq!(english, japanese); + // Guards against the whole comparison passing because every render was empty. + assert!(english.contains("\"name\": \"capsule\"")); + } + + /// Both branches of the `long_about`/`long_help` dedup: a distinct long form is carried, + /// an identical one is dropped rather than stored twice. + #[test] + fn a_long_form_is_carried_only_when_it_differs_from_the_short_one() { + let distinct = Command::new("x") + .about("Short.") + .long_about("Short.\n\nAnd more.") + .arg( + clap::Arg::new("a") + .long("a") + .help("Short help.") + .long_help("Short help.\n\nAnd more."), + ); + let described = describe_command(&distinct); + assert_eq!( + described.get(field::LONG_ABOUT).and_then(Value::as_str), + Some("Short.\n\nAnd more.") + ); + let arg_entry = &described + .get(field::ARGS) + .and_then(Value::as_array) + .expect("the command has arguments")[0]; + assert_eq!( + arg_entry.get(field::LONG_HELP).and_then(Value::as_str), + Some("Short help.\n\nAnd more.") + ); + + let same = Command::new("x").about("Short.").long_about("Short.").arg( + clap::Arg::new("a") + .long("a") + .help("Short help.") + .long_help("Short help."), + ); + let described = describe_command(&same); + assert!(described.get(field::LONG_ABOUT).is_none()); + assert!( + described + .get(field::ARGS) + .and_then(Value::as_array) + .expect("the command has arguments")[0] + .get(field::LONG_HELP) + .is_none() + ); + } + + /// `Command::build` is deliberately not called, so the artifact describes only what + /// this crate declares — `--help` is not repeated under every command. + #[test] + fn the_tree_omits_claps_synthesized_help_argument() { + let tree = command_tree(); + assert!(subcommand(&tree, "status").get("args").is_none()); + assert!( + !serde_json::to_string(&tree) + .expect("the tree serializes") + .contains("\"id\":\"help\"") + ); + } +} diff --git a/capsule-cli/src/cull.rs b/capsule-cli/src/cull.rs index 7c99093e..46fae135 100644 --- a/capsule-cli/src/cull.rs +++ b/capsule-cli/src/cull.rs @@ -22,7 +22,7 @@ //! SSoT: [Organization — Culling](https://docs/design/organization/#culling). use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::CullFlag; +use capsule_core::sidecar::CullFlag; use capsule_i18n::Bundle; use colored::Colorize as _; use thiserror::Error; diff --git a/capsule-cli/src/demo.rs b/capsule-cli/src/demo.rs index 58838c68..556bd791 100644 --- a/capsule-cli/src/demo.rs +++ b/capsule-cli/src/demo.rs @@ -12,7 +12,7 @@ use capsule_core::backup::{recover_seed, split_seed_2of3}; use capsule_core::crypto::primitives::Argon2Params; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::CullFlag; +use capsule_core::sidecar::CullFlag; use colored::*; use eyre::{Result, eyre}; diff --git a/capsule-cli/src/i18n.rs b/capsule-cli/src/i18n.rs index 8d925117..b3d66e11 100644 --- a/capsule-cli/src/i18n.rs +++ b/capsule-cli/src/i18n.rs @@ -10,10 +10,18 @@ //! operator telemetry and stays English. Commands migrated so far: the networked ones //! (`auth`, `sync`, `list`, `push` — `S-D5`), `cull` (`S-D16`), and `import` (`S-I5`); //! the rest are carried as visible debt in `locales/i18n-guard-allowlist.txt`. +//! +//! `--help` is user-facing too, and is rendered from the [`HELP_NAMESPACE`] keys by +//! [`crate::cli::help`] (`S-I8`) rather than from a key constant per string: its keys are +//! derived from the command tree, so a new subcommand's help is a catalog entry the +//! invariant test in that module demands, not a constant somebody has to remember to add. pub use capsule_i18n::{Bundle, Value, error_codes}; use capsule_i18n::{negotiate, supported_locales}; +/// The namespace `--help` text lives under; the key grammar is [`crate::cli::help`]'s. +pub const HELP_NAMESPACE: &str = "cli.help"; + /// Build the CLI's message bundle from the POSIX locale environment, falling back /// to the source locale. `LC_ALL` wins, then `LC_MESSAGES`, then `LANG`. pub fn cli_bundle() -> Bundle { @@ -96,4 +104,62 @@ pub mod keys { pub const CULL_FLAG_PICK: &str = "cli.cull.flag.pick"; pub const CULL_FLAG_NEUTRAL: &str = "cli.cull.flag.neutral"; pub const CULL_FLAG_REJECT: &str = "cli.cull.flag.reject"; + // `capsule show` — the signed-sidecar read surface (slice `S-B18`). One row key per + // field, each taking the rendered `{value}`; the `value.*` keys spell the composite and + // absent values that go into a row. + pub const SHOW_HEADER: &str = "cli.show.header"; + pub const SHOW_ALBUM: &str = "cli.show.album"; + pub const SHOW_CONTENT_TYPE: &str = "cli.show.content_type"; + pub const SHOW_HASH: &str = "cli.show.hash"; + pub const SHOW_DIMENSIONS: &str = "cli.show.dimensions"; + pub const SHOW_CAPTURED: &str = "cli.show.captured"; + pub const SHOW_IMPORTED: &str = "cli.show.imported"; + pub const SHOW_CAPTION: &str = "cli.show.caption"; + pub const SHOW_RATING: &str = "cli.show.rating"; + pub const SHOW_TAGS_USER: &str = "cli.show.tags_user"; + pub const SHOW_TAGS_AI: &str = "cli.show.tags_ai"; + pub const SHOW_GPS: &str = "cli.show.gps"; + pub const SHOW_CULL: &str = "cli.show.cull"; + pub const SHOW_HIDDEN: &str = "cli.show.hidden"; + pub const SHOW_IN_TRASH: &str = "cli.show.in_trash"; + pub const SHOW_STACK: &str = "cli.show.stack"; + pub const SHOW_LQIP: &str = "cli.show.lqip"; + pub const SHOW_PROVENANCE_RECORDS: &str = "cli.show.provenance_records"; + pub const SHOW_VALUE_UNSET: &str = "cli.show.value.unset"; + pub const SHOW_VALUE_YES: &str = "cli.show.value.yes"; + pub const SHOW_VALUE_NO: &str = "cli.show.value.no"; + pub const SHOW_VALUE_PRESENT: &str = "cli.show.value.present"; + pub const SHOW_VALUE_LIST_SEPARATOR: &str = "cli.show.value.list_separator"; + pub const SHOW_GPS_DATUM_WGS84: &str = "cli.show.gps_datum.wgs84"; + pub const SHOW_GPS_DATUM_GCJ02: &str = "cli.show.gps_datum.gcj02"; + pub const SHOW_VALUE_DIMENSIONS: &str = "cli.show.value.dimensions"; + pub const SHOW_VALUE_RATING: &str = "cli.show.value.rating"; + pub const SHOW_VALUE_GPS: &str = "cli.show.value.gps"; + pub const SHOW_VALUE_STACK: &str = "cli.show.value.stack"; + pub const SHOW_GPS_SOURCE_EXIF: &str = "cli.show.gps_source.exif"; + pub const SHOW_GPS_SOURCE_MANUAL: &str = "cli.show.gps_source.manual"; + pub const SHOW_GPS_SOURCE_DERIVED: &str = "cli.show.gps_source.derived"; + pub const SHOW_STACK_ROLE_PRIMARY: &str = "cli.show.stack_role.primary"; + pub const SHOW_STACK_ROLE_MEMBER: &str = "cli.show.stack_role.member"; + pub const SHOW_STACK_ROLE_PROXY: &str = "cli.show.stack_role.proxy"; + pub const SHOW_UNKNOWN_ASSET: &str = "cli.show.unknown_asset"; + pub const SHOW_AMBIGUOUS: &str = "cli.show.ambiguous"; + pub const SHOW_INVALID_SELECTOR: &str = "cli.show.invalid_selector"; + pub const SHOW_FAILED: &str = "cli.show.failed"; + // `capsule repair capture-time` — the capture-timestamp repair pass (slice `S-B17`). + pub const REPAIR_CAPTURE_TIME_CHECKED: &str = "cli.repair.capture_time.checked"; + pub const REPAIR_CAPTURE_TIME_DRY_RUN_NOTICE: &str = "cli.repair.capture_time.dry_run_notice"; + pub const REPAIR_CAPTURE_TIME_ROW: &str = "cli.repair.capture_time.row"; + pub const REPAIR_CAPTURE_TIME_ROW_UNPARSEABLE: &str = "cli.repair.capture_time.row_unparseable"; + pub const REPAIR_CAPTURE_TIME_UNREADABLE: &str = "cli.repair.capture_time.unreadable"; + pub const REPAIR_CAPTURE_TIME_CORRECTED: &str = "cli.repair.capture_time.corrected"; + pub const REPAIR_CAPTURE_TIME_LIMIT_NOTICE: &str = "cli.repair.capture_time.limit_notice"; + pub const REPAIR_CAPTURE_TIME_SUMMARY: &str = "cli.repair.capture_time.summary"; + pub const REPAIR_CAPTURE_TIME_NOTHING: &str = "cli.repair.capture_time.nothing"; + pub const REPAIR_CAPTURE_TIME_DRIFT_NOTICE: &str = "cli.repair.capture_time.drift_notice"; + pub const REPAIR_CAPTURE_TIME_FAILED_ASSET: &str = "cli.repair.capture_time.failed_asset"; + pub const REPAIR_CAPTURE_TIME_TRASHED: &str = "cli.repair.capture_time.trashed"; + pub const REPAIR_CAPTURE_TIME_LIMIT_REQUIRES_APPLY: &str = + "cli.repair.capture_time.limit_requires_apply"; + pub const REPAIR_FAILED: &str = "cli.repair.failed"; } diff --git a/capsule-cli/src/lib.rs b/capsule-cli/src/lib.rs index c38c8669..405165d5 100644 --- a/capsule-cli/src/lib.rs +++ b/capsule-cli/src/lib.rs @@ -12,18 +12,16 @@ use std::path::{Path, PathBuf}; use capitalize::Capitalize; use capsule_core::crypto::primitives::DeviceTier; use capsule_core::domain::ImportMode; -use capsule_core::import::scanner::scan as scan_files; -use capsule_core::import::upload::UploadPolicy; use capsule_core::import::{ CancellationToken, DefaultAlbumContext, ImportConfig, ImportOutcome, ImportProgressEvent, - ScanResult, SourceAdapter, SourceMetadataIndex, TakeoutAdapter, execute_with_source_metadata, - plan, + ScanResult, SourceAdapter, SourceMetadataIndex, TakeoutAdapter, UploadPolicy, + execute_with_source_metadata, plan, scan_paths as scan_files, }; use capsule_core::library::{Library, LibraryError, init_library, open_library, rebuild_index}; use capsule_core::lifecycle::Workspace; use capsule_core::metadata::FileMetadata; use capsule_sdk::net::ConnectionClass; -use cli::{AuthCommands, Cli, Commands, ImportProviderArg, LibraryCommands}; +use cli::{AuthCommands, Cli, Commands, ImportProviderArg, LibraryCommands, RepairCommands}; use colored::*; use dialoguer::{Confirm, Input, Password}; use eyre::{Result, eyre}; @@ -39,7 +37,9 @@ pub mod db; pub mod demo; pub mod i18n; pub mod remote; +pub mod repair; pub mod session; +pub mod show; pub mod status; pub mod syncstore; pub mod utils; @@ -168,8 +168,24 @@ fn read_import_source( } /// Parse the CLI arguments and dispatch the matching command. +/// +/// The parser is built from the derive and then localized (`S-I8`): every `--help` string is +/// resolved through the bundle negotiated from the process locale before a single argument is +/// read, so usage errors and help pages speak the user's language. A locale with no catalog +/// entry for a string falls back to the derive's English rather than printing a key. pub async fn run() -> Result<()> { - let cli = ::parse(); + let command = cli::help::localize( + ::command(), + &i18n::cli_bundle(), + ); + // Kept for error formatting: a derive/matches mismatch is reported the way clap itself + // reports one — with the (localized) command's usage — rather than as a bare message. + let mut for_errors = command.clone(); + let mut matches = command.get_matches(); + let cli = match ::from_arg_matches_mut(&mut matches) { + Ok(cli) => cli, + Err(error) => error.format(&mut for_errors).exit(), + }; tracing::trace!("Parsed CLI arguments: {:#?}", cli); dispatch(cli).await } @@ -496,6 +512,63 @@ async fn dispatch(cli: Cli) -> Result<()> { } } + // ── Show ────────────────────────────────────────────────────────── + Commands::Show { + asset, + library, + passphrase_stdin, + } => { + let bundle = i18n::cli_bundle(); + let ws = open_workspace(&library, passphrase_stdin)?; + let view = show::resolve(&ws, &asset).and_then(|id| { + show::collect(&ws, &id).ok_or_else(|| show::ShowError::UnknownAsset(id.to_string())) + }); + match view { + Ok(view) => print!("{}", show::render(&bundle, &view)), + Err(error) => { + let reason = show::describe_error(&bundle, &error); + return Err(eyre!( + "{}", + bundle.format(keys::SHOW_FAILED, &[("reason", Value::Str(&reason))]) + )); + } + } + } + + // ── Repair ──────────────────────────────────────────────────────── + Commands::Repair { command } => match command { + RepairCommands::CaptureTime { + library, + passphrase_stdin, + apply, + limit, + } => { + let bundle = i18n::cli_bundle(); + // `--limit` bounds what `--apply` writes; alone it would silently do nothing. + if limit.is_some() && !apply { + return Err(eyre!( + "{}", + bundle.format(keys::REPAIR_CAPTURE_TIME_LIMIT_REQUIRES_APPLY, &[]) + )); + } + let request = repair::RepairRequest { + apply, + limit: limit.map(|n| usize::try_from(n).unwrap_or(usize::MAX)), + }; + let mut ws = open_workspace(&library, passphrase_stdin)?; + match repair::run(&mut ws, request) { + Ok(summary) => print!("{}", repair::render(&bundle, request, &summary)), + Err(error) => { + let reason = repair::describe_error(&bundle, &error); + return Err(eyre!( + "{}", + bundle.format(keys::REPAIR_FAILED, &[("reason", Value::Str(&reason))]) + )); + } + } + } + }, + // ── Demo ────────────────────────────────────────────────────────── Commands::Demo { workdir, image } => { demo::run(workdir, image)?; diff --git a/capsule-cli/src/remote.rs b/capsule-cli/src/remote.rs index a180650a..89954e44 100644 --- a/capsule-cli/src/remote.rs +++ b/capsule-cli/src/remote.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; -use capsule_core::import::upload::UploadPolicy; +use capsule_core::import::UploadPolicy; use capsule_core::lifecycle::{LifecycleError, Workspace}; use capsule_sdk::albums::{AlbumClient, AlbumTransport}; use capsule_sdk::auth::{AuthClient, AuthError, LoginOutcome, Session}; @@ -53,7 +53,7 @@ pub struct RemoteConfig { pub protocol_version: String, } -/// The default server origin — one host, one port, matching `mise run serve-api`. +/// The default server origin — one host, one port, matching `mise run serve-memory`. pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:3000"; impl RemoteConfig { diff --git a/capsule-cli/src/repair.rs b/capsule-cli/src/repair.rs new file mode 100644 index 00000000..0a4296b6 --- /dev/null +++ b/capsule-cli/src/repair.rs @@ -0,0 +1,877 @@ +//! `capsule repair capture-time` — recover capture timestamps stamped with import time +//! (slice `S-B17`). +//! +//! Before `S-B16`, `extract_exif` could never parse a well-formed `DateTimeOriginal`, so every +//! import wrote its own clock as the asset's capture time — **inside the signed sidecar**, and +//! as the `media/{YYYY}/{YYYY-MM}` shard. Fixing the parser did not fix the data: the wrong +//! value is under signature, a rebuild reconstructs from those same sidecars and would +//! faithfully preserve it, and nothing else ever goes back to the original to ask. This pass +//! does. +//! +//! ## The rule +//! +//! For every managed asset, re-read the original's EXIF and resolve it exactly as the +//! importer does ([`resolve_timezone`] over [`extract_exif`]): +//! +//! - **No resolvable instant** — no EXIF, no `DateTimeOriginal`, or a floating one with +//! neither an `OffsetTimeOriginal` nor a fix to anchor it — is **skipped**. Nothing is +//! recoverable, and nothing is known to be broken: the importer would write the same value +//! today. (A floating time is not treated as UTC here for the same reason the importer does +//! not: that would be a guess signed as a fact.) +//! - **An instant equal to the recorded one** is skipped: the asset is correct. +//! - **An instant that differs** is *affected*. The comparison is against the sidecar's +//! capture timestamp only — never against import time — so an asset genuinely imported the +//! second it was taken is not reported as broken. +//! - **An original that cannot be read** is reported as unreadable, never as "no EXIF". +//! - **An asset in trash** is skipped and counted as its own category before its original is +//! read: appending an irreversible signed record to an asset the user has decided to +//! discard is not a repair, and a restored asset is picked up by the next run. +//! +//! By construction the pass is a no-op on a library imported after `S-B16`: that importer +//! already wrote the resolved instant wherever one existed, and where none existed this pass +//! skips. Assets whose capture time came from a Takeout record (`CaptureSource::Folded`) have +//! no EXIF instant of their own and are left alone. +//! +//! ## The write +//! +//! Dry run is the default; `--apply` issues one signed `metadata-update` per affected asset +//! through [`Workspace::set_capture_timestamp`], each an independent write — an interrupted +//! run leaves every completed asset correct and a re-run skips them, because their EXIF now +//! agrees. The media bundle is **not** relocated: the sidecar is authoritative for the date +//! and the month directory is only the shard fixed at import, which the design treats as +//! expected drift after a capture correction rather than as a fault. +//! +//! ## Not covered +//! +//! An asset whose capture time a user deliberately set to something other than its EXIF. +//! No such edit surface exists today — `set_capture_timestamp` is the first — so the +//! question does not arise; once one exists, the pass must skip assets whose chain carries a +//! capture correction that was not itself this repair. Recorded in `SLICES.md` under +//! `S-B17` rather than implemented against a surface that is not there. + +use std::path::Path; + +use capsule_core::exif::{extract_exif, resolve_timezone}; +use capsule_core::lifecycle::Workspace; +use capsule_i18n::Bundle; +use colored::Colorize as _; +use jiff::Timestamp; +use thiserror::Error; +use uuid::Uuid; + +use crate::i18n::{Value, keys}; + +/// One `capsule repair capture-time` invocation, independent of the argument parser. +#[derive(Debug, Clone, Copy, Default)] +pub struct RepairRequest { + /// Write the corrections. Without it the pass only reports. + pub apply: bool, + /// Correct at most this many affected assets in one `--apply` run. Detection still covers + /// the whole library; the dry-run report is unaffected. + pub limit: Option, +} + +/// What re-reading one original's EXIF says about its recorded capture time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + /// The recorded value disagrees with the instant recovered from EXIF. + Affected { + /// The sidecar's capture timestamp, parsed; `None` when it does not parse. + recorded: Option, + /// The instant the original's EXIF resolves to. + recovered: Timestamp, + }, + /// The recorded value equals the recovered instant. + Agrees, + /// The original carries no instant the importer could have resolved: nothing to recover. + NoInstant, + /// The original could not be read at all. + Unreadable(String), +} + +/// An affected asset: what the sidecar says, and what its original says. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Affected { + /// The asset. + pub asset_id: Uuid, + /// The sidecar's capture timestamp as written. + pub recorded_text: String, + /// The sidecar's capture timestamp, parsed; `None` when it does not parse. + pub recorded: Option, + /// The instant the original's EXIF resolves to. + pub recovered: Timestamp, +} + +impl Affected { + /// Seconds from the recorded instant to the recovered one, when the recorded one parses. + #[must_use] + pub fn delta_seconds(&self) -> Option { + self.recorded + .map(|recorded| self.recovered.as_second() - recorded.as_second()) + } +} + +/// The verdicts over a whole library, affected assets in ascending asset-id order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Detection { + /// How many assets were examined. + pub scanned: usize, + /// Assets whose recorded capture time disagrees with their EXIF. + pub affected: Vec, + /// Assets whose recorded capture time equals their EXIF instant. + pub agrees: usize, + /// Assets with no recoverable instant. + pub no_instant: usize, + /// Assets whose original could not be read, with the reason. + pub unreadable: Vec<(Uuid, String)>, + /// Assets in trash, skipped without reading their originals. + pub trashed: Vec, +} + +/// What one invocation did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RepairSummary { + /// The detection the run acted on. + pub detection: Detection, + /// Whether `--apply` was given. + pub applied: bool, + /// The assets corrected, in the order they were written. Empty on a dry run. + pub corrected: Vec, + /// Whether `--limit` stopped the run before every affected asset was corrected. + pub limit_reached: bool, +} + +/// Why a repair could not complete. +#[derive(Debug, Error)] +pub enum RepairError { + /// The lifecycle refused a correction. The run stops at the first refusal rather than + /// continuing over a library whose sealing may be broken; everything corrected before it + /// stays corrected. + #[error("correcting {asset_id}: {detail}")] + Lifecycle { + /// The asset the write was for. + asset_id: Uuid, + /// The lifecycle's own description. + detail: String, + }, +} + +/// Compare one asset's recorded capture timestamp against what its original's EXIF resolves +/// to, applying the importer's own resolution. +#[tracing::instrument(skip_all, fields(original = %original.display()))] +pub fn detect_one(recorded: &str, original: &Path) -> Verdict { + // Readability is checked before EXIF is asked for, because `extract_exif` reports "no + // EXIF container" as an empty extract and an unreadable file must not be confused with it. + if let Err(error) = std::fs::File::open(original) { + tracing::warn!(%error, "repair: original unreadable"); + return Verdict::Unreadable(error.to_string()); + } + let exif = match extract_exif(original) { + Ok(exif) => exif, + Err(error) => { + tracing::warn!(%error, "repair: original unreadable while extracting EXIF"); + return Verdict::Unreadable(error.to_string()); + } + }; + let Some(secs) = resolve_timezone(&exif).capture_utc else { + tracing::debug!("repair: no resolvable EXIF instant; nothing to recover"); + return Verdict::NoInstant; + }; + let Ok(recovered) = Timestamp::from_second(secs) else { + tracing::warn!( + secs, + "repair: EXIF instant out of range; treated as unrecoverable" + ); + return Verdict::NoInstant; + }; + let parsed = recorded.parse::().ok(); + if parsed.is_some_and(|recorded| recorded.as_second() == secs) { + return Verdict::Agrees; + } + tracing::debug!(recorded, %recovered, "repair: recorded capture time disagrees with EXIF"); + Verdict::Affected { + recorded: parsed, + recovered, + } +} + +/// Examine every managed asset of `ws`. +#[tracing::instrument(skip_all, fields(assets = ws.asset_ids().len()))] +pub fn detect(ws: &Workspace) -> Detection { + let mut ids = ws.asset_ids(); + ids.sort_unstable(); + let mut detection = Detection::default(); + for id in ids { + let Some(asset) = ws.asset(&id) else { + continue; + }; + let Some(original) = ws.original_path(&id) else { + continue; + }; + detection.scanned += 1; + if ws.is_trashed(&id) { + tracing::debug!(asset_id = %id, "repair: asset in trash; skipped"); + detection.trashed.push(id); + continue; + } + let recorded_text = asset.sidecar.capture_timestamp.clone(); + match detect_one(&recorded_text, &original) { + Verdict::Affected { + recorded, + recovered, + } => detection.affected.push(Affected { + asset_id: id, + recorded_text, + recorded, + recovered, + }), + Verdict::Agrees => detection.agrees += 1, + Verdict::NoInstant => detection.no_instant += 1, + Verdict::Unreadable(reason) => detection.unreadable.push((id, reason)), + } + } + tracing::info!( + scanned = detection.scanned, + affected = detection.affected.len(), + agrees = detection.agrees, + no_instant = detection.no_instant, + unreadable = detection.unreadable.len(), + trashed = detection.trashed.len(), + "repair: capture-time detection complete" + ); + detection +} + +/// Correct the affected assets, at most `limit` of them, each as its own signed write. +#[tracing::instrument(skip_all, fields(affected = affected.len(), limit))] +pub fn apply( + ws: &mut Workspace, + affected: &[Affected], + limit: Option, +) -> Result, RepairError> { + let budget = limit.unwrap_or(affected.len()); + let mut corrected = Vec::new(); + for item in affected.iter().take(budget) { + ws.set_capture_timestamp(&item.asset_id, item.recovered) + .map_err(|error| RepairError::Lifecycle { + asset_id: item.asset_id, + detail: error.to_string(), + })?; + corrected.push(item.asset_id); + } + Ok(corrected) +} + +/// Detect, then (with `--apply`) correct. +pub fn run(ws: &mut Workspace, request: RepairRequest) -> Result { + let detection = detect(ws); + let mut summary = RepairSummary { + applied: request.apply, + ..Default::default() + }; + if request.apply { + summary.corrected = apply(ws, &detection.affected, request.limit)?; + summary.limit_reached = summary.corrected.len() < detection.affected.len(); + } + summary.detection = detection; + Ok(summary) +} + +/// Localize a [`RepairError`] for the failure line. +pub fn describe_error(bundle: &Bundle, error: &RepairError) -> String { + match error { + RepairError::Lifecycle { asset_id, detail } => bundle.format( + keys::REPAIR_CAPTURE_TIME_FAILED_ASSET, + &[ + ("asset_id", Value::Str(&asset_id.to_string())), + ("reason", Value::Str(detail)), + ], + ), + } +} + +/// Render the run as the lines `capsule repair capture-time` prints. +#[must_use] +pub fn render(bundle: &Bundle, request: RepairRequest, summary: &RepairSummary) -> String { + let detection = &summary.detection; + let mut out = String::new(); + let mut line = |text: String| { + out.push_str(&text); + out.push('\n'); + }; + + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_CHECKED, + &[("count", Value::Int(detection.scanned as i64))], + ) + .cyan() + .to_string(), + ); + if !request.apply { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_DRY_RUN_NOTICE, &[]) + .yellow() + .to_string(), + ); + } + + for item in &detection.affected { + let recovered = item.recovered.to_string(); + let asset_id = item.asset_id.to_string(); + line(match item.delta_seconds() { + Some(delta) => bundle.format( + keys::REPAIR_CAPTURE_TIME_ROW, + &[ + ("asset_id", Value::Str(&asset_id)), + ("recorded", Value::Str(&item.recorded_text)), + ("recovered", Value::Str(&recovered)), + ("delta", Value::Int(delta)), + ], + ), + None => bundle.format( + keys::REPAIR_CAPTURE_TIME_ROW_UNPARSEABLE, + &[ + ("asset_id", Value::Str(&asset_id)), + ("recorded", Value::Str(&item.recorded_text)), + ("recovered", Value::Str(&recovered)), + ], + ), + }); + } + for (asset_id, reason) in &detection.unreadable { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_UNREADABLE, + &[ + ("asset_id", Value::Str(&asset_id.to_string())), + ("reason", Value::Str(reason)), + ], + ) + .red() + .to_string(), + ); + } + for asset_id in &summary.corrected { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_CORRECTED, + &[("asset_id", Value::Str(&asset_id.to_string()))], + ) + .green() + .to_string(), + ); + } + if summary.limit_reached { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_LIMIT_NOTICE, + &[("corrected", Value::Int(summary.corrected.len() as i64))], + ) + .yellow() + .to_string(), + ); + } + + if !detection.trashed.is_empty() { + line( + bundle + .format( + keys::REPAIR_CAPTURE_TIME_TRASHED, + &[("count", Value::Int(detection.trashed.len() as i64))], + ) + .yellow() + .to_string(), + ); + } + + if detection.affected.is_empty() && detection.unreadable.is_empty() { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_NOTHING, &[]) + .green() + .to_string(), + ); + } else { + line(bundle.format( + keys::REPAIR_CAPTURE_TIME_SUMMARY, + &[ + ("affected", Value::Int(detection.affected.len() as i64)), + ("corrected", Value::Int(summary.corrected.len() as i64)), + ("agrees", Value::Int(detection.agrees as i64)), + ("no_instant", Value::Int(detection.no_instant as i64)), + ("unreadable", Value::Int(detection.unreadable.len() as i64)), + ("trashed", Value::Int(detection.trashed.len() as i64)), + ], + )); + } + if !summary.corrected.is_empty() { + line( + bundle + .format(keys::REPAIR_CAPTURE_TIME_DRIFT_NOTICE, &[]) + .dimmed() + .to_string(), + ); + } + out +} + +#[cfg(test)] +mod tests { + use capsule_core::crypto::primitives::Argon2Params; + use capsule_core::crypto::verify_asset::VerifyOutcome; + + use super::*; + + const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + + /// The instant `exif_jpeg` carries: 2019-03-04 05:06:07 at +00:00. + const EXIF_SECS: i64 = 1_551_675_967; + + /// A JPEG container holding one EXIF APP1 segment with `DateTimeOriginal` and, when + /// `with_offset`, `OffsetTimeOriginal` +00:00 — the pair the importer resolves to an + /// instant. Without the offset the time is floating, which the importer (and so this + /// pass) does not resolve. + fn exif_jpeg(with_offset: bool, salt: &[u8]) -> Vec { + const DTO: &[u8] = b"2019:03:04 05:06:07\0"; + const OTO: &[u8] = b"+00:00\0"; + let entries: u32 = if with_offset { 2 } else { 1 }; + let ifd0_at: u32 = 8; + let exif_ifd_at = ifd0_at + 2 + 12 + 4; + let data_at = exif_ifd_at + 2 + entries * 12 + 4; + let dto_at = data_at; + let oto_at = dto_at + DTO.len() as u32; + + fn entry(tiff: &mut Vec, tag: u16, kind: u16, count: u32, value: [u8; 4]) { + tiff.extend_from_slice(&tag.to_be_bytes()); + tiff.extend_from_slice(&kind.to_be_bytes()); + tiff.extend_from_slice(&count.to_be_bytes()); + tiff.extend_from_slice(&value); + } + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM"); + tiff.extend_from_slice(&42u16.to_be_bytes()); + tiff.extend_from_slice(&ifd0_at.to_be_bytes()); + tiff.extend_from_slice(&1u16.to_be_bytes()); + entry(&mut tiff, 0x8769, 4, 1, exif_ifd_at.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + tiff.extend_from_slice(&(entries as u16).to_be_bytes()); + entry(&mut tiff, 0x9003, 2, DTO.len() as u32, dto_at.to_be_bytes()); + if with_offset { + entry(&mut tiff, 0x9011, 2, OTO.len() as u32, oto_at.to_be_bytes()); + } + tiff.extend_from_slice(&0u32.to_be_bytes()); + assert_eq!(tiff.len() as u32, data_at); + tiff.extend_from_slice(DTO); + if with_offset { + tiff.extend_from_slice(OTO); + } + + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE1]; + jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(&app1); + jpeg.extend_from_slice(&[0xFF, 0xFE]); + jpeg.extend_from_slice(&((salt.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(salt); + jpeg.extend_from_slice(&[0xFF, 0xD9]); + jpeg + } + + struct Scratch(std::path::PathBuf); + + impl Scratch { + fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("capsule-cli-repair-{}", nanoid::nanoid!())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + Self(dir) + } + + fn file(&self, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = self.0.join(name); + std::fs::write(&path, bytes).expect("fixture file"); + path + } + + fn workspace(&self) -> Workspace { + let lib = self.0.join("lib"); + std::fs::create_dir_all(&lib).expect("library dir"); + let mut ws = + Workspace::create_with_params(&lib, b"pw", FAST_KDF).expect("create workspace"); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports") + .expect("create album"); + ws + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).expect("in range") + } + + // ── detect_one ─────────────────────────────────────────────────────────── + + #[test] + fn an_agreeing_timestamp_is_not_affected() { + let scratch = Scratch::new(); + let original = scratch.file("a.jpg", &exif_jpeg(true, b"a")); + assert_eq!( + detect_one(&ts(EXIF_SECS).to_string(), &original), + Verdict::Agrees + ); + } + + #[test] + fn a_recorded_import_time_is_affected_with_the_exif_instant_recovered() { + let scratch = Scratch::new(); + let original = scratch.file("a.jpg", &exif_jpeg(true, b"a")); + let now = ts(Timestamp::now().as_second()); + assert_eq!( + detect_one(&now.to_string(), &original), + Verdict::Affected { + recorded: Some(now), + recovered: ts(EXIF_SECS), + } + ); + // An unparseable record is affected too, with nothing to compute a delta from. + assert_eq!( + detect_one("not a timestamp", &original), + Verdict::Affected { + recorded: None, + recovered: ts(EXIF_SECS), + } + ); + } + + /// The importer's own rule: a floating `DateTimeOriginal` resolves to no instant, so the + /// pass has nothing to compare and must not guess UTC. + #[test] + fn a_floating_exif_time_or_no_exif_is_no_instant_not_affected() { + let scratch = Scratch::new(); + let floating = scratch.file("floating.jpg", &exif_jpeg(false, b"f")); + assert_eq!( + detect_one(&Timestamp::now().to_string(), &floating), + Verdict::NoInstant + ); + let plain = scratch.file("plain.jpg", b"\xFF\xD8\xFF no exif at all"); + assert_eq!( + detect_one(&Timestamp::now().to_string(), &plain), + Verdict::NoInstant + ); + } + + #[test] + fn a_missing_original_is_unreadable_not_no_instant() { + let scratch = Scratch::new(); + let missing = scratch.0.join("gone.jpg"); + assert!(matches!( + detect_one("2019-03-04T05:06:07Z", &missing), + Verdict::Unreadable(_) + )); + } + + // ── detect / apply over a workspace ────────────────────────────────────── + + /// The `S-B17` acceptance case, in-process: a library whose sidecars carry the wrong + /// instant reports every affected asset, corrects them under `--apply` as one signed + /// revision each, and a second pass finds nothing — while a post-`S-B16` import is + /// untouched from the start. + #[test] + fn detect_and_apply_correct_only_the_assets_that_disagree_with_their_exif() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let good = ws + .import_asset(album, &scratch.file("good.jpg", &exif_jpeg(true, b"good"))) + .expect("import"); + let broken = ws + .import_asset( + album, + &scratch.file("broken.jpg", &exif_jpeg(true, b"broken")), + ) + .expect("import"); + let floating = ws + .import_asset( + album, + &scratch.file("floating.jpg", &exif_jpeg(false, b"floating")), + ) + .expect("import"); + + // A post-S-B16 import already carries the EXIF instant: a no-op by construction. + assert_eq!( + ws.asset(&good).expect("asset").sidecar.capture_timestamp, + ts(EXIF_SECS).to_string() + ); + let clean = detect(&ws); + assert_eq!(clean.scanned, 3); + assert!(clean.affected.is_empty(), "{clean:?}"); + assert_eq!((clean.agrees, clean.no_instant), (2, 1)); + + // Reproduce the pre-S-B16 state on one asset: its sidecar says "now". + let wrong = Timestamp::now(); + ws.set_capture_timestamp(&broken, wrong).expect("stamp"); + + let detection = detect(&ws); + assert_eq!(detection.affected.len(), 1); + let item = &detection.affected[0]; + assert_eq!(item.asset_id, broken); + assert_eq!(item.recovered, ts(EXIF_SECS)); + assert_eq!(item.recorded_text, wrong.to_string()); + assert_eq!( + item.delta_seconds(), + Some(EXIF_SECS - wrong.as_second()), + "the delta is recovered minus recorded" + ); + + // A dry run writes nothing. + let dry = run(&mut ws, RepairRequest::default()).expect("dry run"); + assert!(!dry.applied); + assert!(dry.corrected.is_empty()); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 2); + + // `--apply` corrects it as a third signed record, and only it. + let applied = run( + &mut ws, + RepairRequest { + apply: true, + limit: None, + }, + ) + .expect("apply"); + assert_eq!(applied.corrected, vec![broken]); + assert!(!applied.limit_reached); + let fixed = ws.asset(&broken).expect("asset"); + assert_eq!(fixed.sidecar.capture_timestamp, ts(EXIF_SECS).to_string()); + assert_eq!(fixed.chain.records().len(), 3); + assert_eq!(ws.verify(&broken).expect("verify"), VerifyOutcome::Accept); + assert_eq!(ws.asset(&good).expect("asset").chain.records().len(), 1); + assert_eq!(ws.asset(&floating).expect("asset").chain.records().len(), 1); + + // Idempotent: a second pass has nothing left to do. + let again = detect(&ws); + assert!(again.affected.is_empty(), "{again:?}"); + assert_eq!((again.agrees, again.no_instant), (2, 1)); + } + + #[test] + fn a_limit_stops_after_that_many_corrections_and_says_so() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let ids: Vec = (0..3) + .map(|n| { + let file = scratch.file( + &format!("p{n}.jpg"), + &exif_jpeg(true, format!("asset {n}").as_bytes()), + ); + ws.import_asset(album, &file).expect("import") + }) + .collect(); + for id in &ids { + ws.set_capture_timestamp(id, Timestamp::now()) + .expect("stamp"); + } + + let first = run( + &mut ws, + RepairRequest { + apply: true, + limit: Some(2), + }, + ) + .expect("apply"); + assert_eq!(first.corrected.len(), 2); + assert!(first.limit_reached); + assert_eq!( + first.detection.affected.len(), + 3, + "detection is not limited" + ); + + let second = run( + &mut ws, + RepairRequest { + apply: true, + limit: Some(2), + }, + ) + .expect("apply"); + assert_eq!(second.corrected.len(), 1, "the one the limit left over"); + assert!(!second.limit_reached); + assert!(detect(&ws).affected.is_empty()); + } + + /// A trashed asset is neither corrected nor counted as affected, whatever its sidecar + /// says; it is its own category, and a restore brings it back into the next run. + #[test] + fn a_trashed_asset_is_skipped_and_counted_not_corrected() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let trashed = ws + .import_asset(album, &scratch.file("t.jpg", &exif_jpeg(true, b"trashed"))) + .expect("import"); + ws.set_capture_timestamp(&trashed, Timestamp::now()) + .expect("stamp"); + ws.soft_delete(&trashed, 30).expect("trash"); + let records = ws.asset(&trashed).expect("asset").chain.records().len(); + + let summary = run( + &mut ws, + RepairRequest { + apply: true, + limit: None, + }, + ) + .expect("apply"); + assert!(summary.detection.affected.is_empty(), "{summary:?}"); + assert_eq!(summary.detection.trashed, vec![trashed]); + assert_eq!(summary.detection.scanned, 1); + assert!(summary.corrected.is_empty()); + assert_eq!( + ws.asset(&trashed).expect("asset").chain.records().len(), + records, + "nothing was appended to a trashed asset's chain" + ); + + ws.restore(&trashed).expect("restore"); + let detection = detect(&ws); + assert_eq!( + detection.affected.len(), + 1, + "restored, it is affected again" + ); + assert!(detection.trashed.is_empty()); + } + + /// The tripwire: `apply` writes exactly `Affected::recovered` and nothing else. A + /// hand-built `Affected` naming an arbitrary instant lands as that instant, so any change + /// that made `apply` read a different field would fail here, and the fixture's EXIF + /// equality in the tests above shows the instant `detect` supplies is the EXIF one. + #[test] + fn apply_writes_exactly_the_recovered_instant() { + let scratch = Scratch::new(); + let mut ws = scratch.workspace(); + let album = ws.default_album_id(); + let id = ws + .import_asset(album, &scratch.file("a.jpg", &exif_jpeg(true, b"a"))) + .expect("import"); + let arbitrary = ts(1_234_567_890); + let corrected = apply( + &mut ws, + &[Affected { + asset_id: id, + recorded_text: "irrelevant".into(), + recorded: None, + recovered: arbitrary, + }], + None, + ) + .expect("apply"); + assert_eq!(corrected, vec![id]); + assert_eq!( + ws.asset(&id).expect("asset").sidecar.capture_timestamp, + arbitrary.to_string() + ); + // And the detect → apply path lands the EXIF instant itself, not merely "a change". + let affected = detect(&ws).affected; + apply(&mut ws, &affected, None).expect("apply"); + assert_eq!( + ws.asset(&id).expect("asset").sidecar.capture_timestamp, + ts(EXIF_SECS).to_string() + ); + } + + // ── render ─────────────────────────────────────────────────────────────── + + fn sample_summary(apply: bool, corrected: bool) -> RepairSummary { + let asset_id = Uuid::nil(); + RepairSummary { + detection: Detection { + scanned: 4, + affected: vec![Affected { + asset_id, + recorded_text: "2026-09-02T10:00:00Z".into(), + recorded: Some(ts(1_788_688_800)), + recovered: ts(EXIF_SECS), + }], + agrees: 2, + no_instant: 1, + unreadable: vec![], + trashed: vec![], + }, + applied: apply, + corrected: if corrected { vec![asset_id] } else { vec![] }, + limit_reached: false, + } + } + + #[test] + fn a_dry_run_page_reports_the_affected_asset_and_says_nothing_was_written() { + let bundle = Bundle::for_locale("en"); + let page = render( + &bundle, + RepairRequest::default(), + &sample_summary(false, false), + ); + assert!(page.contains("Dry run"), "{page}"); + assert!(page.contains("--apply"), "{page}"); + assert!(page.contains("2026-09-02T10:00:00Z"), "{page}"); + assert!(page.contains("2019-03-04T05:06:07Z"), "{page}"); + assert!( + page.contains("1 affected, 0 corrected, 2 already correct, 1 without"), + "{page}" + ); + assert!(!page.contains("cli.repair."), "no raw key leaks:\n{page}"); + } + + #[test] + fn an_apply_page_names_the_corrected_asset_and_the_expected_drift() { + let bundle = Bundle::for_locale("en"); + let request = RepairRequest { + apply: true, + limit: None, + }; + let page = render(&bundle, request, &sample_summary(true, true)); + assert!(!page.contains("Dry run"), "{page}"); + assert!(page.contains(&Uuid::nil().to_string()), "{page}"); + assert!(page.contains("1 affected, 1 corrected"), "{page}"); + let drift = bundle.format(keys::REPAIR_CAPTURE_TIME_DRIFT_NOTICE, &[]); + assert!(page.contains(&drift), "{page}"); + } + + #[test] + fn a_clean_library_reports_nothing_to_repair() { + let bundle = Bundle::for_locale("en"); + let summary = RepairSummary { + detection: Detection { + scanned: 2, + agrees: 2, + ..Default::default() + }, + ..Default::default() + }; + let page = render(&bundle, RepairRequest::default(), &summary); + let nothing = bundle.format(keys::REPAIR_CAPTURE_TIME_NOTHING, &[]); + assert!(page.contains(¬hing), "{page}"); + assert!(!page.contains("affected,"), "{page}"); + } +} diff --git a/capsule-cli/src/show.rs b/capsule-cli/src/show.rs new file mode 100644 index 00000000..d4ebf2c8 --- /dev/null +++ b/capsule-cli/src/show.rs @@ -0,0 +1,734 @@ +//! `capsule show` — what an imported asset's signed sidecar actually records (slice `S-B18`). +//! +//! The importer folds a third-party export's caption, favourite, album membership, capture +//! time and GPS into each asset's **signed** sidecar (`S-B10`), and every one of those mappings +//! is lossy by design: a favourite becomes five stars, an album becomes a tag. Nothing in the +//! CLI printed the result back, so a user could not check a mapping they might disagree with +//! until correcting it cost a signed `metadata-update` per asset — and the migration guide had +//! to say so instead of instructing the check. This verb is that check. +//! +//! It reads the sidecar and the provenance chain off [`Workspace::asset`] and writes nothing of +//! its own; the only write on its path is the default-album resolution every library verb +//! shares when it opens the workspace. No index is queried. The shape mirrors `cull.rs` — a +//! selector → [`resolve`] → [`collect`] into a plain [`AssetView`] → [`render`] through the +//! catalog — so the projection and the rendering are unit-testable without a `clap` round trip +//! or a spawned binary. +//! +//! ## Naming an asset +//! +//! Nothing `capsule import` prints is an asset id, but a user following the migration guide +//! already holds the **source hashes** from its spot-hash step, and Capsule imports bytes +//! unchanged — so the sidecar's `hash` is the source file's SHA-256. The positional therefore +//! accepts either an asset id or a hex prefix of that hash (at least [`MIN_HASH_PREFIX`] +//! characters); a prefix matching several assets is refused with the count rather than +//! guessed at. There is no source-filename arm because the sidecar stores no filename. +//! +//! Every absent field is printed as *unset* rather than omitted: an absent caption is a fact +//! the user came to verify, not a row to hide. A fix is printed with its datum, because a +//! GCJ-02 coordinate is stored verbatim and would otherwise read as WGS-84. + +use capsule_core::domain::GpsDatum; +use capsule_core::lifecycle::{AssetState, Workspace}; +use capsule_core::sidecar::{CullFlag, GpsSource, StackRole}; +use capsule_i18n::Bundle; +use colored::Colorize as _; +use thiserror::Error; +use uuid::Uuid; + +use crate::i18n::{Value, keys}; + +/// The shortest hash prefix the selector accepts. Eight hex digits is 32 bits — ample against +/// a personal library, and short enough to type from a `shasum` listing. +pub const MIN_HASH_PREFIX: usize = 8; + +/// Why a selector did not resolve to exactly one asset. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ShowError { + /// Neither an asset id nor a hash prefix matched anything in the library. + /// + /// The `Display` strings on this enum are developer-facing (the selector and the count, + /// nothing else); the user sees [`describe_error`], which goes through the catalog. + #[error("unknown: {0}")] + UnknownAsset(String), + /// A hash prefix matched more than one asset. Refused rather than guessed: printing the + /// wrong asset's metadata under a selector the user believes is unique would defeat the + /// verification this command exists for. + #[error("ambiguous: {selector} ({count})")] + Ambiguous { + /// The prefix as given. + selector: String, + /// How many assets share it. + count: usize, + }, + /// The selector is neither a UUID nor a long-enough hex prefix. + #[error("invalid: {0}")] + InvalidSelector(String), +} + +/// A location fix as the sidecar stores it: coordinates in their own datum, plus where the +/// fix came from. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Fix { + /// Latitude, in `datum`. + pub lat: f64, + /// Longitude, in `datum`. + pub lon: f64, + /// Provenance of the fix. + pub source: GpsSource, + /// The datum the coordinates are expressed in — stored verbatim, never converted. + pub datum: GpsDatum, +} + +/// The sidecar projection `capsule show` prints — a plain value so the rendering can be +/// tested against a hand-built one and the collection against a real [`AssetState`]. +#[derive(Debug, Clone, PartialEq)] +pub struct AssetView { + /// The asset id. + pub asset_id: Uuid, + /// The owning album. + pub album_id: Uuid, + /// The closed content-type string the importer derived from the extension. + pub content_type: String, + /// The plaintext SHA-256, lowercase hex — the same digest `shasum -a 256` prints for the + /// source file. + pub hash: String, + /// Pixel dimensions, when EXIF carried them. + pub dimensions: Option<(u32, u32)>, + /// The signed capture instant, RFC 3339. + pub capture_timestamp: String, + /// The signed import instant, RFC 3339. + pub import_timestamp: String, + /// The caption register's current value. + pub caption: Option, + /// The rating register's current value (0–5). + pub rating: Option, + /// User tags, sorted. + pub tags_user: Vec, + /// AI tag texts, sorted and deduplicated across model versions. + pub tags_ai: Vec, + /// The fix, when any. + pub gps: Option, + /// The culling flag (a never-written register reads as `Neutral`). + pub cull: CullFlag, + /// Whether the asset is hidden from default views. + pub hidden: bool, + /// Whether the asset is currently in trash — [`Workspace::is_trashed`], the chain replay + /// the workspace itself applies. + pub in_trash: bool, + /// Stack placement, when the asset is a stack member: `(stack_id, role)`. + pub stack: Option<(Uuid, StackRole)>, + /// Whether a display placeholder (LQIP) is stored. + pub lqip: bool, + /// How many signed records the provenance chain holds — the `create` plus every + /// lifecycle write since (metadata edits, repairs, derivative writes, trash moves). + pub provenance_records: usize, +} + +/// Resolve `selector` — an asset id, or a hex prefix of an asset's content hash — to exactly +/// one asset of `ws`. +#[tracing::instrument(skip(ws))] +pub fn resolve(ws: &Workspace, selector: &str) -> Result { + let candidates = ws + .asset_ids() + .into_iter() + .filter_map(|id| ws.asset(&id).map(|asset| (id, asset.sidecar.hash.to_hex()))); + resolve_among(candidates, selector) +} + +/// [`resolve`] over an explicit `(asset id, content hash hex)` listing, so the arms are +/// testable against hand-built candidates. +/// +/// A UUID is tried first; if no candidate carries that id and the text also qualifies as a +/// hash prefix (a 32-hex-digit hash prefix parses as a bare UUID), the prefix arm runs before +/// the id is reported unknown. +pub fn resolve_among( + candidates: impl IntoIterator, + selector: &str, +) -> Result { + let selector = selector.trim(); + let as_id = Uuid::parse_str(selector).ok(); + let prefix = selector.to_ascii_lowercase(); + let as_prefix = + prefix.len() >= MIN_HASH_PREFIX && prefix.bytes().all(|b| b.is_ascii_hexdigit()); + + let mut by_prefix: Vec = Vec::new(); + for (id, hash) in candidates { + if as_id == Some(id) { + tracing::debug!(asset_id = %id, "show: selector resolved as an asset id"); + return Ok(id); + } + if as_prefix && hash.starts_with(&prefix) { + by_prefix.push(id); + } + } + + if !as_prefix { + return Err(if as_id.is_some() { + ShowError::UnknownAsset(selector.to_owned()) + } else { + ShowError::InvalidSelector(selector.to_owned()) + }); + } + tracing::debug!( + prefix = %prefix, + matches = by_prefix.len(), + "show: selector matched as a hash prefix" + ); + match by_prefix.as_slice() { + [] => Err(ShowError::UnknownAsset(selector.to_owned())), + [id] => Ok(*id), + many => Err(ShowError::Ambiguous { + selector: selector.to_owned(), + count: many.len(), + }), + } +} + +/// Project a managed asset's in-memory state onto the view. Reads the signed sidecar and the +/// provenance chain only; `None` for an id the workspace does not manage. +#[must_use] +pub fn collect(ws: &Workspace, asset_id: &Uuid) -> Option { + let asset: &AssetState = ws.asset(asset_id)?; + let sidecar = &asset.sidecar; + let mut tags_user: Vec = sidecar.tags_user.value().into_iter().collect(); + tags_user.sort(); + let mut tags_ai: Vec = sidecar + .tags_ai + .value() + .into_iter() + .map(|tag| tag.tag) + .collect(); + tags_ai.sort(); + tags_ai.dedup(); + + Some(AssetView { + asset_id: asset.asset_id, + album_id: asset.album_id, + content_type: sidecar.content_type.clone(), + hash: sidecar.hash.to_hex(), + dimensions: sidecar.dimensions.as_ref().map(|d| (d.width, d.height)), + capture_timestamp: sidecar.capture_timestamp.clone(), + import_timestamp: sidecar.import_timestamp.clone(), + caption: sidecar.caption.get().cloned(), + rating: sidecar.rating.get().copied(), + tags_user, + tags_ai, + gps: sidecar.gps.as_ref().map(|g| Fix { + lat: g.lat, + lon: g.lon, + source: g.source, + datum: g.datum, + }), + cull: sidecar.cull.get().copied().unwrap_or_default(), + hidden: sidecar.hidden.get().copied().unwrap_or(false), + in_trash: ws.is_trashed(asset_id), + stack: sidecar + .stack_membership + .get() + .and_then(Option::as_ref) + .map(|m| (m.stack_id, m.role)), + lqip: sidecar.lqip.is_some(), + provenance_records: asset.chain.records().len(), + }) +} + +/// Localize a [`ShowError`] for the failure line. +pub fn describe_error(bundle: &Bundle, error: &ShowError) -> String { + match error { + ShowError::UnknownAsset(selector) => bundle.format( + keys::SHOW_UNKNOWN_ASSET, + &[("selector", Value::Str(selector))], + ), + ShowError::Ambiguous { selector, count } => bundle.format( + keys::SHOW_AMBIGUOUS, + &[ + ("selector", Value::Str(selector)), + ("count", Value::Int(*count as i64)), + ], + ), + ShowError::InvalidSelector(selector) => bundle.format( + keys::SHOW_INVALID_SELECTOR, + &[ + ("selector", Value::Str(selector)), + ("min", Value::Int(MIN_HASH_PREFIX as i64)), + ], + ), + } +} + +const fn gps_source_key(source: GpsSource) -> &'static str { + match source { + GpsSource::Exif => keys::SHOW_GPS_SOURCE_EXIF, + GpsSource::Manual => keys::SHOW_GPS_SOURCE_MANUAL, + GpsSource::Derived => keys::SHOW_GPS_SOURCE_DERIVED, + } +} + +const fn gps_datum_key(datum: GpsDatum) -> &'static str { + match datum { + GpsDatum::Wgs84 => keys::SHOW_GPS_DATUM_WGS84, + GpsDatum::Gcj02 => keys::SHOW_GPS_DATUM_GCJ02, + } +} + +const fn stack_role_key(role: StackRole) -> &'static str { + match role { + StackRole::Primary => keys::SHOW_STACK_ROLE_PRIMARY, + StackRole::Member => keys::SHOW_STACK_ROLE_MEMBER, + StackRole::Proxy => keys::SHOW_STACK_ROLE_PROXY, + } +} + +const fn cull_key(flag: CullFlag) -> &'static str { + match flag { + CullFlag::Pick => keys::CULL_FLAG_PICK, + CullFlag::Neutral => keys::CULL_FLAG_NEUTRAL, + CullFlag::Reject => keys::CULL_FLAG_REJECT, + } +} + +/// Render the view as the lines `capsule show` prints, one catalog message per line, every +/// absent value spelled out as unset. +#[must_use] +pub fn render(bundle: &Bundle, view: &AssetView) -> String { + let unset = bundle.format(keys::SHOW_VALUE_UNSET, &[]); + let separator = bundle.format(keys::SHOW_VALUE_LIST_SEPARATOR, &[]); + let yes_no = |flag: bool| { + bundle.format( + if flag { + keys::SHOW_VALUE_YES + } else { + keys::SHOW_VALUE_NO + }, + &[], + ) + }; + let or_unset = |value: Option| value.unwrap_or_else(|| unset.clone()); + let list = |items: &[String]| { + if items.is_empty() { + unset.clone() + } else { + items.join(&separator) + } + }; + + let dimensions = or_unset(view.dimensions.map(|(width, height)| { + bundle.format( + keys::SHOW_VALUE_DIMENSIONS, + &[ + ("width", Value::Int(i64::from(width))), + ("height", Value::Int(i64::from(height))), + ], + ) + })); + let rating = or_unset(view.rating.map(|stars| { + bundle.format( + keys::SHOW_VALUE_RATING, + &[("stars", Value::Int(i64::from(stars)))], + ) + })); + let gps = or_unset(view.gps.map(|fix| { + let source = bundle.format(gps_source_key(fix.source), &[]); + let datum = bundle.format(gps_datum_key(fix.datum), &[]); + bundle.format( + keys::SHOW_VALUE_GPS, + &[ + ("lat", Value::Str(&format!("{:.6}", fix.lat))), + ("lon", Value::Str(&format!("{:.6}", fix.lon))), + ("datum", Value::Str(&datum)), + ("source", Value::Str(&source)), + ], + ) + })); + let stack = or_unset(view.stack.map(|(stack_id, role)| { + let role = bundle.format(stack_role_key(role), &[]); + bundle.format( + keys::SHOW_VALUE_STACK, + &[ + ("stack_id", Value::Str(&stack_id.to_string())), + ("role", Value::Str(&role)), + ], + ) + })); + let lqip = if view.lqip { + bundle.format(keys::SHOW_VALUE_PRESENT, &[]) + } else { + unset.clone() + }; + let cull = bundle.format(cull_key(view.cull), &[]); + + let header = bundle.format( + keys::SHOW_HEADER, + &[("asset_id", Value::Str(&view.asset_id.to_string()))], + ); + let rows: [(&str, String); 17] = [ + (keys::SHOW_ALBUM, view.album_id.to_string()), + (keys::SHOW_CONTENT_TYPE, view.content_type.clone()), + (keys::SHOW_HASH, view.hash.clone()), + (keys::SHOW_DIMENSIONS, dimensions), + (keys::SHOW_CAPTURED, view.capture_timestamp.clone()), + (keys::SHOW_IMPORTED, view.import_timestamp.clone()), + (keys::SHOW_CAPTION, or_unset(view.caption.clone())), + (keys::SHOW_RATING, rating), + (keys::SHOW_TAGS_USER, list(&view.tags_user)), + (keys::SHOW_TAGS_AI, list(&view.tags_ai)), + (keys::SHOW_GPS, gps), + (keys::SHOW_CULL, cull), + (keys::SHOW_HIDDEN, yes_no(view.hidden)), + (keys::SHOW_IN_TRASH, yes_no(view.in_trash)), + (keys::SHOW_STACK, stack), + (keys::SHOW_LQIP, lqip), + ( + keys::SHOW_PROVENANCE_RECORDS, + view.provenance_records.to_string(), + ), + ]; + + let mut out = format!("{}\n", header.green()); + for (key, value) in rows { + out.push_str(&bundle.format(key, &[("value", Value::Str(&value))])); + out.push('\n'); + } + out +} + +#[cfg(test)] +mod tests { + use capsule_core::crypto::primitives::Argon2Params; + + use super::*; + + const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + + /// A fast-cost workspace with `count` imported assets of distinct bytes, in a scratch + /// directory that is removed on drop. + struct Fixture { + dir: std::path::PathBuf, + ws: Workspace, + ids: Vec, + } + + impl Fixture { + fn with_assets(count: usize) -> Self { + let dir = std::env::temp_dir().join(format!("capsule-cli-show-{}", nanoid::nanoid!())); + let lib = dir.join("lib"); + std::fs::create_dir_all(&lib).expect("scratch library dir"); + let mut ws = + Workspace::create_with_params(&lib, b"pw", FAST_KDF).expect("create workspace"); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports") + .expect("create album"); + let ids = (0..count) + .map(|n| { + let src = dir.join(format!("photo-{n}.jpg")); + let mut bytes = b"\xFF\xD8\xFF".to_vec(); + bytes.extend_from_slice(format!(" asset {n}").as_bytes()); + std::fs::write(&src, bytes).expect("fixture file"); + ws.import_asset(album, &src).expect("import") + }) + .collect(); + Self { dir, ws, ids } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + fn bundle() -> Bundle { + Bundle::for_locale("en") + } + + /// Three candidates whose hashes share a 10-character prefix, so ambiguity is a fact + /// about the listing rather than a lottery over real digests. + fn candidates() -> Vec<(Uuid, String)> { + let ids = [Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()]; + vec![ + (ids[0], format!("0123456789{}", "a".repeat(54))), + (ids[1], format!("0123456789{}", "b".repeat(54))), + (ids[2], format!("fedcba9876{}", "c".repeat(54))), + ] + } + + #[test] + fn an_asset_id_resolves_to_itself_in_any_spelling() { + let list = candidates(); + let id = list[1].0; + assert_eq!(resolve_among(list.clone(), &id.to_string()), Ok(id)); + assert_eq!( + resolve_among(list.clone(), &format!(" {} ", id.simple())), + Ok(id), + "the simple (hyphen-less) spelling and surrounding whitespace are tolerated" + ); + assert_eq!( + resolve_among(list, &id.to_string().to_ascii_uppercase()), + Ok(id) + ); + } + + #[test] + fn a_hash_prefix_resolves_to_the_one_candidate_carrying_it() { + let list = candidates(); + assert_eq!(resolve_among(list.clone(), "0123456789a"), Ok(list[0].0)); + assert_eq!( + resolve_among(list.clone(), "0123456789B"), + Ok(list[1].0), + "case-folded" + ); + assert_eq!(resolve_among(list.clone(), "fedcba98"), Ok(list[2].0)); + let (expected, full) = list[2].clone(); + assert_eq!(resolve_among(list, &full), Ok(expected), "the whole hash"); + } + + #[test] + fn an_ambiguous_prefix_is_refused_with_the_count() { + let list = candidates(); + assert_eq!( + resolve_among(list, "01234567"), + Err(ShowError::Ambiguous { + selector: "01234567".into(), + count: 2 + }) + ); + } + + #[test] + fn a_short_or_non_hex_selector_is_invalid_and_a_missing_one_is_unknown() { + let list = candidates(); + assert_eq!( + resolve_among(list.clone(), "abc"), + Err(ShowError::InvalidSelector("abc".into())) + ); + assert_eq!( + resolve_among(list.clone(), "not-a-hash-or-id"), + Err(ShowError::InvalidSelector("not-a-hash-or-id".into())) + ); + assert_eq!( + resolve_among(list.clone(), "deadbeef00"), + Err(ShowError::UnknownAsset("deadbeef00".into())) + ); + let ghost = Uuid::now_v7(); + assert_eq!( + resolve_among(list, &ghost.to_string()), + Err(ShowError::UnknownAsset(ghost.to_string())) + ); + } + + /// A 32-hex-digit hash prefix parses as a bare UUID; it must still reach the prefix arm. + #[test] + fn a_thirty_two_digit_hash_prefix_is_not_mistaken_for_an_unknown_id() { + let list = candidates(); + let prefix = &list[2].1[..32]; + assert!( + Uuid::parse_str(prefix).is_ok(), + "the premise: it parses as a UUID" + ); + assert_eq!(resolve_among(list.clone(), prefix), Ok(list[2].0)); + } + + #[test] + fn resolve_reads_the_workspaces_sidecar_hashes() { + let fx = Fixture::with_assets(2); + for id in &fx.ids { + let hex = fx.ws.asset(id).expect("asset").sidecar.hash.to_hex(); + assert_eq!(resolve(&fx.ws, &hex[..MIN_HASH_PREFIX]), Ok(*id)); + assert_eq!(resolve(&fx.ws, &id.to_string()), Ok(*id)); + } + } + + #[test] + fn collect_projects_the_signed_sidecar_and_the_chain() { + let fx = Fixture::with_assets(1); + let id = fx.ids[0]; + let asset = fx.ws.asset(&id).expect("asset"); + let view = collect(&fx.ws, &id).expect("managed"); + assert_eq!(view.asset_id, id); + assert_eq!(view.album_id, fx.ws.default_album_id()); + assert_eq!(view.content_type, asset.sidecar.content_type); + assert_eq!(view.hash, asset.sidecar.hash.to_hex()); + assert_eq!(view.capture_timestamp, asset.sidecar.capture_timestamp); + assert_eq!(view.caption, None); + assert_eq!(view.rating, None); + assert!(view.tags_user.is_empty()); + assert_eq!(view.cull, CullFlag::Neutral); + assert!(!view.hidden); + assert!(!view.in_trash); + assert_eq!(view.stack, None); + assert_eq!( + collect(&fx.ws, &Uuid::now_v7()), + None, + "an unknown id has no view" + ); + assert!(!view.lqip); + assert_eq!(view.provenance_records, asset.chain.records().len()); + } + + #[test] + fn collect_reflects_metadata_edits() { + let mut fx = Fixture::with_assets(1); + let id = fx.ids[0]; + let before = collect(&fx.ws, &id).expect("managed").provenance_records; + fx.ws.set_caption(&id, "On the beach").expect("caption"); + fx.ws.tag_add(&id, "Vacation 2021").expect("tag"); + fx.ws.set_cull(&id, CullFlag::Pick).expect("cull"); + let view = collect(&fx.ws, &id).expect("managed"); + assert_eq!(view.caption.as_deref(), Some("On the beach")); + assert_eq!(view.tags_user, vec!["Vacation 2021".to_string()]); + assert_eq!(view.cull, CullFlag::Pick); + assert_eq!( + view.provenance_records, + before + 3, + "three signed metadata-updates on top of the create" + ); + } + + fn sample_view() -> AssetView { + AssetView { + asset_id: Uuid::nil(), + album_id: Uuid::max(), + content_type: "image/jpeg".into(), + hash: "ab".repeat(32), + dimensions: Some((8, 8)), + capture_timestamp: "2019-03-04T05:06:07Z".into(), + import_timestamp: "2026-09-02T00:00:00Z".into(), + caption: Some("On the beach".into()), + rating: Some(5), + tags_user: vec!["Vacation 2021".into(), "beach".into()], + tags_ai: vec![], + gps: Some(Fix { + lat: 10.0, + lon: 20.0, + source: GpsSource::Exif, + datum: GpsDatum::Wgs84, + }), + cull: CullFlag::Neutral, + hidden: false, + in_trash: false, + stack: None, + lqip: false, + provenance_records: 1, + } + } + + /// Every field the guide asks a user to verify is on the page, and every absent value is + /// spelled out rather than omitted. + #[test] + fn render_prints_every_field_and_spells_out_absent_values() { + let bundle = bundle(); + let page = render(&bundle, &sample_view()); + for expected in [ + "00000000-0000-0000-0000-000000000000", + "image/jpeg", + &"ab".repeat(32), + "8×8", + "2019-03-04T05:06:07Z", + "On the beach", + "5/5", + "Vacation 2021, beach", + "10.000000, 20.000000 (WGS-84, EXIF)", + "neutral", + ] { + assert!(page.contains(expected), "missing {expected:?} in:\n{page}"); + } + let unset = bundle.format(keys::SHOW_VALUE_UNSET, &[]); + assert_ne!(unset, keys::SHOW_VALUE_UNSET, "the key is in the catalog"); + // AI tags, stack and LQIP are absent in the sample: three unset rows. + assert_eq!(page.matches(&unset).count(), 3, "{page}"); + assert_eq!( + page.lines().count(), + 18, + "a header plus seventeen rows:\n{page}" + ); + assert!(!page.contains("cli.show."), "no raw key leaks:\n{page}"); + } + + #[test] + fn render_names_a_stack_placement_a_gcj02_manual_fix_and_the_flags() { + let bundle = bundle(); + let stack_id = Uuid::now_v7(); + let view = AssetView { + gps: Some(Fix { + lat: 39.9, + lon: 116.4, + source: GpsSource::Manual, + datum: GpsDatum::Gcj02, + }), + stack: Some((stack_id, StackRole::Primary)), + hidden: true, + in_trash: true, + lqip: true, + dimensions: None, + caption: None, + ..sample_view() + }; + let page = render(&bundle, &view); + assert!( + page.contains("39.900000, 116.400000 (GCJ-02, manual)"), + "a non-default datum is named, never silently read as WGS-84:\n{page}" + ); + assert!(page.contains(&format!("{stack_id} (primary)")), "{page}"); + let yes = bundle.format(keys::SHOW_VALUE_YES, &[]); + assert_eq!( + page.matches(&yes).count(), + 2, + "hidden and in trash:\n{page}" + ); + let present = bundle.format(keys::SHOW_VALUE_PRESENT, &[]); + assert!(page.contains(&present), "{page}"); + } + + #[test] + fn describe_error_localizes_each_variant_with_its_detail() { + let bundle = bundle(); + let ambiguous = describe_error( + &bundle, + &ShowError::Ambiguous { + selector: "abcdef01".into(), + count: 3, + }, + ); + assert!( + ambiguous.contains("abcdef01") && ambiguous.contains('3'), + "{ambiguous}" + ); + let invalid = describe_error(&bundle, &ShowError::InvalidSelector("zz".into())); + assert!( + invalid.contains("zz") && invalid.contains(&MIN_HASH_PREFIX.to_string()), + "{invalid}" + ); + let unknown = describe_error(&bundle, &ShowError::UnknownAsset("deadbeef".into())); + assert!(unknown.contains("deadbeef"), "{unknown}"); + for text in [&ambiguous, &invalid, &unknown] { + assert!(!text.contains("cli.show."), "raw key leaked: {text}"); + } + } + + /// A swept asset prints the row the catalog already described: the trash fact comes from + /// `Workspace::is_trashed`, the one place the chain is replayed. + #[test] + fn a_swept_asset_shows_in_trash() { + let mut fx = Fixture::with_assets(1); + let id = fx.ids[0]; + fx.ws.set_cull(&id, CullFlag::Reject).expect("cull"); + let swept = fx.ws.reject_sweep(30).expect("sweep"); + assert_eq!(swept, vec![id]); + let view = collect(&fx.ws, &id).expect("a trashed asset is still managed"); + assert!(view.in_trash); + let bundle = bundle(); + let page = render(&bundle, &view); + let row = bundle.format( + keys::SHOW_IN_TRASH, + &[( + "value", + Value::Str(&bundle.format(keys::SHOW_VALUE_YES, &[])), + )], + ); + assert!(page.contains(&row), "{page}"); + } +} diff --git a/capsule-cli/src/status.rs b/capsule-cli/src/status.rs index e4d263a8..4f46d8e8 100644 --- a/capsule-cli/src/status.rs +++ b/capsule-cli/src/status.rs @@ -236,12 +236,21 @@ impl ServerStatus { // exactly the base the generated operation paths hang off. let api_endpoint = remote.sync_endpoint.clone(); - let client = match capsule_sdk::rest::Client::new(&api_endpoint) { + // Over the SDK's one HTTP client rather than the generated `Client::new`, so the probe + // carries the same protocol handshake every other request does; `/v1/version` is + // exempt from the gate, and a probe that spoke differently from the calls it precedes + // would tell the user nothing about them. + let client = match capsule_sdk::net::http_client() + .map_err(|error| error.to_string()) + .and_then(|http| { + capsule_sdk::rest::Client::with_client(http, &api_endpoint) + .map_err(|error| error.to_string()) + }) { Ok(client) => client, Err(error) => { return Ok(ServerStatus { api_endpoint, - connection_status: ConnectionStatus::Error(error.to_string()), + connection_status: ConnectionStatus::Error(error), api_version: None, response_time: None, server_health: None, diff --git a/capsule-cli/tests/import_round_trip.rs b/capsule-cli/tests/import_round_trip.rs index 576c0fd8..968678bd 100644 --- a/capsule-cli/tests/import_round_trip.rs +++ b/capsule-cli/tests/import_round_trip.rs @@ -16,13 +16,18 @@ //! by [`capsule_list_reports_the_sync_feed_not_the_library`] rather than papered over. //! //! **The fixture image.** A synthesized 8×8 baseline JPEG (see [`synthetic_jpeg`]) carrying a -//! real EXIF APP1 segment — no committed binary. The CLI links `capsule-core` **without** the -//! `media` feature, so nothing on this path decodes pixels (every still reports -//! `DerivativeStatus::DeferredNoCodec`, slice `S-B13`); the decode the importer genuinely -//! performs is EXIF, through `extract_exif`. The assertions therefore land on values that can -//! only have come from parsing the segment: the sidecar's 8×8 dimensions and its GPS fix. The -//! bytes are a real, decodable JPEG all the same, so the fixture stays honest if a build that -//! *does* carry a codec ever runs this path. +//! real EXIF APP1 segment — no committed binary. The CLI links `capsule-core` with default +//! features, and `native` implies `media`, so this path **does** decode pixels: the import +//! reports `DerivativeStatus::Decoded`, writes a chromahash `lqip` into the signed sidecar, and +//! signs a thumbnail-tier derivative (slices `S-B1`, `S-B13`, `S-B14`). At 8×8 the source is +//! well inside the 256 px thumbnail cap, so that derivative is the signed `format = "original"` +//! sentinel rather than a re-encode — which is exactly the contract's redundant-derivative rule +//! and worth pinning end to end. +//! +//! The EXIF assertions are still the load-bearing ones, because neither the GPS fix nor the +//! capture time exists anywhere but inside the APP1 segment `synthetic_jpeg` wrote. The 8×8 +//! dimensions now come from decoded pixels *and* agree with the EXIF tags, which is the +//! stronger statement: the two independent readings of the fixture match. //! //! **Argon2id.** `capsule library init` does not create the account — the first `capsule import` //! does, at `DeviceTier::Normal` (256 MiB, t=3), which costs ~5 s per unlock in a debug build and @@ -43,7 +48,7 @@ use capsule_core::crypto::provenance::action::Action; use capsule_core::db::AssetRow; use capsule_core::library::open_library; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; +use capsule_core::sidecar::{SIDECAR_SCHEMA_V1, SidecarV1}; use jiff::Timestamp; use uuid::Uuid; @@ -319,6 +324,30 @@ fn an_import_is_reconstructed_by_a_later_process_from_disk_alone() { "the stored original must be the bytes that were imported" ); + // ── The signed derivatives, in `media/{YYYY}/{YYYY-MM}/derivatives/`. ── + // + // The fixture is 8×8, well inside the thumbnail tier's 256 px cap, so the tier is satisfied + // by the signed `format = "original"` sentinel — the contract's redundant-derivative rule. + // A sentinel *references* the original, so what lands is its signed manifest and no bytes: + // copying them would put this fixture's EXIF, GPS fix included, into a derivative blob. + let derivatives = bucket.join("derivatives"); + let bundle = derivatives.join(format!("{simple}.derivatives.cbor")); + assert!( + bundle.is_file(), + "a signed derivative-manifest bundle must exist in {}", + derivatives.display() + ); + let derivative_files: Vec = std::fs::read_dir(&derivatives) + .expect("read the derivatives directory") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + derivative_files, + vec![format!("{simple}.derivatives.cbor")], + "the sentinel writes its manifest and no derivative bytes" + ); + // ── The signed sidecar, decoded from disk. ── let bytes = std::fs::read(bucket.join(format!("{simple}.cbor"))).expect("read the sidecar"); let sidecar = @@ -340,8 +369,21 @@ fn an_import_is_reconstructed_by_a_later_process_from_disk_alone() { let dimensions = sidecar .dimensions .as_ref() - .expect("dimensions come from the EXIF PixelXDimension/PixelYDimension tags"); + .expect("dimensions come from the decoded pixels, and agree with the EXIF tags"); assert_eq!((dimensions.width, dimensions.height), (8, 8)); + + // The LQIP producer ran on the decoded pixels: a 32-byte chromahash payload at + // `LQIP_FORMAT_V1`, inside the *signed* sidecar (slice `S-B14`). + let lqip = sidecar + .lqip + .as_ref() + .expect("a decodable still carries a chromahash placeholder"); + assert_eq!( + lqip.chromahash.len(), + 32, + "chromahash's DEFAULT_TIER is exactly 32 bytes" + ); + assert_eq!(lqip.format_version, 1, "the sidecar LQIP format version"); let gps = sidecar.gps.as_ref().expect("the EXIF GPS fix"); assert!( (gps.lat - EXIF_LAT).abs() < 1e-6 && (gps.lon - EXIF_LON).abs() < 1e-6, diff --git a/capsule-cli/tests/show_and_repair.rs b/capsule-cli/tests/show_and_repair.rs new file mode 100644 index 00000000..ee857aa5 --- /dev/null +++ b/capsule-cli/tests/show_and_repair.rs @@ -0,0 +1,586 @@ +//! Slices `S-B18` (`capsule show`) and `S-B17` (`capsule repair capture-time`) across a real +//! process boundary, by spawning the `capsule` binary the way `import_round_trip.rs` and +//! `takeout_import.rs` do — and for the same reason: `show` proves what a library *reopened +//! from disk* says about an asset, and a repair that only looked right inside the process +//! that wrote it would prove nothing. +//! +//! **The fixture image** is a synthesized JPEG carrying a real EXIF APP1 segment with +//! `DateTimeOriginal` **and** `OffsetTimeOriginal`, so its capture time resolves to a fixed +//! UTC instant — the case the importer writes as the capture timestamp since `S-B16`, and +//! therefore the case the repair can recover. The Argon2id seeding is the fast-cost trick +//! `import_round_trip.rs` explains; nothing here weakens what the spawned processes run. +//! +//! **The pre-`S-B16` library** the repair exists for is reproduced by importing under the +//! fixed parser and then stamping one asset's sidecar with the import clock through the very +//! API the repair uses (`Workspace::set_capture_timestamp`). That reproduces the half the +//! repair reads — a signed sidecar carrying `now` under a still-EXIF-bearing original — and it +//! is the only way to produce it from a build whose importer no longer has the bug. It does +//! **not** reproduce the other half of the old shape: the bug also sharded the bundle into the +//! import month, whereas here the shard is the EXIF month, so the post-repair +//! directory-vs-timestamp drift and `Workspace::open`'s reconciliation of it are exercised by +//! `capsule-core`'s `lifecycle::metadata` unit test (a no-EXIF import corrected to 2001), not +//! here. +//! +//! ## Test list +//! +//! - `show_prints_the_signed_sidecar_by_hash_prefix_or_id` — the guide's sampling step: +//! a hash prefix from `shasum` and the asset id both resolve, and the page carries the +//! EXIF-derived values. +//! - `show_refuses_a_malformed_or_unknown_selector` — non-zero exit with a localized reason. +//! - `repair_is_a_no_op_on_a_library_imported_after_the_parser_fix` — the `S-B17` +//! "no-op post-`S-B16`" criterion. +//! - `repair_reports_by_default_and_corrects_under_apply` — detects the reproduced bug, +//! writes nothing without `--apply`, corrects with it, keeps the bundle in its original +//! month directory, survives `capsule library rebuild`, and finds nothing on a second run. +//! - `repair_limit_is_a_positive_count_that_needs_apply` — `--limit 0` is rejected by the +//! parser; `--limit` without `--apply` is refused naming both flags. +//! - `show_reports_a_swept_asset_as_in_trash` — the trash row over a real reject sweep. + +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use capsule_core::crypto::hash; +use capsule_core::crypto::primitives::Argon2Params; +use capsule_core::lifecycle::Workspace; +use uuid::Uuid; + +const PASSPHRASE: &str = "show-and-repair-passphrase"; + +const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, +}; + +/// The EXIF capture instant baked into [`exif_jpeg`], as the sidecar renders it. +const EXIF_CAPTURE: &str = "2019-03-04T05:06:07Z"; + +// ── Scratch plumbing ───────────────────────────────────────────────────────── + +struct ScratchDir(PathBuf); + +impl ScratchDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("capsule-cli-s-b17-{}", nanoid::nanoid!())); + std::fs::create_dir_all(&path).expect("create scratch dir"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn spawn(home: &Path, args: &[&str], passphrase_stdin: bool) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_capsule")) + .args(args) + .env("NO_COLOR", "1") + .env("RUST_LOG", "off") + .env("LC_ALL", "en_US.UTF-8") + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_CACHE_HOME", home.join("cache")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn capsule"); + if passphrase_stdin { + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(format!("{PASSPHRASE}\n").as_bytes()) + .expect("write passphrase"); + } + child.wait_with_output().expect("wait for capsule") +} + +/// The stdout of one successful `capsule` run. +fn capsule(home: &Path, args: &[&str], passphrase_stdin: bool) -> String { + let out = spawn(home, args, passphrase_stdin); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + assert!( + out.status.success(), + "capsule {args:?} failed\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + stdout +} + +/// The combined output of one **failing** `capsule` run. +fn capsule_fails(home: &Path, args: &[&str], passphrase_stdin: bool) -> String { + let out = spawn(home, args, passphrase_stdin); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !out.status.success(), + "capsule {args:?} was expected to fail\noutput:\n{text}" + ); + text +} + +fn path(p: &Path) -> &str { + p.to_str().expect("scratch paths are UTF-8") +} + +// ── The fixture image ──────────────────────────────────────────────────────── + +/// A JPEG container holding one EXIF APP1 segment: `DateTimeOriginal` 2019-03-04 05:06:07 +/// with `OffsetTimeOriginal` +00:00, and 8×8 pixel dimensions. `salt` is appended after the +/// segment so several files share the EXIF and differ in bytes (and therefore in hash). +fn exif_jpeg(salt: &[u8]) -> Vec { + const DTO: &[u8] = b"2019:03:04 05:06:07\0"; + const OTO: &[u8] = b"+00:00\0"; + const IFD0_AT: u32 = 8; + // IFD0 holds one entry (the Exif pointer): 2 + 12 + 4 bytes. + const EXIF_IFD_AT: u32 = IFD0_AT + 2 + 12 + 4; + // The Exif IFD holds four entries. + const DATA_AT: u32 = EXIF_IFD_AT + 2 + 4 * 12 + 4; + const DTO_AT: u32 = DATA_AT; + const OTO_AT: u32 = DTO_AT + DTO.len() as u32; + + fn entry(tiff: &mut Vec, tag: u16, kind: u16, count: u32, value: [u8; 4]) { + tiff.extend_from_slice(&tag.to_be_bytes()); + tiff.extend_from_slice(&kind.to_be_bytes()); + tiff.extend_from_slice(&count.to_be_bytes()); + tiff.extend_from_slice(&value); + } + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM"); + tiff.extend_from_slice(&42u16.to_be_bytes()); + tiff.extend_from_slice(&IFD0_AT.to_be_bytes()); + + tiff.extend_from_slice(&1u16.to_be_bytes()); + entry(&mut tiff, 0x8769, 4, 1, EXIF_IFD_AT.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + tiff.extend_from_slice(&4u16.to_be_bytes()); + entry(&mut tiff, 0x9003, 2, DTO.len() as u32, DTO_AT.to_be_bytes()); + entry(&mut tiff, 0x9011, 2, OTO.len() as u32, OTO_AT.to_be_bytes()); + entry(&mut tiff, 0xA002, 4, 1, 8u32.to_be_bytes()); + entry(&mut tiff, 0xA003, 4, 1, 8u32.to_be_bytes()); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + assert_eq!( + tiff.len() as u32, + DATA_AT, + "the IFDs end where the data starts" + ); + tiff.extend_from_slice(DTO); + tiff.extend_from_slice(OTO); + + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + let mut jpeg = vec![0xFF, 0xD8]; + jpeg.extend_from_slice(&[0xFF, 0xE1]); + jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(&app1); + // A COM segment carrying the salt keeps the container well-formed while making each + // fixture's bytes (and hash) distinct. + jpeg.extend_from_slice(&[0xFF, 0xFE]); + jpeg.extend_from_slice(&((salt.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(salt); + jpeg.extend_from_slice(&[0xFF, 0xD9]); + jpeg +} + +// ── The fixture ────────────────────────────────────────────────────────────── + +struct Fixture { + _scratch: ScratchDir, + home: PathBuf, + library: PathBuf, + /// The bytes of every imported fixture image, in source order. + images: Vec>, +} + +impl Fixture { + fn run(&self, args: &[&str]) -> String { + capsule(&self.home, args, true) + } + + fn run_fails(&self, args: &[&str]) -> String { + capsule_fails(&self.home, args, true) + } + + /// `capsule show --library `. + fn show(&self, selector: &str) -> String { + self.run(&[ + "show", + selector, + "--library", + path(&self.library), + "--passphrase-stdin", + ]) + } + + fn hash_hex(&self, image: &[u8]) -> String { + hash::hash_bytes(image).to_hex() + } + + /// The library reopened in this process, after every spawned `capsule` has exited. + fn reopen(&self) -> Workspace { + Workspace::open(&self.library, PASSPHRASE.as_bytes(), FAST_KDF).expect("reopen") + } + + /// The asset holding exactly `image`. + fn asset_for(&self, ws: &Workspace, image: &[u8]) -> Uuid { + ws.asset_ids() + .into_iter() + .find(|id| ws.read_plaintext(id).is_ok_and(|p| p == image)) + .expect("an imported asset holding these bytes") + } +} + +/// `capsule library init` + a fast-cost account + `capsule import` of `count` EXIF JPEGs. +fn fixture(count: usize) -> Fixture { + let scratch = ScratchDir::new(); + let home = scratch.path().join("home"); + let library = scratch.path().join("library"); + let source = scratch.path().join("source"); + std::fs::create_dir_all(&home).expect("create scratch home"); + std::fs::create_dir_all(&source).expect("create source dir"); + let images: Vec> = (0..count) + .map(|n| { + let image = exif_jpeg(format!("fixture {n}").as_bytes()); + std::fs::write(source.join(format!("photo-{n}.jpg")), &image).expect("fixture"); + image + }) + .collect(); + + let out = capsule( + &home, + &["library", "init", path(&library), "--name", "Repair"], + false, + ); + assert!(out.contains("Library created at"), "stdout:\n{out}"); + drop(Workspace::open(&library, PASSPHRASE.as_bytes(), FAST_KDF).expect("seed the account")); + + let out = capsule( + &home, + &[ + "import", + path(&source), + "--library", + path(&library), + "--passphrase-stdin", + ], + true, + ); + assert!( + out.contains(&format!( + "Done: {count} imported, 0 duplicate(s), 0 error(s)." + )), + "stdout:\n{out}" + ); + + Fixture { + _scratch: scratch, + home, + library, + images, + } +} + +// ── `capsule show` (S-B18) ─────────────────────────────────────────────────── + +/// The guide's metadata-sampling step, executed: the SHA-256 a user computed over the source +/// file (here, its first eight hex digits) resolves to the imported asset, and the page shows +/// the values that can only have come from the EXIF segment. The asset id resolves too. +#[test] +fn show_prints_the_signed_sidecar_by_hash_prefix_or_id() { + let fx = fixture(2); + let image = &fx.images[0]; + let hex = fx.hash_hex(image); + + let page = fx.show(&hex[..8]); + assert!(page.contains(&hex), "the full hash is printed:\n{page}"); + assert!( + page.contains(EXIF_CAPTURE), + "the EXIF capture instant:\n{page}" + ); + assert!(page.contains("8×8"), "the EXIF dimensions:\n{page}"); + assert!(page.contains("image/jpeg"), "{page}"); + assert!( + page.contains("(unset)"), + "absent fields are spelled out:\n{page}" + ); + assert!( + page.contains("Provenance: 1 signed record(s)"), + "a fresh import on this build is a chain of one:\n{page}" + ); + assert!(!page.contains("cli.show."), "no raw catalog key:\n{page}"); + + let ws = fx.reopen(); + let id = fx.asset_for(&ws, image); + assert!( + page.contains(&id.to_string()), + "the resolved asset's id:\n{page}" + ); + drop(ws); + let by_id = fx.show(&id.to_string()); + assert_eq!( + by_id, page, + "the id and the hash prefix name the same asset" + ); +} + +/// `show` refuses rather than guesses: a malformed selector, an unknown one, and a prefix +/// too short to be accepted each fail with a localized reason and a non-zero exit. +#[test] +fn show_refuses_a_malformed_or_unknown_selector() { + let fx = fixture(1); + let library = path(&fx.library); + + let malformed = fx.run_fails(&[ + "show", + "not-a-selector", + "--library", + library, + "--passphrase-stdin", + ]); + assert!( + malformed.contains("neither an asset id nor a hex prefix"), + "{malformed}" + ); + + let unknown = fx.run_fails(&[ + "show", + "0000000000000000", + "--library", + library, + "--passphrase-stdin", + ]); + assert!( + unknown.contains("No asset in this library matches"), + "{unknown}" + ); + + let short = fx.run_fails(&["show", "abcdef", "--library", library, "--passphrase-stdin"]); + assert!(short.contains("at least 8"), "{short}"); +} + +// ── `capsule repair capture-time` (S-B17) ──────────────────────────────────── + +impl Fixture { + /// `capsule repair capture-time --library [--apply]`. + fn repair(&self, apply: bool) -> String { + let mut args = vec![ + "repair", + "capture-time", + "--library", + path(&self.library), + "--passphrase-stdin", + ]; + if apply { + args.push("--apply"); + } + self.run(&args) + } + + /// The month directory holding the asset's original, relative to the library root. + fn month_dir(&self, ws: &Workspace, id: Uuid) -> PathBuf { + ws.original_path(&id) + .expect("original") + .parent() + .expect("month directory") + .strip_prefix(&self.library) + .expect("inside the library") + .to_path_buf() + } +} + +/// **The "no-op after `S-B16`" half of the criterion.** Every asset here was imported by the +/// fixed parser, so each sidecar already carries its EXIF instant, and the pass reports nothing. +#[test] +fn repair_is_a_no_op_on_a_library_imported_after_the_parser_fix() { + let fx = fixture(2); + let out = fx.repair(false); + assert!(out.contains("Checked 2 asset(s)"), "{out}"); + assert!(out.contains("nothing to repair"), "{out}"); + assert!(!out.contains("affected,"), "{out}"); + let ws = fx.reopen(); + for image in &fx.images { + let id = fx.asset_for(&ws, image); + assert_eq!(ws.asset(&id).expect("asset").chain.records().len(), 1); + } +} + +/// **The repair itself**, across process boundaries: one of two assets is stamped with the +/// import clock (the pre-`S-B16` shape); a dry run reports it and writes nothing; `--apply` +/// corrects it as a signed record without moving the bundle; `capsule show` and a +/// `capsule library rebuild` both read the corrected instant; a second `--apply` finds nothing. +#[test] +fn repair_reports_by_default_and_corrects_under_apply() { + let fx = fixture(2); + let (broken_image, good_image) = (&fx.images[0], &fx.images[1]); + + // Reproduce the bug on one asset, in-process, and remember where its bundle lives. + let (broken, month_before) = { + let mut ws = fx.reopen(); + let broken = fx.asset_for(&ws, broken_image); + ws.set_capture_timestamp(&broken, jiff::Timestamp::now()) + .expect("reproduce the pre-S-B16 stamp"); + let month = fx.month_dir(&ws, broken); + (broken, month) + }; + assert!( + !fx.show(&broken.to_string()).contains(EXIF_CAPTURE), + "the reproduced stamp is no longer the EXIF instant" + ); + + // Dry run (the default): reported, not written. + let dry = fx.repair(false); + assert!(dry.contains("Checked 2 asset(s)"), "{dry}"); + assert!(dry.contains("Dry run"), "{dry}"); + assert!(dry.contains(&broken.to_string()), "{dry}"); + assert!( + dry.contains(EXIF_CAPTURE), + "the recovered instant is named:\n{dry}" + ); + assert!( + dry.contains("1 affected, 0 corrected, 1 already correct"), + "{dry}" + ); + { + let ws = fx.reopen(); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 2); + assert!( + !ws.asset(&broken) + .expect("asset") + .sidecar + .capture_timestamp + .starts_with("2019") + ); + } + + // `--apply`: corrected as a third signed record; the bundle stays where it was. + let applied = fx.repair(true); + assert!(!applied.contains("Dry run"), "{applied}"); + assert!( + applied.contains("1 affected, 1 corrected, 1 already correct"), + "{applied}" + ); + assert!( + applied.contains("month directory"), + "the drift notice:\n{applied}" + ); + { + let ws = fx.reopen(); + let asset = ws.asset(&broken).expect("asset"); + assert_eq!(asset.sidecar.capture_timestamp, EXIF_CAPTURE); + assert_eq!(asset.chain.records().len(), 3); + assert_eq!( + fx.month_dir(&ws, broken), + month_before, + "the bundle is not relocated" + ); + assert!(ws.original_path(&broken).expect("original").is_file()); + let good = fx.asset_for(&ws, good_image); + assert_eq!( + ws.asset(&good).expect("asset").chain.records().len(), + 1, + "untouched" + ); + } + let page = fx.show(&broken.to_string()); + assert!( + page.contains(&format!("Captured: {EXIF_CAPTURE}")), + "{page}" + ); + assert!( + page.contains("Provenance: 3 signed record(s)"), + "{page}" + ); + + // The two index projections agree: a rebuild from disk reads the same corrected instant. + let rebuilt = capsule(&fx.home, &["library", "rebuild", path(&fx.library)], false); + assert!(rebuilt.contains("Index rebuilt successfully."), "{rebuilt}"); + { + let library = capsule_core::library::open_library(&fx.library).expect("open"); + let row = library + .db + .find_by_uuid(&broken.to_string()) + .expect("query") + .expect("indexed"); + assert_eq!(row.capture_timestamp, 1_551_675_967, "2019-03-04T05:06:07Z"); + } + assert!(fx.show(&broken.to_string()).contains(EXIF_CAPTURE)); + + // Idempotent: nothing left to do. + let again = fx.repair(true); + assert!(again.contains("nothing to repair"), "{again}"); + let ws = fx.reopen(); + assert_eq!(ws.asset(&broken).expect("asset").chain.records().len(), 3); +} + +/// `--limit` is a positive count and only means something with `--apply`: `0` is a parser +/// error, and `--limit` alone is refused with a line naming both flags, before the library +/// is even opened. +#[test] +fn repair_limit_is_a_positive_count_that_needs_apply() { + let fx = fixture(1); + let library = path(&fx.library); + let zero = fx.run_fails(&[ + "repair", + "capture-time", + "--library", + library, + "--passphrase-stdin", + "--apply", + "--limit", + "0", + ]); + assert!(zero.contains("--limit"), "{zero}"); + let alone = fx.run_fails(&[ + "repair", + "capture-time", + "--library", + library, + "--passphrase-stdin", + "--limit", + "1", + ]); + assert!( + alone.contains("--limit") && alone.contains("--apply"), + "{alone}" + ); + assert!( + !alone.contains("Checked"), + "refused before any detection ran:\n{alone}" + ); +} + +/// A reject sweep through the real binary, then `show`: the trash row reads `yes`. +#[test] +fn show_reports_a_swept_asset_as_in_trash() { + let fx = fixture(1); + let id = fx.asset_for(&fx.reopen(), &fx.images[0]).to_string(); + let before = fx.show(&id); + assert!(before.contains("In trash: no"), "{before}"); + let out = fx.run(&[ + "cull", + "--library", + path(&fx.library), + "--passphrase-stdin", + "--reject", + &id, + "--sweep", + ]); + assert!(out.contains("Swept 1"), "{out}"); + let after = fx.show(&id); + assert!(after.contains("In trash: yes"), "{after}"); +} diff --git a/capsule-cli/tests/takeout_import.rs b/capsule-cli/tests/takeout_import.rs index b958cf63..ddf38a5d 100644 --- a/capsule-cli/tests/takeout_import.rs +++ b/capsule-cli/tests/takeout_import.rs @@ -38,6 +38,8 @@ //! - `without_the_provider_flag_the_exporter_metadata_is_not_applied` — the flag is what turns //! the adapter on: the same tree imported as a plain directory keeps the bytes and loses the //! exporter's record, which is what makes `--provider` observable rather than cosmetic. +//! - `the_guides_metadata_sampling_step_is_executable_with_show` — slice `S-B18`: the guide's +//! sampling step, run as written, over the hash a user computed on the source file. //! //! [Google Photos migration guide]: ../../capsule-docs/src/content/docs/guides/google-photos-migration.md @@ -48,7 +50,7 @@ use std::process::{Command, Output, Stdio}; use capsule_core::crypto::primitives::Argon2Params; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::Workspace; -use capsule_core::sidecar::sidecar_v1::GpsSource; +use capsule_core::sidecar::GpsSource; use jiff::Timestamp; use uuid::Uuid; @@ -635,3 +637,61 @@ fn the_import_arm_prints_catalog_messages_not_hardcoded_english() { "a plain import must not announce an export adapter\nstdout:\n{plain}" ); } + +/// Slice `S-B18`: the migration guide's metadata-sampling step, executed as written. The user +/// holds the source file's SHA-256 from the guide's spot-hash step; `capsule show` takes a +/// prefix of it and prints the exporter-authoritative values the import folded into the +/// signed sidecar — the caption, the favourite as five stars, the album as a user tag — and +/// the file's own EXIF fix, which beat the exporter's. +#[test] +fn the_guides_metadata_sampling_step_is_executable_with_show() { + let fx = fixture(); + let library = fx.library(); + fx.import(&library, true); + + // `shasum -a 256 beach.jpg`, as the guide instructs, and its first eight hex digits. + let hex = capsule_core::crypto::hash::hash_bytes(&fx.media.beach).to_hex(); + let page = capsule( + &fx.home, + &[ + "show", + &hex[..8], + "--library", + path(&library), + "--passphrase-stdin", + ], + ); + + for expected in [ + hex.as_str(), + "On the beach", + "5/5", + "Vacation 2021", + "10.000000, 20.000000 (WGS-84, EXIF)", + BEACH_EXIF_CIVIL, + ] { + assert!(page.contains(expected), "missing {expected:?} in:\n{page}"); + } + + // The exporter-filled fix is labelled as such, so a user can tell it apart from EXIF. + let plain_hex = capsule_core::crypto::hash::hash_bytes(&fx.media.plain).to_hex(); + let plain = capsule( + &fx.home, + &[ + "show", + &plain_hex[..8], + "--library", + path(&library), + "--passphrase-stdin", + ], + ); + assert!( + plain.contains("21.300000, -157.800000 (WGS-84, manual)"), + "{plain}" + ); + assert!(plain.contains("Snowy morning"), "{plain}"); + assert!( + plain.contains("Rating: (unset)"), + "an unstarred photo's rating is spelled out as unset:\n{plain}" + ); +} diff --git a/capsule-core-ffi/src/catalog.rs b/capsule-core-ffi/src/catalog.rs index 82549c5c..b1e7051f 100644 --- a/capsule-core-ffi/src/catalog.rs +++ b/capsule-core-ffi/src/catalog.rs @@ -322,6 +322,64 @@ impl Catalog { } } +// ── LQIP placeholder rendering (slice S-B14) ──────────────────────────────── + +/// A decoded LQIP placeholder handed to the native clients: packed RGBA8, ready for a +/// `CGImage` / `Bitmap`. +/// +/// A record rather than raw bytes because the caller cannot know the dimensions in advance: +/// `decode_capped` returns the largest frame that fits *inside* the requested box while +/// preserving the source aspect ratio. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct LqipPlaceholder { + /// Frame width in pixels — at most the requested `max_width`. + pub width: u32, + /// Frame height in pixels — at most the requested `max_height`. + pub height: u32, + /// Packed RGBA8 samples, `width * height * 4` bytes long. + pub rgba: Vec, +} + +/// Render the three fields of a sidecar `lqip` record to a paintable placeholder, band-limited +/// to the box being painted. +/// +/// The mirror of `capsule-wasm`'s `decodeLqip`, over the same +/// [`capsule_core::lqip::render`] the import pipeline encodes against — one implementation, so +/// a photo's placeholder does not depend on which client is painting it (slice `S-B14`). +/// +/// **A free function rather than a [`Catalog`] method, deliberately.** The `assets` table has +/// `chromahash` and `dominant_color` columns, and they are NULL: the signed sidecar is the +/// placeholder's home, and projecting it onto the index would have to be done identically by +/// `capsule_core::library::rebuild` or a rebuilt index would disagree with a freshly written +/// one. Until both sides move together, an accessor keyed on an asset id could only ever +/// return nothing — so this takes the record the caller already holds from the decrypted +/// sidecar instead of pretending the index has it. +/// +/// **Infallible.** An unrecognised `format_version`, a payload the parser rejects, or a +/// `dominant_color` that is not three bytes all yield the 1x1 solid fill: a reader must never +/// misrender a placeholder, and a gallery must never fail to draw a cell over one. +#[uniffi::export] +#[must_use] +pub fn render_lqip( + format_version: u16, + chromahash: Vec, + dominant_color: Vec, + max_width: u32, + max_height: u32, +) -> LqipPlaceholder { + // A malformed fill is the one input `capsule_core::lqip::render` cannot take, and unlike the + // wasm boundary there is nothing useful to throw across the FFI for it: black is the + // conventional empty-cell fill and is what a caller would paint anyway. + let fill: [u8; 3] = dominant_color.try_into().unwrap_or([0, 0, 0]); + let image = + capsule_core::lqip::render(format_version, &chromahash, fill, max_width, max_height); + LqipPlaceholder { + width: image.width, + height: image.height, + rgba: image.rgba, + } +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU32, Ordering}; @@ -625,6 +683,58 @@ mod tests { assert!(cat.find_by_uuid("hidden".to_string()).unwrap().is_some()); } + // ── LQIP placeholder rendering ────────────────────────────────────────── + + /// The native surface decodes the same bytes the import pipeline encoded, to the same + /// pixels `capsule_core::lqip` produces — the `S-B14` cross-surface criterion at the FFI + /// boundary. + #[test] + fn render_lqip_matches_the_core_decoder() { + use capsule_core::lqip::{Gamut, LQIP_FORMAT_V1, Lqip}; + + let (w, h) = (120u32, 90u32); + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[(x * 2) as u8, (y * 2) as u8, 128, 255]); + } + } + let lqip = Lqip::encode(w, h, &rgba, Gamut::Srgb).expect("encode"); + assert_eq!(lqip.as_bytes().len(), 32, "the committed tier is 32 bytes"); + + let expected = lqip.decode_capped(48, 48); + let got = render_lqip( + LQIP_FORMAT_V1, + lqip.as_bytes().to_vec(), + lqip.dominant_color().to_vec(), + 48, + 48, + ); + assert_eq!((got.width, got.height), (expected.width, expected.height)); + assert_eq!(got.rgba, expected.rgba, "byte-identical to the core"); + assert_eq!(got.rgba.len() as u32, got.width * got.height * 4); + } + + /// Every malformed input paints the fallback rather than failing: an unknown version, a + /// corrupt payload, and a `dominant_color` that is not three bytes. + #[test] + fn render_lqip_never_fails_on_a_malformed_record() { + use capsule_core::lqip::LQIP_FORMAT_V1; + + let fill = vec![9u8, 8, 7]; + let unknown = render_lqip(LQIP_FORMAT_V1 + 42, vec![1, 2, 3], fill.clone(), 32, 32); + assert_eq!((unknown.width, unknown.height), (1, 1)); + assert_eq!(unknown.rgba, vec![9, 8, 7, 255]); + + let corrupt = render_lqip(LQIP_FORMAT_V1, vec![0xDE, 0xAD], fill, 32, 32); + assert_eq!(corrupt.rgba, vec![9, 8, 7, 255]); + + // No usable fill: black, the conventional empty-cell colour. + let no_fill = render_lqip(LQIP_FORMAT_V1, vec![0xDE, 0xAD], vec![1, 2], 32, 32); + assert_eq!((no_fill.width, no_fill.height), (1, 1)); + assert_eq!(no_fill.rgba, vec![0, 0, 0, 255]); + } + /// The retention sweep is deliberately ungated (it runs unattended) — pinned here so /// a future change to that decision is a deliberate one, not an accident. #[test] diff --git a/capsule-core-ffi/src/lib.rs b/capsule-core-ffi/src/lib.rs index 544b2a2c..8b19b3de 100644 --- a/capsule-core-ffi/src/lib.rs +++ b/capsule-core-ffi/src/lib.rs @@ -1,5 +1,5 @@ -//! UniFFI bindings exposing the `capsule-core` SQLite catalog and CBOR sidecar -//! to Swift (and, in future, other UniFFI targets such as Android/Kotlin). +//! UniFFI bindings exposing the `capsule-core` SQLite catalog to Swift (and, in +//! future, other UniFFI targets such as Android/Kotlin). //! //! One of the uniffi surfaces in the workspace — `capsule-core`'s `ffi` feature //! exports the crypto `FfiWorkspace` + `HardwareSigner` foreign trait separately. The two @@ -19,10 +19,14 @@ //! Rust ↔ Swift contract: //! //! - [`Catalog`] — a thread-safe handle over the SQLite catalog. +//! - [`render_lqip`] / [`LqipPlaceholder`] — the sidecar `lqip` record rendered to packed RGBA8 +//! through `capsule_core::lqip`, the same implementation the import pipeline encodes with and +//! `capsule-wasm` decodes with (slice `S-B14`). //! - [`AssetRecord`], [`AssetStackRecord`], [`StackMemberRecord`], //! [`AlbumRecord`] — catalog row mirrors. -//! - [`AssetSidecarRecord`] / [`serialize_sidecar`] / [`deserialize_sidecar`] — -//! the canonical CBOR sidecar format, with unknown fields preserved verbatim. +//! - No sidecar codec: the unsigned CBOR shape this crate once (de)serialised was retired +//! with the unsigned-sidecar migration (slice `S-D24`), and the signed `SidecarV1` is +//! authored only by `capsule-core`'s lifecycle, never re-encoded by a client. //! - [`GatedView`] / [`LocalAuthGate`] / [`LocalAuthError`] — the fresh-local-auth seam //! the platform implements, and the views it unlocks ([Local Gallery — SR1]). //! - [`CatalogError`] — the single error type crossing the boundary. @@ -41,13 +45,11 @@ mod catalog; mod error; mod gate; mod records; -mod sidecar; -pub use catalog::Catalog; +pub use catalog::{Catalog, LqipPlaceholder, render_lqip}; pub use error::CatalogError; pub use gate::{GatedView, LocalAuthError, LocalAuthGate}; pub use records::{AlbumRecord, AssetRecord, AssetStackRecord, StackMemberRecord}; -pub use sidecar::{AssetSidecarRecord, StackHintRecord, deserialize_sidecar, serialize_sidecar}; /// Initialise structured logging for the Rust core. /// diff --git a/capsule-core-ffi/src/sidecar.rs b/capsule-core-ffi/src/sidecar.rs deleted file mode 100644 index 141bc3b6..00000000 --- a/capsule-core-ffi/src/sidecar.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! CBOR sidecar (de)serialisation exposed across the FFI boundary. -//! -//! The canonical sidecar format lives in `capsule-core`. Swift never re-encodes -//! it: it builds an [`AssetSidecarRecord`], calls [`serialize_sidecar`] to get -//! the bytes to write to disk, and calls [`deserialize_sidecar`] to read them -//! back. Sidecar fields this build does not recognise are carried through the -//! `unknown_fields_cbor` blob verbatim, so forward compatibility is preserved -//! without Swift needing a CBOR implementation of its own. - -use std::collections::BTreeMap; - -use capsule_core::sidecar::{AssetSidecar, StackHint}; -use ciborium::value::Value; - -use crate::error::CatalogError; - -/// The CBOR sidecar paired with every managed media file. -/// -/// Enum-typed fields (`asset_type`, `import_mode`, `capture_tz_source`) carry -/// their canonical snake_case string values. -#[derive(Debug, Clone, PartialEq, uniffi::Record)] -pub struct AssetSidecarRecord { - pub version: u8, - pub uuid: String, - pub asset_type: String, - pub original_filename: String, - pub import_timestamp: i64, - pub modified_timestamp: i64, - pub hash_sha256: String, - pub file_size: u64, - pub is_deleted: bool, - pub rating: u8, - pub tags: Vec, - pub import_mode: String, - pub importer_version: String, - pub rawshift_version: String, - pub capture_timestamp: Option, - pub capture_utc: Option, - pub capture_tz: Option, - pub capture_tz_source: Option, - pub tz_db_version: Option, - pub width: Option, - pub height: Option, - pub duration_ms: Option, - pub stack_hint: Option, - pub album_id: Option, - pub deleted_at: Option, - pub camera_make: Option, - pub camera_model: Option, - pub gps_lat: Option, - pub gps_lon: Option, - /// Opaque CBOR encoding of sidecar fields this build does not recognise. - /// Round-tripped verbatim; empty when there are none. Never inspected by Swift. - pub unknown_fields_cbor: Vec, -} - -/// Stack-membership hint stored in a sidecar. Enum fields carry snake_case values. -#[derive(Debug, Clone, PartialEq, uniffi::Record)] -pub struct StackHintRecord { - pub detection_key: String, - pub detection_method: String, - pub member_role: String, - pub stack_type: String, -} - -/// Serialise an [`AssetSidecarRecord`] to canonical CBOR bytes. -#[uniffi::export] -pub fn serialize_sidecar(record: AssetSidecarRecord) -> Result, CatalogError> { - let sidecar = record_to_sidecar(record)?; - let mut buf = Vec::new(); - ciborium::ser::into_writer(&sidecar, &mut buf).map_err(|e| CatalogError::Sidecar { - message: format!("failed to encode sidecar: {e}"), - })?; - Ok(buf) -} - -/// Decode canonical CBOR bytes into an [`AssetSidecarRecord`]. -#[uniffi::export] -pub fn deserialize_sidecar(bytes: Vec) -> Result { - let sidecar: AssetSidecar = - ciborium::de::from_reader(bytes.as_slice()).map_err(|e| CatalogError::Sidecar { - message: format!("failed to decode sidecar: {e}"), - })?; - sidecar_to_record(&sidecar) -} - -// ── Conversion ─────────────────────────────────────────────────────────────── - -fn record_to_sidecar(r: AssetSidecarRecord) -> Result { - Ok(AssetSidecar { - version: r.version, - uuid: r.uuid, - asset_type: enum_from_string(&r.asset_type, "asset_type")?, - original_filename: r.original_filename, - import_timestamp: r.import_timestamp, - modified_timestamp: r.modified_timestamp, - hash_sha256: r.hash_sha256, - file_size: r.file_size, - is_deleted: r.is_deleted, - rating: r.rating, - tags: r.tags, - import_mode: enum_from_string(&r.import_mode, "import_mode")?, - importer_version: r.importer_version, - rawshift_version: r.rawshift_version, - capture_timestamp: r.capture_timestamp, - capture_utc: r.capture_utc, - capture_tz: r.capture_tz, - capture_tz_source: r - .capture_tz_source - .as_deref() - .map(|s| enum_from_string(s, "capture_tz_source")) - .transpose()?, - tz_db_version: r.tz_db_version, - width: r.width, - height: r.height, - duration_ms: r.duration_ms, - stack_hint: r.stack_hint.map(record_to_stack_hint).transpose()?, - album_id: r.album_id, - deleted_at: r.deleted_at, - camera_make: r.camera_make, - camera_model: r.camera_model, - gps_lat: r.gps_lat, - gps_lon: r.gps_lon, - unknown_fields: decode_unknown_fields(&r.unknown_fields_cbor)?, - }) -} - -fn sidecar_to_record(s: &AssetSidecar) -> Result { - Ok(AssetSidecarRecord { - version: s.version, - uuid: s.uuid.clone(), - asset_type: enum_to_string(&s.asset_type, "asset_type")?, - original_filename: s.original_filename.clone(), - import_timestamp: s.import_timestamp, - modified_timestamp: s.modified_timestamp, - hash_sha256: s.hash_sha256.clone(), - file_size: s.file_size, - is_deleted: s.is_deleted, - rating: s.rating, - tags: s.tags.clone(), - import_mode: enum_to_string(&s.import_mode, "import_mode")?, - importer_version: s.importer_version.clone(), - rawshift_version: s.rawshift_version.clone(), - capture_timestamp: s.capture_timestamp, - capture_utc: s.capture_utc, - capture_tz: s.capture_tz.clone(), - capture_tz_source: s - .capture_tz_source - .as_ref() - .map(|v| enum_to_string(v, "capture_tz_source")) - .transpose()?, - tz_db_version: s.tz_db_version.clone(), - width: s.width, - height: s.height, - duration_ms: s.duration_ms, - stack_hint: s - .stack_hint - .as_ref() - .map(stack_hint_to_record) - .transpose()?, - album_id: s.album_id.clone(), - deleted_at: s.deleted_at, - camera_make: s.camera_make.clone(), - camera_model: s.camera_model.clone(), - gps_lat: s.gps_lat, - gps_lon: s.gps_lon, - unknown_fields_cbor: encode_unknown_fields(&s.unknown_fields)?, - }) -} - -fn record_to_stack_hint(r: StackHintRecord) -> Result { - Ok(StackHint { - detection_key: r.detection_key, - detection_method: enum_from_string(&r.detection_method, "detection_method")?, - member_role: enum_from_string(&r.member_role, "member_role")?, - stack_type: enum_from_string(&r.stack_type, "stack_type")?, - }) -} - -fn stack_hint_to_record(h: &StackHint) -> Result { - Ok(StackHintRecord { - detection_key: h.detection_key.clone(), - detection_method: enum_to_string(&h.detection_method, "detection_method")?, - member_role: enum_to_string(&h.member_role, "member_role")?, - stack_type: enum_to_string(&h.stack_type, "stack_type")?, - }) -} - -// ── Enum <-> canonical string ──────────────────────────────────────────────── -// -// `capsule-core`'s domain enums all derive serde with `rename_all = "snake_case"`, -// so a JSON round-trip yields exactly the canonical string the catalog and -// sidecar use — no hand-written mapping tables to drift out of sync. - -fn enum_to_string(value: &T, field: &str) -> Result { - match serde_json::to_value(value) { - Ok(serde_json::Value::String(s)) => Ok(s), - Ok(other) => Err(CatalogError::Sidecar { - message: format!("field '{field}' did not serialise to a string: {other}"), - }), - Err(e) => Err(CatalogError::Sidecar { - message: format!("field '{field}': {e}"), - }), - } -} - -fn enum_from_string( - s: &str, - field: &str, -) -> Result { - serde_json::from_value(serde_json::Value::String(s.to_string())).map_err(|e| { - CatalogError::Sidecar { - message: format!("field '{field}' has invalid value '{s}': {e}"), - } - }) -} - -// ── Unknown-field CBOR blob ────────────────────────────────────────────────── - -fn decode_unknown_fields(bytes: &[u8]) -> Result, CatalogError> { - if bytes.is_empty() { - return Ok(BTreeMap::new()); - } - let value: Value = ciborium::de::from_reader(bytes).map_err(|e| CatalogError::Sidecar { - message: format!("unknown_fields_cbor is not valid CBOR: {e}"), - })?; - match value { - Value::Map(entries) => { - let mut map = BTreeMap::new(); - for (k, v) in entries { - match k { - Value::Text(key) => { - map.insert(key, v); - } - _ => { - return Err(CatalogError::Sidecar { - message: "unknown_fields_cbor contains a non-text key".to_string(), - }); - } - } - } - Ok(map) - } - _ => Err(CatalogError::Sidecar { - message: "unknown_fields_cbor is not a CBOR map".to_string(), - }), - } -} - -fn encode_unknown_fields(map: &BTreeMap) -> Result, CatalogError> { - if map.is_empty() { - return Ok(Vec::new()); - } - let value = Value::Map( - map.iter() - .map(|(k, v)| (Value::Text(k.clone()), v.clone())) - .collect(), - ); - let mut buf = Vec::new(); - ciborium::ser::into_writer(&value, &mut buf).map_err(|e| CatalogError::Sidecar { - message: format!("failed to encode unknown_fields: {e}"), - })?; - Ok(buf) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn minimal_record() -> AssetSidecarRecord { - AssetSidecarRecord { - version: 1, - uuid: "01956ef3-0000-7000-8000-000000000001".to_string(), - asset_type: "photo".to_string(), - original_filename: "IMG_1234.jpg".to_string(), - import_timestamp: 1_720_000_000, - modified_timestamp: 1_720_000_000, - hash_sha256: "a".repeat(64), - file_size: 2048, - is_deleted: false, - rating: 0, - tags: vec![], - import_mode: "copy".to_string(), - importer_version: "0.1.0".to_string(), - rawshift_version: "0.0.0".to_string(), - capture_timestamp: None, - capture_utc: None, - capture_tz: None, - capture_tz_source: None, - tz_db_version: None, - width: None, - height: None, - duration_ms: None, - stack_hint: None, - album_id: None, - deleted_at: None, - camera_make: None, - camera_model: None, - gps_lat: None, - gps_lon: None, - unknown_fields_cbor: Vec::new(), - } - } - - #[test] - fn test_sidecar_minimal_roundtrip() { - let record = minimal_record(); - let bytes = serialize_sidecar(record.clone()).unwrap(); - let decoded = deserialize_sidecar(bytes).unwrap(); - assert_eq!(decoded, record); - } - - #[test] - fn test_sidecar_full_roundtrip_with_stack_hint() { - let mut record = minimal_record(); - record.capture_tz_source = Some("gps_lookup".to_string()); - record.width = Some(4032); - record.height = Some(3024); - record.tags = vec!["trip".to_string(), "2024".to_string()]; - record.gps_lat = Some(40.7128); - record.gps_lon = Some(-74.0060); - record.stack_hint = Some(StackHintRecord { - detection_key: "apple-content-id".to_string(), - detection_method: "content_identifier".to_string(), - member_role: "primary".to_string(), - stack_type: "live_photo".to_string(), - }); - let bytes = serialize_sidecar(record.clone()).unwrap(); - let decoded = deserialize_sidecar(bytes).unwrap(); - assert_eq!(decoded, record); - } - - #[test] - fn test_invalid_enum_value_is_rejected() { - let mut record = minimal_record(); - record.asset_type = "not_a_real_type".to_string(); - assert!(matches!( - serialize_sidecar(record), - Err(CatalogError::Sidecar { .. }) - )); - } - - #[test] - fn test_unknown_fields_preserved() { - // A sidecar field written by a future build must survive a full - // decode → re-encode → decode cycle. - let mut unknown = BTreeMap::new(); - unknown.insert( - "future_field".to_string(), - Value::Text("future_value".to_string()), - ); - let mut record = minimal_record(); - record.unknown_fields_cbor = encode_unknown_fields(&unknown).unwrap(); - - let bytes = serialize_sidecar(record).unwrap(); - let decoded = deserialize_sidecar(bytes).unwrap(); - - let decoded_unknown = decode_unknown_fields(&decoded.unknown_fields_cbor).unwrap(); - assert_eq!( - decoded_unknown.get("future_field"), - Some(&Value::Text("future_value".to_string())) - ); - } -} diff --git a/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt b/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt index 7a6ac6d3..f1051107 100644 --- a/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt +++ b/capsule-core-kotlin/src/test/kotlin/com/justin13888/capsule/hardware/SoftwareSignerSmokeTest.kt @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import uniffi.capsule_core.DeviceTier +import uniffi.capsule_core.FfiClientBuild import uniffi.capsule_core.FfiWorkspace import uniffi.capsule_core.HardwareSignerException import java.nio.file.Files @@ -19,6 +20,8 @@ import java.nio.file.Files * first. The StrongBox path is on-device only (see androidInstrumentedTest). */ class SoftwareSignerSmokeTest { + private val client = FfiClientBuild("capsule-core-kotlin", "0.0.0") + private fun freshRoot(): String = Files.createTempDirectory("capsule-kotlin-smoke").toString() @Test @@ -46,7 +49,7 @@ class SoftwareSignerSmokeTest { @Test fun softwarePathCreatesWorkspace() { - val ws = FfiWorkspace.create(freshRoot(), "correct horse".toByteArray(), DeviceTier.NORMAL) + val ws = FfiWorkspace.create(freshRoot(), "correct horse".toByteArray(), DeviceTier.NORMAL, client) assertFalse(ws.userId().isEmpty()) assertFalse(ws.defaultAlbumId().isEmpty()) } @@ -64,6 +67,7 @@ class SoftwareSignerSmokeTest { signer, "device-dsk", ByteArray(32) { 9 }, + client, ) assertFalse(ws.userId().isEmpty()) } diff --git a/capsule-core/Cargo.toml b/capsule-core/Cargo.toml index 6d086b2f..836ddb2c 100644 --- a/capsule-core/Cargo.toml +++ b/capsule-core/Cargo.toml @@ -25,7 +25,15 @@ required-features = ["ffi-bindgen"] # `wasm32-unknown-unknown` for the guest web-upload client (see `drop::seal_drop`). Build: # cargo build -p capsule-core --target wasm32-unknown-unknown --no-default-features default = ["native"] -native = ["dep:rusqlite", "dep:sqlite-vec", "mls"] +native = ["dep:rusqlite", "dep:sqlite-vec", "mls", "media"] +# `media` links the still-image decode/encode stack (`capsule_core::media`, slices `S-B1`/`S-B13`) +# over `rawshift-image`. Implied by `native`, so the CLI, the tests and the mobile FFI all carry +# decoders; **excluded** from the `wasm32-unknown-unknown` sealing build (`--no-default-features`), +# to which the whole stack is irrelevant. Every enabled codec is pure Rust and links no C, which +# is what keeps the mobile cross-builds working — see the dependency's own comment for why WebP +# is not among them. `capsule_core::lqip` stays unconditional and is NOT behind this feature — a +# placeholder must not depend on which client imported the photo (slice `S-B14`). +media = ["dep:rawshift-image"] # `mls` links the live OpenMLS group backend (`crypto::authority::OpenMlsAuthority`, slice # S-X1) pinned to the X-Wing PQ ciphersuite `MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519` # (`0x004D`) via its formally-verified libcrux provider. Implied by `native` so the default @@ -44,6 +52,17 @@ mls = [ # uniffi's CLI so the `uniffi-bindgen` bin can emit the bindings. ffi = ["native", "dep:uniffi"] ffi-bindgen = ["ffi", "uniffi/cli"] +# `test-support` exposes the entry points that let a caller choose the backup artifact's Argon2id +# wrap cost (`backup::export_with_params`, `backup::export_with_salt_and_params`, +# `Workspace::export_backup_with_params`). **Never in `default`, and never in a shipping +# manifest.** A weak cost there produces a brute-forceable backup and nothing downstream +# re-checks it — `pwkdf::derive_wrap_key` accepts anything `argon2::Params::new` validates — so +# the ability to ask for one is a compile-time capability a reviewer can see in a manifest diff, +# not a documented convention. `capsule-core`'s own tests reach the same items through +# `cfg(test)`; a downstream test crate enables this on a **dev**-dependency, which resolver 3 +# keeps out of every build that is not building tests. Enables no dependency and changes no +# production code path. +test-support = [] # `tpm` builds the desktop (Linux/Windows) TPM 2.0 reference `HardwareSigner` (see # `crypto::keys::tpm`). Off by default and never built in CI: tss-esapi links the system # `libtss2` and exercising it needs a real TPM or a software TPM (swtpm). Compile on a @@ -77,6 +96,46 @@ chromahash = { version = "0.7.1", default-features = false, features = ["simd"] indexmap = { workspace = true } jiff = { workspace = true } kamadak-exif = "0.5" +# Still-image decode/encode for `capsule_core::media` (`media` feature). A **registry** +# dependency, not the pinned `rawshift/` submodule, which is an uninitialised newer +# v1-in-progress tree and is not a workspace member. Depended on directly rather than through +# the `rawshift` facade because only the per-crate dependency gives per-format Cargo control +# (`rawshift-image`'s own docs say so), and the format set is a licence, build-host and +# *portability* decision: +# +# - `jpeg-decode` / `png-decode` — pure-Rust zune decode for the two formats every library holds. +# **Decode only in the shipping graph.** Their encoders exist and only the +# test fixtures use them, so they ride `[dev-dependencies]` below: shipping +# `jpeg-encode` would put `jpeg-encoder` — and its conjunctive IJG licence +# arm, which cannot be elected away — into every release binary to satisfy +# nothing a user does. +# - `jxl` — jxl-oxide decode plus the `zune-jpegxl` encoder that produces the thumbnail +# tier. Pure Rust, and `image/jxl` is the tier table's committed *master* +# format. The backend is `JxlSimpleEncoder`, which is lossless — a thumbnail +# therefore costs more bytes than the table's q=50 intends, and closing that +# needs C libjxl (`jxl-encode-libjxl`: `bindgen` + `pkg-config`). +# - `tiff-decode` / `gif-decode` — pure Rust, no encoder needed. +# +# **`webp` is deliberately absent, and it is not a preference.** It does not compile for any +# aarch64 target: `codecs/webp.rs:164,177,190` pass `b"EXIF".as_ptr() as *const i8` to +# `WebPMuxSetChunk`, whose `libwebp-sys` 0.14.4 signature (`ffi.rs:881`) takes +# `*const core::ffi::c_char` — and `c_char` is `u8` on aarch64, so the cast is an E0308. That +# module is compiled under `any(webp-decode, webp-encode)`, so decode-only does not escape it. +# Every mobile target Capsule ships is aarch64, so WebP is a recognised-but-undecodable format +# here until upstream fixes the cast. +# +# Also absent: `heic` (system libheif), `avif` (image's `avif-native` -> system libdav1d for +# decode; `ravif` -> `rav1e/asm` -> nasm on every x86_64 build host for encode), `svg`, and the +# RAW families (`experimental`; CR3 pixel decode unimplemented upstream). Each is a typed +# `media::MediaError::UnsupportedFormat` today rather than a silent gap. MPL-2.0, already +# allow-listed in `deny.toml`; see the Media row in design/dependencies.md. +rawshift-image = { version = "0.1.1", default-features = false, features = [ + "jpeg-decode", + "png-decode", + "jxl", + "tiff-decode", + "gif-decode", +], optional = true } # Bundled SQLite (C) — the on-device library index. Optional + gated by `native` because it # cannot target `wasm32-unknown-unknown`; the WASM sealing build drops it. rusqlite = { version = "0.32", features = ["bundled"], optional = true } @@ -188,6 +247,16 @@ getrandom_04 = { package = "getrandom", version = "0.4", features = ["wasm_js"] uuid = { workspace = true, features = ["rng-getrandom"] } [dev-dependencies] +# The encoders the fixtures need and the shipping graph does not. Cargo unifies features per +# build, so a `cargo build` links decode only while `cargo test` gets the encoders — which keeps +# `jpeg-encoder`'s IJG arm out of every release binary while leaving it in the graph +# `cargo deny --all-features` inspects, so its `deny.toml` exception and NOTICE section stay +# matched rather than becoming stale. The `media` feature is named explicitly because a +# dev-dependency does not inherit the optional dependency's gate. +rawshift-image = { version = "0.1.1", default-features = false, features = [ + "jpeg", + "png", +] } tempfile = "3" # Integration tests (e.g. the S-D14 placement audit) build UUIDs to exercise the `library::paths` # surface; `uuid` is a normal dependency, so the test crate needs its own dev-dependency on it. diff --git a/capsule-core/src/backup/artifact.rs b/capsule-core/src/backup/artifact.rs index 66b495a9..228eaa38 100644 --- a/capsule-core/src/backup/artifact.rs +++ b/capsule-core/src/backup/artifact.rs @@ -37,21 +37,24 @@ use crate::crypto::{kdf, pwkdf, rng}; type HmacSha256 = Hmac; -/// Argon2id params for the backup wrap key, recorded in VERSION so restore reproduces the -/// key. Production uses the normal-tier cost; tests use a trivially-fast cost (the wrap-key -/// strength is orthogonal to the format/round-trip correctness the tests exercise). -#[cfg(not(test))] -const WRAP_PARAMS: Argon2Params = Argon2Params { +/// The production Argon2id cost for the backup wrap key: the normal device tier. +/// +/// The parameters an export actually used are recorded in the artifact's `VERSION` entry, so a +/// restore reproduces the wrap key from the artifact and never from a constant. That is why +/// this is a *default* and not a rule: [`export`] and [`export_with_salt`] — the two entry +/// points a production build has — apply it and take no cost argument, while the `*_with_params` +/// pair takes the cost from the caller and is compiled only under `cfg(test)` or the non-default +/// `test-support` feature. [`BackupArtifact::open`] reads the cost back off the artifact either +/// way. +/// +/// It is one constant for every build. It used to be forked on `#[cfg(test)]`, which meant +/// `capsule-core`'s own tests were the only code in the workspace that never exercised the +/// production cost, while every downstream test paid it in full with no way to opt out. +pub const WRAP_PARAMS: Argon2Params = Argon2Params { mem_kib: 256 * 1024, t_cost: 3, p_cost: 1, }; -#[cfg(test)] -const WRAP_PARAMS: Argon2Params = Argon2Params { - mem_kib: 64, - t_cost: 1, - p_cost: 1, -}; /// One asset to back up: its ciphertext, metadata blob, and full provenance chain. #[derive(Debug, Clone)] @@ -177,13 +180,16 @@ fn tar_read(bytes: &[u8]) -> Result)>, BackupError> { Ok(out) } -fn version_blob(salt: &[u8; 32]) -> Vec { +/// The plaintext `VERSION` entry. `params` is the cost this export actually derived under, not +/// a compiled-in default: [`parse_version`] reads it straight back, which is what makes a +/// restore reproduce the wrap key of an artifact exported at any cost. +fn version_blob(salt: &[u8; 32], params: Argon2Params) -> Vec { format!( "artifact_format={ARTIFACT_FORMAT_VERSION}\ncrypto_suite_id={CRYPTO_SUITE_ID}\nmin_protocol_version={PROTOCOL_VERSION}\nwrap_salt={}\nwrap_mem_kib={}\nwrap_t={}\nwrap_p={}\n", hex::encode(salt), - WRAP_PARAMS.mem_kib, - WRAP_PARAMS.t_cost, - WRAP_PARAMS.p_cost, + params.mem_kib, + params.t_cost, + params.p_cost, ) .into_bytes() } @@ -245,14 +251,55 @@ fn open_ledger(wrap_key: &[u8; 32], sealed: &[u8]) -> Result Result, BackupError> { - let wrap_key = pwkdf::derive_wrap_key(passphrase, &salt, WRAP_PARAMS)?; + export_inner(input, passphrase, salt, WRAP_PARAMS, exporter) +} + +/// Assemble a backup artifact with an explicit wrap salt **and** an explicit Argon2id cost. +/// +/// `params` is written into the artifact's `VERSION` entry, so whatever cost is chosen here is +/// the cost [`BackupArtifact::open`] pays to reproduce the wrap key — a caller that exports +/// cheaply gets a cheap restore, and neither side needs to be told twice. +/// +/// **Not in a production build.** A weak `params` produces a brute-forceable artifact, and +/// nothing downstream of here re-checks the cost: [`pwkdf::derive_wrap_key`] accepts anything +/// `argon2::Params::new` validates. So the entry point that can do it is compiled only under +/// `cfg(test)` or the non-default `test-support` feature, and weakening a real backup therefore +/// takes a visible line in a production manifest rather than an unnoticed call. The always- +/// available [`export`] and [`export_with_salt`] cannot be given a cost at all. +/// +/// This is the same guard [`escrow_master_key`](super::escrow_master_key) gets from taking a +/// closed [`DeviceTier`](crate::crypto::primitives::DeviceTier) instead of raw parameters, +/// reached differently: every `DeviceTier` arm is memory-hard by design, so the tier enum has no +/// arm a test could use as the fast path. +#[cfg(any(test, feature = "test-support"))] +pub fn export_with_salt_and_params( + input: &BackupInput, + passphrase: &[u8], + salt: [u8; 32], + params: Argon2Params, + exporter: &dyn Signer, +) -> Result, BackupError> { + export_inner(input, passphrase, salt, params, exporter) +} + +/// The one implementation every export entry point delegates to. Private, so the only way to +/// reach it with a caller-chosen cost is through the gated entry points above. +fn export_inner( + input: &BackupInput, + passphrase: &[u8], + salt: [u8; 32], + params: Argon2Params, + exporter: &dyn Signer, +) -> Result, BackupError> { + let wrap_key = pwkdf::derive_wrap_key(passphrase, &salt, params)?; // Build the AMK ledger, asserting completeness for every referenced epoch. let mut ledger = AmkLedger::default(); @@ -354,7 +401,7 @@ pub fn export_with_salt( // Write the tar: VERSION, MANIFEST, ledger, then sorted payloads. let mut builder = tar::Builder::new(Vec::new()); - tar_append(&mut builder, "VERSION", &version_blob(&salt)); + tar_append(&mut builder, "VERSION", &version_blob(&salt, params)); tar_append(&mut builder, "MANIFEST.cbor", &manifest_bytes); tar_append(&mut builder, "keys/amk-ledger.cbor", &sealed_ledger); // Re-sort payloads to match the manifest entry order. @@ -372,7 +419,11 @@ pub fn export_with_salt( .map_err(|e| BackupError::Format(e.to_string())) } -/// Assemble a backup artifact, drawing a fresh random wrap salt (production path). +/// Assemble a backup artifact, drawing a fresh random wrap salt, at the production +/// [`WRAP_PARAMS`] cost (production path). +/// +/// This is the entry point [`Workspace::export_backup`](crate::lifecycle::Workspace::export_backup) +/// runs, and it takes no cost argument, so no caller of it can produce a weak artifact. pub fn export( input: &BackupInput, passphrase: &[u8], @@ -381,6 +432,26 @@ pub fn export( export_with_salt(input, passphrase, rng::random_array::<32>(), exporter) } +/// As [`export`] but with an explicit Argon2id cost for the wrap key — the entry point a test +/// uses instead of reaching for a build-configuration fork. +/// +/// **Not in a production build**, for the reason given on [`export_with_salt_and_params`]. +#[cfg(any(test, feature = "test-support"))] +pub fn export_with_params( + input: &BackupInput, + passphrase: &[u8], + params: Argon2Params, + exporter: &dyn Signer, +) -> Result, BackupError> { + export_inner( + input, + passphrase, + rng::random_array::<32>(), + params, + exporter, + ) +} + // ── Restore ───────────────────────────────────────────────────────────────── /// How aggressively a restore acts. Dry-run is the safe default. @@ -664,6 +735,16 @@ mod tests { const ALBUM: u128 = 0xA1; + /// The wrap-key cost these tests export under. The artifact records it, so opening is just + /// as cheap; the wrap key's *strength* is orthogonal to the format, HMAC, signature and + /// reconciliation behaviour under test, and [`WRAP_PARAMS`] is exercised by the callers that + /// ship to users. + const FAST: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }; + struct Fix { device: HybridSigningKey, write: HybridSigningKey, @@ -752,8 +833,8 @@ mod tests { let f = Fix::new(); let input = f.input(vec![f.asset(1, b"alpha"), f.asset(2, b"beta")]); let salt = [0x11; 32]; - let a = export_with_salt(&input, b"pw", salt, &f.device).unwrap(); - let b = export_with_salt(&input, b"pw", salt, &f.device).unwrap(); + let a = export_with_salt_and_params(&input, b"pw", salt, FAST, &f.device).unwrap(); + let b = export_with_salt_and_params(&input, b"pw", salt, FAST, &f.device).unwrap(); assert_eq!(a, b, "deterministic export must be byte-identical"); } @@ -764,7 +845,7 @@ mod tests { f.asset(1, b"hello world"), f.asset(2, b"second asset"), ]); - let bytes = export(&input, b"pw", &f.device).unwrap(); + let bytes = export_with_params(&input, b"pw", FAST, &f.device).unwrap(); let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); // Fresh library (no local heads) → everything applies. @@ -791,7 +872,7 @@ mod tests { with_receipts.receipts = b"receipt-log-cbor-bytes".to_vec(); let plain = f.asset(2, b"no receipts"); // absent = no entry emitted let input = f.input(vec![with_receipts, plain]); - let bytes = export(&input, b"pw", &f.device).unwrap(); + let bytes = export_with_params(&input, b"pw", FAST, &f.device).unwrap(); let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); let report = art.restore(RestoreMode::Commit, &BTreeMap::new()).unwrap(); @@ -812,14 +893,16 @@ mod tests { #[test] fn wrong_passphrase_fails_to_open() { let f = Fix::new(); - let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"right", &f.device).unwrap(); + let bytes = export_with_params(&f.input(vec![f.asset(1, b"x")]), b"right", FAST, &f.device) + .unwrap(); assert!(BackupArtifact::open(&bytes, b"wrong", &f.device.verifying_key()).is_err()); } #[test] fn tampering_an_entry_is_detected() { let f = Fix::new(); - let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + let bytes = + export_with_params(&f.input(vec![f.asset(1, b"x")]), b"pw", FAST, &f.device).unwrap(); // Flip a byte somewhere in the archive body (a blob) → entry-hash or HMAC mismatch. let mut t = bytes.clone(); let mid = t.len() / 2; @@ -830,7 +913,8 @@ mod tests { #[test] fn wrong_exporter_key_is_rejected() { let f = Fix::new(); - let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + let bytes = + export_with_params(&f.input(vec![f.asset(1, b"x")]), b"pw", FAST, &f.device).unwrap(); let imposter = HybridSigningKey::from_seed_bytes(&[9; 32], &[9; 32]).verifying_key(); assert!(BackupArtifact::open(&bytes, b"pw", &imposter).is_err()); } @@ -842,7 +926,7 @@ mod tests { let mut input = f.input(vec![f.asset(1, b"x")]); input.amks.clear(); assert!(matches!( - export(&input, b"pw", &f.device), + export_with_params(&input, b"pw", FAST, &f.device), Err(BackupError::AmkIncomplete(_)) )); } @@ -853,7 +937,7 @@ mod tests { let asset = f.asset(1, b"content"); let head = asset.provenance.last().unwrap().record_hash(); let asset_id = asset.asset_id; - let bytes = export(&f.input(vec![asset]), b"pw", &f.device).unwrap(); + let bytes = export_with_params(&f.input(vec![asset]), b"pw", FAST, &f.device).unwrap(); let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); // Identical local head → no-op. @@ -882,7 +966,8 @@ mod tests { #[test] fn dry_run_writes_nothing() { let f = Fix::new(); - let bytes = export(&f.input(vec![f.asset(1, b"x")]), b"pw", &f.device).unwrap(); + let bytes = + export_with_params(&f.input(vec![f.asset(1, b"x")]), b"pw", FAST, &f.device).unwrap(); let art = BackupArtifact::open(&bytes, b"pw", &f.device.verifying_key()).unwrap(); let r = art.restore(RestoreMode::DryRun, &BTreeMap::new()).unwrap(); // DryRun verifies (decrypts) but returns nothing to write. diff --git a/capsule-core/src/backup/mod.rs b/capsule-core/src/backup/mod.rs index 39a976a8..146c268d 100644 --- a/capsule-core/src/backup/mod.rs +++ b/capsule-core/src/backup/mod.rs @@ -15,8 +15,14 @@ pub mod artifact; pub use artifact::{ - BackupArtifact, BackupAsset, BackupInput, RestoreMode, RestoreReport, export, export_with_salt, + BackupArtifact, BackupAsset, BackupInput, RestoreMode, RestoreReport, WRAP_PARAMS, export, + export_with_salt, }; +/// The caller-chosen-cost export entry points. Gated identically to their definitions: a weak +/// Argon2id cost yields a brute-forceable artifact, so reaching one takes `cfg(test)` or an +/// explicit `test-support` line in the consuming manifest. +#[cfg(any(test, feature = "test-support"))] +pub use artifact::{export_with_params, export_with_salt_and_params}; use thiserror::Error; use crate::crypto::primitives::DeviceTier; diff --git a/capsule-core/src/client_build.rs b/capsule-core/src/client_build.rs index 3f0859e2..04c1e569 100644 --- a/capsule-core/src/client_build.rs +++ b/capsule-core/src/client_build.rs @@ -12,8 +12,10 @@ //! `capsule-android`, `capsule-desktop`, `capsule-cli`, `capsule-web`, or an out-of-repo //! client's own stable id). //! - `commit` — the git commit of the client's own source tree (a `≥ 12`-hex prefix), embedded -//! at build time by [`build.rs`](../build.rs) as [`BUILD_COMMIT`]. -//! - `.dirty` — appended ([`BUILD_DIRTY_SUFFIX`]) when built from a modified tree. +//! at build time by [`build.rs`](../build.rs) as +//! [`BUILD_COMMIT`](crate::client_build::BUILD_COMMIT). +//! - `.dirty` — appended ([`BUILD_DIRTY_SUFFIX`](crate::client_build::BUILD_DIRTY_SUFFIX)) when +//! built from a modified tree. //! //! The value is **audit-only** and never load-bearing for authorization: `verify_asset` does not //! gate on the grammar (a nonconforming string still verifies), so this module is producer diff --git a/capsule-core/src/constants.rs b/capsule-core/src/constants.rs index 6e0b7a43..de070fc9 100644 --- a/capsule-core/src/constants.rs +++ b/capsule-core/src/constants.rs @@ -1,10 +1,3 @@ -pub const IGNORE_RULES: &[&str] = &[ - // Ignore hidden files and directories - ".*", - // Ignore system files - "*.DS_Store", -]; - pub const SIDECAR_EXTENSIONS: &[&str] = &[ // XMP "xmp", // Custom formats diff --git a/capsule-core/src/crypto/authority/mod.rs b/capsule-core/src/crypto/authority/mod.rs index f05cd476..0b415ad7 100644 --- a/capsule-core/src/crypto/authority/mod.rs +++ b/capsule-core/src/crypto/authority/mod.rs @@ -20,7 +20,7 @@ //! Because `verify_asset` consumes only `&dyn AlbumAuthority`, the two are interchangeable; the //! [`Authority`] enum lets a caller store either without naming a concrete backend. //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! SSoT for the rules this seam encodes: [Keys — Write Authorization]. //! //! [Keys — Write Authorization]: https://docs/design/cryptography/keys/#write-authorization diff --git a/capsule-core/src/crypto/authority/openmls_authority/identity.rs b/capsule-core/src/crypto/authority/openmls_authority/identity.rs index b35cb589..7cfc8300 100644 --- a/capsule-core/src/crypto/authority/openmls_authority/identity.rs +++ b/capsule-core/src/crypto/authority/openmls_authority/identity.rs @@ -140,7 +140,7 @@ pub(crate) fn verify_leaf_binding( Ok((binding.core.user_id, binding.core.device_id)) } -/// One device's MLS participation identity: the owned OpenMLS [provider](CapsuleMlsProvider) +/// One device's MLS participation identity: the owned OpenMLS provider (`CapsuleMlsProvider`) /// (crypto + rand + serializable storage), the device's **MLS Ed25519 leaf signer**, and the /// device's **hybrid DSK identity key** used to attest the leaf binding. /// diff --git a/capsule-core/src/crypto/authority/openmls_authority/mod.rs b/capsule-core/src/crypto/authority/openmls_authority/mod.rs index d0916f8d..b5c3cb50 100644 --- a/capsule-core/src/crypto/authority/openmls_authority/mod.rs +++ b/capsule-core/src/crypto/authority/openmls_authority/mod.rs @@ -1,8 +1,8 @@ //! The live [`AlbumAuthority`] backed by a real OpenMLS group (RFC 9420), pinned to the //! X-Wing PQ ciphersuite `MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519` (`0x004D`) via the //! formally-verified libcrux provider. This is the design-target authority the offline -//! [`ReferenceAuthority`](super::ReferenceAuthority) stands in for — it drops in behind the -//! same `&dyn AlbumAuthority` seam without touching [`verify_asset`](crate::crypto::verify_asset). +//! [`ReferenceAuthority`](super::ReferenceAuthority) stands in for — it drops in behind the same +//! `&dyn AlbumAuthority` seam without touching [`verify_asset`](fn@crate::crypto::verify_asset). //! //! **Slice S-X1** landed the backend/authority layer: single-member (self) group creation, the //! epoch-ledger semantics `verify_asset` consumes (monotonic ceiling, per-epoch write-tier key, @@ -36,9 +36,8 @@ //! vehicle for a future move off the `0x004D` X-Wing suite), `intent_id`-keyed and resumable; //! - the **group re-keying ceremony** ([resilience]): a compromise/scheduled response that mints a //! fresh AMK + write-tier key for every member as one `intent_id`-keyed, resumable operation; -//! - **reconciliation** ([`ReconcileOutcome`](resilience::ReconcileOutcome)): the single -//! "bring-me-current" entry point over the server-authoritative commit chain, plus the -//! lost-commit retry primitive. +//! - **reconciliation** ([`ReconcileOutcome`]): the single "bring-me-current" entry point over +//! the server-authoritative commit chain, plus the lost-commit retry primitive. //! //! SSoT: [Cryptography — MLS](https://docs/design/cryptography/mls/), //! [Keys — Write Authority](https://docs/design/cryptography/keys/#write-authorization), @@ -90,7 +89,7 @@ use crate::crypto::keys::{AmkVersion, DeviceDirectory, HybridSigningKey, HybridV pub(crate) const PINNED_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519; -/// The wire codepoint of [`PINNED_CIPHERSUITE`]. Asserted in tests so an upstream re-pin can +/// The wire codepoint of `PINNED_CIPHERSUITE`. Asserted in tests so an upstream re-pin can /// never silently change the suite Capsule negotiates. pub const PINNED_CIPHERSUITE_ID: u16 = 0x004D; @@ -163,7 +162,7 @@ pub enum OpenMlsAuthorityError { /// upgraded and all activity has moved to the fork. Reads are unaffected — only writes refuse. #[error("album is tombstoned under upgrade intent {0}")] Tombstoned(Uuid), - /// A step of the [tombstone-plus-fork upgrade ceremony](upgrade) failed (intent signature, + /// A step of the tombstone-plus-fork upgrade ceremony failed (intent signature, /// quiescence conflict, or fork construction). #[error("album upgrade ceremony: {0}")] Upgrade(String), @@ -172,7 +171,7 @@ pub enum OpenMlsAuthorityError { /// normal operation (each member independently). #[error("frozen-state hash mismatch: at least one member's album view diverges")] FrozenStateMismatch, - /// A [resilience](resilience) operation (reconciliation / re-keying) failed. + /// A resilience operation (reconciliation / re-keying) failed. #[error("mls resilience: {0}")] Resilience(String), /// [`block_user`](OpenMlsAuthority::block_user) was asked to block the **local** user. A device @@ -213,8 +212,8 @@ struct EpochState { amk: [u8; AMK_LEN], } -/// How an epoch's write-tier key material arrives at [`ingest_current_epoch`] -/// (`OpenMlsAuthority::ingest_current_epoch`). +/// How an epoch's write-tier key material arrives at +/// [`OpenMlsAuthority::ingest_current_epoch`]. enum WriteTierIngest { /// This member is the committer: it minted the keypair (holds both halves). Minted(HybridSigningKey), @@ -312,7 +311,7 @@ impl BlockOutcome { } } -/// An [`AlbumAuthority`] backed by a live OpenMLS group pinned to [`PINNED_CIPHERSUITE`]. +/// An [`AlbumAuthority`] backed by a live OpenMLS group pinned to `PINNED_CIPHERSUITE`. /// /// One instance owns one album's group **as seen by one device**. Its epoch ledger is produced by /// real MLS commits (self-update, add, remove) and Welcome processing; every `verify_asset` answer @@ -1189,7 +1188,7 @@ impl OpenMlsAuthority { /// **Roles seam:** core has no roles model yet, so every member is a writer and this returns /// all leaves — which is what makes the group-encrypted application channel a faithful /// "writers-only" delivery today. When the roles model lands, this filter narrows to the - /// role-holding leaves and [`build_write_tier_distribution`](Self::build_write_tier_distribution)'s + /// role-holding leaves and `build_write_tier_distribution`'s /// send-path switches to per-writer delivery (the group channel is readable by every member, /// so a proper subset cannot ride it). pub fn writers(&self) -> Vec { @@ -1430,9 +1429,10 @@ fn describe_content(content: &ProcessedMessageContent) -> &'static str { } } -/// Turn an incoming MLS message into a [`ProtocolMessage`] (a commit or application message) or a -/// typed error. Uses OpenMLS's public `try_into_protocol_message` (the `into_protocol_message` -/// convenience is `test-utils`-gated upstream). +/// Turn an incoming MLS message into a [`ProtocolMessage`](openmls::prelude::ProtocolMessage) (a +/// commit or application message) or a typed error. Uses OpenMLS's public +/// `try_into_protocol_message` (the `into_protocol_message` convenience is `test-utils`-gated +/// upstream). fn protocol_message(message: MlsMessageIn) -> Result { message.try_into_protocol_message().map_err(|e| { OpenMlsAuthorityError::UnexpectedMessage(format!("not a protocol message: {e:?}")) diff --git a/capsule-core/src/crypto/hash.rs b/capsule-core/src/crypto/hash.rs index e711d1b3..f3626ae9 100644 --- a/capsule-core/src/crypto/hash.rs +++ b/capsule-core/src/crypto/hash.rs @@ -159,6 +159,21 @@ pub fn hash_reader(mut reader: R) -> io::Result { Ok(hasher.finalize()) } +/// SHA-256 of a file's contents, streamed in fixed blocks. +/// +/// Opens `path` and feeds it through [`hash_reader`] rather than reading the whole file +/// into memory, so arbitrarily large originals hash with bounded memory. +/// +/// `native`-gated, unlike the rest of this module: the browser sealing build +/// (`--no-default-features`, `wasm32-unknown-unknown`) has no filesystem, so an ungated +/// `hash_file` would compile there and then fail at runtime on every call. Its two callers +/// — the import planner and the file-metadata reader — are `native` anyway. +#[cfg(feature = "native")] +pub fn hash_file(path: &std::path::Path) -> io::Result { + let file = std::fs::File::open(path)?; + hash_reader(io::BufReader::new(file)) +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +216,26 @@ mod tests { assert_eq!(hash_reader(&data[..]).unwrap(), one_shot); } + #[cfg(feature = "native")] + #[test] + fn hash_file_matches_one_shot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("blob.bin"); + // Larger than the 64 KiB read block, so the streaming path takes more than one turn. + let data = vec![0x5Au8; 64 * 1024 + 7]; + std::fs::write(&path, &data).unwrap(); + + assert_eq!(hash_file(&path).unwrap(), hash_bytes(&data)); + } + + #[cfg(feature = "native")] + #[test] + fn hash_file_reports_a_missing_path() { + let dir = tempfile::tempdir().unwrap(); + let err = hash_file(&dir.path().join("absent.bin")).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::NotFound); + } + #[test] fn hex_round_trip() { let h = hash_bytes(b"capsule"); diff --git a/capsule-core/src/crypto/keys/album.rs b/capsule-core/src/crypto/keys/album.rs index cd11a6c1..655bc7a7 100644 --- a/capsule-core/src/crypto/keys/album.rs +++ b/capsule-core/src/crypto/keys/album.rs @@ -32,7 +32,7 @@ impl AmkVersion { /// A random 32-byte album content key for one epoch. Holding it lets you decrypt; not /// holding it means you cannot (secrecy is enforced by encryption, authorization by -/// signatures — see [`super::hybrid_sig`] write-tier keys). +/// signatures — see [`HybridSigningKey`](super::HybridSigningKey) write-tier keys). #[derive(Clone)] pub struct Amk([u8; 32]); diff --git a/capsule-core/src/crypto/keys/albumstore.rs b/capsule-core/src/crypto/keys/albumstore.rs index 1f6f1170..ae81a8bb 100644 --- a/capsule-core/src/crypto/keys/albumstore.rs +++ b/capsule-core/src/crypto/keys/albumstore.rs @@ -59,10 +59,10 @@ use crate::utils::paths::tmp_path; /// The album store's on-disk format version. Bumped only on a breaking layout change; a store /// declaring a higher version is refused rather than misread. -pub const ALBUM_STORE_VERSION: u16 = 1; +pub(crate) const ALBUM_STORE_VERSION: u16 = 1; /// The store's filename beneath `{root}/.library/`. -pub const ALBUM_STORE_FILE: &str = "albums.cbor"; +pub(crate) const ALBUM_STORE_FILE: &str = "albums.cbor"; /// One escrowed album content key: `(album_id, epoch, amk)`. /// @@ -110,7 +110,7 @@ pub enum AlbumStoreError { type Result = std::result::Result; /// One album's persisted authority state, behind the -/// [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) seam. +/// [`AlbumAuthority`] seam. /// /// Both variants exist on **every** build, `mls` or not: a non-mls build must be able to *decode* /// an MLS-authority album and report it as [`AlbumStoreError::MlsUnavailable`], which it could not diff --git a/capsule-core/src/crypto/keys/directory.rs b/capsule-core/src/crypto/keys/directory.rs index 1f207569..b90936c0 100644 --- a/capsule-core/src/crypto/keys/directory.rs +++ b/capsule-core/src/crypto/keys/directory.rs @@ -46,7 +46,7 @@ use crate::crypto::CryptoError; /// - the software **X-Wing** DEK, `pk_M ‖ pk_X` — [`DEK_PUBLIC_LEN`] (1216) bytes; /// - the hardware-bound **P-256 hybrid** DEK, `ek_M ‖ pk_P` — [`DEK_P256_PUBLIC_LEN`] (1249). /// -/// They are **length-disjoint by construction** (see [`kem_p256`](super::kem_p256)), so the +/// They are **length-disjoint by construction** (see [`P256HybridDek`](super::P256HybridDek)), so the /// composition is recovered from the bytes themselves and needs no tag — the same way a /// [`HybridVerifyingKey`] recovers its Ed25519-vs-P-256 classical half from the wire length, and /// the same way [`DeviceDek::public_bytes`](super::keystore::DeviceDek::public_bytes) already diff --git a/capsule-core/src/crypto/keys/hardware.rs b/capsule-core/src/crypto/keys/hardware.rs index c9fa2148..ce781b4f 100644 --- a/capsule-core/src/crypto/keys/hardware.rs +++ b/capsule-core/src/crypto/keys/hardware.rs @@ -51,7 +51,7 @@ pub trait HardwareSigner: Send + Sync { /// [`HardwareSigner`], implemented by native code (Swift/Kotlin) over the uniffi foreign-trait /// boundary. It backs the hardware-bound classical half of the device **encryption** key (DEK): /// shipping secure elements expose **ECDH-P256** (Secure Enclave `P256.KeyAgreement`, StrongBox -/// ECDH, TPM 2.0 ECDH), so the DEK's X25519 half of [the X-Wing KEM](super::kem) is replaced by a +/// ECDH, TPM 2.0 ECDH), so the DEK's X25519 half of [the X-Wing KEM](super::DekKeypair) is replaced by a /// hardware-held P-256 static key while the ML-KEM-768 half stays software-sealed. The element /// holds the P-256 private key and performs the ECDH so it never leaves hardware; Rust drives the /// ML-KEM half and the hybrid combiner (SSoT: [Cryptography — Keys § Device Keys], slice `S-F5`). diff --git a/capsule-core/src/crypto/keys/kem.rs b/capsule-core/src/crypto/keys/kem.rs index 3b93ae9c..2f1c9795 100644 --- a/capsule-core/src/crypto/keys/kem.rs +++ b/capsule-core/src/crypto/keys/kem.rs @@ -28,7 +28,7 @@ use crate::crypto::{CryptoError, rng}; /// Length of the X-Wing secret-key seed. SHAKE256-expanded to 96 bytes: the ML-KEM-768 seed /// `d ‖ z` (64) and the X25519 secret scalar (32). -pub const DEK_SEED_LEN: usize = 32; +pub(crate) const DEK_SEED_LEN: usize = 32; /// X-Wing public-key length: `pk_M (1184) ‖ pk_X (32)`. pub const DEK_PUBLIC_LEN: usize = 1184 + 32; diff --git a/capsule-core/src/crypto/keys/keystore.rs b/capsule-core/src/crypto/keys/keystore.rs index 6221bdaf..ab44f68f 100644 --- a/capsule-core/src/crypto/keys/keystore.rs +++ b/capsule-core/src/crypto/keys/keystore.rs @@ -41,8 +41,9 @@ use crate::crypto::{CryptoError, pwkdf}; /// /// Both expose the same two operations — publish a public encapsulation key, decapsulate a /// ciphertext sealed to it — so every caller is agnostic to where the classical half lives. The -/// two byte formats are length-disjoint (see [`kem_p256`](super::kem_p256)), so a ciphertext for -/// one is rejected outright by the other rather than silently recovering a wrong secret. +/// two byte formats are length-disjoint — X-Wing's [`DekKeypair`] and the P-256 hybrid's +/// [`P256HybridDek`] publish and accept different lengths — so a ciphertext for one is rejected +/// outright by the other rather than silently recovering a wrong secret. pub enum DeviceDek { /// **Software fallback.** X-Wing (X25519 + ML-KEM-768), both halves in software. The /// composition every host can run, including those with no secure element and the TPM-1.2 / diff --git a/capsule-core/src/crypto/keys/mod.rs b/capsule-core/src/crypto/keys/mod.rs index d91670cf..85ec4ab3 100644 --- a/capsule-core/src/crypto/keys/mod.rs +++ b/capsule-core/src/crypto/keys/mod.rs @@ -7,30 +7,30 @@ //! //! [Cryptography — Keys]: https://docs/design/cryptography/keys/ -pub mod album; +pub(crate) mod album; // The sealed, durable album-key store (slice `S-A10`) is filesystem-backed — it writes through // `utils::paths::tmp_path` for atomic replace — so it is gated with the rest of the native // surface. Leaving it ungated broke the `wasm32-unknown-unknown` sealing build (`S-A6`), which // no gate builds: `check-rust` compiles the host triple only. #[cfg(feature = "native")] -pub mod albumstore; -pub mod directory; -pub mod hardware; -pub mod hybrid_sig; -pub mod kem; -pub mod kem_p256; -pub mod keystore; -pub mod master; -pub mod p256; -pub mod signer; -pub mod software; +pub(crate) mod albumstore; +pub(crate) mod directory; +pub(crate) mod hardware; +pub(crate) mod hybrid_sig; +pub(crate) mod kem; +pub(crate) mod kem_p256; +pub(crate) mod keystore; +pub(crate) mod master; +pub(crate) mod p256; +pub(crate) mod signer; +pub(crate) mod software; // The Windows TPM 2.0 `HardwareSigner` over TBS (slice `S-F4`). The pure wire codec compiles on // any host under `cfg(test)` (host-runnable mock tests); the TBS transport + signer are Windows // only. Unlike `tpm` (the tss-esapi Linux reference), TBS needs no external crate — it links // `tbs.dll` via `windows-sys`. -pub mod tbs; +pub(crate) mod tbs; #[cfg(feature = "tpm")] -pub mod tpm; +pub(crate) mod tpm; pub use album::{Amk, AmkVersion}; #[cfg(feature = "native")] diff --git a/capsule-core/src/crypto/keys/p256.rs b/capsule-core/src/crypto/keys/p256.rs index 1a44ed89..e543e5a1 100644 --- a/capsule-core/src/crypto/keys/p256.rs +++ b/capsule-core/src/crypto/keys/p256.rs @@ -33,7 +33,8 @@ use crate::crypto::CryptoError; /// Parse a hardware element's P-256 public key into a verifying key. Shipping elements emit the /// point in one of three shapes: compressed SEC1 (33 bytes), uncompressed SEC1 (65 bytes, /// `0x04‖x‖y`, e.g. Secure Enclave), or the bare `x‖y` coordinate pair (64 bytes, e.g. the TPM -/// reference in [`super::tpm`]). All three normalize to the same key. +/// reference in the `tpm` adapter, which is `tpm`-feature-gated and so has no doc page in a +/// default build). All three normalize to the same key. fn parse_p256_public(point: &[u8]) -> Result { let vk = match point.len() { 33 | 65 => P256VerifyingKey::from_sec1_bytes(point), diff --git a/capsule-core/src/crypto/keys/tbs.rs b/capsule-core/src/crypto/keys/tbs.rs index ca0174d4..62ff7876 100644 --- a/capsule-core/src/crypto/keys/tbs.rs +++ b/capsule-core/src/crypto/keys/tbs.rs @@ -1,20 +1,21 @@ -//! Windows TPM 2.0 [`HardwareSigner`] over **TBS** (TPM Base Services) — slice `S-F4`. +//! Windows TPM 2.0 [`HardwareSigner`](super::HardwareSigner) over **TBS** (TPM Base Services) — +//! slice `S-F4`. //! -//! The [`tpm`](super::tpm) reference adapter drives a TPM through `tss-esapi`'s high-level ESAPI -//! and links the system `libtss2` — the Linux path. Windows exposes the TPM through `tbs.dll` -//! instead: a *raw command channel*. [`Tbsip_Submit_Command`] takes a marshalled TPM 2.0 command -//! byte-stream and returns the raw response, with no ESAPI in between. This adapter therefore -//! marshals the same key lifecycle the reference performs — `CreatePrimary` → `Create` → `Load` -//! → `EvictControl`, then `ReadPublic` / `Hash` + `Sign` — directly to the wire and submits each -//! through TBS. +//! The `tpm` reference adapter (`tpm`-feature-gated, so undocumented in a default build) drives a +//! TPM through `tss-esapi`'s high-level ESAPI and links the system `libtss2` — the Linux path. +//! Windows exposes the TPM through `tbs.dll` instead: a *raw command channel*. +//! [`Tbsip_Submit_Command`] takes a marshalled TPM 2.0 command byte-stream and returns the raw +//! response, with no ESAPI in between. This adapter therefore marshals the same key lifecycle the +//! reference performs — `CreatePrimary` → `Create` → `Load` → `EvictControl`, then `ReadPublic` / +//! `Hash` + `Sign` — directly to the wire and submits each through TBS. //! //! # P-256, composed //! //! Shipping TPMs expose **ECDSA over NIST P-256**, so — exactly like Secure Enclave and StrongBox -//! (slice `S-F2`) and the [`tpm`](super::tpm) reference — the classical half is P-256, and this -//! signer plugs into [`P256HybridSigningKey`](super::p256::P256HybridSigningKey) unchanged: -//! [`enroll`](HardwareSigner::enroll) returns the bare `x‖y` public point (64 bytes — the form -//! [`super::p256::parse_p256_public`] normalizes), and [`sign_classical`] returns a **DER-encoded** +//! (slice `S-F2`) and the `tpm` reference — the classical half is P-256, and this signer plugs +//! into [`P256HybridSigningKey`](super::p256::P256HybridSigningKey) unchanged: +//! [`enroll`](super::HardwareSigner::enroll) returns the bare `x‖y` public point (64 bytes — the +//! form `p256::parse_p256_public` normalizes), and [`sign_classical`] returns a **DER-encoded** //! ECDSA signature (the composition's contract; the TPM emits raw `r‖s`, which this adapter //! re-encodes). The ML-DSA-65 half stays software-sealed. //! @@ -22,8 +23,8 @@ //! //! The signing key is created with `fixedTPM | fixedParent | sensitiveDataOrigin`, so its private //! portion is generated inside the TPM and can never be duplicated out. -//! [`assert_non_exportable`](HardwareSigner::assert_non_exportable) re-reads the public area and -//! confirms `fixedTPM`/`fixedParent` — the TBS analogue of the reference's check. +//! [`assert_non_exportable`](super::HardwareSigner::assert_non_exportable) re-reads the public +//! area and confirms `fixedTPM`/`fixedParent` — the TBS analogue of the reference's check. //! //! # Testing //! @@ -34,7 +35,8 @@ //! Enclave availability gate. //! //! [`Tbsip_Submit_Command`]: https://learn.microsoft.com/windows/win32/api/tbs/nf-tbs-tbsip_submit_command -//! [`sign_classical`]: HardwareSigner::sign_classical +//! [`Tbsi_Is_Tpm_Present`]: https://learn.microsoft.com/windows/win32/api/tbs/nf-tbs-tbsi_is_tpm_present +//! [`sign_classical`]: super::HardwareSigner::sign_classical #[cfg(windows)] pub use backend::TbsTpmSigner; diff --git a/capsule-core/src/crypto/membership.rs b/capsule-core/src/crypto/membership.rs new file mode 100644 index 00000000..3844c90d --- /dev/null +++ b/capsule-core/src/crypto/membership.rs @@ -0,0 +1,350 @@ +//! The album roster attestation — the one membership fact a **key-free server** can verify +//! (slice `S-C51`). +//! +//! # What the server cannot see, and what this gives it instead +//! +//! Membership of a shared album is decided inside the MLS group, and every control message +//! that adds or removes a member is AEAD-protected under a group key the server never holds. +//! `crypto::authority` can classify a commit chain as behind, ahead or forked from a server's +//! view of it, but it cannot tell the server *who is in the group*. So the server has no +//! roster — and without one it cannot answer "may this account read this album's blobs", which +//! is why the blob route and the album write routes have been owner-only. +//! +//! The [`SignedAlbumRoster`] is the album owner's **statement** of the roster, signed by one of +//! the owner account's devices. The server verifies it against the owner's published +//! [`DeviceDirectory`] — the same trust anchor `S-C42` established and the same check +//! [`SignedUpgradeIntent::verify`](crate::crypto::upgrade::SignedUpgradeIntent::verify) runs +//! for the upgrade ceremony — and then holds it as a *transport control*: who may fetch which +//! bytes. It is **not** a confidentiality control. A former member who kept the AMK for an +//! epoch can still decrypt what they already downloaded; what the roster does is stop the +//! server handing them anything further, exactly as design/federation.md says an unshare cuts +//! read access to the historical photos at the transport level. +//! +//! # A full document, versioned, and the owner is implicit +//! +//! The roster is the **whole** member list every time, with a strictly monotonic +//! [`AlbumRoster::roster_version`] — the same shape as the device directory's +//! `directory_version` (invariant 23). One monotonic field gives idempotency, replay-safety and +//! ordering at once, and it needs no per-grant ids and no separate revocation artefact: +//! removal is *absence* at a higher version. The owner account is never listed, because the +//! owner's access is the album record's `owner_id` fact and a roster that could omit the owner +//! would be a roster that could lock the owner out. +//! +//! # Why this lives here and not in the server crate +//! +//! A client signs it. The DSK that signs a roster is on a device, so the type must be +//! constructible and signable without the server crate — which is the rule `crypto::upgrade` +//! records for the upgrade intent, for the same reason: a structure defined at both ends is one +//! added field away from a signature that stops verifying. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::crypto::keys::{AmkVersion, DeviceDirectory, HybridSignature, HybridSigningKey}; + +/// What went wrong encoding or verifying an album roster. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MembershipError { + /// The roster could not be canonically encoded for signing. + #[error("the album roster could not be encoded: {0}")] + Encode(String), + /// The signature did not verify, or the attesting device is not a live device of the album + /// owner's account. + #[error("the album roster's attester signature did not verify: {0}")] + Attester(&'static str), +} + +/// What a member may do with the album's contents, as far as the server is concerned. +/// +/// Two values only. The finer MLS-side distinctions (admin, for one) never reach the server, +/// which needs exactly this: whether to serve bytes, and whether to accept them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemberRole { + /// May read the album's blobs and its sync feed. + Reader, + /// May read, and may add or change assets under the album owner's namespace. + Writer, +} + +/// One member of an album, as the roster names them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RosterMember { + /// The member's account. + pub user_id: Uuid, + /// What the member may do. + pub role: MemberRole, +} + +/// The signed-over content of a roster attestation. Every field is covered by the attesting +/// device's DSK hybrid signature in the enclosing [`SignedAlbumRoster`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AlbumRoster { + /// The album this roster is for. + pub album_id: Uuid, + /// Strictly monotonic per album. A server refuses a version at or below the one it holds + /// unless the bytes are identical (a replay), so a roster can neither be rolled back nor + /// silently replaced. + /// + /// A server also bounds it **above**, by a small step over the version it holds: the field + /// is an ordering, not a count, and a version nothing could ever exceed would freeze the + /// album's membership permanently. A client that is refused for it re-signs the same + /// document one above the version the refusal names. + pub roster_version: u64, + /// The AMK epoch the group is at after the commit this roster reflects. Non-decreasing + /// across versions; the server records the epoch at which a member was granted and the one + /// at which they vanished. + pub amk_epoch: AmkVersion, + /// The album owner's account. The server anchors on this account's published device + /// directory, and refuses a roster whose owner is not the album's. + pub attested_by_user: Uuid, + /// The owner-account device whose DSK signed this roster. Must be present and **not + /// revoked** in the owner's directory. + pub attested_by_device: Uuid, + /// RFC 3339 time the client produced the roster. Audit-only: the server orders by + /// `roster_version`, never by this. + pub attested_at: String, + /// Everyone other than the owner who may read the album, and what they may do. Absence at a + /// higher version *is* removal. + pub members: Vec, +} + +impl AlbumRoster { + /// The canonical-CBOR signing bytes the attesting device's DSK covers. + /// + /// # Errors + /// + /// Returns [`MembershipError::Encode`] if the roster cannot be canonically encoded. + pub fn signing_bytes(&self) -> Result, MembershipError> { + crate::cbor::to_canonical_vec(self).map_err(|e| MembershipError::Encode(e.to_string())) + } +} + +/// An [`AlbumRoster`] plus the attesting device's DSK **hybrid** signature over it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedAlbumRoster { + /// The attested roster. + pub roster: AlbumRoster, + /// The attesting DSK's hybrid signature over [`AlbumRoster::signing_bytes`]. + pub attester_sig: HybridSignature, +} + +impl SignedAlbumRoster { + /// Sign `roster` with the attesting device's DSK. + /// + /// The caller is responsible for `roster.attested_by_device` naming the device `dsk` + /// belongs to; [`verify`](Self::verify) is what checks it, on the other end. + /// + /// # Errors + /// + /// Returns [`MembershipError::Encode`] if the roster cannot be canonically encoded. + pub fn sign(roster: AlbumRoster, dsk: &HybridSigningKey) -> Result { + let attester_sig = dsk.sign(&roster.signing_bytes()?); + Ok(Self { + roster, + attester_sig, + }) + } + + /// Verify the attester's DSK hybrid signature (Ed25519 **and** ML-DSA) against the album + /// owner's published device directory. + /// + /// Stricter than the upgrade intent's check in one respect: a device the directory has + /// **revoked** may not attest a roster, whatever it signed before. The entry is retained so + /// that older manifests stay verifiable; it is not a licence to keep issuing new documents. + /// + /// # Errors + /// + /// Returns [`MembershipError::Attester`] when the directory names a different account, does + /// not hold the attesting device, holds it revoked, or the signature does not verify under + /// its DSK. + pub fn verify(&self, directory: &DeviceDirectory) -> Result<(), MembershipError> { + if directory.core.user_id != self.roster.attested_by_user { + return Err(MembershipError::Attester( + "the roster's attested_by_user is not this directory's account", + )); + } + let entry = + directory + .device(&self.roster.attested_by_device) + .ok_or(MembershipError::Attester( + "the attesting device is not in the directory", + ))?; + if entry.revoked_at.is_some() { + return Err(MembershipError::Attester("the attesting device is revoked")); + } + if !entry + .dsk_public + .verify(&self.roster.signing_bytes()?, &self.attester_sig) + { + return Err(MembershipError::Attester( + "the attester's DSK signature does not verify", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::keys::{DeviceEntry, DirectoryCore}; + + const OWNER: Uuid = Uuid::from_u128(0xA11CE); + const DEVICE: Uuid = Uuid::from_u128(0xD1); + const ALBUM: Uuid = Uuid::from_u128(0xA1B); + const BOB: Uuid = Uuid::from_u128(0xB0B); + const CAROL: Uuid = Uuid::from_u128(0xCA501); + + fn ik() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]) + } + + fn dsk() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]) + } + + fn other_dsk() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[5; 32], &[6; 32]) + } + + fn directory_for(user_id: Uuid, revoked: bool) -> DeviceDirectory { + DirectoryCore { + user_id, + directory_version: 1, + updated_at: "2026-09-01T00:00:00Z".into(), + devices: vec![DeviceEntry { + device_id: DEVICE, + dsk_public: dsk().verifying_key(), + dek_public: None, + added_at: "2026-09-01T00:00:00Z".into(), + revoked_at: revoked.then(|| "2026-09-02T00:00:00Z".to_owned()), + }], + } + .sign(&ik()) + } + + fn roster() -> AlbumRoster { + AlbumRoster { + album_id: ALBUM, + roster_version: 1, + amk_epoch: AmkVersion(1), + attested_by_user: OWNER, + attested_by_device: DEVICE, + attested_at: "2026-09-02T00:00:00Z".into(), + members: vec![ + RosterMember { + user_id: BOB, + role: MemberRole::Writer, + }, + RosterMember { + user_id: CAROL, + role: MemberRole::Reader, + }, + ], + } + } + + #[test] + fn a_roster_signed_by_a_live_owner_device_verifies() { + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!(signed.verify(&directory_for(OWNER, false)), Ok(())); + } + + #[test] + fn the_signed_roster_round_trips_through_canonical_cbor() { + // The server stores the document verbatim and the SDK ships it base64-encoded, so the + // bytes must decode back to a value that still verifies. + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + let bytes = crate::cbor::to_canonical_vec(&signed).expect("encodes"); + let decoded: SignedAlbumRoster = crate::cbor::from_slice(&bytes).expect("decodes"); + assert_eq!(decoded, signed); + assert_eq!(decoded.verify(&directory_for(OWNER, false)), Ok(())); + // And the signing bytes are stable: the same roster encodes to the same bytes. + assert_eq!( + roster().signing_bytes().expect("encodes"), + decoded.roster.signing_bytes().expect("encodes") + ); + } + + #[test] + fn a_directory_of_another_account_is_refused() { + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(BOB, false)), + Err(MembershipError::Attester( + "the roster's attested_by_user is not this directory's account" + )) + ); + } + + #[test] + fn a_device_the_directory_does_not_hold_is_refused() { + let mut unknown = roster(); + unknown.attested_by_device = Uuid::from_u128(0xD2); + let signed = SignedAlbumRoster::sign(unknown, &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attesting device is not in the directory" + )) + ); + } + + #[test] + fn a_revoked_device_may_not_attest_a_roster() { + // Stricter than the upgrade intent: the entry is retained so old manifests verify, not + // so the device can keep issuing new documents. + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, true)), + Err(MembershipError::Attester("the attesting device is revoked")) + ); + } + + #[test] + fn a_tampered_member_role_does_not_verify() { + let mut signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + signed.roster.members[1].role = MemberRole::Writer; + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attester's DSK signature does not verify" + )) + ); + } + + #[test] + fn a_tampered_version_or_epoch_does_not_verify() { + let refused = Err(MembershipError::Attester( + "the attester's DSK signature does not verify", + )); + let mut bumped = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + bumped.roster.roster_version = 2; + assert_eq!(bumped.verify(&directory_for(OWNER, false)), refused); + + let mut rolled = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + rolled.roster.amk_epoch = AmkVersion(2); + assert_eq!(rolled.verify(&directory_for(OWNER, false)), refused); + } + + #[test] + fn a_signature_by_the_wrong_key_does_not_verify() { + // The right device id, the wrong DSK: what a member forging the owner's attestation + // looks like. + let signed = SignedAlbumRoster::sign(roster(), &other_dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attester's DSK signature does not verify" + )) + ); + } + + #[test] + fn roles_encode_as_their_snake_case_tokens() { + let bytes = crate::cbor::to_canonical_vec(&MemberRole::Reader).expect("encodes"); + let value: ciborium::Value = ciborium::from_reader(bytes.as_slice()).expect("decodes"); + assert_eq!(value, ciborium::Value::Text("reader".into())); + } +} diff --git a/capsule-core/src/crypto/mod.rs b/capsule-core/src/crypto/mod.rs index 5bb63611..39ba6f06 100644 --- a/capsule-core/src/crypto/mod.rs +++ b/capsule-core/src/crypto/mod.rs @@ -8,6 +8,7 @@ //! ```text //! hash · primitives · rng · kdf · pwkdf (foundation, no internal deps) //! └─ keys ─ encryption (key hierarchy + AEAD) +//! └─ keys ─ membership (the owner-signed album roster) //! └─ authority ─┐ //! └─ provenance ┴─ verify_asset (the single acknowledgement chokepoint) //! ``` @@ -21,6 +22,7 @@ pub mod encryption; pub mod hash; pub mod kdf; pub mod keys; +pub mod membership; pub mod primitives; pub mod provenance; pub mod pwkdf; diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index f8d115e7..f93e6d65 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -6,7 +6,7 @@ //! excludes the signatures, so signing bytes are unambiguous and downgrade-resistant //! (both sigs cover `crypto_suite_id`, `protocol_version`, and `prior_provenance_hash`). //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ use serde::{Deserialize, Serialize}; @@ -126,9 +126,26 @@ pub struct ManifestCore { /// `delete | derivative-* | trash-restore`. #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata_blob_hash: Option, - /// User who produced the asset. + /// The account whose device signed **this record**. + /// + /// Per-record, not per-asset: a `delete` written by a second device — or by a member of a + /// shared album — names that writer, not the account that created the asset. The asset's + /// original creator is recoverable from the `create` record at the head of the append-only + /// provenance chain, which is where it belongs. + /// + /// The pairing with [`Self::created_by_device`] is load-bearing rather than descriptive: + /// [`verify_asset`](crate::crypto::verify_asset::verify_asset) resolves the device *inside + /// this account's* published directory (step 6) and verifies [`AssetManifest::device_sig`] + /// under that entry's key (step 8), so a record naming anyone but its own signer cannot + /// verify. Album write authority is decided separately, by `write_sig` at step 10. pub created_by_user: Uuid, - /// Device that produced the asset (resolved in the device directory). + /// The device that signed **this record**, resolved in [`Self::created_by_user`]'s directory. + /// + /// Per-record for the same reason and with the same consequence: it must be the device whose + /// DSK produced [`AssetManifest::device_sig`], or step 8 of + /// [`verify_asset`](crate::crypto::verify_asset::verify_asset) rejects the manifest. The + /// server mirrors the resolvable half key-free as invariant 7 — the device must be in the + /// *calling* account's published directory, with `added_at` before the manifest's timestamp. pub created_by_device: Uuid, /// Producing client version string. pub client_version: String, @@ -246,9 +263,47 @@ pub struct DerivativeCore { /// Which kind of derivative. pub role: DerivativeRole, /// MIME/format string, e.g. `image/avif` or `embedding/mobileclip-b`. + /// + /// **A `String`, and deliberately so, even though the still formats are a closed set.** + /// Two reasons, and both are about keeping a *policy* rejection from becoming a *parse* + /// failure: + /// + /// - the same field carries the embedding-role grammar `embedding/{model_id}` + /// (`crate::ml`), which no still-format enum can model, so a single typed field would + /// have to be an enum over both grammars; + /// - a `#[serde(try_from = "String")]` newtype would make an *older* manifest naming a + /// future codec fail at deserialisation — before its signature is examined at all — + /// turning "this receiver does not recognise that format" into "this manifest is + /// unreadable". + /// + /// The closed set is enforced at the two boundaries instead: production, because + /// `media::generate_still_derivatives` only ever writes + /// [`DerivativeFormat::mime`](crate::derivative_format::DerivativeFormat::mime); and + /// verification, via + /// [`verify_still_format`](crate::derivative_format::verify_still_format), which rejects a + /// still-role manifest whose value is outside the set and leaves the embedding-role grammar + /// alone. + /// + /// Both live in [`crate::derivative_format`], which is **unconditional** — deliberately not + /// behind the `media` feature. `capsule-server` and `capsule-wasm` build + /// `default-features = false`, and they are exactly the crates that receive a manifest they + /// did not author, so a check they could not link would be a closed set only its producer + /// could evaluate. SSoT: [Thumbnails](https://docs/design/thumbnails/). pub format: String, /// Content-address digest over the derivative ciphertext. pub ciphertext_hash: Hash32, + /// The STREAM nonce prefix the derivative ciphertext was produced under; folded into the + /// file-key salt, so it also selects the key. + /// + /// **Required, not `Option`.** Derivative bytes are encrypted client-side exactly like the + /// original ([Encryption](https://docs/design/cryptography/encryption/)), so a receiver that + /// cannot recover this cannot decrypt the blob at all — an absent prefix would be an + /// unopenable derivative rather than a tolerable gap. It is safe to make it required + /// because no real `derivative-manifest/v1` has ever been written to any store: derivatives + /// were unconditionally `DeferredNoCodec` until the decoder landed, and `crate::ml` + /// constructs no derivative manifest. There is nothing to stay compatible with, so the + /// schema string does not move. + pub nonce_prefix: [u8; 7], /// Device that generated the derivative. pub generated_by_device: Uuid, /// Generating client version. @@ -473,6 +528,7 @@ mod tests { role: DerivativeRole::Thumbnail, format: "image/avif".into(), ciphertext_hash: Hash32([0xAB; 32]), + nonce_prefix: [9, 8, 7, 6, 5, 4, 3], generated_by_device: Uuid::from_u128(0xD1), generated_by_client: "capsule-cli/0.1.0".into(), model_id: None, diff --git a/capsule-core/src/crypto/provenance/mod.rs b/capsule-core/src/crypto/provenance/mod.rs index 18133f53..23562827 100644 --- a/capsule-core/src/crypto/provenance/mod.rs +++ b/capsule-core/src/crypto/provenance/mod.rs @@ -7,7 +7,7 @@ //! //! Verification of all of this flows through the single [`verify_asset`] chokepoint. //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [Cryptography — Provenance]: https://docs/design/cryptography/provenance/ pub mod action; diff --git a/capsule-core/src/crypto/provenance/record.rs b/capsule-core/src/crypto/provenance/record.rs index 012bf50b..379ef13c 100644 --- a/capsule-core/src/crypto/provenance/record.rs +++ b/capsule-core/src/crypto/provenance/record.rs @@ -36,7 +36,14 @@ impl ProvenanceRecord { } /// Whether the manifest's `prior_provenance_hash` mirrors the record's, as required. - fn mirrors_manifest(&self) -> bool { + /// + /// Crate-visible rather than private because the chain walker is no longer the only + /// checker: [`apply_remote_entry`](crate::lifecycle::Workspace::apply_remote_entry) decodes + /// a record straight off the wire and must run the same check before it trusts either copy. + /// provenance.md's *Chained, Append-Only Structure* is explicit that the two copies are + /// "a checked invariant, not trusted redundancy", and a checker that lived in one place + /// would leave the wire path unchecked. + pub(crate) fn mirrors_manifest(&self) -> bool { self.manifest.core.prior_provenance_hash == self.prior_provenance_hash } } diff --git a/capsule-core/src/crypto/upgrade.rs b/capsule-core/src/crypto/upgrade.rs index 08f42d6a..695efcdc 100644 --- a/capsule-core/src/crypto/upgrade.rs +++ b/capsule-core/src/crypto/upgrade.rs @@ -24,7 +24,7 @@ //! # What the server can decide from here, and what it cannot //! //! It can verify [`SignedUpgradeIntent`] against the account's published -//! [`DeviceDirectory`](crate::crypto::keys::DeviceDirectory) — the same trust anchor `S-C42` +//! [`DeviceDirectory`] — the same trust anchor `S-C42` //! established — so a quiescence it records is one an admin device really asked for. It can //! evaluate [`UpgradeIntent::is_expired`] against its own clock, which is the whole point of the //! deadline being a *duration*: a skewed member clock can neither extend nor shorten the window. @@ -104,7 +104,7 @@ impl UpgradeIntent { } /// An [`UpgradeIntent`] plus the proposing admin device's DSK **hybrid** signature over it. Rides -/// the group's application-message channel (self-describing as [`MlsAppPayload::Upgrade`]). +/// the group's application-message channel (self-describing as the `MlsAppPayload::Upgrade` variant). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct SignedUpgradeIntent { /// The proposed upgrade. diff --git a/capsule-core/src/culling.rs b/capsule-core/src/culling.rs index d60bda18..52a7f229 100644 --- a/capsule-core/src/culling.rs +++ b/capsule-core/src/culling.rs @@ -1,7 +1,7 @@ //! The client-side **culling** workflow engine (SSoT: [Organization — Culling]). //! //! Culling is the review pass a photographer makes after a shoot: keep, undecided, toss. -//! Capsule models it as a trinary [`CullFlag`](crate::sidecar::sidecar_v1::CullFlag) per asset +//! Capsule models it as a trinary [`CullFlag`] per asset //! stored in the sidecar's `cull` LWW register. This module owns the *derived* pieces the //! schema deliberately does **not** store, so there is no second source of truth to diverge: //! diff --git a/capsule-core/src/db/driver.rs b/capsule-core/src/db/driver.rs index c4dff898..1418b711 100644 --- a/capsule-core/src/db/driver.rs +++ b/capsule-core/src/db/driver.rs @@ -11,17 +11,33 @@ use crate::domain::model_identity::TaskKind; pub struct DatabaseDriver { pub(in crate::db) conn: Connection, /// Tasks whose `vec0` partition table this driver has already created. The DDL is idempotent, - /// so this is purely a hot-path memo — see [`crate::db::vector::VectorTableSpec`] for why the + /// so this is purely a hot-path memo — see [`crate::db::VectorTableSpec`] for why the /// tables are created by their writer rather than at open time. pub(in crate::db) vector_tables: RefCell>, } impl DatabaseDriver { + /// Open (or create) the catalog at `path` and bring it to the crate's `SCHEMA_VERSION`. + /// + /// The migrator's typed [`MigrationError`] is flattened into `rusqlite::Error` here + /// because this signature is consumed by `capsule-core-ffi`; the crate's own library + /// opener goes through the crate-private `open_typed` so a catalog newer than this build + /// surfaces as a typed refusal rather than a message inside a `SqliteFailure`. pub fn open(path: &Path) -> Result { + Self::open_typed(path).map_err(rusqlite::Error::from) + } + + /// As [`open`](Self::open), keeping the migrator's typed error. + /// + /// [`MigrationError::CatalogTooNew`] is the one variant a caller acts on differently: the + /// catalog was left untouched and the recovery is to update the app, so `library::open` + /// maps it to [`LibraryError::CatalogTooNew`](crate::library::LibraryError::CatalogTooNew) + /// instead of folding it into a generic database error (slice `S-D23`). + pub(crate) fn open_typed(path: &Path) -> Result { crate::db::vector::ensure_vec_extension(); let conn = Connection::open(path)?; let driver = Self::new(conn); - driver.init_schema()?; + driver.migrate()?; Ok(driver) } @@ -40,8 +56,8 @@ impl DatabaseDriver { } } - /// Bring the catalog to [`crate::db::schema::SCHEMA_VERSION`], creating it if the database is empty - /// and otherwise migrating it forward (see [`crate::db::migrate`]). + /// Bring the catalog to the crate's `SCHEMA_VERSION`, creating it if the database is empty + /// and otherwise migrating it forward (see the crate-private `db::migrate`). /// /// The migrator's typed [`MigrationError`] is flattened into `rusqlite::Error` here because /// this signature is consumed by `capsule-core-ffi` and `library::open`; callers that want @@ -55,7 +71,7 @@ impl DatabaseDriver { Ok(()) } - /// Bring the catalog to [`crate::db::schema::SCHEMA_VERSION`], reporting exactly what ran. + /// Bring the catalog to the crate's `SCHEMA_VERSION`, reporting exactly what ran. /// /// Refuses (without writing anything) a catalog stamped newer than this build supports — /// see [`MigrationError::CatalogTooNew`]. diff --git a/capsule-core/src/db/migrate.rs b/capsule-core/src/db/migrate.rs index 393459a2..a6bedf24 100644 --- a/capsule-core/src/db/migrate.rs +++ b/capsule-core/src/db/migrate.rs @@ -68,7 +68,7 @@ use crate::db::schema::{DDL, SCHEMA_VERSION}; /// Also the version an *unstamped* catalog (one that already has tables but reports /// `user_version = 0`) is adopted at — v1 predates nothing, so there is no older shape to /// mistake it for. -pub const BASELINE_VERSION: u32 = 1; +pub(crate) const BASELINE_VERSION: u32 = 1; /// A single DDL statement (or batch) in a migration step, plus the condition under which it /// runs. @@ -77,7 +77,7 @@ pub const BASELINE_VERSION: u32 = 1; /// visible in its data, and therefore fingerprintable — see the immutability rule in the /// module docs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Ddl { +pub(crate) enum Ddl { /// Run unconditionally. Must be idempotent (`IF NOT EXISTS` / `DROP … IF EXISTS`). Always(&'static str), /// Run only when `table` **has** `column` — used for renames of a column that an @@ -114,7 +114,7 @@ impl Ddl { /// a catalog four versions behind is brought forward by four separate, separately committed /// steps rather than one jump. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Step { +pub(crate) struct Step { pub from: u32, pub to: u32, /// Stable identifier used in logs and in the immutability fingerprint. @@ -193,7 +193,7 @@ const STEP_1_TO_2: Step = Step { /// /// The `vec0` partition tables are *not* created here: their vector dimension is declared by /// the model registry, so they are created at runtime by their writer (see -/// [`crate::db::vector::VectorTableSpec`]). +/// [`crate::db::VectorTableSpec`]). /// /// The first two statements re-assert the v2 shape. That is not redundancy: two branches /// stamped 2 for different additions, so a v2-stamped catalog is missing one of the two tables @@ -275,7 +275,7 @@ const STEP_3_TO_4: Step = Step { }; /// Every shipped step, in ascending order. Append only. -pub const STEPS: &[Step] = &[STEP_1_TO_2, STEP_2_TO_3, STEP_3_TO_4]; +pub(crate) const STEPS: &[Step] = &[STEP_1_TO_2, STEP_2_TO_3, STEP_3_TO_4]; /// SHA-256 (hex) of each shipped step's canonical rendering, parallel to [`STEPS`]. /// @@ -294,7 +294,7 @@ const STEP_FINGERPRINTS: &[&str] = &[ // ── Errors ────────────────────────────────────────────────────────────────── -/// Why a catalog could not be brought to [`SCHEMA_VERSION`]. +/// Why a catalog could not be brought to `SCHEMA_VERSION`. #[derive(Debug, thiserror::Error)] pub enum MigrationError { /// The catalog was written by a newer build than this one. @@ -354,7 +354,7 @@ impl From for rusqlite::Error { // ── Outcome ───────────────────────────────────────────────────────────────── -/// One applied step, as reported by [`migrate`]. +/// One applied step, as reported by `migrate`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Applied { pub from: u32, @@ -362,13 +362,13 @@ pub struct Applied { pub name: &'static str, } -/// What [`migrate`] did. Recorded so the CLI, the FFI host, and a support bundle can all say +/// What `migrate` did. Recorded so the CLI, the FFI host, and a support bundle can all say /// exactly what ran against a user's library. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Outcome { /// The version the catalog reported on open (`0` for a catalog this call created). pub from: u32, - /// The version it is stamped at now — always [`SCHEMA_VERSION`] on success. + /// The version it is stamped at now — always `SCHEMA_VERSION` on success. pub to: u32, /// True when the catalog was empty and the current schema was created outright. pub created: bool, @@ -385,7 +385,7 @@ pub struct Outcome { /// transaction. Catalog already current → nothing runs. Catalog newer than this build → /// [`MigrationError::CatalogTooNew`], with nothing written. #[tracing::instrument(level = "debug", skip_all, fields(target_version = SCHEMA_VERSION))] -pub fn migrate(conn: &Connection) -> Result { +pub(crate) fn migrate(conn: &Connection) -> Result { let stamped = read_user_version(conn)?; if stamped > SCHEMA_VERSION { diff --git a/capsule-core/src/db/mod.rs b/capsule-core/src/db/mod.rs index efed5b4a..2dd47e60 100644 --- a/capsule-core/src/db/mod.rs +++ b/capsule-core/src/db/mod.rs @@ -1,8 +1,8 @@ -pub mod driver; -pub mod migrate; -pub mod rows; -pub mod schema; -pub mod vector; +pub(crate) mod driver; +pub(crate) mod migrate; +pub(crate) mod rows; +pub(crate) mod schema; +pub(crate) mod vector; pub use driver::DatabaseDriver; pub use migrate::{Applied, MigrationError, Outcome as MigrationOutcome}; diff --git a/capsule-core/src/db/schema.rs b/capsule-core/src/db/schema.rs index 861c6a0f..d53254dc 100644 --- a/capsule-core/src/db/schema.rs +++ b/capsule-core/src/db/schema.rs @@ -19,11 +19,11 @@ /// only through the gated Hidden view (SSoT: design/organization § Hidden Assets). /// Distinct from `is_stack_hidden`, which suppresses non-primary stack members. /// -/// **Bumping this constant requires appending a step to [`crate::db::migrate::STEPS`]** — +/// **Bumping this constant requires appending a step to `db::migrate::STEPS`** — /// `steps_form_a_contiguous_chain` fails otherwise. Never edit a step that has shipped. -pub const SCHEMA_VERSION: u32 = 4; +pub(crate) const SCHEMA_VERSION: u32 = 4; -pub const DDL: &str = r" +pub(crate) const DDL: &str = r" PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS assets ( diff --git a/capsule-core/src/derivative_format.rs b/capsule-core/src/derivative_format.rs new file mode 100644 index 00000000..770eb18f --- /dev/null +++ b/capsule-core/src/derivative_format.rs @@ -0,0 +1,164 @@ +//! The closed set of committed still-derivative formats, and the structural check over it. +//! +//! SSoT: [Thumbnails and Previews](https://docs/design/thumbnails/) — the tier table's format +//! column *is* this enum, and "every receiver (and every federated peer) compares +//! `DerivativeManifest.format` against this list" is +//! [`verify_still_format`](crate::derivative_format::verify_still_format). +//! +//! # Why this is at the crate root and not in `capsule_core::media` +//! +//! It was in `media` first, and that was a placement mistake rather than a constraint. `media` +//! is behind the `media` feature that `native` implies, so `capsule-server` and `capsule-wasm` +//! — both `default-features = false` — cannot link it. Those are exactly the two crates that +//! *receive* a manifest they did not author, which is where a structural rejection has to run. +//! A closed set only a producer can evaluate is not a closed set. +//! +//! So it lives here, beside nothing and depending on nothing but +//! [`crate::crypto::provenance`], which is itself unconditional for the same reason. `media` +//! re-exports both names, so every existing `media::DerivativeFormat` path still resolves. +//! +//! This mirrors [`crate::lqip`]: a contract every surface needs cannot live inside a +//! feature-gated stack, however natural the stack looks as a home. + +use std::fmt; + +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; + +/// The closed set of committed still-derivative formats — the tier table's format column. +/// +/// The wire value is [`mime`](Self::mime), carried in `DerivativeManifest.format`. A value +/// outside this set is a structural rejection, never a "future format to ignore". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DerivativeFormat { + /// **JPEG XL** — the committed primary/master still codec, and the one format this build + /// encodes. Losslessly: the pure-Rust backend is `zune-jpegxl`'s `JxlSimpleEncoder`. + Jxl, + /// **AVIF** — the universal delivery format for clients without a JXL decoder. Not + /// encodable in this build. + Avif, + /// **WebP** — the last-resort delivery fallback. Not encodable in this build: the crate's + /// WebP codec does not compile for aarch64 — see `media::StillFormat::WebP`, which cannot be + /// linked from here because `media` is feature-gated and this module is not. + WebP, + /// The recognised `format = "original"` sentinel: the tier **references** the original asset + /// rather than generating a redundant derivative, because the source is not larger than the + /// tier's cap. **Distinct from an absent derivative** — this is an explicit, signed marker, + /// where absence means "rebuildable from the original". + /// + /// A sentinel derivative carries **no bytes of its own** (`media::GeneratedDerivative::bytes` + /// is empty). "References" is the operative word in the contract: the signed manifest's + /// `ciphertext_hash` content-addresses the original, which the holder already has, so + /// copying the bytes under a thumbnail's name would duplicate a file sitting two directories + /// up *and* re-expose the original's EXIF — GPS included — as a derivative blob, where a + /// re-encoded thumbnail is metadata-free by construction. + Original, +} + +impl DerivativeFormat { + /// The committed still formats per tier, in delivery-preference order: the JXL master, then + /// the AVIF -> WebP delivery variants. + pub const STILL_DELIVERY_ORDER: [Self; 3] = [Self::Jxl, Self::Avif, Self::WebP]; + + /// The exact wire string for `DerivativeManifest.format`. + pub const fn mime(self) -> &'static str { + match self { + Self::Jxl => "image/jxl", + Self::Avif => "image/avif", + Self::WebP => "image/webp", + Self::Original => "original", + } + } + + /// The on-disk file extension for a persisted derivative of this format. `Original` has + /// none of its own — it reuses the source asset's. + pub const fn extension(self) -> Option<&'static str> { + match self { + Self::Jxl => Some("jxl"), + Self::Avif => Some("avif"), + Self::WebP => Some("webp"), + Self::Original => None, + } + } + + /// Parse a `DerivativeManifest.format` value against the closed set. `None` **is** the + /// structural rejection. + pub fn parse(s: &str) -> Option { + match s { + "image/jxl" => Some(Self::Jxl), + "image/avif" => Some(Self::Avif), + "image/webp" => Some(Self::WebP), + "original" => Some(Self::Original), + _ => None, + } + } + + /// Whether a `format` string names a currently-recognised still-derivative format — the + /// exact check a receiver runs. + pub fn is_recognized(s: &str) -> bool { + Self::parse(s).is_some() + } + + /// Whether this build can produce bytes in this format. + pub const fn is_encodable(self) -> bool { + matches!(self, Self::Jxl | Self::Original) + } +} + +impl fmt::Display for DerivativeFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.mime()) + } +} + +/// The closed-set check a receiver runs on a still-role derivative manifest. +/// +/// Returns the parsed format for a `thumbnail` or `preview` manifest whose `format` is in the +/// closed set. An embedding-role manifest is **not** rejected: its `format` is +/// `embedding/{model_id}`, which this set deliberately does not model, so it is reported as +/// [`None`] rather than as a violation. +/// +/// # Errors +/// `media::MediaError::UnsupportedFormat` — which carries the still format Capsule *would* have +/// needed — is not what an unrecognised value produces, because there is no still format to name +/// (and this module cannot reference `media` in any case: it is unconditional and `media` is +/// not). An unrecognised still-role format is `Err(format.to_string())`. +pub fn verify_still_format( + manifest: &DerivativeManifest, +) -> Result, String> { + let core = &manifest.core; + match core.role { + DerivativeRole::Thumbnail | DerivativeRole::Preview => { + DerivativeFormat::parse(&core.format) + .map(Some) + .ok_or_else(|| core.format.clone()) + } + // Not a still. The embedding-role format grammar belongs to `crate::ml`. + DerivativeRole::Embedding => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **The reason this module is not in `media`.** These tests compile and run under + /// `--no-default-features`, which is how `capsule-server` and `capsule-wasm` build — the two + /// crates that receive a `DerivativeManifest` they did not author. A closed set only its + /// producer can evaluate is not a closed set. + /// + /// This test asserts nothing a caller could not, and that is the point: it exists so the + /// *linkage* is exercised by the `--no-default-features` build rather than assumed. + #[test] + fn the_closed_set_is_evaluable_without_the_media_feature() { + for format in [ + DerivativeFormat::Jxl, + DerivativeFormat::Avif, + DerivativeFormat::WebP, + DerivativeFormat::Original, + ] { + assert_eq!(DerivativeFormat::parse(format.mime()), Some(format)); + } + assert!(!DerivativeFormat::is_recognized("image/future-codec")); + assert!(!DerivativeFormat::is_recognized("embedding/mobileclip-b")); + } +} diff --git a/capsule-core/src/domain/gps_datum.rs b/capsule-core/src/domain/gps_datum.rs index 8ec19d38..01daeb14 100644 --- a/capsule-core/src/domain/gps_datum.rs +++ b/capsule-core/src/domain/gps_datum.rs @@ -142,7 +142,7 @@ fn gcj02_to_bd09(lat: f64, lon: f64) -> (f64, f64) { /// Fold a BD-09 input coordinate to GCJ-02 at the input edge, returning the `(lat, lon)` /// to store under [`GpsDatum::Gcj02`]. /// -/// Only [`gcj02_to_bd09`] — the *forward* direction — is closed-form, so this is the +/// Only `gcj02_to_bd09` — the *forward* direction — is closed-form, so this is the /// **error-bounded iterative inverse** of it: seed with the offset-removed coordinate, then /// repeatedly push the estimate forward, measure the residual against the input, and /// correct by it. The forward map is the identity plus a small perturbation, so the diff --git a/capsule-core/src/domain/mod.rs b/capsule-core/src/domain/mod.rs index f405698f..a692eb23 100644 --- a/capsule-core/src/domain/mod.rs +++ b/capsule-core/src/domain/mod.rs @@ -1,10 +1,10 @@ -pub mod capture_tz_source; -pub mod detection_method; -pub mod gps_datum; -pub mod import_mode; -pub mod member_role; -pub mod model_identity; -pub mod stack_type; +pub(crate) mod capture_tz_source; +pub(crate) mod detection_method; +pub(crate) mod gps_datum; +pub(crate) mod import_mode; +pub(crate) mod member_role; +pub(crate) mod model_identity; +pub(crate) mod stack_type; pub use capture_tz_source::CaptureTzSource; pub use detection_method::DetectionMethod; diff --git a/capsule-core/src/domain/model_identity.rs b/capsule-core/src/domain/model_identity.rs index 8673c4ec..1044e57d 100644 --- a/capsule-core/src/domain/model_identity.rs +++ b/capsule-core/src/domain/model_identity.rs @@ -55,7 +55,7 @@ impl TaskKind { /// A model identifier (stable across versions; e.g. `mobileclip-b`). Declared in exactly one /// [`ModelRow`](crate::ml::ModelRow). Serializes transparently as its string so it interoperates -/// with the `model_id` fields on [`AiTag`](crate::sidecar::sidecar_v1::AiTag) and +/// with the `model_id` fields on [`AiTag`](crate::sidecar::AiTag) and /// [`DerivativeCore`](crate::crypto::provenance::manifest::DerivativeCore). #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -127,7 +127,7 @@ pub enum DistanceMetric { } /// Refusals from the embedding-provenance invariant — produced by the -/// [gate](crate::db::vector::EmbeddingProvenance) the vector index calls on every insert, and +/// [gate](crate::db::EmbeddingProvenance) the vector index calls on every insert, and /// carried outward by [`VectorIndexError`](crate::db::VectorIndexError). #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RegistryError { diff --git a/capsule-core/src/import/enrichment.rs b/capsule-core/src/import/enrichment.rs index 47009882..0149d1c8 100644 --- a/capsule-core/src/import/enrichment.rs +++ b/capsule-core/src/import/enrichment.rs @@ -5,7 +5,7 @@ //! extraction — embedded EXIF wins over the exporter's records, **except** for constructs the //! exporter is authoritative for (album membership, favorites/rating, user-typed descriptions). //! This module is the other half: it maps that folded record onto the fields the signed -//! [`SidecarV1`](crate::sidecar::sidecar_v1::SidecarV1) actually has, and indexes it by source +//! [`SidecarV1`](crate::sidecar::SidecarV1) actually has, and indexes it by source //! path so the [executor](crate::import::executor) can attach it to the member it is importing. //! //! The mapping, one row per [Takeout mapping-table] rule: @@ -344,7 +344,7 @@ mod archive_tests { use super::*; use crate::crypto::primitives::Argon2Params; - use crate::import::executor::execute_with_source_metadata; + use crate::import::execute_with_source_metadata; use crate::import::executor_cancellation::CancellationToken; use crate::import::importers::SourceAdapter; use crate::import::importers::takeout::TakeoutAdapter; diff --git a/capsule-core/src/import/executor.rs b/capsule-core/src/import/executor.rs index 6f768277..defebe75 100644 --- a/capsule-core/src/import/executor.rs +++ b/capsule-core/src/import/executor.rs @@ -2,16 +2,18 @@ //! //! Each `ImportDecision::Import` candidate is imported through //! [`Workspace::import_asset_with`](crate::lifecycle::Workspace::import_asset_with): every member -//! becomes a signed [`SidecarV1`](crate::sidecar::sidecar_v1::SidecarV1) + signed manifest + +//! becomes a signed [`SidecarV1`](crate::sidecar::SidecarV1) + signed manifest + //! append-only provenance, self-verified through -//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — behind the `media` feature, -//! when a [`StillEncoder`](crate::media::image::derivative::StillEncoder) is attached to the -//! workspace — with signed thumbnail/preview derivatives + an LQIP in the sidecar. +//! [`verify_asset`](crate::crypto::verify_asset::verify_asset), and — when the still decodes — +//! with a chromahash `lqip` in the sidecar and signed thumbnail derivatives on disk +//! ([`capsule_core::media`](crate::media), slices `S-B1`/`S-B13`/`S-B14`). A format with no +//! codec in this build still imports, as a signed original with the gap recorded rather than +//! hidden. //! //! This retired the legacy unsigned `AssetSidecar` write path from the executor; the production //! write path itself is now gone (`S-G4`) — no code writes unsigned sidecars anymore. Only the //! *read* path survives, for the recovery-first index rebuild -//! ([`rebuild_index`](crate::library::rebuild::rebuild_index)) that still ingests unsigned `.cbor` +//! ([`rebuild_index`](crate::library::rebuild_index)) that still ingests unsigned `.cbor` //! sidecars left by pre-signed-path libraries. The pure planner (`import::planner`) is unchanged: //! it still decides *what* to import; the executor decides *how* to commit it. @@ -68,7 +70,7 @@ pub fn execute( /// discarded once the plan was built. A file the index does not cover imports exactly as it /// does through [`execute`]. /// -/// [source adapter]: crate::import::importers +/// [source adapter]: crate::import::SourceAdapter /// [precedence rule]: https://docs/design/import/pipeline/#third-party-importers #[tracing::instrument( skip_all, @@ -181,10 +183,9 @@ pub fn execute_with_source_metadata( /// The signed-sidecar enrichment for one member path, with the fold decision logged. /// -/// The single place a [`SourceMetadataIndex`] is turned into a -/// [`SidecarEnrichment`](crate::lifecycle::SidecarEnrichment), shared by this executor and the -/// [streaming window](crate::import::streaming) so a *streamed* third-party import writes exactly -/// what a bulk one does (`S-B11` closed the gap `S-B10` left open). A path the index does not +/// The single place a [`SourceMetadataIndex`] is turned into a [`SidecarEnrichment`], shared by +/// this executor and the [streaming window](crate::import::streaming) so a *streamed* +/// third-party import writes exactly what a bulk one does (`S-B11` closed the gap `S-B10` left open). A path the index does not /// cover, or a record that folds to nothing, yields [`None`] — the untouched write path. pub(crate) fn member_enrichment( source: &SourceMetadataIndex, @@ -246,6 +247,7 @@ fn execute_candidate( path.clone(), ImportOutcome::Imported { derivatives: receipt.derivatives, + deferred_formats: receipt.deferred_formats, }, )); } @@ -419,22 +421,28 @@ mod tests { /// **The S-B13 contract (slice `S-B13`).** An original whose format has no codec in this /// build is imported as a signed, encrypted, verifiable asset — it simply arrives without a - /// thumbnail/preview, and the run summary says so. + /// thumbnail, and the run summary says so. /// - /// **`S-C59` narrowed what this can assert.** It used to pin the distinction the logs must - /// preserve: `iphone.heic` an *expected* deferral, `snap.jpg` a *genuine* decode failure of a - /// format we do support. With `capsule_core::media` retired there is no decoder for any - /// format, so both are deferrals and the distinction is unobservable — it comes back with - /// Rawshift. What survives is the half that matters most and would be the worst to lose - /// silently: **an undecodable original is still a signed, encrypted, self-verifying backup**, - /// and both files land. + /// **The distinction is observable again.** `S-C59` retired the decoder and collapsed both + /// files below into deferrals, which made the logs' most useful property untestable. With + /// `capsule_core::media` on `rawshift-image` the two are apart once more, and they are apart + /// for the reason that matters rather than by extension: `iphone.heic` is a format Capsule + /// *recognises and cannot decode* (an expected, backfillable gap), while `snap.jpg` is a + /// format it can decode whose bytes are not a JPEG (a real problem). Both still land as + /// signed, encrypted, self-verifying backups, which is the half that would be worst to lose. #[test] fn originals_with_no_codec_are_still_imported_and_signed() { use crate::lifecycle::DerivativeStatus; let src = TempDir::new().unwrap(); let lib_dir = TempDir::new().unwrap(); - fs::write(src.path().join("iphone.heic"), b"fake heic bytes").unwrap(); + // A real ISO-BMFF `ftyp heic` header, so the classification rests on the **bytes** — + // which is what the doc above claims. `b"fake heic bytes"` carried no `ftyp` and + // silently exercised the extension fallback instead. + let mut heic = vec![0, 0, 0, 0x20]; + heic.extend_from_slice(b"ftypheic"); + heic.extend_from_slice(&[0; 16]); + fs::write(src.path().join("iphone.heic"), &heic).unwrap(); fs::write(src.path().join("snap.jpg"), b"not really a jpeg").unwrap(); let mut ws = signed_workspace(lib_dir.path()); @@ -452,28 +460,32 @@ mod tests { assert_eq!(summary.imported_count(), 2, "both originals are backed up"); assert_eq!( summary.deferred_derivative_count(), - 2, - "with no decoder in the build, every still is a codec deferral" + 1, + "the HEIC is an expected codec deferral" ); assert_eq!( summary.decode_failed_count(), + 1, + "the .jpg is a format we do decode, failing on these bytes — a real problem" + ); + assert_eq!( + summary.deferred_format_count(), 0, - "nothing is *attempted*, so nothing can fail to decode — the distinction returns \ - with Rawshift" + "nothing decoded, so no per-format variant was even attempted" ); - // Reported per file rather than only in aggregate, so the shape a caller reads is - // pinned even while there is one reason rather than two. + // Reported per file rather than only in aggregate, so a caller reads the reason for the + // file in front of it. for (path, outcome) in &summary.outcomes { - let ImportOutcome::Imported { derivatives } = outcome else { + let ImportOutcome::Imported { derivatives, .. } = outcome else { panic!("{} should have imported, got {outcome:?}", path.display()); }; - assert_eq!( - *derivatives, - DerivativeStatus::DeferredNoCodec, - "for {}", - path.display() - ); + let expected = if path.extension().is_some_and(|e| e == "heic") { + DerivativeStatus::DeferredNoCodec + } else { + DerivativeStatus::DecodeFailed + }; + assert_eq!(*derivatives, expected, "for {}", path.display()); } // Both land on the signed path and self-verify — a missing thumbnail is not a missing @@ -488,6 +500,11 @@ mod tests { /// A RAW-only candidate — no same-stem JPEG to fall back on — still lands as a signed, /// self-verifying original. RAW has no decoder in this build, which is exactly why this /// needs pinning: the archive is the whole point, the derivative is a bonus (slice `S-B13`). + /// + /// A Sony ARW is a TIFF container, so its *header* says TIFF and only the extension names + /// the family. This fixture is not a real ARW, so the classification here rests on the + /// extension fallback — which is the path a real one would also take for the family, and + /// either way the outcome is the same expected deferral. #[test] fn raw_only_candidate_lands_as_a_signed_original() { use crate::lifecycle::DerivativeStatus; @@ -512,7 +529,8 @@ mod tests { assert!(matches!( summary.outcomes[0].1, ImportOutcome::Imported { - derivatives: DerivativeStatus::DeferredNoCodec + derivatives: DerivativeStatus::DeferredNoCodec, + deferred_formats: 0, } )); diff --git a/capsule-core/src/import/group.rs b/capsule-core/src/import/group.rs index ac971aa9..3e52f71a 100644 --- a/capsule-core/src/import/group.rs +++ b/capsule-core/src/import/group.rs @@ -21,16 +21,16 @@ pub const VIDEO_EXTS: &[&str] = &["mp4", "mov", "m4v", "avi", "mkv", "mts", "m2t const XMP_EXT: &str = "xmp"; -pub fn is_raw(ext: &str) -> bool { +pub(crate) fn is_raw(ext: &str) -> bool { RAW_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_primary(ext: &str) -> bool { +pub(crate) fn is_primary(ext: &str) -> bool { PRIMARY_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_video(ext: &str) -> bool { +pub(crate) fn is_video(ext: &str) -> bool { VIDEO_EXTS.contains(&ext.to_lowercase().as_str()) } -pub fn is_xmp(ext: &str) -> bool { +pub(crate) fn is_xmp(ext: &str) -> bool { ext.to_lowercase() == XMP_EXT } diff --git a/capsule-core/src/import/importers/mod.rs b/capsule-core/src/import/importers/mod.rs index e51b5ba0..3ebc2479 100644 --- a/capsule-core/src/import/importers/mod.rs +++ b/capsule-core/src/import/importers/mod.rs @@ -14,7 +14,7 @@ //! the same [`ScanResult`] on every run. //! //! The folded record is not planner input only. The -//! [executor](crate::import::executor::execute_with_source_metadata) writes it into the **signed +//! [executor](crate::import::execute_with_source_metadata) writes it into the **signed //! sidecar** at import through [`enrichment`](crate::import::enrichment) (`S-B10`) — capture time //! and GPS as fallbacks behind the file's own EXIF, and the exporter-authoritative constructs //! (description, favorites, album membership) unconditionally — so a migrated library keeps what @@ -26,7 +26,7 @@ //! //! [Scan & Extract]: https://docs/design/import/pipeline/#scan--extract -pub mod takeout; +pub(crate) mod takeout; use std::path::{Path, PathBuf}; @@ -89,7 +89,7 @@ pub struct GeoPoint { /// The out-of-band metadata an adapter folds for one media entry, with the precedence already /// resolved. This is the artifact the [Takeout mapping table] validates, one field per rule, and -/// what [`sidecar_enrichment`](crate::import::enrichment::sidecar_enrichment) maps onto the +/// what [`sidecar_enrichment`](crate::import::sidecar_enrichment) maps onto the /// signed sidecar's fields at import. /// /// [Takeout mapping table]: https://docs/design/import/pipeline/#validation @@ -130,7 +130,7 @@ impl Default for ExtractedMetadata { /// exporter [`ExtractedMetadata`] for its primary media file. #[derive(Debug, Clone)] pub struct SourceEntry { - /// The candidate fed verbatim to [`plan`](crate::import::planner::plan). + /// The candidate fed verbatim to [`plan`](crate::import::plan). pub candidate: ImportCandidate, /// The folded out-of-band metadata for this entry's primary media file. pub metadata: ExtractedMetadata, @@ -152,7 +152,7 @@ pub struct ExtractedImport { } impl ExtractedImport { - /// Build the [`ScanResult`] the pure [`planner`](crate::import::planner) consumes. This is the + /// Build the [`ScanResult`] the pure [planner](crate::import::plan) consumes. This is the /// only handoff into the pipeline — the adapter **feeds** the planner and never modifies it. pub fn to_scan_result(&self) -> ScanResult { ScanResult { diff --git a/capsule-core/src/import/importers/takeout.rs b/capsule-core/src/import/importers/takeout.rs index 8430aecc..ee01313e 100644 --- a/capsule-core/src/import/importers/takeout.rs +++ b/capsule-core/src/import/importers/takeout.rs @@ -936,7 +936,7 @@ mod tests { #[test] fn fixture_import_is_resumable_and_skips_completed_work() { use crate::crypto::primitives::Argon2Params; - use crate::import::executor::execute; + use crate::import::execute; use crate::import::executor_cancellation::CancellationToken; use crate::import::planner::{ImportConfig, plan}; use crate::lifecycle::Workspace; diff --git a/capsule-core/src/import/mod.rs b/capsule-core/src/import/mod.rs index 92fe6cbb..e8e4ea78 100644 --- a/capsule-core/src/import/mod.rs +++ b/capsule-core/src/import/mod.rs @@ -1,17 +1,17 @@ -pub mod default_album; -pub mod enrichment; -pub mod executor; -pub mod executor_cancellation; -pub mod group; -pub mod importers; -pub mod planner; -pub mod progress; -pub mod scan; -pub mod scanner; -pub mod scope; -pub mod special; -pub mod streaming; -pub mod upload; +pub(crate) mod default_album; +pub(crate) mod enrichment; +pub(crate) mod executor; +pub(crate) mod executor_cancellation; +pub(crate) mod group; +pub(crate) mod importers; +pub(crate) mod planner; +pub(crate) mod progress; +pub(crate) mod scan; +pub(crate) mod scanner; +pub(crate) mod scope; +pub(crate) mod special; +pub(crate) mod streaming; +pub(crate) mod upload; pub use default_album::{ DefaultAlbumContext, DefaultAlbumError, ResolutionRule, ResolvedAlbum, resolve_default_album, @@ -33,6 +33,6 @@ pub use scope::{IMPORT_SCOPE_V1, Scope, SourceKind}; pub use special::{SpecialDirectoryStatus, SpecialFileStatus, SpecialStatus}; pub use streaming::{ AssetUploader, StreamHalt, StreamedOutcome, StreamedState, StreamingError, StreamingEvent, - StreamingReport, UploadHalt, execute_streaming, execute_streaming_with_source_metadata, + StreamingOptions, StreamingReport, UploadHalt, execute_streaming, }; pub use upload::{StagedStreamingConflict, UploadPolicy, UploadTier, ensure_streaming_compatible}; diff --git a/capsule-core/src/import/planner.rs b/capsule-core/src/import/planner.rs index 70a75b03..813b75a1 100644 --- a/capsule-core/src/import/planner.rs +++ b/capsule-core/src/import/planner.rs @@ -71,7 +71,7 @@ pub struct ImportActionPlan { /// [`attach_streaming_recommendation`](Self::attach_streaming_recommendation) — never by the /// pure planner (the probe is I/O). `None` until attached; `Some(true)` means the library /// volume is near/over full for this plan's `total_size` and the user should confirm a - /// [streaming import](crate::import::streaming). Recording it on the plan (like the resolved + /// [streaming import](crate::import::execute_streaming). Recording it on the plan (like the resolved /// destination `album_id`) keeps the planner deterministic. pub streaming_recommended: Option, /// The destination album and **which resolution rule chose it** (SSoT: organization @@ -140,8 +140,7 @@ impl ImportActionPlan { /// planner's single rejection point — call it at confirmation with the run's /// `policy` and whether a streaming import was chosen (`use_streaming`); a /// conflicting combination returns [`StagedStreamingConflict`] instead of ever - /// entering the executor. Delegates to the pure - /// [`ensure_streaming_compatible`](crate::import::upload::ensure_streaming_compatible) + /// entering the executor. Delegates to the pure [`ensure_streaming_compatible`] /// invariant so the rule lives in one place. pub fn confirm_upload_policy( &self, @@ -234,7 +233,7 @@ fn decide( } fn hash_file(path: &Path) -> Result { - crate::utils::hash::get_file_hash(path) + Ok(crate::crypto::hash::hash_file(path)?.to_hex()) } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -281,7 +280,7 @@ mod tests { // Write a file and pre-insert its hash let content = b"unique_photo_content"; fs::write(tmp.path().join("photo.jpg"), content).unwrap(); - let hash = crate::utils::hash::hash_bytes(content); + let hash = crate::crypto::hash::hash_bytes(content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "existing-uuid".to_string(), @@ -327,7 +326,7 @@ mod tests { fs::write(tmp.path().join("b.jpg"), vec![0u8; 25]).unwrap(); let dup_content = vec![7u8; 99]; fs::write(tmp.path().join("dup.jpg"), &dup_content).unwrap(); - let dup_hash = crate::utils::hash::hash_bytes(&dup_content); + let dup_hash = crate::crypto::hash::hash_bytes(&dup_content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "dup-uuid".to_string(), asset_type: "photo".to_string(), @@ -474,7 +473,7 @@ mod tests { let content = b"reimport_me"; fs::write(tmp.path().join("photo.jpg"), content).unwrap(); - let hash = crate::utils::hash::hash_bytes(content); + let hash = crate::crypto::hash::hash_bytes(content).to_hex(); let row = crate::db::rows::AssetRow { uuid: "existing-uuid2".to_string(), diff --git a/capsule-core/src/import/progress.rs b/capsule-core/src/import/progress.rs index 47097288..2a4a1691 100644 --- a/capsule-core/src/import/progress.rs +++ b/capsule-core/src/import/progress.rs @@ -12,6 +12,11 @@ pub enum ImportOutcome { /// fully successful import that happens to be missing its derivative (slice `S-B13`). Imported { derivatives: DerivativeStatus, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot + /// encode. Orthogonal to `derivatives`: a `Decoded` asset with a renderable JXL + /// thumbnail still reports the AVIF delivery variant and WebP as deferred, and that + /// count is how the gap shrinks visibly as codecs land rather than silently. + deferred_formats: u32, }, DuplicateSkipped { existing_uuid: String, @@ -77,13 +82,36 @@ impl ImportExecutionSummary { matches!( o, ImportOutcome::Imported { - derivatives: DerivativeStatus::DeferredNoCodec + derivatives: DerivativeStatus::DeferredNoCodec, + .. } ) }) .count() } + /// The total number of `(tier, format)` pairs across the run that the tier table commits to + /// and this build cannot encode (slice `S-B13`). + /// + /// **Not a failure count, and not comparable to + /// [`deferred_derivative_count`](Self::deferred_derivative_count).** That one counts *assets* + /// with no thumbnail at all; this one counts *format variants* missing from assets that do + /// have one. A library of decodable JPEGs reports zero deferred derivatives and two deferred + /// formats per asset — the JXL master and the AVIF delivery variant — which is exactly the + /// number that should fall to zero as the encoders land, and the reason it is reported rather + /// than left implicit in a doc. + pub fn deferred_format_count(&self) -> usize { + self.outcomes + .iter() + .map(|(_, o)| match o { + ImportOutcome::Imported { + deferred_formats, .. + } => *deferred_formats as usize, + _ => 0, + }) + .sum() + } + /// How many imported assets are in a format this build *does* support but whose bytes did /// not decode — unlike [`deferred_derivative_count`](Self::deferred_derivative_count) this /// is a real problem worth surfacing, not an expected gap. The original is still imported. @@ -94,7 +122,8 @@ impl ImportExecutionSummary { matches!( o, ImportOutcome::Imported { - derivatives: DerivativeStatus::DecodeFailed + derivatives: DerivativeStatus::DecodeFailed, + .. } ) }) diff --git a/capsule-core/src/import/streaming.rs b/capsule-core/src/import/streaming.rs index 9590dde0..6be85e78 100644 --- a/capsule-core/src/import/streaming.rs +++ b/capsule-core/src/import/streaming.rs @@ -2,7 +2,7 @@ //! `S-B3` in the repo-root `SLICES.md`; SSoT: //! [Import — Pipeline: Import-Upload Streaming Mode](https://docs/design/import/pipeline/#import-upload-streaming-mode)). //! -//! The default [`execute`](crate::import::executor::execute) imports every file into the local +//! The default [`execute`](crate::import::execute) imports every file into the local //! library *before* upload, so the device temporarily holds the whole import on disk — impossible //! on a storage-constrained device. Streaming mode removes that requirement by running a bounded //! **sliding window** of one asset at a time: @@ -10,8 +10,8 @@ //! 1. Import the next file onto the signed path — with source release *deferred* //! ([`Workspace::import_asset_streaming`](crate::lifecycle::Workspace::import_asset_streaming)). //! 2. Upload its bundle via the injected [`AssetUploader`] seam. -//! 3. Confirm durability + custody through the `S-D4` [`ReleaseGate`](crate::library::ReleaseGate) -//! over the injected [`StorageVerifier`](crate::library::StorageVerifier) seam. +//! 3. Confirm durability + custody through the `S-D4` [`ReleaseGate`] over the injected +//! [`StorageVerifier`] seam. //! 4. **Release** the local original (and delete the Move-mode source) *only* on the `durable` //! verdict, so the device never drops the only copy of bytes the server has not confirmed. //! 5. Advance the window. @@ -97,7 +97,7 @@ pub enum StreamingError { #[error("free-space probe: {0}")] Probe(#[from] crate::library::LibraryError), - /// The run was configured with a [`UploadPolicy::Staged`](crate::import::upload::UploadPolicy) + /// The run was configured with a [`UploadPolicy::Staged`](crate::import::UploadPolicy) /// policy, which is mutually exclusive with streaming import (staged uploads, /// slice `S-B4`). Streaming releases local bytes quickly; staged defers exactly /// the T2 upload release depends on — so a staged policy can never enter the @@ -228,88 +228,80 @@ pub enum StreamingEvent { // ── The window executor ──────────────────────────────────────────────────────── +/// Everything the streaming window needs beyond the plan, the workspace, and the event sink. +/// +/// One struct rather than six positionals: the two entry points this replaced differed only in +/// `source` and carried eight and nine arguments respectively, both behind a +/// `#[allow(clippy::too_many_arguments)]`. A caller that wants no source-adapter enrichment +/// passes `&SourceMetadataIndex::empty()`, which is exactly what the thinner of the two +/// entry points did for it. +pub struct StreamingOptions<'a, U: AssetUploader, V: StorageVerifier> { + /// The planner configuration the run was confirmed against. + pub config: &'a ImportConfig, + /// Folded metadata a third-party [source adapter](crate::import::SourceAdapter) extracted, + /// attached to each file it covers. [`SourceMetadataIndex::empty`] for a plain filesystem + /// drive; files the index does not cover import identically either way. + pub source: &'a SourceMetadataIndex, + /// The injected upload seam. `capsule-core` performs no network I/O of its own. + pub uploader: &'a U, + /// The injected durability/custody seam behind the `S-D4` release gate. + pub verifier: &'a V, + /// The free-space cushion the minimum-headroom check and the implicit recommendation apply. + pub headroom_margin: u64, + /// Honored at every window boundary. + pub cancel: &'a CancellationToken, +} + /// Drive a streaming import: the bounded per-asset import → upload → verify → release window over -/// `plan`. `headroom_margin` is the free-space cushion the minimum-headroom check and the -/// implicit recommendation apply. `uploader` and `verifier` are the injected network seams; the -/// executor itself performs no network I/O. Honors `cancel` at every window boundary. +/// `plan`, under `opts`. +/// +/// The enrichment in [`StreamingOptions::source`] is written *inside the signed sidecar*, at the +/// same single write path the bulk executor drives, so the drive mode a user picks never changes +/// what a migrated library keeps: a storage-constrained device streaming a Takeout export lands +/// the exporter's capture time, GPS, caption, favorite, and album membership exactly as an +/// unconstrained one does (slice `S-B11`, closing the gap `S-B10` left open). This is the +/// streaming twin of +/// [`execute_with_source_metadata`](crate::import::execute_with_source_metadata). /// /// Returns a [`StreamingReport`]; a hard failure to *start* (insufficient headroom, a failed /// probe) is a [`StreamingError`]. Per-asset upload pauses and gate retentions are outcomes, not /// errors: on a pause the window halts (`report.halted` set) with no further files admitted, and /// the run can be re-invoked after reconnect — the deterministic planner re-derives the remaining /// work and skips already-completed assets. -#[allow(clippy::too_many_arguments)] -pub fn execute_streaming( - plan: &ImportActionPlan, - workspace: &mut Workspace, - config: &ImportConfig, - uploader: &U, - verifier: &V, - headroom_margin: u64, - on_event: impl Fn(StreamingEvent), - cancel: &CancellationToken, -) -> Result -where - U: AssetUploader, - V: StorageVerifier, -{ - execute_streaming_with_source_metadata( - plan, - workspace, - config, - &SourceMetadataIndex::empty(), - uploader, - verifier, - headroom_margin, - on_event, - cancel, - ) -} - -/// [`execute_streaming`], with the folded metadata a third-party [source adapter] extracted -/// attached to each file it covers — the streaming twin of -/// [`execute_with_source_metadata`](crate::import::executor::execute_with_source_metadata) -/// (slice `S-B11`, closing the gap `S-B10` left open). -/// -/// The enrichment is written *inside the signed sidecar*, at the same single write path the bulk -/// executor drives, so the drive mode a user picks never changes what a migrated library keeps: -/// a storage-constrained device streaming a Takeout export lands the exporter's capture time, -/// GPS, caption, favorite, and album membership exactly as an unconstrained one does. Files the -/// index does not cover import exactly as they do through [`execute_streaming`]. -/// -/// [source adapter]: crate::import::importers #[tracing::instrument( skip_all, fields( candidates = plan.actions.len(), - mode = ?config.import_mode, - headroom = headroom_margin, - source_metadata = source.len(), + mode = ?opts.config.import_mode, + headroom = opts.headroom_margin, + source_metadata = opts.source.len(), ) )] -#[allow(clippy::too_many_arguments)] -pub fn execute_streaming_with_source_metadata( +pub fn execute_streaming( plan: &ImportActionPlan, workspace: &mut Workspace, - config: &ImportConfig, - source: &SourceMetadataIndex, - uploader: &U, - verifier: &V, - headroom_margin: u64, + opts: StreamingOptions<'_, U, V>, on_event: impl Fn(StreamingEvent), - cancel: &CancellationToken, ) -> Result where U: AssetUploader, V: StorageVerifier, { + // Every field is a shared reference or a `u64`, so this copies out of `opts` rather than + // moving it; the struct is handed to `stream_candidate` whole below. + let StreamingOptions { + config, + headroom_margin, + cancel, + .. + } = opts; // ── Exclusion: a staged upload policy can never enter the streaming window ─── // Streaming exists to release local bytes as fast as possible; staged uploads // defer exactly the T2 (original) upload that release depends on. The planner // rejects the combination at confirmation; this is the by-construction backstop // so a staged policy cannot reach `execute_streaming` even if a caller skips // confirmation. Surfaces before any file is imported (staged uploads, S-B4). - crate::import::upload::ensure_streaming_compatible(config.upload_policy, true)?; + crate::import::ensure_streaming_compatible(config.upload_policy, true)?; // Same single resolution the executor runs: bind the library's derived de facto album // (rule 3), then apply the order (SSoT: organization § The Default Album). @@ -364,9 +356,7 @@ where } match decision { ImportDecision::Import => { - match stream_candidate( - workspace, config, album_id, candidate, source, uploader, verifier, &on_event, - )? { + match stream_candidate(workspace, album_id, candidate, &opts, &on_event)? { CandidateFlow::Done(outs) => report.outcomes.extend(outs), CandidateFlow::Halt(reason, outs) => { report.outcomes.extend(outs); @@ -414,21 +404,24 @@ enum CandidateFlow { /// (RAW+JPEG, Live Photo) mints one stack id and hides its non-primary members, exactly as the /// bulk executor does; the stack row is persisted once its members exist in the index — released /// originals keep their queryable asset rows, so grouping survives release. -#[allow(clippy::too_many_arguments)] fn stream_candidate( workspace: &mut Workspace, - config: &ImportConfig, album_id: Uuid, candidate: &ImportCandidate, - source: &SourceMetadataIndex, - uploader: &U, - verifier: &V, + opts: &StreamingOptions<'_, U, V>, on_event: &impl Fn(StreamingEvent), ) -> Result where U: AssetUploader, V: StorageVerifier, { + let StreamingOptions { + config, + source, + uploader, + verifier, + .. + } = *opts; let move_source = matches!(config.import_mode, ImportMode::Move); let stack = candidate.stack_type.map(|st| (Uuid::now_v7(), st)); @@ -712,12 +705,15 @@ mod tests { let report = execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &verifier, - 0, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &verifier, + headroom_margin: 0, + cancel: &CancellationToken::new(), + }, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -756,12 +752,15 @@ mod tests { let report = execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &verifier, - 0, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &verifier, + headroom_margin: 0, + cancel: &CancellationToken::new(), + }, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -808,15 +807,18 @@ mod tests { let report = execute_streaming( &plan_a, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert_eq!(report.released_count(), 1); @@ -839,15 +841,18 @@ mod tests { execute_streaming( &plan2, &mut ws2, - &config, - &OkUploader, - &MockVerifier { - durable: false, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: false, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert!( @@ -884,15 +889,18 @@ mod tests { let report = execute_streaming( &plan1, &mut ws, - &config, - &uploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &uploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); @@ -940,15 +948,18 @@ mod tests { let report2 = execute_streaming( &plan2, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); assert!(!report2.is_halted()); @@ -978,15 +989,18 @@ mod tests { let err = execute_streaming( &plan, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: u64::MAX, + cancel: &CancellationToken::new(), }, - u64::MAX, noop_event, - &CancellationToken::new(), ) .unwrap_err(); @@ -1003,7 +1017,7 @@ mod tests { } /// **Staged × streaming exclusion (by construction).** A run configured with the - /// [`UploadPolicy::Staged`](crate::import::upload::UploadPolicy) policy can never + /// [`UploadPolicy::Staged`](crate::import::UploadPolicy) policy can never /// enter the streaming window: `execute_streaming` refuses it *before* any file /// is imported, mirroring the planner's confirmation-time rejection. (SSoT: /// download-sync doc — staged uploads are mutually exclusive with streaming.) @@ -1029,15 +1043,18 @@ mod tests { let err = execute_streaming( &plan, &mut ws, - &config, - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &config, + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap_err(); @@ -1105,19 +1122,21 @@ mod tests { fn stream_with(index: &SourceMetadataIndex, src: &Path, ws: &mut Workspace) -> StreamingReport { let config = ImportConfig::default(); let plan = plan(&scan(&[src.to_path_buf()]).unwrap(), ws.db(), &config).unwrap(); - execute_streaming_with_source_metadata( + execute_streaming( &plan, ws, - &config, - index, - &OkUploader, - &MockVerifier { - durable: false, - receipt: true, + StreamingOptions { + config: &config, + source: index, + uploader: &OkUploader, + verifier: &MockVerifier { + durable: false, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap() } @@ -1187,15 +1206,18 @@ mod tests { execute_streaming( &plan, &mut ws, - &ImportConfig::default(), - &OkUploader, - &MockVerifier { - durable: true, - receipt: true, + StreamingOptions { + config: &ImportConfig::default(), + source: &SourceMetadataIndex::empty(), + uploader: &OkUploader, + verifier: &MockVerifier { + durable: true, + receipt: true, + }, + headroom_margin: 0, + cancel: &CancellationToken::new(), }, - 0, noop_event, - &CancellationToken::new(), ) .unwrap(); diff --git a/capsule-core/src/lib.rs b/capsule-core/src/lib.rs index d8872155..98ec350f 100644 --- a/capsule-core/src/lib.rs +++ b/capsule-core/src/lib.rs @@ -19,6 +19,13 @@ pub mod sharing; /// build-embedded git commit (S-D15). Always compiled: pure string formatting, no native deps. pub mod client_build; +/// The closed set of still-derivative formats and the structural check over it — the tier +/// table's format column as a type. Always compiled, and for the same reason [`lqip`] is: the +/// crates that *receive* a `DerivativeManifest` (`capsule-server`, `capsule-wasm`) build with +/// `default-features = false`, so a check they cannot link is a check that never runs. Depends +/// only on [`crypto::provenance`]; `media` re-exports it. +pub mod derivative_format; + /// LQIP — the chromahash placeholder carried in the signed sidecar's `lqip` field (S-B14). /// Always compiled, and deliberately so: the placeholder is produced by the import pipeline, /// read by the apps through the uniffi FFI, and read by the browser through `capsule-wasm`, so @@ -54,12 +61,26 @@ pub mod import; pub mod library; #[cfg(feature = "native")] pub mod lifecycle; +/// Still decode, orientation, metadata normalisation and derivative generation over +/// `rawshift-image` (`media` feature, implied by `native`; slices `S-B1`/`S-B13`). Feature-gated +/// rather than `native`-gated so the codec stack is one manifest edit away from being dropped +/// from a size-constrained build, and so the `wasm32-unknown-unknown` sealing surface provably +/// does not link it. See [`media`]. +#[cfg(feature = "media")] +pub mod media; #[cfg(feature = "native")] pub mod metadata; #[cfg(feature = "native")] pub mod ml; -#[cfg(feature = "native")] -pub mod models; +// `notify` — alert classes and their trigger predicates (`S-D29`): one shared decision +// function every platform evaluates, so the taxonomy is not reimplemented per client. +// `native`-gated because an alert is composed from *decrypted device state*, and the +// un-gated surface here is the key-free guest sealing path, which holds none of it. The +// rationale lives as a plain comment rather than a doc comment because rustdoc merges an +// outer `mod` doc with the module's own `//!` block and then resolves the whole thing at +// the declaration site — which would break every short intra-doc link in `notify/mod.rs`. +#[cfg(feature = "native")] +pub mod notify; #[cfg(feature = "native")] pub mod sidecar; #[cfg(feature = "native")] diff --git a/capsule-core/src/library/error.rs b/capsule-core/src/library/error.rs index 31934836..32f5a917 100644 --- a/capsule-core/src/library/error.rs +++ b/capsule-core/src/library/error.rs @@ -21,6 +21,19 @@ pub enum LibraryError { #[error("version mismatch: found {found}, expected {expected}")] VersionMismatch { found: u8, expected: u8 }, + /// The catalog (`index/library.sqlite`) was stamped by a newer build than this one. + /// + /// A refusal, not a downgrade: the catalog is left byte-for-byte untouched and the lock + /// is released, because an older binary cannot know what invariants the newer schema + /// added. The recovery is to update Capsule (SSoT: Versioning — Client Catalog + /// Migration). Typed here rather than flattened into [`Db`](Self::Db) so a client can + /// tell the user *which* two versions disagree (slice `S-D23`). + #[error( + "catalog schema v{found} is newer than this build supports (v{supported}); \ + update Capsule to open this library" + )] + CatalogTooNew { found: u32, supported: u32 }, + #[error("I/O error: {0}")] Io(#[from] std::io::Error), diff --git a/capsule-core/src/library/lock.rs b/capsule-core/src/library/lock.rs index 0d25d909..9dc22800 100644 --- a/capsule-core/src/library/lock.rs +++ b/capsule-core/src/library/lock.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::library::error::LibraryError; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockRecord { +pub(crate) struct LockRecord { pub pid: u32, pub hostname: String, pub locked_at: i64, @@ -17,7 +17,7 @@ pub struct LockRecord { /// On AlreadyExists: reads the existing lock; if the holding process is no /// longer alive (same host, dead PID), the stale lock is removed and /// acquisition retried. Otherwise returns `LibraryError::Locked`. -pub fn try_acquire(root: &Path) -> Result<(), LibraryError> { +pub(crate) fn try_acquire(root: &Path) -> Result<(), LibraryError> { let lock_path = root.join(".library/lock"); let record = LockRecord { @@ -67,7 +67,7 @@ pub fn try_acquire(root: &Path) -> Result<(), LibraryError> { } /// Release the lock by deleting `.library/lock`. -pub fn release(root: &Path) -> Result<(), LibraryError> { +pub(crate) fn release(root: &Path) -> Result<(), LibraryError> { let lock_path = root.join(".library/lock"); if lock_path.exists() { fs::remove_file(&lock_path)?; diff --git a/capsule-core/src/library/mod.rs b/capsule-core/src/library/mod.rs index b673c859..bea93efe 100644 --- a/capsule-core/src/library/mod.rs +++ b/capsule-core/src/library/mod.rs @@ -1,17 +1,17 @@ -pub mod auth_gate; -pub mod cache; -pub mod error; -pub mod init; +pub(crate) mod auth_gate; +pub(crate) mod cache; +pub(crate) mod error; +pub(crate) mod init; #[allow(clippy::module_inception)] -pub mod library; -pub mod lock; -pub mod open; -pub mod paths; -pub mod rebuild; -pub mod receipts; -pub mod scrub; -pub mod space; -pub mod storage_verify; +pub(crate) mod library; +pub(crate) mod lock; +pub(crate) mod open; +pub(crate) mod paths; +pub(crate) mod rebuild; +pub(crate) mod receipts; +pub(crate) mod scrub; +pub(crate) mod space; +pub(crate) mod storage_verify; pub use auth_gate::{ DEFAULT_GRACE, GateError, GateKeeper, GatedQueryError, GatedView, GraceClock, LocalAuthError, @@ -23,13 +23,13 @@ pub use init::init_library; pub use library::Library; pub use open::open_library; pub use paths::{ - ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, tmp_path, - transcode_h264_path, transcode_live_path, trash_path, uuid_shard, + ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, + thumbnail_path, tmp_path, transcode_h264_path, transcode_live_path, trash_path, uuid_shard, }; pub use rebuild::rebuild_index; pub use receipts::{ - CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, append_receipt, - load_receipts, verify_receipt, + CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, ReceiptStoreError, + append_receipt, load_receipts, verify_receipt, }; pub use space::{available_bytes, largest_asset_fits, streaming_recommended}; pub use storage_verify::{ diff --git a/capsule-core/src/library/open.rs b/capsule-core/src/library/open.rs index a96c7df8..d154a9ed 100644 --- a/capsule-core/src/library/open.rs +++ b/capsule-core/src/library/open.rs @@ -1,6 +1,6 @@ use std::path::Path; -use crate::db::DatabaseDriver; +use crate::db::{DatabaseDriver, MigrationError}; use crate::library::error::LibraryError; use crate::library::library::Library; use crate::library::lock; @@ -28,11 +28,17 @@ pub fn open_library(root: &Path) -> Result { // 2. Acquire lock. lock::try_acquire(root)?; - // 3. Open DB (release lock on failure). + // 3. Open DB (release lock on failure). A catalog stamped newer than this build is the + // one open failure with its own recovery (update the app), so it keeps its type. let db_path = root.join("index/library.sqlite"); - let db = DatabaseDriver::open(&db_path).map_err(|e| { + let db = DatabaseDriver::open_typed(&db_path).map_err(|e| { let _ = lock::release(root); - LibraryError::Db(e) + match e { + MigrationError::CatalogTooNew { found, supported } => { + LibraryError::CatalogTooNew { found, supported } + } + other => LibraryError::Db(other.into()), + } })?; // 4. Read config (release lock on failure). @@ -133,4 +139,51 @@ mod tests { } assert!(!root.join(".library/lock").exists()); } + + /// **S-D23, the owed half.** A catalog stamped by a newer build is refused with a typed + /// error naming both versions — never downgraded, never opened, and never flattened into + /// the generic `Db` arm a client can only print. The file is left byte-for-byte untouched + /// and the lock is released, so the user's *current* build can still open it after an + /// update. + #[test] + fn test_open_refuses_a_catalog_newer_than_this_build_untouched() { + use crate::db::schema::SCHEMA_VERSION; + + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("lib"); + init_library(&root, "T").unwrap().close().unwrap(); + + let db_path = root.join("index/library.sqlite"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1)) + .unwrap(); + } + let before = std::fs::read(&db_path).unwrap(); + + match open_library(&root) { + Err(LibraryError::CatalogTooNew { found, supported }) => { + assert_eq!(found, SCHEMA_VERSION + 1); + assert_eq!(supported, SCHEMA_VERSION); + } + Err(other) => panic!("expected CatalogTooNew, got {other:?}"), + Ok(_) => panic!("a too-new catalog must not open"), + } + assert_eq!( + std::fs::read(&db_path).unwrap(), + before, + "a refused catalog must not be written to" + ); + assert!( + !root.join(".library/lock").exists(), + "the lock is released on a refused open" + ); + // And the same library opens fine once the stamp is back within range. + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};")) + .unwrap(); + } + assert!(open_library(&root).is_ok()); + } } diff --git a/capsule-core/src/library/rebuild.rs b/capsule-core/src/library/rebuild.rs index 6b146635..dc6494d8 100644 --- a/capsule-core/src/library/rebuild.rs +++ b/capsule-core/src/library/rebuild.rs @@ -1,31 +1,31 @@ //! Recovery-first rebuild of the SQLite index from the artifacts on disk. //! -//! Two sidecar shapes can be found under `media/`, and this module reads both: +//! One sidecar shape is read here: the signed [`SidecarV1`] every write path emits +//! (`write_asset_files`, behind [`import_asset`](crate::lifecycle::Workspace::import_asset) +//! and every later metadata write). It carries the CRDT registers (`hidden`, `cull`, +//! `stack_membership`, rating, tags), and it is the write path's own output, so a rebuild +//! reconstructs exactly what the write path indexed. //! -//! * [`SidecarV1`] — the **signed** record every current write path emits (`write_asset_files`, -//! behind [`import_asset`](crate::lifecycle::Workspace::import_asset) and every later -//! metadata write). It carries the CRDT registers (`hidden`, `cull`, `stack_membership`, -//! rating, tags), so it is the shape a rebuild must prefer: it is the write path's own -//! output. -//! * [`AssetSidecar`] — the unsigned pre-signed-path shape. Its *write* path was retired by -//! `S-B2`/`S-G4`; the **read** stays as the compatibility case for libraries written before -//! the signed path existed. It has no register fields at all. +//! The unsigned pre-signed-path shape is **not** read any more (slice `S-D24`). Its write path +//! was retired by `S-B2`/`S-G4`, and its read path — the compatibility branch `S-D21` kept so a +//! pre-signed-path library still rebuilt — was retired once +//! [`Workspace::migrate_unsigned_sidecars`](crate::lifecycle::Workspace::migrate_unsigned_sidecars) +//! existed to bring such a library forward. A rebuild holds no key material and cannot sign a +//! sidecar, a manifest, or seal a blob, so it could never have *upgraded* one; what it does now +//! is [probe](crate::sidecar::shape) an unsigned file, count it, and name the verb in a `warn`, +//! rather than index an asset the workspace cannot verify, export, or upload. //! -//! Reading only the unsigned shape (the pre-`S-D21` behaviour) meant a rebuilt library came -//! back with every asset visible and un-trashed — a gate bypass, because rebuild is the -//! recovery path and the state it cannot carry is state the user cannot re-assert. +//! What the signed shape restores: //! -//! What each shape can restore: +//! | state | source | +//! |---|---| +//! | `hidden` (gated Hidden view) | the `hidden` LWW register | +//! | trash (`is_deleted`/`deleted_at`) | the provenance chain's lifecycle actions | +//! | `album_id` | the provenance chain head manifest | +//! | stacks | the `stack_membership` LWW register | +//! | `cull` | *not an index projection* — see below | //! -//! | state | signed `SidecarV1` | unsigned `AssetSidecar` | -//! |---|---|---| -//! | `hidden` (gated Hidden view) | the `hidden` LWW register | absent — no such field | -//! | trash (`is_deleted`/`deleted_at`) | the provenance chain's lifecycle actions | the `is_deleted`/`deleted_at` fields | -//! | `album_id` | the provenance chain head manifest | the `album_id` field | -//! | stacks | the `stack_membership` LWW register | the `stack_hint` field | -//! | `cull` | *not an index projection* — see below | absent — no such field | -//! -//! An **importer-formed** stack used to be the one thing neither shape carried: pre-`S-B15`, +//! An **importer-formed** stack used to be the one thing the sidecar did not carry: pre-`S-B15`, //! `import_asset_with` recorded it as `assets.stack_id` / `is_stack_hidden` and wrote no //! `stack_membership` register, so it lived only in the index and a lost index lost it. //! `S-B15` closed that: the importer now writes the register, so an importer-formed stack is @@ -55,17 +55,12 @@ use crate::cbor; use crate::crypto::provenance::ProvenanceRecord; use crate::crypto::provenance::action::Action; use crate::db::rows::{AssetRow, AssetStackRow, StackMemberRow}; -use crate::domain::{CaptureTzSource, DetectionMethod, MemberRole, StackType}; +use crate::domain::StackType; use crate::library::error::LibraryError; use crate::library::library::Library; -use crate::metadata::AssetType; -use crate::sidecar::AssetSidecar; -use crate::sidecar::io::read_sidecar; +use crate::sidecar::shape::{self, SidecarShape}; use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole}; -type StackGroupKey = (String, String); -type StackGroupMembers = Vec<(String, String, StackType)>; - /// A signed sidecar together with the directory it was found in (its provenance chain, /// which carries the album and the trash state, is that directory's sibling file). struct SignedOnDisk { @@ -88,13 +83,14 @@ struct ChainFacts { /// Rebuild the SQLite index from the sidecars on disk. /// -/// Every `{uuid}.cbor` under `media/` is decoded — preferring the signed [`SidecarV1`] shape -/// and falling back to the unsigned [`AssetSidecar`] compatibility shape — and upserted as an -/// `assets` row. Stacks are then reconstructed: from the `stack_membership` register for -/// signed sidecars, from `stack_hint` for unsigned ones. +/// Every `{uuid}.cbor` under `media/` is decoded as the signed [`SidecarV1`] shape and upserted +/// as an `assets` row; stacks are then reconstructed from the `stack_membership` registers. /// -/// A sidecar that decodes as neither shape is warned about and skipped: one unreadable file -/// must not cost the whole library its index. +/// An unsigned pre-signed-path sidecar is **not** indexed (slice `S-D24`): a rebuild holds no +/// keys and cannot admit it, so it is counted and reported with a `warn` naming +/// [`Workspace::migrate_unsigned_sidecars`](crate::lifecycle::Workspace::migrate_unsigned_sidecars). +/// A sidecar that is neither shape is warned about and skipped: one unreadable file must not +/// cost the whole library its index. #[tracing::instrument(skip_all, fields(root = %library.root.display()))] pub fn rebuild_index(library: &Library) -> Result<(), LibraryError> { let media_dir = library.root.join("media"); @@ -104,7 +100,7 @@ pub fn rebuild_index(library: &Library) -> Result<(), LibraryError> { } let mut signed: Vec = Vec::new(); - let mut legacy: Vec = Vec::new(); + let mut unsigned_pending = 0usize; let mut skipped = 0usize; for entry in WalkDir::new(&media_dir).into_iter().filter_map(Result::ok) { @@ -136,8 +132,8 @@ pub fn rebuild_index(library: &Library) -> Result<(), LibraryError> { } }; - // Signed shape first: it is what every current write path emits, and it is the only - // shape that carries the `hidden` register the default projections gate on. + // The signed shape is the only one read: it is what every write path emits, and it is + // the only shape that carries the `hidden` register the default projections gate on. match SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1) { Ok(sidecar) => { let dir = path.parent().unwrap_or(&media_dir).to_path_buf(); @@ -149,27 +145,25 @@ pub fn rebuild_index(library: &Library) -> Result<(), LibraryError> { ); signed.push(SignedOnDisk { dir, sidecar }); } - Err(signed_err) => match read_sidecar(path) { - // Compatibility case (`S-B2`/`S-G4`): a library written before the signed - // path existed. Nothing writes this shape any more, so nothing here can - // restore a register it never had. - Ok(sidecar) => { - tracing::debug!( + Err(signed_err) => match shape::probe(&bytes) { + // A pre-signed-path library (`S-D24`). Nothing here holds the keys to admit it, + // and indexing it would show an asset the workspace cannot verify, export, or + // upload — so it is counted and named, not indexed. + SidecarShape::LegacyUnsigned => { + unsigned_pending += 1; + tracing::warn!( sidecar = %path.display(), - asset_id = %sidecar.uuid, - shape = "asset-sidecar-unsigned", - "rebuild_index: decoded pre-signed-path sidecar" + "rebuild_index: unsigned pre-signed-path sidecar; not indexed. Run \ + `Workspace::migrate_unsigned_sidecars` to admit it as a signed asset" ); - legacy.push(sidecar); } - Err(legacy_err) => { + other => { skipped += 1; tracing::warn!( sidecar = %path.display(), - signed_error = %signed_err, - unsigned_error = %legacy_err, - "rebuild_index: sidecar decodes as neither the signed nor the \ - pre-signed-path shape; skipping" + shape = ?other, + error = %signed_err, + "rebuild_index: sidecar does not decode as the signed shape; skipping" ); } }, @@ -203,29 +197,15 @@ pub fn rebuild_index(library: &Library) -> Result<(), LibraryError> { library.db.upsert_asset(&row)?; } - for sidecar in &legacy { - let row = legacy_asset_row(sidecar); - trashed += usize::from(row.is_deleted); - tracing::trace!( - asset_id = %row.uuid, - album_id = ?row.album_id, - is_deleted = row.is_deleted, - "rebuild_index: upserting row rebuilt from a pre-signed-path sidecar" - ); - library.db.upsert_asset(&row)?; - } - let signed_stacks = rebuild_signed_stacks(library, &signed); - let legacy_stacks = rebuild_legacy_stacks(library, &legacy); tracing::info!( signed = signed.len(), - unsigned = legacy.len(), + unsigned_pending, skipped, hidden, trashed, signed_stacks, - unsigned_stacks = legacy_stacks, "rebuild_index: index rebuilt from on-disk sidecars" ); Ok(()) @@ -444,109 +424,6 @@ fn rebuild_signed_stacks(library: &Library, signed: &[SignedOnDisk]) -> usize { groups.len() } -// ── the pre-signed-path compatibility shape ───────────────────────────────── - -/// Project an unsigned pre-signed-path sidecar onto an `assets` row. -/// -/// This shape predates every CRDT register, so `is_hidden` is necessarily `false` here: the -/// file carries no `hidden` field to read. That is a property of the old on-disk format, not -/// a projection choice — a library that was ever written by the signed path has a -/// [`SidecarV1`] instead, and takes the branch above. -fn legacy_asset_row(s: &AssetSidecar) -> AssetRow { - AssetRow { - uuid: s.uuid.clone(), - asset_type: asset_type_str(s.asset_type).to_string(), - capture_timestamp: s.capture_timestamp.unwrap_or(s.import_timestamp), - capture_utc: s.capture_utc, - capture_tz_source: s.capture_tz_source.map(|c| tz_source_str(c).to_string()), - import_timestamp: s.import_timestamp, - hash_sha256: s.hash_sha256.clone(), - width: s.width.map(i64::from), - height: s.height.map(i64::from), - duration_ms: s.duration_ms.map(|d| d as i64), - stack_id: None, - is_stack_hidden: false, - chromahash: None, - dominant_color: None, - album_id: s.album_id.clone(), - rating: i64::from(s.rating), - is_deleted: s.is_deleted, - deleted_at: s.deleted_at, - is_hidden: false, - } -} - -/// Reconstruct stacks from the unsigned shape's `stack_hint` fields, grouping by -/// `(detection_key, detection_method)`. Returns the number of stacks written. -fn rebuild_legacy_stacks(library: &Library, legacy: &[AssetSidecar]) -> usize { - let mut groups: HashMap = HashMap::new(); - - for sidecar in legacy { - if let Some(hint) = &sidecar.stack_hint { - let method_str = detection_method_str(hint.detection_method); - groups - .entry((hint.detection_key.clone(), method_str.to_string())) - .or_default() - .push(( - sidecar.uuid.clone(), - member_role_str(hint.member_role).to_string(), - hint.stack_type, - )); - } - } - - let now = now_secs(); - for ((detection_key, detection_method), members) in &groups { - let stack_id = format!("{detection_method}:{detection_key}"); - let Some(primary_uuid) = members - .iter() - .find(|(_, role, _)| role == "primary") - .or_else(|| members.first()) - .map(|(uuid, _, _)| uuid.clone()) - else { - continue; - }; - - let stack_type_str = members - .first() - .map_or("custom", |(_, _, st)| stack_type_str(*st)); - - let stack_row = AssetStackRow { - id: stack_id.clone(), - stack_type: stack_type_str.to_string(), - primary_asset_id: primary_uuid.clone(), - cover_asset_id: Some(primary_uuid.clone()), - is_collapsed: true, - is_auto_generated: true, - created_at: now, - modified_at: now, - }; - // Ignore error if stack already exists (idempotent on rebuild). - let _ = library.db.insert_stack(&stack_row); - - for (i, (uuid, role, _)) in members.iter().enumerate() { - let member_row = StackMemberRow { - id: format!("{stack_id}#{i}"), - stack_id: stack_id.clone(), - asset_id: uuid.clone(), - sequence_order: i as i64, - member_role: role.clone(), - created_at: now, - }; - let _ = library.db.insert_stack_member(&member_row); - - let is_primary = uuid == &primary_uuid; - let _ = library.db.update_stack_hidden(uuid, !is_primary); - } - tracing::debug!( - stack_id = %stack_id, - members = members.len(), - "rebuild_index: stack reconstructed from pre-signed-path stack hints" - ); - } - groups.len() -} - // ── helpers ───────────────────────────────────────────────────────────────── fn rfc3339_to_secs(s: &str) -> Option { @@ -555,49 +432,7 @@ fn rfc3339_to_secs(s: &str) -> Option { .map(|t: jiff::Timestamp| t.as_second()) } -fn asset_type_str(t: AssetType) -> &'static str { - match t { - AssetType::Photo => "photo", - AssetType::Video => "video", - AssetType::Sidecar => "sidecar", - } -} - -fn tz_source_str(s: CaptureTzSource) -> &'static str { - match s { - CaptureTzSource::OffsetExif => "offset_exif", - CaptureTzSource::GpsLookup => "gps_lookup", - CaptureTzSource::Floating => "floating", - } -} - -fn detection_method_str(m: DetectionMethod) -> &'static str { - match m { - DetectionMethod::FilenameStem => "filename_stem", - DetectionMethod::ContentIdentifier => "content_identifier", - DetectionMethod::Timecode => "timecode", - DetectionMethod::Manual => "manual", - } -} - -fn member_role_str(r: MemberRole) -> &'static str { - match r { - MemberRole::Primary => "primary", - MemberRole::Raw => "raw", - MemberRole::Video => "video", - MemberRole::Audio => "audio", - MemberRole::DepthMap => "depth_map", - MemberRole::Processed => "processed", - MemberRole::Source => "source", - MemberRole::Alternate => "alternate", - MemberRole::Sidecar => "sidecar", - MemberRole::Proxy => "proxy", - MemberRole::Master => "master", - } -} - -/// The `stack_members.member_role` string for a signed membership's role. Shares the -/// vocabulary of [`member_role_str`] where the two enums overlap. +/// The `stack_members.member_role` string for a signed membership's role. fn stack_role_str(r: StackRole) -> &'static str { match r { StackRole::Primary => "primary", @@ -644,9 +479,9 @@ fn now_secs() -> i64 { // 7. the `{uuid}.provenance.cbor` / `{uuid}.receipts.cbor` siblings are not sidecars // 8. `cull` needs no rebuild support — it is not an index projection (audit finding) // -// unsigned shape (`AssetSidecar` — the pre-signed-path compatibility read) -// 9-11. the pre-existing standalone / stacked / idempotent cases still pass -// 12. a library holding both shapes rebuilds both +// the retired unsigned shape (`S-D24`) +// 9. the two shapes are disjoint on the wire, so the probe cannot mis-route a file +// 10. an unsigned sidecar is reported and not indexed; the signed asset beside it is #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -657,15 +492,11 @@ mod tests { use super::*; use crate::crypto::hash::Hash32; use crate::crypto::primitives::{Argon2Params, CRYPTO_SUITE_ID}; - use crate::domain::{DetectionMethod, ImportMode, MemberRole, StackType}; use crate::library::init::init_library; use crate::library::open::open_library; use crate::lifecycle::Workspace; - use crate::metadata::AssetType; use crate::metadata::crdt::{Lww, OrSet}; - use crate::sidecar::io::write_sidecar; use crate::sidecar::sidecar_v1::{CullFlag, Dimensions}; - use crate::sidecar::{AssetSidecar, StackHint}; /// The media directory every fixture in this module writes into (`capture_timestamp` /// below resolves here). @@ -679,43 +510,6 @@ mod tests { } } - // ── the unsigned, pre-signed-path shape ───────────────────────────────── - - fn make_sidecar(uuid: &str, hash: &str, hint: Option) -> AssetSidecar { - AssetSidecar { - version: 1, - uuid: uuid.to_string(), - asset_type: AssetType::Photo, - original_filename: format!("{uuid}.jpg"), - import_timestamp: 1720000000, - modified_timestamp: 1720000000, - hash_sha256: hash.to_string(), - file_size: 1024, - is_deleted: false, - rating: 0, - tags: vec![], - import_mode: ImportMode::Copy, - importer_version: "0.1.0".to_string(), - rawshift_version: "0.1.0".to_string(), - capture_timestamp: None, - capture_utc: None, - capture_tz: None, - capture_tz_source: None, - tz_db_version: None, - width: None, - height: None, - duration_ms: None, - stack_hint: hint, - album_id: None, - deleted_at: None, - camera_make: None, - camera_model: None, - gps_lat: None, - gps_lon: None, - unknown_fields: BTreeMap::new(), - } - } - // ── the signed shape ──────────────────────────────────────────────────── /// A signed sidecar as `lifecycle::write_asset_files` would leave it on disk, minus the @@ -761,42 +555,84 @@ mod tests { .unwrap(); } - /// The two shapes are disjoint on the wire, so probing signed-then-unsigned cannot - /// mis-route a file: a signed sidecar has integer field 0 and no `version` key, an - /// unsigned one has `version` and no field 0. This is also *why* the pre-`S-D21` rebuild - /// lost the register state silently — it did not mis-read signed sidecars, it skipped - /// every one of them, so a signed library rebuilt to nothing at all. + /// A legacy unsigned sidecar exactly as the retired serializer wrote it: text keys and + /// `version: 1`, no integer key `0`. + fn legacy_sidecar_bytes(uuid: &str, hash_hex: &str) -> Vec { + use ciborium::value::Value; + let text = |s: &str| Value::Text(s.to_string()); + let map = Value::Map(vec![ + (text("version"), Value::Integer(1.into())), + (text("uuid"), text(uuid)), + (text("asset_type"), text("photo")), + (text("hash_sha256"), text(hash_hex)), + ( + text("import_timestamp"), + Value::Integer(1_720_000_000.into()), + ), + ]); + let mut out = Vec::new(); + ciborium::ser::into_writer(&map, &mut out).unwrap(); + out + } + + /// The two shapes are disjoint on the wire, so the probe cannot mis-route a file: a + /// signed sidecar has integer field 0 and no `version` key, an unsigned one has `version` + /// and no field 0. This is also *why* the pre-`S-D21` rebuild lost the register state + /// silently — it did not mis-read signed sidecars, it skipped every one of them, so a + /// signed library rebuilt to nothing at all. #[test] - fn the_two_sidecar_shapes_do_not_decode_as_each_other() { - let tmp = TempDir::new().unwrap(); - let dir = tmp.path(); + fn the_two_sidecar_shapes_probe_disjointly() { + let signed = signed_sidecar(Uuid::from_u128(0xD15), 0x66).to_canonical_vec(); + assert_eq!(shape::probe(&signed), SidecarShape::Signed { schema: 1 }); + assert!(SidecarV1::from_canonical_slice(&signed, SIDECAR_SCHEMA_V1).is_ok()); - let signed_path = dir.join("signed.cbor"); - fs::write( - &signed_path, - signed_sidecar(Uuid::from_u128(0xD15), 0x66).to_canonical_vec(), - ) - .unwrap(); + let legacy = legacy_sidecar_bytes("eeee0000-0000-0000-0000-000000000005", &"e".repeat(64)); + assert_eq!(shape::probe(&legacy), SidecarShape::LegacyUnsigned); assert!( - read_sidecar(&signed_path).is_err(), - "the pre-signed-path reader must reject a signed sidecar" + SidecarV1::from_canonical_slice(&legacy, SIDECAR_SCHEMA_V1).is_err(), + "the signed reader must reject a pre-signed-path sidecar" ); + } - let legacy_path = dir.join("legacy.cbor"); - write_sidecar( - &legacy_path, - &make_sidecar( - "eeee0000-0000-0000-0000-000000000005", - &"e".repeat(64), - None, - ), + /// **`S-D24`.** An unsigned pre-signed-path sidecar is reported and *not* indexed: a + /// rebuild holds no keys and cannot admit it, and indexing it would show an asset the + /// workspace cannot verify, export, or upload. The signed asset beside it rebuilds as + /// before, and the run still succeeds — one legacy file must not cost the library its + /// index. + #[test] + fn an_unsigned_sidecar_is_reported_not_indexed() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path().join("lib"); + let lib = init_library(&root, "T").unwrap(); + let media_dir = root.join(FIXTURE_MEDIA); + fs::create_dir_all(&media_dir).unwrap(); + + fs::write( + media_dir.join("dddd000000000000000000000000004.cbor"), + legacy_sidecar_bytes("dddd0000-0000-0000-0000-000000000004", &"d".repeat(64)), ) .unwrap(); - let bytes = fs::read(&legacy_path).unwrap(); + let signed_id = Uuid::from_u128(0x11D); + let mut signed = signed_sidecar(signed_id, 0x44); + signed + .hidden + .set(true, "2026-08-01T00:00:00Z", Uuid::from_u128(0xD1)); + write_signed(&media_dir, &signed); + + rebuild_index(&lib).unwrap(); + assert!( - SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1).is_err(), - "the signed reader must reject a pre-signed-path sidecar" + lib.db.find_by_hash(&"d".repeat(64)).unwrap().is_none(), + "the unsigned sidecar is not indexed" ); + let signed_row = lib + .db + .find_by_uuid(&signed_id.to_string()) + .unwrap() + .unwrap(); + assert!(signed_row.is_hidden); + assert!(lib.db.query_timeline(0, 100).unwrap().is_empty()); + assert_eq!(lib.db.query_hidden(0, 100).unwrap().len(), 1); } /// **The `S-D21` acceptance case.** A hidden asset survives a rebuild still hidden — and @@ -1286,162 +1122,4 @@ mod tests { ); assert_eq!(ws.assets_by_cull(CullFlag::Reject), vec![id]); } - - // ── the pre-signed-path compatibility read ────────────────────────────── - - #[test] - fn test_rebuild_standalone_asset() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("lib"); - let lib = init_library(&root, "T").unwrap(); - - // Manually write a sidecar - let media_dir = root.join(FIXTURE_MEDIA); - std::fs::create_dir_all(&media_dir).unwrap(); - let sidecar = make_sidecar( - "aabbccdd-0000-0000-0000-000000000001", - &"a".repeat(64), - None, - ); - write_sidecar( - &media_dir.join("aabbccdd00000000000000000000001.cbor"), - &sidecar, - ) - .unwrap(); - - rebuild_index(&lib).unwrap(); - - let found = lib.db.find_by_hash(&"a".repeat(64)).unwrap(); - assert!(found.is_some(), "asset should be in DB after rebuild"); - } - - #[test] - fn test_rebuild_stacked_assets() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("lib"); - let lib = init_library(&root, "T").unwrap(); - - let media_dir = root.join(FIXTURE_MEDIA); - std::fs::create_dir_all(&media_dir).unwrap(); - - let primary_hint = StackHint { - detection_key: "img_0042".to_string(), - detection_method: DetectionMethod::FilenameStem, - member_role: MemberRole::Primary, - stack_type: StackType::RawJpeg, - }; - let raw_hint = StackHint { - detection_key: "img_0042".to_string(), - detection_method: DetectionMethod::FilenameStem, - member_role: MemberRole::Raw, - stack_type: StackType::RawJpeg, - }; - - let primary = make_sidecar( - "aaaa0000-0000-0000-0000-000000000001", - &"a".repeat(64), - Some(primary_hint), - ); - let raw = make_sidecar( - "bbbb0000-0000-0000-0000-000000000002", - &"b".repeat(64), - Some(raw_hint), - ); - - write_sidecar( - &media_dir.join("aaaa000000000000000000000000001.cbor"), - &primary, - ) - .unwrap(); - write_sidecar( - &media_dir.join("bbbb000000000000000000000000002.cbor"), - &raw, - ) - .unwrap(); - - rebuild_index(&lib).unwrap(); - - // Both assets should be in the DB - assert!(lib.db.find_by_hash(&"a".repeat(64)).unwrap().is_some()); - assert!(lib.db.find_by_hash(&"b".repeat(64)).unwrap().is_some()); - - // Primary should be visible, raw hidden - let timeline = lib.db.query_timeline(0, 100).unwrap(); - assert_eq!( - timeline.len(), - 1, - "only primary should be visible in timeline" - ); - } - - #[test] - fn test_rebuild_is_idempotent() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("lib"); - let lib = init_library(&root, "T").unwrap(); - - let media_dir = root.join(FIXTURE_MEDIA); - std::fs::create_dir_all(&media_dir).unwrap(); - let sidecar = make_sidecar( - "cccc0000-0000-0000-0000-000000000003", - &"c".repeat(64), - None, - ); - write_sidecar( - &media_dir.join("cccc000000000000000000000000003.cbor"), - &sidecar, - ) - .unwrap(); - - rebuild_index(&lib).unwrap(); - rebuild_index(&lib).unwrap(); // second call should not fail - - let found = lib.db.find_by_hash(&"c".repeat(64)).unwrap(); - assert!(found.is_some()); - } - - /// The two shapes coexist: a library part-written before the signed path must rebuild - /// both, with the signed asset keeping its register state and the unsigned one keeping - /// what its shape can carry. - #[test] - fn mixed_library_rebuilds_both_sidecar_shapes() { - let tmp = TempDir::new().unwrap(); - let root = tmp.path().join("lib"); - let lib = init_library(&root, "T").unwrap(); - let media_dir = root.join(FIXTURE_MEDIA); - std::fs::create_dir_all(&media_dir).unwrap(); - - let legacy = make_sidecar( - "dddd0000-0000-0000-0000-000000000004", - &"d".repeat(64), - None, - ); - write_sidecar( - &media_dir.join("dddd000000000000000000000000004.cbor"), - &legacy, - ) - .unwrap(); - - let signed_id = Uuid::from_u128(0x11D); - let mut signed = signed_sidecar(signed_id, 0x44); - signed - .hidden - .set(true, "2026-08-01T00:00:00Z", Uuid::from_u128(0xD1)); - write_signed(&media_dir, &signed); - - rebuild_index(&lib).unwrap(); - - assert!( - lib.db.find_by_hash(&"d".repeat(64)).unwrap().is_some(), - "the pre-signed-path sidecar still rebuilds" - ); - let signed_row = lib - .db - .find_by_uuid(&signed_id.to_string()) - .unwrap() - .unwrap(); - assert!(signed_row.is_hidden); - let timeline = lib.db.query_timeline(0, 100).unwrap(); - assert_eq!(timeline.len(), 1, "only the unsigned, visible asset shows"); - } } diff --git a/capsule-core/src/library/receipts.rs b/capsule-core/src/library/receipts.rs index 724c426e..1142453d 100644 --- a/capsule-core/src/library/receipts.rs +++ b/capsule-core/src/library/receipts.rs @@ -9,11 +9,14 @@ //! server that withholds receipts never becomes the sole holder of an only-copy. //! //! This module is the client's **persistence** path, and only that. The receipt type, its -//! verification and [`BlobRole`] moved to [`crate::crypto::receipts`] (`S-C46`) so the server -//! that *issues* receipts shares one definition instead of mirroring it — a signed structure -//! defined twice is a signature that eventually stops verifying, and the failure would look -//! like the server withholding receipts. They are re-exported here, so every path a client -//! already uses keeps working. +//! verification and the blob-role enum moved to [`crate::crypto::receipts`] (`S-C46`) so the +//! server that *issues* receipts shares one definition instead of mirroring it — a signed +//! structure defined twice is a signature that eventually stops verifying, and the failure would +//! look like the server withholding receipts. The receipt type and its verification are +//! re-exported here and through [`crate::library`], so every path a client already uses keeps +//! working. The role enum is not: it is reached as +//! [`crypto::receipts::BlobRole`](crate::crypto::receipts::BlobRole), and as +//! [`library::BlobRole`](crate::library::BlobRole) through the storage-verify barrel. //! //! Persistence is first-class, not a cache: the log is appended to //! `media/{YYYY}/{YYYY-MM}/{uuid}.receipts.cbor` and included verbatim in the backup artifact — @@ -26,8 +29,7 @@ use uuid::Uuid; use crate::cbor::{self, CanonicalError}; pub use crate::crypto::receipts::{ - BlobRole, CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, role_str, - verify_receipt, + CustodyReceipt, CustodyReceiptCore, ReceiptExpectations, ReceiptRejection, verify_receipt, }; use crate::library::paths::receipts_path; @@ -92,6 +94,7 @@ mod tests { use super::*; use crate::crypto::hash::Hash32; use crate::crypto::keys::{HybridSignature, HybridSigningKey, HybridVerifyingKey}; + use crate::crypto::receipts::BlobRole; fn signing_key(seed: u8) -> HybridSigningKey { HybridSigningKey::from_seed64(&[seed; 64]) diff --git a/capsule-core/src/library/scrub.rs b/capsule-core/src/library/scrub.rs index 92ef2114..74183e9a 100644 --- a/capsule-core/src/library/scrub.rs +++ b/capsule-core/src/library/scrub.rs @@ -11,7 +11,10 @@ const TMP_AGE_SECS: u64 = 5 * 60; /// Run a startup scrub if more than 7 days have passed since the last one. /// Removes `.tmp` files older than 5 minutes from `media/` and updates /// `config.last_scrubbed_at`. -pub fn startup_scrub(root: &Path, config: &mut LibraryConfigCbor) -> Result<(), LibraryError> { +pub(crate) fn startup_scrub( + root: &Path, + config: &mut LibraryConfigCbor, +) -> Result<(), LibraryError> { let now = now_secs(); let needs_scrub = match config.last_scrubbed_at { None => true, diff --git a/capsule-core/src/library/space.rs b/capsule-core/src/library/space.rs index e4586b89..bce29bcc 100644 --- a/capsule-core/src/library/space.rs +++ b/capsule-core/src/library/space.rs @@ -6,7 +6,7 @@ //! confirmation, so the planner stays deterministic. The recommendation predicate below //! is pure and is the contract the executor's streaming drive mode keys off. The //! `total_size` accounting and the plan-level `streaming_recommended` attachment live on -//! [`ImportActionPlan`](crate::import::planner::ImportActionPlan); this module owns the +//! [`ImportActionPlan`](crate::import::ImportActionPlan); this module owns the //! probe and the two pure verdicts (`streaming_recommended`, `largest_asset_fits`) they //! feed. diff --git a/capsule-core/src/library/storage_verify.rs b/capsule-core/src/library/storage_verify.rs index c9493386..287fa796 100644 --- a/capsule-core/src/library/storage_verify.rs +++ b/capsule-core/src/library/storage_verify.rs @@ -183,7 +183,7 @@ impl<'a, V: StorageVerifier + ?Sized> ReleaseGate<'a, V> { // ─── The three destructive paths, gated ─────────────────────────────────────── /// **Device-owned-original release** (the cache-eviction sweep's counterpart for owned -/// originals, which [`cache_sweep`](crate::library::cache::cache_sweep) never touches +/// originals, which [`cache_sweep`](crate::library::cache_sweep) never touches /// automatically). An original the device itself uploaded is the source of truth until the /// server durably holds it; it is released — its local file deleted and its owned-original /// representation row dropped, after which it becomes an ordinary server-only, re-fetchable diff --git a/capsule-core/src/lifecycle/album.rs b/capsule-core/src/lifecycle/album.rs index 33ffa165..b5094585 100644 --- a/capsule-core/src/lifecycle/album.rs +++ b/capsule-core/src/lifecycle/album.rs @@ -260,7 +260,9 @@ impl Workspace { /// under (`amk_version`), never assuming the album's current epoch — so an asset imported /// before a rotation still derives the key it was encrypted with. Because the fresh /// `nonce_prefix` is folded into the salt, this is the read/regenerate path; a *fresh* - /// write goes through [`encrypt_asset_rekey`], which draws the nonce and derives together. + /// write goes through + /// [`encrypt_asset_rekey`](crate::crypto::encryption::encrypt_asset_rekey), which draws the + /// nonce and derives together. pub(super) fn file_key( &self, album: &AlbumKeys, @@ -279,7 +281,7 @@ mod tests { use tempfile::TempDir; - use super::super::fast_workspace; + use super::super::{FAST_PARAMS, fast_workspace}; use super::*; use crate::crypto::verify_asset::VerifyOutcome; @@ -317,7 +319,8 @@ mod tests { // A cross-epoch backup escrows each asset's own-epoch AMK; restore into a fresh library // is byte-equal for both (guards the export file-key / blob-key / escrow-value epochs). let backup_path = src.path().join("backup.tar"); - ws.export_backup(&backup_path, b"recovery-pass").unwrap(); + ws.export_backup_with_params(&backup_path, b"recovery-pass", FAST_PARAMS) + .unwrap(); let exporter_pub = ws.exporter_verifying_key(); let fresh = TempDir::new().unwrap(); diff --git a/capsule-core/src/lifecycle/backup.rs b/capsule-core/src/lifecycle/backup.rs index 7991f156..b5a8b0f1 100644 --- a/capsule-core/src/lifecycle/backup.rs +++ b/capsule-core/src/lifecycle/backup.rs @@ -14,6 +14,8 @@ use crate::backup::{ }; use crate::crypto::hash::{self, Hash32}; use crate::crypto::keys::HybridVerifyingKey; +#[cfg(any(test, feature = "test-support"))] +use crate::crypto::primitives::Argon2Params; use crate::crypto::primitives::{CRYPTO_SUITE_ID, DeviceTier}; use crate::crypto::provenance::{AssetManifest, ProvenanceChain}; use crate::crypto::pwkdf::WrappedSecret; @@ -70,8 +72,47 @@ impl Workspace { } /// Export every managed asset to a portable backup artifact. + /// + /// The AMK ledger is wrapped at the production Argon2id cost + /// ([`backup::WRAP_PARAMS`]) and there is no way to ask for another: this method takes no + /// cost argument and [`backup::export`] takes none either, so no caller reachable from a + /// production build can write a brute-forceable artifact. #[tracing::instrument(skip_all, fields(out = %out.display()))] pub fn export_backup(&self, out: &Path, passphrase: &[u8]) -> Result<()> { + let input = self.backup_input()?; + let bytes = backup::export(&input, passphrase, self.device_signer.as_ref())?; + self.write_artifact(out, &bytes) + } + + /// As [`export_backup`](Self::export_backup) but with an explicit Argon2id cost for the + /// artifact's wrap key. + /// + /// The cost is recorded in the artifact, so [`import_backup`](Self::import_backup) + /// reproduces the wrap key from what it reads rather than from a constant: exporting cheaply + /// makes *both* legs of a backup round trip cheap, and no import-side entry point is needed. + /// + /// **Not in a production build.** Compiled only under `cfg(test)` or the non-default + /// `test-support` feature, because a weak cost here is a brute-forceable backup and nothing + /// downstream re-checks it — the same reason + /// [`escrow_master_key`](Self::escrow_master_key) takes a closed `DeviceTier` rather than + /// raw parameters. + #[cfg(any(test, feature = "test-support"))] + #[tracing::instrument(skip_all, fields(out = %out.display(), ?params))] + pub fn export_backup_with_params( + &self, + out: &Path, + passphrase: &[u8], + params: Argon2Params, + ) -> Result<()> { + let input = self.backup_input()?; + let bytes = + backup::export_with_params(&input, passphrase, params, self.device_signer.as_ref())?; + self.write_artifact(out, &bytes) + } + + /// Everything the artifact needs about this library, collected once so both export entry + /// points read the same state rather than each walking the library themselves. + fn backup_input(&self) -> Result { let mut assets = Vec::new(); let mut amks: BTreeMap<(Uuid, u32), [u8; 32]> = BTreeMap::new(); @@ -105,15 +146,18 @@ impl Workspace { }); } - let input = BackupInput { + Ok(BackupInput { assets, amks, exporter_device: self.account.device.device_id, source_library_version: "1".into(), export_timestamp: now_rfc3339(), - }; - let bytes = backup::export(&input, passphrase, self.device_signer.as_ref())?; - fs::write(out, &bytes).map_err(|e| LifecycleError::Io(e.to_string()))?; + }) + } + + /// Write an assembled artifact to `out`. + fn write_artifact(&self, out: &Path, bytes: &[u8]) -> Result<()> { + fs::write(out, bytes).map_err(|e| LifecycleError::Io(e.to_string()))?; tracing::info!(bytes = bytes.len(), "backup: export complete"); Ok(()) } diff --git a/capsule-core/src/lifecycle/derivatives.rs b/capsule-core/src/lifecycle/derivatives.rs new file mode 100644 index 00000000..54bff749 --- /dev/null +++ b/capsule-core/src/lifecycle/derivatives.rs @@ -0,0 +1,1267 @@ +//! The one `lifecycle` file that reaches [`crate::media`]: decode a still once at import and +//! derive everything that needs pixels (slices `S-B1`, `S-B13`). +//! +//! # Why this is not feature-gated +//! +//! `capsule-core`'s `native` feature *implies* `media`, and the whole `lifecycle` module is +//! `native`-gated, so a build that compiles this file always has the codec stack. The two builds +//! that drop `media` — `capsule-server` and `capsule-wasm`, both +//! `default-features = false` — drop `lifecycle` with it. A `#[cfg(feature = "media")]` here +//! would therefore guard nothing while making every signature read as optional; if the +//! implication is ever removed, this file fails to compile, which is the right way for that +//! decision to surface. +//! +//! # Never fails the import +//! +//! Capsule is a backup tool. Every path below degrades to "the original is imported signed, +//! encrypted and `verify_asset`-accepting, without a placeholder or a thumbnail" and records +//! **why** in the returned [`DerivativeStatus`]. The only errors that propagate are the ones +//! that mean the *workspace* is broken — a missing album, a signer that refused — not the ones +//! that mean the pixels were unreadable. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::Path; + +use uuid::Uuid; + +use super::{AssetState, DerivativeStatus, LifecycleError, Result, Workspace, media_dir}; +use crate::cbor; +use crate::crypto::encryption::rekey::encrypt_asset_rekey_with_prefix; +use crate::crypto::encryption::stream::{AssetEncryption, NONCE_PREFIX_LEN}; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::{Amk, AmkVersion}; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::crypto::rng; +use crate::exif::extract::ExifExtract; +use crate::lqip::Lqip; +use crate::media::{ + DecodedImage, DerivativeContext, DerivativeSealer, DerivativeTier, GeneratedDerivative, + MediaError, RawshiftDecoder, SealedDerivative, StillFormat, decode_guarded, + generate_still_derivatives, guarded, +}; +use crate::sidecar::sidecar_v1::{Dimensions, Lqip as SidecarLqip}; + +/// The file under import, as [`prepare_still`](Workspace::prepare_still) needs to see it. +/// +/// A parameter object rather than four positional arguments: these four are one thing — the +/// bytes on the way in and what the scanner already learned about them — and they are always +/// passed together. The alternative was silencing `clippy::too_many_arguments`, which would have +/// hidden that the signature had grown two *kinds* of input (the file, and the crypto identity +/// it commits under) without saying so. +pub(super) struct StillSource<'a> { + /// The file's bytes. + pub(super) plaintext: &'a [u8], + /// Its lowercase extension without the dot, `""` when it has none. + pub(super) ext: &'a str, + /// Where it came from — for logs only; the bytes above are authoritative. + pub(super) src: &'a Path, + /// What `capsule_core::exif` read off it, the fallback for dimensions. + pub(super) exif: &'a ExifExtract, +} + +/// Everything one still yields in a single decode pass: the sidecar fields, the signed +/// derivatives to persist after the durable commit, and the reason for anything missing. +pub(super) struct PreparedStill { + /// The format detection identified, if the bytes are a still Capsule models. Drives the + /// sidecar's `content_type` from the *header* rather than from the file name. + pub(super) format: Option, + /// Pixel dimensions when the still decoded, EXIF dimensions otherwise, `None` if neither. + /// + /// Decoded pixels win over EXIF because they are post-orientation: a quarter-turned JPEG's + /// EXIF `PixelXDimension` is its *stored* width, which is transposed relative to what a + /// viewer shows. + pub(super) dimensions: Option, + /// The sidecar LQIP, present only when the still decoded. + pub(super) lqip: Option, + /// Signed thumbnail derivatives, to be written after the asset's own files. + pub(super) derivatives: Vec, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot encode. + pub(super) deferred_formats: usize, + /// Whether derivatives were generated, and if not, why. + pub(super) status: DerivativeStatus, +} + +impl PreparedStill { + /// The outcome for bytes that yielded no pixels: EXIF dimensions only, no LQIP, no + /// derivatives, and `status` carrying which of the reasons it was. + fn undecoded( + format: Option, + exif_dimensions: Option, + status: DerivativeStatus, + ) -> Self { + Self { + format, + dimensions: exif_dimensions, + lqip: None, + derivatives: Vec::new(), + deferred_formats: 0, + status, + } + } +} + +/// Map a decode failure onto the status the run summary counts, logging the distinction that +/// makes the two reasons useful (slice `S-B13`). +fn classify(error: &MediaError, src: &Path, format: Option) -> DerivativeStatus { + match error { + MediaError::UnsupportedFormat { format, op } => { + tracing::warn!( + path = %src.display(), + %format, + op = op.as_str(), + supported = ?crate::media::SUPPORTED_STILL_FORMATS, + "derivatives: no codec for this format in this build; the original is imported \ + signed and encrypted, but without a thumbnail or LQIP until the codec lands. \ + Derivatives are backfillable from the stored original (S-B13)" + ); + DerivativeStatus::DeferredNoCodec + } + MediaError::NotAStillImage => { + tracing::debug!( + path = %src.display(), + "derivatives: not a still image Capsule models; nothing to decode" + ); + DerivativeStatus::NotAKnownStill + } + // Everything else is a format we *do* support failing on these particular bytes — a + // real problem worth investigating, not an expected gap. + error => { + tracing::warn!( + path = %src.display(), + ?format, + %error, + "derivatives: a supported format failed to decode; the original is imported \ + signed and encrypted, but without a thumbnail or LQIP" + ); + DerivativeStatus::DecodeFailed + } + } +} + +/// Compute the sidecar LQIP from a decoded frame. +/// +/// From the **full-resolution, orientation-applied** frame, never from the thumbnail: +/// chromahash consumes the whole frame and band-limits on the read side via `decode_capped`, so +/// pre-resizing would silently cap fidelity the format can carry ([`crate::lqip`]). +fn lqip_from(decoded: &DecodedImage, src: &Path) -> Option { + // Guarded because `chromahash` is pre-1.0 too and its own `encode` panics on a zero + // dimension or a length mismatch. `Lqip::encode` checks both, so this is belt and braces — + // but it is what makes the module's "no codec can abort an import" claim true rather than + // nearly true. + let encoded = guarded("lqip", || { + Lqip::encode( + decoded.width(), + decoded.height(), + &decoded.image.rgba, + decoded.gamut, + ) + .map_err(|e| MediaError::Decode { + format: decoded.format, + detail: format!("LQIP encode: {e}"), + }) + }); + match encoded { + Ok(lqip) => Some(lqip.to_sidecar()), + Err(error) => { + // A decoded frame satisfies both of `encode`'s preconditions by construction, so + // this is unreachable rather than expected — logged as such, and never fatal. + tracing::warn!( + path = %src.display(), + %error, + "derivatives: a decoded frame was rejected by the LQIP encoder; importing \ + without a placeholder" + ); + None + } + } +} + +/// What an asset's existing derivative bundle constrains about the next generation. +/// +/// One read, two facts, because both come off the same file and both are needed together. +pub(super) struct ExistingDerivatives { + /// The current head of each role's chain. Empty when the asset has no bundle yet, which is + /// every import: a create starts each role's chain. It is a **regeneration** — the `#437` + /// backfill that adds a second format to an asset that already has one — that needs this, + /// and it needs it to be right the first time, because a forked chain is not something a + /// later run can repair. + /// + /// The link is SHA-256 over the manifest's canonical CBOR, signatures included: the same + /// content-hash link the asset provenance chain uses. + pub(super) heads: HashMap, + /// Every `nonce_prefix` already used for this `file_id` by a derivative. + /// + /// The encryption doc makes the refusal normative: "the writer additionally refuses to emit + /// a `nonce_prefix` it has already used for that `file_id` … the same rule governs + /// derivative re-encryption". A prefix is folded into the file-key salt, so reusing one + /// reuses the *key* as well as the nonce — the keystream separation the whole construction + /// rests on. + pub(super) used_prefixes: HashSet<[u8; NONCE_PREFIX_LEN]>, +} + +/// Read `asset_id`'s persisted derivative bundle, if it has one. +/// +/// A bundle that does not decode yields the empty answer *and a warning*: treating an +/// unreadable bundle as "no constraints" is the safe direction for the chain (a role restarts) +/// but the unsafe one for prefixes, so the warning says which risk is being taken. +pub(super) fn existing_derivatives(dir: &Path, asset_id: Uuid) -> ExistingDerivatives { + let empty = ExistingDerivatives { + heads: HashMap::new(), + used_prefixes: HashSet::new(), + }; + let path = dir.join(format!("{}.derivatives.cbor", asset_id.simple())); + let Ok(bytes) = fs::read(&path) else { + return empty; + }; + let Ok(manifests) = cbor::from_slice::>(&bytes) else { + tracing::warn!( + path = %path.display(), + "derivatives: undecodable bundle; every role's chain restarts and no previously used \ + nonce prefix can be excluded from the next draw" + ); + return empty; + }; + + // Generation order is the chain order, so the last manifest of a role is that role's head. + let mut heads = HashMap::new(); + let mut used_prefixes = HashSet::new(); + for manifest in &manifests { + used_prefixes.insert(manifest.core.nonce_prefix); + match cbor::to_canonical_vec(manifest) { + Ok(canonical) => { + heads.insert(manifest.core.role, hash::hash_bytes(&canonical)); + } + Err(error) => tracing::warn!( + %error, + "derivatives: a persisted manifest did not re-serialise; its role's chain \ + restarts rather than linking to something unverifiable" + ), + } + } + ExistingDerivatives { + heads, + used_prefixes, + } +} + +/// The album-key half of derivative generation: `media` produces the bytes, this encrypts them. +/// +/// One `encrypt_asset_rekey_with_prefix` per derivative under the **source asset's** `file_id` +/// and the album's current AMK, so every derivative gets its own file key per the encryption +/// doc's per-file derivation. The ciphertext is deliberately dropped: the client keeps the +/// plaintext derivative on disk (the local gallery paints it) and re-derives the ciphertext at +/// push time from the recorded prefix, exactly as it already does for the original. +/// +/// # The reuse refusal +/// +/// A CSPRNG draw is not on its own what the design asks for. The encryption doc requires that +/// the writer *refuse* a `nonce_prefix` it has already used for that `file_id`, "defense in +/// depth on top of the CSPRNG draw", and says the same rule governs derivative re-encryption. +/// So [`used`](Self::used) starts as the original's prefix plus every prefix in the existing +/// bundle, each newly sealed prefix joins it, and a collision is redrawn. +/// +/// A prefix is folded into the file-key salt, so a reused one reuses the **key** as well as the +/// nonce — two blobs under one keystream, which is the failure the whole construction exists to +/// prevent. +struct AlbumSealer<'a> { + amk: &'a Amk, + asset_id: Uuid, + /// Prefixes already spoken for on this `file_id`. `RefCell` because [`DerivativeSealer`] + /// takes `&self` — the seam is shared, and each seal has to see what the last one used. + used: RefCell>, + /// Where a candidate prefix comes from: the OS CSPRNG in production, forced in the test + /// that proves the refusal fires. + draw: &'a dyn Fn() -> [u8; NONCE_PREFIX_LEN], +} + +/// How many times a collision is redrawn before the draw itself is called broken. +/// +/// A 7-byte prefix collides by chance at about 1 in 2^56, so a run of eight is not bad luck — +/// it is a CSPRNG returning something it should not, which is a **workspace** fault and not +/// this asset's. Hence `MediaError::Sign`, which decision 22 routes to a propagated error +/// rather than to a missing thumbnail: an import that cannot draw a safe nonce must stop, not +/// quietly write one derivative fewer. +const MAX_PREFIX_DRAWS: usize = 8; + +/// Test-only fault injection for the sealer. +/// +/// The two failure paths decision 22 and decision 23 turn on — a codec refusing a frame the +/// decoder accepted, and a codec *panicking* on one — cannot be produced from real bytes on +/// demand, and testing them anywhere but through `import_asset_with` proves nothing about the +/// property that matters: that **the asset still commits**. So the fault is injected at the one +/// point inside the real import path where a codec failure originates. +/// +/// `#[cfg(test)]`, so it does not exist in a release build at all — not a disabled branch, not a +/// dead field, absent. A thread-local rather than a parameter because threading an `Option<&dyn +/// DerivativeSealer>` through `prepare_still` would put a test seam in a production signature; +/// nextest runs each test in its own process, so there is nothing for it to leak into. +#[cfg(test)] +#[derive(Clone, Copy, Debug)] +pub(super) enum SealerFault { + /// A codec refuses the frame — an `Encode`-class error, which decision 22 degrades. + Refuse, + /// A pre-1.0 codec panics — which decision 23's guard has to catch. + Panic, +} + +#[cfg(test)] +thread_local! { + static SEALER_FAULT: RefCell> = const { RefCell::new(None) }; +} + +/// Run `body` with `fault` injected into every seal, restoring the previous state after. +#[cfg(test)] +pub(super) fn with_sealer_fault(fault: SealerFault, body: impl FnOnce() -> T) -> T { + SEALER_FAULT.with(|slot| *slot.borrow_mut() = Some(fault)); + let out = body(); + SEALER_FAULT.with(|slot| *slot.borrow_mut() = None); + out +} + +impl DerivativeSealer for AlbumSealer<'_> { + fn seal(&self, plaintext: &[u8]) -> std::result::Result { + #[cfg(test)] + if let Some(fault) = SEALER_FAULT.with(|slot| *slot.borrow()) { + match fault { + // Deliberately **not** `Sign`: this stands in for a codec refusing pixels, which + // decision 22 degrades to `DecodeFailed` rather than propagating. + SealerFault::Refuse => { + return Err(MediaError::Encode { + format: crate::media::DerivativeFormat::Jxl, + detail: "injected codec refusal".into(), + }); + } + SealerFault::Panic => panic!("injected codec panic on an accepted frame"), + } + } + + for attempt in 0..MAX_PREFIX_DRAWS { + let prefix = (self.draw)(); + if self.used.borrow().contains(&prefix) { + tracing::warn!( + asset_id = %self.asset_id, + attempt, + "derivatives: drew a nonce prefix already used for this file_id; redrawing" + ); + continue; + } + let (enc, _ciphertext, _file_key) = + encrypt_asset_rekey_with_prefix(self.amk, &self.asset_id, plaintext, prefix, None) + .map_err(|e| MediaError::Sign { + detail: format!("sealing the derivative: {e}"), + })?; + self.used.borrow_mut().insert(enc.nonce_prefix); + return Ok(SealedDerivative { + ciphertext_hash: enc.ciphertext_hash, + nonce_prefix: enc.nonce_prefix, + }); + } + Err(MediaError::Sign { + detail: format!( + "could not draw an unused nonce prefix for {} in {MAX_PREFIX_DRAWS} attempts", + self.asset_id + ), + }) + } +} + +impl Workspace { + /// Decode the still once and derive: the `content_type`, pixel `dimensions`, the sidecar + /// `lqip`, and the signed thumbnail derivatives. All are attached before the sidecar is + /// sealed, per the pipeline's Execute step. + /// + /// **Never fails over unreadable pixels.** A still this build cannot decode still commits as + /// a signed, encrypted original — it falls back to EXIF dimensions with no LQIP and no + /// derivatives, and the returned [`DerivativeStatus`] says which reason applied so the + /// caller can report the gap instead of it being invisible. + #[tracing::instrument( + level = "debug", + skip_all, + fields(asset_id = %asset_id, src = %source.src.display(), bytes = source.plaintext.len()) + )] + pub(super) fn prepare_still( + &self, + source: &StillSource<'_>, + asset_id: Uuid, + album_id: Uuid, + capture_utc: i64, + amk: &Amk, + original: &AssetEncryption, + ) -> Result { + let StillSource { + plaintext, + ext, + src, + exif, + } = *source; + let exif_dimensions = exif + .width + .zip(exif.height) + .map(|(width, height)| Dimensions { width, height }); + // Detected here as well as inside the decoder so the sidecar's `content_type` is + // header-derived even for a format with no codec: a HEIC is `image/heic` in the sidecar + // whether or not this build can read its pixels. + let sniffed = StillFormat::detect(plaintext, ext); + + let decoded = match decode_guarded(&RawshiftDecoder, plaintext, ext) { + Ok(decoded) => decoded, + Err(error) => { + let status = classify(&error, src, sniffed); + return Ok(PreparedStill::undecoded(sniffed, exif_dimensions, status)); + } + }; + + let dimensions = Some(Dimensions { + width: decoded.width(), + height: decoded.height(), + }); + if let (Some(pixels), Some(exif_dims)) = (dimensions.as_ref(), exif_dimensions.as_ref()) + && pixels != exif_dims + { + // Not an error: EXIF dimensions are pre-orientation and are frequently stale after + // an edit. Logged because a surprising sidecar dimension is otherwise unexplainable + // after the fact. + tracing::debug!( + asset_id = %asset_id, + pixel_width = pixels.width, + pixel_height = pixels.height, + exif_width = exif_dims.width, + exif_height = exif_dims.height, + orientation = decoded.orientation_applied, + "derivatives: decoded dimensions differ from EXIF; the pixels are authoritative" + ); + } + let lqip = lqip_from(&decoded, src); + + let album = self.album(&album_id)?; + let ExistingDerivatives { + heads, + mut used_prefixes, + } = existing_derivatives( + &media_dir(&self.root, capture_utc).join("derivatives"), + asset_id, + ); + // The original's prefix is spoken for too: it is a prefix used for this `file_id`. + used_prefixes.insert(original.nonce_prefix); + + let ctx = DerivativeContext { + source_asset_id: asset_id, + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + amk_version: AmkVersion(album.current_epoch), + generated_by_device: self.account.device.device_id, + generated_by_client: self.client_version.clone(), + generated_at: super::now_rfc3339(), + device_signer: self.device_signer.as_ref(), + write_tier_signer: album.write_tier_signer()?, + sealer: &AlbumSealer { + amk, + asset_id, + // Seeded with the original's own prefix and every prefix the existing bundle + // already spent on this `file_id`. + used: RefCell::new(used_prefixes), + draw: &rng::random_array::, + }, + // Empty on a create; a regeneration continues each role's chain from here. + prior_heads: &heads, + // The `original` sentinel references the original blob rather than encrypting + // anything, so it signs what the original's own manifest signs. + original: SealedDerivative { + ciphertext_hash: original.ciphertext_hash, + nonce_prefix: original.nonce_prefix, + }, + }; + // Guarded, and **not** propagated on failure. A codec refusing a frame the decoder just + // produced is a real defect, but it is this asset's derivative that is broken, not the + // workspace: the signed original, its dimensions and its placeholder are all still + // right, and failing the import would trade a missing thumbnail for a missing backup. + // Reported as `DecodeFailed` — the "a supported path produced no derivative and somebody + // should look at it" bucket — so the run summary counts it instead of staying silent. + let generated = guarded("derivatives", || { + generate_still_derivatives(&decoded, &DerivativeTier::GENERATED, &ctx) + }); + let derivatives = match generated { + Ok(derivatives) => derivatives, + // **A signing fault is the workspace's, not this asset's.** A hardware signer that + // refuses, or a missing epoch write-tier key, is the same fault that would stop the + // asset's own manifest being authored — degrading it to "no thumbnail" would hide a + // broken workspace behind a cosmetic gap. It propagates. + Err(error @ MediaError::Sign { .. }) => { + tracing::error!( + asset_id = %asset_id, + path = %src.display(), + %error, + "derivatives: the workspace could not author a signed derivative record" + ); + return Err(LifecycleError::Io(format!("derivative signing: {error}"))); + } + // Everything else is about *pixels*: a codec refused a frame, a resize was rejected, + // a third-party encoder panicked. The signed original, its dimensions and its + // placeholder are all still right, and failing the import would trade a missing + // thumbnail for a missing backup — which is the whole of `S-B13`'s reasoning and + // this module's stated contract. Reported as `DecodeFailed`, the "a supported path + // produced no derivative and somebody should look at it" bucket, so the run summary + // counts it instead of staying silent. + Err(error) => { + tracing::warn!( + asset_id = %asset_id, + path = %src.display(), + format = %decoded.format, + width = decoded.width(), + height = decoded.height(), + %error, + "derivatives: the still decoded but no derivative could be produced from it; \ + the original, its dimensions and its placeholder are committed regardless" + ); + return Ok(PreparedStill { + format: Some(decoded.format), + dimensions, + lqip, + derivatives: Vec::new(), + deferred_formats: 0, + status: DerivativeStatus::DecodeFailed, + }); + } + }; + + Ok(PreparedStill { + format: Some(decoded.format), + dimensions, + lqip, + deferred_formats: derivatives.deferred.len(), + derivatives: derivatives.generated, + status: DerivativeStatus::Decoded, + }) + } + + /// Write the generated derivative bytes plus their signed manifest bundle under the asset's + /// media directory: `derivatives/{uuid}.{role}.{ext}` and `{uuid}.derivatives.cbor`. + /// + /// The layout is the one the upload bundle reader already looks for: since `F5` it composes + /// a derivative's exact path from the manifest's `(role, format)` pair rather than scanning + /// for a `{uuid}.{role}.` prefix, so two formats of one role cannot be mistaken for each + /// other. + /// + /// Called **after** the asset's own files are durable: a derivative is regenerable and must + /// never be able to fail an import that has already committed. A write error is therefore + /// logged and swallowed rather than propagated. + pub(super) fn persist_derivatives( + &self, + asset: &AssetState, + derivatives: &[GeneratedDerivative], + ) { + if derivatives.is_empty() { + return; + } + let dir = media_dir(&self.root, asset.capture_utc).join("derivatives"); + if let Err(error) = fs::create_dir_all(&dir) { + tracing::warn!( + asset_id = %asset.asset_id, + dir = %dir.display(), + %error, + "derivatives: could not create the derivative directory; the asset is committed \ + and its derivatives are regenerable" + ); + return; + } + let stem = asset.asset_id.simple(); + + let mut manifests = Vec::with_capacity(derivatives.len()); + for derivative in derivatives { + // The `original` sentinel has no bytes of its own: its manifest *references* the + // original, whose content address it signs. Writing a byte-for-byte copy under a + // thumbnail's name would duplicate a file two directories up and re-expose the + // original's EXIF — GPS included — as a derivative, where a re-encoded thumbnail is + // metadata-free by construction. Its manifest still goes into the bundle: that + // signed marker is the difference between "the original *is* the thumbnail" and + // "the thumbnail is missing, rebuild it". + if let Some(format_ext) = derivative.format.extension() { + let path = dir.join(format!( + "{stem}.{}.{format_ext}", + derivative.tier.role_name() + )); + if let Err(error) = fs::write(&path, &derivative.bytes) { + tracing::warn!( + asset_id = %asset.asset_id, + path = %path.display(), + %error, + "derivatives: could not write a derivative; skipping it" + ); + continue; + } + } else { + debug_assert!( + derivative.bytes.is_empty(), + "only the byte-free `original` sentinel has no extension" + ); + } + manifests.push(derivative.manifest.clone()); + } + + if manifests.is_empty() { + return; + } + match cbor::to_canonical_vec(&manifests) { + Ok(bundle) => { + let path = dir.join(format!("{stem}.derivatives.cbor")); + if let Err(error) = fs::write(&path, bundle) { + tracing::warn!( + asset_id = %asset.asset_id, + path = %path.display(), + %error, + "derivatives: could not write the manifest bundle; the bytes on disk are \ + unusable without it and will be regenerated" + ); + return; + } + tracing::debug!( + asset_id = %asset.asset_id, + count = manifests.len(), + dir = %dir.display(), + "derivatives: persisted with their signed manifest bundle" + ); + } + Err(error) => tracing::warn!( + asset_id = %asset.asset_id, + %error, + "derivatives: the manifest bundle did not serialise; skipping persistence" + ), + } + } +} + +#[cfg(test)] +mod tests { + use rawshift_image::core::metadata::{ImageInfo, ImageMetadata}; + use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; + use rawshift_image::formats::encode_rgb_image_to_vec; + use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, ZunePngEncodeConfig, + }; + use tempfile::TempDir; + + use super::super::{DerivativeStatus, SignedImportOptions, Workspace, fast_workspace}; + use super::*; + use crate::crypto::hash; + use crate::crypto::provenance::DerivativeManifest; + use crate::media::{Decoder as _, DerivativeFormat, verify_still_format}; + use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; + + /// A deterministic RGB gradient, `width` x `height`, as interleaved RGB `u16`. + fn frame(width: u32, height: u32) -> rawshift_image::core::image::RgbImage { + let (w, h) = (width as usize, height as usize); + let mut data = Vec::with_capacity(w * h * 3); + for y in 0..h { + for x in 0..w { + data.push(((x * 255 / w) as u16) * 257); + data.push(((y * 255 / h) as u16) * 257); + data.push((((x + y) * 255 / (w + h)) as u16) * 257); + } + } + rawshift_image::core::image::RgbImage::with_color_space( + width, + height, + data, + rawshift_image::core::ColorSpace::Srgb, + ) + } + + fn common(metadata: MetadataEmbedOptions) -> CommonEncodeOptions { + CommonEncodeOptions { + metadata, + bit_depth: BitDepth::Eight, + } + } + + /// A JPEG carrying an EXIF orientation tag, so the decode path has a transform to apply and + /// the sidecar's dimensions have to disagree with the stored ones. + fn jpeg(width: u32, height: u32, orientation: Option) -> Vec { + let metadata = ImageMetadata { + image: ImageInfo { + orientation, + ..ImageInfo::default() + }, + ..ImageMetadata::default() + }; + let embed = MetadataEmbedOptions { + embed_exif: orientation.is_some(), + embed_icc: false, + embed_xmp: false, + }; + encode_rgb_image_to_vec( + &frame(width, height), + &metadata, + &EncodeOptions::JpegJpegEnc(JpegEncEncodeConfig { + common: common(embed), + quality: 90, + }), + ) + .expect("the fixture JPEG encodes") + } + + fn png(width: u32, height: u32) -> Vec { + encode_rgb_image_to_vec( + &frame(width, height), + &ImageMetadata::default(), + &EncodeOptions::PngZune(ZunePngEncodeConfig { + common: common(MetadataEmbedOptions::none()), + ..ZunePngEncodeConfig::default() + }), + ) + .expect("the fixture PNG encodes") + } + + /// A workspace with fast Argon2 params and its default album created. + fn workspace(dir: &Path) -> (Workspace, Uuid) { + let mut ws = fast_workspace(dir); + let album = ws.default_album_id(); + ws.create_album_with_id(album, "Imports").unwrap(); + (ws, album) + } + + /// Write `bytes` into `dir` under `name` and import it, returning the receipt. + fn import( + ws: &mut Workspace, + album: Uuid, + dir: &Path, + name: &str, + bytes: &[u8], + ) -> super::super::SignedImport { + let path = dir.join(name); + fs::write(&path, bytes).unwrap(); + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("the import commits") + } + + /// Read back the signed sidecar an import wrote, from the library directory alone. + fn sidecar_of(root: &Path, asset_id: Uuid) -> SidecarV1 { + let mut stack = vec![root.join("media")]; + let name = format!("{}.cbor", asset_id.simple()); + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if entry.file_name() == std::ffi::OsString::from(&name) { + let bytes = fs::read(&path).unwrap(); + return SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1) + .expect("the sidecar decodes"); + } + } + } + panic!("no sidecar for {asset_id}"); + } + + /// The derivatives directory for the bucket holding `asset_id`'s files. + fn derivatives_dir(root: &Path, asset_id: Uuid) -> std::path::PathBuf { + let mut stack = vec![root.join("media")]; + let name = format!("{}.cbor", asset_id.simple()); + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir).unwrap().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if entry.file_name() == std::ffi::OsString::from(&name) { + return dir.join("derivatives"); + } + } + } + panic!("no bucket for {asset_id}"); + } + + /// **The `S-B14` acceptance case, and the first production caller of `capsule_core::lqip`.** + /// + /// A decodable still imports with real pixel dimensions and a 32-byte chromahash placeholder + /// inside the *signed* sidecar, and the sidecar still verifies — the placeholder is + /// signature-covered, so producing it is a signature-visible change and has to be checked as + /// one. + #[test] + fn a_decodable_still_imports_with_pixel_dimensions_and_an_lqip() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import( + &mut ws, + album, + src.path(), + "photo.jpg", + &jpeg(320, 240, None), + ); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + assert_eq!( + receipt.deferred_formats, 2, + "the JXL master and the AVIF delivery variant have no encoder in this build" + ); + + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar + .dimensions + .as_ref() + .expect("dimensions from decoded pixels"); + assert_eq!((dimensions.width, dimensions.height), (320, 240)); + assert_eq!( + sidecar.content_type, "image/jpeg", + "the content type is header-derived" + ); + + let lqip = sidecar.lqip.as_ref().expect("the LQIP producer ran"); + assert_eq!(lqip.chromahash.len(), 32, "DEFAULT_TIER is 32 bytes"); + assert_eq!(lqip.format_version, crate::lqip::LQIP_FORMAT_V1); + assert!( + Lqip::from_bytes(&lqip.chromahash).is_ok(), + "the stored payload is a structurally valid chromahash" + ); + + assert!( + sidecar.verify(&ws.user_ik_public()), + "the sidecar signature covers the placeholder it now carries" + ); + } + + /// The sidecar's dimensions are the **upright** ones. A quarter-turned JPEG's stored width + /// is its EXIF `PixelXDimension`, transposed relative to what a viewer shows, so taking the + /// decoded pixels rather than the tag is the whole point. + #[test] + fn a_rotated_still_records_upright_dimensions() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import( + &mut ws, + album, + src.path(), + "portrait.jpg", + &jpeg(320, 240, Some(6)), + ); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar.dimensions.as_ref().expect("dimensions"); + assert_eq!( + (dimensions.width, dimensions.height), + (240, 320), + "orientation 6 is a quarter-turn, so the sidecar records the transposed pair" + ); + } + + /// Thumbnail bytes and a signed manifest bundle land on disk, at the layout the upload + /// bundle reader already looks for, and the bundle re-verifies against the bytes beside it. + #[test] + fn an_import_persists_thumbnail_bytes_and_a_verifying_manifest_bundle() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let receipt = import(&mut ws, album, src.path(), "big.png", &png(512, 384)); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + + let dir = derivatives_dir(lib.path(), receipt.asset_id); + let stem = receipt.asset_id.simple().to_string(); + let thumb = dir.join(format!("{stem}.thumbnail.jxl")); + let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); + assert!(thumb.is_file(), "thumbnail bytes at {}", thumb.display()); + assert!(bundle_path.is_file(), "a manifest bundle beside them"); + + let bytes = fs::read(&thumb).unwrap(); + let manifests: Vec = + cbor::from_slice(&fs::read(&bundle_path).unwrap()).expect("the bundle decodes"); + assert_eq!(manifests.len(), 1); + let core = &manifests[0].core; + assert_ne!( + core.ciphertext_hash, + hash::hash_bytes(&bytes), + "the manifest addresses the ciphertext, never the plaintext on disk" + ); + assert_ne!( + core.nonce_prefix, [0u8; 7], + "and it records the prefix that ciphertext was produced under" + ); + assert_eq!(core.source_asset_id, receipt.asset_id); + assert_eq!( + verify_still_format(&manifests[0]), + Ok(Some(DerivativeFormat::Jxl)), + "the persisted format is inside the closed set" + ); + + // The bytes really are a 256 px JXL. + let decoded = crate::media::RawshiftDecoder + .decode(&bytes, "jxl") + .expect("the persisted thumbnail decodes"); + assert_eq!((decoded.width(), decoded.height()), (256, 192)); + } + + /// A still already inside the tier cap gets the signed `original` sentinel: a manifest that + /// says `original` and content-addresses the source, and **no derivative bytes on disk**. + /// + /// The absent bytes are the point, and they are what "the tier *references* the original" + /// means. Writing a copy would put the original — EXIF and GPS intact — in + /// `derivatives/{uuid}.thumbnail.{ext}` and therefore into the derivative blob of the upload + /// bundle, which is the one place a re-encoded thumbnail is metadata-free by construction. + /// The signed marker is still there, so this stays distinct from an absent derivative, which + /// means "rebuild me". + #[test] + fn a_small_still_persists_the_original_sentinel_without_copying_it() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + let original = png(128, 96); + let receipt = import(&mut ws, album, src.path(), "small.png", &original); + assert_eq!(receipt.derivatives, DerivativeStatus::Decoded); + assert_eq!( + receipt.deferred_formats, 0, + "the sentinel satisfies the tier, so nothing was deferred" + ); + + let dir = derivatives_dir(lib.path(), receipt.asset_id); + let stem = receipt.asset_id.simple().to_string(); + + let files: Vec = fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + files, + vec![format!("{stem}.derivatives.cbor")], + "the sentinel writes its manifest and no derivative bytes" + ); + + let manifests: Vec = + cbor::from_slice(&fs::read(dir.join(format!("{stem}.derivatives.cbor"))).unwrap()) + .expect("the bundle decodes"); + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].core.format, "original"); + // The sentinel references the original *blob*: it signs the original manifest's own + // ciphertext address and nonce prefix, not the plaintext's. + let state = ws.asset(&receipt.asset_id).expect("the asset is held"); + let original_core = &state + .chain + .records() + .last() + .expect("a create record") + .manifest + .core; + assert_eq!( + manifests[0].core.ciphertext_hash, original_core.ciphertext_hash, + "the sentinel points at the blob a receiver already holds" + ); + assert_eq!( + manifests[0].core.nonce_prefix, original_core.nonce_prefix, + "under the same key the original was encrypted with" + ); + assert_ne!( + manifests[0].core.ciphertext_hash, + hash::hash_bytes(&original), + "which is not the plaintext's address" + ); + assert_eq!( + verify_still_format(&manifests[0]), + Ok(Some(DerivativeFormat::Original)), + "the sentinel is inside the closed set" + ); + } + + /// **Decision 22's degradation path, through the real import.** A codec that refuses a frame + /// the decoder accepted costs this asset its thumbnail and **nothing else**: the original + /// commits, signed and self-verifying, with real pixel dimensions and a real placeholder, + /// and the run reports `DecodeFailed` so somebody can look at it. + /// + /// Reverting `prepare_still`'s match arm to a bare `?` must fail this test — that is what it + /// is for. Before the review round the code did exactly that, and because the failure + /// happened *before* `write_asset_files`, an encoder refusal lost the original from the + /// backup outright. + #[test] + fn a_codec_refusal_costs_the_thumbnail_and_not_the_backup() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + let path = src.path().join("photo.png"); + fs::write(&path, png(512, 384)).unwrap(); + + let receipt = super::with_sealer_fault(super::SealerFault::Refuse, || { + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("a codec refusal must never fail the import") + }); + + assert_eq!( + receipt.derivatives, + DerivativeStatus::DecodeFailed, + "reported as a real problem rather than an expected gap" + ); + assert_eq!(receipt.deferred_formats, 0); + + // The half that must not be lost: a signed, encrypted, self-verifying original. + assert_eq!( + ws.verify(&receipt.asset_id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept + ); + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + let dimensions = sidecar.dimensions.as_ref().expect("real pixel dimensions"); + assert_eq!((dimensions.width, dimensions.height), (512, 384)); + assert_eq!( + sidecar.lqip.as_ref().map(|l| l.chromahash.len()), + Some(32), + "the placeholder came from the decode, which succeeded" + ); + assert!( + !derivatives_dir(lib.path(), receipt.asset_id).exists(), + "and no derivative was written" + ); + } + + /// **Decision 23's guard, through the real import.** A codec that *panics* on a frame the + /// decoder accepted is caught, and the import still commits. + /// + /// Driven through `import_asset_with` rather than by calling `guarded` directly: a test that + /// calls the guard itself stays green even if every production call site is deleted, which + /// is precisely the hole this replaces. Deleting the guard around generation makes this test + /// abort the process rather than fail — which is the failure mode it exists to prevent. + #[test] + fn a_codec_panic_is_caught_and_the_import_still_commits() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + let path = src.path().join("photo.png"); + fs::write(&path, png(512, 384)).unwrap(); + + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let receipt = super::with_sealer_fault(super::SealerFault::Panic, || { + ws.import_asset_with(album, &path, &SignedImportOptions::default()) + .expect("a codec panic must never fail the import") + }); + std::panic::set_hook(previous); + + assert_eq!(receipt.derivatives, DerivativeStatus::DecodeFailed); + assert_eq!( + ws.verify(&receipt.asset_id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept, + "one panicking photo does not cost the asset, let alone the rest of the run" + ); + let sidecar = sidecar_of(lib.path(), receipt.asset_id); + assert!(sidecar.lqip.is_some(), "the placeholder still landed"); + } + + /// A format with no codec here, and bytes that are no still at all: both import as signed, + /// verifiable originals, with EXIF-or-nothing dimensions, no placeholder, no derivative + /// files, and the reason recorded (slice `S-B13`). + #[test] + fn an_undecodable_original_still_imports_with_the_reason_recorded() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, album) = workspace(lib.path()); + + // A real HEIC header (ISO-BMFF `ftyp heic`) with no payload: recognised, no codec. + let mut heic = vec![0, 0, 0, 0x20]; + heic.extend_from_slice(b"ftypheic"); + heic.extend_from_slice(&[0; 16]); + let deferred = import(&mut ws, album, src.path(), "shot.heic", &heic); + assert_eq!(deferred.derivatives, DerivativeStatus::DeferredNoCodec); + assert_eq!(deferred.deferred_formats, 0, "nothing was attempted"); + + let sidecar = sidecar_of(lib.path(), deferred.asset_id); + assert!(sidecar.lqip.is_none(), "no pixels, no placeholder"); + assert!( + sidecar.dimensions.is_none(), + "no pixels and no EXIF dimensions" + ); + assert_eq!( + sidecar.content_type, "image/heic", + "the header still names the format, codec or not" + ); + assert!( + !derivatives_dir(lib.path(), deferred.asset_id).exists(), + "no derivative directory is created for an asset with no derivatives" + ); + + // Not a still at all. + let video = import( + &mut ws, + album, + src.path(), + "clip.mp4", + b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", + ); + assert_eq!(video.derivatives, DerivativeStatus::NotAKnownStill); + assert_eq!( + sidecar_of(lib.path(), video.asset_id).content_type, + "video/mp4", + "the extension table still types a video" + ); + + // Both are signed, encrypted, self-verifying backups regardless. + for id in [deferred.asset_id, video.asset_id] { + assert_eq!( + ws.verify(&id).unwrap(), + crate::crypto::verify_asset::VerifyOutcome::Accept + ); + } + } +} + +// ── The nonce-prefix reuse refusal (encryption.md, "Re-keying on Rewrite") ─── + +#[cfg(test)] +mod sealer_tests { + use super::*; + + /// A draw that hands back a fixed sequence, so a collision can be *forced* rather than + /// waited for — a real 7-byte collision is a 1-in-2^56 event. + struct ScriptedDraw { + prefixes: RefCell>, + } + + impl ScriptedDraw { + fn next(&self) -> [u8; NONCE_PREFIX_LEN] { + let mut queue = self.prefixes.borrow_mut(); + if queue.len() == 1 { + queue[0] + } else { + queue.remove(0) + } + } + } + + fn sealer<'a>( + amk: &'a Amk, + used: HashSet<[u8; NONCE_PREFIX_LEN]>, + draw: &'a dyn Fn() -> [u8; NONCE_PREFIX_LEN], + ) -> AlbumSealer<'a> { + AlbumSealer { + amk, + asset_id: Uuid::from_u128(0xDEF), + used: RefCell::new(used), + draw, + } + } + + /// **The normative refusal.** A draw that keeps returning a prefix already used for this + /// `file_id` is refused rather than accepted, and the refusal is a `Sign`-class fault so + /// decision 22 propagates it instead of silently writing one derivative fewer. + /// + /// A prefix is folded into the file-key salt, so reusing one reuses the **key**: two blobs + /// under one keystream, which is exactly what the encryption doc's "defense in depth on top + /// of the CSPRNG draw" exists to prevent. + #[test] + fn a_prefix_already_used_for_this_file_id_is_refused() { + let amk = Amk::from_bytes([0x11; 32]); + let original = [1, 2, 3, 4, 5, 6, 7]; + let mut used = HashSet::new(); + used.insert(original); + + // The RNG is forced to keep offering the original's prefix. + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![original]), + }; + let draw = || scripted.next(); + let error = sealer(&amk, used, &draw) + .seal(b"derivative plaintext") + .expect_err("a reused prefix is refused"); + assert!( + matches!(error, MediaError::Sign { .. }), + "an exhausted draw is a workspace fault, not a missing thumbnail: {error:?}" + ); + } + + /// A collision is **redrawn**, not fatal: the first candidate is taken, the second is used. + #[test] + fn a_collision_is_redrawn_and_the_next_candidate_is_accepted() { + let amk = Amk::from_bytes([0x22; 32]); + let taken = [9, 9, 9, 9, 9, 9, 9]; + let fresh = [8, 7, 6, 5, 4, 3, 2]; + let mut used = HashSet::new(); + used.insert(taken); + + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![taken, fresh]), + }; + let draw = || scripted.next(); + let sealed = sealer(&amk, used, &draw) + .seal(b"derivative plaintext") + .expect("the redraw succeeds"); + assert_eq!( + sealed.nonce_prefix, fresh, + "the colliding candidate is skipped and the next one is used" + ); + } + + /// Each sealed prefix **joins** the set, so two derivatives of one asset cannot collide with + /// each other either — not only with what was already on disk. + #[test] + fn a_freshly_sealed_prefix_is_spoken_for_by_the_next_seal() { + let amk = Amk::from_bytes([0x33; 32]); + let first = [1, 1, 1, 1, 1, 1, 1]; + let second = [2, 2, 2, 2, 2, 2, 2]; + + // The draw offers `first`, then `first` again (a collision with what was just sealed), + // then `second`. + let scripted = ScriptedDraw { + prefixes: RefCell::new(vec![first, first, second]), + }; + let draw = || scripted.next(); + let sealer = sealer(&amk, HashSet::new(), &draw); + + assert_eq!(sealer.seal(b"one").expect("first seal").nonce_prefix, first); + assert_eq!( + sealer.seal(b"two").expect("second seal").nonce_prefix, + second, + "the prefix the first seal used is refused for the second" + ); + } + + /// The bundle reader hands the sealer every prefix already spent on this `file_id`. + #[test] + fn existing_derivatives_reports_every_persisted_prefix() { + use crate::crypto::keys::HybridSigningKey; + use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; + + let dir = tempfile::tempdir().expect("scratch"); + let asset_id = Uuid::from_u128(0xFEED); + let device = HybridSigningKey::from_seed_bytes(&[31; 32], &[32; 32]); + let write = HybridSigningKey::from_seed_bytes(&[33; 32], &[34; 32]); + + let manifest = |role, prefix: [u8; NONCE_PREFIX_LEN]| { + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(1)), + source_asset_id: asset_id, + role, + format: "image/jxl".into(), + ciphertext_hash: hash::hash_bytes(b"bytes"), + nonce_prefix: prefix, + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-02T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write) + .expect("signing") + }; + let manifests = vec![ + manifest(DerivativeRole::Thumbnail, [1, 1, 1, 1, 1, 1, 1]), + manifest(DerivativeRole::Preview, [2, 2, 2, 2, 2, 2, 2]), + ]; + fs::write( + dir.path() + .join(format!("{}.derivatives.cbor", asset_id.simple())), + cbor::to_canonical_vec(&manifests).unwrap(), + ) + .unwrap(); + + let existing = existing_derivatives(dir.path(), asset_id); + assert!(existing.used_prefixes.contains(&[1, 1, 1, 1, 1, 1, 1])); + assert!(existing.used_prefixes.contains(&[2, 2, 2, 2, 2, 2, 2])); + assert_eq!(existing.used_prefixes.len(), 2); + assert_eq!(existing.heads.len(), 2, "and both roles have a chain head"); + } +} diff --git a/capsule-core/src/lifecycle/import.rs b/capsule-core/src/lifecycle/import.rs index dc1105be..18f6fbd0 100644 --- a/capsule-core/src/lifecycle/import.rs +++ b/capsule-core/src/lifecycle/import.rs @@ -5,9 +5,11 @@ use std::collections::BTreeMap; use std::fs; use std::path::Path; +use ciborium::value::Value; use jiff::Timestamp; use uuid::Uuid; +use super::derivatives::{PreparedStill, StillSource}; use super::{ AssetState, LifecycleError, Result, SidecarEnrichment, SignedImport, SignedImportOptions, StackPlacement, StreamedImport, Workspace, asset_is_deleted, media_dir, now_rfc3339, @@ -28,8 +30,27 @@ use crate::exif::extract::extract_exif; use crate::exif::timezone::resolve_timezone; use crate::metadata::crdt::{Lww, OrSet}; use crate::sidecar::sidecar_v1::{ - Dimensions, Gps, GpsSource, SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole, + Gps, GpsSource, SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole, }; +use crate::utils::paths::tmp_path; + +// A fault injected between the signed sidecar's `.tmp` write and its rename into place — the +// crash the single-file atomic rename exists to survive (maintenance doc, Atomic Writes). +// `#[cfg(test)]` and thread-local for the same reasons as `derivatives::SealerFault`: absent +// from a release build, and a test seam that stays out of a production signature. +#[cfg(test)] +thread_local! { + static SIDECAR_RENAME_FAULT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Run `body` with the sidecar rename failing after the `.tmp` write, restoring after. +#[cfg(test)] +pub(super) fn with_sidecar_rename_fault(body: impl FnOnce() -> T) -> T { + SIDECAR_RENAME_FAULT.with(|slot| slot.set(true)); + let out = body(); + SIDECAR_RENAME_FAULT.with(|slot| slot.set(false)); + out +} /// Render a Unix-second capture time as the sidecar's RFC 3339 `capture_timestamp`. fn capture_rfc3339(secs: i64) -> String { @@ -97,13 +118,19 @@ fn folded_gps(embedded: Option, folded: Option<&Gps>) -> Option { embedded.or_else(|| folded.cloned()) } +/// The sidecar `content_type` for a file whose bytes named no still image Capsule models. +/// +/// The **fallback only**: [`Workspace::prepare_still`] sniffs the header first, so a still's +/// media type comes from [`StillFormat::mime`](crate::media::StillFormat::mime) and a `.jpg` +/// that is really a HEIC is typed `image/heic`. What is left for this table is the non-still +/// suffixes — video above all, which has no detection path until slice `S-B5`. fn content_type_for(ext: &str) -> String { match ext { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", + "mp4" | "m4v" => "video/mp4", + "mov" | "qt" => "video/quicktime", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "avi" => "video/x-msvideo", _ => "application/octet-stream", } .to_string() @@ -157,11 +184,33 @@ fn asset_row_from_state(asset: &AssetState) -> AssetRow { .as_ref() .map_or((None, false), |s| (Some(s.stack_id.clone()), s.hidden)), }; + // Capture time is projected from the **signed sidecar**, not from `AssetState::capture_utc` + // — exactly as `library::rebuild::signed_asset_row` projects it, so the write path and a + // rebuild agree. The two values are equal at import; they part ways after a capture-time + // correction (`Workspace::set_capture_timestamp`, `S-B17`), which by design re-signs the + // sidecar and leaves the `media/{YYYY}/{YYYY-MM}` shard — and therefore `capture_utc`, + // which names it — where the files already are. Reading the shard here would keep the + // timeline on the wrong date until the next rebuild while the rebuild read the right one. + let capture_utc = asset + .sidecar + .capture_timestamp + .parse::() + .map_or_else( + |_| { + tracing::warn!( + asset_id = %asset.asset_id, + capture_timestamp = %asset.sidecar.capture_timestamp, + "index: unparseable capture timestamp; indexing it as the epoch" + ); + 0 + }, + Timestamp::as_second, + ); AssetRow { uuid: asset.asset_id.to_string(), asset_type: asset_type_for(&asset.sidecar.content_type), - capture_timestamp: asset.capture_utc, - capture_utc: Some(asset.capture_utc), + capture_timestamp: capture_utc, + capture_utc: Some(capture_utc), capture_tz_source: None, import_timestamp: rfc3339_to_secs(&asset.sidecar.import_timestamp), hash_sha256: asset.sidecar.hash.to_hex(), @@ -182,14 +231,96 @@ fn asset_row_from_state(asset: &AssetState) -> AssetRow { } } +/// Whether `a` and `b` resolve to the same existing file. +fn is_same_file(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(x), Ok(y)) => x == y, + _ => false, + } +} + +/// One signed create, as [`Workspace::commit_signed_create`] takes it: everything a caller +/// decides *before* the sealing order starts. A fresh import fills the defaults; the +/// unsigned-sidecar migration (`S-D24`) is the one caller that pins an id, a bucket, an import +/// time, and a fold. +pub(super) struct CreateRequest<'a> { + /// The asset id. An import mints `Uuid::now_v7()`; the migration keeps the legacy id. + pub asset_id: Uuid, + /// The owning album; must be held with write capability. + pub album_id: Uuid, + /// The plaintext to admit. When this already *is* the asset's own media path, the bytes are + /// left where they are rather than rewritten over themselves. + pub src: &'a Path, + /// The sidecar's `import_timestamp`; `None` stamps now. + pub import_timestamp: Option, + /// The `media/{YYYY}/{YYYY-MM}` bucket the asset's files live in, as UTC seconds. `None` + /// derives it from the resolved capture time (a fresh import); the migration pins the + /// bucket the files already sit in, so nothing moves. + pub media_bucket: Option, + /// `_unknown` entries the signed sidecar carries from birth — empty for an import. + pub extra_unknown: BTreeMap, + /// Move-mode release, stack placement, and exporter enrichment. + pub opts: &'a SignedImportOptions, +} + impl Workspace { + /// Write an asset's plaintext and its signed artifacts. + /// + /// The plaintext is written only when the media path does not already hold it: an + /// original that is already correct is never rewritten over itself, which would only open + /// a crash window in which the one copy is truncated. The decision is made from the + /// buffer — a `stat` of the media path, a length comparison, and `hash_bytes(plaintext)` + /// against the sidecar's `hash` — with no second read of the disk, so a metadata edit + /// costs one read of the original and no write. + /// + /// **Caller rule.** `plaintext` must be either the bytes read from the media path + /// (`append_lifecycle`) or the file about to become it (`commit_signed_create`; for the + /// migration that is the media path itself). A caller whose buffer may legitimately differ + /// from a *same-length* file already at the media path is not covered by this guard and + /// must decide the overwrite itself; `import_backup` restores into a workspace where the + /// media path does not exist, so it never meets that case. pub(super) fn write_asset_files(&self, asset: &AssetState, plaintext: &[u8]) -> Result<()> { let dir = media_dir(&self.root, asset.capture_utc); fs::create_dir_all(&dir).map_err(|e| LifecycleError::Io(e.to_string()))?; - fs::write(self.media_path(asset), plaintext) - .map_err(|e| LifecycleError::Io(e.to_string()))?; - fs::write(self.sidecar_path(asset), asset.sidecar.to_canonical_vec()) + let media_path = self.media_path(asset); + let already_correct = fs::metadata(&media_path) + .is_ok_and(|m| m.is_file() && m.len() == plaintext.len() as u64) + && hash::hash_bytes(plaintext) == asset.sidecar.hash; + if already_correct { + tracing::debug!( + asset_id = %asset.asset_id, + "original already on disk with the signed hash; not rewriting it" + ); + } else { + fs::write(&media_path, plaintext).map_err(|e| LifecycleError::Io(e.to_string()))?; + } + self.write_signed_artifacts(asset) + } + + /// The signed half of [`write_asset_files`](Self::write_asset_files): the sidecar, the + /// provenance chain, and the sealed metadata blob — everything but the plaintext. The + /// right call for a writer whose plaintext is already on disk and unchanged (a metadata + /// edit), which then needs neither to read nor to write the original. + /// + /// The sidecar is staged to `{uuid}.cbor.tmp` and renamed into place (the single-file + /// atomic write of the maintenance doc), so a crash mid-write leaves the previous sidecar + /// intact rather than a torn one — for the migration, that previous sidecar is the legacy + /// record itself. The stale `.tmp` is the startup scrub's to remove. The per-asset + /// *bundle* (sidecar, chain, blob renamed together) remains its own slice. + pub(super) fn write_signed_artifacts(&self, asset: &AssetState) -> Result<()> { + let dir = media_dir(&self.root, asset.capture_utc); + fs::create_dir_all(&dir).map_err(|e| LifecycleError::Io(e.to_string()))?; + let sidecar_path = self.sidecar_path(asset); + let staged = tmp_path(&sidecar_path); + fs::write(&staged, asset.sidecar.to_canonical_vec()) .map_err(|e| LifecycleError::Io(e.to_string()))?; + #[cfg(test)] + if SIDECAR_RENAME_FAULT.with(std::cell::Cell::get) { + return Err(LifecycleError::Io( + "injected fault: crashed before renaming the sidecar into place".into(), + )); + } + fs::rename(&staged, &sidecar_path).map_err(|e| LifecycleError::Io(e.to_string()))?; let prov = cbor::to_canonical_vec(&asset.chain.records().to_vec()) .map_err(|e| LifecycleError::Cbor(e.to_string()))?; fs::write(self.provenance_path(asset), prov) @@ -303,12 +434,13 @@ impl Workspace { /// As [`import_asset`](Self::import_asset) but with executor-supplied [`SignedImportOptions`] /// (Move-mode source release + stack placement). This is the single signed write path the /// import executor drives (S-B2): every imported member lands as a signed `SidecarV1` + - /// manifest + append-only provenance, self-verified through [`verify_asset`], and — behind - /// the `media` feature, when a [`StillEncoder`](crate::media::image::derivative::StillEncoder) - /// is attached — with signed thumbnail/preview derivatives + an LQIP in the sidecar. + /// manifest + append-only provenance, self-verified through [`verify_asset`], and — when the + /// still decodes — with a chromahash `lqip` in the sidecar and signed thumbnail derivatives + /// on disk (the private `prepare_still`, slices `S-B1`/`S-B14`). /// - /// Returns a [`SignedImport`]: the asset id, plus the [`DerivativeStatus`](super::DerivativeStatus) - /// saying whether derivatives were generated and, if not, why. A format this build has no + /// Returns a [`SignedImport`]: the asset id, the + /// [`DerivativeStatus`](super::DerivativeStatus) saying whether derivatives were generated + /// and, if not, why, and the per-format deferral count. A format this build has no /// codec for **still imports** — the original is the backup, the thumbnail is a bonus — so /// the status is a report, never a rejection (slice `S-B13`). #[tracing::instrument(skip_all, fields(album_id = %album_id, src = %src.display()))] @@ -318,12 +450,36 @@ impl Workspace { src: &Path, opts: &SignedImportOptions, ) -> Result { + self.commit_signed_create(&CreateRequest { + asset_id: Uuid::now_v7(), + album_id, + src, + import_timestamp: None, + media_bucket: None, + extra_unknown: BTreeMap::new(), + opts, + }) + } + + /// The signed create commit every import — and the unsigned-sidecar migration — goes + /// through: EXIF scan, encrypt, derivatives, author + sign the sidecar, seal it, build + + /// sign the create manifest, self-verify, write, index. The [`CreateRequest`] carries the + /// few things a caller decides beforehand; the sealing order and the self-checks are the + /// same for every caller, which is the point of there being one of these. + #[tracing::instrument( + skip_all, + fields(asset_id = %req.asset_id, album_id = %req.album_id, src = %req.src.display()) + )] + pub(super) fn commit_signed_create(&mut self, req: &CreateRequest<'_>) -> Result { + let src = req.src; + let asset_id = req.asset_id; + let album_id = req.album_id; + let opts = req.opts; let plaintext = fs::read(src) .map_err(|e| LifecycleError::Io(format!("read {}: {e}", src.display())))?; let ext = src .extension() .map_or_else(|| "bin".into(), |e| e.to_string_lossy().to_lowercase()); - let asset_id = Uuid::now_v7(); // Scan & extract: capture time, dimensions, and GPS from the file's EXIF. Missing values // degrade cleanly (capture → now; dimensions/GPS → absent). @@ -382,27 +538,53 @@ impl Workspace { "import: sidecar metadata resolved" ); - // Still-derived sidecar metadata. Dimensions come from EXIF; there is **no decoder in - // this build** since `S-C59` retired `capsule_core::media`, so no still is decoded, no - // LQIP is computed and no derivatives are generated. The import proceeds regardless: the - // original is still backed up as a signed, encrypted blob, and `derivative_status` - // records the gap so it is reportable rather than silent (`S-B13`). Rawshift's - // replacement is what closes it. - let (dimensions, lqip, derivative_status) = ( - exif.width - .zip(exif.height) - .map(|(width, height)| Dimensions { width, height }), - None::, - super::DerivativeStatus::DeferredNoCodec, - ); - let album = self.album(&album_id)?; let epoch = album.current_epoch; let amk = Amk::from_bytes(album.amks[&epoch]); // First write: draw a fresh nonce prefix and derive the folded file key together // (nothing to replace on a create). + // + // **Before** the derivatives, and that ordering is load-bearing: the `original` + // sentinel is a signed *reference* to this blob, so it commits to this ciphertext's + // address and this nonce prefix, neither of which exists until now. let (enc, ciphertext, _file_key) = encrypt_asset_rekey(&amk, &asset_id, &plaintext, None)?; + // The media bucket every file of this asset resolves to. A fresh import buckets by the + // resolved capture time; the migration pins the bucket the files already sit in, and + // the sidecar's `capture_timestamp` below still carries the resolved truth. + let bucket = req.media_bucket.unwrap_or(capture_utc); + + // Still-derived sidecar metadata, from one decode pass over the plaintext: the + // header-derived `content_type`, pixel `dimensions`, the chromahash `lqip`, and the + // signed thumbnail derivatives to persist once the asset's own files are durable. Each + // generated derivative is encrypted under its own fresh nonce prefix as it is signed — + // derivative bytes cross the network encrypted exactly like the original. + // + // Never fatal. A still this build cannot decode — or cannot decode *these bytes* of — + // commits exactly as before: EXIF dimensions, no LQIP, no derivatives, and a + // `DerivativeStatus` recording which reason applied so the gap is reportable rather + // than silent (`S-B13`). + let PreparedStill { + format, + dimensions, + lqip, + derivatives, + deferred_formats, + status: derivative_status, + } = self.prepare_still( + &StillSource { + plaintext: &plaintext, + ext: &ext, + src, + exif: &exif, + }, + asset_id, + album_id, + bucket, + &amk, + &enc, + )?; + // Sealing order (1) the prior head `H` is `None` on a create; (2) author + sign the // sidecar with `provenance_chain_hash = H`. let mut sidecar = SidecarV1 { @@ -411,8 +593,10 @@ impl Workspace { uuid: asset_id, hash: hash::hash_bytes(&plaintext), capture_timestamp: capture_rfc3339(capture_utc), - import_timestamp: now_rfc3339(), - content_type: content_type_for(&ext), + import_timestamp: req.import_timestamp.clone().unwrap_or_else(now_rfc3339), + // Header-derived wherever the bytes name a still Capsule models; the extension + // table is the fallback for everything else (video, unknown suffixes). + content_type: format.map_or_else(|| content_type_for(&ext), |f| f.mime().to_string()), dimensions, lqip, tags_user, @@ -435,7 +619,7 @@ impl Workspace { session_id: Uuid::now_v7(), gps, provenance_chain_hash: None, - unknown: BTreeMap::new(), + unknown: req.extra_unknown.clone(), signature: None, }; sidecar.sign(&self.account.user_ik); @@ -503,7 +687,7 @@ impl Workspace { asset_id, album_id, ext, - capture_utc, + capture_utc: bucket, chain, sidecar, metadata_blob, @@ -511,14 +695,22 @@ impl Workspace { // reaches `AssetState::stack` by any other route. stack: opts.stack.as_ref().map(StackPlacement::from_membership), }; + // Bytes already at their own media path (the migration) are signed where they lie: + // `write_asset_files` sees the signed hash already on disk and leaves the original + // alone, and the Move-mode release below must not delete what is now the asset. + let in_place = is_same_file(src, &self.media_path(&asset)); self.write_asset_files(&asset, &plaintext)?; + // After the asset's own files, and deliberately: a derivative is regenerable, so a + // failure to write one must never fail an import whose signed original is already + // durable. `persist_derivatives` logs and continues rather than returning. + self.persist_derivatives(&asset, &derivatives); self.index_asset_row(&asset)?; self.index_original_representation(&asset, plaintext.len())?; // Move mode: release the source only after the durable, self-verified commit — unless // the caller defers release to its server-side verify-before-destroy gate (S-D4/S-B3), // where the source is the only copy until the *server* durably holds it. - if opts.move_source && !opts.defer_source_release { + if opts.move_source && !opts.defer_source_release && !in_place { let _ = fs::remove_file(src); } @@ -526,15 +718,16 @@ impl Workspace { Ok(SignedImport { asset_id, derivatives: derivative_status, + deferred_formats: deferred_formats as u32, }) } /// Import a file on the signed path for a **streaming** import: identical to /// [`import_asset_with`](Self::import_asset_with) but with `defer_source_release` forced on, - /// and returning the [`StreamedImport`] descriptor the streaming window drives its - /// per-asset upload → verify → release step from. The local original (and any Move-mode - /// source) is left in place — the [streaming executor](crate::import::streaming) releases it - /// only after the server's `durable` verdict + custody receipt clear the `S-D4` gate. + /// and returning the [`StreamedImport`] descriptor the streaming window drives its per-asset + /// upload → verify → release step from. The local original (and any Move-mode source) is left + /// in place — the [streaming executor](crate::import::execute_streaming) releases it only + /// after the server's `durable` verdict + custody receipt clear the `S-D4` gate. /// /// `enrichment` carries the folded third-party exporter metadata for this file exactly as /// [`import_asset_with`](Self::import_asset_with) takes it (`S-B11`). It is a parameter and @@ -589,7 +782,7 @@ impl Workspace { mod tests { use tempfile::TempDir; - use super::super::fast_workspace; + use super::super::{FAST_PARAMS, fast_workspace}; use super::*; use crate::crypto::keys::HybridSigningKey; @@ -699,6 +892,135 @@ mod tests { assert_eq!(ws.verify(&asset).unwrap(), VerifyOutcome::Accept); } + /// The rewrite guard, directly. A media path holding bytes of a *different length* than + /// the correct plaintext is overwritten; a media path already holding the correct bytes is + /// left alone, its mtime intact — the safety property the migration and every metadata + /// edit rely on. + #[test] + fn write_asset_files_overwrites_wrong_bytes_and_leaves_correct_bytes_alone() { + use std::time::{Duration, SystemTime}; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("photo.jpg"); + let correct = b"\xFF\xD8\xFF the signed bytes".to_vec(); + fs::write(&img, &correct).unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + let media = ws.media_path(ws.asset(&id).unwrap()); + + // Wrong bytes at the media path (truncated, then longer): overwritten. + for wrong in [ + &correct[..correct.len() / 2], + b"\xFF\xD8\xFF not the signed bytes at all", + ] { + fs::write(&media, wrong).unwrap(); + ws.write_asset_files(ws.asset(&id).unwrap(), &correct) + .unwrap(); + assert_eq!( + fs::read(&media).unwrap(), + correct, + "wrong bytes were rewritten" + ); + } + + // Correct bytes already there: not touched, which the mtime proves. + let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + fs::File::options() + .write(true) + .open(&media) + .unwrap() + .set_modified(long_ago) + .unwrap(); + ws.write_asset_files(ws.asset(&id).unwrap(), &correct) + .unwrap(); + assert_eq!(fs::metadata(&media).unwrap().modified().unwrap(), long_ago); + assert_eq!(fs::read(&media).unwrap(), correct); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + } + + /// The guard's documented limit, pinned so it cannot change silently: the decision is + /// made from the buffer with no second read of the disk, so a *same-length* file whose + /// bytes differ from a correct buffer is not detected. No caller in the tree can produce + /// that case — `append_lifecycle` passes the bytes it just read from this very path, a + /// create's source either is this path or the path does not exist yet, and `import_backup` + /// restores into a workspace where the path does not exist — and a wrong original is + /// caught by `verify`, never silently accepted. A caller that could meet the case must + /// decide the overwrite itself (see the caller rule on `write_asset_files`). + #[test] + fn write_asset_files_trusts_a_same_length_buffer_over_the_disk() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("photo.jpg"); + let correct = b"\xFF\xD8\xFF the signed bytes".to_vec(); + fs::write(&img, &correct).unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + let media = ws.media_path(ws.asset(&id).unwrap()); + + let same_length = b"\xFF\xD8\xFF THE SIGNED BYTES".to_vec(); + assert_eq!(same_length.len(), correct.len()); + fs::write(&media, &same_length).unwrap(); + ws.write_asset_files(ws.asset(&id).unwrap(), &correct) + .unwrap(); + assert_eq!( + fs::read(&media).unwrap(), + same_length, + "the buffer is trusted; no second read decides this" + ); + // ...and the wrong original does not pass verification. + assert_ne!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + } + + /// A lifecycle write — caption, tag, soft-delete, restore — touches the signed artifacts + /// and nothing else: the original is neither read nor rewritten. The original is made + /// unreadable for the duration (so any read would fail the edit), and its mtime and bytes + /// are unchanged afterwards. + #[test] + fn metadata_edits_neither_read_nor_rewrite_the_original() { + use std::time::{Duration, SystemTime}; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("photo.jpg"); + let bytes = b"\xFF\xD8\xFF never touched by an edit".to_vec(); + fs::write(&img, &bytes).unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + let media = ws.media_path(ws.asset(&id).unwrap()); + + let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + fs::File::options() + .write(true) + .open(&media) + .unwrap() + .set_modified(long_ago) + .unwrap(); + #[cfg(unix)] + let readable = { + use std::os::unix::fs::PermissionsExt; + let readable = fs::metadata(&media).unwrap().permissions(); + fs::set_permissions(&media, fs::Permissions::from_mode(0o000)).unwrap(); + readable + }; + + ws.set_caption(&id, "edited without touching the bytes") + .unwrap(); + ws.tag_add(&id, "untouched").unwrap(); + ws.soft_delete(&id, 30).unwrap(); + ws.restore(&id).unwrap(); + + #[cfg(unix)] + fs::set_permissions(&media, readable).unwrap(); + assert_eq!(fs::metadata(&media).unwrap().modified().unwrap(), long_ago); + assert_eq!(fs::read(&media).unwrap(), bytes); + assert_eq!(ws.asset(&id).unwrap().chain.records().len(), 5); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + } + // ── Importer-formed stacks (S-B15) ────────────────────────────────────────── /// Write `n` distinct fixture files into `dir` and return their paths. @@ -929,7 +1251,8 @@ mod tests { // Backup → restore into a FRESH library (new device, verifying against the // exporter's published key) → byte-equal plaintext. let backup_path = src.path().join("backup.tar"); - ws.export_backup(&backup_path, b"recovery-pass").unwrap(); + ws.export_backup_with_params(&backup_path, b"recovery-pass", FAST_PARAMS) + .unwrap(); let exporter_pub = ws.exporter_verifying_key(); let fresh = TempDir::new().unwrap(); @@ -998,4 +1321,49 @@ mod tests { ws.restore(&id).unwrap(); assert_eq!(ws.db().query_timeline(0, 100).unwrap().len(), 1); } + + // ── The index projection of capture time (S-B17 precondition) ─────────────── + + /// The live `assets` row is projected from the **signed sidecar's** capture timestamp, as + /// a rebuild projects it — not from the `capture_utc` shard, which is fixed at import and + /// stays put through a capture-time correction. Equal at import; this test drives the two + /// apart the way `set_capture_timestamp` will and asserts the row follows the sidecar. + #[test] + fn the_live_index_row_projects_capture_time_from_the_sidecar() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("p.jpg"); + fs::write(&img, b"\xFF\xD8\xFF capture projection bytes").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("A").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + + // At import the shard and the sidecar name the same instant. + let asset = ws.assets.get(&id).unwrap(); + let row = asset_row_from_state(asset); + assert_eq!(row.capture_timestamp, asset.capture_utc); + assert_eq!(row.capture_utc, Some(asset.capture_utc)); + assert_eq!( + rfc3339_to_secs(&asset.sidecar.capture_timestamp), + asset.capture_utc + ); + + // Drive them apart in memory: the sidecar says 2001, the shard still says now. + let corrected = 1_000_000_000; + ws.assets.get_mut(&id).unwrap().sidecar.capture_timestamp = capture_rfc3339(corrected); + let asset = ws.assets.get(&id).unwrap(); + assert_ne!(asset.capture_utc, corrected, "the shard is untouched"); + let row = asset_row_from_state(asset); + assert_eq!( + row.capture_timestamp, corrected, + "the row follows the sidecar" + ); + assert_eq!(row.capture_utc, Some(corrected)); + + // An unparseable sidecar timestamp indexes as the epoch, as `rebuild_index` does. + ws.assets.get_mut(&id).unwrap().sidecar.capture_timestamp = "not a timestamp".into(); + let row = asset_row_from_state(ws.assets.get(&id).unwrap()); + assert_eq!(row.capture_timestamp, 0); + assert_eq!(row.capture_utc, Some(0)); + } } diff --git a/capsule-core/src/lifecycle/metadata.rs b/capsule-core/src/lifecycle/metadata.rs index 7abdf9cf..21a495ac 100644 --- a/capsule-core/src/lifecycle/metadata.rs +++ b/capsule-core/src/lifecycle/metadata.rs @@ -5,6 +5,7 @@ //! `Workspace` directly, which — with this file importing `ml` — made the two modules mutually //! recursive; the trait inverts that edge without moving any behaviour. +use jiff::Timestamp; use uuid::Uuid; use super::{LifecycleError, Result, Workspace, now_rfc3339}; @@ -13,7 +14,7 @@ use crate::db::DatabaseDriver; use crate::metadata::crdt::AddId; use crate::ml::orchestrator::{AiTagSink, AssetSource}; use crate::ml::{ModelId, Registry}; -use crate::sidecar::sidecar_v1::AiTag; +use crate::sidecar::AiTag; impl Workspace { /// Add a user tag (OR-set) and emit a `metadata-update` provenance record. @@ -34,6 +35,45 @@ impl Workspace { }) } + /// Correct the asset's signed `capture_timestamp` to `capture`, as a new signed revision: + /// one `metadata-update` record, with the sidecar re-signed and its sealed blob re-sealed + /// under a fresh nonce, exactly as any other metadata edit lands (slice `S-B17`). + /// + /// The **media bundle is not relocated.** `capture_timestamp` is authoritative for the + /// asset's date; the `media/{YYYY}/{YYYY-MM}` directory is only the shard fixed at import + /// (`AssetState::capture_utc`), and the design treats bucket-vs-timestamp drift after a + /// capture correction as expected rather than as a fault (Maintenance — Structural + /// Validation). Moving the bundle here would orphan the old directory for every writer + /// that still resolves it, so the shard stays and every path keeps resolving; the index + /// row follows the sidecar (`asset_row_from_state`), so the timeline shows the corrected + /// instant immediately rather than after a rebuild. + /// + /// Takes a [`Timestamp`] rather than raw seconds so an out-of-range instant is + /// unrepresentable at the call site instead of being clamped to the epoch here. + #[tracing::instrument(skip(self), fields(asset_id = %asset_id, capture = %capture))] + pub fn set_capture_timestamp(&mut self, asset_id: &Uuid, capture: Timestamp) -> Result<()> { + let recorded = self + .assets + .get(asset_id) + .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))? + .sidecar + .capture_timestamp + .clone(); + let corrected = capture.to_string(); + self.append_lifecycle(asset_id, Action::MetadataUpdate, None, move |s, _add_id| { + s.capture_timestamp = corrected; + })?; + let head = self.assets[asset_id].chain.head(); + tracing::info!( + asset_id = %asset_id, + recorded = %recorded, + corrected = %capture, + chain_head = ?head, + "capture timestamp corrected as a signed metadata-update" + ); + Ok(()) + } + // ── AI metadata containment (SSoT: metadata § Tag Provenance, ai § AI Output Containment) ── // // AI outputs land in a namespace **structurally** separate from user-authored metadata: the @@ -175,6 +215,7 @@ mod tests { use super::super::fast_workspace; use super::*; + use crate::crypto::verify_asset::VerifyOutcome; use crate::ml::{CANONICAL_PARTITION, FixtureRunner, TaskKind}; use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; @@ -300,6 +341,126 @@ mod tests { assert_eq!(ws.ai_tags(&id).unwrap().len(), 2); } + // ── Capture-time correction (S-B17) ────────────────────────────────────────────────────── + + /// The `S-B17` write, end to end: the correction lands as a new signed revision, the bundle + /// stays in the directory the import chose, the index row follows the sidecar at once, and + /// a rebuild from disk agrees with the live row — the two projections + /// (`asset_row_from_state` and `rebuild::signed_asset_row`) name the same instant. + #[test] + fn a_capture_correction_is_a_signed_revision_that_leaves_the_bundle_in_place() { + use crate::library::{open_library, rebuild_index}; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, id) = workspace_with(&lib, &src, b"\xFF\xD8\xFF capture correction bytes"); + + // No EXIF in those bytes, so the import stamped the import clock — the pre-`S-B16` + // shape every affected asset has. + let before = ws.asset(&id).unwrap(); + let shard = before.capture_utc; + let original = ws + .original_path(&id) + .expect("a managed asset has an original"); + assert!(original.is_file()); + assert_eq!(before.chain.records().len(), 1); + let recorded = before.sidecar.capture_timestamp.clone(); + + let corrected = Timestamp::from_second(1_000_000_000).unwrap(); + ws.set_capture_timestamp(&id, corrected).unwrap(); + + // A new signed revision: one more record, a `metadata-update`, and the asset verifies. + let after = ws.asset(&id).unwrap(); + assert_eq!(after.sidecar.capture_timestamp, "2001-09-09T01:46:40Z"); + assert_ne!(after.sidecar.capture_timestamp, recorded); + assert_eq!(after.chain.records().len(), 2); + assert_eq!( + after.chain.records().last().unwrap().manifest.core.action, + Action::MetadataUpdate + ); + assert!(after.sidecar.signature.is_some()); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + + // The shard — and therefore every artifact path — is exactly where it was. + assert_eq!(after.capture_utc, shard, "the bundle is not relocated"); + assert_eq!(ws.original_path(&id).unwrap(), original); + assert!(original.is_file()); + assert!(!original.to_string_lossy().contains("2001-09")); + + // The live index row follows the sidecar immediately, not the shard. + let row = ws + .db() + .find_by_uuid(&id.to_string()) + .unwrap() + .expect("indexed"); + assert_eq!(row.capture_timestamp, 1_000_000_000); + assert_eq!(row.capture_utc, Some(1_000_000_000)); + + // Reopened from disk, the correction holds and the files stay reachable. + let root = lib.path().to_path_buf(); + drop(ws); + let ws = Workspace::open( + &root, + b"passphrase", + crate::crypto::primitives::Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + }, + ) + .unwrap(); + let reopened = ws.asset(&id).unwrap(); + assert_eq!(reopened.sidecar.capture_timestamp, "2001-09-09T01:46:40Z"); + // `open` reconciles a sidecar that no longer names its directory by taking the shard + // from the directory itself (the month's first instant), which is the same directory. + assert_ne!( + reopened.capture_utc, 1_000_000_000, + "the shard is not the corrected instant" + ); + assert_eq!(ws.original_path(&id).unwrap(), original); + assert!(ws.read_plaintext(&id).is_ok()); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + + // A rebuild from the artifacts on disk projects the same instant the live row did. + drop(ws); + std::fs::remove_file(root.join("index/library.sqlite")).unwrap(); + let library = open_library(&root).unwrap(); + rebuild_index(&library).unwrap(); + let rebuilt = library + .db + .find_by_uuid(&id.to_string()) + .unwrap() + .expect("back in the rebuilt index"); + assert_eq!(rebuilt.capture_timestamp, 1_000_000_000); + assert_eq!(rebuilt.capture_utc, Some(1_000_000_000)); + } + + /// `is_trashed` is the chain replay the workspace applies, exposed once: a soft delete + /// flips it, a restore flips it back, an unknown id is simply not in trash. + #[test] + fn is_trashed_replays_the_chain_and_is_false_for_an_unknown_id() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, id) = workspace_with(&lib, &src, b"\xFF\xD8\xFF trash replay bytes"); + assert!(!ws.is_trashed(&id)); + ws.soft_delete(&id, 30).unwrap(); + assert!(ws.is_trashed(&id)); + ws.restore(&id).unwrap(); + assert!(!ws.is_trashed(&id)); + assert!(!ws.is_trashed(&Uuid::now_v7())); + } + + #[test] + fn a_capture_correction_on_an_unknown_asset_is_refused() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (mut ws, _) = workspace_with(&lib, &src, b"\xFF\xD8\xFF some bytes"); + let err = ws + .set_capture_timestamp(&Uuid::now_v7(), Timestamp::UNIX_EPOCH) + .unwrap_err(); + assert!(matches!(err, LifecycleError::NotFound(_)), "{err:?}"); + } + // ── The inference seam (`ml::AiTagSink` / `ml::AssetSource`) ───────────────────────────── /// The trait forwards must land in exactly the same place the inherent methods do. This is diff --git a/capsule-core/src/lifecycle/migrate_unsigned.rs b/capsule-core/src/lifecycle/migrate_unsigned.rs new file mode 100644 index 00000000..3dd76579 --- /dev/null +++ b/capsule-core/src/lifecycle/migrate_unsigned.rs @@ -0,0 +1,2327 @@ +//! Migrate the unsigned pre-signed-path sidecars into signed assets (slice `S-D24`). +//! +//! A library written before the signed path existed holds, beside each original, a flat +//! unsigned CBOR map (`version: 1`, text keys) with no provenance chain, no sealed metadata +//! blob, and no album key material. [`Workspace::open`] anchors on provenance chains, so such +//! an asset is invisible to every signed operation — it cannot be verified, exported, or +//! uploaded — and a keyless [`rebuild_index`](crate::library::rebuild_index) cannot admit it +//! either: it holds no album write capability and cannot sign a sidecar or a manifest, and +//! the unsigned reader it once fell back to is gone. +//! +//! The migration is an **explicit verb**, never automatic: it authors signed records, which an +//! open must not do unasked, and it needs the album write capability a keyless rebuild does +//! not hold. Each legacy record is *admitted* as a signed `create` authored by this device now, +//! attesting exactly what any import attests — the content hash of the bytes on disk (checked +//! against the legacy record first), this device, this album, and now — through the one signed +//! write path every import takes. The legacy bytes are preserved verbatim under +//! `.library/quarantine/` before anything is written, and the whole decoded legacy map rides +//! inside the signed sidecar's `_unknown` under [`LEGACY_FOLD_KEY`], where the signature covers +//! it and the never-strip rule protects it. +//! +//! This module owns the only decoder of the legacy shape, and it is private: the verb reads +//! the handful of fields it projects and carries the rest as an opaque map. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use ciborium::value::Value; +use jiff::Timestamp; +use thiserror::Error; +use uuid::Uuid; +use walkdir::WalkDir; + +use super::import::CreateRequest; +use super::open::{month_dir_timestamp, original_extension}; +use super::{LifecycleError, Result, SidecarEnrichment, SignedImportOptions, Workspace, media_dir}; +use crate::crypto::hash; +use crate::crypto::provenance::action::Action; +use crate::domain::{GpsDatum, StackType}; +use crate::sidecar::shape::{self, SidecarShape}; +use crate::sidecar::sidecar_v1::{ + Gps, GpsSource, SIDECAR_SCHEMA_V1, SidecarV1, StackMembership, StackRole, +}; +use crate::utils::paths::sync_dir; + +/// The `_unknown` key under which a migrated asset's signed sidecar carries its whole legacy +/// record. Hyphenated on purpose: no snake_case schema field can ever collide with it. +pub const LEGACY_FOLD_KEY: &str = "legacy-unsigned-sidecar"; + +/// The `reason` recorded in a quarantined legacy sidecar's `.reason.json`. +const QUARANTINE_REASON: &str = "unsigned-sidecar-migrated"; + +/// Domain separator for `legacy_stack_id`. +const LEGACY_STACK_ID_DOMAIN: &[u8] = b"capsule-legacy-stack-id-v1"; + +/// The deterministic stack id for a legacy `stack_hint` group: an RFC 9562 v8 (custom) UUID +/// over `SHA-256(domain ‖ user_id ‖ "{method}:{key}")`, the same construction the master key +/// uses for the default album id. A pure function of the user and the group key, so an +/// interrupted or repeated run — or the same user migrating a copy — lands every member under +/// the same id; carries no creation time. +fn legacy_stack_id(user_id: &Uuid, detection_method: &str, detection_key: &str) -> Uuid { + let mut input = Vec::with_capacity(64 + detection_method.len() + detection_key.len()); + input.extend_from_slice(LEGACY_STACK_ID_DOMAIN); + input.extend_from_slice(user_id.as_bytes()); + input.extend_from_slice(format!("{detection_method}:{detection_key}").as_bytes()); + let digest = hash::hash_bytes(&input); + let mut b = [0u8; 16]; + b.copy_from_slice(&digest.0[..16]); + uuid::Builder::from_custom_bytes(b).into_uuid() +} + +/// What [`Workspace::migrate_unsigned_sidecars`] needs beyond the workspace itself. +#[derive(Debug, Clone)] +pub struct UnsignedMigrationOptions { + /// The album a legacy asset lands in when its own `album_id` is absent, unparseable, or + /// names an album this workspace holds no write capability for. Must already exist and + /// be writable: the verb never mints an album. + pub fallback_album: Uuid, + /// The retention window, in days, stamped on the `delete` record of a legacy asset whose + /// record says `is_deleted: true` — the same argument [`Workspace::soft_delete`] takes. + pub trash_retain_days: i64, +} + +/// What one run of [`Workspace::migrate_unsigned_sidecars`] did. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct UnsignedMigrationReport { + /// Every asset admitted as a signed create this run, in the order it was written. + pub migrated: Vec, + /// Every asset that received its signed `delete` record this run: the subset of + /// [`migrated`](Self::migrated) whose legacy record said `is_deleted`, plus any asset an + /// earlier run admitted whose `delete` never landed and was applied now. + pub trashed: Vec, + /// The stack ids derived from legacy `stack_hint` groups of two or more members. + pub stacks: Vec, + /// Sidecar files the run refused, each with why. Nothing was written for any of them. + pub skipped: Vec<(PathBuf, MigrationSkip)>, +} + +/// Why the migration refused one sidecar file without writing anything for it. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MigrationSkip { + /// The file's stem does not parse as a UUID, so it cannot be an asset's sidecar. + #[error("sidecar file name is not an asset id: {0}")] + InvalidAssetId(String), + /// The file is neither the signed shape nor the legacy unsigned one. + #[error("sidecar is neither the signed nor the legacy unsigned shape")] + UnknownShape, + /// The legacy map is missing a required field or carries one of the wrong type. + #[error("legacy record does not decode: {0}")] + Undecodable(String), + /// A signed asset with this id is already restored; migrating over it would replace a + /// signed record with a re-derived one. + #[error("asset {0} is already a signed asset in this library")] + IdCollision(Uuid), + /// No `{uuid}.{ext}` original sits beside the sidecar. An orphaned sidecar is the + /// maintenance scrub's finding, not this verb's. + #[error("asset {0}: no original media file beside the sidecar")] + OriginalMissing(Uuid), + /// The bytes on disk do not hash to what the legacy record says. A corrupt original is + /// surfaced, never laundered into a fresh signature. + #[error("asset {asset_id}: original hashes to {actual}, but the legacy record says {recorded}")] + HashMismatch { + /// The asset whose original disagrees with its record. + asset_id: Uuid, + /// The `hash_sha256` the legacy record carries. + recorded: String, + /// The SHA-256 of the bytes actually on disk. + actual: String, + }, + /// `.library/quarantine/` already holds a copy of this sidecar with different bytes, so + /// the run cannot tell which is the legacy record. + #[error("asset {0}: quarantine already holds different bytes for this sidecar")] + QuarantineConflict(Uuid), + /// The sidecar does not sit in a `media/{YYYY}/{YYYY-MM}` bucket, so its files cannot be + /// addressed by the lifecycle's bucket-derived paths without moving them — and the + /// migration never moves a file. + #[error("asset {asset_id}: {dir} is not a media/{{YYYY}}/{{YYYY-MM}} bucket")] + OutsideMonthBucket { + /// The asset whose sidecar sits outside a bucket. + asset_id: Uuid, + /// The directory it was found in. + dir: PathBuf, + }, + /// The original's extension is not a lowercase single segment (`jpg`, `dng`, `mp4`), the + /// form every lifecycle path derives; a `JPG` or `tar.gz` original would resolve to a + /// different path than the file that exists. + #[error("asset {asset_id}: original extension {ext:?} is not lowercase single-segment")] + UnusualExtension { + /// The asset whose original has the extension. + asset_id: Uuid, + /// The extension as found on disk. + ext: String, + }, + /// An asset an earlier run admitted still owes its `delete` record, but its album holds + /// no write capability now (recovered from a backup); the record cannot be authored. + #[error( + "asset {asset_id}: album {album_id} is read-only; its owed delete record was not written" + )] + AlbumReadOnly { + /// The asset that still owes a `delete`. + asset_id: Uuid, + /// The album without write capability. + album_id: Uuid, + }, + /// A signed sidecar with no provenance chain that is not an interrupted migration + /// create: it carries a later write (`provenance_chain_hash` set), it carries no legacy + /// fold, or there is no quarantine copy to resume from. Nothing this verb can rebuild + /// without discarding signed state, so it is reported and left alone. + #[error("asset {0}: signed sidecar without a provenance chain is not a resumable migration")] + Stranded(Uuid), +} + +/// The shape of a `{uuid}.cbor` under `media/` that no `.provenance.cbor` anchors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnmigratedShape { + /// The retired unsigned pre-signed-path record: what the migration verb rewrites. + LegacyUnsigned, + /// A signed sidecar whose chain is missing — an interrupted migration, resumable from its + /// quarantine copy — with the `sidecar_schema` it carries. + SignedWithoutChain { + /// The value at the sidecar's integer key `0`. + schema: u16, + }, + /// Neither shape — not CBOR, not a map, or a torn write. Resumed from its quarantine copy + /// when one exists; otherwise reported so it is never silently skipped, and never touched. + Unknown, +} + +/// One sidecar file [`Workspace::open`] found that no provenance chain anchors — an asset the +/// workspace cannot see until [`Workspace::migrate_unsigned_sidecars`] runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnmigratedSidecar { + /// The `{uuid}.cbor` file. + pub path: PathBuf, + /// The asset id its stem names, when the stem parses as one. + pub asset_id: Option, + /// Which shape the bytes have. + pub shape: UnmigratedShape, +} + +/// Every `{uuid}.cbor` under `root/media` — the sidecars, never the sibling +/// `.provenance.cbor` / `.receipts.cbor` logs. The same filter `rebuild_index` and the add-id +/// sweep apply. +fn sidecar_files(root: &Path) -> Vec { + let media = root.join("media"); + if !media.exists() { + return Vec::new(); + } + let mut out: Vec = WalkDir::new(&media) + .into_iter() + .filter_map(std::result::Result::ok) + .filter(|e| e.path().is_file()) + .filter(|e| { + let name = e.file_name().to_string_lossy(); + name.ends_with(".cbor") + && !name.ends_with(".provenance.cbor") + && !name.ends_with(".receipts.cbor") + }) + .map(|e| e.path().to_path_buf()) + .collect(); + // Deterministic order regardless of directory-walk order. + out.sort(); + out +} + +/// The sidecars under `root/media` with no `.provenance.cbor` sibling, each probed for its +/// shape. Used by [`Workspace::open`] to report them and by the migration verb to find its +/// candidates. +pub(super) fn find_unanchored(root: &Path) -> Vec { + let mut out = Vec::new(); + for path in sidecar_files(root) { + let Some(stem) = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(".cbor")) + else { + continue; + }; + let dir = path.parent().unwrap_or(root); + if dir.join(format!("{stem}.provenance.cbor")).exists() { + continue; + } + let asset_id = Uuid::parse_str(stem).ok(); + let shape = match fs::read(&path) { + Ok(bytes) => match shape::probe(&bytes) { + SidecarShape::Signed { schema } => UnmigratedShape::SignedWithoutChain { schema }, + SidecarShape::LegacyUnsigned => UnmigratedShape::LegacyUnsigned, + SidecarShape::Unknown => UnmigratedShape::Unknown, + }, + Err(e) => { + tracing::warn!(sidecar = %path.display(), error = %e, "unreadable sidecar file"); + UnmigratedShape::Unknown + } + }; + out.push(UnmigratedSidecar { + path, + asset_id, + shape, + }); + } + out +} + +// ── the legacy shape, decoded once and privately ──────────────────────────── + +/// Re-encode a CBOR [`Value`] and deserialize it as `T`. +fn from_value(v: &Value) -> std::result::Result { + let mut buf = Vec::new(); + ciborium::ser::into_writer(v, &mut buf).map_err(|e| e.to_string())?; + ciborium::de::from_reader(buf.as_slice()).map_err(|e| e.to_string()) +} + +/// [`from_value`] with the field name in the error. +fn typed(key: &str, v: &Value) -> std::result::Result { + from_value(v).map_err(|e| format!("field {key}: {e}")) +} + +/// The legacy `stack_hint`, with the two fields the grouping keys on kept as the strings the +/// legacy serializer wrote (`snake_case` enum names). +#[derive(Debug, Clone, PartialEq)] +struct LegacyStackHint { + detection_key: String, + detection_method: String, + member_role: String, + stack_type: StackType, +} + +/// The fields of a legacy unsigned record the migration projects, plus the whole map. +/// +/// Everything not named here — `original_filename`, `import_mode`, `importer_version`, +/// `rawshift_version`, `capture_tz*`, `tz_db_version`, `file_size`, `duration_ms`, +/// `modified_timestamp`, `camera_make`/`model`, and any key a later build added — has no +/// signed home of its own and survives only inside [`map`](Self::map). +#[derive(Debug, Clone, PartialEq)] +struct LegacyRecord { + uuid: String, + hash_sha256: String, + import_timestamp: i64, + capture_utc: Option, + capture_timestamp: Option, + rating: u8, + tags: Vec, + stack_hint: Option, + album_id: Option, + is_deleted: bool, + gps: Option<(f64, f64)>, + /// The entire legacy map, verbatim (every key, projected ones included). + map: Value, +} + +impl LegacyRecord { + /// Decode a legacy unsigned sidecar. Required: `uuid`, `hash_sha256`, `import_timestamp`. + /// Every other projected field defaults when absent and fails when present with the wrong + /// type; a `null` counts as absent, as the legacy reader treated it. + fn decode(bytes: &[u8]) -> std::result::Result { + let value: Value = + ciborium::de::from_reader(bytes).map_err(|e| format!("cbor decode: {e}"))?; + let Value::Map(entries) = &value else { + return Err("legacy sidecar must be a CBOR map".into()); + }; + let mut fields: BTreeMap<&str, &Value> = BTreeMap::new(); + for (k, v) in entries { + if let Value::Text(key) = k + && !matches!(v, Value::Null) + { + fields.insert(key.as_str(), v); + } + } + let req = |key: &str| -> std::result::Result<&Value, String> { + fields + .get(key) + .copied() + .ok_or_else(|| format!("missing required field: {key}")) + }; + let opt = |key: &str| fields.get(key).copied(); + + let uuid: String = typed("uuid", req("uuid")?)?; + let hash_sha256: String = typed("hash_sha256", req("hash_sha256")?)?; + let import_timestamp: i64 = typed("import_timestamp", req("import_timestamp")?)?; + let capture_utc = opt("capture_utc") + .map(|v| typed("capture_utc", v)) + .transpose()?; + let capture_timestamp = opt("capture_timestamp") + .map(|v| typed("capture_timestamp", v)) + .transpose()?; + let rating: u8 = opt("rating").map_or(Ok(0), |v| typed("rating", v))?; + let tags: Vec = opt("tags").map_or(Ok(Vec::new()), |v| typed("tags", v))?; + let album_id = opt("album_id").map(|v| typed("album_id", v)).transpose()?; + let is_deleted: bool = opt("is_deleted").map_or(Ok(false), |v| typed("is_deleted", v))?; + let gps_lat: Option = opt("gps_lat").map(|v| typed("gps_lat", v)).transpose()?; + let gps_lon: Option = opt("gps_lon").map(|v| typed("gps_lon", v)).transpose()?; + let stack_hint = match opt("stack_hint") { + None => None, + Some(Value::Map(hint)) => { + let mut h: BTreeMap<&str, &Value> = BTreeMap::new(); + for (k, v) in hint { + if let Value::Text(key) = k { + h.insert(key.as_str(), v); + } + } + let field = |key: &str| -> std::result::Result<&Value, String> { + h.get(key) + .copied() + .ok_or_else(|| format!("stack_hint missing field: {key}")) + }; + Some(LegacyStackHint { + detection_key: typed("stack_hint.detection_key", field("detection_key")?)?, + detection_method: typed( + "stack_hint.detection_method", + field("detection_method")?, + )?, + member_role: typed("stack_hint.member_role", field("member_role")?)?, + stack_type: typed("stack_hint.stack_type", field("stack_type")?)?, + }) + } + Some(_) => return Err("field stack_hint: expected a map".into()), + }; + + Ok(Self { + uuid, + hash_sha256, + import_timestamp, + capture_utc, + capture_timestamp, + rating, + tags, + stack_hint, + album_id, + is_deleted, + gps: gps_lat.zip(gps_lon), + map: value, + }) + } + + /// The legacy capture instant the sidecar's `capture_timestamp` falls back to when the + /// file's own EXIF resolves none: `capture_utc`, else `capture_timestamp`, else the legacy + /// import time — never the migration's own clock. + fn capture_fallback(&self) -> Timestamp { + [ + self.capture_utc, + self.capture_timestamp, + Some(self.import_timestamp), + ] + .into_iter() + .flatten() + .find_map(|secs| Timestamp::from_second(secs).ok()) + .unwrap_or(Timestamp::UNIX_EPOCH) + } +} + +// ── candidates ────────────────────────────────────────────────────────────── + +/// A legacy sidecar the run has admitted: decoded, its original present and hash-checked. +struct Candidate { + /// The `{uuid}.cbor` file the signed sidecar will be written over. + path: PathBuf, + /// Its media directory (`media/{YYYY}/{YYYY-MM}`). + dir: PathBuf, + asset_id: Uuid, + /// The legacy bytes — from the file itself, or from quarantine when resuming. + bytes: Vec, + record: LegacyRecord, + /// The original's extension (`{uuid}.{ext}` beside the sidecar). + ext: String, + /// Whether the legacy bytes came from quarantine (an interrupted run being resumed), in + /// which case the quarantine copy is already in place. + resumed: bool, +} + +impl Workspace { + /// The sidecar files [`open`](Self::open) found under `media/` that no provenance chain + /// anchors — assets this workspace cannot see, verify, export, or upload until + /// [`migrate_unsigned_sidecars`](Self::migrate_unsigned_sidecars) runs. Empty for a library + /// written entirely on the signed path. + pub fn unmigrated_sidecars(&self) -> &[UnmigratedSidecar] { + &self.unmigrated + } + + /// Where a legacy sidecar's verbatim bytes are preserved: `.library/quarantine/{uuid}.cbor`. + fn quarantine_sidecar_path(&self, asset_id: &Uuid) -> PathBuf { + self.root + .join(".library") + .join("quarantine") + .join(format!("{}.cbor", asset_id.simple())) + } + + /// Migrate every unsigned pre-signed-path sidecar under `media/` into a signed asset, then + /// rebuild the index so stack rows are reconstructed uniformly (slice `S-D24`). + /// + /// For each legacy sidecar, in asset-id order: + /// + /// 1. **Refuse without writing** when the file name is not a UUID, a signed asset with that + /// id is already restored, the original beside it is missing, or the original's SHA-256 + /// disagrees with the record's `hash_sha256`. Each refusal is a [`MigrationSkip`] in the + /// report; the file is untouched. + /// 2. **Preserve** the legacy bytes verbatim at `.library/quarantine/{uuid}.cbor` with a + /// sibling `{uuid}.reason.json`, before any signed write. + /// 3. **Admit** the asset through the one signed write path (`import_asset_with`'s commit), + /// keeping its id, its bytes where they are, and its media bucket: the signed sidecar is + /// written over the legacy one, the chain, sealed metadata blob, and index row beside it. + /// Capture time follows the import precedence — the file's own EXIF, else the legacy + /// record's, else the legacy import time. Rating, tags, and GPS are carried into their + /// signed registers; the whole legacy map rides in `_unknown` under [`LEGACY_FOLD_KEY`]. + /// 4. **Carry trash state**: a legacy `is_deleted` becomes a signed `delete` record with + /// `opts.trash_retain_days` of retention. + /// + /// Legacy `stack_hint`s are grouped by `(detection_method, detection_key)` first; a group + /// of two or more admitted members gets a deterministic stack id — an RFC 9562 v8 (custom) + /// UUID over `SHA-256(domain ‖ user_id ‖ "{method}:{key}")` — written into each member's + /// signed `stack_membership` register at create. A singleton gets no stack; its hint + /// survives in the fold. + /// + /// The album is the legacy `album_id` when this workspace holds write capability for it, + /// else `opts.fallback_album`, which must already exist and be writable — the verb never + /// mints an album, and a read-only fallback is a typed + /// [`AlbumReadOnly`](LifecycleError::AlbumReadOnly) refusal before anything is written. + /// + /// Idempotent and resumable: a second run finds signed sidecars with chains and does + /// nothing; a sidecar left without a chain (or torn) by an interrupted run is re-migrated + /// from its quarantine copy, provided the on-disk sidecar is still the migration's own + /// create — one that carries a later write is [`Stranded`](MigrationSkip::Stranded), never + /// overwritten; and a legacy `is_deleted` whose `delete` record never landed is applied on + /// the next run, unless the asset has since been trashed and restored by hand. A write + /// failure mid-run is returned as the error; the assets already migrated stay migrated, + /// and a rerun picks up the rest. + #[tracing::instrument( + skip_all, + fields(root = %self.root.display(), fallback_album = %opts.fallback_album) + )] + pub fn migrate_unsigned_sidecars( + &mut self, + opts: &UnsignedMigrationOptions, + ) -> Result { + // The fallback album must exist and be writable before a single byte is written. + self.album(&opts.fallback_album)?.write_tier_signer()?; + + // Assets a previous run admitted but whose `delete` record never landed. + let (trashed, unwritable) = self.reconcile_legacy_trash(opts.trash_retain_days)?; + let mut report = UnsignedMigrationReport { + trashed, + skipped: unwritable, + ..UnsignedMigrationReport::default() + }; + let mut candidates: Vec = Vec::new(); + + // Pass 1: find, decode, and check every candidate; refuse what cannot be admitted. + for found in find_unanchored(&self.root) { + match self.admit(&found) { + Ok(candidate) => candidates.push(candidate), + Err(skip) => { + tracing::warn!( + sidecar = %found.path.display(), + reason = %skip, + "unsigned migration: refusing this sidecar; nothing written for it" + ); + report.skipped.push((found.path, skip)); + } + } + } + candidates.sort_by_key(|c| c.asset_id); + // One id, one asset: a second legacy sidecar claiming an id already admitted this run + // (the same id in two month buckets) would overwrite the first's signed record. + candidates.dedup_by(|later, first| { + let dup = later.asset_id == first.asset_id; + if dup { + tracing::warn!( + sidecar = %later.path.display(), + asset_id = %later.asset_id, + "unsigned migration: a second sidecar claims an id admitted this run; refusing it" + ); + report + .skipped + .push((later.path.clone(), MigrationSkip::IdCollision(later.asset_id))); + } + dup + }); + + // Pass 2: derive the stack placements from the admitted members' hints. + let memberships = self.legacy_stack_memberships(&candidates); + report.stacks = memberships.values().map(|m| m.stack_id).collect(); + report.stacks.sort(); + report.stacks.dedup(); + + // Pass 3: quarantine, then admit, then carry trash state — one asset at a time. + for candidate in candidates { + let asset_id = candidate.asset_id; + let album_id = self.resolve_legacy_album(candidate.record.album_id.as_deref(), opts); + self.quarantine_legacy(&candidate)?; + let stack = memberships.get(&asset_id).cloned(); + let stacked = stack.is_some(); + self.commit_legacy_create(&candidate, album_id, stack)?; + report.migrated.push(asset_id); + if candidate.record.is_deleted { + self.soft_delete(&asset_id, opts.trash_retain_days)?; + report.trashed.push(asset_id); + } + tracing::info!( + asset_id = %asset_id, + album_id = %album_id, + trashed = candidate.record.is_deleted, + stacked, + resumed = candidate.resumed, + "unsigned migration: asset admitted as a signed create" + ); + } + + // The "on rebuild" half: stack rows are reconstructed from the registers uniformly, and + // every migrated row is re-projected from its signed artifacts. + crate::library::rebuild_index(&self.library)?; + self.unmigrated = find_unanchored(&self.root); + + tracing::info!( + migrated = report.migrated.len(), + trashed = report.trashed.len(), + stacks = report.stacks.len(), + skipped = report.skipped.len(), + remaining = self.unmigrated.len(), + "unsigned migration: run complete" + ); + Ok(report) + } + + /// The legacy bytes `.library/quarantine/{uuid}.cbor` holds for `asset_id`, when it holds + /// a legacy record at all. + fn quarantine_twin(&self, asset_id: &Uuid) -> std::result::Result, MigrationSkip> { + let twin = self.quarantine_sidecar_path(asset_id); + let bytes = fs::read(&twin).map_err(|_| MigrationSkip::Stranded(*asset_id))?; + if shape::probe(&bytes) != SidecarShape::LegacyUnsigned { + return Err(MigrationSkip::Stranded(*asset_id)); + } + Ok(bytes) + } + + /// Apply the `delete` record a previous run owed: an asset whose signed sidecar carries a + /// legacy fold saying `is_deleted: true` but whose chain has **never** carried a `delete`. + /// An asset the user has since trashed and restored has a `delete` in its chain and is + /// left exactly as they left it. An owed delete whose album is read-only now is reported + /// ([`MigrationSkip::AlbumReadOnly`]) rather than aborting the run. + #[allow(clippy::type_complexity)] + fn reconcile_legacy_trash( + &mut self, + retain_days: i64, + ) -> Result<(Vec, Vec<(PathBuf, MigrationSkip)>)> { + let is_deleted_key = Value::Text("is_deleted".to_string()); + let mut owed: Vec = self + .assets + .values() + .filter(|asset| { + let Some(Value::Map(fold)) = asset.sidecar.unknown.get(LEGACY_FOLD_KEY) else { + return false; + }; + let says_deleted = fold + .iter() + .any(|(k, v)| *k == is_deleted_key && matches!(v, Value::Bool(true))); + let ever_trashed = asset + .chain + .records() + .iter() + .any(|r| r.manifest.core.action == Action::Delete); + says_deleted && !ever_trashed + }) + .map(|asset| asset.asset_id) + .collect(); + owed.sort(); + let mut trashed = Vec::new(); + let mut unwritable = Vec::new(); + for id in owed { + let asset = &self.assets[&id]; + let album_id = asset.album_id; + let writable = self + .albums + .get(&album_id) + .is_some_and(|a| a.write_tier.is_some()); + if !writable { + let path = self.sidecar_path(asset); + tracing::warn!( + asset_id = %id, + album_id = %album_id, + "unsigned migration: an owed delete record cannot be written into a read-only album" + ); + unwritable.push(( + path, + MigrationSkip::AlbumReadOnly { + asset_id: id, + album_id, + }, + )); + continue; + } + tracing::info!( + asset_id = %id, + "unsigned migration: applying the delete record an interrupted run owed" + ); + self.soft_delete(&id, retain_days)?; + trashed.push(id); + } + Ok((trashed, unwritable)) + } + + /// Decode one unanchored sidecar and check everything that must hold before it is + /// admitted. Reads the original once, streaming, for the hash check. + fn admit(&self, found: &UnmigratedSidecar) -> std::result::Result { + let stem = found + .path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let asset_id = found.asset_id.ok_or(MigrationSkip::InvalidAssetId(stem))?; + let dir = found + .path + .parent() + .map_or_else(|| self.root.clone(), Path::to_path_buf); + // Every later path for this asset is derived from its bucket, so the bucket must round + // trip: parse the directory's month, and require that the lifecycle maps it back to + // exactly this directory. Nothing is ever moved to make it fit. + if media_dir(&self.root, month_dir_timestamp(&dir)) != dir { + return Err(MigrationSkip::OutsideMonthBucket { asset_id, dir }); + } + + let (bytes, resumed) = match found.shape { + UnmigratedShape::LegacyUnsigned => ( + fs::read(&found.path).map_err(|e| MigrationSkip::Undecodable(e.to_string()))?, + false, + ), + // A signed sidecar with no chain: an interrupted run wrote the sidecar and died + // before the chain. Its quarantine copy is the legacy record; resume from it — but + // only if the sidecar on disk is still that run's own create. A later write sets + // `provenance_chain_hash`, and a signed asset that was never migrated has no fold; + // redoing the create over either would discard signed state. + UnmigratedShape::SignedWithoutChain { .. } => { + let on_disk = + fs::read(&found.path).map_err(|_| MigrationSkip::Stranded(asset_id))?; + let signed = SidecarV1::from_canonical_slice(&on_disk, SIDECAR_SCHEMA_V1) + .map_err(|_| MigrationSkip::Stranded(asset_id))?; + if signed.provenance_chain_hash.is_some() + || !signed.unknown.contains_key(LEGACY_FOLD_KEY) + { + return Err(MigrationSkip::Stranded(asset_id)); + } + (self.quarantine_twin(&asset_id)?, true) + } + // A torn write of the signed sidecar, most likely: the quarantine copy, if there is + // one, is the legacy record and the run resumes from it. + UnmigratedShape::Unknown => ( + self.quarantine_twin(&asset_id) + .map_err(|_| MigrationSkip::UnknownShape)?, + true, + ), + }; + let record = LegacyRecord::decode(&bytes).map_err(MigrationSkip::Undecodable)?; + if Uuid::parse_str(&record.uuid).ok() != Some(asset_id) { + return Err(MigrationSkip::Undecodable(format!( + "record uuid {:?} does not name the file's asset id {asset_id}", + record.uuid + ))); + } + if self.assets.contains_key(&asset_id) { + return Err(MigrationSkip::IdCollision(asset_id)); + } + if !resumed { + let twin = self.quarantine_sidecar_path(&asset_id); + if let Ok(existing) = fs::read(&twin) + && existing != bytes + { + return Err(MigrationSkip::QuarantineConflict(asset_id)); + } + } + let ext = + original_extension(&dir, &asset_id).ok_or(MigrationSkip::OriginalMissing(asset_id))?; + if ext.is_empty() + || !ext + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return Err(MigrationSkip::UnusualExtension { asset_id, ext }); + } + let original = dir.join(format!("{}.{ext}", asset_id.simple())); + let actual = fs::File::open(&original) + .and_then(hash::hash_reader) + .map_err(|_| MigrationSkip::OriginalMissing(asset_id))? + .to_hex(); + if !actual.eq_ignore_ascii_case(&record.hash_sha256) { + return Err(MigrationSkip::HashMismatch { + asset_id, + recorded: record.hash_sha256.clone(), + actual, + }); + } + tracing::debug!( + asset_id = %asset_id, + resumed, + has_stack_hint = record.stack_hint.is_some(), + is_deleted = record.is_deleted, + legacy_album = ?record.album_id, + "unsigned migration: candidate admitted" + ); + Ok(Candidate { + path: found.path.clone(), + dir, + asset_id, + bytes, + record, + ext, + resumed, + }) + } + + /// The legacy `album_id` when it parses and this workspace can write into it, else the + /// fallback. + fn resolve_legacy_album(&self, legacy: Option<&str>, opts: &UnsignedMigrationOptions) -> Uuid { + let held = legacy + .and_then(|s| Uuid::parse_str(s).ok()) + .filter(|id| self.albums.get(id).is_some_and(|a| a.write_tier.is_some())); + if let Some(id) = held { + return id; + } + if let Some(legacy) = legacy { + tracing::debug!( + legacy_album = legacy, + fallback_album = %opts.fallback_album, + "unsigned migration: legacy album not held or not writable; using the fallback" + ); + } + opts.fallback_album + } + + /// Group the admitted members by `(detection_method, detection_key)` and derive one + /// deterministic stack id per group of two or more (`legacy_stack_id`), so an + /// interrupted or repeated run lands every member under the same id. + fn legacy_stack_memberships( + &self, + candidates: &[Candidate], + ) -> BTreeMap { + let mut groups: BTreeMap<(String, String), Vec<(Uuid, &LegacyStackHint)>> = BTreeMap::new(); + for c in candidates { + if let Some(hint) = &c.record.stack_hint { + groups + .entry((hint.detection_method.clone(), hint.detection_key.clone())) + .or_default() + .push((c.asset_id, hint)); + } + } + let user_id = self.account.user_id; + let mut out = BTreeMap::new(); + for ((method, key), mut members) in groups { + if members.len() < 2 { + continue; + } + members.sort_by_key(|(id, _)| *id); + let stack_id = legacy_stack_id(&user_id, &method, &key); + let stack_type = members[0].1.stack_type; + for (index, (asset_id, hint)) in members.iter().enumerate() { + let role = match hint.member_role.as_str() { + "primary" => StackRole::Primary, + "proxy" => StackRole::Proxy, + _ => StackRole::Member, + }; + out.insert( + *asset_id, + StackMembership { + stack_id, + stack_type, + role, + member_index: Some(index as u32), + }, + ); + } + tracing::debug!( + stack_id = %stack_id, + method = %method, + key = %key, + members = members.len(), + "unsigned migration: stack derived from legacy hints" + ); + } + out + } + + /// Copy the legacy bytes to `.library/quarantine/{uuid}.cbor` and write the sibling + /// `.reason.json`, before any signed write. The copy is `fsync`ed and its directory entry + /// made durable ([`sync_dir`], a no-op where the platform has no directory fsync), so the + /// legacy record is on disk before the signed sidecar can replace it (the replacement + /// itself is a single-file atomic rename). A resumed candidate's copy is already there. + fn quarantine_legacy(&self, candidate: &Candidate) -> Result<()> { + let twin = self.quarantine_sidecar_path(&candidate.asset_id); + let dir = twin + .parent() + .ok_or_else(|| LifecycleError::Io("quarantine path has no parent".into()))?; + fs::create_dir_all(dir).map_err(|e| LifecycleError::Io(format!("quarantine dir: {e}")))?; + if !candidate.resumed { + fs::copy(&candidate.path, &twin).map_err(|e| { + LifecycleError::Io(format!("quarantine {}: {e}", candidate.path.display())) + })?; + // A write handle: flushing a file's buffers needs one on every platform. + fs::File::options() + .write(true) + .open(&twin) + .and_then(|f| f.sync_all()) + .map_err(|e| LifecycleError::Io(format!("fsync {}: {e}", twin.display())))?; + sync_dir(dir) + .map_err(|e| LifecycleError::Io(format!("fsync {}: {e}", dir.display())))?; + } + let reason = serde_json::json!({ + "reason": QUARANTINE_REASON, + "migrated_at": super::now_rfc3339(), + "sha256_of_legacy_bytes": hash::hash_bytes(&candidate.bytes).to_hex(), + "source": candidate.path.display().to_string(), + }); + let reason_path = dir.join(format!("{}.reason.json", candidate.asset_id.simple())); + let body = serde_json::to_vec_pretty(&reason) + .map_err(|e| LifecycleError::Io(format!("reason json: {e}")))?; + fs::write(&reason_path, body) + .map_err(|e| LifecycleError::Io(format!("write {}: {e}", reason_path.display())))?; + tracing::debug!( + asset_id = %candidate.asset_id, + quarantine = %twin.display(), + "unsigned migration: legacy bytes preserved" + ); + Ok(()) + } + + /// Admit one candidate through the signed create commit, with its id, its bytes in place, + /// and its media bucket kept. + fn commit_legacy_create( + &mut self, + candidate: &Candidate, + album_id: Uuid, + stack: Option, + ) -> Result<()> { + let record = &candidate.record; + let enrichment = SidecarEnrichment { + capture_time: Some(record.capture_fallback()), + // Not `Exif`: the fix was read out of the legacy record, not out of these file + // bytes, and the sidecar is signed — `Manual` is the honest provenance for a + // record-held coordinate, exactly as the Takeout enrichment tags its own + // (`import::enrichment::sidecar_enrichment`). A file whose EXIF carries a fix + // wins at the write site and is tagged `Exif` there. + gps: record.gps.map(|(lat, lon)| Gps { + lat, + lon, + source: GpsSource::Manual, + datum: GpsDatum::Wgs84, + }), + caption: None, + rating: (record.rating > 0).then_some(record.rating), + tags: record.tags.clone(), + }; + let opts = SignedImportOptions { + move_source: false, + defer_source_release: false, + stack, + enrichment: Some(enrichment), + }; + let mut extra_unknown = BTreeMap::new(); + extra_unknown.insert(LEGACY_FOLD_KEY.to_string(), record.map.clone()); + let import_timestamp = Timestamp::from_second(record.import_timestamp) + .unwrap_or(Timestamp::UNIX_EPOCH) + .to_string(); + let original = + candidate + .dir + .join(format!("{}.{}", candidate.asset_id.simple(), candidate.ext)); + self.commit_signed_create(&CreateRequest { + asset_id: candidate.asset_id, + album_id, + src: &original, + import_timestamp: Some(import_timestamp), + media_bucket: Some(month_dir_timestamp(&candidate.dir)), + extra_unknown, + opts: &opts, + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::super::fast_workspace; + use super::*; + use crate::cbor; + use crate::crypto::primitives::Argon2Params; + use crate::crypto::verify_asset::VerifyOutcome; + use crate::library::{open_library, rebuild_index}; + use crate::sidecar::sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; + + fn fast_params() -> Argon2Params { + Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + } + } + + fn text(s: &str) -> Value { + Value::Text(s.to_string()) + } + + fn int(i: i64) -> Value { + Value::Integer(i.into()) + } + + /// A legacy unsigned record exactly as the retired serializer wrote it: text keys, the + /// required fields, `version: 1`, plus `extra` entries appended. + fn legacy_map(asset_id: Uuid, original: &[u8], extra: Vec<(&str, Value)>) -> Value { + let mut entries = vec![ + ("version", int(1)), + ("uuid", text(&asset_id.to_string())), + ("asset_type", text("photo")), + ("original_filename", text("IMG_0001.JPG")), + ("import_timestamp", int(1_720_000_000)), + ("modified_timestamp", int(1_720_000_000)), + ("hash_sha256", text(&hash::hash_bytes(original).to_hex())), + ("file_size", int(original.len() as i64)), + ("is_deleted", Value::Bool(false)), + ("rating", int(0)), + ("tags", Value::Array(vec![])), + ("import_mode", text("copy")), + ("importer_version", text("0.1.0")), + ("rawshift_version", text("0.1.0")), + ]; + entries.extend(extra); + Value::Map(entries.into_iter().map(|(k, v)| (text(k), v)).collect()) + } + + fn stack_hint(key: &str, role: &str) -> Value { + Value::Map(vec![ + (text("detection_key"), text(key)), + (text("detection_method"), text("filename_stem")), + (text("member_role"), text(role)), + (text("stack_type"), text("raw_jpeg")), + ]) + } + + /// Write a legacy asset — the original `{uuid}.jpg` and its unsigned `{uuid}.cbor` — + /// into `media/1970/1970-01` (where a `None` capture time landed). Returns the sidecar + /// path and the legacy bytes. + fn write_legacy( + root: &Path, + asset_id: Uuid, + original: &[u8], + extra: Vec<(&str, Value)>, + ) -> (PathBuf, Vec) { + write_legacy_in(root, "media/1970/1970-01", "jpg", asset_id, original, extra) + } + + /// As [`write_legacy`], into `rel_dir` under `root`, with the original named + /// `{uuid}.{ext}`. + fn write_legacy_in( + root: &Path, + rel_dir: &str, + ext: &str, + asset_id: Uuid, + original: &[u8], + extra: Vec<(&str, Value)>, + ) -> (PathBuf, Vec) { + let dir = root.join(rel_dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(format!("{}.{ext}", asset_id.simple())), original).unwrap(); + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&legacy_map(asset_id, original, extra), &mut bytes).unwrap(); + let path = dir.join(format!("{}.cbor", asset_id.simple())); + fs::write(&path, &bytes).unwrap(); + (path, bytes) + } + + fn opts(fallback: Uuid) -> UnsignedMigrationOptions { + UnsignedMigrationOptions { + fallback_album: fallback, + trash_retain_days: 30, + } + } + + /// Three legacy assets, one of them the "rich" one (rating, tags, GPS) and one carrying a + /// key no build ever defined. Returns `(rich, future, plain)`. + fn three_legacy_assets(root: &Path) -> (Uuid, Uuid, Uuid) { + let rich = Uuid::from_u128(0xA1); + let future = Uuid::from_u128(0xA2); + let plain = Uuid::from_u128(0xA3); + write_legacy( + root, + rich, + b"\xFF\xD8\xFF rich legacy asset", + vec![ + ("rating", int(4)), + ("tags", Value::Array(vec![text("trip"), text("2024")])), + ("gps_lat", Value::Float(48.8584)), + ("gps_lon", Value::Float(2.2945)), + ("capture_utc", int(1_700_000_000)), + ], + ); + write_legacy( + root, + future, + b"\xFF\xD8\xFF legacy asset from the future", + vec![("future_field", text("kept verbatim"))], + ); + write_legacy(root, plain, b"\xFF\xD8\xFF plain legacy asset", vec![]); + (rich, future, plain) + } + + fn read_signed(ws: &Workspace, id: &Uuid) -> SidecarV1 { + let bytes = fs::read(ws.sidecar_path(ws.asset(id).unwrap())).unwrap(); + SidecarV1::from_canonical_slice(&bytes, SIDECAR_SCHEMA_V1).unwrap() + } + + // ── T1: the acceptance case ───────────────────────────────────────────── + + /// **The `S-D24` acceptance case.** After the verb, every legacy asset is a signed asset: + /// `verify_asset` accepts it, its sidecar verifies under the user IK, its chain, sealed + /// metadata blob, and index row exist, and the legacy rating, tags, GPS, and capture time + /// are carried into their signed homes. + #[test] + fn migrated_assets_verify_and_carry_their_legacy_metadata() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let (rich, future, plain) = three_legacy_assets(lib.path()); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![rich, future, plain]); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + assert!(report.trashed.is_empty()); + assert!(report.stacks.is_empty()); + + let ik = ws.user_ik_public(); + for id in [rich, future, plain] { + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept, "{id}"); + let asset = ws.asset(&id).unwrap(); + assert_eq!(asset.album_id, album); + assert!( + asset.sidecar.verify(&ik), + "sidecar signed under the user IK" + ); + assert!(ws.provenance_path(asset).exists()); + assert!(ws.metadata_blob_path(asset).exists()); + assert!(!asset.metadata_blob.is_empty()); + assert_eq!(asset.chain.records().len(), 1, "one create record"); + assert!( + ws.db().find_by_uuid(&id.to_string()).unwrap().is_some(), + "index row written" + ); + // The files stayed in their legacy bucket. + assert_eq!(asset.capture_utc, 0, "1970-01 bucket"); + assert!(ws.media_path(asset).exists()); + // The sidecar's import time is the legacy import time, not now. + assert_eq!(asset.sidecar.import_timestamp, "2024-07-03T09:46:40Z"); + } + + // The rich record's metadata landed in the signed registers. + let sidecar = read_signed(&ws, &rich); + assert_eq!(sidecar.rating.get(), Some(&4)); + let tags = sidecar.tags_user.value(); + assert!(tags.contains("trip") && tags.contains("2024")); + let gps = sidecar.gps.expect("legacy GPS carried"); + assert_eq!((gps.lat, gps.lon), (48.8584, 2.2945)); + assert_eq!( + gps.source, + GpsSource::Manual, + "a record-held fix is not attributed to these file bytes" + ); + // The legacy capture time is the sidecar's capture timestamp (these bytes carry no + // EXIF, so the fold wins over the import clock). + assert_eq!(sidecar.capture_timestamp, "2023-11-14T22:13:20Z"); + assert_eq!( + ws.db() + .find_by_uuid(&rich.to_string()) + .unwrap() + .unwrap() + .rating, + 4 + ); + assert_eq!(ws.db().tags_for(&rich.to_string()).unwrap().len(), 2); + // A record with no capture time falls back to its legacy import time, never `now`. + assert_eq!( + read_signed(&ws, &plain).capture_timestamp, + "2024-07-03T09:46:40Z" + ); + } + + // ── T2: the never-strip tripwire ──────────────────────────────────────── + + /// **The never-strip tripwire.** The whole legacy map — including a key no build has ever + /// defined — rides in the signed sidecar's `_unknown` under [`LEGACY_FOLD_KEY`], and the + /// signature covers it: the fold is byte-equal to the canonicalised legacy map, and + /// removing the fold (or one key inside it) fails `verify`. A fold that dropped + /// `future_field` would fail the equality assertion below. + #[test] + fn the_legacy_map_is_folded_verbatim_and_covered_by_the_signature() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let (_, future, _) = three_legacy_assets(lib.path()); + let legacy_bytes = fs::read( + lib.path() + .join("media/1970/1970-01") + .join(format!("{}.cbor", future.simple())), + ) + .unwrap(); + + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + + let sidecar = read_signed(&ws, &future); + let fold = sidecar + .unknown + .get(LEGACY_FOLD_KEY) + .expect("the legacy record is folded into _unknown"); + // Byte-equal to the legacy map, canonically re-encoded. + assert_eq!( + cbor::value_to_canonical_vec(fold), + cbor::canonicalize(&legacy_bytes).unwrap(), + "the fold is the entire legacy map, verbatim" + ); + let Value::Map(entries) = fold else { + panic!("the fold is a map"); + }; + assert!( + entries.contains(&(text("future_field"), text("kept verbatim"))), + "a key no build defined survives inside the fold" + ); + assert!( + entries.iter().any(|(k, _)| *k == text("original_filename")), + "fields with no signed home of their own survive inside the fold" + ); + + // The signature covers the fold: stripping it, or a key inside it, invalidates it. + let ik = ws.user_ik_public(); + assert!(sidecar.verify(&ik)); + let mut stripped = sidecar.clone(); + stripped.unknown.remove(LEGACY_FOLD_KEY); + assert!( + !stripped.verify(&ik), + "stripping the fold breaks the signature" + ); + let mut trimmed = sidecar.clone(); + let Some(Value::Map(inner)) = trimmed.unknown.get_mut(LEGACY_FOLD_KEY) else { + panic!("fold present") + }; + inner.retain(|(k, _)| *k != text("future_field")); + assert!( + !trimmed.verify(&ik), + "dropping one legacy key from the fold breaks the signature" + ); + } + + // ── T3: bytes preserved ───────────────────────────────────────────────── + + /// The legacy bytes survive verbatim in quarantine with a reason file, and are still there + /// after a reopen (the startup scrub deletes only `.tmp` files). The original is signed + /// where it lies: its file is not rewritten, which its unchanged mtime proves. + #[test] + fn legacy_bytes_are_preserved_verbatim_in_quarantine() { + use std::time::{Duration, SystemTime}; + + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let id = Uuid::from_u128(0xB1); + let (sidecar_path, legacy_bytes) = + write_legacy(lib.path(), id, b"\xFF\xD8\xFF preserved", vec![]); + let original = lib + .path() + .join("media/1970/1970-01") + .join(format!("{}.jpg", id.simple())); + let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + fs::File::options() + .write(true) + .open(&original) + .unwrap() + .set_modified(long_ago) + .unwrap(); + + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + + assert_eq!( + fs::metadata(&original).unwrap().modified().unwrap(), + long_ago, + "the original is signed in place, never rewritten over itself" + ); + assert_eq!(fs::read(&original).unwrap(), b"\xFF\xD8\xFF preserved"); + + let quarantine = lib.path().join(".library/quarantine"); + let twin = quarantine.join(format!("{}.cbor", id.simple())); + assert_eq!(fs::read(&twin).unwrap(), legacy_bytes, "byte-equal copy"); + let reason: serde_json::Value = serde_json::from_slice( + &fs::read(quarantine.join(format!("{}.reason.json", id.simple()))).unwrap(), + ) + .unwrap(); + assert_eq!(reason["reason"], QUARANTINE_REASON); + assert_eq!( + reason["sha256_of_legacy_bytes"], + hash::hash_bytes(&legacy_bytes).to_hex() + ); + assert!(reason["migrated_at"].is_string()); + // The signed sidecar was written over the legacy one, in place. + assert_ne!(fs::read(&sidecar_path).unwrap(), legacy_bytes); + assert!(SidecarV1::from_canonical_slice(&fs::read(&sidecar_path).unwrap(), 1).is_ok()); + + drop(ws); + let _ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + assert_eq!( + fs::read(&twin).unwrap(), + legacy_bytes, + "a reopen leaves it alone" + ); + } + + /// **Bucket pinning.** A legacy asset in a later month bucket stays exactly where it is: + /// its `AssetState::capture_utc` is that bucket's first instant, every derived path + /// resolves to the files already there, and the original is neither moved nor rewritten. + #[test] + fn a_legacy_asset_in_a_later_bucket_stays_in_it() { + use std::time::{Duration, SystemTime}; + + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let id = Uuid::from_u128(0xB7); + let (sidecar_path, _) = write_legacy_in( + lib.path(), + "media/2024/2024-07", + "jpg", + id, + b"\xFF\xD8\xFF july 2024", + vec![("capture_utc", int(1_720_000_000))], + ); + let original = lib + .path() + .join("media/2024/2024-07") + .join(format!("{}.jpg", id.simple())); + let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + fs::File::options() + .write(true) + .open(&original) + .unwrap() + .set_modified(long_ago) + .unwrap(); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![id]); + let asset = ws.asset(&id).unwrap(); + assert_eq!( + asset.capture_utc, 1_719_792_000, + "2024-07-01T00:00:00Z, the bucket" + ); + assert_eq!( + ws.media_path(asset), + original, + "the lifecycle addresses the file in place" + ); + assert_eq!(ws.sidecar_path(asset), sidecar_path); + assert!( + ws.provenance_path(asset) + .starts_with(lib.path().join("media/2024/2024-07")) + ); + assert_eq!( + fs::metadata(&original).unwrap().modified().unwrap(), + long_ago + ); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + assert_eq!( + read_signed(&ws, &id).capture_timestamp, + "2024-07-03T09:46:40Z" + ); + assert!( + !lib.path().join("media/1970").exists(), + "nothing was relocated" + ); + } + + /// A sidecar outside a `media/{YYYY}/{YYYY-MM}` bucket, or an original whose extension is + /// not lowercase single-segment, cannot be addressed by the lifecycle's derived paths + /// without moving or renaming it — so it is refused, reported, and left exactly as found. + #[test] + fn a_sidecar_outside_a_month_bucket_or_with_an_odd_extension_is_refused() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + + let loose = Uuid::from_u128(0xB8); + let (loose_path, loose_bytes) = write_legacy_in( + lib.path(), + "media/loose", + "jpg", + loose, + b"\xFF\xD8\xFF loose", + vec![], + ); + let shouty = Uuid::from_u128(0xB9); + let (shouty_path, shouty_bytes) = + write_legacy(lib.path(), shouty, b"\xFF\xD8\xFF SHOUTY", vec![]); + let shouty_dir = lib.path().join("media/1970/1970-01"); + fs::rename( + shouty_dir.join(format!("{}.jpg", shouty.simple())), + shouty_dir.join(format!("{}.JPG", shouty.simple())), + ) + .unwrap(); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert!(report.migrated.is_empty()); + let mut skips = report.skipped.clone(); + skips.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + skips, + vec![ + ( + shouty_path.clone(), + MigrationSkip::UnusualExtension { + asset_id: shouty, + ext: "JPG".to_string(), + }, + ), + ( + loose_path.clone(), + MigrationSkip::OutsideMonthBucket { + asset_id: loose, + dir: lib.path().join("media/loose"), + }, + ), + ] + ); + assert_eq!(fs::read(&loose_path).unwrap(), loose_bytes); + assert_eq!(fs::read(&shouty_path).unwrap(), shouty_bytes); + assert!( + !lib.path().join(".library/quarantine").exists(), + "nothing was written" + ); + assert!(ws.asset(&loose).is_none() && ws.asset(&shouty).is_none()); + } + + // ── T4: idempotent ────────────────────────────────────────────────────── + + /// A second run migrates nothing and changes no bytes; a second rebuild yields the same + /// rows and no duplicate stack members. + #[test] + fn a_second_run_is_a_no_op() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let primary = Uuid::from_u128(0xC1); + let raw = Uuid::from_u128(0xC2); + write_legacy( + lib.path(), + primary, + b"\xFF\xD8\xFF stack primary", + vec![("stack_hint", stack_hint("img_0042", "primary"))], + ); + write_legacy( + lib.path(), + raw, + b"\xFF\xD8\xFF stack raw", + vec![("stack_hint", stack_hint("img_0042", "raw"))], + ); + + let first = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(first.migrated.len(), 2); + assert_eq!(first.stacks.len(), 1); + let snapshot = |ws: &Workspace| { + [primary, raw].map(|id| { + let asset = ws.asset(&id).unwrap(); + ( + fs::read(ws.sidecar_path(asset)).unwrap(), + fs::read(ws.provenance_path(asset)).unwrap(), + asset.chain.records().len(), + ) + }) + }; + let before = snapshot(&ws); + let rows_before = ws.db().query_timeline(0, 100).unwrap(); + + let second = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!( + second, + UnsignedMigrationReport::default(), + "nothing left to do" + ); + assert_eq!(snapshot(&ws), before, "no bytes changed"); + assert!(ws.unmigrated_sidecars().is_empty()); + + rebuild_index(&ws.library).unwrap(); + assert_eq!(ws.db().query_timeline(0, 100).unwrap(), rows_before); + let stack_id = first.stacks[0]; + assert_eq!( + ws.db() + .list_stack_members(&stack_id.to_string()) + .unwrap() + .len(), + 2, + "no duplicate stack members after repeated rebuilds" + ); + } + + // ── T5: the open outcome, before and after ────────────────────────────── + + /// Before the verb, `Workspace::open` still succeeds on a library holding unsigned + /// sidecars — the signed asset is restored and verifies — and reports the legacy files + /// through `unmigrated_sidecars`. After the verb and a reopen, nothing is left to report + /// and every asset verifies. + #[test] + fn open_reports_unmigrated_sidecars_until_the_verb_runs() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("signed.jpg"); + fs::write(&img, b"\xFF\xD8\xFF a signed asset").unwrap(); + + let (album, signed) = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + (album, ws.import_asset(album, &img).unwrap()) + }; + let (rich, future, plain) = three_legacy_assets(lib.path()); + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let unmigrated = ws.unmigrated_sidecars(); + assert_eq!(unmigrated.len(), 3); + let mut ids: Vec = unmigrated.iter().filter_map(|u| u.asset_id).collect(); + ids.sort(); + assert_eq!(ids, vec![rich, future, plain]); + assert!( + unmigrated + .iter() + .all(|u| u.shape == UnmigratedShape::LegacyUnsigned) + ); + assert_eq!( + ws.asset_ids(), + vec![signed], + "only the signed asset is restored" + ); + assert_eq!(ws.verify(&signed).unwrap(), VerifyOutcome::Accept); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated.len(), 3); + assert!(ws.unmigrated_sidecars().is_empty()); + drop(ws); + + let ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + assert!(ws.unmigrated_sidecars().is_empty()); + let mut all = ws.asset_ids(); + all.sort(); + let mut expected = vec![signed, rich, future, plain]; + expected.sort(); + assert_eq!(all, expected); + for id in all { + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept, "{id}"); + } + } + + // ── T6: trash carry-over ──────────────────────────────────────────────── + + /// A legacy `is_deleted: true` becomes a signed `delete` record with a retention window: + /// the chain is Create then Delete, the asset is in Recently Deleted and out of the + /// timeline. The `delete` write, like the create, leaves the original untouched (its mtime + /// proves it): no path rewrites an original whose bytes already carry the signed hash. + #[test] + fn a_deleted_legacy_asset_lands_in_trash() { + use std::time::{Duration, SystemTime}; + + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let gone = Uuid::from_u128(0xD1); + let kept = Uuid::from_u128(0xD2); + write_legacy( + lib.path(), + gone, + b"\xFF\xD8\xFF trashed legacy asset", + vec![ + ("is_deleted", Value::Bool(true)), + ("deleted_at", int(1_720_100_000)), + ], + ); + write_legacy(lib.path(), kept, b"\xFF\xD8\xFF kept legacy asset", vec![]); + let gone_original = lib + .path() + .join("media/1970/1970-01") + .join(format!("{}.jpg", gone.simple())); + let long_ago = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + fs::File::options() + .write(true) + .open(&gone_original) + .unwrap() + .set_modified(long_ago) + .unwrap(); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.trashed, vec![gone]); + assert_eq!( + fs::metadata(&gone_original).unwrap().modified().unwrap(), + long_ago, + "neither the create nor the delete rewrote the original" + ); + assert_eq!( + fs::read(&gone_original).unwrap(), + b"\xFF\xD8\xFF trashed legacy asset" + ); + + let actions: Vec = ws + .asset(&gone) + .unwrap() + .chain + .records() + .iter() + .map(|r| r.manifest.core.action) + .collect(); + assert_eq!(actions, vec![Action::Create, Action::Delete]); + let delete = &ws.asset(&gone).unwrap().chain.records()[1].manifest.core; + assert!( + delete.retention_until.is_some(), + "the delete carries retention" + ); + assert_eq!(ws.verify(&gone).unwrap(), VerifyOutcome::Accept); + + let timeline = ws.db().query_timeline(0, 100).unwrap(); + assert_eq!(timeline.len(), 1); + assert_eq!(timeline[0].uuid, kept.to_string()); + let trash = ws.db().query_trash(0, 100).unwrap(); + assert_eq!(trash.len(), 1); + assert_eq!(trash[0].uuid, gone.to_string()); + } + + // ── T7: stack carry-over ──────────────────────────────────────────────── + + /// Two legacy members sharing a `(filename_stem, img_0042)` hint land under one + /// deterministic stack id, written into each signed `stack_membership` register at + /// create: the timeline collapses to the primary, both are in `stack_members`, and the id + /// is a pure function of the user id and the group key. + #[test] + fn legacy_stack_hints_become_one_signed_stack() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let primary = Uuid::from_u128(0xE1); + let raw = Uuid::from_u128(0xE2); + let loner = Uuid::from_u128(0xE3); + write_legacy( + lib.path(), + primary, + b"\xFF\xD8\xFF stack primary", + vec![("stack_hint", stack_hint("img_0042", "primary"))], + ); + write_legacy( + lib.path(), + raw, + b"\xFF\xD8\xFF stack raw", + vec![("stack_hint", stack_hint("img_0042", "raw"))], + ); + // A hint with no partner: no stack, the hint survives only in the fold. + write_legacy( + lib.path(), + loner, + b"\xFF\xD8\xFF lonely hint", + vec![("stack_hint", stack_hint("img_0099", "primary"))], + ); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + let expected = legacy_stack_id(&ws.user_id(), "filename_stem", "img_0042"); + assert_eq!( + report.stacks, + vec![expected], + "a pure function of user id + key" + ); + assert_eq!(expected.get_version_num(), 8, "an RFC 9562 custom (v8) id"); + + for (id, role, index) in [ + (primary, StackRole::Primary, 0), + (raw, StackRole::Member, 1), + ] { + let sidecar = read_signed(&ws, &id); + let membership = sidecar + .stack_membership + .get() + .and_then(Option::as_ref) + .expect("the register is written at create"); + assert_eq!(membership.stack_id, expected); + assert_eq!(membership.role, role); + assert_eq!(membership.member_index, Some(index)); + assert_eq!(membership.stack_type, StackType::RawJpeg); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + } + assert_eq!(read_signed(&ws, &loner).stack_membership.get(), None); + + let timeline = ws.db().query_timeline(0, 100).unwrap(); + let mut shown: Vec = timeline.iter().map(|r| r.uuid.clone()).collect(); + shown.sort(); + let mut want = vec![primary.to_string(), loner.to_string()]; + want.sort(); + assert_eq!(shown, want, "the raw member is collapsed under the primary"); + assert_eq!( + ws.db() + .list_stack_members(&expected.to_string()) + .unwrap() + .len(), + 2 + ); + + // Determinism across libraries: the same user migrating a copy derives the same id. + let copy = TempDir::new().unwrap(); + let mut ws2 = fast_workspace(copy.path()); + let album2 = ws2.create_album("Imports").unwrap(); + write_legacy( + copy.path(), + primary, + b"\xFF\xD8\xFF stack primary", + vec![("stack_hint", stack_hint("img_0042", "primary"))], + ); + write_legacy( + copy.path(), + raw, + b"\xFF\xD8\xFF stack raw", + vec![("stack_hint", stack_hint("img_0042", "raw"))], + ); + let report2 = ws2.migrate_unsigned_sidecars(&opts(album2)).unwrap(); + assert_eq!( + report2.stacks, + vec![legacy_stack_id(&ws2.user_id(), "filename_stem", "img_0042")] + ); + assert_ne!( + report2.stacks, report.stacks, + "a different user derives a different id" + ); + } + + // ── T8: refusals ──────────────────────────────────────────────────────── + + /// A missing original or a hash mismatch is refused with nothing written — the sidecar + /// bytes, the quarantine directory, and the workspace's assets are all untouched — while + /// an unknown legacy `album_id` lands the asset in the fallback album. + #[test] + fn refusals_write_nothing_and_unknown_albums_fall_back() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + + let orphan = Uuid::from_u128(0xF1); + let (orphan_path, orphan_bytes) = + write_legacy(lib.path(), orphan, b"\xFF\xD8\xFF orphaned", vec![]); + fs::remove_file( + lib.path() + .join("media/1970/1970-01") + .join(format!("{}.jpg", orphan.simple())), + ) + .unwrap(); + + let corrupt = Uuid::from_u128(0xF2); + let (corrupt_path, corrupt_bytes) = + write_legacy(lib.path(), corrupt, b"\xFF\xD8\xFF as recorded", vec![]); + fs::write( + lib.path() + .join("media/1970/1970-01") + .join(format!("{}.jpg", corrupt.simple())), + b"\xFF\xD8\xFF silently altered", + ) + .unwrap(); + + let stray = Uuid::from_u128(0xF3); + write_legacy( + lib.path(), + stray, + b"\xFF\xD8\xFF unknown album", + vec![("album_id", text(&Uuid::from_u128(0xBAD).to_string()))], + ); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![stray]); + assert_eq!(ws.asset(&stray).unwrap().album_id, album, "fallback album"); + + let mut skips: Vec<(PathBuf, MigrationSkip)> = report.skipped.clone(); + skips.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(skips.len(), 2); + assert_eq!( + skips[0], + (orphan_path.clone(), MigrationSkip::OriginalMissing(orphan)) + ); + assert!(matches!( + &skips[1].1, + MigrationSkip::HashMismatch { asset_id, .. } if *asset_id == corrupt + )); + assert_eq!(skips[1].0, corrupt_path); + + // Nothing was written for either refusal. + assert_eq!(fs::read(&orphan_path).unwrap(), orphan_bytes); + assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_bytes); + assert!(ws.asset(&orphan).is_none()); + assert!(ws.asset(&corrupt).is_none()); + let quarantine = lib.path().join(".library/quarantine"); + assert!( + !quarantine + .join(format!("{}.cbor", orphan.simple())) + .exists() + ); + assert!( + !quarantine + .join(format!("{}.cbor", corrupt.simple())) + .exists() + ); + // They are still reported as unmigrated, and the closing `rebuild_index` indexed + // neither: a refusal writes nothing, the index included. + assert_eq!(ws.unmigrated_sidecars().len(), 2); + assert!(ws.db().find_by_uuid(&orphan.to_string()).unwrap().is_none()); + assert!( + ws.db() + .find_by_uuid(&corrupt.to_string()) + .unwrap() + .is_none() + ); + } + + /// A read-only fallback album (recovered from a backup: content keys, no write + /// capability) is a typed refusal before anything is written. + #[test] + fn a_read_only_fallback_album_is_refused_before_any_write() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + // Strip the write capability, exactly the shape `import_backup` restores. + ws.albums.get_mut(&album).unwrap().write_tier = None; + let id = Uuid::from_u128(0xF4); + let (path, bytes) = write_legacy(lib.path(), id, b"\xFF\xD8\xFF read-only", vec![]); + + assert!(matches!( + ws.migrate_unsigned_sidecars(&opts(album)), + Err(LifecycleError::AlbumReadOnly(a)) if a == album + )); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert!(!lib.path().join(".library/quarantine").exists()); + assert!(ws.asset(&id).is_none()); + + // An album the workspace does not hold at all is `NotFound`, not a minted album. + assert!(matches!( + ws.migrate_unsigned_sidecars(&opts(Uuid::from_u128(0x404))), + Err(LifecycleError::NotFound(_)) + )); + assert_eq!(ws.albums().len(), 1); + } + + /// A signed asset with the legacy id already restored is refused: migrating over it would + /// replace a signed record with a re-derived one. + #[test] + fn a_colliding_signed_asset_is_refused() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("signed.jpg"); + fs::write(&img, b"\xFF\xD8\xFF a signed asset").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let signed = ws.import_asset(album, &img).unwrap(); + // A legacy sidecar claiming the same id, in a different bucket. + let (path, bytes) = write_legacy(lib.path(), signed, b"\xFF\xD8\xFF impostor", vec![]); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert!(report.migrated.is_empty()); + assert_eq!( + report.skipped, + vec![(path.clone(), MigrationSkip::IdCollision(signed))] + ); + assert_eq!(fs::read(&path).unwrap(), bytes); + assert_eq!(ws.verify(&signed).unwrap(), VerifyOutcome::Accept); + } + + // ── resumability ──────────────────────────────────────────────────────── + + /// An interrupted run that wrote the signed sidecar but died before the chain leaves a + /// signed sidecar with no `.provenance.cbor` and a quarantine twin. `open` reports it as + /// `SignedWithoutChain`; the next run resumes from the quarantine copy and lands a + /// verifying asset. + #[test] + fn an_interrupted_migration_resumes_from_quarantine() { + let lib = TempDir::new().unwrap(); + let id = Uuid::from_u128(0x1A); + let album = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + write_legacy(lib.path(), id, b"\xFF\xD8\xFF interrupted", vec![]); + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + // Simulate the crash window: the chain never made it to disk. + fs::remove_file(ws.provenance_path(ws.asset(&id).unwrap())).unwrap(); + album + }; + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + assert!(ws.asset(&id).is_none(), "no chain, not restored"); + assert_eq!(ws.unmigrated_sidecars().len(), 1); + assert_eq!( + ws.unmigrated_sidecars()[0].shape, + UnmigratedShape::SignedWithoutChain { schema: 1 } + ); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![id]); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + assert!(ws.unmigrated_sidecars().is_empty()); + let sidecar = read_signed(&ws, &id); + assert!(sidecar.unknown.contains_key(LEGACY_FOLD_KEY)); + } + + /// A migrated asset that was *edited* after migration and then lost its chain is not an + /// interrupted create: redoing the create would discard the signed edit. It is refused as + /// stranded and its sidecar bytes are left exactly as found. + #[test] + fn a_chainless_sidecar_carrying_a_later_write_is_not_resumed() { + let lib = TempDir::new().unwrap(); + let id = Uuid::from_u128(0x1B); + let (album, sidecar_path, edited_bytes) = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + write_legacy(lib.path(), id, b"\xFF\xD8\xFF edited later", vec![]); + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + ws.tag_add(&id, "kept").unwrap(); + let path = ws.sidecar_path(ws.asset(&id).unwrap()); + fs::remove_file(ws.provenance_path(ws.asset(&id).unwrap())).unwrap(); + let bytes = fs::read(&path).unwrap(); + (album, path, bytes) + }; + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert!(report.migrated.is_empty()); + assert_eq!( + report.skipped, + vec![(sidecar_path.clone(), MigrationSkip::Stranded(id))] + ); + assert_eq!( + fs::read(&sidecar_path).unwrap(), + edited_bytes, + "left as found" + ); + assert!( + lib.path() + .join(".library/quarantine") + .join(format!("{}.cbor", id.simple())) + .exists() + ); + } + + /// A torn write of the signed sidecar leaves bytes that are neither shape. With the + /// quarantine copy present the run resumes from it; without one, the file is reported as + /// unknown and left alone. + #[test] + fn a_torn_sidecar_resumes_from_quarantine_or_is_reported_unknown() { + let lib = TempDir::new().unwrap(); + let torn = Uuid::from_u128(0x1C); + let album = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + write_legacy(lib.path(), torn, b"\xFF\xD8\xFF torn write", vec![]); + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + let asset = ws.asset(&torn).unwrap(); + let sidecar = ws.sidecar_path(asset); + let bytes = fs::read(&sidecar).unwrap(); + fs::write(&sidecar, &bytes[..bytes.len() / 2]).unwrap(); + fs::remove_file(ws.provenance_path(asset)).unwrap(); + album + }; + // And a garbage sidecar with no twin at all. + let garbage = Uuid::from_u128(0x1D); + let garbage_path = lib + .path() + .join("media/1970/1970-01") + .join(format!("{}.cbor", garbage.simple())); + fs::write(&garbage_path, b"\xFF\x00 not cbor").unwrap(); + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + assert!( + ws.unmigrated_sidecars() + .iter() + .all(|u| u.shape == UnmigratedShape::Unknown) + ); + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![torn]); + assert_eq!(ws.verify(&torn).unwrap(), VerifyOutcome::Accept); + assert_eq!( + report.skipped, + vec![(garbage_path.clone(), MigrationSkip::UnknownShape)] + ); + assert_eq!(fs::read(&garbage_path).unwrap(), b"\xFF\x00 not cbor"); + } + + /// The signed sidecar replaces the legacy one by a single-file atomic rename: a failure + /// between the `.tmp` write and the rename leaves the legacy sidecar byte-for-byte intact + /// (and its quarantine copy in place), and the next run completes the migration. + #[test] + fn a_failure_before_the_sidecar_rename_leaves_the_legacy_sidecar_intact() { + use super::super::import::with_sidecar_rename_fault; + + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let id = Uuid::from_u128(0x1A7); + let (sidecar_path, legacy_bytes) = + write_legacy(lib.path(), id, b"\xFF\xD8\xFF renamed into place", vec![]); + + let failed = with_sidecar_rename_fault(|| ws.migrate_unsigned_sidecars(&opts(album))); + assert!( + matches!(failed, Err(LifecycleError::Io(ref m)) if m.contains("injected fault")), + "{failed:?}" + ); + assert_eq!( + fs::read(&sidecar_path).unwrap(), + legacy_bytes, + "the legacy sidecar is untouched: only a `.tmp` was written" + ); + assert!(ws.asset(&id).is_none(), "nothing was admitted"); + assert_eq!( + fs::read(ws.quarantine_sidecar_path(&id)).unwrap(), + legacy_bytes, + "the quarantine copy landed before the signed write started" + ); + + // The rerun finds a legacy sidecar whose quarantine twin matches, and completes. + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![id]); + assert_eq!(ws.verify(&id).unwrap(), VerifyOutcome::Accept); + assert!( + !sidecar_path.with_extension("cbor.tmp").exists(), + "the rename consumed the staged file" + ); + } + + /// A crash between the create and the `delete` record leaves an admitted asset whose fold + /// says `is_deleted` but whose chain does not. The next run applies the delete it owes — + /// and never re-deletes an asset the user has since restored from trash by hand. + #[test] + fn an_owed_delete_record_is_applied_on_the_next_run_but_a_restore_is_respected() { + let lib = TempDir::new().unwrap(); + let owed = Uuid::from_u128(0x1E); + let restored = Uuid::from_u128(0x1F); + let album = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + for id in [owed, restored] { + write_legacy( + lib.path(), + id, + &[b"\xFF\xD8\xFF deleted ".as_slice(), id.as_bytes()].concat(), + vec![("is_deleted", Value::Bool(true))], + ); + } + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.trashed, vec![owed, restored]); + // Simulate the crash window for `owed`: the chain holds the create only. + let asset = ws.asset(&owed).unwrap(); + let create_only = + cbor::to_canonical_vec(&vec![asset.chain.records()[0].clone()]).unwrap(); + fs::write(ws.provenance_path(asset), create_only).unwrap(); + // The user restores `restored` by hand. + ws.restore(&restored).unwrap(); + album + }; + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + assert!(ws.unmigrated_sidecars().is_empty(), "both are anchored"); + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert!(report.migrated.is_empty()); + assert_eq!(report.trashed, vec![owed], "the owed delete, and only that"); + let actions = |ws: &Workspace, id: &Uuid| -> Vec { + ws.asset(id) + .unwrap() + .chain + .records() + .iter() + .map(|r| r.manifest.core.action) + .collect() + }; + assert_eq!(actions(&ws, &owed), vec![Action::Create, Action::Delete]); + assert_eq!( + actions(&ws, &restored), + vec![Action::Create, Action::Delete, Action::TrashRestore], + "a hand restore is left alone" + ); + let trash: Vec = ws + .db() + .query_trash(0, 100) + .unwrap() + .into_iter() + .map(|r| r.uuid) + .collect(); + assert_eq!(trash, vec![owed.to_string()]); + // Idempotent from here. + assert_eq!( + ws.migrate_unsigned_sidecars(&opts(album)).unwrap(), + UnsignedMigrationReport::default() + ); + } + + /// An owed delete whose album has since lost its write capability is reported, not + /// written, and does not abort the run: the other candidates still migrate. + #[test] + fn an_owed_delete_into_a_read_only_album_is_reported_not_fatal() { + let lib = TempDir::new().unwrap(); + let owed = Uuid::from_u128(0x3A); + let fresh = Uuid::from_u128(0x3B); + let (album, fallback) = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Recovered").unwrap(); + let fallback = ws.create_album("Imports").unwrap(); + write_legacy( + lib.path(), + owed, + b"\xFF\xD8\xFF owed into read-only", + vec![("is_deleted", Value::Bool(true))], + ); + assert_eq!( + ws.migrate_unsigned_sidecars(&opts(album)).unwrap().trashed, + vec![owed] + ); + // The crash window: the chain holds the create only. + let asset = ws.asset(&owed).unwrap(); + let create_only = + cbor::to_canonical_vec(&vec![asset.chain.records()[0].clone()]).unwrap(); + fs::write(ws.provenance_path(asset), create_only).unwrap(); + (album, fallback) + }; + write_legacy(lib.path(), fresh, b"\xFF\xD8\xFF still migrates", vec![]); + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + // The album the owed asset lives in is read-only now (the backup-recovered shape). + ws.albums.get_mut(&album).unwrap().write_tier = None; + let owed_sidecar = ws.sidecar_path(ws.asset(&owed).unwrap()); + + let report = ws.migrate_unsigned_sidecars(&opts(fallback)).unwrap(); + assert_eq!(report.migrated, vec![fresh], "the run went on"); + assert!(report.trashed.is_empty()); + assert_eq!( + report.skipped, + vec![( + owed_sidecar, + MigrationSkip::AlbumReadOnly { + asset_id: owed, + album_id: album, + }, + )] + ); + assert_eq!( + ws.asset(&owed).unwrap().chain.records().len(), + 1, + "no delete was authored into the read-only album" + ); + } + + /// Ids and names are checked before anything is written: the same id in two month + /// buckets admits one and refuses the other; a record whose `uuid` field does not name + /// its file is refused; a stem that is not a UUID is refused; and a quarantine twin that + /// holds *different* bytes is a conflict, not something to overwrite. + #[test] + fn duplicate_ids_mismatched_records_and_conflicting_twins_are_refused() { + let lib = TempDir::new().unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + + // The same id in two buckets. + let twice = Uuid::from_u128(0x2B); + write_legacy(lib.path(), twice, b"\xFF\xD8\xFF first bucket", vec![]); + let other_dir = lib.path().join("media/2024/2024-07"); + fs::create_dir_all(&other_dir).unwrap(); + let second_original = b"\xFF\xD8\xFF second bucket"; + fs::write( + other_dir.join(format!("{}.jpg", twice.simple())), + second_original, + ) + .unwrap(); + let mut second = Vec::new(); + ciborium::ser::into_writer(&legacy_map(twice, second_original, vec![]), &mut second) + .unwrap(); + let second_path = other_dir.join(format!("{}.cbor", twice.simple())); + fs::write(&second_path, &second).unwrap(); + + // A record naming a different uuid than its file. + let misnamed = Uuid::from_u128(0x2C); + let (misnamed_path, _) = write_legacy( + lib.path(), + misnamed, + b"\xFF\xD8\xFF misnamed", + vec![("uuid", text(&Uuid::from_u128(0x2CC).to_string()))], + ); + + // A stem that is not a uuid. + let not_an_id = lib.path().join("media/1970/1970-01/not-an-asset-id.cbor"); + let mut junk = Vec::new(); + ciborium::ser::into_writer(&legacy_map(Uuid::from_u128(0x2D), b"x", vec![]), &mut junk) + .unwrap(); + fs::write(¬_an_id, &junk).unwrap(); + + // A quarantine twin holding different bytes. + let conflicted = Uuid::from_u128(0x2E); + let (conflicted_path, conflicted_bytes) = + write_legacy(lib.path(), conflicted, b"\xFF\xD8\xFF conflicted", vec![]); + let quarantine = lib.path().join(".library/quarantine"); + fs::create_dir_all(&quarantine).unwrap(); + fs::write( + quarantine.join(format!("{}.cbor", conflicted.simple())), + b"\xA1\x67version\x01", + ) + .unwrap(); + + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!(report.migrated, vec![twice]); + assert_eq!( + ws.read_plaintext(&twice).unwrap(), + b"\xFF\xD8\xFF first bucket", + "the 1970 bucket sorts first" + ); + let mut skips = report.skipped.clone(); + skips.sort_by(|a, b| a.0.cmp(&b.0)); + let mut want = vec![ + (second_path.clone(), MigrationSkip::IdCollision(twice)), + ( + conflicted_path.clone(), + MigrationSkip::QuarantineConflict(conflicted), + ), + ( + not_an_id.clone(), + MigrationSkip::InvalidAssetId("not-an-asset-id".to_string()), + ), + ]; + want.sort_by(|a, b| a.0.cmp(&b.0)); + let misnamed_skip = skips + .iter() + .position(|(p, _)| *p == misnamed_path) + .expect("the misnamed record is refused"); + assert!( + matches!(&skips[misnamed_skip].1, MigrationSkip::Undecodable(m) if m.contains("does not name")) + ); + skips.remove(misnamed_skip); + assert_eq!(skips, want); + assert_eq!(fs::read(&second_path).unwrap(), second, "untouched"); + assert_eq!( + fs::read(&conflicted_path).unwrap(), + conflicted_bytes, + "untouched" + ); + assert!(ws.asset(&misnamed).is_none()); + assert!(ws.asset(&conflicted).is_none()); + } + + /// A signed sidecar with no chain and no quarantine copy is nothing this verb can rebuild: + /// reported as stranded, never touched. + #[test] + fn a_stranded_signed_sidecar_is_reported_not_touched() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("signed.jpg"); + fs::write(&img, b"\xFF\xD8\xFF loses its chain").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + let id = ws.import_asset(album, &img).unwrap(); + let chain = ws.provenance_path(ws.asset(&id).unwrap()); + let sidecar_path = ws.sidecar_path(ws.asset(&id).unwrap()); + fs::remove_file(&chain).unwrap(); + let bytes = fs::read(&sidecar_path).unwrap(); + drop(ws); + + let mut ws = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let report = ws.migrate_unsigned_sidecars(&opts(album)).unwrap(); + assert_eq!( + report.skipped, + vec![(sidecar_path.clone(), MigrationSkip::Stranded(id))] + ); + assert_eq!(fs::read(&sidecar_path).unwrap(), bytes); + } + + // ── keyless rebuild on the un-migrated fixture ────────────────────────── + + /// A keyless `rebuild_index` over an un-migrated library succeeds, indexes the signed + /// asset, and indexes nothing for the legacy files — the one user-visible regression the + /// deletion of the unsigned reader accepts, and the reason the verb exists. + #[test] + fn keyless_rebuild_reports_legacy_files_and_indexes_none_of_them() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let img = src.path().join("signed.jpg"); + fs::write(&img, b"\xFF\xD8\xFF a signed asset").unwrap(); + let signed = { + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Imports").unwrap(); + ws.import_asset(album, &img).unwrap() + }; + let (rich, future, plain) = three_legacy_assets(lib.path()); + + fs::remove_file(lib.path().join("index/library.sqlite")).unwrap(); + let library = open_library(lib.path()).unwrap(); + rebuild_index(&library).unwrap(); + + assert!( + library + .db + .find_by_uuid(&signed.to_string()) + .unwrap() + .is_some() + ); + for id in [rich, future, plain] { + assert!( + library.db.find_by_uuid(&id.to_string()).unwrap().is_none(), + "a keyless rebuild cannot sign, so it indexes no legacy asset" + ); + } + assert_eq!(library.db.query_timeline(0, 100).unwrap().len(), 1); + } + + // ── the private decoder ───────────────────────────────────────────────── + + #[test] + fn the_legacy_decoder_projects_its_fields_and_keeps_the_map() { + let id = Uuid::from_u128(0x2A); + let map = legacy_map( + id, + b"bytes", + vec![ + ("rating", int(3)), + ("tags", Value::Array(vec![text("a")])), + ("capture_timestamp", int(1_600_000_000)), + ("capture_utc", Value::Null), + ("gps_lat", Value::Float(1.5)), + ("gps_lon", Value::Float(-2.5)), + ("stack_hint", stack_hint("k", "raw")), + ("album_id", text("not-a-uuid")), + ("mystery", Value::Bytes(vec![1, 2, 3])), + ], + ); + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&map, &mut bytes).unwrap(); + let record = LegacyRecord::decode(&bytes).unwrap(); + assert_eq!(record.uuid, id.to_string()); + assert_eq!(record.import_timestamp, 1_720_000_000); + assert_eq!(record.rating, 3); + assert_eq!(record.tags, vec!["a".to_string()]); + assert_eq!(record.capture_utc, None, "null counts as absent"); + assert_eq!(record.capture_timestamp, Some(1_600_000_000)); + assert_eq!(record.gps, Some((1.5, -2.5))); + assert_eq!(record.album_id.as_deref(), Some("not-a-uuid")); + assert!(!record.is_deleted); + let hint = record.stack_hint.as_ref().unwrap(); + assert_eq!( + (hint.detection_key.as_str(), hint.member_role.as_str()), + ("k", "raw") + ); + assert_eq!(hint.detection_method, "filename_stem"); + assert_eq!(hint.stack_type, StackType::RawJpeg); + assert_eq!( + record.map, map, + "the whole map is kept, unknown keys included" + ); + // Precedence inside the fallback: capture_utc, then capture_timestamp, then import. + assert_eq!(record.capture_fallback().as_second(), 1_600_000_000); + + // Required fields are required; a wrong type is an error, not a default. + let mut missing = Vec::new(); + ciborium::ser::into_writer( + &Value::Map(vec![(text("version"), int(1)), (text("uuid"), text("x"))]), + &mut missing, + ) + .unwrap(); + assert!( + LegacyRecord::decode(&missing) + .unwrap_err() + .contains("hash_sha256") + ); + let mut wrong = Vec::new(); + ciborium::ser::into_writer( + &legacy_map(id, b"bytes", vec![("rating", text("four"))]), + &mut wrong, + ) + .unwrap(); + assert!(LegacyRecord::decode(&wrong).unwrap_err().contains("rating")); + assert!(LegacyRecord::decode(b"garbage").is_err()); + } +} diff --git a/capsule-core/src/lifecycle/mod.rs b/capsule-core/src/lifecycle/mod.rs index e90fd03c..a208af8c 100644 --- a/capsule-core/src/lifecycle/mod.rs +++ b/capsule-core/src/lifecycle/mod.rs @@ -21,15 +21,17 @@ //! ([`rotate_epoch`](Workspace::rotate_epoch)); the MLS membership ceremony (`Welcome`, //! add/remove) remains deferred (see `SLICES.md`). //! -//! [`verify_asset`]: crate::crypto::verify_asset +//! [`verify_asset`]: fn@crate::crypto::verify_asset //! [`ReferenceAuthority`]: crate::crypto::authority::ReferenceAuthority mod album; mod backup; +mod derivatives; mod drops; mod groups; mod import; mod metadata; +mod migrate_unsigned; mod open; mod organize; mod provenance; @@ -46,6 +48,10 @@ use thiserror::Error; use uuid::Uuid; use self::drops::{InboxEntry, IssuedLink}; +pub use self::migrate_unsigned::{ + LEGACY_FOLD_KEY, MigrationSkip, UnmigratedShape, UnmigratedSidecar, UnsignedMigrationOptions, + UnsignedMigrationReport, +}; pub use self::open::HardwareDekBinding; pub use self::sync_apply::{QuarantineReason, RemoteAssetFacts, RemoteEntry, SyncApplyOutcome}; pub use self::upload::{DerivativeBlob, UploadBundle}; @@ -62,7 +68,7 @@ use crate::crypto::verify_asset::{MetadataBinding, VerifyOutcome}; use crate::db::DatabaseDriver; use crate::drop::{DropId, UploadLinkId}; use crate::federation::AlbumGroupAssertion; -use crate::library::Library; +use crate::library::{Library, LibraryError}; use crate::metadata::crdt::Counter; use crate::sharing::{ShareLinkId, ShareLinkRecord}; use crate::sidecar::sidecar_v1::{Gps, SidecarV1, StackMembership, StackRole}; @@ -96,6 +102,11 @@ pub enum LifecycleError { /// Library index (SQLite) error. #[error("db: {0}")] Db(String), + /// The on-disk library could not be opened. Typed rather than stringified so a caller + /// can act on the one open failure with its own recovery — a catalog stamped by a newer + /// build ([`LibraryError::CatalogTooNew`], slice `S-D23`) — instead of matching on text. + #[error("open library: {0}")] + Library(#[from] LibraryError), /// The durable album-key store could not be read or written (slice `S-A10`). Never /// swallowed: losing album keys silently is exactly the failure this store exists to fix. #[error(transparent)] @@ -203,7 +214,7 @@ pub struct AssetState { /// [`Workspace::set_stack_membership`] write. It survives here for the one case the register /// cannot serve — an asset imported **before** `S-B15`, whose placement was written only to the /// index and therefore exists nowhere else (see [`Workspace::open`] step (6) and -/// [`library::rebuild`](crate::library::rebuild)). +/// [`library::rebuild_index`](crate::library::rebuild_index)). #[derive(Debug, Clone)] pub struct StackPlacement { /// The shared stack id (the `asset_stacks` row id the members belong to). @@ -224,8 +235,8 @@ impl StackPlacement { } } -/// Out-of-band metadata a third-party [source adapter](crate::import::importers) folded for one -/// media file, in the shape the signed sidecar stores it (slice `S-B10`). +/// Out-of-band metadata a third-party [source adapter](crate::import::SourceAdapter) folded for +/// one media file, in the shape the signed sidecar stores it (slice `S-B10`). /// /// The [precedence rule] is resolved in two places, and this type is what keeps the two halves /// apart: @@ -242,7 +253,7 @@ impl StackPlacement { /// /// Every field left empty writes nothing, so an import carrying no exporter record produces a /// sidecar byte-identical to a plain filesystem import's. The provider-specific mapping that -/// fills this in lives in [`import::enrichment`](crate::import::enrichment). +/// fills this in lives in [`import::sidecar_enrichment`](crate::import::sidecar_enrichment). /// /// [precedence rule]: https://docs/design/import/pipeline/#third-party-importers #[derive(Debug, Clone, Default, PartialEq)] @@ -278,9 +289,9 @@ pub struct SignedImportOptions { /// row. `None` imports a standalone asset and leaves the register wire-absent. pub stack: Option, /// Folded third-party exporter metadata for this file (`S-B10`), attached by the - /// [executor](crate::import::executor::execute_with_source_metadata) when the import came - /// from a [source adapter](crate::import::importers). `None` — a plain filesystem import — - /// leaves every enriched field exactly as it was before the slice. + /// [executor](crate::import::execute_with_source_metadata) when the import came + /// from a [source adapter](crate::import::SourceAdapter). `None` — a plain filesystem + /// import — leaves every enriched field exactly as it was before the slice. pub enrichment: Option, } @@ -295,29 +306,34 @@ pub struct SignedImportOptions { /// is a real problem someone should look at. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DerivativeStatus { - /// The still decoded: dimensions and LQIP came from real pixels, and signed derivatives - /// were generated if a [`StillEncoder`](crate::media::image::derivative::StillEncoder) is - /// attached to the workspace. + /// The still decoded: `dimensions` and `lqip` came from real pixels, and the derivatives + /// this build can encode were generated and signed. **Independent of how many *formats* + /// deferred** — a decoded still whose AVIF and WebP variants have no encoder here is still + /// `Decoded`, because it has a renderable JXL thumbnail. The per-format gap is counted + /// separately by + /// [`ImportExecutionSummary::deferred_format_count`](crate::import::ImportExecutionSummary::deferred_format_count). Decoded, /// **Expected deferral.** This build links no codec for the asset's format — see - /// [`SUPPORTED_IMAGE_FORMATS`](crate::media::image::types::SUPPORTED_IMAGE_FORMATS). The + /// [`SUPPORTED_STILL_FORMATS`](crate::media::SUPPORTED_STILL_FORMATS) for what it does + /// link, and [`StillFormat`](crate::media::StillFormat) for what it recognises. The /// original is safely backed up; dimensions fall back to EXIF and there is no LQIP or - /// preview until the codec lands, at which point derivatives can be backfilled from the + /// thumbnail until the codec lands, at which point derivatives can be backfilled from the /// stored original. Counted by - /// [`ImportExecutionSummary::deferred_derivative_count`](crate::import::progress::ImportExecutionSummary::deferred_derivative_count). + /// [`ImportExecutionSummary::deferred_derivative_count`](crate::import::ImportExecutionSummary::deferred_derivative_count). /// - /// A build compiled without the `media` feature has no codecs at all, so every asset it - /// imports reports this. + /// What reaches here today: HEIC, AVIF and the RAW families, each needing a system library + /// (libheif, libdav1d) or an assembler the cross and cargo-ndk builds do not carry. DeferredNoCodec, /// **A real problem.** The format *is* one this build can decode, but these particular /// bytes did not decode — truncation, corruption, or a decoder bug. The original is still /// imported (the bytes are backed up verbatim, whatever they are), but this is worth /// investigating rather than shrugging at. DecodeFailed, - /// Nothing to decode: the extension names no still image this build models — a video, an - /// XMP sidecar, an unknown suffix, or an exotic RAW flavour - /// [`RawImageFormat`](crate::media::image::types::RawImageFormat) has no variant for. Video - /// derivatives are generated on their own path. + /// Nothing to decode: neither the bytes' header nor the extension names a still image + /// Capsule models — a video, an XMP sidecar, an SVG, or an unknown suffix. Distinct from + /// [`DeferredNoCodec`](Self::DeferredNoCodec), which is a still whose codec is merely + /// absent and whose derivatives are therefore backfillable. Video derivatives are generated + /// on their own path (slice `S-B5`). NotAKnownStill, } @@ -342,13 +358,19 @@ pub struct SignedImport { pub asset_id: Uuid, /// Whether thumbnail/preview derivatives were generated, and if not, why. pub derivatives: DerivativeStatus, + /// How many `(tier, format)` pairs the tier table commits to and this build cannot encode + /// — the per-format half of the `S-B13` gap, which is orthogonal to + /// [`derivatives`](Self::derivatives): a `Decoded` asset can still carry deferred formats. + /// Zero when the still did not decode at all, because nothing was attempted. + pub deferred_formats: u32, } -/// A streamed import: everything the [streaming window](crate::import::streaming) needs about one -/// just-imported asset to drive its upload → verify → release step, without exposing workspace -/// internals. Produced by [`Workspace::import_asset_streaming`], which commits on the signed path -/// with source release **deferred** to the server-side verify-before-destroy gate (`S-D4`), since -/// in streaming mode the local bytes are the only copy until the *server* durably holds them. +/// A streamed import: everything the [streaming window](crate::import::execute_streaming) needs +/// about one just-imported asset to drive its upload → verify → release step, without exposing +/// workspace internals. Produced by [`Workspace::import_asset_streaming`], which commits on the +/// signed path with source release **deferred** to the server-side verify-before-destroy gate +/// (`S-D4`), since in streaming mode the local bytes are the only copy until the *server* durably +/// holds them. #[derive(Debug, Clone)] pub struct StreamedImport { /// The imported asset's id. @@ -383,7 +405,8 @@ pub struct Workspace { albums: HashMap, /// Per-album write authority behind the [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) /// seam (`&Authority` coerces to `&dyn AlbumAuthority` at every `verify_asset` call site). The - /// offline [`ReferenceAuthority`] is the shipped default; the enum lets the live + /// offline [`ReferenceAuthority`](crate::crypto::authority::ReferenceAuthority) is the shipped + /// default; the enum lets the live /// [`OpenMlsAuthority`](crate::crypto::authority::OpenMlsAuthority) drop in without the /// lifecycle naming a concrete backend. **Persisted** alongside the album keys in /// [`AlbumStore`](crate::crypto::keys::AlbumStore) and restored on open (`S-A10`) — without @@ -436,6 +459,11 @@ pub struct Workspace { /// **Deliberately session-scoped** (`S-A10`): the server's staging store is the authority and /// a client refills this from it, so there is nothing here to lose. inbox: HashMap, + /// The `{uuid}.cbor` files under `media/` that no provenance chain anchors, found at + /// [`open`](Self::open): unsigned pre-signed-path sidecars awaiting + /// [`migrate_unsigned_sidecars`](Self::migrate_unsigned_sidecars), or the debris of an + /// interrupted run (`S-D24`). Recomputed by the verb; empty for a signed-only library. + unmigrated: Vec, } fn now_rfc3339() -> String { @@ -481,7 +509,7 @@ impl Workspace { } /// This device's stable id — the `created_by_device` every manifest this workspace - /// authors carries, and the [`DeviceEntry`](crate::crypto::keys::directory::DeviceEntry) + /// authors carries, and the [`DeviceEntry`](crate::crypto::keys::DeviceEntry) /// key under which its signing key is published in the device directory. pub fn device_id(&self) -> Uuid { self.account.device.device_id @@ -554,6 +582,29 @@ impl Workspace { self.assets.keys().copied().collect() } + /// The on-disk path of a managed asset's plaintext original, or `None` for an unknown id. + /// + /// The path is derived from the asset's **shard** (`AssetState::capture_utc`), which is + /// fixed at import — so it keeps resolving after a capture-time correction + /// ([`set_capture_timestamp`](Self::set_capture_timestamp)) even though the sidecar's + /// timestamp no longer names the month directory. Exposed for the repair pass, which has + /// to re-read each original's EXIF without loading every file through + /// [`read_plaintext`](Self::read_plaintext). + pub fn original_path(&self, asset_id: &Uuid) -> Option { + self.assets + .get(asset_id) + .map(|asset| self.media_path(asset)) + } + + /// Whether a managed asset is currently in trash — a replay of its provenance chain's + /// lifecycle actions (a `delete` moves it to trash, a later `trash-restore` brings it + /// back), which is the single source of truth the workspace itself applies. `false` for an + /// unknown id. Exposed so a client can report or skip trashed assets without re-deriving + /// the rule from the chain. + pub fn is_trashed(&self, asset_id: &Uuid) -> bool { + self.assets.get(asset_id).is_some_and(asset_is_deleted) + } + /// A managed asset's current state. pub fn asset(&self, asset_id: &Uuid) -> Option<&AssetState> { self.assets.get(asset_id) @@ -593,18 +644,20 @@ impl Workspace { } } +/// The trivially-fast Argon2id cost the `lifecycle` suite derives under. Named, rather than +/// spelled out per call, because a test now hands it to explicit-parameter entry points +/// (`Workspace::create_with_params`, `Workspace::export_backup_with_params`) instead of getting +/// a cheap cost from a `#[cfg(test)]` fork inside the library. +#[cfg(test)] +const FAST_PARAMS: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, +}; + /// A fast-Argon2 workspace over `dir` — the shared fixture every `lifecycle` test module /// builds on (the production cost would dominate the suite's runtime). #[cfg(test)] fn fast_workspace(dir: &Path) -> Workspace { - Workspace::create_with_params( - dir, - b"passphrase", - Argon2Params { - mem_kib: 64, - t_cost: 1, - p_cost: 1, - }, - ) - .unwrap() + Workspace::create_with_params(dir, b"passphrase", FAST_PARAMS).unwrap() } diff --git a/capsule-core/src/lifecycle/open.rs b/capsule-core/src/lifecycle/open.rs index 9ea6432d..089f468c 100644 --- a/capsule-core/src/lifecycle/open.rs +++ b/capsule-core/src/lifecycle/open.rs @@ -11,6 +11,7 @@ use jiff::tz::TimeZone; use uuid::Uuid; use walkdir::WalkDir; +use super::migrate_unsigned::find_unanchored; use super::{ AssetState, LifecycleError, Result, StackPlacement, Workspace, media_dir, now_rfc3339, }; @@ -65,7 +66,7 @@ impl std::fmt::Debug for HardwareDekBinding { /// across restarts]). /// /// The sweep reads the sidecars **without re-verifying their signatures**, for the same reason -/// [`rebuild_index`](crate::library::rebuild::rebuild_index) does not: these are the device's own +/// [`rebuild_index`](crate::library::rebuild_index) does not: these are the device's own /// local plaintext files, and the value recovered is only ever a *floor* on the next counter. /// An unreadable or foreign-schema sidecar is warned about and skipped rather than failing the /// open — but note that skipping can only lower the floor, so the warning is load-bearing for @@ -136,7 +137,7 @@ fn sweep_max_add_counter(root: &Path, device: &Uuid) -> Option { /// The extension of an asset's **original** media file in `dir`: the sibling named /// `{uuid}.{ext}` that is not one of the sidecar / provenance / receipts / metadata-blob /// artifacts the lifecycle writes beside it. -fn original_extension(dir: &Path, asset_id: &Uuid) -> Option { +pub(super) fn original_extension(dir: &Path, asset_id: &Uuid) -> Option { let prefix = format!("{}.", asset_id.simple()); let entries = fs::read_dir(dir).ok()?; for entry in entries.filter_map(std::result::Result::ok) { @@ -161,7 +162,7 @@ fn original_extension(dir: &Path, asset_id: &Uuid) -> Option { /// The UTC timestamp of the first instant of the `{YYYY}/{YYYY-MM}` bucket `dir` names — a value /// that provably resolves back to `dir` through [`media_dir`], used as the fallback when an /// asset's recorded capture time does not. -fn month_dir_timestamp(dir: &Path) -> i64 { +pub(super) fn month_dir_timestamp(dir: &Path) -> i64 { let parse = || -> Option { let name = dir.file_name()?.to_string_lossy().into_owned(); let (year, month) = name.split_once('-')?; @@ -302,6 +303,7 @@ impl Workspace { share_links: HashMap::new(), upload_links: HashMap::new(), inbox: HashMap::new(), + unmigrated: Vec::new(), }) } @@ -333,6 +335,11 @@ impl Workspace { /// A library with no `albums.cbor` predates this and opens with zero albums plus a `warn` /// naming backup restore — see [`AlbumStore::load`]. /// + /// A library holding **unsigned pre-signed-path sidecars** still opens (`S-D24`): those + /// assets have no provenance chain, so they are not restored, but each is reported through + /// [`unmigrated_sidecars`](Self::unmigrated_sidecars) with a `warn` naming + /// [`migrate_unsigned_sidecars`](Self::migrate_unsigned_sidecars) as the way in. + /// /// Still session-scoped by design, and dropped on close: the federation group assertions /// (re-delivered by the feed), the pending guest-drop inbox (server-authoritative), and the /// issued share/upload link records — see the [`Workspace`] fields for why each is deferred. @@ -368,8 +375,7 @@ impl Workspace { params: crate::crypto::primitives::Argon2Params, dek_binding: Option, ) -> Result { - let library = crate::library::open_library(root) - .map_err(|e| LifecycleError::Io(format!("open library: {e}")))?; + let library = crate::library::open_library(root)?; let account_path = root.join(".library").join("account.cbor"); let account = if account_path.exists() { let bytes = fs::read(&account_path).map_err(|e| LifecycleError::Io(e.to_string()))?; @@ -428,6 +434,7 @@ impl Workspace { share_links: HashMap::new(), upload_links: HashMap::new(), inbox: HashMap::new(), + unmigrated: Vec::new(), }; // `S-A10`: album keys, authorities, and every managed asset come back from disk here. // Without this the reopened workspace would hold an unlocked account and nothing else. @@ -477,7 +484,7 @@ impl Workspace { /// stack placement, which lives nowhere else. /// /// The filesystem, not the SQLite index, is the source of truth here for the same - /// recovery-first reason [`rebuild_index`](crate::library::rebuild::rebuild_index) exists: an + /// recovery-first reason [`rebuild_index`](crate::library::rebuild_index) exists: an /// index can be rebuilt from the signed artifacts, but an artifact the index has forgotten is /// gone. A missing or undecodable piece for one asset is a `warn` and a skip — never a failed /// open, which would take the whole library down for one bad file. @@ -521,6 +528,28 @@ impl Workspace { skipped, "workspace open: assets restored from disk" ); + + // Second pass (`S-D24`): the sidecars no chain anchors. An unsigned pre-signed-path + // record has never had one; a signed sidecar without one is an interrupted migration. + // Neither is restorable here — open holds no album write capability and must not author + // signed records unasked — so each is recorded and named, never silently dropped. + self.unmigrated = find_unanchored(&self.root); + for found in &self.unmigrated { + tracing::warn!( + sidecar = %found.path.display(), + asset_id = ?found.asset_id, + shape = ?found.shape, + "workspace open: sidecar with no provenance chain; the asset is invisible until \ + `Workspace::migrate_unsigned_sidecars` runs" + ); + } + if !self.unmigrated.is_empty() { + tracing::info!( + unmigrated = self.unmigrated.len(), + "workspace open: unmigrated sidecars found; run \ + `Workspace::migrate_unsigned_sidecars` to admit them as signed assets" + ); + } } fn restore_one_asset(&self, provenance_path: &Path, stem: &str) -> Result { @@ -657,21 +686,12 @@ impl Workspace { mod tests { use tempfile::TempDir; - use super::super::fast_workspace; + use super::super::{FAST_PARAMS, fast_workspace}; use super::*; use crate::crypto::keys::kem_p256::encapsulate_to_p256_public; use crate::crypto::primitives::Argon2Params; use crate::crypto::verify_asset::VerifyOutcome; - /// Fast-Argon2 params for a reopen in these tests (the production cost would dominate). - fn fast_params() -> Argon2Params { - Argon2Params { - mem_kib: 64, - t_cost: 1, - p_cost: 1, - } - } - /// **S-A10, the core claim.** An asset imported in one session is fully usable in the next: /// its album key comes back from the sealed keystore, so the workspace can re-derive the file /// key (proved by `verify_asset` accepting, which regenerates the ciphertext and re-checks its @@ -696,7 +716,7 @@ mod tests { }; assert!(!blob_before.is_empty()); - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); // The album's key material is back... assert!(ws2.has_album(&album), "the album survived the close"); @@ -745,7 +765,7 @@ mod tests { (album, id1) }; - let mut ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let mut ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); // Resolve-or-create returns the SAME album rather than minting a second one. assert_eq!(ws2.ensure_album(album, "Imports").unwrap(), album); assert_eq!(ws2.albums().len(), 1, "no duplicate album was minted"); @@ -795,7 +815,7 @@ mod tests { (album, authority.epoch_ceiling(), pubs) }; - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); let authority = ws2.authority(&album).expect("authority restored"); assert!( authority.admin_chain_verifies(), @@ -837,7 +857,7 @@ mod tests { assert!(store.exists()); fs::remove_file(&store).unwrap(); - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); assert!(ws2.albums().is_empty(), "no album keys are recoverable"); // The asset itself is still tracked (its plaintext is on disk) — only its key is gone. assert!(ws2.asset(&id).is_some()); @@ -873,9 +893,10 @@ mod tests { }; // Export from the *reopened* workspace. - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); let archive = src.path().join("backup.tar"); - ws2.export_backup(&archive, b"recovery-pass").unwrap(); + ws2.export_backup_with_params(&archive, b"recovery-pass", FAST_PARAMS) + .unwrap(); let exporter_pub = ws2.exporter_verifying_key(); // Restore into a fresh library and confirm the bytes come back. @@ -893,12 +914,13 @@ mod tests { let album = ws3.asset(&id).unwrap().album_id; assert!(ws3.has_album(&album)); let again = fresh.path().join("re-export.tar"); - ws3.export_backup(&again, b"pass-two").unwrap(); + ws3.export_backup_with_params(&again, b"pass-two", FAST_PARAMS) + .unwrap(); assert!(again.exists()); // And those recovered keys are durable in ws3 too: reopening it keeps them. drop(ws3); - let ws4 = Workspace::open(fresh.path(), b"passphrase", fast_params()).unwrap(); + let ws4 = Workspace::open(fresh.path(), b"passphrase", FAST_PARAMS).unwrap(); assert!( ws4.has_album(&album), "recovered AMKs were persisted, not just held for the session" @@ -922,7 +944,8 @@ mod tests { let mut ws = fast_workspace(lib.path()); let album = ws.create_album("Trip").unwrap(); ws.import_asset(album, &img).unwrap(); - ws.export_backup(&archive, b"pw").unwrap(); + ws.export_backup_with_params(&archive, b"pw", FAST_PARAMS) + .unwrap(); (album, ws.exporter_verifying_key()) }; @@ -1002,7 +1025,7 @@ mod tests { }; // Session 2: a brand-new process over the same library. - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); assert_eq!( ws2.account.device.device_id, device, "reopening resumes the same device identity" @@ -1032,7 +1055,7 @@ mod tests { ws.import_asset(album, &img).unwrap(); } - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); assert_eq!( ws2.counter.peek(), 0, @@ -1075,7 +1098,7 @@ mod tests { }; assert_ne!(asset_id, Uuid::nil()); - let ws2 = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let ws2 = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); assert_eq!( ws2.counter.peek(), 1, @@ -1239,7 +1262,7 @@ mod tests { let mut ws = Workspace::create_with_hardware_dek( lib.path(), b"passphrase", - fast_params(), + FAST_PARAMS, binding.clone(), ) .unwrap(); @@ -1265,7 +1288,7 @@ mod tests { // Reopen with the element re-attached: the same DEK comes back and still opens the // ciphertext sealed to it in the previous session. let reopened = - Workspace::open_with_hardware_dek(lib.path(), b"passphrase", fast_params(), binding) + Workspace::open_with_hardware_dek(lib.path(), b"passphrase", FAST_PARAMS, binding) .unwrap(); assert!(reopened.device_dek_is_hardware_bound()); assert_eq!(reopened.device_dek_public(), published); @@ -1287,7 +1310,7 @@ mod tests { Workspace::create_with_hardware_dek( lib.path(), b"passphrase", - fast_params(), + FAST_PARAMS, mock_dek_binding(0x1D), ) .unwrap(), @@ -1295,7 +1318,7 @@ mod tests { assert!( matches!( - Workspace::open(lib.path(), b"passphrase", fast_params()), + Workspace::open(lib.path(), b"passphrase", FAST_PARAMS), Err(LifecycleError::Crypto(CryptoError::Key(_))) ), "a hardware-bound library must not open without its secure element" @@ -1316,7 +1339,7 @@ mod tests { ws.device_dek_public() }; - let reopened = Workspace::open(lib.path(), b"passphrase", fast_params()).unwrap(); + let reopened = Workspace::open(lib.path(), b"passphrase", FAST_PARAMS).unwrap(); assert!(!reopened.device_dek_is_hardware_bound()); assert_eq!(reopened.device_dek_public(), published); @@ -1329,11 +1352,43 @@ mod tests { let hw_ws = Workspace::create_with_hardware_dek( hw_lib.path(), b"passphrase", - fast_params(), + FAST_PARAMS, mock_dek_binding(0x2E), ) .unwrap(); let (hw_ct, _) = encapsulate_to_p256_public(&hw_ws.device_dek_public()).unwrap(); assert!(reopened.device_dek_decapsulate(&hw_ct).is_err()); } + + /// **S-D23, the owed half at the workspace boundary.** `Workspace::open` no longer + /// stringifies a library-open failure into `Io`: a catalog newer than this build surfaces + /// as `LifecycleError::Library(LibraryError::CatalogTooNew { .. })`, so a client can name + /// both versions and tell the user to update rather than to check their disk. + #[test] + fn open_surfaces_a_too_new_catalog_as_a_typed_library_error() { + use crate::db::schema::SCHEMA_VERSION; + use crate::library::LibraryError; + + let lib = TempDir::new().unwrap(); + drop(fast_workspace(lib.path())); + + let db_path = lib.path().join("index/library.sqlite"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1)) + .unwrap(); + } + let before = fs::read(&db_path).unwrap(); + + match Workspace::open(lib.path(), b"passphrase", FAST_PARAMS) { + Err(LifecycleError::Library(LibraryError::CatalogTooNew { found, supported })) => { + assert_eq!(found, SCHEMA_VERSION + 1); + assert_eq!(supported, SCHEMA_VERSION); + } + Ok(_) => panic!("a too-new catalog must not open"), + Err(other) => panic!("expected Library(CatalogTooNew), got {other:?}"), + } + assert_eq!(fs::read(&db_path).unwrap(), before, "nothing was written"); + assert!(!lib.path().join(".library/lock").exists()); + } } diff --git a/capsule-core/src/lifecycle/provenance.rs b/capsule-core/src/lifecycle/provenance.rs index b9b04943..297d36f4 100644 --- a/capsule-core/src/lifecycle/provenance.rs +++ b/capsule-core/src/lifecycle/provenance.rs @@ -16,13 +16,34 @@ use crate::crypto::verify_asset::{ MetadataBinding, VerifyOutcome, verify_asset, verify_metadata_binding, }; use crate::metadata::crdt::AddId; -use crate::sidecar::sidecar_v1::SidecarV1; +use crate::sidecar::SidecarV1; impl Workspace { /// Build a signed lifecycle manifest for `asset`, sharing the create manifest's content /// fields. Used for metadata-update / delete / trash-restore. `metadata_blob_hash` is set /// explicitly per the presence-by-action rule (`Some` for a metadata-update that seals a /// fresh blob, `None` for delete / trash-restore) rather than inherited from `base`. + /// + /// # Who a continuation names, and why it cannot be the creator + /// + /// `created_by_user` / `created_by_device` name the **signer of this record**, re-minted per + /// write like `timestamp` and `client_version` — never inherited from `base`. + /// + /// That is not a preference, it is what + /// [`verify_asset`] requires: it resolves + /// `created_by_device` *inside `created_by_user`'s* published directory (step 6) and then + /// verifies `device_sig` under **that entry's** key (step 8). A record naming a device that + /// did not sign it fails step 8 and is unverifiable by every reader. Inheriting the pair + /// therefore broke the ordinary two-device case — device B deleting an asset created on + /// device A produced a manifest claiming A and signed by B — as well as every write by a + /// shared album's member. + /// + /// Album authority is a separate check and is unaffected: step 10 verifies `write_sig` + /// under the epoch's attested write-tier key, so naming the acting member as this record's + /// author does not weaken the owner's album. + /// + /// The asset's original creator stays recoverable where it always was — the `create` record + /// at the head of the provenance chain, which is append-only. fn sign_lifecycle( &self, album: &AlbumKeys, @@ -38,6 +59,11 @@ impl Workspace { retention_until, metadata_blob_hash, timestamp: now_rfc3339(), + // This record's signer, not the asset's creator — see the doc comment above. The + // same pair every create path writes (`import.rs`, `drops.rs`, `drop/mod.rs`), for + // the same reason: it is the device whose DSK signs the bytes below. + created_by_user: self.account.user_id, + created_by_device: self.account.device.device_id, // Each write records the exact client build that produced *this* record (S-D15), not // the creator's — so an edit by a different client identifies itself in the chain. client_version: self.client_version.clone(), @@ -190,14 +216,15 @@ impl Workspace { } } - // Re-borrow immutably to write the updated artifacts to disk. + // Re-borrow immutably to write the updated artifacts to disk. Only the signed + // artifacts: a lifecycle write never changes the original, so it neither reads nor + // rewrites it — a caption edit on a multi-gigabyte video touches the sidecar, the + // chain, and the blob, and nothing else. let asset = self .assets .get(asset_id) .expect("asset_id was validated above"); - let plaintext = - fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string()))?; - self.write_asset_files(asset, &plaintext)?; + self.write_signed_artifacts(asset)?; self.index_asset_row(asset) } } @@ -210,7 +237,7 @@ mod tests { use super::super::fast_workspace; use super::*; - use crate::crypto::keys::Amk; + use crate::crypto::keys::{Amk, HybridSigningKey}; /// S-A3: the `Workspace` populates `metadata_blob_hash` per the sealing order, the sidecar /// binds to the manifest through the prior head, and a one-byte sidecar mutation quarantines. @@ -307,4 +334,151 @@ mod tests { ); assert!(del.structural_ok()); } + + /// Re-point `ws` at a different signing device — and optionally a different **account** — + /// publishing a directory that holds it. What a second phone, or a shared album's member, + /// looks like to everything below the signer. + fn become_device( + ws: &mut Workspace, + account: Option<(Uuid, HybridSigningKey)>, + device_id: Uuid, + dsk: HybridSigningKey, + ) { + use crate::crypto::keys::{DeviceEntry, DirectoryCore}; + + let entry = DeviceEntry { + device_id, + dsk_public: dsk.verifying_key(), + dek_public: None, + // Must precede any manifest it signs; the workspace stamps `now`. + added_at: "2020-01-01T00:00:00Z".into(), + revoked_at: None, + }; + ws.directory = match account { + // A second device of the *same* account: appended to the account's own directory, + // which is re-signed by the account IK at a higher version. + None => { + let mut core = ws.directory.core.clone(); + core.directory_version += 1; + core.devices.push(entry); + core.sign(&ws.account.user_ik) + } + // A different account entirely: its own directory, under its own IK. + Some((user_id, ref ik)) => { + let directory = DirectoryCore { + user_id, + directory_version: 1, + updated_at: now_rfc3339(), + devices: vec![entry], + } + .sign(ik); + ws.account.user_id = user_id; + directory + } + }; + ws.account.device.device_id = device_id; + ws.device_signer = Box::new(dsk); + } + + fn imported(lib: &TempDir, src: &TempDir) -> (Workspace, Uuid, Uuid) { + let img = src.path().join("photo.jpg"); + fs::write(&img, b"\xFF\xD8\xFF continuation-authorship bytes").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip").unwrap(); + let asset = ws.import_asset(album, &img).unwrap(); + (ws, album, asset) + } + + /// **A second device of the same account continues a chain, and the result verifies.** + /// + /// The case `sign_lifecycle` used to break outright: it inherited `created_by_user` and + /// `created_by_device` from the chain head while signing with the *current* device, so a + /// delete from device B claimed device A and failed `verify_asset` step 8 — the device + /// signature does not verify under the named entry's key. Ordinary two-device use, no + /// sharing required. + #[test] + fn a_continuation_from_a_second_device_names_it_and_verifies() { + let (lib, src) = (TempDir::new().unwrap(), TempDir::new().unwrap()); + let (mut ws, _album, asset) = imported(&lib, &src); + + let creator = ws.account.device.device_id; + let second = Uuid::from_u128(0xD2); + become_device( + &mut ws, + None, + second, + HybridSigningKey::from_seed_bytes(&[9; 32], &[10; 32]), + ); + + ws.soft_delete(&asset, 30).unwrap(); + + let st = ws.asset(&asset).unwrap(); + let head = &st.chain.records().last().unwrap().manifest; + assert_eq!(head.core.action, Action::Delete); + assert_eq!( + head.core.created_by_device, second, + "the continuation names the device that signed it" + ); + assert_ne!( + head.core.created_by_device, creator, + "and not the one that created the asset" + ); + assert_eq!( + ws.verify(&asset).unwrap(), + VerifyOutcome::Accept, + "which is the only reason it can verify at all" + ); + + // The creator is not lost — it is where the append-only chain keeps it. + assert_eq!( + st.chain.records()[0].manifest.core.created_by_device, + creator + ); + assert_eq!(st.chain.records()[0].manifest.core.action, Action::Create); + } + + /// **A member of a shared album continues the owner's chain under the member's own account**, + /// and it verifies against the *member's* directory. + /// + /// The write-tier signature is what carries album authority (step 10) and it is unaffected: + /// the member holds the epoch's write-tier key, which is what membership *is*. Naming the + /// acting member as the record's author therefore does not weaken the owner's album — it is + /// the only way the record can be verified by anyone. + #[test] + fn a_members_continuation_verifies_under_the_members_own_directory() { + let (lib, src) = (TempDir::new().unwrap(), TempDir::new().unwrap()); + let (mut ws, _album, asset) = imported(&lib, &src); + + let owner = ws.account.user_id; + let member = Uuid::from_u128(0xB0B); + become_device( + &mut ws, + Some(( + member, + HybridSigningKey::from_seed_bytes(&[11; 32], &[12; 32]), + )), + Uuid::from_u128(0xD3), + HybridSigningKey::from_seed_bytes(&[13; 32], &[14; 32]), + ); + + ws.soft_delete(&asset, 30).unwrap(); + + let st = ws.asset(&asset).unwrap(); + let head = &st.chain.records().last().unwrap().manifest; + assert_eq!( + head.core.created_by_user, member, + "a member's write is authored by the member" + ); + assert_ne!(head.core.created_by_user, owner); + assert_eq!( + ws.verify(&asset).unwrap(), + VerifyOutcome::Accept, + "verified under the member's directory, against the owner's album authority" + ); + assert_eq!( + st.chain.records()[0].manifest.core.created_by_user, + owner, + "and the album's asset is still the owner's creation" + ); + } } diff --git a/capsule-core/src/lifecycle/sync_apply.rs b/capsule-core/src/lifecycle/sync_apply.rs index eb6ba04f..9c3217e3 100644 --- a/capsule-core/src/lifecycle/sync_apply.rs +++ b/capsule-core/src/lifecycle/sync_apply.rs @@ -12,8 +12,9 @@ //! steps. What this module adds is the orchestration a feed entry needs and a local import //! does not: //! -//! 1. decode the manifest from the **opaque canonical CBOR** the feed carries verbatim (never -//! re-encoded — re-encoding would detach it from its signatures); +//! 1. decode the **provenance record** from the opaque canonical CBOR the feed carries +//! verbatim (never re-encoded — re-encoding would detach the manifest inside it from its +//! signatures), and check the record's `prior_provenance_hash` against the manifest's; //! 2. run [`verify_asset`] against this workspace's device directory, the album's attested //! authority, and the caller's local provenance head; //! 3. bind the sealed metadata blob to the manifest (content address, then AEAD open under the @@ -39,7 +40,31 @@ //! federation pull, and a LAN peering delta all deliver the identical three byte strings, so //! these signatures are unaffected by which one a client speaks. //! +//! # What the feed's `manifest_cbor` actually carries +//! +//! The **provenance blob's** bytes, unchanged — and the provenance blob is the canonical CBOR +//! of a [`ProvenanceRecord`], not of a bare [`AssetManifest`]. That is forced, not chosen: +//! [provenance.md § Physical Storage] makes the server-side chain an append-only sequence of +//! envelope objects "served back unchanged", and the server's chain head is the SHA-256 of +//! those bytes while a client's next `prior_provenance_hash` is +//! [`record_hash()`](crate::crypto::provenance::ProvenanceRecord::record_hash) — the digest of +//! the canonical *record*. Any other encoding makes the two disagree, and no lifecycle op could +//! ever chain onto a synced asset. +//! +//! The manifest is not re-encoded by the wrapper: canonical CBOR is deterministic, so the +//! manifest sub-map inside the record is byte-identical to the manifest's own signed bytes, and +//! [provenance.md § Asset Manifest]'s "the signed bytes are the served bytes" holds through it. +//! [`verify_asset`] still verifies over the manifest's recomputed signing bytes, exactly as +//! before. +//! +//! The record's `prior_provenance_hash` is checked against the manifest's own before either is +//! used — [provenance.md § Chained, Append-Only Structure] calls the pair "a checked invariant, +//! not trusted redundancy", and this is the wire path where nothing else would check it. +//! //! [download & sync]: https://docs/design/import/download-sync/ +//! [provenance.md § Asset Manifest]: https://docs/design/cryptography/provenance/#asset-manifest +//! [provenance.md § Chained, Append-Only Structure]: https://docs/design/cryptography/provenance/#chained-append-only-structure +//! [provenance.md § Physical Storage]: https://docs/design/cryptography/provenance/#physical-storage //! [`verify_asset`]: crate::crypto::verify_asset::verify_asset //! [`VerifyOutcome`]: crate::crypto::verify_asset::VerifyOutcome @@ -189,15 +214,45 @@ impl Workspace { Binding, MalformedManifest, MalformedSidecar, Rejected, SidecarSignature, UnknownAlbum, }; - let manifest: AssetManifest = match cbor::from_slice(entry.manifest_cbor) { - Ok(manifest) => manifest, + // The feed serves the provenance blob's bytes, which are a *record* — see the module + // docs. Decoding them as a bare manifest is why a correctly pushed asset used to be + // quarantined by every receiving device. + let record: ProvenanceRecord = match cbor::from_slice(entry.manifest_cbor) { + Ok(record) => record, Err(e) => { - tracing::warn!(error = %e, "sync-apply: manifest CBOR did not decode"); + tracing::warn!(error = %e, "sync-apply: provenance record CBOR did not decode"); return Ok(SyncApplyOutcome::Quarantined(MalformedManifest( e.to_string(), ))); } }; + // The two copies of the chain link, checked before either is used. A record that + // disagrees with the manifest it carries is malformed whichever copy is right, and the + // manifest's is the signed one — so preferring either would be trusting a value no + // signature covers. + if !record.mirrors_manifest() { + tracing::warn!( + asset_id = %record.asset_id, + "sync-apply: the record's prior_provenance_hash does not mirror the manifest's" + ); + return Ok(SyncApplyOutcome::Quarantined(MalformedManifest( + "the record's prior_provenance_hash does not mirror the manifest's".to_owned(), + ))); + } + // The record names its asset too; a record whose subject is not the manifest's `file_id` + // is a splice of two assets' history and never applies. + if record.asset_id != record.manifest.core.file_id { + tracing::warn!( + record_asset = %record.asset_id, + manifest_asset = %record.manifest.core.file_id, + "sync-apply: the record names a different asset than its manifest" + ); + return Ok(SyncApplyOutcome::Quarantined(MalformedManifest(format!( + "record names asset {} but its manifest names {}", + record.asset_id, record.manifest.core.file_id + )))); + } + let manifest: AssetManifest = record.manifest; let core = manifest.core.clone(); tracing::debug!( asset_id = %core.file_id, @@ -343,6 +398,9 @@ mod tests { /// The three byte strings a feed entry carries for one asset, exactly as `upload_bundle` /// puts them on the wire. struct Wire { + /// The **provenance blob**: the canonical CBOR of the chain head record, taken + /// straight off `UploadBundle::provenance_blob` so these fixtures cannot drift from + /// what the push ladder actually uploads. manifest_cbor: Vec, metadata_blob: Vec, ciphertext: Vec, @@ -357,16 +415,8 @@ mod tests { let asset = ws.import_asset(album, &img).unwrap(); let bundle = ws.upload_bundle(&asset).unwrap(); - let head = &ws - .asset(&asset) - .unwrap() - .chain - .records() - .last() - .unwrap() - .manifest; let wire = Wire { - manifest_cbor: cbor::to_canonical_vec(head).unwrap(), + manifest_cbor: bundle.provenance_blob.clone(), metadata_blob: bundle.metadata_blob.clone(), ciphertext: bundle.ciphertext.clone(), }; @@ -423,7 +473,7 @@ mod tests { let create_head = records[0].record_hash(); let bundle = ws.upload_bundle(&asset).unwrap(); let wire = Wire { - manifest_cbor: cbor::to_canonical_vec(&records[1].manifest).unwrap(), + manifest_cbor: bundle.provenance_blob.clone(), // A tombstone carries no metadata blob on the wire. metadata_blob: Vec::new(), ciphertext: bundle.ciphertext.clone(), @@ -522,4 +572,104 @@ mod tests { SyncApplyOutcome::Quarantined(QuarantineReason::UnknownAlbum(foreign)) ); } + + /// **The encoding the whole chain rests on.** The bytes the push ladder uploads as the + /// `provenance` blob — and therefore the bytes the feed serves back as `manifest_cbor` — + /// are the canonical CBOR of the head record, whose digest is by definition + /// `record_hash()`. + /// + /// That equality is the entire reason the encoding is not a free choice: the server's chain + /// head is the SHA-256 of the blob's bytes, and a client's next `prior_provenance_hash` is + /// `record_hash()`. Encode the bare manifest instead and the two are different numbers, so + /// no lifecycle op can ever chain onto a synced asset. + #[test] + fn the_provenance_blob_is_the_canonical_record_and_hashes_to_the_chain_head() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (ws, _album, asset, wire) = seeded(&lib, &src); + + let head = ws + .asset(&asset) + .unwrap() + .chain + .records() + .last() + .unwrap() + .clone(); + assert_eq!( + crate::crypto::hash::hash_bytes(&wire.manifest_cbor), + head.record_hash(), + "the blob's digest is the value a later op's prior_provenance_hash must equal" + ); + + let decoded: ProvenanceRecord = cbor::from_slice(&wire.manifest_cbor).unwrap(); + assert_eq!( + decoded, head, + "the wire bytes round-trip to the head record" + ); + // "The signed bytes are the served bytes" survives the wrapper: canonical CBOR is + // deterministic, so the manifest that comes back out of the record encodes to exactly + // the bytes the manifest encodes to on its own — the wrapper carries it, it does not + // re-author it. That the signatures over those bytes still verify is + // `unseen_entry_verifies_and_yields_facts`, through `verify_asset`. + assert_eq!( + cbor::to_canonical_vec(&decoded.manifest).unwrap(), + cbor::to_canonical_vec(&head.manifest).unwrap(), + ); + } + + /// The record's `prior_provenance_hash` and its manifest's must agree — provenance.md calls + /// them "a checked invariant, not trusted redundancy". Only the manifest's copy is signed, + /// so a divergence is refused rather than resolved in either direction. + #[test] + fn a_record_whose_mirror_diverges_is_quarantined() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (ws, album, _asset, wire) = seeded(&lib, &src); + + let mut record: ProvenanceRecord = cbor::from_slice(&wire.manifest_cbor).unwrap(); + assert_eq!(record.prior_provenance_hash, None, "a create has no prior"); + // A prior the signed manifest does not carry: the unsigned copy claims a chain + // position the signed one denies. + record.prior_provenance_hash = Some(Hash32::from_bytes([0x11; 32])); + + let forged = Wire { + manifest_cbor: cbor::to_canonical_vec(&record).unwrap(), + metadata_blob: wire.metadata_blob.clone(), + ciphertext: wire.ciphertext.clone(), + }; + assert!( + matches!( + ws.apply_remote_entry(entry(album, &forged, None)).unwrap(), + SyncApplyOutcome::Quarantined(QuarantineReason::MalformedManifest(_)) + ), + "a divergent mirror is malformed, not applied" + ); + } + + /// A record whose `asset_id` is not its manifest's `file_id` is a splice of two assets' + /// history. The manifest still verifies — it is genuinely signed — so nothing downstream + /// would catch it. + #[test] + fn a_record_naming_another_asset_is_quarantined() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (ws, album, _asset, wire) = seeded(&lib, &src); + + let mut record: ProvenanceRecord = cbor::from_slice(&wire.manifest_cbor).unwrap(); + record.asset_id = Uuid::now_v7(); + + let spliced = Wire { + manifest_cbor: cbor::to_canonical_vec(&record).unwrap(), + metadata_blob: wire.metadata_blob.clone(), + ciphertext: wire.ciphertext.clone(), + }; + assert!( + matches!( + ws.apply_remote_entry(entry(album, &spliced, None)).unwrap(), + SyncApplyOutcome::Quarantined(QuarantineReason::MalformedManifest(_)) + ), + "a record must name the asset its manifest names" + ); + } } diff --git a/capsule-core/src/lifecycle/upload.rs b/capsule-core/src/lifecycle/upload.rs index b1147c62..928e1c2a 100644 --- a/capsule-core/src/lifecycle/upload.rs +++ b/capsule-core/src/lifecycle/upload.rs @@ -7,7 +7,7 @@ //! this one accessor so there is exactly one copy of that crypto). //! //! **This is a post-import pass, not an implementation of -//! [`AssetUploader`](crate::import::streaming::AssetUploader).** That trait cannot be +//! [`AssetUploader`](crate::import::AssetUploader).** That trait cannot be //! implemented: `stream_candidate` holds `&mut Workspace` across the `uploader.upload(...)` //! call, so an implementor can never borrow the workspace back to read the bytes it is //! supposed to send. Do not try again — push reads a *committed* asset out of an @@ -18,16 +18,17 @@ use std::fs; use uuid::Uuid; -use super::{AssetState, LifecycleError, Result, Workspace, media_dir}; +use super::{AlbumKeys, AssetState, LifecycleError, Result, Workspace, media_dir}; use crate::cbor; use crate::crypto::encryption::stream; use crate::crypto::hash::{self, Hash32}; use crate::crypto::provenance::DerivativeManifest; use crate::crypto::provenance::action::{Action, DerivativeRole}; use crate::crypto::provenance::manifest::KeyMode; +use crate::media::{DerivativeFormat, verify_still_format}; -/// One derivative blob of an asset bundle: the bytes plus the content address its signed -/// [`DerivativeManifest`] committed to. +/// One derivative blob of an asset bundle: the **ciphertext** plus the content address its +/// signed [`DerivativeManifest`] committed to. #[derive(Debug, Clone)] pub struct DerivativeBlob { /// Which derivative this is (`thumbnail` / `preview` / `embedding`). @@ -36,7 +37,10 @@ pub struct DerivativeBlob { pub format: String, /// The AMK epoch the derivative manifest was authorized under, when it recorded one. pub amk_version: Option, - /// The derivative's transferable bytes. + /// The derivative's transferable bytes — **ciphertext**, re-derived from the plaintext the + /// library holds using the nonce prefix the manifest recorded. Derivative bytes are + /// encrypted client-side exactly like the original + /// ([Encryption](https://docs/design/cryptography/encryption/)). pub bytes: Vec, /// The content address the signed derivative manifest committed to. pub ciphertext_hash: Hash32, @@ -75,6 +79,22 @@ pub struct UploadBundle { pub key_mode: KeyMode, /// The **exact** sealed metadata-blob wire bytes the manifest commits to. pub metadata_blob: Vec, + /// The **exact** provenance-blob wire bytes: the canonical CBOR of the chain's head + /// [`ProvenanceRecord`](crate::crypto::provenance::ProvenanceRecord). + /// + /// This is the asset's *envelope object* — the `provenance` blob of + /// [provenance.md § Physical Storage](https://docs/design/cryptography/provenance/#physical-storage), + /// stored by the server verbatim and served back unchanged on the sync feed. It is the + /// record and not the bare manifest because the server's chain head is the SHA-256 of these + /// bytes, while a client's next `prior_provenance_hash` is `record_hash()` — the digest of + /// the canonical record — so no other encoding lets a later lifecycle op chain onto what + /// this upload established. The manifest travels inside it, canonically encoded and + /// therefore byte-identical to its own signed bytes: "the signed bytes are the served + /// bytes" holds through the wrapper. + /// + /// Always present, unlike [`metadata_blob_hash`](Self::metadata_blob_hash): a managed asset + /// always has a chain head, even when its head action binds no metadata blob. + pub provenance_blob: Vec, /// The content address of the sealed metadata blob, when the head action binds one. pub metadata_blob_hash: Option, /// The asset's derivative blobs, if any were generated and persisted. @@ -119,13 +139,18 @@ impl Workspace { .get(asset_id) .ok_or_else(|| LifecycleError::NotFound(format!("asset {asset_id}")))?; let album = self.album(&asset.album_id)?; - let head = &asset + let head_record = asset .chain .records() .last() - .expect("provenance chain is never empty") - .manifest - .core; + .expect("provenance chain is never empty"); + let head = &head_record.manifest.core; + // The envelope object, encoded once here so the SDK ladder and the FFI hand the wire + // the same bytes. Canonical by construction: `record_hash()` is defined as the digest + // of exactly this encoding, which is what makes the server's chain head and a client's + // next `prior_provenance_hash` the same value. + let provenance_blob = cbor::to_canonical_vec(head_record) + .map_err(|e| LifecycleError::Cbor(format!("encoding the provenance record: {e}")))?; let plaintext = fs::read(self.media_path(asset)).map_err(|e| LifecycleError::Io(e.to_string()))?; @@ -147,12 +172,13 @@ impl Workspace { return Err(LifecycleError::CiphertextMismatch(asset.asset_id)); } - let derivatives = self.derivative_blobs(asset); + let derivatives = self.derivative_blobs(asset, album, epoch); tracing::debug!( album_id = %asset.album_id, amk_version = epoch, ciphertext_bytes = ciphertext.len(), metadata_blob_bytes = asset.metadata_blob.len(), + provenance_blob_bytes = provenance_blob.len(), derivatives = derivatives.len(), action = ?head.action, "upload bundle built" @@ -172,6 +198,7 @@ impl Workspace { key_mode: head.key_mode, metadata_blob: asset.metadata_blob.clone(), metadata_blob_hash: head.metadata_blob_hash, + provenance_blob, derivatives, created_by_user: head.created_by_user, created_by_device: head.created_by_device, @@ -184,11 +211,40 @@ impl Workspace { } /// The asset's persisted derivative blobs, read back from - /// `media/{YYYY}/{YYYY-MM}/derivatives/`. A derivative whose bytes are missing or no - /// longer content-address to its signed manifest is **skipped with a warning** rather - /// than failing the bundle: the original and its metadata are what a backup must not + /// `media/{YYYY}/{YYYY-MM}/derivatives/` and **encrypted** for the wire. + /// + /// The library holds derivatives as plaintext, exactly as it holds the original: the local + /// gallery paints them, and the ciphertext is re-derived here from the nonce prefix the + /// signed manifest recorded — the same round trip + /// [`upload_bundle`](Self::upload_bundle) performs for the original. So what leaves this + /// function in [`DerivativeBlob::bytes`] is ciphertext, and the field's `ciphertext_hash` + /// name is now true of it. A thumbnail is a recognisable low-resolution copy of a private + /// photo; the encryption doc admits no exception for it. + /// + /// **Still roles only.** An embedding-role manifest is skipped at `debug!` and named as out + /// of scope: its `embedding/{model_id}` grammar is not in the still format set, and + /// `crate::ml` produces no derivative manifest for this reader to have an opinion about. + /// + /// Five reasons a manifest is skipped rather than shipped, and two of them are quiet: + /// + /// - the `original` sentinel, which references the original blob and has no bytes of its + /// own — an **expected** absence, logged at `debug!`; + /// - an embedding-role manifest, out of scope as above — also `debug!`; + /// - a still-role `format` outside the closed set, which is the structural rejection the + /// tier table specifies; + /// - an `amk_version` naming an epoch this album does not hold, which would otherwise panic + /// on the key lookup; + /// - bytes missing on disk, or bytes whose re-derived ciphertext does not match the signed + /// content address. + /// + /// None of them fails the bundle: the original and its metadata are what a backup must not /// lose, and a stale thumbnail is regenerable. - fn derivative_blobs(&self, asset: &AssetState) -> Vec { + fn derivative_blobs( + &self, + asset: &AssetState, + album: &AlbumKeys, + epoch: u32, + ) -> Vec { let dir = media_dir(&self.root, asset.capture_utc).join("derivatives"); let stem = asset.asset_id.simple().to_string(); let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); @@ -209,10 +265,54 @@ impl Workspace { let mut blobs = Vec::with_capacity(manifests.len()); for manifest in manifests { + let role_name = derivative_role_name(manifest.core.role); + + // The closed-format check the tier table calls a structural rejection. It answers + // `Ok(None)` for an embedding-role manifest, whose `embedding/{model_id}` grammar + // this set deliberately does not model, so those pass through untouched. + match verify_still_format(&manifest) { + Ok(Some(DerivativeFormat::Original)) => { + // A reference to the original blob, not a missing file: the receiver + // resolves it against the original it already has. `debug!`, because an + // expected absence logged as a warning trains people to ignore warnings. + tracing::debug!( + asset_id = %asset.asset_id, + role = role_name, + "upload bundle: `original` sentinel references the original blob; \ + nothing to upload for this tier" + ); + continue; + } + Ok(None) => { + // An embedding-role manifest. Its `embedding/{model_id}` grammar is not in + // the still format set, and nothing produces one yet: `crate::ml` writes no + // derivative manifest at all. Rather than invent a reader for an artefact + // with no writer, this reader says plainly that embeddings are out of its + // scope — at `debug!`, because encountering one is not a fault. + tracing::debug!( + asset_id = %asset.asset_id, + role = role_name, + "upload bundle: embedding-role derivatives are outside this reader's \ + scope until `crate::ml` produces manifests for them; skipping" + ); + continue; + } + Ok(Some(_)) => {} + Err(format) => { + tracing::warn!( + asset_id = %asset.asset_id, + role = role_name, + %format, + "upload bundle: still-role derivative names a format outside the closed \ + set; skipping" + ); + continue; + } + } + let core = manifest.core; - let role_name = derivative_role_name(core.role); - let prefix = format!("{stem}.{role_name}."); - let Some(bytes) = read_derivative_bytes(&dir, &prefix) else { + let Some(plaintext) = read_derivative_bytes(&dir, &stem, role_name, &core.format) + else { tracing::warn!( asset_id = %asset.asset_id, role = role_name, @@ -220,7 +320,32 @@ impl Workspace { ); continue; }; - let observed = hash::hash_bytes(&bytes); + + // Re-derive the ciphertext from the prefix the manifest signed. The prefix is + // folded into the file-key salt, so it selects the key as well as the nonces — + // there is exactly one ciphertext this manifest can be describing. + // + // The epoch is **checked**, not indexed. It comes off an unverified `.cbor` on + // disk, and `file_key` reaches `album.amks[&epoch]`, which panics on a missing key + // — inside a function whose whole contract is that it never fails the bundle. An + // album recovered from a backup holds only the epochs it escrowed, so a manifest + // naming one it does not hold is reachable without any tampering at all. + let key_epoch = core.amk_version.map_or(epoch, |v| v.0); + if !album.amks.contains_key(&key_epoch) { + tracing::warn!( + asset_id = %asset.asset_id, + role = role_name, + epoch = key_epoch, + "upload bundle: derivative manifest names an AMK epoch this album does not \ + hold; skipping" + ); + continue; + } + let file_key = self.file_key(album, key_epoch, &asset.asset_id, &core.nonce_prefix); + let (_, ciphertext) = + stream::encrypt_asset_vec_with_prefix(&file_key, core.nonce_prefix, &plaintext); + + let observed = hash::hash_bytes(&ciphertext); if observed != core.ciphertext_hash { tracing::warn!( asset_id = %asset.asset_id, @@ -233,7 +358,7 @@ impl Workspace { role: core.role, format: core.format, amk_version: core.amk_version.map(|v| v.0), - bytes, + bytes: ciphertext, ciphertext_hash: observed, }); } @@ -250,17 +375,28 @@ fn derivative_role_name(role: DerivativeRole) -> &'static str { } } -/// The first file in `dir` whose name starts with `prefix` — the derivative's bytes, whose -/// extension varies with the encoder's chosen format. -fn read_derivative_bytes(dir: &std::path::Path, prefix: &str) -> Option> { - let entries = fs::read_dir(dir).ok()?; - let mut names: Vec<_> = entries - .filter_map(std::result::Result::ok) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with(prefix)) - .collect(); - names.sort(); - fs::read(dir.join(names.first()?)).ok() +/// The persisted bytes of one derivative, addressed by **(role, format)** rather than by role +/// alone. +/// +/// Role alone was ambiguous the moment a tier could carry more than one format: with a JXL and +/// an AVIF thumbnail side by side, a prefix match would take whichever sorted first and then +/// content-address it against the *other* manifest, so both would be skipped as mismatched. +/// `#437` lands exactly that pair, so this is a latent break rather than a hypothetical one. +/// +/// Returns `None` for any `format` outside the closed set — including the embedding-role +/// grammar, which has no still extension. The caller has already skipped both cases, so reaching +/// this with one is not expected; it answers `None` rather than asserting, because a reader that +/// panics on a manifest it merely does not understand is worse than one that ships nothing. A stale file left by a +/// retired format is simply never read — nothing enumerates the directory any more, so an +/// orphan is inert rather than a candidate, and it is regenerable by design. +fn read_derivative_bytes( + dir: &std::path::Path, + stem: &str, + role_name: &str, + format: &str, +) -> Option> { + let extension = DerivativeFormat::parse(format)?.extension()?; + fs::read(dir.join(format!("{stem}.{role_name}.{extension}"))).ok() } #[cfg(test)] diff --git a/capsule-core/src/lifecycle/upload/tests.rs b/capsule-core/src/lifecycle/upload/tests.rs index b2fad48a..6f9e5a76 100644 --- a/capsule-core/src/lifecycle/upload/tests.rs +++ b/capsule-core/src/lifecycle/upload/tests.rs @@ -145,7 +145,8 @@ fn export_backup_still_round_trips_through_the_accessor() { let (exporter_pub, bundle) = { let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); - ws.export_backup(&archive, b"backup-pass").unwrap(); + ws.export_backup_with_params(&archive, b"backup-pass", FAST) + .unwrap(); ( ws.exporter_verifying_key(), ws.upload_bundle(&asset_id).unwrap(), @@ -173,3 +174,527 @@ fn walk_find(root: &std::path::Path, name: &str) -> bool { .filter_map(std::result::Result::ok) .any(|e| e.file_name().to_string_lossy() == name) } + +// ── Derivative blobs cross the network encrypted (S-B1 / encryption.md) ────── + +/// A library with one **decodable** asset large enough to earn a real derivative, so the +/// derivative path is exercised rather than the `original` sentinel. +/// +/// A 512x384 PNG, built here rather than committed: the repository carries no binary fixtures. +fn library_with_a_thumbnailed_asset(lib: &TempDir, src: &TempDir) -> (Uuid, Uuid) { + use rawshift_image::core::metadata::ImageMetadata; + use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; + use rawshift_image::formats::encode_rgb_image_to_vec; + use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, ZunePngEncodeConfig, + }; + + let (w, h) = (512u32, 384u32); + let mut data = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + data.push(((x * 255 / w) as u16) * 257); + data.push(((y * 255 / h) as u16) * 257); + data.push((((x + y) * 255 / (w + h)) as u16) * 257); + } + } + let frame = rawshift_image::core::image::RgbImage::with_color_space( + w, + h, + data, + rawshift_image::core::ColorSpace::Srgb, + ); + let png = encode_rgb_image_to_vec( + &frame, + &ImageMetadata::default(), + &EncodeOptions::PngZune(ZunePngEncodeConfig { + common: CommonEncodeOptions { + metadata: MetadataEmbedOptions::none(), + bit_depth: BitDepth::Eight, + }, + ..ZunePngEncodeConfig::default() + }), + ) + .expect("the fixture PNG encodes"); + + let img = src.path().join("photo.png"); + fs::write(&img, &png).unwrap(); + let mut ws = Workspace::create_with_params(lib.path(), b"passphrase", FAST).unwrap(); + let album = ws.default_album_id(); + ws.ensure_album(album, "Imports").unwrap(); + let asset = ws.import_asset(album, &img).unwrap(); + (album, asset) +} + +/// The derivative round trip, end to end: the plaintext the library holds is **not** what the +/// bundle ships, the bundle's bytes content-address to the signed `ciphertext_hash`, and +/// decrypting them with the manifest's recorded `nonce_prefix` yields the plaintext back. +/// +/// This is the property the encryption doc states without qualification — "every asset — +/// original bytes, derivative bytes, metadata blob — is encrypted client-side" — and a thumbnail +/// is a recognisable low-resolution copy of a private photo, so shipping one in the clear would +/// hand the server the picture it is not allowed to see. +#[test] +fn derivative_blobs_ship_ciphertext_that_decrypts_to_the_bytes_on_disk() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!(bundle.derivatives.len(), 1, "one encodable format today"); + let blob = &bundle.derivatives[0]; + assert_eq!(blob.format, "image/jxl"); + + // The plaintext the local gallery paints, straight off disk. + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let stem = asset_id.simple().to_string(); + let plaintext = fs::read(dir.join(format!("{stem}.thumbnail.jxl"))).unwrap(); + assert_eq!( + &plaintext[..2], + b"\xFF\x0A", + "the on-disk derivative is a bare JXL codestream" + ); + + assert_ne!( + blob.bytes, plaintext, + "what crosses the network is not what sits on disk" + ); + assert_eq!( + hash::hash_bytes(&blob.bytes), + blob.ciphertext_hash, + "the blob's declared address is its own content address" + ); + + // And that address is the one the *signed* manifest committed to. + let bundle_path = dir.join(format!("{stem}.derivatives.cbor")); + let manifests: Vec = + cbor::from_slice(&fs::read(&bundle_path).unwrap()).expect("the bundle decodes"); + let core = &manifests[0].core; + assert_eq!(core.ciphertext_hash, blob.ciphertext_hash); + + // The receiver's half: the recorded prefix selects the key and the nonces. + let album_keys = ws.album(&asset.album_id).unwrap(); + let file_key = ws.file_key( + album_keys, + core.amk_version.unwrap().0, + &asset_id, + &core.nonce_prefix, + ); + let recovered = + stream::decrypt_asset_vec(&file_key, &core.nonce_prefix, &blob.bytes).expect("it opens"); + assert_eq!( + recovered, plaintext, + "and it decrypts to exactly the derivative the client holds" + ); +} + +/// A derivative whose on-disk bytes have been altered no longer re-derives to the address its +/// manifest signed, so it is skipped rather than shipped. The bundle still carries the original +/// and its metadata — a stale thumbnail is regenerable, a missing backup is not. +#[test] +fn a_tampered_derivative_is_skipped_rather_than_shipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let path = dir.join(format!("{}.thumbnail.jxl", asset_id.simple())); + + let mut bytes = fs::read(&path).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + fs::write(&path, &bytes).unwrap(); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "a derivative that does not match its signed manifest is not uploaded" + ); + assert!( + !bundle.ciphertext.is_empty(), + "and the original is still shipped — the backup is what must not be lost" + ); +} + +/// The `original` sentinel carries no bytes **by design**, so the bundle simply has no +/// derivative blob for that tier — and the skip is not a warning, because an expected absence +/// logged as a problem is how people learn to ignore warnings. +#[test] +fn the_original_sentinel_contributes_no_blob_and_is_not_an_error() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + // The 8x8 JPEG fixture is far inside the 256 px thumbnail cap, so its tier is the sentinel. + let (_album, asset_id) = library_with_one_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "the sentinel references the original blob; there is nothing extra to upload" + ); +} + +/// Rewrite an asset's derivative bundle with `manifests`, returning the directory it lives in. +fn rewrite_bundle(lib: &TempDir, ws: &Workspace, asset_id: Uuid, manifests: &[DerivativeManifest]) { + let asset = ws.asset(&asset_id).expect("the asset is held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(format!("{}.derivatives.cbor", asset_id.simple())), + cbor::to_canonical_vec(&manifests.to_vec()).unwrap(), + ) + .unwrap(); +} + +/// Sign a derivative manifest with the given role and wire `format`, over `ciphertext_hash`. +fn signed_derivative( + asset_id: Uuid, + role: DerivativeRole, + format: &str, + ciphertext_hash: crate::crypto::hash::Hash32, +) -> DerivativeManifest { + signed_derivative_at_epoch(asset_id, role, format, ciphertext_hash, 1) +} + +/// As [`signed_derivative`], with an explicit `amk_version` — so a manifest can name an epoch +/// the album does not hold. +fn signed_derivative_at_epoch( + asset_id: Uuid, + role: DerivativeRole, + format: &str, + ciphertext_hash: crate::crypto::hash::Hash32, + epoch: u32, +) -> DerivativeManifest { + use crate::crypto::keys::{AmkVersion, HybridSigningKey}; + use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; + use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; + + let device = HybridSigningKey::from_seed_bytes(&[21; 32], &[22; 32]); + let write = HybridSigningKey::from_seed_bytes(&[23; 32], &[24; 32]); + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(epoch)), + source_asset_id: asset_id, + role, + format: format.into(), + ciphertext_hash, + nonce_prefix: [7, 6, 5, 4, 3, 2, 1], + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-02T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write) + .expect("signing") +} + +/// **The closed-format rule, at the boundary that ships bytes.** A still-role manifest naming a +/// format outside the committed set is a structural rejection, so its bytes never reach the +/// network — even though they are sitting on disk and hash correctly. +/// +/// The embedding role is deliberately exempt: it writes `embedding/{model_id}` into the same +/// field, a grammar this set does not model, so it must not be caught in the crossfire. +#[test] +fn a_still_role_derivative_outside_the_closed_set_is_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + // The bytes on disk stay exactly as the import wrote them; only the manifest's format moves. + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let plaintext = fs::read(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + let album_keys = ws.album(&asset.album_id).unwrap(); + let file_key = ws.file_key(album_keys, 1, &asset_id, &[7, 6, 5, 4, 3, 2, 1]); + let (_, ciphertext) = + stream::encrypt_asset_vec_with_prefix(&file_key, [7, 6, 5, 4, 3, 2, 1], &plaintext); + let address = hash::hash_bytes(&ciphertext); + + // A recognised format ships... + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + address, + )], + ); + assert_eq!( + ws.upload_bundle(&asset_id).unwrap().derivatives.len(), + 1, + "a format inside the closed set is uploaded" + ); + + // ...and an unrecognised one does not, with everything else held equal. + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/future-codec", + address, + )], + ); + assert!( + ws.upload_bundle(&asset_id).unwrap().derivatives.is_empty(), + "an unrecognised still format is a structural rejection, not a blob" + ); +} + +/// A manifest with a **recognised** format and no bytes on disk is a genuine problem and is +/// skipped, which is what keeps the sentinel's quiet skip from being a blanket amnesty for +/// missing files. +#[test] +fn a_non_sentinel_manifest_with_no_bytes_is_still_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + fs::remove_file(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + + assert!( + ws.upload_bundle(&asset_id).unwrap().derivatives.is_empty(), + "a thumbnail manifest whose bytes have gone is not shipped" + ); +} + +/// The `capsule import` acceptance case, at the bundle boundary: the bytes a push would send for +/// the thumbnail tier are **not** the bytes on disk, and their magic differs — the on-disk file +/// is a bare JXL codestream (`FF 0A`), the wire blob is STREAM ciphertext. +/// +/// The magic check is the cheap, legible version of the round trip above: it is what someone +/// eyeballing a packet capture would look for. +#[test] +fn the_pushed_thumbnail_is_not_the_jxl_on_disk() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let disk = fs::read(dir.join(format!("{}.thumbnail.jxl", asset_id.simple()))).unwrap(); + assert_eq!(&disk[..2], b"\xFF\x0A", "on disk: a bare JXL codestream"); + + let mut bundle = ws.upload_bundle(&asset_id).unwrap(); + let blob = bundle.derivatives.remove(0); + assert_ne!( + &blob.bytes[..2], + b"\xFF\x0A", + "on the wire: ciphertext, so it does not begin with the JXL magic" + ); + assert_ne!(blob.bytes, disk); +} + +/// **The `F1` contract at the import boundary.** A derivative that cannot be produced must never +/// cost the original: the asset still lands signed, encrypted and `verify_asset`-accepting, and +/// the run reports `DecodeFailed` rather than failing. +/// +/// Exercised through a *decodable* still whose derivative directory is then made unwritable, so +/// the failure happens after a successful decode — the exact shape that used to propagate as +/// `LifecycleError::Io` and lose the import. +#[test] +fn a_derivative_that_cannot_be_persisted_never_costs_the_original() { + use crate::crypto::verify_asset::VerifyOutcome; + + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + // The asset committed, derivatives or not. + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + assert_eq!(ws.verify(&asset_id).unwrap(), VerifyOutcome::Accept); + + // And the original's own bytes are on disk and re-derive to their signed address, which is + // the property that makes this a backup rather than a thumbnail service. + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!(!bundle.ciphertext.is_empty()); + assert_eq!(hash::hash_bytes(&bundle.ciphertext), bundle.ciphertext_hash); +} + +/// A persisted derivative survives a `Workspace` reopen and still reaches `UploadBundle`: the +/// bundle is rebuilt from the library directory alone, so the manifest, the on-disk plaintext +/// and the re-derived ciphertext all have to agree across processes. +#[test] +fn a_derivative_survives_a_reopen_and_still_reaches_the_bundle() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + + // A second `Workspace::open` — the S-A10 shape: nothing shared but the directory. + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!( + bundle.derivatives.len(), + 1, + "the derivative survives a reopen" + ); + let blob = &bundle.derivatives[0]; + assert_eq!( + hash::hash_bytes(&blob.bytes), + blob.ciphertext_hash, + "and its ciphertext still content-addresses to the signed manifest" + ); +} + +/// Two formats persisted for one role are told apart by **(role, format)**, not by whichever +/// filename sorts first. +/// +/// `#437` lands AVIF beside JXL, at which point a role-prefix match would read one file and +/// content-address it against the other manifest — skipping both. Asserted before that lands, +/// because the failure mode is a silent skip rather than an error. +#[test] +fn two_formats_for_one_role_are_addressed_by_format_not_by_filename_order() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + let asset = ws.asset(&asset_id).expect("held"); + let dir = media_dir(lib.path(), asset.capture_utc).join("derivatives"); + let stem = asset_id.simple().to_string(); + let jxl = fs::read(dir.join(format!("{stem}.thumbnail.jxl"))).unwrap(); + + // A decoy AVIF for the same role, sorting *before* `.jxl`, with different bytes. + let avif = b"not a real avif, and deliberately different".to_vec(); + fs::write(dir.join(format!("{stem}.thumbnail.avif")), &avif).unwrap(); + + // Both manifests, each addressing its own ciphertext. + let album_keys = ws.album(&asset.album_id).unwrap(); + let address = |bytes: &[u8]| { + let key = ws.file_key(album_keys, 1, &asset_id, &[7, 6, 5, 4, 3, 2, 1]); + let (_, ct) = stream::encrypt_asset_vec_with_prefix(&key, [7, 6, 5, 4, 3, 2, 1], bytes); + hash::hash_bytes(&ct) + }; + rewrite_bundle( + &lib, + &ws, + asset_id, + &[ + signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + address(&jxl), + ), + signed_derivative( + asset_id, + DerivativeRole::Thumbnail, + "image/avif", + address(&avif), + ), + ], + ); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert_eq!( + bundle.derivatives.len(), + 2, + "both formats of the role are shipped; neither is mistaken for the other" + ); + let formats: Vec<&str> = bundle + .derivatives + .iter() + .map(|d| d.format.as_str()) + .collect(); + assert_eq!(formats, vec!["image/jxl", "image/avif"]); +} + +/// **A manifest naming an epoch the album does not hold is skipped, not a panic.** +/// +/// `file_key` reaches `album.amks[&epoch]`, which panics on a missing key — and the epoch comes +/// off an unverified `.cbor` on disk, inside a function whose whole contract is that it never +/// fails the bundle. This is reachable without any tampering at all: an album recovered from a +/// backup holds only the epochs it escrowed. +#[test] +fn a_derivative_naming_an_unheld_epoch_is_skipped_rather_than_panicking() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative_at_epoch( + asset_id, + DerivativeRole::Thumbnail, + "image/jxl", + hash::hash_bytes(b"whatever"), + 9_999, + )], + ); + + // The assertion is as much that this returns at all as that it returns nothing. + let bundle = ws + .upload_bundle(&asset_id) + .expect("an unheld epoch must not fail the bundle"); + assert!( + bundle.derivatives.is_empty(), + "a derivative whose key epoch is absent is skipped" + ); + assert!( + !bundle.ciphertext.is_empty(), + "and the original is still shipped" + ); +} + +/// An embedding-role manifest is **out of this reader's scope**, and says so quietly. +/// +/// Its `embedding/{model_id}` grammar is not in the still format set and `crate::ml` produces no +/// derivative manifest at all, so the reader neither ships it nor complains about it — the +/// previous behaviour logged "no bytes on disk" for a manifest it had already decided not to +/// handle, which is a misleading warning for an artefact with no writer. +#[test] +fn an_embedding_role_manifest_is_out_of_scope_and_skipped() { + let lib = TempDir::new().unwrap(); + let src = TempDir::new().unwrap(); + let (_album, asset_id) = library_with_a_thumbnailed_asset(&lib, &src); + let ws = Workspace::open(lib.path(), b"passphrase", FAST).unwrap(); + + rewrite_bundle( + &lib, + &ws, + asset_id, + &[signed_derivative( + asset_id, + DerivativeRole::Embedding, + "embedding/mobileclip-b", + hash::hash_bytes(b"an embedding"), + )], + ); + + let bundle = ws.upload_bundle(&asset_id).unwrap(); + assert!( + bundle.derivatives.is_empty(), + "embeddings are not this reader's business until something produces them" + ); + assert_eq!( + verify_still_format(&signed_derivative( + asset_id, + DerivativeRole::Embedding, + "embedding/mobileclip-b", + hash::hash_bytes(b"an embedding"), + )), + Ok(None), + "and the closed-set check reports them as out of scope rather than rejecting them" + ); +} diff --git a/capsule-core/src/lqip/mod.rs b/capsule-core/src/lqip/mod.rs index 5f857d7a..b047773c 100644 --- a/capsule-core/src/lqip/mod.rs +++ b/capsule-core/src/lqip/mod.rs @@ -8,15 +8,15 @@ //! uniffi FFI, and read by the browser through `capsule-wasm`. A placeholder that differed by //! which client imported the photo would be a visible defect, so there is exactly **one** //! implementation and it must be reachable from all three. That rules out -//! `capsule_core::media`, which is gated behind the `media` feature (implying `native`) and -//! retires to `legacy-review/` with the rest of the decode/encode stack. It equally rules out +//! `capsule_core::media`, the retired decode/encode stack that lives in `legacy-review/` +//! and is `native`-only wherever it is restored. It equally rules out //! Rawshift, which `AGENTS.md` forbids from wrapping Chromahash — Capsule imports it directly. //! //! This module is therefore **unconditional**: no feature gate, no `native`, and it compiles //! for `wasm32-unknown-unknown` (chromahash has zero runtime dependencies and ships a //! `simd128` backend). The one part that cannot be unconditional is the bridge to the sidecar -//! record, because `crate::sidecar` is itself `native`-gated; that lives in [`sidecar`], behind -//! the same gate, over this same encoder. +//! record, because `crate::sidecar` is itself `native`-gated; that lives in +//! [`sidecar`](crate::lqip::sidecar), behind the same gate, over this same encoder. //! //! # The contract //! @@ -27,10 +27,10 @@ //! //! | Call | Role here | //! | --- | --- | -//! | `encode(w, h, &rgba, gamut)` | [`Lqip::encode`] — generation at the default tier. | -//! | `decode_capped(max_w, max_h)` | [`Lqip::decode_capped`] — the band-limited render. | -//! | `average_color()` | [`Lqip::dominant_color`] — the DC-only fallback fill. | -//! | `from_bytes` / `as_bytes` | [`Lqip::from_bytes`] / [`Lqip::as_bytes`] — the sidecar round trip. | +//! | `encode(w, h, &rgba, gamut)` | [`Lqip::encode`](crate::lqip::Lqip::encode) — generation at the default tier. | +//! | `decode_capped(max_w, max_h)` | [`Lqip::decode_capped`](crate::lqip::Lqip::decode_capped) — the band-limited render. | +//! | `average_color()` | [`Lqip::dominant_color`](crate::lqip::Lqip::dominant_color) — the DC-only fallback fill. | +//! | `from_bytes` / `as_bytes` | [`Lqip::from_bytes`](crate::lqip::Lqip::from_bytes) / [`Lqip::as_bytes`](crate::lqip::Lqip::as_bytes) — the sidecar round trip. | //! //! Notably absent: any pre-resize of the source. The 100 px downscale the retired ThumbHash //! implementation performed before hashing was a ThumbHash artifact — chromahash takes the full @@ -39,11 +39,12 @@ //! //! # Versioned fallback //! -//! [`LQIP_FORMAT_V1`] tags the payload inside the sidecar. A reader that does not recognize the -//! version — or that holds bytes `from_bytes` rejects — paints the solid `dominant_color` fill -//! instead of misrendering (see [`render`]). That is the mechanism that makes a future -//! chromahash revision a versioned change rather than a silent break, and it is also what makes -//! any stale non-chromahash payload degrade to a flat colour rather than to noise. +//! [`LQIP_FORMAT_V1`](crate::lqip::LQIP_FORMAT_V1) tags the payload inside the sidecar. A reader +//! that does not recognize the version — or that holds bytes `from_bytes` rejects — paints the +//! solid `dominant_color` fill instead of misrendering (see [`render`](crate::lqip::render)). +//! That is the mechanism that makes a future chromahash revision a versioned change rather than a +//! silent break, and it is also what makes any stale non-chromahash payload degrade to a flat +//! colour rather than to noise. use thiserror::Error; diff --git a/capsule-core/src/media/decode.rs b/capsule-core/src/media/decode.rs new file mode 100644 index 00000000..4f32bc86 --- /dev/null +++ b/capsule-core/src/media/decode.rs @@ -0,0 +1,399 @@ +//! The decode seam: bytes in, orientation-applied RGBA8 out. +//! +//! # The pipeline, and why each step is here +//! +//! 1. **Identify** ([`StillFormat::detect`]) — header first, so a `.jpg` that is really a HEIC +//! is classified as HEIC. +//! 2. **Gate** ([`StillFormat::is_decodable`]) — refuse with a typed +//! [`MediaError::UnsupportedFormat`] *before* touching a decoder, so a HEIC never reaches a +//! stub that would return a less informative error. +//! 3. **Budget** ([`MAX_DECODE_PIXELS`]) — a header-only +//! [`probe`](Decoder::probe) refuses an oversized frame before the decoder allocates. It has +//! to be pre-decode: `rawshift-image` decodes to interleaved RGB `u16`, so the bomb is +//! inside the decoder, not in Capsule's copy of the result. +//! 4. **Decode**, then **apply the EXIF orientation** to the pixels, so the frame this module +//! returns is always upright and its dimensions are the ones a viewer shows. +//! 5. **Normalise** to packed RGBA8, which is what [`crate::lqip`] and the downscale both take. +//! +//! # Two lossy edges, both deliberate and both asserted +//! +//! - **Alpha is lost.** `rawshift-image`'s decode target is interleaved RGB `u16` with no alpha +//! channel, and `decode_png` drops the alpha channel outright. Every frame this module +//! returns is therefore opaque. Nothing downstream needs alpha — the LQIP is an opaque +//! placeholder and the thumbnail is composited onto a grid — but a caller must not *assume* +//! transparency survived, so a test pins the flattening rather than leaving it to be +//! discovered. +//! - **16-bit is narrowed to 8.** `(sample >> 8) as u8` is the exact inverse of the crate's own +//! `u8_to_u16` widening (`v * 257`), so an 8-bit source round-trips bit-exactly and only a +//! genuinely deeper source loses its low byte. That is the same narrowing every encode +//! backend in the crate performs anyway. +//! +//! # The panic guard +//! +//! [`decode_guarded`] wraps a [`Decoder`] call in [`std::panic::catch_unwind`]. It is a free +//! function over the trait rather than a detail inside [`RawshiftDecoder`] for one reason: a +//! test has to be able to prove the guard holds, and it can only do that by injecting a +//! [`Decoder`] that panics. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use rawshift_image::core::ColorSpace; +use rawshift_image::core::image::RgbImage; +use rawshift_image::formats::{ + StandardFormat, decode_standard_image, probe_standard_image, read_standard_image_metadata, +}; +use rawshift_image::transforms::orientation::apply_orientation; + +use super::detect::{MAX_DECODE_PIXELS, StillFormat}; +use super::error::{FormatOp, MediaError}; +use crate::lqip::{Gamut, RgbaImage}; + +/// The EXIF orientation value meaning "already upright". +const ORIENTATION_NORMAL: u16 = 1; + +/// What a header-only probe can say about a still, normalised onto Capsule's own types. +/// +/// This is the "metadata normalisation" half of the module: `rawshift-image` reports a format, +/// a size, an optional bit depth and a colour space, plus (from a separate EXIF read) an +/// orientation. None of those types may appear in Capsule's public surface — they belong to a +/// pre-1.0 dependency — so each is mapped onto a Capsule type here, once. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MediaMetadata { + /// The identified format. + pub format: StillFormat, + /// Dimensions **as stored**, i.e. before the EXIF orientation is applied. A probe reads the + /// codec header, which knows nothing about the orientation tag; the upright dimensions are + /// what [`DecodedImage`] carries. + pub stored_dimensions: (u32, u32), + /// The EXIF orientation tag (1..=8), where the format carries one and it was readable. + pub orientation: Option, + /// Bits per channel, where the header exposes it cheaply. + pub bit_depth: Option, + /// The gamut [`crate::lqip::Lqip::encode`] is told to interpret the samples in. + /// + /// **Always [`Gamut::Srgb`] in this build, and that is upstream's doing rather than a + /// choice made here.** `probe_standard_image` hard-codes `ColorSpace::Srgb` into every + /// `ImageProbe` it returns, and `decode_standard_image` likewise tags every decoded frame + /// sRGB without converting — so `rawshift-image` 0.1.1 reports no source gamut for a + /// standard format at all, whatever the file's ICC profile says. The consequence is a + /// fidelity limitation, not a correctness bug: a Display P3 source gets a placeholder + /// interpreted as sRGB, i.e. slightly under-saturated, which is the direction slice `S-B14` + /// chose when it had to pick one. This module's private `gamut_of` is kept as the single + /// mapping point for when + /// the crate does start reporting it. + pub gamut: Gamut, +} + +impl MediaMetadata { + /// The dimensions a viewer shows: the stored ones, transposed when the orientation tag is + /// one of the four quarter-turns (5, 6, 7, 8). + pub const fn upright_dimensions(&self) -> (u32, u32) { + let (width, height) = self.stored_dimensions; + match self.orientation { + Some(5..=8) => (height, width), + _ => (width, height), + } + } +} + +/// A decoded still: upright, opaque, packed RGBA8. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedImage { + /// The pixels, `width * height * 4` bytes, alpha uniformly `255`. + /// + /// [`crate::lqip::RgbaImage`] rather than a new buffer type: it is already the shape + /// [`crate::lqip::Lqip::encode`] and [`downscale_rgba8`](super::downscale_rgba8) take, and + /// it is unconditional, so no `media`-only type reaches the LQIP contract. + pub image: RgbaImage, + /// The source colour space this frame's samples are in. + pub gamut: Gamut, + /// The EXIF orientation value that was **consumed** — the transform is already applied to + /// `image`, so a renderer that rotates again is double-applying. `1` when the source + /// carried no tag. + pub orientation_applied: u16, + /// The format the pixels came out of. + pub format: StillFormat, +} + +impl DecodedImage { + /// Frame width in pixels, upright. + pub const fn width(&self) -> u32 { + self.image.width + } + + /// Frame height in pixels, upright. + pub const fn height(&self) -> u32 { + self.image.height + } +} + +/// The still-decode seam. +/// +/// A trait with exactly one production implementation ([`RawshiftDecoder`]), and that is the +/// point: the failure modes worth testing — a panicking decoder, a decoder that reports +/// dimensions its buffer does not match — cannot be produced from real bytes on demand, so they +/// are injected. +pub trait Decoder { + /// Read the format, dimensions and orientation from the header without decoding pixels. + /// + /// # Errors + /// [`MediaError::NotAStillImage`] when nothing recognisable is there, + /// [`MediaError::UnsupportedFormat`] when the format has no decoder in this build, and + /// [`MediaError::PixelBudgetExceeded`] when the header claims more than + /// [`MAX_DECODE_PIXELS`]. + fn probe(&self, bytes: &[u8], ext: &str) -> Result; + + /// Decode to upright, packed RGBA8. Probes first, so every [`probe`](Self::probe) error is + /// also a `decode` error. + /// + /// # Errors + /// As [`probe`](Self::probe), plus [`MediaError::Decode`] when a supported format's bytes + /// do not decode and [`MediaError::BufferLengthMismatch`] / [`MediaError::ZeroDimension`] + /// when the decoder's own output is inconsistent. + fn decode(&self, bytes: &[u8], ext: &str) -> Result; +} + +/// The `rawshift-image`-backed [`Decoder`] — the only production implementation. +#[derive(Debug, Clone, Copy, Default)] +pub struct RawshiftDecoder; + +impl Decoder for RawshiftDecoder { + #[tracing::instrument(level = "debug", skip_all, fields(bytes = bytes.len(), ext))] + fn probe(&self, bytes: &[u8], ext: &str) -> Result { + let format = gate(bytes, ext, FormatOp::Decode)?; + let probe = probe_standard_image(bytes).map_err(|e| MediaError::Decode { + format, + detail: format!("header probe: {e}"), + })?; + let (width, height) = (probe.size.width, probe.size.height); + if width == 0 || height == 0 { + return Err(MediaError::ZeroDimension { width, height }); + } + let pixels = u64::from(width) * u64::from(height); + if pixels > MAX_DECODE_PIXELS { + tracing::warn!( + %format, + width, + height, + pixels, + limit = MAX_DECODE_PIXELS, + "media: refusing an oversized still before the decoder allocates" + ); + return Err(MediaError::PixelBudgetExceeded { + pixels, + limit: MAX_DECODE_PIXELS, + }); + } + let metadata = MediaMetadata { + format, + stored_dimensions: (width, height), + orientation: orientation_of(bytes, format), + bit_depth: probe.bit_depth, + gamut: gamut_of(probe.color_space), + }; + tracing::debug!( + %format, + width, + height, + orientation = ?metadata.orientation, + bit_depth = ?metadata.bit_depth, + gamut = ?metadata.gamut, + "media: probed a still" + ); + Ok(metadata) + } + + #[tracing::instrument(level = "debug", skip_all, fields(bytes = bytes.len(), ext))] + fn decode(&self, bytes: &[u8], ext: &str) -> Result { + let probed = self.probe(bytes, ext)?; + let format = probed.format; + let mut rgb = decode_standard_image(bytes, standard_format(format)).map_err(|e| { + MediaError::Decode { + format, + detail: e.to_string(), + } + })?; + check_rgb_buffer(&rgb, format)?; + + let orientation = probed.orientation.unwrap_or(ORIENTATION_NORMAL); + if orientation != ORIENTATION_NORMAL { + apply_orientation(&mut rgb, orientation); + check_rgb_buffer(&rgb, format)?; + } + + let image = to_rgba8(&rgb); + tracing::debug!( + %format, + width = image.width, + height = image.height, + orientation, + "media: decoded a still" + ); + Ok(DecodedImage { + image, + gamut: probed.gamut, + orientation_applied: orientation, + format, + }) + } +} + +/// Run `decoder.decode` with the unwind boundary an import needs. +/// +/// A pre-1.0 decoder fed untrusted bytes is exactly where a panic is plausible, and a missing +/// thumbnail must never be able to abort an import that has already written signed, encrypted +/// bytes. A caught unwind becomes [`MediaError::DecoderPanic`] — reported, not swallowed. +pub fn decode_guarded( + decoder: &dyn Decoder, + bytes: &[u8], + ext: &str, +) -> Result { + guarded("decode", || decoder.decode(bytes, ext)) +} + +/// Run any fallible step of the still pipeline behind the same unwind boundary. +/// +/// `pub(crate)` rather than `pub`: `lifecycle` is the only caller and no client of this crate has +/// pixels of its own to run through it, so exporting it would widen the frozen surface for +/// nothing. +/// +/// Not just for `decode` — that is not the only third-party code the import path runs over pixels: +/// the placeholder goes through `chromahash` (also pre-1.0) and the derivative through +/// `zune-jpegxl`, and the module's promise is that *none* of them can abort an import — not that the +/// decoder specifically cannot. `stage` names the step in the warning so a caught panic is +/// attributable. +/// +/// `AssertUnwindSafe` is sound for the callers here: each closure borrows shared slices and +/// stateless values, so a caught unwind cannot leave a Capsule-owned invariant torn. +pub(crate) fn guarded( + stage: &'static str, + step: impl FnOnce() -> Result, +) -> Result { + if let Ok(result) = catch_unwind(AssertUnwindSafe(step)) { + return result; + } + tracing::warn!( + stage, + "media: a third-party codec panicked; the original is imported without a derivative" + ); + Err(MediaError::DecoderPanic) +} + +/// Identify a still and refuse anything this build has no codec for, before any decoder runs. +fn gate(bytes: &[u8], ext: &str, op: FormatOp) -> Result { + let Some(format) = StillFormat::detect(bytes, ext) else { + return Err(MediaError::NotAStillImage); + }; + if !format.is_decodable() { + return Err(MediaError::UnsupportedFormat { format, op }); + } + Ok(format) +} + +/// Map Capsule's format onto the crate's. +/// +/// Only the decodable set reaches here through [`gate`], which refuses the rest first — of the +/// grouped arms below, `Tiff` is the sole reachable one. The unreachable variants are still +/// mapped to the container the crate would actually see (a TIFF-based RAW *is* a TIFF to it, and +/// a CR3 is an ISO-BMFF file it can only reach through its HEIC arm) rather than panicking, so a +/// future `is_decodable` widening that forgets this table degrades to a decode error instead of +/// aborting an import. +fn standard_format(format: StillFormat) -> StandardFormat { + match format { + StillFormat::Jpeg => StandardFormat::Jpeg, + StillFormat::Png => StandardFormat::Png, + StillFormat::WebP => StandardFormat::WebP, + StillFormat::Jxl => StandardFormat::Jxl, + StillFormat::Gif => StandardFormat::Gif, + StillFormat::Ppm => StandardFormat::Ppm, + StillFormat::Avif => StandardFormat::Avif, + // `Tiff` is reachable and is the reason this arm exists; the RAW families ride along + // because a TIFF-based RAW is a TIFF to the crate. + StillFormat::Tiff + | StillFormat::Arw + | StillFormat::Cr2 + | StillFormat::Crw + | StillFormat::Dng + | StillFormat::Nef + | StillFormat::Raf => StandardFormat::Tiff, + StillFormat::Heic | StillFormat::Cr3 => StandardFormat::Heic, + } +} + +/// The EXIF orientation tag, where the format carries one. Only JPEG, TIFF, WebP, PNG and AVIF +/// have an EXIF block the crate's parser reads; the others return `None` rather than guessing. +fn orientation_of(bytes: &[u8], format: StillFormat) -> Option { + let metadata = read_standard_image_metadata(bytes, standard_format(format)); + // Only the eight defined values are honoured. `apply_orientation` warns and no-ops on + // anything else, which would leave `orientation_applied` claiming a transform that never + // happened — so an out-of-range tag is dropped here instead. + metadata.image.orientation.filter(|o| (1..=8).contains(o)) +} + +/// Map the crate's colour space onto the gamut the LQIP encoder takes. +/// +/// `LinearSrgb` and `Unknown` both become [`Gamut::Srgb`]: `Linear` names a transfer function +/// rather than a gamut, and sRGB primaries are the only safe assumption for an untagged source +/// (over-saturating is worse than under-saturating — the resolution slice `S-B14` recorded). +/// +/// **The wide-gamut arms are unreachable today**, because `probe_standard_image` hard-codes +/// `ColorSpace::Srgb` for every format (see [`MediaMetadata::gamut`]). They are here as the one +/// place that has to change when the crate starts reporting a source gamut, rather than as a +/// claim that it already does. +fn gamut_of(color_space: ColorSpace) -> Gamut { + match color_space { + ColorSpace::DisplayP3 => Gamut::DisplayP3, + ColorSpace::AdobeRgb => Gamut::AdobeRgb, + ColorSpace::Rec2020 => Gamut::Bt2020, + ColorSpace::ProPhotoRgb => Gamut::ProPhotoRgb, + // `Srgb`, `LinearSrgb`, `Unknown`, and — because `ColorSpace` is `#[non_exhaustive]` — + // any variant a future release adds. One wildcard rather than an explicit list plus a + // catch-all, since the answer is the same and two arms with one body only look like a + // distinction. sRGB is the conservative default: under-saturating a wide-gamut source + // is a smaller defect than over-saturating a narrow one. + _ => Gamut::Srgb, + } +} + +/// Refuse a decoder result whose buffer does not match the dimensions it reports. +/// +/// `RgbImage::new` performs no validation and `set_size` is public, so a decoder bug (or a +/// transform bug) can produce an inconsistent value. Checked here because the very next thing +/// Capsule does is index that buffer by those dimensions. +fn check_rgb_buffer(rgb: &RgbImage, format: StillFormat) -> Result<(), MediaError> { + let (width, height) = (rgb.width(), rgb.height()); + if width == 0 || height == 0 { + return Err(MediaError::ZeroDimension { width, height }); + } + let expected = u128::from(width) * u128::from(height) * 3; + let actual = rgb.data.len() as u128; + if expected != actual { + return Err(MediaError::BufferLengthMismatch { + format, + width, + height, + expected, + actual, + }); + } + Ok(()) +} + +/// Narrow interleaved RGB `u16` to packed, opaque RGBA8. +/// +/// `sample >> 8` is the exact inverse of the crate's `v * 257` widening, so an 8-bit source is +/// reproduced bit-for-bit. +fn to_rgba8(rgb: &RgbImage) -> RgbaImage { + let mut rgba = Vec::with_capacity(rgb.data.len() / 3 * 4); + for px in rgb.data.chunks_exact(3) { + rgba.push((px[0] >> 8) as u8); + rgba.push((px[1] >> 8) as u8); + rgba.push((px[2] >> 8) as u8); + rgba.push(u8::MAX); + } + RgbaImage { + width: rgb.width(), + height: rgb.height(), + rgba, + } +} diff --git a/capsule-core/src/media/derivative.rs b/capsule-core/src/media/derivative.rs new file mode 100644 index 00000000..780d202f --- /dev/null +++ b/capsule-core/src/media/derivative.rs @@ -0,0 +1,495 @@ +//! Still-derivative tiers, the closed format set, and the signed manifests over them. +//! +//! SSoT for the tiers and the formats: [Thumbnails and Previews](https://docs/design/thumbnails/). +//! This module owns the Capsule-side half — sizing, the closed enum, and building + signing a +//! [`DerivativeManifest`] through the same two-signature path assets use +//! ([`DerivativeCore::sign`]) — while `rawshift-image` owns the byte encode. +//! +//! # The closed format set, and where it is enforced +//! +//! [`DerivativeFormat`] is the tier table's format column as a closed enum. `format` is a +//! `String` in the signed struct and **stays** one, deliberately: the same field carries +//! `embedding/{model_id}` for embedding-role manifests (`crate::ml`), so a still-only enum +//! cannot be its type; and a `try_from` newtype would make +//! an *older* manifest carrying a future codec fail at deserialisation, turning a policy +//! rejection into a parse error before any signature is examined. The closed set is therefore +//! enforced at the two boundaries the contract names: +//! +//! - **production** — [`generate_still_derivatives`] only ever writes +//! [`DerivativeFormat::mime`], so no other value can be authored here; +//! - **verification** — [`verify_still_format`](crate::derivative_format::verify_still_format) +//! rejects a still-role manifest whose `format` does not parse, which is the structural +//! rejection the tier table specifies. +//! +//! Note the asymmetry in how those three are linked. Decision 24 moved all of them out of this +//! feature-gated module so the crates that *receive* a manifest can link the check; this file +//! then imported `DerivativeFormat`, but not the verification function, which it never calls. +//! The function therefore has to be written out as `crate::derivative_format::…`, because a bare +//! name for it resolves only through the `media` re-export — which holds under some feature +//! unions and not others, so it was a live link in isolation and a broken one in the merged +//! tree. The other two resolve through the import above, and spelling them out as well would be +//! a redundant target. +//! +//! # What this build encodes +//! +//! **JXL only, and losslessly.** `image/jxl` is the tier table's committed *master* format, so +//! the format that ships first is the one the table already puts first — but the pure-Rust +//! backend is `zune-jpegxl`'s `JxlSimpleEncoder`, which is lossless, so the tier's `q=50` is +//! passed through and ignored and a thumbnail costs more bytes than the table intends. A lossy +//! JXL needs C libjxl (`bindgen` + `pkg-config`). +//! +//! WebP — the table's last-resort delivery variant, and the obvious cheap lossy encoder — is +//! **not available at all**: `rawshift-image` 0.1.1's WebP module does not compile for aarch64 +//! (it passes `*const i8` where `libwebp-sys` declares `*const c_char`, and `c_char` is `u8` +//! there), and every mobile target Capsule ships is aarch64. AVIF needs `nasm` on every x86_64 +//! build host. Each is recorded as a per-`(tier, format)` deferral on +//! [`StillDerivatives::deferred`] and warned once, so the gap is countable rather than +//! invisible. +//! +//! [`DerivativeManifest`]: crate::crypto::provenance::DerivativeManifest +//! [`DerivativeCore::sign`]: crate::crypto::provenance::manifest::DerivativeCore::sign + +use std::collections::HashMap; +use std::fmt; + +use rawshift_image::core::image::RgbImage; +use rawshift_image::core::metadata::ImageMetadata; +use rawshift_image::core::{BitDepth, ColorSpace, MetadataEmbedOptions}; +use rawshift_image::formats::encode_rgb_image_to_vec; +use rawshift_image::formats::export::{CommonEncodeOptions, EncodeOptions, ZuneJxlEncodeConfig}; +use uuid::Uuid; + +use super::decode::DecodedImage; +use super::error::MediaError; +use super::resize::downscale_rgba8; +use crate::cbor; +use crate::crypto::CryptoError; +use crate::crypto::encryption::stream::NONCE_PREFIX_LEN; +use crate::crypto::hash::{self, Hash32}; +use crate::crypto::keys::{AmkVersion, Signer}; +use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::derivative_format::DerivativeFormat; +use crate::lqip::RgbaImage; + +/// A derivative tier from the tier table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DerivativeTier { + /// Grid display: long edge capped at 256 px, q=50. + Thumbnail, + /// Lightbox / single-asset view: source resolution, q=70. **Not generated by this build** + /// — a source-resolution derivative is only worth its bytes in the master codec, and the + /// master codec is the half that is still blocked on a toolchain. Kept in the enum because + /// the tier table commits to it and the sizing rule is the contract. + Preview, +} + +impl DerivativeTier { + /// The tiers this build actually generates. + pub const GENERATED: [Self; 1] = [Self::Thumbnail]; + + /// The provenance role this tier records. + pub const fn role(self) -> DerivativeRole { + match self { + Self::Thumbnail => DerivativeRole::Thumbnail, + Self::Preview => DerivativeRole::Preview, + } + } + + /// The role's on-disk name — mirrors `derivative_role_name` in the upload bundle reader, + /// which composes a derivative's exact path from this **and** the manifest's format, so two + /// formats of one role stay distinguishable. + pub const fn role_name(self) -> &'static str { + match self { + Self::Thumbnail => "thumbnail", + Self::Preview => "preview", + } + } + + /// Long-edge cap in pixels, or `None` to keep the source resolution. The 1080p cap in the + /// tier table governs the *video* preview transcode (slice `S-B5`), not the still preview. + pub const fn max_long_edge(self) -> Option { + match self { + Self::Thumbnail => Some(256), + Self::Preview => None, + } + } + + /// Lossy encoder quality for this tier, on the 0..=100 scale every backend here uses. + pub const fn quality(self) -> f32 { + match self { + Self::Thumbnail => 50.0, + Self::Preview => 70.0, + } + } +} + +impl fmt::Display for DerivativeTier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.role_name()) + } +} + +/// What a derivative's signed manifest has to commit to about its **ciphertext**. +/// +/// Derivative bytes cross the network encrypted, exactly like the original +/// ([Encryption](https://docs/design/cryptography/encryption/) — "every asset — original bytes, +/// derivative bytes, metadata blob — is encrypted client-side"), so the content address a +/// manifest signs is the address of the *ciphertext*, and the nonce prefix that produced it is +/// signed alongside because it also selects the key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SealedDerivative { + /// SHA-256 over the derivative ciphertext. + pub ciphertext_hash: Hash32, + /// The STREAM nonce prefix the ciphertext was produced under. + pub nonce_prefix: [u8; NONCE_PREFIX_LEN], +} + +/// The encryption seam between `media` and `lifecycle`. +/// +/// `media` owns pixels and knows nothing about album keys; `lifecycle` owns the AMK and must +/// not own codecs. A derivative's manifest cannot be signed until its ciphertext exists — the +/// hash is *of* the ciphertext — so the encryption has to happen inside generation, and this is +/// the narrowest thing that lets it: one method, no key material in any `media` signature. +/// +/// Each call must draw a **fresh** nonce prefix, so two derivatives of one asset get distinct +/// keys and distinct ciphertexts even when their plaintext is identical. +pub trait DerivativeSealer { + /// Encrypt `plaintext` under the source asset's identity and epoch, returning what the + /// manifest commits to. The ciphertext itself is discarded: clients keep the plaintext + /// derivative locally and re-derive the ciphertext at push time from the recorded prefix, + /// exactly as they do for the original. + /// + /// # Errors + /// [`MediaError::Sign`], and only that variant. Two things can go wrong: the underlying + /// encryption refuses, or no unused nonce prefix can be drawn for this `file_id` inside the + /// implementation's retry budget. Both are **workspace** faults rather than pixel ones — a + /// broken signer or a broken CSPRNG, not a photo this build cannot render — so the import + /// path propagates them instead of degrading to a missing thumbnail. An implementation must + /// therefore not report either as [`MediaError::Encode`], which means a codec refused a + /// frame and nothing else. + /// + /// `replaces` plays no part here: a derivative supersedes nothing, so the production + /// implementation passes `None` and enforces non-reuse against the set of prefixes already + /// spent on this `file_id` instead. + fn seal(&self, plaintext: &[u8]) -> Result; +} + +/// Everything the manifest signer needs that the pixels do not carry: the asset identity, the +/// epoch/authorisation context, and the two signing keys that produce the manifest's two hybrid +/// signatures. +pub struct DerivativeContext<'a> { + /// The asset the derivatives are generated from. + pub source_asset_id: Uuid, + /// Primitive bundle in force. + pub crypto_suite_id: u16, + /// Date-based wire protocol version (matches the album pin). + pub protocol_version: String, + /// The AMK epoch whose write-tier key signs the manifests. + pub amk_version: AmkVersion, + /// Device that generated the derivatives. + pub generated_by_device: Uuid, + /// Generating client version string. + pub generated_by_client: String, + /// RFC 3339 generation time (audit-only). + pub generated_at: String, + /// The device DSK (provenance signature); may be hardware-backed. + pub device_signer: &'a dyn Signer, + /// The per-epoch write-tier key (authorisation signature). + pub write_tier_signer: &'a dyn Signer, + /// Encrypts each generated derivative so its manifest can commit to the ciphertext. + pub sealer: &'a dyn DerivativeSealer, + /// The current head of each role's derivative chain, when this asset already has one. + /// + /// A `HashMap` rather than a `BTreeMap` because [`DerivativeRole`] derives `Hash + Eq` and + /// not `Ord`, and adding `Ord` to a signed wire type to key a lookup would be the tail + /// wagging the dog. Iteration order never escapes this map — it is only ever queried by + /// key — so the non-determinism a `HashMap` would otherwise introduce cannot reach the + /// signed bytes. + /// + /// A role's manifests are append-only **across time**, not merely within one call: a + /// backfill that adds the AVIF variant to an asset that already has a JXL thumbnail extends + /// that role's chain rather than starting a second one. Empty on a create, which is why the + /// import path passes an empty map; a regeneration reads it off the persisted bundle. + pub prior_heads: &'a HashMap, + /// What the **original**'s own manifest committed to. The `original` sentinel generates no + /// bytes and encrypts nothing: it is a reference to the original blob, so it signs the + /// original's ciphertext address and the original's nonce prefix, and a receiver resolves it + /// against the blob it already has. + pub original: SealedDerivative, +} + +/// One generated derivative: the encoded bytes plus its signed manifest. +#[derive(Debug, Clone)] +pub struct GeneratedDerivative { + /// Which tier this is. + pub tier: DerivativeTier, + /// Which committed format, or the `Original` sentinel. + pub format: DerivativeFormat, + /// The derivative bytes — the encoder output, and **empty** for + /// [`DerivativeFormat::Original`], whose manifest is a reference to the original rather than + /// a copy of it. + pub bytes: Vec, + /// The signed manifest binding `hash(bytes)`, the role and the format. + pub manifest: DerivativeManifest, +} + +/// The outcome of one asset's still-derivative generation. +/// +/// `deferred` is the per-`(tier, format)` gap S-B13 asks for: a pair with no encoder in this +/// build is *recorded*, not collapsed into the asset-level status. The asset is still +/// `DerivativeStatus::Decoded` — the decode succeeded and a renderable derivative exists — +/// which is why the two live in different places. +#[derive(Debug, Clone, Default)] +pub struct StillDerivatives { + /// The derivatives that were produced, in generation order. + pub generated: Vec, + /// The `(tier, format)` pairs the tier table commits to and this build cannot encode. + pub deferred: Vec<(DerivativeTier, DerivativeFormat)>, +} + +/// Generate the committed still derivatives for `decoded` across `tiers`, signing a +/// [`DerivativeManifest`] for each. +/// +/// Per tier: +/// - if the tier caps the long edge and the source is **not larger** than the cap, a single +/// `format = "original"` manifest is signed as a *reference* to the original blob (no bytes, +/// nothing encrypted, committing to [`DerivativeContext::original`]) — the +/// redundant-derivative sentinel from the contract, never a re-encode; +/// - otherwise the frame is downscaled to the tier, encoded to each encodable format of +/// [`DerivativeFormat::STILL_DELIVERY_ORDER`], and **encrypted** through +/// [`DerivativeContext::sealer`] before its manifest is signed over the ciphertext's address; +/// the formats with no encoder here are recorded as deferrals. +/// +/// Manifests of the same role are hash-chained in generation order, so a role's derivative +/// provenance is append-only exactly like the asset's. +/// +/// # Errors +/// - [`MediaError::Encode`] — a codec refused the frame. That is the **only** thing this variant +/// means here. +/// - [`MediaError::ZeroDimension`] — the source frame has a zero dimension. +/// - [`MediaError::Sign`] — the device signer or the epoch write-tier signer refused, or +/// [`DerivativeContext::sealer`] failed (an encryption refusal, or an exhausted nonce-prefix +/// draw). +/// +/// The split is the contract rather than bookkeeping. The import path degrades the first two to +/// "this asset has no thumbnail" and commits the original anyway; it **propagates** `Sign`, +/// because a workspace that cannot author a signed record is broken in a way a missing +/// derivative is not — the same fault would stop the asset's own manifest. +#[tracing::instrument( + level = "debug", + skip_all, + fields(asset_id = %ctx.source_asset_id, tiers = tiers.len()) +)] +pub fn generate_still_derivatives( + decoded: &DecodedImage, + tiers: &[DerivativeTier], + ctx: &DerivativeContext<'_>, +) -> Result { + if decoded.width() == 0 || decoded.height() == 0 { + return Err(MediaError::ZeroDimension { + width: decoded.width(), + height: decoded.height(), + }); + } + + let mut out = StillDerivatives::default(); + let source_long_edge = decoded.width().max(decoded.height()); + + for &tier in tiers { + // Each tier records a distinct role, so its manifests form their own chain — continued + // from wherever that role left off, not restarted. + let mut prior: Option = ctx.prior_heads.get(&tier.role()).copied(); + + if let Some(cap) = tier.max_long_edge() + && source_long_edge <= cap + { + tracing::debug!( + asset_id = %ctx.source_asset_id, + %tier, + source_long_edge, + cap, + "media: source is within the tier cap; signing the `original` sentinel" + ); + // A reference to the original blob: no bytes of its own, and **nothing encrypted** + // — it commits to the original's own ciphertext address and nonce prefix, and the + // receiver resolves it against the blob it already holds. See + // `DerivativeFormat::Original`. + out.generated.push(sign_derivative( + ctx, + tier, + DerivativeFormat::Original, + Vec::new(), + ctx.original, + &mut prior, + )?); + continue; + } + + let work = match tier.max_long_edge() { + Some(cap) => downscale_rgba8(&decoded.image, cap), + None => decoded.image.clone(), + }; + for format in DerivativeFormat::STILL_DELIVERY_ORDER { + if !format.is_encodable() { + tracing::warn!( + asset_id = %ctx.source_asset_id, + %tier, + %format, + "media: no encoder for this (tier, format) in this build; the tier still \ + ships its encodable variants and this pair is backfillable (S-B1 remainder)" + ); + out.deferred.push((tier, format)); + continue; + } + // Guarded: `libwebp`'s successor here is `zune-jpegxl`, also pre-1.0, and a panic + // inside it must not abort an import that may hold the only copy of a file. The + // stage name makes a caught unwind attributable to the encoder rather than to the + // decoder that ran before it. + let bytes = super::decode::guarded("encode", || encode(&work, format, tier))?; + // Encrypt before signing: the manifest's content address is the ciphertext's, so + // the ciphertext has to exist first. A fresh prefix per derivative, so two + // derivatives of one asset never share a key even for identical plaintext. + let sealed = ctx.sealer.seal(&bytes)?; + out.generated.push(sign_derivative( + ctx, tier, format, bytes, sealed, &mut prior, + )?); + } + } + + tracing::debug!( + asset_id = %ctx.source_asset_id, + generated = out.generated.len(), + deferred = out.deferred.len(), + "media: still derivatives generated" + ); + Ok(out) +} + +/// Encode a tier-sized RGBA8 frame to `format`. +/// +/// **Every encode passes [`MetadataEmbedOptions::none`] and an empty [`ImageMetadata`]**, and +/// both are load-bearing rather than tidy: the crate's own default is `all()`, so a +/// default-configured encode copies the source's EXIF — GPS fix included — into the derivative +/// bytes, and the JXL backend has a working `append_to_jxl` that would do exactly that. A +/// thumbnail is the derivative most likely to be served widest, so leaking a home address into +/// it would be the worst possible place for that default to win. Passing empty metadata means +/// the source's block is never even read. A test asserts the absence rather than trusting this +/// comment. +fn encode( + frame: &RgbaImage, + format: DerivativeFormat, + tier: DerivativeTier, +) -> Result, MediaError> { + let options = match format { + DerivativeFormat::Jxl => EncodeOptions::JxlZune(ZuneJxlEncodeConfig { + common: CommonEncodeOptions { + metadata: MetadataEmbedOptions::none(), + bit_depth: BitDepth::Eight, + }, + // The tier table's number, passed through as declared even though the backend + // ignores it: `JxlSimpleEncoder` is lossless, so today this is advisory. Keeping the + // contract's value here rather than hard-coding `0.0` (the crate's explicit + // "lossless" request) makes the eventual libjxl swap a backend change and not a + // quality decision taken again from scratch. + quality: tier.quality(), + // Encoder effort, 1..=9; the simple encoder may ignore this too. 7 is the crate's + // own default and there is no reason to differ. + effort: 7, + }), + // Unreachable: `is_encodable` gates the call, and `Original` never routes here at all + // (it carries no bytes). Kept as a typed refusal rather than a panic so a future + // widening that forgets an arm degrades to a reported failure. Reported as + // `Encode { format }` and not `UnsupportedFormat`, because the latter names a + // `StillFormat` and there is no still format at fault here — the caller asked for a + // *derivative* format this build cannot write, and the message has to say which. + DerivativeFormat::WebP | DerivativeFormat::Avif | DerivativeFormat::Original => { + return Err(MediaError::Encode { + format, + detail: "this build links no encoder for this derivative format".to_string(), + }); + } + }; + + let rgb = to_rgb_u16(frame); + encode_rgb_image_to_vec(&rgb, &ImageMetadata::default(), &options).map_err(|e| { + MediaError::Encode { + format, + detail: e.to_string(), + } + }) +} + +/// Widen packed RGBA8 to the interleaved RGB `u16` the encoders take, dropping alpha. +/// +/// `v * 257` is the crate's own widening, and the encoders narrow it back with `v >> 8`, so an +/// 8-bit frame reaches the codec bit-for-bit. Alpha is dropped because the decode path already +/// flattened it — every frame here is opaque. +fn to_rgb_u16(frame: &RgbaImage) -> RgbImage { + let mut data = Vec::with_capacity(frame.rgba.len() / 4 * 3); + for px in frame.rgba.chunks_exact(4) { + data.push(u16::from(px[0]) * 257); + data.push(u16::from(px[1]) * 257); + data.push(u16::from(px[2]) * 257); + } + RgbImage::with_color_space(frame.width, frame.height, data, ColorSpace::Srgb) +} + +/// Build, sign and chain one derivative manifest. +/// +/// `bytes` is the **plaintext** the client keeps on disk; `sealed` is what the manifest actually +/// commits to — the ciphertext's content address and the nonce prefix that produced it. The two +/// are separate arguments because they are separate artefacts: only one of them ever crosses the +/// network, and only the other is ever painted locally. +/// +/// `pub(super)` so the module's tests can exercise the chaining directly. That is not test +/// convenience for its own sake: today exactly one still format is encodable, so a single call +/// to [`generate_still_derivatives`] produces one manifest per role and the multi-link case — +/// the part of the chain that can actually be wrong — is unreachable through the public entry +/// point until a second encoder lands. +pub(super) fn sign_derivative( + ctx: &DerivativeContext<'_>, + tier: DerivativeTier, + format: DerivativeFormat, + bytes: Vec, + sealed: SealedDerivative, + prior: &mut Option, +) -> Result { + let core = DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: ctx.crypto_suite_id, + protocol_version: Some(ctx.protocol_version.clone()), + amk_version: Some(ctx.amk_version), + source_asset_id: ctx.source_asset_id, + role: tier.role(), + format: format.mime().into(), + // The address of the **ciphertext**, not of `bytes`: `bytes` is the plaintext kept + // locally, and what a receiver content-addresses is what crossed the network. + ciphertext_hash: sealed.ciphertext_hash, + nonce_prefix: sealed.nonce_prefix, + generated_by_device: ctx.generated_by_device, + generated_by_client: ctx.generated_by_client.clone(), + model_id: None, + model_version: None, + generated_at: ctx.generated_at.clone(), + prior_provenance_hash: *prior, + }; + let manifest = core + .sign(ctx.device_signer, ctx.write_tier_signer) + .map_err(|e: CryptoError| MediaError::Sign { + detail: format!("signing the derivative manifest: {e}"), + })?; + // The next manifest of this role chains to this one: SHA-256 over its canonical CBOR, + // signatures included — the same content-hash link the asset provenance chain uses. + *prior = Some(hash::hash_bytes( + &cbor::to_canonical_vec(&manifest).map_err(|e| MediaError::Sign { + detail: format!("serialising the derivative manifest: {e}"), + })?, + )); + Ok(GeneratedDerivative { + tier, + format, + bytes, + manifest, + }) +} diff --git a/capsule-core/src/media/detect.rs b/capsule-core/src/media/detect.rs new file mode 100644 index 00000000..e2a001c2 --- /dev/null +++ b/capsule-core/src/media/detect.rs @@ -0,0 +1,280 @@ +//! Capsule's closed still-format set, its magic-byte table, and the codec-coverage predicate. +//! +//! # Why Capsule sniffs rather than delegating +//! +//! `rawshift-image` ships `detect_standard_format`, and Capsule deliberately does not use it as +//! the primary table. Its `heic | heis | hevc | hevx` arm is `#[cfg(feature = "heic-decode")]`, +//! so a build without the HEIC codec cannot *recognise* an Apple HEIC either — its major brand +//! is `heic`. That would make the typed refusal for exactly the formats this build cannot decode +//! depend on whether it can decode them: a HEIC would arrive as "not a still image" instead of +//! "a still image with no codec here", which is the difference between a reportable, +//! backfillable gap and an apparent non-image. Capsule's reference library is HEIC end to end, +//! so that distinction is the whole point of slice `S-B13`. +//! +//! Two smaller reasons ride along, both about the brand table rather than a feature: the crate +//! reads the generic HEIF brand `mif1` as **AVIF**, and it does not recognise `heix` or `msf1` +//! under any configuration. +//! +//! The two tables are held together by a test rather than by hope: +//! `capsule-core`'s `still_format_agrees_with_rawshift_detection` asserts that for every format +//! both sides define unconditionally, Capsule's sniff and `detect_standard_format` name the same +//! thing. +//! +//! # Bytes first, extension second +//! +//! Detection is by header, so a `.jpg` that is really a HEIC is classified as HEIC. The +//! extension is consulted in exactly two places, both of them cases a header genuinely cannot +//! settle: +//! +//! 1. **RAW refinement.** ARW, CR2, DNG and NEF are TIFF containers and CR3 is ISO-BMFF; their +//! headers are their container's, so only the extension distinguishes a Sony ARW from a +//! scanner's TIFF. A misrefinement costs a deferral, never wrong pixels, because no RAW +//! family decodes in this build. +//! 2. **Fallback.** When the header sniffs to nothing at all. + +use std::fmt; + +/// The decode budget in pixels, refused **before** the decoder allocates. +/// +/// **128 Mpx**, which admits every real camera — a 102 Mpx medium-format frame has ~25% +/// headroom — while bounding what one still can ask the process for. +/// +/// The bound is worth stating honestly, because the naive figure is a large understatement. A +/// frame at this ceiling costs, in sequence and with overlap: 0.77 GB for `zune-png`'s `u16` +/// samples, another 0.77 GB when `decode_png` reallocates to drop the alpha channel, 0.51 GB for +/// Capsule's RGBA8 copy, and 0.77 GB again when the encode path widens a tier-sized frame back +/// to RGB `u16`. Peak is on the order of **2.5 GB**, not the ~1 GB a single buffer suggests. +/// Since `media` is implied by `native`, that peak happens on a phone as well as a workstation, +/// where it is an OOM kill rather than an error — which is the reason this is not simply set as +/// high as an allocation bomb would require. +pub const MAX_DECODE_PIXELS: u64 = 128_000_000; + +/// The closed set of still-image formats Capsule models. +/// +/// Closed on purpose: a still Capsule cannot name is not a still it silently ignores, it is a +/// [`MediaError::NotAStillImage`](super::MediaError::NotAStillImage). Membership here says +/// "Capsule knows this is a photo"; [`is_decodable`](Self::is_decodable) says whether *this +/// build* can read its pixels. The two are deliberately separate — that gap is what +/// `DerivativeStatus::DeferredNoCodec` reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StillFormat { + /// JPEG / JFIF. Decoded by `zune-jpeg`. + Jpeg, + /// PNG. Decoded by `zune-png`; alpha is flattened at the decode boundary. + Png, + /// WebP. **Recognised, not decoded**, and unusually the codec exists — it just does not + /// compile. `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 + /// declares `*const c_char`, and `c_char` is `u8` on aarch64, so the crate's `webp` feature + /// is an E0308 on every 64-bit ARM target — which is every mobile target Capsule ships. The + /// module is compiled by decode *or* encode, so there is no decode-only escape. Enabling it + /// would mean thumbnails on desktop and none on a phone; recognising and deferring it is the + /// honest outcome until upstream fixes the cast. + WebP, + /// JPEG XL. Decoded by `jxl-oxide`. No lossy encoder without C libjxl. + Jxl, + /// TIFF. Decoded by the `tiff` crate. Also the container of most RAW families. + Tiff, + /// GIF. Decoded by `gif`; the first frame only. + Gif, + /// Netpbm (P5/P6/P7/PFM). Recognised, not decoded: the `ppm-decode` backend is deliberately + /// not enabled. Netpbm is an intermediate and test-fixture format rather than something a + /// photo library holds, so recognising it and deferring is the honest outcome — and it costs + /// one fewer dependency than a codec nobody's library needs. + Ppm, + /// AVIF. Recognised, not decoded — the backend needs system libdav1d. + Avif, + /// HEIC / HEIF. Recognised, not decoded — the backend needs system libheif. + Heic, + /// Sony ARW. + Arw, + /// Canon CR2. + Cr2, + /// Canon CR3. + Cr3, + /// Canon CRW. + Crw, + /// Adobe DNG (including Apple ProRAW). + Dng, + /// Nikon NEF. + Nef, + /// Fujifilm RAF. + Raf, +} + +/// Every format this build can read pixels out of — the codec-coverage table, in one place. +/// +/// Logged verbatim on a deferral so the gap is legible in the field rather than only in a doc. +pub const SUPPORTED_STILL_FORMATS: &[StillFormat] = &[ + StillFormat::Jpeg, + StillFormat::Png, + StillFormat::Jxl, + StillFormat::Tiff, + StillFormat::Gif, +]; + +/// The RAW families, all of them recognised and none of them decodable here. +const RAW_FORMATS: &[StillFormat] = &[ + StillFormat::Arw, + StillFormat::Cr2, + StillFormat::Cr3, + StillFormat::Crw, + StillFormat::Dng, + StillFormat::Nef, + StillFormat::Raf, +]; + +impl StillFormat { + /// Whether *this build* can read pixels out of the format — the single codec-coverage + /// predicate, and the gate the decode path checks before touching a decoder. + pub fn is_decodable(self) -> bool { + SUPPORTED_STILL_FORMATS.contains(&self) + } + + /// Whether the format is one of the RAW families. RAW is recognised, never decoded here; + /// its container is TIFF or ISO-BMFF, so the extension is what names the family. + pub fn is_raw(self) -> bool { + RAW_FORMATS.contains(&self) + } + + /// The canonical media type — what the sidecar's `content_type` carries when detection + /// succeeded. + pub const fn mime(self) -> &'static str { + match self { + Self::Jpeg => "image/jpeg", + Self::Png => "image/png", + Self::WebP => "image/webp", + Self::Jxl => "image/jxl", + Self::Tiff => "image/tiff", + Self::Gif => "image/gif", + Self::Ppm => "image/x-portable-anymap", + Self::Avif => "image/avif", + Self::Heic => "image/heic", + Self::Arw => "image/x-sony-arw", + Self::Cr2 => "image/x-canon-cr2", + Self::Cr3 => "image/x-canon-cr3", + Self::Crw => "image/x-canon-crw", + Self::Dng => "image/x-adobe-dng", + Self::Nef => "image/x-nikon-nef", + Self::Raf => "image/x-fuji-raf", + } + } + + /// The lowercase extension table — the *fallback*, used only where a header cannot settle + /// the question (see the module docs). Extensions arrive lowercased. + pub fn from_extension(ext: &str) -> Option { + Some(match ext { + "jpg" | "jpeg" | "jpe" | "jfif" => Self::Jpeg, + "png" => Self::Png, + "webp" => Self::WebP, + "jxl" => Self::Jxl, + "tif" | "tiff" => Self::Tiff, + "gif" => Self::Gif, + "ppm" | "pgm" | "pnm" | "pfm" => Self::Ppm, + "avif" | "avifs" => Self::Avif, + "heic" | "heif" | "hif" => Self::Heic, + "arw" => Self::Arw, + "cr2" => Self::Cr2, + "cr3" => Self::Cr3, + "crw" => Self::Crw, + "dng" => Self::Dng, + "nef" | "nrw" => Self::Nef, + "raf" => Self::Raf, + _ => return None, + }) + } + + /// Sniff the format from the file header alone. + /// + /// `None` means "no still image Capsule models starts like this" — a video, an SVG, an XMP + /// sidecar, or noise. A RAW file sniffs to its *container* here ([`Tiff`](Self::Tiff), or + /// `None` for CR3's unrecognised `ftyp` brand); [`detect`](Self::detect) is what refines it. + /// + /// Each signature carries **its own** length requirement rather than sharing one floor: a + /// Netpbm header is eleven bytes and a JPEG's SOI three, so a blanket minimum would report + /// a perfectly well-formed short file as "not an image". + pub fn from_bytes(bytes: &[u8]) -> Option { + let at = |start: usize, needle: &[u8]| -> bool { + bytes + .get(start..start + needle.len()) + .is_some_and(|window| window == needle) + }; + + if at(0, b"GIF87a") || at(0, b"GIF89a") { + return Some(Self::Gif); + } + if at(0, b"\xFF\xD8\xFF") { + return Some(Self::Jpeg); + } + if at(0, b"\x89PNG\r\n\x1a\n") { + return Some(Self::Png); + } + if at(0, b"RIFF") && at(8, b"WEBP") { + return Some(Self::WebP); + } + // Bare JXL codestream, then the ISO-BMFF-wrapped form. + if at(0, b"\xFF\x0A") { + return Some(Self::Jxl); + } + if at(4, b"JXL ") { + return Some(Self::Jxl); + } + if at(0, b"II\x2A\x00") || at(0, b"MM\x00\x2A") { + return Some(Self::Tiff); + } + if at(4, b"ftyp") + && let Some(brand) = bytes.get(8..12) + { + return Self::from_isobmff_brand(brand); + } + // Netpbm: 'P' + a binary version + whitespace. P1-P4 are excluded because the + // `zune-ppm` backend does not decode them, matching `rawshift-image`. + if let Some(&[b'P', version, space]) = bytes.get(..3) + && matches!(version, b'5' | b'6' | b'7' | b'F' | b'f') + && space.is_ascii_whitespace() + { + return Some(Self::Ppm); + } + None + } + + /// The ISO-BMFF `ftyp` brand table. + /// + /// `mif1` is the generic HEIF brand and is claimed in practice by both HEIC and AVIF + /// writers. Capsule reads it as HEIC because the reference library is HEIC end to end; + /// `rawshift-image` reads it as AVIF. The divergence is cosmetic while neither decodes — + /// both produce the same + /// [`UnsupportedFormat`](super::MediaError::UnsupportedFormat) refusal and the same + /// `DeferredNoCodec` status — and it is a log-label difference, never a pixel difference. + fn from_isobmff_brand(brand: &[u8]) -> Option { + Some(match brand { + b"avif" | b"avis" => Self::Avif, + b"heic" | b"heix" | b"heis" | b"hevc" | b"hevx" | b"msf1" | b"mif1" => Self::Heic, + b"crx " => Self::Cr3, + _ => return None, + }) + } + + /// Identify a still: header first, extension only where a header cannot settle it. + /// + /// `ext` is the source file's lowercase extension without the dot (`""` when it has none). + /// The two extension-consulting cases are documented on the module. + pub fn detect(bytes: &[u8], ext: &str) -> Option { + match Self::from_bytes(bytes) { + // A TIFF header is also every TIFF-based RAW's header. Refine on the extension, and + // only ever *into* a RAW family — a `.tif` stays TIFF. + Some(Self::Tiff) => match Self::from_extension(ext) { + Some(raw) if raw.is_raw() => Some(raw), + _ => Some(Self::Tiff), + }, + Some(format) => Some(format), + None => Self::from_extension(ext), + } + } +} + +impl fmt::Display for StillFormat { + /// The format's media type — what a log line and an error message both want. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.mime()) + } +} diff --git a/capsule-core/src/media/error.rs b/capsule-core/src/media/error.rs new file mode 100644 index 00000000..b0e65426 --- /dev/null +++ b/capsule-core/src/media/error.rs @@ -0,0 +1,137 @@ +//! The typed failure set of the still pipeline (slice `S-B13`). +//! +//! Every one of these is a *report*, never a rejection: Capsule is a backup tool, so an original +//! whose pixels cannot be read is still imported as a signed, encrypted, `verify_asset`-accepting +//! asset. What varies is only whether a placeholder and a thumbnail could be produced beside it. +//! The point of the enum is that the reasons stay apart — a missing codec is a known, deferred +//! gap, while a *supported* format that fails to decode is a defect somebody should look at. + +use thiserror::Error; + +use super::detect::StillFormat; +use crate::derivative_format::DerivativeFormat; + +/// Which direction of a codec a format was needed for. A build can decode a format it cannot +/// encode (every format here except WebP) and the message has to say which half is missing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FormatOp { + /// Reading pixels out of the format. + Decode, + /// Writing pixels into the format. + Encode, +} + +impl FormatOp { + /// The lowercase word used in log fields and messages. + pub const fn as_str(self) -> &'static str { + match self { + Self::Decode => "decode", + Self::Encode => "encode", + } + } +} + +/// Why the still pipeline could not produce what was asked of it. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum MediaError { + /// The format was identified and this build links no codec for it in that direction — + /// HEIC, AVIF and the RAW families for decode; everything but WebP for encode. The + /// **expected** gap: the format table is honest about it and + /// [`StillFormat::is_decodable`](super::StillFormat::is_decodable) is the same predicate the + /// pipeline gates on, so this is reached only when a caller bypasses that gate. + #[error("this build links no {} codec for {format}", op.as_str())] + UnsupportedFormat { + /// The format that was identified. + format: StillFormat, + /// Which half of the codec was missing. + op: FormatOp, + }, + /// The bytes are not any still image Capsule models — a video, an XMP sidecar, an SVG, or + /// noise. Distinct from [`UnsupportedFormat`](Self::UnsupportedFormat): there is nothing to + /// defer, because there is no still here to decode later either. + #[error("not a still image Capsule models")] + NotAStillImage, + /// A format this build *does* decode did not decode these particular bytes — truncation, + /// corruption, or a decoder bug. `detail` is the underlying decoder's message, flattened to + /// a `String` (rather than held as a `#[source]`) so this type stays `Clone + PartialEq` and + /// so no pre-1.0 dependency type appears in Capsule's public error shape. + #[error("decoding {format} failed: {detail}")] + Decode { + /// The format that was being decoded. + format: StillFormat, + /// The decoder's own message. + detail: String, + }, + /// A derivative encode failed. Same shape and same reasoning as + /// [`Decode`](Self::Decode). + #[error("encoding {format} failed: {detail}")] + Encode { + /// The derivative format that was being written. + format: DerivativeFormat, + /// The encoder's own message. + detail: String, + }, + /// The decoded frame has a zero dimension. Guarded rather than trusted because + /// [`crate::lqip::Lqip::encode`] and the downscale both need a non-empty frame, and a + /// hand-crafted header can claim one. + #[error("decoded frame has a zero dimension ({width}x{height})")] + ZeroDimension { + /// The width the decoder reported. + width: u32, + /// The height the decoder reported. + height: u32, + }, + /// The header claims more pixels than [`MAX_DECODE_PIXELS`](super::MAX_DECODE_PIXELS) + /// allows, and the decode was refused **before** allocating. + /// + /// This is the decode-bomb guard, and it has to be a pre-decode check rather than a + /// post-decode sanity assert: `rawshift-image` decodes to interleaved RGB `u16`, i.e. six + /// bytes per pixel, so a 60000x60000 PNG asks for ~21 GB inside the decoder before Capsule + /// ever sees a buffer. + #[error("{pixels} pixels exceeds the {limit}-pixel decode budget")] + PixelBudgetExceeded { + /// The pixel count the header claims. + pixels: u64, + /// The budget in force. + limit: u64, + }, + /// The decoder's buffer length does not match the dimensions it reported. A `RgbImage` is + /// constructible from mismatched parts (`RgbImage::new` validates nothing), so this is + /// checked at the boundary rather than assumed. + #[error( + "{format} decoder returned {actual} samples for {width}x{height} (expected {expected})" + )] + BufferLengthMismatch { + /// The format that was decoded. + format: StillFormat, + /// The width the decoder reported. + width: u32, + /// The height the decoder reported. + height: u32, + /// The sample count the dimensions imply. + expected: u128, + /// The sample count the decoder actually returned. + actual: u128, + }, + /// Signing or sealing a derivative manifest failed — a hardware device signer refused, or + /// the album's write-tier key for this epoch is missing. + /// + /// **The one derivative failure that is not about pixels**, and the reason it has its own + /// variant rather than being folded into [`Encode`](Self::Encode): every other error here + /// says "this asset has no thumbnail", which an import survives, while this one says the + /// *workspace* cannot author a signed record — the same fault that would stop the asset's + /// own manifest. The import path propagates this and degrades on everything else, and it can + /// only tell them apart if the type does. + #[error("signing the derivative manifest failed: {detail}")] + Sign { + /// The underlying crypto error's message. + detail: String, + }, + /// A third-party decoder panicked and the unwind was caught at the pipeline boundary. + /// + /// A pre-1.0 decoder fed untrusted bytes is exactly the place a panic is plausible, and an + /// import must never abort over a thumbnail. Reported as a decode failure rather than + /// swallowed, because a panic is a defect worth seeing. + #[error("the decoder panicked")] + DecoderPanic, +} diff --git a/capsule-core/src/media/mod.rs b/capsule-core/src/media/mod.rs new file mode 100644 index 00000000..71e728df --- /dev/null +++ b/capsule-core/src/media/mod.rs @@ -0,0 +1,78 @@ +//! Still-image decode, orientation, metadata normalisation and derivative generation — the +//! Capsule-side owner of the media pipeline (slices `S-B1`, `S-B13`). +//! +//! SSoT: [Thumbnails and Previews](https://docs/design/thumbnails/). +//! +//! # Boundaries +//! +//! Rawshift owns codecs; this module owns everything Capsule decides. Concretely, +//! [`rawshift-image`] performs format sniffing, pixel decode, and the byte encode, while this +//! module owns: +//! +//! - **the closed format sets** — [`StillFormat`](crate::media::StillFormat) (what Capsule +//! models as a still) and [`DerivativeFormat`](crate::media::DerivativeFormat) (what a signed +//! `DerivativeManifest.format` may say); +//! - **the pixel budget and the panic guard**, because a third-party pre-1.0 decoder is fed +//! untrusted bytes on the import path; +//! - **tier sizing and the downscale**, because `rawshift-image` has no resize and because a +//! derivative's bytes are signed, so the resample must be deterministic; +//! - **the metadata strip**, because the crate's own default embeds EXIF (GPS included) into +//! every encode. +//! +//! Every path above is reached through the re-exports below, and the doc links name them by +//! their full `crate::media::…` path deliberately. A bare ``[`StillFormat`]`` here does **not** +//! resolve under the `doc-check-rust` gate (`cargo doc --no-deps`, `-D warnings`) even though +//! the type is re-exported a few lines down — while it *does* resolve when the same command is +//! given `--document-private-items`, which is why the failure only appeared in CI. Rather than +//! guess at which of rustdoc's resolution rules produces that asymmetry, these links use the +//! path that resolves under both. +//! +//! LQIP is *not* here: it lives in the unconditional [`crate::lqip`] module so the import +//! pipeline, the uniffi FFI and `capsule-wasm` share one implementation (slice `S-B14`). This +//! module produces the pixels [`crate::lqip::Lqip::encode`] consumes. +//! +//! # What this build can and cannot do +//! +//! Every gap is a typed +//! [`UnsupportedFormat`](crate::media::MediaError::UnsupportedFormat) or a recorded per-format +//! deferral — never a silent absence, and never a panic (slice `S-B13`). +//! +//! **Decode** covers JPEG, PNG, JXL, TIFF and GIF. **Encode** covers JXL alone, and losslessly: +//! `image/jxl` is the tier table's committed master format, but the pure-Rust backend is +//! `zune-jpegxl`'s `JxlSimpleEncoder`, so the tier's declared `q=50` is advisory today. +//! +//! HEIC, AVIF, WebP and the RAW families sniff correctly and refuse to decode. HEIC and AVIF +//! need system libraries (libheif, libdav1d), AVIF encode needs an assembler (nasm) the cross +//! and cargo-ndk builds do not have, and **WebP is a compile failure rather than a missing +//! toolchain**: `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` declares +//! `*const c_char`, which is `u8` on aarch64 — every mobile target — and the module is compiled +//! by decode *or* encode, so there is no decode-only escape. +//! +//! [`rawshift-image`]: https://docs.rs/rawshift-image + +mod decode; +mod derivative; +mod detect; +mod error; +mod resize; + +// `native`-gated because `lifecycle` is its only caller and `lifecycle` is `native`-gated: a +// `--features media` build without `native` (which the aarch64 cross-check uses, to isolate the +// codecs from SQLite's C build) would otherwise carry an unused re-export. +#[cfg(feature = "native")] +pub(crate) use self::decode::guarded; +pub use self::decode::{DecodedImage, Decoder, MediaMetadata, RawshiftDecoder, decode_guarded}; +pub use self::derivative::{ + DerivativeContext, DerivativeSealer, DerivativeTier, GeneratedDerivative, SealedDerivative, + StillDerivatives, generate_still_derivatives, +}; +pub use self::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; +pub use self::error::{FormatOp, MediaError}; +pub use self::resize::{capped_dimensions, downscale_rgba8}; +// Re-exported so `media::DerivativeFormat` keeps resolving, but *owned* by the unconditional +// module: the closed set has to be linkable by the crates that receive a manifest, and they +// build without this feature. See [`crate::derivative_format`]. +pub use crate::derivative_format::{DerivativeFormat, verify_still_format}; + +#[cfg(test)] +mod tests; diff --git a/capsule-core/src/media/resize.rs b/capsule-core/src/media/resize.rs new file mode 100644 index 00000000..7c992481 --- /dev/null +++ b/capsule-core/src/media/resize.rs @@ -0,0 +1,142 @@ +//! Capsule-owned downscale for the derivative tiers. +//! +//! `rawshift-image` has no resize — it offers crop, flips, rotations, blur and lens correction, +//! and nothing that changes the sample grid — so tier sizing is Capsule's, not the codec's. +//! +//! # Why integer area-averaging, specifically +//! +//! A derivative's bytes are content-addressed by a **signed** `DerivativeManifest`, so two runs +//! over the same source must produce the same bytes. That rules out floating-point accumulation +//! whose order or width could differ between builds and targets, and it rules out any resampler +//! with platform-tuned SIMD paths that are not required to be bit-identical. What is left is a +//! box filter accumulated in integers and divided by an exact sample count: deterministic on +//! every target, and the right filter for a large downscale anyway (a box average over the full +//! source rect is alias-free, where a bilinear tap would ignore most of the source pixels). +//! +//! Every product here is computed in `u64`, never in `usize`, because two of the CI-gated +//! targets (`armv7-linux-androideabi`, `i686-linux-android`) are 32-bit and both the +//! destination-to-source boundary and the channel accumulator reach past `u32` for shapes this +//! function accepts. +//! +//! Determinism here is **necessary, not sufficient**, and the distinction matters: the bytes a +//! manifest actually signs come out of `zune-jpegxl`, and an encoder version bump can change +//! them for the same input. That is fine — each generation signs the bytes it produced, and +//! manifests of a role chain in order — but it means "the resample is deterministic" buys +//! reproducibility of *this* step, not a stable content address across toolchains. +//! +//! Upscaling is not a thing this performs: a tier only ever caps a long edge, and a source +//! already inside the cap takes the `format = "original"` sentinel path instead +//! ([`DerivativeTier`](super::DerivativeTier)). + +use crate::lqip::RgbaImage; + +/// The dimensions a `width` x `height` frame takes when its long edge is capped at +/// `max_long_edge`, preserving aspect ratio. +/// +/// Returns the input unchanged when it already fits, so a caller can compare and skip — and +/// unchanged for an empty frame, which is the one case where "never zero" cannot hold: there is +/// no non-degenerate size for a frame with no pixels, and inventing one would have +/// [`downscale_rgba8`] read a buffer that has nothing in it. +pub fn capped_dimensions(width: u32, height: u32, max_long_edge: u32) -> (u32, u32) { + if width == 0 || height == 0 { + return (width, height); + } + let cap = max_long_edge.max(1); + let long_edge = width.max(height); + if long_edge <= cap { + return (width, height); + } + // Rounded rather than truncated so a 3:2 frame keeps its ratio as closely as an integer + // grid allows; `.max(1)` because a very lopsided frame (e.g. 8000x3) would otherwise round + // its short edge to zero and produce an empty buffer. + let scale = |edge: u32| -> u32 { + let numerator = u64::from(edge) * u64::from(cap); + let denominator = u64::from(long_edge); + (((numerator + denominator / 2) / denominator) as u32).max(1) + }; + (scale(width), scale(height)) +} + +/// Downscale packed RGBA8 so its long edge is at most `max_long_edge`. +/// +/// A frame already within the cap — or an empty one — is returned unchanged (cloned), which is +/// what makes this safe to call unconditionally. Deterministic: identical input yields +/// byte-identical output on every target. +pub fn downscale_rgba8(source: &RgbaImage, max_long_edge: u32) -> RgbaImage { + let (dst_w, dst_h) = capped_dimensions(source.width, source.height, max_long_edge); + if (dst_w, dst_h) == (source.width, source.height) { + return source.clone(); + } + + let (src_w, src_h) = (source.width as usize, source.height as usize); + // `u64`, not `usize`: on a 32-bit target `w * h * 4` overflows above ~1 Gpx, and the whole + // point of this branch is to be reached rather than to panic on its own arithmetic. + if source.rgba.len() as u64 != u64::from(source.width) * u64::from(source.height) * 4 { + // Defensive: this is a `pub` entry point and the very next thing it does is index the + // buffer by those dimensions. Every in-tree caller passes a `DecodedImage`, whose + // invariant this is, so a mismatch is a bug in a *new* caller — reported and returned + // unchanged rather than turned into a panic inside an import. + tracing::error!( + width = source.width, + height = source.height, + len = source.rgba.len(), + "media: downscale refused a buffer that does not match its dimensions" + ); + return source.clone(); + } + let (dw, dh) = (dst_w as usize, dst_h as usize); + let mut out = Vec::with_capacity(dw * dh * 4); + + // Boundary products and the channel accumulator are `u64`, not `usize`/`u32`, and both + // widths are load-bearing rather than defensive habit: + // + // - `(y + 1) * src_h` reaches `dst_edge * src_edge`. For a 1 x 256M frame reduced to a + // 256 px long edge that is 6.5e10, which overflows a 32-bit `usize` — and two of the + // CI-gated targets (`armv7-linux-androideabi`, `i686-linux-android`) are 32-bit. + // - the per-channel sum reaches `count * 255`, and `count` is the whole frame when this is + // called with a cap of 1 (a `pub` entry point, so that is reachable), i.e. 6.5e10 again — + // past `u32::MAX`. + // + // Neither is hypothetical-only: a debug build panics on the overflow and a release build + // wraps into wrong pixels or an out-of-bounds index. + for y in 0..dh { + // The source rows this destination row averages. Floor boundaries, so the destination + // grid is an exact partition of the source grid — every source pixel contributes to + // exactly one output pixel. Widened to at least one row because a lopsided cap can put + // two destination rows inside one source row, and an empty rect would divide by zero. + let y0 = (y as u64 * src_h as u64 / dh as u64) as usize; + let y1 = (((y as u64 + 1) * src_h as u64 / dh as u64) as usize) + .max(y0 + 1) + .min(src_h); + for x in 0..dw { + let x0 = (x as u64 * src_w as u64 / dw as u64) as usize; + let x1 = (((x as u64 + 1) * src_w as u64 / dw as u64) as usize) + .max(x0 + 1) + .min(src_w); + + let mut acc = [0u64; 4]; + let count = ((y1 - y0) * (x1 - x0)) as u64; + for sy in y0..y1 { + let row = sy * src_w * 4; + for sx in x0..x1 { + let i = row + sx * 4; + acc[0] += u64::from(source.rgba[i]); + acc[1] += u64::from(source.rgba[i + 1]); + acc[2] += u64::from(source.rgba[i + 2]); + acc[3] += u64::from(source.rgba[i + 3]); + } + } + // Round-half-up on the mean, so a uniform region reproduces its own value exactly + // rather than drifting down by up to one level per reduction. + for channel in acc { + out.push(((channel + count / 2) / count) as u8); + } + } + } + + RgbaImage { + width: dst_w, + height: dst_h, + rgba: out, + } +} diff --git a/capsule-core/src/media/tests.rs b/capsule-core/src/media/tests.rs new file mode 100644 index 00000000..67e6756d --- /dev/null +++ b/capsule-core/src/media/tests.rs @@ -0,0 +1,1695 @@ +//! Unit coverage for the still pipeline (slices `S-B1`, `S-B13`). +//! +//! # Fixtures are built, never committed +//! +//! The repository carries no binary image fixtures and this suite adds none. Three kinds of +//! input appear below, and the mix is deliberate: +//! +//! 1. **Procedural frames** ([`quadrants`], [`gradient`]) encoded in-test by +//! `rawshift-image`'s own JPEG/PNG/WebP encoders. Self-consistent by construction, which is +//! exactly what makes them right for the *orientation* and *EXIF* cases: the crate writes the +//! EXIF block and Capsule reads it back, so the fixture cannot drift from the parser. +//! 2. **A hand-built PNG** ([`hand_written_png`]) — IHDR/IDAT/IEND assembled byte by byte with +//! a local CRC-32 and an uncompressed-deflate zlib stream, so at least one decode case is +//! fed an input no part of the code under test produced. Without it the suite could pass +//! against an encoder and decoder that agree with each other and with nothing else. +//! 3. **Bare magic-byte headers** for the formats this build cannot decode. There is nothing to +//! decode there and nothing to fake: the assertion is that they are *recognised* and refused +//! with a typed error. + +use std::collections::HashMap; + +use rawshift_image::core::metadata::{ImageInfo, ImageMetadata, URational}; +use rawshift_image::core::{BitDepth, MetadataEmbedOptions}; +use rawshift_image::formats::encode_rgb_image_to_vec; +use rawshift_image::formats::export::{ + CommonEncodeOptions, EncodeOptions, JpegEncEncodeConfig, ZuneJxlEncodeConfig, + ZunePngEncodeConfig, +}; +use uuid::Uuid; + +use super::decode::{Decoder, RawshiftDecoder, decode_guarded}; +use super::derivative::{ + DerivativeContext, DerivativeSealer, DerivativeTier, SealedDerivative, StillDerivatives, + generate_still_derivatives, +}; +use super::detect::{MAX_DECODE_PIXELS, SUPPORTED_STILL_FORMATS, StillFormat}; +use super::error::{FormatOp, MediaError}; +use super::resize::{capped_dimensions, downscale_rgba8}; +use crate::crypto::encryption::encrypt_asset_rekey; +use crate::crypto::hash::Hash32; +use crate::crypto::keys::{Amk, AmkVersion, HybridSigningKey}; +use crate::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; +use crate::crypto::provenance::manifest::{DERIVATIVE_MANIFEST_VERSION, DerivativeCore}; +use crate::crypto::provenance::{DerivativeManifest, DerivativeRole}; +use crate::derivative_format::{DerivativeFormat, verify_still_format}; +use crate::lqip::{Gamut, Lqip, RgbaImage}; + +// ── Procedural fixtures ────────────────────────────────────────────────────── + +/// A frame of four flat quadrants at distinct luminances — TL 0, TR 85, BL 170, BR 255. +/// +/// Flat regions rather than a gradient because these fixtures go through a *lossy* JPEG: +/// sampling the middle of a flat quadrant is stable to within a couple of levels at q=90, while +/// a gradient's corner is not. Four distinct values make all eight EXIF orientations +/// distinguishable from one another. +fn quadrants(width: u32, height: u32) -> RgbaImage { + let (w, h) = (width as usize, height as usize); + let mut rgba = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let v = match (x < w / 2, y < h / 2) { + (true, true) => 0, + (false, true) => 85, + (true, false) => 170, + (false, false) => 255, + }; + rgba.extend_from_slice(&[v, v, v, 255]); + } + } + RgbaImage { + width, + height, + rgba, + } +} + +/// A deterministic RGB gradient — the general-purpose frame for size and encode cases. +fn gradient(width: u32, height: u32) -> RgbaImage { + let (w, h) = (width as usize, height as usize); + let mut rgba = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + rgba.extend_from_slice(&[ + (x * 255 / w.max(1)) as u8, + (y * 255 / h.max(1)) as u8, + ((x + y) * 255 / (w + h).max(1)) as u8, + 255, + ]); + } + } + RgbaImage { + width, + height, + rgba, + } +} + +/// Mean of each quadrant's inner half, as `(TL, TR, BL, BR)`. +/// +/// The inner half avoids the quadrant boundaries, where JPEG's 8x8 blocks and chroma +/// subsampling smear one region into the next. +fn quadrant_means(image: &RgbaImage) -> (u32, u32, u32, u32) { + let (w, h) = (image.width as usize, image.height as usize); + let mean = |xs: std::ops::Range, ys: std::ops::Range| -> u32 { + let mut sum = 0u64; + let mut n = 0u64; + for y in ys.clone() { + for x in xs.clone() { + sum += u64::from(image.rgba[(y * w + x) * 4]); + n += 1; + } + } + (sum / n.max(1)) as u32 + }; + let (qw, qh) = (w / 2, h / 2); + let (ix, iy) = (qw / 4, qh / 4); + ( + mean(ix..qw - ix, iy..qh - iy), + mean(qw + ix..w - ix, iy..qh - iy), + mean(ix..qw - ix, qh + iy..h - iy), + mean(qw + ix..w - ix, qh + iy..h - iy), + ) +} + +/// Widen packed RGBA8 into the interleaved RGB `u16` the encoders take. +fn to_rgb_u16(frame: &RgbaImage) -> rawshift_image::core::image::RgbImage { + let mut data = Vec::with_capacity(frame.rgba.len() / 4 * 3); + for px in frame.rgba.chunks_exact(4) { + data.push(u16::from(px[0]) * 257); + data.push(u16::from(px[1]) * 257); + data.push(u16::from(px[2]) * 257); + } + rawshift_image::core::image::RgbImage::with_color_space( + frame.width, + frame.height, + data, + rawshift_image::core::ColorSpace::Srgb, + ) +} + +/// A `CommonEncodeOptions` embedding whatever `metadata` asks for, at 8 bits. +fn common(metadata: MetadataEmbedOptions) -> CommonEncodeOptions { + CommonEncodeOptions { + metadata, + bit_depth: BitDepth::Eight, + } +} + +/// Encode a frame as a baseline JPEG, optionally embedding `metadata`. +/// +/// Quality 90 rather than the tier's 50: this is a *source* fixture, and a decode test should +/// not have to absorb the tier's own quality budget as well. +fn jpeg_bytes(frame: &RgbaImage, metadata: Option<&ImageMetadata>) -> Vec { + let embed = if metadata.is_some() { + MetadataEmbedOptions { + embed_exif: true, + embed_icc: false, + embed_xmp: false, + } + } else { + MetadataEmbedOptions::none() + }; + let options = EncodeOptions::JpegJpegEnc(JpegEncEncodeConfig { + common: common(embed), + quality: 90, + }); + let empty = ImageMetadata::default(); + encode_rgb_image_to_vec(&to_rgb_u16(frame), metadata.unwrap_or(&empty), &options) + .expect("the fixture JPEG encodes") +} + +/// Encode a frame as a PNG with no metadata at all. +fn png_bytes(frame: &RgbaImage) -> Vec { + let options = EncodeOptions::PngZune(ZunePngEncodeConfig { + common: common(MetadataEmbedOptions::none()), + ..ZunePngEncodeConfig::default() + }); + encode_rgb_image_to_vec(&to_rgb_u16(frame), &ImageMetadata::default(), &options) + .expect("the fixture PNG encodes") +} + +/// A bare 12-byte RIFF/WEBP header. +/// +/// A *header*, not an encode, because this build has no WebP codec at all: `rawshift-image`'s +/// WebP module does not compile for aarch64 (`*const i8` against `libwebp-sys`'s `*const +/// c_char`), so the crate's `webp` feature is off in both directions. Twelve bytes is all a +/// detection case needs, and there is nothing to decode. +fn webp_header() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes +} + +/// Encode a frame as JXL through the same backend the derivative path uses, optionally +/// embedding `metadata`. +fn jxl_bytes(frame: &RgbaImage, metadata: Option<&ImageMetadata>) -> Vec { + let embed = if metadata.is_some() { + MetadataEmbedOptions::all() + } else { + MetadataEmbedOptions::none() + }; + let options = EncodeOptions::JxlZune(ZuneJxlEncodeConfig { + common: common(embed), + quality: 50.0, + effort: 7, + }); + let empty = ImageMetadata::default(); + encode_rgb_image_to_vec(&to_rgb_u16(frame), metadata.unwrap_or(&empty), &options) + .expect("the fixture JXL encodes") +} + +/// An `ImageMetadata` carrying only an EXIF orientation tag. +fn oriented(orientation: u16) -> ImageMetadata { + ImageMetadata { + image: ImageInfo { + orientation: Some(orientation), + ..ImageInfo::default() + }, + ..ImageMetadata::default() + } +} + +/// The GPS degrees/minutes/seconds triple used by the privacy cases — a distinctive fix whose +/// rationals are searchable as raw bytes. +const GPS_LAT_DMS: [u32; 3] = [51, 30, 26]; +const GPS_LON_DMS: [u32; 3] = [0, 7, 39]; + +/// An `ImageMetadata` carrying a GPS fix — the metadata a thumbnail must never inherit. +fn located() -> ImageMetadata { + let dms = |v: [u32; 3]| { + v.map(|n| URational { + numerator: n, + denominator: 1, + }) + }; + let mut metadata = ImageMetadata::default(); + metadata.gps.latitude = Some(dms(GPS_LAT_DMS)); + metadata.gps.latitude_ref = Some('N'); + metadata.gps.longitude = Some(dms(GPS_LON_DMS)); + metadata.gps.longitude_ref = Some('W'); + metadata +} + +// ── The independently-constructed PNG ──────────────────────────────────────── + +/// CRC-32 (IEEE 802.3), computed here so the PNG fixture owes nothing to a dependency. +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc +} + +/// Adler-32, the zlib stream checksum. +fn adler32(bytes: &[u8]) -> u32 { + let mut a = 1u32; + let mut b = 0u32; + for &byte in bytes { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + (b << 16) | a +} + +/// One PNG chunk: length, type, payload, CRC over type+payload. +fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::with_capacity(payload.len() + 12); + chunk.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(payload); + let mut crc_input = kind.to_vec(); + crc_input.extend_from_slice(payload); + chunk.extend_from_slice(&crc32(&crc_input).to_be_bytes()); + chunk +} + +/// A 2x2 8-bit RGB PNG built byte by byte: signature, IHDR, a stored-deflate IDAT, IEND. +/// +/// `deflate` here is a single final *stored* block (BTYPE 00), so no compressor is involved — +/// the point of this fixture is that nothing under test, and no encoder it shares code with, +/// produced it. +fn hand_written_png() -> Vec { + // Four pixels: red, green, blue, white — each row prefixed by filter type 0 (None). + let raw: Vec = vec![ + 0, 255, 0, 0, 0, 255, 0, // row 0: filter, red, green + 0, 0, 0, 255, 255, 255, 255, // row 1: filter, blue, white + ]; + + let mut zlib = vec![0x78, 0x01]; // CM=8, CINFO=7, FLEVEL=0, FCHECK making it a multiple of 31 + zlib.push(0x01); // final stored block + zlib.extend_from_slice(&(raw.len() as u16).to_le_bytes()); + zlib.extend_from_slice(&(!(raw.len() as u16)).to_le_bytes()); + zlib.extend_from_slice(&raw); + zlib.extend_from_slice(&adler32(&raw).to_be_bytes()); + + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&2u32.to_be_bytes()); // width + ihdr.extend_from_slice(&2u32.to_be_bytes()); // height + ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, colour type 2 (RGB), deflate, no filter/interlace + + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + png.extend(png_chunk(b"IDAT", &zlib)); + png.extend(png_chunk(b"IEND", &[])); + png +} + +// ── Detection ──────────────────────────────────────────────────────────────── + +/// A 12-byte header for each format Capsule recognises but cannot decode. Twelve bytes is +/// exactly what [`StillFormat::from_bytes`] requires, so these also pin the minimum length. +fn isobmff(brand: &[u8; 4]) -> Vec { + let mut bytes = vec![0, 0, 0, 0x20]; + bytes.extend_from_slice(b"ftyp"); + bytes.extend_from_slice(brand); + bytes +} + +/// Real encodes on one side, bare headers on the other: every variant of the closed set is +/// reachable from bytes, and the sniff names the right one. +#[test] +fn every_still_format_is_reachable_from_its_header() { + let frame = gradient(8, 8); + let cases: &[(Vec, &str, StillFormat)] = &[ + (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg), + (png_bytes(&frame), "png", StillFormat::Png), + (webp_header(), "webp", StillFormat::WebP), + (hand_written_png(), "png", StillFormat::Png), + ( + b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), + "gif", + StillFormat::Gif, + ), + ( + b"\xFF\x0A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "jxl", + StillFormat::Jxl, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "tif", + StillFormat::Tiff, + ), + ( + b"MM\x00\x2A\x00\x00\x00\x08\x00\x00\x00\x00".to_vec(), + "tiff", + StillFormat::Tiff, + ), + (b"P6\n8 8\n255\n".to_vec(), "ppm", StillFormat::Ppm), + (isobmff(b"avif"), "avif", StillFormat::Avif), + (webp_header(), "webp", StillFormat::WebP), + (isobmff(b"heic"), "heic", StillFormat::Heic), + (isobmff(b"mif1"), "heic", StillFormat::Heic), + (isobmff(b"crx "), "cr3", StillFormat::Cr3), + // TIFF-container RAW: the header says TIFF and only the extension names the family. + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "arw", + StillFormat::Arw, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "cr2", + StillFormat::Cr2, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "dng", + StillFormat::Dng, + ), + ( + b"MM\x00\x2A\x00\x00\x00\x08\x00\x00\x00\x00".to_vec(), + "nef", + StillFormat::Nef, + ), + ]; + for (bytes, ext, expected) in cases { + assert_eq!( + StillFormat::detect(bytes, ext), + Some(*expected), + "detecting a .{ext} fixture" + ); + } + + // CRW and RAF have no in-tree header fixture; they are extension-only entries, and the + // point of asserting them is that the fallback table covers the whole RAW set. + assert_eq!(StillFormat::detect(b"", "crw"), Some(StillFormat::Crw)); + assert_eq!(StillFormat::detect(b"", "raf"), Some(StillFormat::Raf)); +} + +/// Bytes win over the extension. A HEIC named `.jpg` must not be handed to the JPEG decoder — +/// that is the difference between a typed "no codec for HEIC" deferral and a decode failure +/// blamed on JPEG. +#[test] +fn the_header_beats_a_lying_extension() { + assert_eq!( + StillFormat::detect(&isobmff(b"heic"), "jpg"), + Some(StillFormat::Heic) + ); + let png = png_bytes(&gradient(4, 4)); + assert_eq!(StillFormat::detect(&png, "jpeg"), Some(StillFormat::Png)); + + // The one refinement that runs the other way is *into* a RAW family, and only from a TIFF + // header — a real `.tif` stays TIFF. + let tiff_header = b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00"; + assert_eq!( + StillFormat::detect(tiff_header, "tif"), + Some(StillFormat::Tiff) + ); +} + +/// Nothing recognisable, and never a panic. `detect` is the first thing untrusted bytes touch. +#[test] +fn unrecognisable_bytes_are_not_a_still() { + let cases: &[&[u8]] = &[ + b"", + b"\x00", + b"\xFF\xD8", // a JPEG SOI truncated below the 12-byte floor + b"noise-x!", // 8 bytes, under the floor + b"not an image at all, really", // long enough, no signature + b"\x00\x00\x00\x20ftypqt ", // ISO-BMFF with an unmodelled brand (QuickTime) + b"", + b"\x1AE\xDF\xA3\x01\x00\x00\x00\x00\x00\x00\x23", // Matroska/WebM + ]; + for bytes in cases { + assert_eq!( + StillFormat::detect(bytes, ""), + None, + "these bytes are not a still Capsule models: {:?}", + &bytes[..bytes.len().min(12)] + ); + } +} + +/// The Capsule table and `rawshift-image`'s own `detect_standard_format` must not drift where +/// both define an answer. +/// +/// Scoped to the formats whose signature is unconditional on both sides. The ISO-BMFF brands are +/// excluded on purpose: the crate's HEIC arm is feature-gated (so it cannot recognise HEIC in +/// this build — the reason Capsule sniffs at all), and it reads the generic `mif1` brand as AVIF +/// where Capsule reads it as HEIC. Neither decodes here, so that divergence is a log label, not +/// a pixel. +#[test] +fn still_format_agrees_with_rawshift_detection() { + use rawshift_image::formats::{StandardFormat, detect_standard_format}; + + let frame = gradient(8, 8); + let cases: &[(Vec, StandardFormat, StillFormat)] = &[ + ( + jpeg_bytes(&frame, None), + StandardFormat::Jpeg, + StillFormat::Jpeg, + ), + (png_bytes(&frame), StandardFormat::Png, StillFormat::Png), + (webp_header(), StandardFormat::WebP, StillFormat::WebP), + (hand_written_png(), StandardFormat::Png, StillFormat::Png), + ( + b"GIF89a\x08\x00\x08\x00\x00\x00".to_vec(), + StandardFormat::Gif, + StillFormat::Gif, + ), + ( + b"\xFF\x0A\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec(), + StandardFormat::Jxl, + StillFormat::Jxl, + ), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + StandardFormat::Tiff, + StillFormat::Tiff, + ), + ( + b"P6\n8 8\n255\n".to_vec(), + StandardFormat::Ppm, + StillFormat::Ppm, + ), + // A JPEG SOI is three bytes and a Netpbm header eleven, so both sides recognise a file + // far shorter than a blanket minimum-length floor would admit. + (isobmff(b"avif"), StandardFormat::Avif, StillFormat::Avif), + ]; + for (bytes, theirs, ours) in cases { + assert_eq!(detect_standard_format(bytes), Some(*theirs)); + assert_eq!(StillFormat::from_bytes(bytes), Some(*ours)); + } +} + +/// The coverage table is the single answer to "can this build read these pixels?", and it has +/// to agree with what the decoder actually does. +#[test] +fn is_decodable_matches_the_supported_table() { + for format in SUPPORTED_STILL_FORMATS { + assert!(format.is_decodable(), "{format} is in the supported table"); + assert!(!format.is_raw(), "no RAW family decodes in this build"); + } + for format in [ + StillFormat::Ppm, + StillFormat::WebP, + StillFormat::Avif, + StillFormat::Heic, + StillFormat::Arw, + StillFormat::Cr2, + StillFormat::Cr3, + StillFormat::Crw, + StillFormat::Dng, + StillFormat::Nef, + StillFormat::Raf, + ] { + assert!(!format.is_decodable(), "{format} has no decoder here"); + } + // Every mime is distinct, so a `content_type` cannot silently collide. + let mut mimes: Vec<&str> = SUPPORTED_STILL_FORMATS.iter().map(|f| f.mime()).collect(); + mimes.sort_unstable(); + let count = mimes.len(); + mimes.dedup(); + assert_eq!(mimes.len(), count, "each format has its own media type"); +} + +// ── Decode ─────────────────────────────────────────────────────────────────── + +/// Every decodable format round-trips to the dimensions it was built with, with a full RGBA8 +/// buffer and uniform opaque alpha. +#[test] +fn decodes_every_supported_container_to_opaque_rgba8() { + let frame = gradient(6, 4); + let cases: &[(Vec, &str, StillFormat, u32, u32)] = &[ + (jpeg_bytes(&frame, None), "jpg", StillFormat::Jpeg, 6, 4), + (png_bytes(&frame), "png", StillFormat::Png, 6, 4), + (jxl_bytes(&frame, None), "jxl", StillFormat::Jxl, 6, 4), + (hand_written_png(), "png", StillFormat::Png, 2, 2), + ]; + for (bytes, ext, format, width, height) in cases { + let decoded = RawshiftDecoder + .decode(bytes, ext) + .unwrap_or_else(|e| panic!("decoding the .{ext} fixture: {e}")); + assert_eq!(decoded.format, *format); + assert_eq!((decoded.width(), decoded.height()), (*width, *height)); + assert_eq!( + decoded.image.rgba.len() as u32, + width * height * 4, + "the buffer is exactly w*h*4" + ); + assert!( + decoded.image.rgba.chunks_exact(4).all(|px| px[3] == 255), + "every decoded frame is opaque" + ); + assert_eq!(decoded.orientation_applied, 1, "no tag, no transform"); + } +} + +/// The hand-built PNG's actual pixels, checked against the bytes that were written into it. +/// +/// This is the one place the suite is not self-consistent: the input owes nothing to the encoder +/// the other cases use, so agreement here is evidence about the decoder rather than about a +/// matched pair. +#[test] +fn the_hand_written_png_decodes_to_the_pixels_it_declares() { + let decoded = RawshiftDecoder + .decode(&hand_written_png(), "png") + .expect("a hand-built 2x2 RGB PNG decodes"); + assert_eq!((decoded.width(), decoded.height()), (2, 2)); + assert_eq!( + decoded.image.rgba, + vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 255, 255, 255, 255, // white + ] + ); +} + +/// PNG alpha is **flattened**, not preserved — `rawshift-image` decodes to RGB with no alpha +/// channel. Asserted so the loss cannot regress into an "alpha survives" assumption somewhere +/// downstream. +#[test] +fn png_alpha_is_flattened_to_opaque() { + // A 1x1 fully transparent RGBA PNG, hand-built for the same reason as the fixture above. + let raw = vec![0u8, 0, 0, 0, 0]; // filter 0 + RGBA(0,0,0,0) + let mut zlib = vec![0x78, 0x01, 0x01]; + zlib.extend_from_slice(&(raw.len() as u16).to_le_bytes()); + zlib.extend_from_slice(&(!(raw.len() as u16)).to_le_bytes()); + zlib.extend_from_slice(&raw); + zlib.extend_from_slice(&adler32(&raw).to_be_bytes()); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1u32.to_be_bytes()); + ihdr.extend_from_slice(&1u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // colour type 6 = RGBA + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + png.extend(png_chunk(b"IDAT", &zlib)); + png.extend(png_chunk(b"IEND", &[])); + + let decoded = RawshiftDecoder + .decode(&png, "png") + .expect("RGBA PNG decodes"); + assert_eq!(decoded.image.rgba, vec![0, 0, 0, 255], "alpha is dropped"); +} + +/// A recognised format with no codec refuses **before** any decoder runs, and says which half +/// of the codec is missing. This is the S-B13 contract at the seam. +#[test] +fn a_format_with_no_codec_refuses_with_a_typed_error() { + let cases: &[(Vec, &str, StillFormat)] = &[ + (isobmff(b"heic"), "heic", StillFormat::Heic), + (isobmff(b"avif"), "avif", StillFormat::Avif), + (isobmff(b"crx "), "cr3", StillFormat::Cr3), + ( + b"II\x2A\x00\x08\x00\x00\x00\x00\x00\x00\x00".to_vec(), + "arw", + StillFormat::Arw, + ), + (b"".to_vec(), "raf", StillFormat::Raf), + (b"P6\n8 8\n255\n".to_vec(), "ppm", StillFormat::Ppm), + ]; + for (bytes, ext, format) in cases { + assert_eq!( + RawshiftDecoder.decode(bytes, ext), + Err(MediaError::UnsupportedFormat { + format: *format, + op: FormatOp::Decode, + }), + "a .{ext} must defer rather than fail" + ); + assert_eq!( + RawshiftDecoder.probe(bytes, ext), + Err(MediaError::UnsupportedFormat { + format: *format, + op: FormatOp::Decode, + }), + ); + } +} + +/// Bytes that are no still at all are a distinct outcome from a still with no codec: there is +/// nothing to backfill later. +#[test] +fn non_still_bytes_are_not_a_deferral() { + assert_eq!( + RawshiftDecoder.decode(b"\x1AE\xDF\xA3 a webm, not a photo", "webm"), + Err(MediaError::NotAStillImage) + ); +} + +/// A supported format whose bytes are broken is a **decode failure**, not a deferral — the +/// distinction the run summary reports and the one worth investigating. +#[test] +fn corrupt_bytes_of_a_supported_format_are_a_decode_failure() { + let mut jpeg = jpeg_bytes(&gradient(16, 16), None); + jpeg.truncate(jpeg.len() / 2); + match RawshiftDecoder.decode(&jpeg, "jpg") { + Err(MediaError::Decode { format, .. }) => assert_eq!(format, StillFormat::Jpeg), + other => panic!("a truncated JPEG must be a decode failure, got {other:?}"), + } + + // Not even a header: the extension is the only evidence, and it says a format we decode. + match RawshiftDecoder.decode(b"this is definitely not a jpeg", "jpeg") { + Err(MediaError::Decode { format, .. }) => assert_eq!(format, StillFormat::Jpeg), + other => panic!("garbage under a .jpeg name must be a decode failure, got {other:?}"), + } +} + +/// The decode-bomb guard: a header claiming more than the budget is refused before the decoder +/// allocates. Built as a PNG header alone, because the *point* is that no pixel data is needed +/// to trigger it — a 33-byte file must not be able to ask for 21 GB. +#[test] +fn an_oversized_header_is_refused_before_decoding() { + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend(png_chunk(b"IHDR", &ihdr)); + + assert_eq!( + RawshiftDecoder.probe(&png, "png"), + Err(MediaError::PixelBudgetExceeded { + pixels: 900_000_000, + limit: MAX_DECODE_PIXELS, + }), + ); + assert_eq!( + RawshiftDecoder.decode(&png, "png"), + Err(MediaError::PixelBudgetExceeded { + pixels: 900_000_000, + limit: MAX_DECODE_PIXELS, + }), + "decode probes first, so the guard covers it too" + ); + // The budget is a ceiling on the frame, not on the file: a small image is unaffected. + assert!( + RawshiftDecoder + .probe(&png_bytes(&gradient(4, 4)), "png") + .is_ok() + ); +} + +/// A probe reports the header's stored dimensions and the EXIF orientation without decoding, +/// and knows which pairs are transposed on display. +#[test] +fn a_probe_reports_stored_dimensions_and_the_upright_pair() { + let frame = gradient(12, 6); + let upright = RawshiftDecoder + .probe(&jpeg_bytes(&frame, Some(&oriented(1))), "jpg") + .expect("probe"); + assert_eq!(upright.stored_dimensions, (12, 6)); + assert_eq!(upright.upright_dimensions(), (12, 6)); + assert_eq!(upright.orientation, Some(1)); + assert_eq!(upright.gamut, Gamut::Srgb); + + let rotated = RawshiftDecoder + .probe(&jpeg_bytes(&frame, Some(&oriented(6))), "jpg") + .expect("probe"); + assert_eq!(rotated.stored_dimensions, (12, 6)); + assert_eq!( + rotated.upright_dimensions(), + (6, 12), + "a quarter-turn transposes what a viewer shows" + ); +} + +/// All eight EXIF orientations, as a permutation of four marked quadrants plus the dimension +/// pair. This is the table an upright frame depends on, and it is hand-derived from the EXIF +/// definitions rather than from the transform code. +#[test] +fn every_exif_orientation_lands_upright() { + // (orientation, transposed?, (TL, TR, BL, BR) after the transform) + let table: &[(u16, bool, (u32, u32, u32, u32))] = &[ + (1, false, (0, 85, 170, 255)), // identity + (2, false, (85, 0, 255, 170)), // mirror horizontal + (3, false, (255, 170, 85, 0)), // rotate 180 + (4, false, (170, 255, 0, 85)), // mirror vertical + (5, true, (0, 170, 85, 255)), // transpose + (6, true, (170, 0, 255, 85)), // rotate 90 CW + (7, true, (255, 85, 170, 0)), // transverse + (8, true, (85, 255, 0, 170)), // rotate 90 CCW + ]; + let (width, height) = (64u32, 32u32); + let frame = quadrants(width, height); + + for &(orientation, transposed, expected) in table { + let bytes = jpeg_bytes(&frame, Some(&oriented(orientation))); + let decoded = RawshiftDecoder + .decode(&bytes, "jpg") + .unwrap_or_else(|e| panic!("orientation {orientation}: {e}")); + + assert_eq!( + decoded.orientation_applied, orientation, + "the consumed tag is recorded so a renderer does not rotate again" + ); + let expected_dims = if transposed { + (height, width) + } else { + (width, height) + }; + assert_eq!( + (decoded.width(), decoded.height()), + expected_dims, + "orientation {orientation} dimensions" + ); + + let got = quadrant_means(&decoded.image); + let close = |a: u32, b: u32| a.abs_diff(b) <= 20; + assert!( + close(got.0, expected.0) + && close(got.1, expected.1) + && close(got.2, expected.2) + && close(got.3, expected.3), + "orientation {orientation}: quadrants {got:?} are not {expected:?}" + ); + } +} + +/// An orientation value outside 1..=8 is dropped rather than recorded. `apply_orientation` +/// warns and no-ops on one, so honouring it would leave `orientation_applied` claiming a +/// transform that never happened. +#[test] +fn an_out_of_range_orientation_tag_is_ignored() { + let bytes = jpeg_bytes(&gradient(8, 8), Some(&oriented(42))); + let probed = RawshiftDecoder.probe(&bytes, "jpg").expect("probe"); + assert_eq!(probed.orientation, None); + let decoded = RawshiftDecoder.decode(&bytes, "jpg").expect("decode"); + assert_eq!(decoded.orientation_applied, 1); +} + +// ── The panic guard ────────────────────────────────────────────────────────── + +/// A [`Decoder`] that panics, and one that lies about its buffer — the two failures real bytes +/// cannot be relied on to produce. +struct HostileDecoder { + panic: bool, +} + +impl Decoder for HostileDecoder { + fn probe(&self, _bytes: &[u8], _ext: &str) -> Result { + Err(MediaError::NotAStillImage) + } + + fn decode(&self, _bytes: &[u8], _ext: &str) -> Result { + assert!( + !self.panic, + "a third-party decoder panicking on untrusted bytes" + ); + Err(MediaError::NotAStillImage) + } +} + +/// A panicking decoder becomes a reported error, never an aborted import. +#[test] +fn a_panicking_decoder_is_caught_at_the_boundary() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = decode_guarded(&HostileDecoder { panic: true }, b"whatever", "jpg"); + std::panic::set_hook(previous); + assert_eq!(caught, Err(MediaError::DecoderPanic)); + + // The guard is transparent when nothing panics. + assert_eq!( + decode_guarded(&HostileDecoder { panic: false }, b"whatever", "jpg"), + Err(MediaError::NotAStillImage) + ); + assert!(decode_guarded(&RawshiftDecoder, &png_bytes(&gradient(4, 4)), "png").is_ok()); +} + +// ── Resize ─────────────────────────────────────────────────────────────────── + +/// The sizing rule: cap the long edge, keep the aspect ratio, never return a zero edge, and +/// leave a frame that already fits exactly as it was. +#[test] +fn capped_dimensions_preserves_aspect_and_never_returns_zero() { + assert_eq!(capped_dimensions(512, 384, 256), (256, 192)); + assert_eq!(capped_dimensions(384, 512, 256), (192, 256)); + assert_eq!(capped_dimensions(1000, 1000, 256), (256, 256)); + assert_eq!(capped_dimensions(4032, 3024, 256), (256, 192)); + // Already inside the cap: untouched, which is what lets a caller compare and skip. + assert_eq!(capped_dimensions(128, 96, 256), (128, 96)); + assert_eq!(capped_dimensions(256, 256, 256), (256, 256)); + // Extreme ratios round the short edge to 1 rather than to an empty buffer. + assert_eq!(capped_dimensions(8000, 3, 256), (256, 1)); + assert_eq!(capped_dimensions(3, 8000, 256), (1, 256)); + // A zero cap is raised to 1 rather than producing an empty frame. + assert_eq!(capped_dimensions(100, 50, 0), (1, 1)); +} + +/// The downscale is deterministic and its output is exactly `w*h*4`. Determinism is a +/// requirement, not a nicety: the derivative's bytes are content-addressed by a signed manifest. +#[test] +fn the_downscale_is_deterministic_and_correctly_sized() { + let source = gradient(512, 384); + let first = downscale_rgba8(&source, 256); + let second = downscale_rgba8(&source, 256); + assert_eq!((first.width, first.height), (256, 192)); + assert_eq!(first.rgba.len(), 256 * 192 * 4); + assert_eq!(first.rgba, second.rgba, "identical input, identical bytes"); + + // A frame within the cap comes back untouched. + let small = gradient(100, 80); + assert_eq!(downscale_rgba8(&small, 256), small); +} + +/// A box average over a flat region reproduces that region's own value exactly — the property +/// that keeps a downscaled thumbnail from drifting darker with every reduction. +#[test] +fn the_downscale_preserves_flat_regions_and_stays_opaque() { + let uniform = RgbaImage { + width: 64, + height: 64, + rgba: vec![137, 42, 200, 255].repeat(64 * 64), + }; + let out = downscale_rgba8(&uniform, 16); + assert_eq!((out.width, out.height), (16, 16)); + assert!( + out.rgba.chunks_exact(4).all(|px| px == [137, 42, 200, 255]), + "a flat region survives an area average exactly" + ); + + // The quadrant markers survive a 4x reduction, i.e. the filter is not smearing regions into + // one another beyond their own boundary. + let reduced = downscale_rgba8(&quadrants(128, 128), 32); + let means = quadrant_means(&reduced); + assert_eq!(means, (0, 85, 170, 255)); +} + +// ── Derivatives ────────────────────────────────────────────────────────────── + +/// Two signing keys and a fixed context — the epoch/authorisation material a manifest needs +/// that pixels do not carry. +fn signers() -> (HybridSigningKey, HybridSigningKey) { + ( + HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]), + HybridSigningKey::from_seed_bytes(&[9; 32], &[10; 32]), + ) +} + +/// A real AMK-backed sealer — the same `encrypt_asset_rekey` construction the import path uses, +/// so these tests exercise the production encryption rather than a stand-in. +struct TestSealer { + amk: Amk, + asset_id: Uuid, +} + +impl DerivativeSealer for TestSealer { + fn seal(&self, plaintext: &[u8]) -> Result { + let (enc, _ciphertext, _key) = + encrypt_asset_rekey(&self.amk, &self.asset_id, plaintext, None).expect("sealing"); + Ok(SealedDerivative { + ciphertext_hash: enc.ciphertext_hash, + nonce_prefix: enc.nonce_prefix, + }) + } +} + +fn sealer(asset_id: Uuid) -> TestSealer { + TestSealer { + amk: Amk::from_bytes([0x5A; 32]), + asset_id, + } +} + +/// What the *original*'s own manifest committed to; the `original` sentinel signs exactly this. +const ORIGINAL_SEAL: SealedDerivative = SealedDerivative { + ciphertext_hash: Hash32([0xC1; 32]), + nonce_prefix: [1, 2, 3, 4, 5, 6, 7], +}; + +/// No prior chain: every test that does not say otherwise generates for a fresh asset. +fn no_prior_heads() -> HashMap { + HashMap::new() +} + +fn context<'a>( + device: &'a HybridSigningKey, + write_tier: &'a HybridSigningKey, + sealer: &'a dyn DerivativeSealer, + prior_heads: &'a HashMap, + asset_id: Uuid, +) -> DerivativeContext<'a> { + DerivativeContext { + source_asset_id: asset_id, + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: PROTOCOL_VERSION.into(), + amk_version: AmkVersion(1), + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + generated_at: "2026-09-01T00:00:00Z".into(), + device_signer: device, + write_tier_signer: write_tier, + sealer, + prior_heads, + original: ORIGINAL_SEAL, + } +} + +fn generate(frame: &RgbaImage, original: &[u8]) -> StillDerivatives { + let (device, write_tier) = signers(); + let seal = sealer(Uuid::from_u128(0xB1)); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, Uuid::from_u128(0xB1)); + let decoded = RawshiftDecoder + .decode(original, "png") + .expect("the fixture decodes"); + assert_eq!( + (decoded.width(), decoded.height()), + (frame.width, frame.height) + ); + generate_still_derivatives(&decoded, &DerivativeTier::GENERATED, &ctx) + .expect("generation succeeds") +} + +/// The thumbnail tier over a source larger than the cap: real JXL bytes, a signed manifest +/// binding their hash, and the two formats this build cannot encode recorded as deferrals rather +/// than silently omitted. +#[test] +fn the_thumbnail_tier_encodes_jxl_and_defers_the_rest() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + + assert_eq!(result.generated.len(), 1, "one encodable format today"); + let thumb = &result.generated[0]; + assert_eq!(thumb.tier, DerivativeTier::Thumbnail); + assert_eq!(thumb.format, DerivativeFormat::Jxl); + assert_eq!(thumb.manifest.core.format, "image/jxl"); + assert_eq!(thumb.manifest.core.role, DerivativeRole::Thumbnail); + // The manifest commits to the **ciphertext**, not to the plaintext on disk. Re-derive it + // the way the push path does — from the recorded prefix under the same AMK — and the + // signed address has to come back. + let seal = sealer(Uuid::from_u128(0xB1)); + let file_key = seal + .amk + .derive_file_key(&Uuid::from_u128(0xB1), &thumb.manifest.core.nonce_prefix); + let (_, ciphertext) = crate::crypto::encryption::stream::encrypt_asset_vec_with_prefix( + &file_key, + thumb.manifest.core.nonce_prefix, + &thumb.bytes, + ); + assert_eq!( + thumb.manifest.core.ciphertext_hash, + crate::crypto::hash::hash_bytes(&ciphertext), + "the manifest binds the ciphertext the push path re-derives" + ); + assert_ne!( + thumb.manifest.core.ciphertext_hash, + crate::crypto::hash::hash_bytes(&thumb.bytes), + "and that is not the plaintext's address — a thumbnail is a recognisable copy of a \ + private photo and does not cross the network in the clear" + ); + assert_eq!( + crate::crypto::encryption::stream::decrypt_asset_vec( + &file_key, + &thumb.manifest.core.nonce_prefix, + &ciphertext + ) + .expect("the ciphertext authenticates"), + thumb.bytes, + "and it round-trips back to the bytes on disk" + ); + assert_eq!(thumb.manifest.core.version, DERIVATIVE_MANIFEST_VERSION); + assert!( + thumb.manifest.core.prior_provenance_hash.is_none(), + "first of its role" + ); + + // The bytes are a real JXL of the tier's size. + assert_eq!( + StillFormat::from_bytes(&thumb.bytes), + Some(StillFormat::Jxl) + ); + let back = RawshiftDecoder + .decode(&thumb.bytes, "jxl") + .expect("the thumbnail decodes"); + assert_eq!((back.width(), back.height()), (256, 192)); + + // The gap is per (tier, format), recorded rather than collapsed. WebP is here for a + // different reason from AVIF: not a missing toolchain but a codec that does not compile for + // aarch64, so it is deferred on every target rather than only where nasm is absent. + assert_eq!( + result.deferred, + vec![ + (DerivativeTier::Thumbnail, DerivativeFormat::Avif), + (DerivativeTier::Thumbnail, DerivativeFormat::WebP), + ] + ); + + // Both signatures verify over the canonical core. + let (device, write_tier) = signers(); + let bytes = thumb.manifest.core.signing_bytes(); + assert!( + device + .verifying_key() + .verify(&bytes, &thumb.manifest.device_sig) + ); + assert!( + write_tier + .verifying_key() + .verify(&bytes, &thumb.manifest.write_sig) + ); +} + +/// The tier's declared `q=50` is **advisory today**: `zune-jpegxl`'s `JxlSimpleEncoder` is +/// lossless, so the thumbnail round-trips its downscaled pixels exactly and costs what a +/// lossless encode costs. +/// +/// Asserted rather than left as a comment, because it is the one place this build visibly +/// departs from the tier table and the departure disappears the moment a lossy backend lands. +#[test] +fn the_jxl_thumbnail_is_lossless_today() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + let thumb = &result.generated[0]; + + let back = RawshiftDecoder + .decode(&thumb.bytes, "jxl") + .expect("the thumbnail decodes"); + let expected = downscale_rgba8(&gradient(512, 384), 256); + assert_eq!( + back.image, expected, + "a lossless encode reproduces the downscaled frame exactly" + ); +} + +/// A source no larger than the tier's cap takes the signed `original` sentinel — an explicit +/// marker, distinct from an absent derivative, and never a redundant re-encode. +#[test] +fn a_source_within_the_cap_signs_the_original_sentinel() { + let frame = gradient(128, 96); + let original = png_bytes(&frame); + let result = generate(&frame, &original); + + assert_eq!(result.generated.len(), 1); + let only = &result.generated[0]; + assert_eq!(only.format, DerivativeFormat::Original); + assert_eq!(only.manifest.core.format, "original"); + assert!( + only.bytes.is_empty(), + "the sentinel *references* the original rather than copying it: a copy would put the \ + source's EXIF, GPS included, into a derivative blob" + ); + assert_eq!( + only.manifest.core.ciphertext_hash, ORIGINAL_SEAL.ciphertext_hash, + "the sentinel signs the **original's** ciphertext address — the blob a receiver already \ + holds — and encrypts nothing of its own" + ); + assert_eq!( + only.manifest.core.nonce_prefix, ORIGINAL_SEAL.nonce_prefix, + "and the original's prefix, so the reference selects the same key" + ); + assert!( + result.deferred.is_empty(), + "nothing was deferred: the tier is satisfied by the original, not by a missing encoder" + ); + assert_eq!(DerivativeFormat::Original.extension(), None); +} + +/// Manifests of the same role chain by content hash over the previous one's canonical CBOR, +/// signatures included — the same append-only link the asset provenance chain uses. +/// +/// Exercised through [`sign_derivative`](super::derivative::sign_derivative) rather than +/// through [`generate_still_derivatives`], and deliberately: only JXL is encodable today, so a +/// single call produces one manifest per role and the multi-link case — the half that can +/// actually be wrong — is unreachable from the public entry point until a second encoder lands +/// (the filed `S-B1` remainder, #437). +#[test] +fn manifests_of_one_role_form_an_append_only_chain() { + let (device, write_tier) = signers(); + let seal = sealer(Uuid::from_u128(0xB2)); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, Uuid::from_u128(0xB2)); + let mut prior = None; + + let first = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::Jxl, + b"first generation bytes".to_vec(), + seal.seal(b"first generation bytes").expect("sealing"), + &mut prior, + ) + .expect("signing the first manifest"); + assert!( + first.manifest.core.prior_provenance_hash.is_none(), + "the first manifest of a role starts that role's chain" + ); + + let expected_link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(&first.manifest).expect("canonical CBOR"), + ); + assert_eq!( + prior, + Some(expected_link), + "the cursor advances to this manifest" + ); + + let second = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::Jxl, + b"second generation bytes".to_vec(), + seal.seal(b"second generation bytes").expect("sealing"), + &mut prior, + ) + .expect("signing the second manifest"); + assert_eq!( + second.manifest.core.prior_provenance_hash, + Some(expected_link), + "the second manifest chains to the first by content hash" + ); + + // Breaking the link is detectable: the hash covers the signatures, so any edit to the first + // manifest moves the value the second one has to carry. + let mut tampered = first.manifest.clone(); + tampered.core.generated_at = "2026-09-02T00:00:00Z".into(); + let tampered_link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(&tampered).expect("canonical CBOR"), + ); + assert_ne!( + tampered_link, expected_link, + "a rewritten predecessor no longer matches the link its successor signed" + ); +} + +/// Each tier records its own role, so each role is its own chain rather than one interleaved +/// sequence. +#[test] +fn each_tier_starts_its_own_role_chain() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let seal = sealer(Uuid::from_u128(0xB5)); + let both = generate_still_derivatives( + &decoded, + &[DerivativeTier::Thumbnail, DerivativeTier::Preview], + &context( + &device, + &write_tier, + &seal, + &no_prior_heads(), + Uuid::from_u128(0xB5), + ), + ) + .expect("both tiers"); + + let roles: Vec = both + .generated + .iter() + .map(|d| d.manifest.core.role) + .collect(); + assert_eq!( + roles, + vec![DerivativeRole::Thumbnail, DerivativeRole::Preview] + ); + for derivative in &both.generated { + assert!( + derivative.manifest.core.prior_provenance_hash.is_none(), + "the first manifest of each role starts that role's chain" + ); + } + + // The preview tier keeps the source resolution; only the thumbnail caps a long edge. + let previewed = both + .generated + .iter() + .find(|d| d.tier == DerivativeTier::Preview) + .expect("a preview was generated"); + let back = RawshiftDecoder + .decode(&previewed.bytes, "jxl") + .expect("the preview decodes"); + assert_eq!((back.width(), back.height()), (512, 384)); +} + +/// Two derivatives of one asset get **distinct** nonce prefixes, and therefore distinct keys and +/// distinct ciphertexts, even when their plaintext is byte-identical. +/// +/// This is the property that makes per-derivative sealing worth doing rather than reusing the +/// original's key: a shared prefix would reuse a keystream across two blobs under one file key, +/// which is the failure the encryption doc's per-file derivation exists to prevent. +#[test] +fn two_derivatives_of_one_asset_never_share_a_nonce_prefix() { + let (device, write_tier) = signers(); + let asset_id = Uuid::from_u128(0xB6); + let seal = sealer(asset_id); + let heads = no_prior_heads(); + let ctx = context(&device, &write_tier, &seal, &heads, asset_id); + let identical = b"byte-identical derivative plaintext".to_vec(); + + let mut prior = None; + let first = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Thumbnail, + DerivativeFormat::Jxl, + identical.clone(), + seal.seal(&identical).expect("sealing"), + &mut prior, + ) + .expect("first"); + let mut prior = None; + let second = super::derivative::sign_derivative( + &ctx, + DerivativeTier::Preview, + DerivativeFormat::Jxl, + identical.clone(), + seal.seal(&identical).expect("sealing"), + &mut prior, + ) + .expect("second"); + + assert_eq!( + first.bytes, second.bytes, + "the plaintext really is identical" + ); + assert_ne!( + first.manifest.core.nonce_prefix, second.manifest.core.nonce_prefix, + "a fresh prefix is drawn per derivative" + ); + assert_ne!( + first.manifest.core.ciphertext_hash, second.manifest.core.ciphertext_hash, + "so identical plaintext does not produce a shared ciphertext or a shared key" + ); +} + +/// **The privacy case.** A thumbnail must not inherit the source's EXIF, and above all not its +/// GPS fix. +/// +/// The control half is what makes this a real test: the crate's *default* is to embed +/// everything, so the same frame encoded the way `MetadataEmbedOptions::default()` would encode +/// it does carry the fix. Capsule's derivative does not. +#[test] +fn a_thumbnail_carries_no_exif_and_no_gps() { + let frame = gradient(512, 384); + let located_metadata = located(); + let source = jpeg_bytes(&frame, Some(&located_metadata)); + + // The source really does carry the fix — otherwise the assertion below proves nothing. + assert!( + contains(&source, b"Exif\0\0"), + "the fixture JPEG must carry an EXIF APP1 segment" + ); + assert!( + gps_rationals_present(&source), + "the fixture JPEG must carry the GPS rationals" + ); + + // The control: the same frame through the same JXL backend, configured the way the crate's + // own `MetadataEmbedOptions::default()` would configure it. It leaks — which is what makes + // the assertion below a test of Capsule's strip rather than of a codec that never embeds. + let leaky = jxl_bytes(&frame, Some(&located_metadata)); + assert!( + gps_rationals_present(&leaky), + "the control must leak, or this test is not testing the strip" + ); + + // Capsule's derivative, over the same GPS-bearing source. + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&source, "jpg").expect("decode"); + let seal = sealer(Uuid::from_u128(0xB3)); + let result = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context( + &device, + &write_tier, + &seal, + &no_prior_heads(), + Uuid::from_u128(0xB3), + ), + ) + .expect("generation"); + let thumb = &result.generated[0].bytes; + assert!(!thumb.is_empty(), "the thumbnail has bytes to inspect"); + assert!(!contains(thumb, b"Exif"), "no EXIF box in the thumbnail"); + assert!(!contains(thumb, b"xml "), "no XMP box"); + assert!(!contains(thumb, b"jumb"), "no metadata container box"); + assert!( + !gps_rationals_present(thumb), + "the GPS rationals must not survive into a thumbnail" + ); +} + +/// Whether `needle` appears anywhere in `haystack`. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Whether the fixture's GPS degrees/minutes/seconds appear as EXIF rationals — a +/// `numerator/denominator` pair per component, in either byte order. +fn gps_rationals_present(bytes: &[u8]) -> bool { + let rational = |value: u32| -> [Vec; 2] { + let mut be = value.to_be_bytes().to_vec(); + be.extend_from_slice(&1u32.to_be_bytes()); + let mut le = value.to_le_bytes().to_vec(); + le.extend_from_slice(&1u32.to_le_bytes()); + [be, le] + }; + // The seconds component of each coordinate is the most distinctive value; requiring both + // keeps an incidental byte match from reading as a leak. + let has = |value: u32| rational(value).iter().any(|n| contains(bytes, n)); + has(GPS_LAT_DMS[2]) && has(GPS_LON_DMS[2]) +} + +// ── The closed format set ──────────────────────────────────────────────────── + +/// `mime` and `parse` are inverses over the whole closed set, and nothing outside it parses. +#[test] +fn the_closed_format_set_round_trips_and_admits_nothing_else() { + for format in [ + DerivativeFormat::Jxl, + DerivativeFormat::Avif, + DerivativeFormat::WebP, + DerivativeFormat::Original, + ] { + assert_eq!(DerivativeFormat::parse(format.mime()), Some(format)); + assert!(DerivativeFormat::is_recognized(format.mime())); + } + for rejected in [ + "image/future-codec", + "image/jpeg", + "image/png", + "IMAGE/WEBP", + "original ", + "", + "embedding/mobileclip-b", + ] { + assert!( + !DerivativeFormat::is_recognized(rejected), + "{rejected:?} is outside the closed set" + ); + } + // Only JXL — the table's committed *master* format — and the sentinel can be produced here. + // AVIF is blocked on a build-host assembler; WebP is blocked on an upstream defect, its + // codec not compiling for aarch64 at all. + assert!(DerivativeFormat::Jxl.is_encodable()); + assert!(DerivativeFormat::Original.is_encodable()); + assert!(!DerivativeFormat::Avif.is_encodable()); + assert!(!DerivativeFormat::WebP.is_encodable()); +} + +/// A signed still-role manifest whose `format` is outside the closed set is rejected at +/// verification, and an embedding-role manifest — which writes `embedding/{model_id}` into the +/// same field — is not caught in the crossfire. +#[test] +fn verification_rejects_an_unrecognised_still_format() { + let (device, write_tier) = signers(); + let sign = |role: DerivativeRole, format: &str| -> DerivativeManifest { + DerivativeCore { + version: DERIVATIVE_MANIFEST_VERSION.into(), + crypto_suite_id: CRYPTO_SUITE_ID, + protocol_version: Some(PROTOCOL_VERSION.into()), + amk_version: Some(AmkVersion(1)), + source_asset_id: Uuid::from_u128(0xB4), + role, + format: format.into(), + ciphertext_hash: crate::crypto::hash::hash_bytes(b"bytes"), + nonce_prefix: [9, 8, 7, 6, 5, 4, 3], + generated_by_device: Uuid::from_u128(0xD1), + generated_by_client: "capsule-core/test".into(), + model_id: None, + model_version: None, + generated_at: "2026-09-01T00:00:00Z".into(), + prior_provenance_hash: None, + } + .sign(&device, &write_tier) + .expect("signing") + }; + + assert_eq!( + verify_still_format(&sign(DerivativeRole::Thumbnail, "image/webp")), + Ok(Some(DerivativeFormat::WebP)) + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Preview, "original")), + Ok(Some(DerivativeFormat::Original)) + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Thumbnail, "image/future-codec")), + Err("image/future-codec".to_string()), + "an unrecognised still format is a structural rejection" + ); + assert_eq!( + verify_still_format(&sign(DerivativeRole::Embedding, "embedding/mobileclip-b")), + Ok(None), + "the embedding-role grammar is not this set's business" + ); +} + +// ── The LQIP producer ──────────────────────────────────────────────────────── + +/// A decoded frame is exactly what the unconditional LQIP encoder takes — the reason +/// [`DecodedImage`](super::DecodedImage) carries a [`RgbaImage`] rather than its own buffer +/// type. +#[test] +fn a_decoded_frame_encodes_an_lqip_at_the_committed_width() { + let frame = gradient(200, 150); + let decoded = RawshiftDecoder + .decode(&png_bytes(&frame), "png") + .expect("decode"); + let lqip = Lqip::encode( + decoded.width(), + decoded.height(), + &decoded.image.rgba, + decoded.gamut, + ) + .expect("a decoded frame is a valid LQIP source"); + assert_eq!(lqip.as_bytes().len(), 32, "DEFAULT_TIER is 32 bytes"); + assert_eq!( + lqip.to_sidecar().format_version, + crate::lqip::LQIP_FORMAT_V1 + ); + + // The placeholder is computed from the full-resolution frame, not from the thumbnail: + // chromahash band-limits on the read side, so pre-resizing would cap fidelity. + let thumb = downscale_rgba8(&decoded.image, 256); + let from_thumb = Lqip::encode(thumb.width, thumb.height, &thumb.rgba, decoded.gamut) + .expect("a downscaled frame also encodes"); + assert_eq!( + from_thumb.as_bytes().len(), + 32, + "the tier is fixed regardless of the source size" + ); +} + +/// The per-channel accumulator genuinely crosses `u32::MAX`, and the boundary arithmetic is +/// documented for what it is. +/// +/// **The accumulator overflow is real and reachable.** Reducing a frame to a cap of 1 sums every +/// sample into one destination pixel, so the running total is `pixels * 255`. At 4200x4200 that +/// is 4.5e9 — past `u32::MAX` (4.29e9) — and a `u32` accumulator would panic in debug and wrap +/// into wrong pixels in release. `downscale_rgba8` is a `pub` entry point, so a cap of 1 is +/// reachable even though the tier table only ever passes 256. +/// +/// **The boundary product is defensive, not demonstrated.** `(y + 1) * src_h` reaches +/// `dst_edge * src_edge`, which for the shapes this build actually produces (a 256 px cap) stays +/// far inside 32 bits. It is computed in `u64` anyway because the function is public and its +/// inputs are not bounded by the tier table — but the earlier claim that a 1x300000 frame +/// crossed `u32::MAX` was simply wrong arithmetic (7.7e7, not 7.7e10), and a test asserting a +/// false reason is worse than no test. +#[test] +fn the_downscale_accumulator_survives_crossing_u32_max() { + // 4200 * 4200 * 255 = 4_501_980_000 > u32::MAX. + let edge = 4200u32; + let pixels = u64::from(edge) * u64::from(edge); + assert!( + pixels * 255 > u64::from(u32::MAX), + "the fixture must actually cross the boundary it exists to test" + ); + + let flat = RgbaImage { + width: edge, + height: edge, + rgba: vec![255, 255, 255, 255].repeat((edge * edge) as usize), + }; + let single = downscale_rgba8(&flat, 1); + assert_eq!((single.width, single.height), (1, 1)); + assert_eq!( + single.rgba, + vec![255, 255, 255, 255], + "every sample sums into one pixel, and the mean is still 255" + ); + + // The tall-frame shape, kept because it is the one the tier path can actually meet: a + // lopsided source reduced to a 256 px long edge. + let tall = RgbaImage { + width: 1, + height: 300_000, + rgba: vec![200, 100, 50, 255].repeat(300_000), + }; + let reduced = downscale_rgba8(&tall, 256); + assert_eq!((reduced.width, reduced.height), (1, 256)); + assert!( + reduced + .rgba + .chunks_exact(4) + .all(|px| px == [200, 100, 50, 255]), + "a flat frame survives a 1172x row reduction exactly" + ); +} + +/// A sealer that refuses is a **workspace** fault and must be distinguishable at the type level +/// from a codec that refuses, because the import path propagates one and degrades on the other. +/// +/// This is the `F1` contract: `MediaError::Sign` is its own variant precisely so +/// `prepare_still` can tell "this asset has no thumbnail" from "this workspace cannot author a +/// signed record", instead of string-matching both into one `LifecycleError::Io`. +#[test] +fn a_signing_fault_is_its_own_variant_not_an_encode_failure() { + struct RefusingSealer; + impl DerivativeSealer for RefusingSealer { + fn seal(&self, _plaintext: &[u8]) -> Result { + Err(MediaError::Sign { + detail: "the hardware signer refused".into(), + }) + } + } + + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let error = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context( + &device, + &write_tier, + &RefusingSealer, + &no_prior_heads(), + Uuid::from_u128(0xB8), + ), + ) + .expect_err("a refusing sealer fails generation"); + assert!( + matches!(error, MediaError::Sign { .. }), + "a signing/sealing refusal keeps its own identity all the way out: {error:?}" + ); +} + +/// **A role's chain continues across generations.** A second run over an asset that already has +/// a thumbnail extends that role's chain rather than starting a parallel one. +/// +/// This is what makes derivative provenance append-only *in time* rather than merely within one +/// call, and it is the property a `#437` backfill depends on: adding the AVIF variant to an +/// asset that already has JXL must not fork the record. A forked chain is not something a later +/// run can repair, so it is asserted before the backfill exists. +#[test] +fn a_roles_chain_continues_across_generation_runs() { + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let asset_id = Uuid::from_u128(0xB7); + let seal = sealer(asset_id); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + // First generation: the role's chain starts. + let first = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context(&device, &write_tier, &seal, &no_prior_heads(), asset_id), + ) + .expect("first run"); + let head = &first.generated[0].manifest; + assert!( + head.core.prior_provenance_hash.is_none(), + "the first manifest of a role starts that role's chain" + ); + + // What a reader would compute from the persisted bundle. + let link = crate::crypto::hash::hash_bytes( + &crate::cbor::to_canonical_vec(head).expect("canonical CBOR"), + ); + let mut heads = HashMap::new(); + heads.insert(DerivativeRole::Thumbnail, link); + + // Second generation, handed that head. + let second = generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context(&device, &write_tier, &seal, &heads, asset_id), + ) + .expect("second run"); + assert_eq!( + second.generated[0].manifest.core.prior_provenance_hash, + Some(link), + "the second run extends the chain instead of forking it" + ); + + // A role with no recorded head still starts cleanly — the map is a lookup, not a gate. + let preview = generate_still_derivatives( + &decoded, + &[DerivativeTier::Preview], + &context(&device, &write_tier, &seal, &heads, asset_id), + ) + .expect("preview run"); + assert!( + preview.generated[0] + .manifest + .core + .prior_provenance_hash + .is_none(), + "a role the map does not mention starts its own chain" + ); +} + +/// A **panicking sealer/encoder** is caught at the same boundary a panicking decoder is, so one +/// bad frame cannot abort a 20,000-photo import part way through. +/// +/// The decode guard was never the whole story: `chromahash` and the JXL encoder are both pre-1.0 +/// too, and they run *after* the decoder on the same untrusted pixels. This mirrors +/// [`HostileDecoder`] on the encode side. +#[test] +fn a_panicking_encoder_is_caught_like_a_panicking_decoder() { + struct PanickingSealer; + impl DerivativeSealer for PanickingSealer { + fn seal(&self, _plaintext: &[u8]) -> Result { + panic!("a pre-1.0 codec panicking on a frame the decoder accepted"); + } + } + + let frame = gradient(512, 384); + let original = png_bytes(&frame); + let (device, write_tier) = signers(); + let decoded = RawshiftDecoder.decode(&original, "png").expect("decode"); + + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let caught = super::guarded("derivatives", || { + generate_still_derivatives( + &decoded, + &DerivativeTier::GENERATED, + &context( + &device, + &write_tier, + &PanickingSealer, + &no_prior_heads(), + Uuid::from_u128(0xB9), + ), + ) + }); + std::panic::set_hook(previous); + + assert!( + matches!(caught, Err(MediaError::DecoderPanic)), + "an unwind from anywhere in generation becomes a reported error, never an abort" + ); +} diff --git a/capsule-core/src/metadata/export_policy.rs b/capsule-core/src/metadata/export_policy.rs deleted file mode 100644 index c233d77b..00000000 --- a/capsule-core/src/metadata/export_policy.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Privacy on export (SSoT: [Metadata — Privacy on Export]). -//! -//! Several sidecar fields are fingerprinting surface if they leave the user's trust -//! boundary unredacted (a camera serial links every photo to one device; precise GPS -//! reveals a home address). When an asset crosses a boundary (share link, external backup -//! handed off, federated peer), Capsule strips these by default and retains them only on -//! explicit, per-export opt-in. The **local** sidecar is never modified. -//! -//! [Metadata — Privacy on Export]: https://docs/design/metadata/#privacy-on-export - -use crate::sidecar::sidecar_v1::SidecarV1; - -/// Per-export opt-ins. Defaults strip everything (the safe default). -#[derive(Debug, Clone, Copy, Default)] -pub struct ExportOptions { - /// Retain the camera serial number. - pub retain_camera_serial: bool, - /// Retain the importing device id. - pub retain_device_id: bool, - /// Retain the importing session id. - pub retain_session_id: bool, - /// Retain full-precision GPS (otherwise rounded to ~1 km). - pub retain_full_gps: bool, -} - -/// Round a coordinate to 2 decimal places (~1 km), matching the export default. -fn round_2dp(x: f64) -> f64 { - (x * 100.0).round() / 100.0 -} - -/// Produce an export copy of `sidecar` with fingerprinting fields stripped per `opts`. The -/// returned sidecar is **unsigned** (the caller re-signs for the export context); the input -/// is left untouched. -pub fn strip_for_export(sidecar: &SidecarV1, opts: &ExportOptions) -> SidecarV1 { - let mut out = sidecar.clone(); - out.signature = None; - - if !opts.retain_camera_serial - && let Some(cam) = out.camera_id.as_mut() - { - // Keep the model (not identifying); drop the per-device serial. - cam.serial.clear(); - } - if !opts.retain_device_id { - out.device_id = uuid::Uuid::nil(); - } - if !opts.retain_session_id { - out.session_id = uuid::Uuid::nil(); - } - if !opts.retain_full_gps - && let Some(gps) = out.gps.as_mut() - { - gps.lat = round_2dp(gps.lat); - gps.lon = round_2dp(gps.lon); - } - out -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use uuid::Uuid; - - use super::*; - use crate::crypto::hash::Hash32; - use crate::sidecar::sidecar_v1::{CameraId, Gps, GpsSource, SIDECAR_SCHEMA_V1}; - - fn sidecar() -> SidecarV1 { - SidecarV1 { - sidecar_schema: SIDECAR_SCHEMA_V1, - crypto_suite_id: crate::crypto::CRYPTO_SUITE_ID, - uuid: Uuid::from_u128(1), - hash: Hash32([0; 32]), - capture_timestamp: "2026-05-31T10:00:00Z".into(), - import_timestamp: "2026-05-31T11:00:00Z".into(), - content_type: "image/jpeg".into(), - dimensions: None, - lqip: None, - tags_user: Default::default(), - tags_ai: Default::default(), - caption: Default::default(), - rating: Default::default(), - stack_membership: Default::default(), - cull: Default::default(), - hidden: Default::default(), - camera_id: Some(CameraId { - model: "iPhone 15 Pro".into(), - serial: "SECRET-SERIAL".into(), - }), - device_id: Uuid::from_u128(0xD1), - session_id: Uuid::from_u128(0x5E), - gps: Some(Gps { - lat: 40.712812, - lon: -74.006015, - source: GpsSource::Exif, - datum: crate::domain::GpsDatum::Wgs84, - }), - provenance_chain_hash: Some(Hash32([0; 32])), - unknown: BTreeMap::new(), - signature: None, - } - } - - #[test] - fn default_strips_all_fingerprinting_fields() { - let s = sidecar(); - let e = strip_for_export(&s, &ExportOptions::default()); - assert_eq!(e.camera_id.as_ref().unwrap().serial, ""); - assert_eq!(e.camera_id.as_ref().unwrap().model, "iPhone 15 Pro"); // model retained - assert_eq!(e.device_id, Uuid::nil()); - assert_eq!(e.session_id, Uuid::nil()); - let gps = e.gps.unwrap(); - assert_eq!(gps.lat, 40.71); // rounded to 2dp - assert_eq!(gps.lon, -74.01); - - // Local sidecar is untouched. - assert_eq!(s.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); - assert_eq!(s.device_id, Uuid::from_u128(0xD1)); - assert_eq!(s.gps.as_ref().unwrap().lat, 40.712812); - } - - #[test] - fn opt_in_retains_each_field() { - let s = sidecar(); - let opts = ExportOptions { - retain_camera_serial: true, - retain_device_id: true, - retain_session_id: true, - retain_full_gps: true, - }; - let e = strip_for_export(&s, &opts); - assert_eq!(e.camera_id.as_ref().unwrap().serial, "SECRET-SERIAL"); - assert_eq!(e.device_id, Uuid::from_u128(0xD1)); - assert_eq!(e.session_id, Uuid::from_u128(0x5E)); - assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); - } - - #[test] - fn partial_opt_in() { - let s = sidecar(); - let opts = ExportOptions { - retain_full_gps: true, - ..Default::default() - }; - let e = strip_for_export(&s, &opts); - // GPS retained, but device id still stripped. - assert_eq!(e.gps.as_ref().unwrap().lat, 40.712812); - assert_eq!(e.device_id, Uuid::nil()); - } -} diff --git a/capsule-core/src/metadata/file.rs b/capsule-core/src/metadata/file.rs index 8d7b882f..7f1a4755 100644 --- a/capsule-core/src/metadata/file.rs +++ b/capsule-core/src/metadata/file.rs @@ -5,7 +5,7 @@ use std::{fs, io}; use jiff::Timestamp; use serde::{Deserialize, Serialize}; -use crate::utils::hash::get_file_hash; +use crate::crypto::hash::hash_file; #[derive(Clone, Serialize, Deserialize)] pub struct HashData(String); @@ -57,7 +57,7 @@ impl FileMetadata { let metadata = fs::metadata(path)?; // Get file hash - let hash = get_file_hash(path)?; + let hash = hash_file(path)?.to_hex(); // Get file size let size = metadata.len(); diff --git a/capsule-core/src/metadata/mod.rs b/capsule-core/src/metadata/mod.rs index ba86a85b..f84e2cd7 100644 --- a/capsule-core/src/metadata/mod.rs +++ b/capsule-core/src/metadata/mod.rs @@ -4,7 +4,6 @@ use std::path::Path; use crate::constants::SIDECAR_EXTENSIONS; pub mod crdt; -pub mod export_policy; mod file; mod filter; mod types; diff --git a/capsule-core/src/ml/mod.rs b/capsule-core/src/ml/mod.rs index de0e7bfa..8957660d 100644 --- a/capsule-core/src/ml/mod.rs +++ b/capsule-core/src/ml/mod.rs @@ -5,40 +5,40 @@ //! //! - the [`registry`] — the **canonical model inventory** (one v1-committed row per task) and the //! **embedding-provenance invariant** (every embedding carries `(model_id, model_version)`; -//! the [vector index](crate::db::vector) refuses inserts from **unknown** models, while a +//! the [vector index](crate::db::EmbeddingInsert) refuses inserts from **unknown** models, while a //! *superseded-but-known* version is admitted as a stale-flagged row and excluded from //! queries until regenerated). It owns the version-bump [swap //! primitive](registry::Registry::bump_version); //! - the [`regen`] loop — the background per-asset **regeneration orchestration** that consumes //! the staleness a swap creates: it walks //! [`stale_embedding_assets`](crate::db::DatabaseDriver::stale_embedding_assets) and re-embeds -//! each asset at the new canonical version through an injected [`Embedder`](regen::Embedder) +//! each asset at the new canonical version through an injected [`Embedder`] //! seam, resumably and per-asset (never a global truncate). The production implementor is -//! [`RunnerEmbedder`](regen::RunnerEmbedder), which rebuilds the derived index by re-reading -//! each **original** through [`AssetSource`](orchestrator::AssetSource) and re-running the -//! canonical model through [`ModelRunner`](runner::ModelRunner) — the same two seams first-time +//! [`RunnerEmbedder`], which rebuilds the derived index by re-reading +//! each **original** through [`AssetSource`] and re-running the +//! canonical model through [`ModelRunner`] — the same two seams first-time //! indexing uses, so a regenerated vector equals what a fresh import would produce. //! -//! - the [`runner`] seam — the [`ModelRunner`](runner::ModelRunner) trait every consumer routes +//! - the [`runner`] seam — the [`ModelRunner`] trait every consumer routes //! per-task inference over decoded pixels through, and the deterministic -//! [`FixtureRunner`](runner::FixtureRunner) double. Real per-platform runners (ONNX/CoreML/NNAPI, +//! [`FixtureRunner`] double. Real per-platform runners (ONNX/CoreML/NNAPI, //! the CLIP runner) ride the **default-off, weight-fetching `inference` feature** — a follow-up, //! never part of the default gate; //! - the [`orchestrator`] — the deterministic execution path for the v1-committed slots: run the //! canonical model over an asset, land semantic + face vectors in the right `vec0` partition //! ([platform-partition fallback](orchestrator::resolve_partition)), and write zero-shot //! [AI tags](orchestrator::auto_tag) into `tags_ai` as a signed metadata update through the -//! [`AiTagSink`](orchestrator::AiTagSink) seam — implemented by +//! [`AiTagSink`] seam — implemented by //! [`Workspace`](crate::lifecycle::Workspace), named as a trait so `ml` does not import //! `lifecycle` (which imports `ml`); plus the device-bound batching/thermal policy. //! -//! Real per-platform inference is deferred behind the [`Embedder`](regen::Embedder) / -//! [`ModelRunner`](runner::ModelRunner) seams (the real runner is a later slice), exactly as live +//! Real per-platform inference is deferred behind the [`Embedder`] / +//! [`ModelRunner`] seams (the real runner is a later slice), exactly as live //! MLS group state is deferred behind [`AlbumAuthority`](crate::crypto::authority::AlbumAuthority) //! with `ReferenceAuthority` standing in; the deterministic -//! [`DeterministicEmbedder`](regen::DeterministicEmbedder) and -//! [`FixtureRunner`](runner::FixtureRunner) drive every path with no model weights. The local -//! vector index lives in [`crate::db::vector`]. No model weights are committed to this repository. +//! [`DeterministicEmbedder`] and +//! [`FixtureRunner`] drive every path with no model weights. The local +//! vector index lives in the crate-private `db::vector`. No model weights are committed to this repository. //! //! [AI/ML Integrations]: https://docs/design/ai/ diff --git a/capsule-core/src/ml/orchestrator.rs b/capsule-core/src/ml/orchestrator.rs index 03d8c3db..e1985142 100644 --- a/capsule-core/src/ml/orchestrator.rs +++ b/capsule-core/src/ml/orchestrator.rs @@ -5,7 +5,7 @@ //! pixels, then land the results — //! //! - **embeddings** (semantic-search + face-recognition vectors) into the right `vec0` partition of -//! the [vector index](crate::db::vector), tagged with the runner's resolved partition +//! the [vector index](crate::db::EmbeddingInsert), tagged with the runner's resolved partition //! discriminator ([`resolve_partition`]); //! - **zero-shot AI tags** into the asset's `tags_ai` OR-set as a **signed metadata update** through //! the lifecycle ([`AiTagSink::add_ai_tags`]) — structurally separate from user tags, mirroring @@ -22,7 +22,7 @@ //! Two invariants are enforced at this boundary: //! //! - **Provenance.** A runner whose declared model is not the registry canonical for a task is -//! refused before any output is stored ([`require_canonical_runner`]). +//! refused before any output is stored (`require_canonical_runner`). //! - **Platform partition.** Comparable embeddings need byte-identical inference output across //! NPUs/CPUs. A device that reproduces the pinned known-answer bit-exactly shares the //! [`CANONICAL_PARTITION`]; a device that cannot is **not merged** into another platform's index — @@ -43,7 +43,7 @@ use uuid::Uuid; use crate::db::{DatabaseDriver, EmbeddingInsert, KnnHit, VectorIndexError}; use crate::ml::runner::{Embedding, Frame, ModelRunner, RunnerError}; use crate::ml::{ModelId, Registry, TaskKind}; -use crate::sidecar::sidecar_v1::AiTag; +use crate::sidecar::AiTag; /// The store the orchestrator reads asset pixels from and indexes vectors into. /// diff --git a/capsule-core/src/ml/registry.rs b/capsule-core/src/ml/registry.rs index 1d418812..d7852a7d 100644 --- a/capsule-core/src/ml/registry.rs +++ b/capsule-core/src/ml/registry.rs @@ -19,7 +19,7 @@ //! rows* (the v1-committed slots, enriched with the function and fallback the contract names) and //! the version-bump **swap primitive** ([`Registry::bump_version`]); the background per-asset //! **regeneration orchestration** that consumes the resulting staleness lives in -//! [`crate::ml::regen`], and the provenance gate the [vector index](crate::db::vector) calls is +//! [`crate::ml::regen`], and the provenance gate the [vector index](crate::db::EmbeddingInsert) calls is //! [`Registry::check_insert`]. //! //! [AI/ML — Models and Algorithms]: https://docs/design/ai/#models-and-algorithms diff --git a/capsule-core/src/models/album.rs b/capsule-core/src/models/album.rs deleted file mode 100644 index 4bbf9710..00000000 --- a/capsule-core/src/models/album.rs +++ /dev/null @@ -1,12 +0,0 @@ -pub enum AlbumAccess { - Owner, - Write, - Read, -} - -impl AlbumAccess { - /// Returns whether user has write access to album - pub fn is_write(&self) -> bool { - matches!(self, AlbumAccess::Owner | AlbumAccess::Write) - } -} diff --git a/capsule-core/src/models/asset.rs b/capsule-core/src/models/asset.rs deleted file mode 100644 index 79c3095c..00000000 --- a/capsule-core/src/models/asset.rs +++ /dev/null @@ -1,27 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Asset { - /// Asset ID - pub id: Uuid, - /// Album ID - pub album_id: Option, - /// Owner ID - pub owner_id: String, - /// File extension (e.g., "png", "mp4", "json") - /// Do NOT prepend with a dot (`.`) - /// String is case-sensitive - pub ext: String, -} - -impl Asset { - pub fn new(album_id: Option, owner_id: String, ext: String) -> Self { - Self { - id: Uuid::now_v7(), - album_id, - owner_id, - ext, - } - } -} diff --git a/capsule-core/src/models/mod.rs b/capsule-core/src/models/mod.rs deleted file mode 100644 index d050bcd1..00000000 --- a/capsule-core/src/models/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod album; -pub mod asset; diff --git a/capsule-core/src/notify/class.rs b/capsule-core/src/notify/class.rs new file mode 100644 index 00000000..ee15ae19 --- /dev/null +++ b/capsule-core/src/notify/class.rs @@ -0,0 +1,290 @@ +//! The closed alert-class enum, its severity, and the [`Alert`] record the predicate emits. +//! +//! SSoT: [Notifications — Alert Classes]. The class list is closed here; each class's *trigger +//! predicate and thresholds* stay owned by the doc that defines the condition, and are +//! implemented in [`super::evaluate()`] against those citations. +//! +//! [Notifications — Alert Classes]: https://docs/design/notifications/#alert-classes + +use std::collections::BTreeMap; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +/// Every alert Capsule can raise. **A closed enum**: an unknown wire value is a structural +/// error, like every other closed enum in the schema rules — which is what the `serde` derive +/// without `#[serde(other)]` gives. +/// +/// The variant order is the delivery order [`super::evaluate()`] emits in, and matches the +/// SSoT's own table so a reader can diff the two. +/// +/// `Ord` is derived because the suppression map ([`super::NotifyInput::suppressed`]) is keyed on +/// this type and must iterate deterministically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertClass { + /// Two weeks without a completed sync while changes remain un-synced. Trigger owner: + /// [Download & Sync — Notifications](https://docs/design/import/download-sync/#notifications). + SyncStale, + /// A recovery-secret verification check is due. Trigger owner: + /// [Backup — Schedule and Triggers](https://docs/design/backup-recovery/#schedule-and-triggers). + RecoveryCheckDue, + /// Storage crossed the soft limit and is below the hard limit. Trigger owner: + /// [Quota — Thresholds and States](https://docs/design/quota/#thresholds-and-states). + QuotaSoft, + /// Storage is over the hard limit — the grace window is counting, or has closed. Trigger + /// owner: [Quota — Thresholds and States](https://docs/design/quota/#thresholds-and-states). + QuotaGraceExpiring, + /// Items are sitting on a quarantine surface awaiting a human. Trigger owner: + /// [Threat Model — Quarantine Surfaces](https://docs/design/threat-model/scenarios/#quarantine-surfaces). + QuarantinePending, + /// Guest drops are awaiting review and adoption. Trigger owner: + /// [Web Upload — Drop and Adoption Lifecycle](https://docs/design/web-upload/#drop-and-adoption-lifecycle). + DropPending, +} + +impl AlertClass { + /// Every class, in delivery order. Iterating this rather than a hand-written list is what + /// makes "the enum is closed" a property a reader can check: adding a variant without + /// extending this array fails to compile, because the array's length is declared. + pub const ALL: [Self; 6] = [ + Self::SyncStale, + Self::RecoveryCheckDue, + Self::QuotaSoft, + Self::QuotaGraceExpiring, + Self::QuarantinePending, + Self::DropPending, + ]; + + /// The stable wire name — identical to the `serde` representation, so a log line, a + /// [`Alert::params`] value, and the serialized form never disagree. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::SyncStale => "sync_stale", + Self::RecoveryCheckDue => "recovery_check_due", + Self::QuotaSoft => "quota_soft", + Self::QuotaGraceExpiring => "quota_grace_expiring", + Self::QuarantinePending => "quarantine_pending", + Self::DropPending => "drop_pending", + } + } + + /// The inverse of [`as_str`](Self::as_str): parse a wire name, rejecting anything that is + /// not one of the six. + /// + /// The enum is closed, so an unrecognized name is `None` — a structural error for the + /// caller to report, never a class that is silently dropped. This exists so a boundary that + /// carries the name as a plain string (the uniffi FFI's suppression map, a persisted + /// client preference) has one table to parse against rather than its own copy. + #[must_use] + pub fn from_wire(name: &str) -> Option { + Self::ALL.into_iter().find(|class| class.as_str() == name) + } + + /// How loudly a client should present this class. A property of the class, not of the + /// instant it fired, so it lives here rather than being decided per-[`Alert`]. + #[must_use] + pub const fn severity(self) -> AlertSeverity { + match self { + // The library is silently falling out of date — the case the alert exists for. + Self::SyncStale + // Writes beyond the originals are now being refused. + | Self::QuotaGraceExpiring + // Items are neither applied nor dropped; they are waiting on a human. + | Self::QuarantinePending => AlertSeverity::Warning, + // Advisory: nothing is failing yet. + Self::RecoveryCheckDue | Self::QuotaSoft | Self::DropPending => AlertSeverity::Advisory, + } + } + + /// Whether this class's trigger is a deadline the device can compute alone, and can + /// therefore be pre-armed as a scheduled local notification. + /// + /// `false` for the three server-state classes (`quota_*`, `quarantine_pending`, + /// `drop_pending`): their condition lives on the server, so on a device that never runs + /// they cannot fire at all. That gap is real and v1 accepts it — those three surface at + /// next app launch. Closing it is what the post-v1 wake tier is for. + #[must_use] + pub const fn pre_armable(self) -> bool { + matches!(self, Self::SyncStale | Self::RecoveryCheckDue) + } +} + +/// How prominently a client presents an alert. +/// +/// Two variants deliberately, and **neither gates anything** — see +/// [`Alert::blocks_critical_flow`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AlertSeverity { + /// Informational: nothing is failing, and nothing is about to. + Advisory, + /// Something is already degraded or is being refused. + Warning, +} + +/// One alert that is true at an instant. +/// +/// Pure data: a class, its severity, the deadline that produced it (for the pre-armable classes), +/// and the parameters a client interpolates into its own catalog string. **Never a localized +/// string** — the copy is a `notification.*` catalog key the client owns. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Alert { + /// Which alert this is. + pub class: AlertClass, + /// [`AlertClass::severity`] for [`class`](Self::class), carried so a consumer that only + /// deserializes the record does not have to re-derive it. + pub severity: AlertSeverity, + /// The instant whose passing made this alert true, for the pre-armable classes; `None` for + /// the three whose condition is server-held and has no device-computable deadline. + pub deadline: Option, + /// Parameters for the client's catalog string — plain strings, deterministically ordered. + /// + /// The keys in use are `count` (`sync_stale`, `quarantine_pending`, `drop_pending`), + /// `days_behind` (`sync_stale`), `grace` (`quota_grace_expiring`), and `snooze_budget` + /// (`available` / `spent`) plus `recovery` (`check` / `rewrap`), both of which + /// `recovery_check_due` always carries. Each is documented at the predicate that sets it. + pub params: BTreeMap, +} + +impl Alert { + /// An alert of `class` with its severity filled in, no deadline, and no parameters. + pub(crate) fn new(class: AlertClass) -> Self { + Self { + class, + severity: class.severity(), + deadline: None, + params: BTreeMap::new(), + } + } + + /// Attach the deadline whose passing made this alert true. + pub(crate) fn with_deadline(mut self, deadline: Timestamp) -> Self { + self.deadline = Some(deadline); + self + } + + /// Attach one catalog parameter. + pub(crate) fn with_param(mut self, key: &str, value: impl Into) -> Self { + self.params.insert(key.to_owned(), value.into()); + self + } + + /// Alerts are advisory by construction: **no** alert blocks sync, unlock, upload, or any + /// critical flow. `const false` for every alert, so the "never blocks" rule is a + /// compile-time property of the type rather than a convention reviewers have to police — + /// the same trick + /// [`VerificationState::blocks_critical_flow`](https://docs/design/backup-recovery/) uses + /// for the recovery prompt. + #[must_use] + pub const fn blocks_critical_flow(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The enum is closed and its wire names are stable: a rename would break every client's + /// persisted suppression map and its catalog keys at once. + #[test] + fn wire_names_match_serde_and_are_stable() { + let expected = [ + "sync_stale", + "recovery_check_due", + "quota_soft", + "quota_grace_expiring", + "quarantine_pending", + "drop_pending", + ]; + for (class, name) in AlertClass::ALL.into_iter().zip(expected) { + assert_eq!(class.as_str(), name); + assert_eq!( + serde_json::to_string(&class).unwrap(), + format!("\"{name}\"") + ); + assert_eq!( + serde_json::from_str::(&format!("\"{name}\"")).unwrap(), + class + ); + } + } + + /// `from_wire` is exactly the inverse of `as_str`, and rejects everything else. + #[test] + fn from_wire_round_trips_and_rejects_the_rest() { + for class in AlertClass::ALL { + assert_eq!(AlertClass::from_wire(class.as_str()), Some(class)); + } + for name in [ + "", + "SyncStale", + "sync-stale", + "sync_stale ", + "telemetry_ready", + ] { + assert_eq!(AlertClass::from_wire(name), None, "{name:?}"); + } + } + + /// SSoT: unknown classes are rejected as structural errors. + #[test] + fn unknown_class_is_a_structural_error() { + assert!(serde_json::from_str::("\"telemetry_ready\"").is_err()); + assert!(serde_json::from_str::("\"critical\"").is_err()); + } + + /// Every class's severity is pinned, so changing one arm of the mapping fails here rather + /// than silently changing how loudly a shipped client presents an alert. The three + /// `Warning`s are the states where something is already degraded or being refused; the + /// three `Advisory`s are the states where nothing is failing yet. + #[test] + fn severity_is_pinned_for_every_class() { + let expected = [ + (AlertClass::SyncStale, AlertSeverity::Warning), + (AlertClass::RecoveryCheckDue, AlertSeverity::Advisory), + (AlertClass::QuotaSoft, AlertSeverity::Advisory), + (AlertClass::QuotaGraceExpiring, AlertSeverity::Warning), + (AlertClass::QuarantinePending, AlertSeverity::Warning), + (AlertClass::DropPending, AlertSeverity::Advisory), + ]; + // Zipping against ALL also pins the delivery order the table is written in. + for (class, (expected_class, severity)) in AlertClass::ALL.into_iter().zip(expected) { + assert_eq!(class, expected_class, "class order changed"); + assert_eq!(class.severity(), severity, "{}", class.as_str()); + } + assert_eq!(AlertClass::ALL.len(), expected.len()); + } + + /// SSoT: only the two device-computable deadlines are pre-armable. + #[test] + fn pre_armable_is_exactly_the_two_device_computable_classes() { + let armable: Vec<_> = AlertClass::ALL + .into_iter() + .filter(|c| c.pre_armable()) + .collect(); + assert_eq!( + armable, + vec![AlertClass::SyncStale, AlertClass::RecoveryCheckDue] + ); + } + + /// SSoT: "No alert ever blocks." Asserted for every class so a new variant cannot opt out. + #[test] + fn no_alert_blocks_a_critical_flow() { + for class in AlertClass::ALL { + assert!(!Alert::new(class).blocks_critical_flow()); + } + } + + /// The severity carried on the record is the class's own, never an independent field a + /// caller can desynchronize. + #[test] + fn severity_is_derived_from_the_class() { + for class in AlertClass::ALL { + assert_eq!(Alert::new(class).severity, class.severity()); + } + } +} diff --git a/capsule-core/src/notify/evaluate.rs b/capsule-core/src/notify/evaluate.rs new file mode 100644 index 00000000..265f703b --- /dev/null +++ b/capsule-core/src/notify/evaluate.rs @@ -0,0 +1,980 @@ +//! The trigger predicates: [`evaluate()`] (which classes are true now) and [`next_deadline()`] +//! (the one instant to arm an OS timer for). +//! +//! Each predicate cites the doc that owns its threshold. This module owns none of them — it owns +//! only the *composition*, which is the thing that must not be reimplemented per platform. +//! +//! # Boundary convention +//! +//! Every threshold in this module **fires at the boundary instant**: `now >= deadline`, never +//! `>`. That matches the recovery cadence's own `now >= next_due`, so a client that renders both +//! never sees them disagree by a second. Suppression is the mirror image and is exclusive +//! (`until > now`), so a class snoozed to exactly `now` is due at `now`. + +use std::collections::BTreeMap; + +use jiff::Timestamp; + +use super::class::{Alert, AlertClass}; +use super::input::{NotifyInput, QuotaAdvisory, RecoveryFacts, SyncFacts}; + +/// One day, in seconds — the unit the staleness threshold is expressed in. +pub const DAY_SECS: i64 = 86_400; + +/// The staleness threshold: **two weeks** without a completed sync while changes remain +/// un-synced. From [Download & Sync — Notifications], which owns the predicate; this module owns +/// only its evaluation. +/// +/// [Download & Sync — Notifications]: https://docs/design/import/download-sync/#notifications +pub const SYNC_STALE_SECS: i64 = 14 * DAY_SECS; + +/// Every alert that is true at `now`, in [`AlertClass::ALL`] order. +/// +/// Pure: `now` is an argument, nothing is read from the environment, and equal inputs give +/// equal outputs. A suppressed class is skipped before its predicate runs, so suppression can +/// never be observed as "fired but hidden". +/// +/// Absent facts emit nothing: `None` sync facts, `None` quota facts and zero counts are all +/// silence, not an error. +#[must_use] +pub fn evaluate(input: &NotifyInput, now: Timestamp) -> Vec { + let mut alerts = Vec::new(); + for class in AlertClass::ALL { + if input.is_suppressed(class, now) { + continue; + } + if let Some(alert) = evaluate_class(input, class, now) { + alerts.push(alert); + } + } + // The whole point of one shared decision function is that a field report says which + // classes a device decided were true, without the device having to explain itself. + tracing::debug!( + now = %now, + classes = ?alerts.iter().map(|a| a.class.as_str()).collect::>(), + "notify: evaluated the alert classes" + ); + alerts +} + +/// The instant an OS timer must be armed for, **per class** — the arm half of the +/// arm / re-arm / cancel rule. +/// +/// A class present in the map should have exactly one live timer, set to the returned instant. +/// A class *absent* from it should have no timer: cancel whatever it holds. Because the answer +/// is a pure function of state, the whole client-side protocol is "recompute after any state +/// change, then reconcile your timers against this map" — and one entry per class from one +/// function is why two live timers for one class is structurally impossible. +/// +/// It is keyed per class rather than collapsed to a single instant because the timers are +/// independent: with a staleness deadline two weeks out and a recovery check ninety days out, +/// arming only the earlier one loses the later alert entirely on a device the app never runs on +/// again — which is the exact case pre-arming exists for. A client also needs the class to pick +/// its `notification.*` catalog key for the notification it is arming. +/// +/// This is deliberately **narrower** than [`evaluate()`]. An armed notification fires from the +/// OS's own timer with the app not running, so it cannot be re-checked when it arrives: an +/// instant is returned only when the alert is certain to be true on arrival. Three things +/// therefore withhold one: +/// +/// - the class is not [pre-armable](AlertClass::pre_armable) — its condition is server-held, so +/// the device cannot compute a deadline for it at all; +/// - the resulting instant is not strictly after `now` (it has already passed, so there is +/// nothing left to schedule) — including a class the alert has already fired for; +/// - the class would arrive as something other than a notification: `sync_stale` with nothing +/// un-synced (only a sync can change that, and a sync re-arms), `recovery_check_due` with its +/// snooze budget spent (a badge, which is in-app), and any class the user has +/// [disabled](NotifyInput::disabled). +/// +/// A [snooze](NotifyInput::suppressed) **defers** the armed instant rather than cancelling it: a +/// class snoozed after it fired must re-fire when the snooze ends, and the snooze end is a +/// deadline the device can compute. +#[must_use] +pub fn pre_arm_deadlines(input: &NotifyInput, now: Timestamp) -> BTreeMap { + let mut armed = BTreeMap::new(); + for class in AlertClass::ALL { + if !class.pre_armable() { + continue; + } + if let Some(at) = pre_arm_deadline(input, class) + && at > now + { + armed.insert(class, at); + } + } + // A timer armed for the wrong instant, or cancelled when it should not have been, is + // otherwise invisible until an alert fails to arrive weeks later. + tracing::debug!( + now = %now, + armed = ?armed + .iter() + .map(|(class, at)| (class.as_str(), at.to_string())) + .collect::>(), + "notify: computed the pre-arm deadlines" + ); + armed +} + +/// The earliest instant in [`pre_arm_deadlines()`], or `None` when nothing is to be armed. +/// +/// A convenience for a caller that holds a single timer — a desktop scheduler, a CLI, a status +/// line. **A client that pre-arms per class wants [`pre_arm_deadlines()`]**: collapsing the map +/// to its minimum discards the later class's timer, which on a device the app never runs on +/// again loses that alert. +#[must_use] +pub fn next_deadline(input: &NotifyInput, now: Timestamp) -> Option { + pre_arm_deadlines(input, now).into_values().min() +} + +/// The predicate for one class. Separated per class rather than per fact so the emission order +/// is the enum's and the two quota classes stay independently suppressible. +fn evaluate_class(input: &NotifyInput, class: AlertClass, now: Timestamp) -> Option { + match class { + AlertClass::SyncStale => sync_stale(input.sync.as_ref()?, now), + AlertClass::RecoveryCheckDue => recovery_check_due(input.recovery.as_ref()?, now), + AlertClass::QuotaSoft => { + (input.quota?.state == QuotaAdvisory::SoftWarning).then(|| Alert::new(class)) + } + AlertClass::QuotaGraceExpiring => quota_grace_expiring(input.quota?.state), + AlertClass::QuarantinePending => counted(class, input.quarantine_pending), + AlertClass::DropPending => counted(class, input.drops_pending), + } +} + +/// A class that fires on any non-zero count and carries it as the `count` parameter. +/// +/// A quarantined item is never silently dropped and never silently applied, so any non-zero +/// count is reported. The two counts are **disjoint**: a pending drop is a quarantine surface in +/// the threat model's inventory, but it has its own class here, so +/// [`NotifyInput::quarantine_pending`] excludes drops and only +/// [`NotifyInput::drops_pending`] counts them. +fn counted(class: AlertClass, count: u64) -> Option { + (count > 0).then(|| Alert::new(class).with_param("count", count.to_string())) +} + +/// "After two weeks without a completed sync *while changes remain un-synced*." +/// +/// Both halves are load-bearing: with nothing un-synced the library is not behind, so a device +/// that simply has not needed to sync raises nothing. +fn sync_stale(facts: &SyncFacts, now: Timestamp) -> Option { + if facts.unsynced_changes == 0 { + return None; + } + let deadline = sync_stale_deadline(facts); + if now < deadline { + return None; + } + // Clock skew backwards is not a fire: `now < deadline` already returned above, so the + // subtraction here cannot go negative — but it saturates regardless rather than trusting it. + let days_behind = now + .as_second() + .saturating_sub(facts.last_completed_sync.as_second()) + / DAY_SECS; + Some( + Alert::new(AlertClass::SyncStale) + .with_deadline(deadline) + .with_param("count", facts.unsynced_changes.to_string()) + .with_param("days_behind", days_behind.to_string()), + ) +} + +/// When the staleness alert becomes true, given the sync epoch. +fn sync_stale_deadline(facts: &SyncFacts) -> Timestamp { + add_secs(facts.last_completed_sync, SYNC_STALE_SECS) +} + +/// The verification prompt is due, and no active snooze is holding it back. +/// +/// The cadence ladder and the snooze accounting stay with the scheduler; this consumes +/// `next_due` as given. `snooze_budget_spent` does not change *whether* the class is reported — +/// a client cannot render a badge for a condition it was not told about — only how, which is +/// delivery and therefore the client's. It is carried as the `snooze_budget` parameter. +/// +/// The reported `deadline` is the **later** of `next_due` and an expired `snoozed_until`, +/// because that is the instant whose passing actually made the alert true — and it is the same +/// instant [`pre_arm_deadline`] armed, so what a client scheduled and what it is handed on +/// arrival agree. +fn recovery_check_due(facts: &RecoveryFacts, now: Timestamp) -> Option { + if facts.snoozed_until.is_some_and(|until| until > now) { + return None; + } + if now < facts.next_due { + return None; + } + let became_true_at = facts + .snoozed_until + .map_or(facts.next_due, |until| until.max(facts.next_due)); + Some( + Alert::new(AlertClass::RecoveryCheckDue) + .with_deadline(became_true_at) + .with_param( + "snooze_budget", + if facts.snooze_budget_spent { + "spent" + } else { + "available" + }, + ) + // Without this the alert for "you told us you lost your recovery secret" is + // byte-identical to the routine ninety-day check, and a client rendering from the + // class alone would say "time for your periodic check" at the worst moment. + .with_param( + "recovery", + if facts.rewrap_due { "rewrap" } else { "check" }, + ), + ) +} + +/// "Entering the grace window raises `quota_grace_expiring`." +/// +/// `GraceExpired` raises the same class with `grace = "expired"` rather than going silent: it is +/// strictly additive to `HardExceeded` (metadata-growth writes are now refused too), so a device +/// that first polls after the window closed must still hear about it, and the class set is closed +/// — there is no `quota_grace_expired` to escalate into. +fn quota_grace_expiring(state: QuotaAdvisory) -> Option { + let grace = match state { + QuotaAdvisory::HardExceeded => "counting", + QuotaAdvisory::GraceExpired => "expired", + // `Ok`/`SoftWarning` are below the hard limit; `Suspended` is an admin or billing + // action owned by moderation, not a quota threshold. + QuotaAdvisory::Ok | QuotaAdvisory::SoftWarning | QuotaAdvisory::Suspended => return None, + }; + Some(Alert::new(AlertClass::QuotaGraceExpiring).with_param("grace", grace)) +} + +/// The instant to arm for one pre-armable class, if it has one that will certainly fire. +/// +/// Two composition rules, both of which exist because an armed notification cannot be +/// re-checked when it fires: +/// +/// - the class's own condition instant is the **later** of every gate on it — a recovery check +/// snoozed to a date *before* its due date is still not due until `next_due`, so arming the +/// snooze end alone would fire into no alert; +/// - a [snooze](NotifyInput::suppressed) **defers** that instant instead of cancelling it, so a +/// class snoozed after firing re-fires when the snooze ends. Only a +/// [disable](NotifyInput::disabled) cancels. +fn pre_arm_deadline(input: &NotifyInput, class: AlertClass) -> Option { + let condition_at = match class { + AlertClass::SyncStale => input + .sync + .as_ref() + .filter(|facts| facts.unsynced_changes > 0) + .map(sync_stale_deadline), + AlertClass::RecoveryCheckDue => input + .recovery + .as_ref() + .filter(|facts| !facts.snooze_budget_spent) + .map(|facts| { + facts + .snoozed_until + .map_or(facts.next_due, |until| until.max(facts.next_due)) + }), + // Not pre-armable; `pre_arm_deadlines` filters these out before asking. + AlertClass::QuotaSoft + | AlertClass::QuotaGraceExpiring + | AlertClass::QuarantinePending + | AlertClass::DropPending => None, + }?; + if input.disabled.contains(&class) { + // Disabled: nothing is ever armed again for this class. + return None; + } + // Snoozed: re-fire when the snooze ends, if that outlasts the condition itself. + Some(match input.suppressed.get(&class) { + Some(&until) => condition_at.max(until), + None => condition_at, + }) +} + +/// Add a signed second offset to a timestamp, saturating at the representable bounds. Nothing +/// here operates near them; saturation just keeps the arithmetic total, so a disabled class +/// pinned at [`Timestamp::MAX`] can never panic the predicate. +fn add_secs(base: Timestamp, secs: i64) -> Timestamp { + let target = base.as_second().saturating_add(secs); + Timestamp::from_second(target).unwrap_or(if target < 0 { + Timestamp::MIN + } else { + Timestamp::MAX + }) +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use super::super::class::AlertSeverity; + use super::super::input::QuotaFacts; + use super::*; + + /// A fixed, round base instant well away from the timestamp bounds. + const BASE: i64 = 1_700_000_000; + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).unwrap() + } + + /// The classes `evaluate` reported, in order. + fn classes(input: &NotifyInput, now: Timestamp) -> Vec { + evaluate(input, now).into_iter().map(|a| a.class).collect() + } + + /// The `params` of the single alert of `class`, or `None` if it did not fire. + fn params_of( + input: &NotifyInput, + class: AlertClass, + now: Timestamp, + ) -> Option> { + evaluate(input, now) + .into_iter() + .find(|a| a.class == class) + .map(|a| a.params) + } + + fn with_sync(last_completed_sync: i64, unsynced_changes: u64) -> NotifyInput { + NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: ts(last_completed_sync), + unsynced_changes, + }), + ..NotifyInput::default() + } + } + + fn with_recovery( + next_due: i64, + snoozed_until: Option, + snooze_budget_spent: bool, + ) -> NotifyInput { + NotifyInput { + recovery: Some(RecoveryFacts { + next_due: ts(next_due), + snoozed_until: snoozed_until.map(ts), + snooze_budget_spent, + rewrap_due: false, + }), + ..NotifyInput::default() + } + } + + fn with_quota(state: QuotaAdvisory) -> NotifyInput { + NotifyInput { + quota: Some(QuotaFacts { state }), + ..NotifyInput::default() + } + } + + // ── sync_stale ────────────────────────────────────────────────────────── + + /// SSoT: "After two weeks without a completed sync *while changes remain un-synced*." + /// Table-driven over the boundary instant and the un-synced half. + #[test] + fn sync_stale_fires_at_two_weeks_and_not_before() { + let due = BASE + SYNC_STALE_SECS; + let cases: &[(i64, u64, bool, &str)] = &[ + (due - 1, 1, false, "one second before the threshold"), + (due, 1, true, "exactly at the threshold"), + (due + DAY_SECS, 1, true, "a day past the threshold"), + (due, 0, false, "at the threshold with nothing un-synced"), + ( + due + 365 * DAY_SECS, + 0, + false, + "a year past, with nothing un-synced", + ), + (BASE, 5, false, "the instant the sync completed"), + (BASE - DAY_SECS, 5, false, "clock skewed behind the epoch"), + ]; + for &(now, unsynced, expected, why) in cases { + let input = with_sync(BASE, unsynced); + assert_eq!( + classes(&input, ts(now)).contains(&AlertClass::SyncStale), + expected, + "{why}" + ); + } + } + + /// A device that has never completed a sync raises nothing: the alert is about a *stale* + /// sync, not a missing one. + #[test] + fn sync_stale_needs_a_completed_sync_to_be_stale_from() { + let input = NotifyInput::default(); + assert!(evaluate(&input, ts(BASE + 10 * SYNC_STALE_SECS)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), None); + } + + /// The alert carries the deadline that produced it and the two catalog parameters. + #[test] + fn sync_stale_carries_deadline_and_params() { + let input = with_sync(BASE, 42); + let now = ts(BASE + SYNC_STALE_SECS + 3 * DAY_SECS); + let alert = evaluate(&input, now) + .into_iter() + .find(|a| a.class == AlertClass::SyncStale) + .expect("stale after 17 days with changes pending"); + + assert_eq!(alert.severity, AlertSeverity::Warning); + assert_eq!(alert.deadline, Some(ts(BASE + SYNC_STALE_SECS))); + assert_eq!(alert.params["count"], "42"); + assert_eq!(alert.params["days_behind"], "17"); + } + + // ── recovery_check_due ────────────────────────────────────────────────── + + /// Due at the boundary; an active snooze holds it back; a snooze that has expired does not. + #[test] + fn recovery_check_due_boundaries() { + let due = BASE + 7 * DAY_SECS; + let cases: &[(i64, Option, bool, &str)] = &[ + (due - 1, None, false, "one second before due"), + (due, None, true, "exactly at due"), + (due + 1, None, true, "one second after due"), + (due, Some(due + 1), false, "snoozed one second past now"), + (due, Some(due), true, "snooze expiring exactly at now"), + (due, Some(due - 1), true, "snooze already expired"), + (due - 1, Some(due + 1), false, "snoozed and not yet due"), + ]; + for &(now, snoozed, expected, why) in cases { + let input = with_recovery(due, snoozed, false); + assert_eq!( + classes(&input, ts(now)).contains(&AlertClass::RecoveryCheckDue), + expected, + "{why}" + ); + } + } + + /// The reported deadline is the instant that actually made the alert true — the expired + /// snooze when one outlasted the due date, and the due date otherwise. It is the same + /// instant `next_deadline` armed, so a client's timer and the alert it is handed agree. + #[test] + fn recovery_check_due_reports_the_instant_that_made_it_true() { + let due = BASE + 7 * DAY_SECS; + let until = due + 2 * DAY_SECS; + + // A snooze that outlasted the due date: it, not `next_due`, is what held the alert. + let input = with_recovery(due, Some(until), false); + assert_eq!( + next_deadline(&input, ts(due)), + Some(ts(until)), + "the snooze end is what gets armed" + ); + let alert = evaluate(&input, ts(until)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due once the snooze expires"); + assert_eq!(alert.deadline, Some(ts(until)), "and what gets reported"); + + // A snooze that expired before the due date leaves `next_due` as the true instant. + let early = with_recovery(due, Some(due - DAY_SECS), false); + let alert = evaluate(&early, ts(due)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due at the boundary"); + assert_eq!(alert.deadline, Some(ts(due))); + + // No snooze at all: `next_due`. + let none = with_recovery(due, None, false); + let alert = evaluate(&none, ts(due)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due at the boundary"); + assert_eq!(alert.deadline, Some(ts(due))); + } + + /// The two halves of the bounded-snooze rule, which live at different layers. + /// + /// "Past that bound the alert stops re-firing and degrades to a persistent, non-blocking + /// badge": the *stops re-firing* half is enforced here at the pre-arm layer — with the + /// budget spent, no timer is armed for the class, ever. The *badge* half needs the opposite: + /// `evaluate` keeps reporting the class, because a client cannot render a badge for a + /// condition it was never told about. Suppressing the class instead would satisfy the first + /// sentence by making the second impossible. + #[test] + fn a_spent_snooze_budget_stops_the_timer_but_not_the_report() { + let due = BASE + 7 * DAY_SECS; + for (now, why) in [ + (due - DAY_SECS, "before the due date"), + (due, "at the due date"), + (due + 30 * DAY_SECS, "long after it"), + ] { + let spent = with_recovery(due, None, true); + assert!( + pre_arm_deadlines(&spent, ts(now)).is_empty(), + "no timer is armed {why}" + ); + assert_eq!(next_deadline(&spent, ts(now)), None, "{why}"); + } + + // ...and the class is still reported once due, carrying the fact that makes it a badge. + let spent = with_recovery(due, None, true); + let params = params_of(&spent, AlertClass::RecoveryCheckDue, ts(due)) + .expect("a spent budget degrades the presentation, it does not silence the class"); + assert_eq!(params["snooze_budget"], "spent"); + + // A snooze still running with the budget spent arms nothing either: the class is + // already a badge, and a badge never escalates back into an alert on its own. + let snoozed_and_spent = with_recovery(due, Some(due + DAY_SECS), true); + assert!(pre_arm_deadlines(&snoozed_and_spent, ts(due)).is_empty()); + } + + /// A spent snooze budget is reported, not silenced — the client needs the fact to render a + /// badge — and it is carried as a parameter rather than a class of its own. + #[test] + fn recovery_check_due_reports_the_snooze_budget() { + let due = BASE + 7 * DAY_SECS; + for (spent, expected) in [(false, "available"), (true, "spent")] { + let input = with_recovery(due, None, spent); + let params = params_of(&input, AlertClass::RecoveryCheckDue, ts(due)) + .expect("due at the boundary regardless of the budget"); + assert_eq!(params["snooze_budget"], expected); + } + } + + // ── quota ─────────────────────────────────────────────────────────────── + + /// Each quota state maps to exactly the classes the SSoT's table names, and no more. + #[test] + fn quota_states_map_to_their_classes() { + let cases: &[(QuotaAdvisory, &[AlertClass], Option<&str>)] = &[ + (QuotaAdvisory::Ok, &[], None), + (QuotaAdvisory::SoftWarning, &[AlertClass::QuotaSoft], None), + ( + QuotaAdvisory::HardExceeded, + &[AlertClass::QuotaGraceExpiring], + Some("counting"), + ), + ( + QuotaAdvisory::GraceExpired, + &[AlertClass::QuotaGraceExpiring], + Some("expired"), + ), + (QuotaAdvisory::Suspended, &[], None), + ]; + for &(state, expected, grace) in cases { + let input = with_quota(state); + assert_eq!(classes(&input, ts(BASE)), expected, "{state:?}"); + if let Some(grace) = grace { + let params = params_of(&input, AlertClass::QuotaGraceExpiring, ts(BASE)) + .expect("the grace class fired"); + assert_eq!(params["grace"], grace, "{state:?}"); + } + } + } + + /// Quota alerts carry no deadline: the wire response has no `over_since`, so the device + /// cannot compute one, which is why the class is not pre-armable. + #[test] + fn quota_alerts_are_not_pre_armable() { + for state in [QuotaAdvisory::SoftWarning, QuotaAdvisory::GraceExpired] { + let input = with_quota(state); + for alert in evaluate(&input, ts(BASE)) { + assert_eq!(alert.deadline, None, "{state:?}"); + } + assert_eq!(next_deadline(&input, ts(BASE)), None, "{state:?}"); + } + } + + // ── counted classes ───────────────────────────────────────────────────── + + /// A quarantined item is never silently dropped and never silently applied: any non-zero + /// count is reported, with the count as a parameter. + #[test] + fn counted_classes_fire_on_any_non_zero_count() { + let cases: &[(u64, u64, &[AlertClass])] = &[ + (0, 0, &[]), + (1, 0, &[AlertClass::QuarantinePending]), + (0, 1, &[AlertClass::DropPending]), + ( + 3, + 7, + &[AlertClass::QuarantinePending, AlertClass::DropPending], + ), + ]; + for &(quarantine, drops, expected) in cases { + let input = NotifyInput { + quarantine_pending: quarantine, + drops_pending: drops, + ..NotifyInput::default() + }; + assert_eq!(classes(&input, ts(BASE)), expected); + } + + let input = NotifyInput { + quarantine_pending: 3, + drops_pending: 7, + ..NotifyInput::default() + }; + assert_eq!( + params_of(&input, AlertClass::QuarantinePending, ts(BASE)).unwrap()["count"], + "3" + ); + assert_eq!( + params_of(&input, AlertClass::DropPending, ts(BASE)).unwrap()["count"], + "7" + ); + } + + // ── suppression ───────────────────────────────────────────────────────── + + /// Suppression is per class, exclusive at the instant, and removes the class from both the + /// report and the arm decision. + #[test] + fn suppression_boundaries_apply_to_alerts_and_deadlines() { + let due = BASE + SYNC_STALE_SECS; + let cases: &[(i64, bool, &str)] = &[ + (due + 1, false, "suppressed past now"), + (due, true, "suppression expiring exactly at now"), + (due - 1, true, "suppression already expired"), + ]; + for &(until, expected, why) in cases { + let mut input = with_sync(BASE, 1); + input.suppressed.insert(AlertClass::SyncStale, ts(until)); + assert_eq!( + classes(&input, ts(due)).contains(&AlertClass::SyncStale), + expected, + "{why}" + ); + } + + // A disabled class contributes no deadline even while its own is still in the future. + let mut input = with_sync(BASE, 1); + input.disabled.insert(AlertClass::SyncStale); + assert_eq!(next_deadline(&input, ts(BASE)), None); + assert!(evaluate(&input, ts(due)).is_empty()); + } + + /// Disabling suppresses the warning, never the behavior — so the *other* classes are + /// untouched by one class's disable. + #[test] + fn suppression_does_not_leak_across_classes() { + let mut input = with_sync(BASE, 1); + input.drops_pending = 2; + input.disabled.insert(AlertClass::SyncStale); + assert_eq!( + classes(&input, ts(BASE + SYNC_STALE_SECS)), + [AlertClass::DropPending] + ); + } + + // ── next_deadline ─────────────────────────────────────────────────────── + + /// The earlier of the two pre-armable deadlines wins, and only future ones count. + #[test] + fn next_deadline_is_the_earliest_future_pre_armable_instant() { + let sync_due = BASE + SYNC_STALE_SECS; // BASE + 14 d + let recovery_due = BASE + 7 * DAY_SECS; // earlier + + let mut input = with_sync(BASE, 1); + input.recovery = Some(RecoveryFacts { + next_due: ts(recovery_due), + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }); + + // Both future → the earlier. + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(recovery_due))); + // The recovery deadline has passed → the staleness one. + assert_eq!( + next_deadline(&input, ts(recovery_due)), + Some(ts(sync_due)), + "a deadline exactly at now has already fired and is not re-armed" + ); + // Both passed → nothing to arm; the client cancels its timer. + assert_eq!(next_deadline(&input, ts(sync_due)), None); + assert_eq!(next_deadline(&input, ts(sync_due + DAY_SECS)), None); + } + + /// While snoozed, the armed instant is the snooze's end rather than the original due date. + #[test] + fn snooze_moves_the_armed_instant() { + let due = BASE + 7 * DAY_SECS; + let until = due + 2 * DAY_SECS; + let input = with_recovery(due, Some(until), false); + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(until))); + assert_eq!(next_deadline(&input, ts(due)), Some(ts(until))); + // Once the snooze has expired the class is due, so there is nothing left to arm. + assert_eq!(next_deadline(&input, ts(until)), None); + } + + /// An armed notification cannot be re-checked when it fires, so nothing is armed that would + /// arrive as a badge or as no alert at all. + #[test] + fn nothing_is_armed_that_would_arrive_empty() { + // Nothing un-synced: only a sync can change that, and a sync re-arms. + assert_eq!(next_deadline(&with_sync(BASE, 0), ts(BASE)), None); + // Snooze budget spent: the class has degraded to a badge, which is in-app, not a timer. + let due = BASE + 7 * DAY_SECS; + assert_eq!( + next_deadline(&with_recovery(due, None, true), ts(BASE)), + None + ); + // ...while the same facts with budget left do arm. + assert_eq!( + next_deadline(&with_recovery(due, None, false), ts(BASE)), + Some(ts(due)) + ); + } + + // ── whole-surface properties ──────────────────────────────────────────── + + /// A client that has learned nothing reports nothing and arms nothing. + #[test] + fn default_input_is_silent() { + let input = NotifyInput::default(); + assert!(evaluate(&input, ts(BASE)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), None); + } + + /// Emission order is the enum's, and every class can be true at once. + #[test] + fn every_class_can_fire_together_in_enum_order() { + let due = BASE + SYNC_STALE_SECS; + let input = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: ts(BASE), + unsynced_changes: 1, + }), + recovery: Some(RecoveryFacts { + next_due: ts(BASE), + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }), + // `SoftWarning` and `HardExceeded` are mutually exclusive states, so the two quota + // classes cannot both be true; this asserts the ordering of the five that can. + quota: Some(QuotaFacts { + state: QuotaAdvisory::HardExceeded, + }), + quarantine_pending: 1, + drops_pending: 1, + suppressed: BTreeMap::new(), + disabled: BTreeSet::new(), + }; + assert_eq!( + classes(&input, ts(due)), + [ + AlertClass::SyncStale, + AlertClass::RecoveryCheckDue, + AlertClass::QuotaGraceExpiring, + AlertClass::QuarantinePending, + AlertClass::DropPending, + ] + ); + } + + /// Determinism: equal input gives byte-equal output through `serde`, which is what + /// `BTreeMap` params and a fixed emission order buy. + #[test] + fn evaluation_is_deterministic_through_serde() { + let mut input = with_sync(BASE, 9); + input.quarantine_pending = 2; + input.drops_pending = 4; + input.disabled.insert(AlertClass::RecoveryCheckDue); + let now = ts(BASE + SYNC_STALE_SECS); + + let first = serde_json::to_string(&evaluate(&input, now)).unwrap(); + let second = serde_json::to_string(&evaluate(&input, now)).unwrap(); + assert_eq!(first, second); + + // And the input itself round-trips, so a persisted snapshot re-evaluates identically. + let round_tripped: NotifyInput = + serde_json::from_str(&serde_json::to_string(&input).unwrap()).unwrap(); + assert_eq!(round_tripped, input); + assert_eq!(evaluate(&round_tripped, now), evaluate(&input, now)); + } + + // ── per-class arming, deferral, and the new facts ─────────────────────── + + /// The two pre-armable classes get **independent** entries. Collapsing them to a single + /// minimum, as `next_deadline` does, discards the later timer — which on a device the app + /// never runs on again loses that alert entirely. + #[test] + fn pre_arm_deadlines_arms_each_class_independently() { + let sync_due = BASE + SYNC_STALE_SECS; // + 14 d + let recovery_due = BASE + 90 * DAY_SECS; // much later + let mut input = with_sync(BASE, 1); + input.recovery = Some(RecoveryFacts { + next_due: ts(recovery_due), + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }); + + let armed = pre_arm_deadlines(&input, ts(BASE)); + assert_eq!(armed.len(), 2); + assert_eq!(armed[&AlertClass::SyncStale], ts(sync_due)); + assert_eq!(armed[&AlertClass::RecoveryCheckDue], ts(recovery_due)); + // The single-timer convenience keeps only the earlier one, which is precisely why it is + // not what a per-class client should call. + assert_eq!(next_deadline(&input, ts(BASE)), Some(ts(sync_due))); + + // Past the staleness deadline the recovery timer is still armed and still later. + let armed = pre_arm_deadlines(&input, ts(sync_due)); + assert_eq!( + armed.keys().copied().collect::>(), + [AlertClass::RecoveryCheckDue] + ); + assert_eq!(armed[&AlertClass::RecoveryCheckDue], ts(recovery_due)); + } + + /// A class snoozed *after* it fired must fire again when the snooze ends. Cancelling + /// instead would leave `sync_stale` reachable only in-app, defeating the pre-arm rule for + /// the one class it exists for. + #[test] + fn a_finite_suppression_defers_the_timer_rather_than_cancelling_it() { + let due = BASE + SYNC_STALE_SECS; + let snooze_end = due + 3 * DAY_SECS; + let mut input = with_sync(BASE, 1); + input + .suppressed + .insert(AlertClass::SyncStale, ts(snooze_end)); + + // Snoozed: nothing is reported, but the timer moves to the snooze end. + assert!(evaluate(&input, ts(due + DAY_SECS)).is_empty()); + assert_eq!( + pre_arm_deadlines(&input, ts(due + DAY_SECS))[&AlertClass::SyncStale], + ts(snooze_end) + ); + // At the snooze end it is due again, and there is nothing left to arm. + assert!( + classes(&input, ts(snooze_end)).contains(&AlertClass::SyncStale), + "the snooze has expired, so the class is due" + ); + assert!(pre_arm_deadlines(&input, ts(snooze_end)).is_empty()); + + // A snooze that ends before the class's own deadline does not pull the timer earlier. + let mut early = with_sync(BASE, 1); + early + .suppressed + .insert(AlertClass::SyncStale, ts(due - DAY_SECS)); + assert_eq!( + pre_arm_deadlines(&early, ts(BASE))[&AlertClass::SyncStale], + ts(due) + ); + } + + /// A disable is the one suppression that cancels the timer: nothing is ever armed again. + #[test] + fn a_disabled_class_holds_no_timer_and_reports_nothing() { + let mut input = with_sync(BASE, 1); + input.disabled.insert(AlertClass::SyncStale); + assert!(pre_arm_deadlines(&input, ts(BASE)).is_empty()); + assert!(pre_arm_deadlines(&input, ts(BASE + SYNC_STALE_SECS)).is_empty()); + assert!(evaluate(&input, ts(BASE + SYNC_STALE_SECS)).is_empty()); + } + + /// A snooze set before the due date does not make the check due earlier, so it must not be + /// armed alone: the armed instant is the later of the two, or the timer fires into no alert. + #[test] + fn a_snooze_before_the_due_date_does_not_pull_the_recovery_timer_earlier() { + let due = BASE + 7 * DAY_SECS; + let snooze_end = BASE + DAY_SECS; // expires long before the check is due + let input = with_recovery(due, Some(snooze_end), false); + + assert_eq!( + pre_arm_deadlines(&input, ts(BASE))[&AlertClass::RecoveryCheckDue], + ts(due) + ); + assert!( + evaluate(&input, ts(snooze_end)).is_empty(), + "the snooze ended but the check is not due yet" + ); + assert!(classes(&input, ts(due)).contains(&AlertClass::RecoveryCheckDue)); + } + + /// The re-arm half of the rule: a completed sync moves the staleness timer, and a client + /// that recomputes sees a different value to cancel-and-arm against. + #[test] + fn a_completed_sync_re_arms_the_staleness_timer() { + let first = with_sync(BASE, 1); + let before = pre_arm_deadlines(&first, ts(BASE))[&AlertClass::SyncStale]; + assert_eq!(before, ts(BASE + SYNC_STALE_SECS)); + + // A sync completes a day later with changes still pending: the deadline moves by a day. + let second = with_sync(BASE + DAY_SECS, 1); + let after = pre_arm_deadlines(&second, ts(BASE + DAY_SECS))[&AlertClass::SyncStale]; + assert_eq!(after, ts(BASE + DAY_SECS + SYNC_STALE_SECS)); + assert_ne!(before, after, "the value moved, so the client re-arms"); + + // A sync that clears the backlog cancels it instead. + let cleared = with_sync(BASE + DAY_SECS, 0); + assert!(pre_arm_deadlines(&cleared, ts(BASE + DAY_SECS)).is_empty()); + } + + /// The escalation to the guided re-wrap is carried as a parameter, because the closed class + /// set has only `recovery_check_due` to report it and the routine check must not look the + /// same. + #[test] + fn the_rewrap_escalation_is_distinguishable_from_a_routine_check() { + let due = BASE + 7 * DAY_SECS; + let routine = with_recovery(due, None, false); + assert_eq!( + params_of(&routine, AlertClass::RecoveryCheckDue, ts(due)).unwrap()["recovery"], + "check" + ); + + let mut escalated = routine.clone(); + if let Some(facts) = escalated.recovery.as_mut() { + facts.rewrap_due = true; + } + assert_eq!( + params_of(&escalated, AlertClass::RecoveryCheckDue, ts(due)).unwrap()["recovery"], + "rewrap" + ); + } + + /// `days_behind` truncates toward the completed day, so the whole last day before the next + /// one reads the same. Asserted at both ends of that interval. + #[test] + fn days_behind_truncates_to_whole_days() { + for (offset, expected) in [ + (SYNC_STALE_SECS, "14"), + (SYNC_STALE_SECS + DAY_SECS - 1, "14"), + (SYNC_STALE_SECS + DAY_SECS, "15"), + ] { + let input = with_sync(BASE, 1); + let params = params_of(&input, AlertClass::SyncStale, ts(BASE + offset)) + .expect("stale with changes pending"); + assert_eq!(params["days_behind"], expected, "at +{offset}s"); + } + } + + /// `quota_soft` carries no parameters: there is nothing to interpolate that the client does + /// not already hold from its own quota response. + #[test] + fn quota_soft_carries_no_parameters() { + let input = with_quota(QuotaAdvisory::SoftWarning); + let params = params_of(&input, AlertClass::QuotaSoft, ts(BASE)).expect("soft warning"); + assert!(params.is_empty(), "{params:?}"); + } + + /// Saturating arithmetic: a sync epoch pinned at the far end of the range neither panics + /// nor wraps into the past. + #[test] + fn deadlines_saturate_at_the_representable_bounds() { + let input = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: Timestamp::MAX, + unsynced_changes: 1, + }), + ..NotifyInput::default() + }; + assert!(evaluate(&input, ts(BASE)).is_empty()); + assert_eq!(next_deadline(&input, ts(BASE)), Some(Timestamp::MAX)); + + let ancient = NotifyInput { + sync: Some(SyncFacts { + last_completed_sync: Timestamp::MIN, + unsynced_changes: 1, + }), + ..NotifyInput::default() + }; + assert!( + classes(&ancient, ts(BASE)).contains(&AlertClass::SyncStale), + "an epoch at the far past is long stale" + ); + assert_eq!(next_deadline(&ancient, ts(BASE)), None); + } +} diff --git a/capsule-core/src/notify/input.rs b/capsule-core/src/notify/input.rs new file mode 100644 index 00000000..6f954106 --- /dev/null +++ b/capsule-core/src/notify/input.rs @@ -0,0 +1,256 @@ +//! [`NotifyInput`] — the snapshot of device-held state the predicate is evaluated against. +//! +//! Every field is caller-supplied and `Option`/zero where the client has not learned it yet. +//! Nothing here reads a clock, a socket, or SQLite: this crate holds none of the trigger state +//! (see the [module docs](super) for why), so the only honest shape is a struct the client +//! fills. +//! +//! The security boundary is the shape itself: counts and instants only. No album id, no title, +//! no asset id, nothing a server could author. + +use std::collections::{BTreeMap, BTreeSet}; + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; + +use super::class::AlertClass; + +/// The state [`super::evaluate()`] and [`super::next_deadline()`] decide from. +/// +/// [`Default`] is the "a client that has just installed and learned nothing" input, and it +/// yields no alerts and no deadline. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct NotifyInput { + /// Sync progress, persisted by the client at the end of each successful sync. `None` on a + /// device that has never completed one — which raises no `sync_stale`, because the alert is + /// about a *stale* sync and not a missing one. + pub sync: Option, + /// The recovery-verification cadence, projected into flat facts. `None` before recovery is + /// set up. + pub recovery: Option, + /// The last quota response the client received. `None` before the first + /// `GET /v1/quota` — quota state is server-held, so it is only ever as current as that call. + pub quota: Option, + /// How many items sit on the client's quarantine surfaces awaiting a human. + /// + /// **Excludes pending drops.** A pending drop is a quarantine surface in the threat model's + /// own inventory, but it has its own alert class here, so a client that filled this field + /// from that inventory unfiltered would raise both classes for the same items. Count drops + /// in [`drops_pending`](Self::drops_pending) and nowhere else. + pub quarantine_pending: u64, + /// How many guest drops are awaiting review and adoption. + pub drops_pending: u64, + /// Per-class **snooze**: the instant a deferred class becomes due again. A class whose entry + /// is strictly after `now` emits nothing from [`super::evaluate()`]. + /// + /// This is how a client applies a snooze without this crate owning that state machine — the + /// bounded-snooze-then-badge mechanic has one owner already + /// ([`RecoveryCadence`](https://docs/design/backup-recovery/#recovery-verification-cadence)), + /// and a second copy here would be two owners of one mechanic. + /// + /// A snooze **defers** the class's pre-arm deadline rather than cancelling it + /// ([`super::pre_arm_deadlines()`]): a class snoozed after it fired must fire again when the + /// snooze ends, and that end is a deadline the device can compute. Cancelling instead would + /// leave the alert reachable only in-app, which for `sync_stale` defeats the entire pre-arm + /// rule the class exists under. + /// + /// **This map is for the other five classes.** Recovery snoozing is owned by + /// [`RecoveryFacts::snoozed_until`], because the cadence scheduler already tracks it against + /// a bounded budget and this map has no budget to spend. An entry here for + /// [`AlertClass::RecoveryCheckDue`] is honoured as a fallback — the armed instant becomes + /// the later of the two, and either suppresses the report — but it is not the canonical + /// channel, and a client that writes both is expressing one snooze twice. + pub suppressed: BTreeMap, + /// Per-class **disable**: the user turned this alert off. Emits nothing and arms nothing, at + /// any instant. + /// + /// A separate field from [`suppressed`](Self::suppressed) rather than a far-future instant in + /// it, because they are different mechanics with different effects on the timer — a snooze + /// defers, a disable cancels — and because a sentinel instant is exactly the kind of + /// convention that does not survive a string-typed FFI boundary: a client writing "the year + /// 2999" would mean *disabled* and get a timer armed 975 years out. + /// + /// **Disabling suppresses the warning, never the behavior.** Turning off `sync_stale` does + /// not turn off auto-sync; turning off `recovery_check_due` does not stop the recovery check + /// mattering. An alert is a report about a condition, never the mechanism managing it. + pub disabled: BTreeSet, +} + +impl NotifyInput { + /// Whether `class` is snoozed or disabled at `now`, and therefore reports nothing. + /// + /// A snooze entry exactly at `now` has expired: a snooze is `until`, exclusive, so a class + /// snoozed to `now` is due again at `now` — the same boundary convention as every other + /// threshold in this module. A disable never expires. + #[must_use] + pub fn is_suppressed(&self, class: AlertClass, now: Timestamp) -> bool { + self.disabled.contains(&class) + || self + .suppressed + .get(&class) + .is_some_and(|until| *until > now) + } +} + +/// What the client knows about its own sync progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyncFacts { + /// When the last **completed** sync finished. This is the epoch the two-week deadline is + /// measured from, and the instant at which a client re-arms the timer. + pub last_completed_sync: Timestamp, + /// Changes still waiting to reach the server — including originals still pending under a + /// staged upload policy. Zero means nothing is behind, so nothing is stale. + pub unsynced_changes: u64, +} + +/// The recovery-verification cadence, flattened to the facts the predicate needs. +/// +/// The 7 d → 90 d → 180 d ladder, its re-arm triggers, and its snooze accounting stay owned by +/// the cadence scheduler; this module consumes the already-computed `next_due` and never +/// recomputes the ladder. `capsule-sdk` depends on this crate and not the reverse, so the +/// scheduler cannot be named here — the SDK projects into this struct instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecoveryFacts { + /// When the next verification prompt becomes due. + pub next_due: Timestamp, + /// When an active snooze expires, if one is active. + /// + /// **This is where recovery snoozing lives**, rather than + /// [`NotifyInput::suppressed`](super::NotifyInput::suppressed): the cadence scheduler owns + /// the bounded-snooze-then-badge budget, so the snooze and the budget that bounds it stay + /// in one place. The generic map is for the other five classes. + /// + /// A snooze set *before* the due date does not make the check due earlier: the class is due + /// at the later of this and [`next_due`](Self::next_due). + pub snoozed_until: Option, + /// Whether the consecutive-snooze budget is spent. When it is, the class has degraded to a + /// persistent, non-blocking badge: it is still reported (a client cannot render a badge for + /// a condition it was not told about), but it is no longer pre-armed as a notification — + /// the badge never escalates back into an alert on its own. + pub snooze_budget_spent: bool, + /// Whether the scheduler has escalated to the guided re-wrap — repeated verification + /// failures, or the user explicitly declaring the secret lost. + /// + /// Carried because the alert class set is closed and `recovery_check_due` is the only class + /// that can report it: without this fact the alert for "you told us you lost your recovery + /// secret" would be indistinguishable from the routine ninety-day check. It surfaces as the + /// `recovery` parameter (`"rewrap"` / `"check"`) so a client routes into the guided re-wrap + /// instead of a verification prompt. `#[serde(default)]` so a snapshot persisted before this + /// field existed still loads. + #[serde(default)] + pub rewrap_due: bool, +} + +/// The last quota answer the client holds. +/// +/// A one-field struct on purpose: the wire response carries no `over_since` and no grace +/// deadline, which is exactly why the quota classes are not pre-armable. When it grows one, the +/// deadline field lands here without changing [`NotifyInput`]'s shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct QuotaFacts { + /// The state the server reported. + pub state: QuotaAdvisory, +} + +/// The quota state, as `GET /v1/quota` reports it. +/// +/// Mirrors the SSoT's state table one-for-one rather than collapsing it, so a client can hand +/// the server's answer straight through. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaAdvisory { + /// `used < soft_limit`. All uploads succeed normally. + Ok, + /// `soft_limit <= used < hard_limit`. Uploads succeed; the client warns. + SoftWarning, + /// `used >= hard_limit`. New uploads are rejected at session creation; the grace window is + /// counting. + HardExceeded, + /// Over the hard limit for longer than the grace window. Additive to + /// [`HardExceeded`](Self::HardExceeded): metadata-growth writes are refused too. + GraceExpired, + /// An admin or billing action, not a threshold. Server-defined and owned by moderation, so + /// it raises no quota alert class here. + Suspended, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(secs: i64) -> Timestamp { + Timestamp::from_second(secs).unwrap() + } + + /// A blank client has learned nothing, and nothing is suppressed. + #[test] + fn default_input_suppresses_nothing() { + let input = NotifyInput::default(); + assert_eq!(input.sync, None); + assert_eq!(input.recovery, None); + assert_eq!(input.quota, None); + assert_eq!(input.quarantine_pending, 0); + assert_eq!(input.drops_pending, 0); + assert!(input.disabled.is_empty()); + for class in AlertClass::ALL { + assert!(!input.is_suppressed(class, ts(0))); + } + } + + /// Suppression is `until`, exclusive: before / at / after the instant. + #[test] + fn suppression_boundary_is_exclusive() { + let mut input = NotifyInput::default(); + input.suppressed.insert(AlertClass::SyncStale, ts(1_000)); + + assert!(input.is_suppressed(AlertClass::SyncStale, ts(999))); + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(1_000))); + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(1_001))); + // Suppression is per class and never leaks to a neighbour. + assert!(!input.is_suppressed(AlertClass::RecoveryCheckDue, ts(999))); + } + + /// A disabled class is suppressed at every instant, and only that class. + #[test] + fn disabled_is_suppressed_forever() { + let mut input = NotifyInput::default(); + input.disabled.insert(AlertClass::DropPending); + for at in [i64::from(i32::MIN), 0, 1_700_000_000, i64::from(i32::MAX)] { + assert!(input.is_suppressed(AlertClass::DropPending, ts(at)), "{at}"); + } + assert!(!input.is_suppressed(AlertClass::SyncStale, ts(0))); + } + + /// `#[serde(default)]` means a client may send only the fields it has. + #[test] + fn partial_json_deserializes_to_defaults() { + let input: NotifyInput = serde_json::from_str(r#"{"drops_pending":3}"#).unwrap(); + assert_eq!(input.drops_pending, 3); + assert_eq!( + input, + NotifyInput { + drops_pending: 3, + ..NotifyInput::default() + } + ); + } + + /// The quota states are a closed enum with stable wire names. + #[test] + fn quota_advisory_wire_names() { + for (state, name) in [ + (QuotaAdvisory::Ok, "ok"), + (QuotaAdvisory::SoftWarning, "soft_warning"), + (QuotaAdvisory::HardExceeded, "hard_exceeded"), + (QuotaAdvisory::GraceExpired, "grace_expired"), + (QuotaAdvisory::Suspended, "suspended"), + ] { + assert_eq!( + serde_json::to_string(&state).unwrap(), + format!("\"{name}\"") + ); + } + assert!(serde_json::from_str::("\"over\"").is_err()); + } +} diff --git a/capsule-core/src/notify/mod.rs b/capsule-core/src/notify/mod.rs new file mode 100644 index 00000000..3ef0199f --- /dev/null +++ b/capsule-core/src/notify/mod.rs @@ -0,0 +1,72 @@ +//! Alert classes and their trigger predicates — the **one shared decision function** every +//! platform evaluates instead of reimplementing the taxonomy (slice `S-D29`, core half; SSoT: +//! [Notifications]). +//! +//! # The surface +//! +//! [`evaluate()`] turns a snapshot of device-held state ([`NotifyInput`]) into the [`Alert`]s +//! that are true at an instant. [`pre_arm_deadlines()`] returns the instant an OS timer must be +//! armed for, **per class** — a class absent from the map has no timer to hold. +//! [`next_deadline()`] is its minimum, for a caller that holds only one timer. +//! +//! Both are **pure**: no clock read, no socket, no SQLite, no `unsafe`, and no allocation beyond +//! the returned vector. `now` is always an argument, so the whole surface is driven by a mocked +//! clock in tests with no sleeps and no I/O — the same discipline as the recovery cadence whose +//! projection it consumes ([`RecoveryFacts`]). +//! +//! # Why every input is caller-supplied +//! +//! Nothing in this crate holds the trigger state. There is no `last_completed_sync` column, no +//! client-side quota type (server-held, and only as current as the last `GET /v1/quota`), and no +//! persisted quarantine table — a refused sync entry is a per-entry verdict +//! ([`crate::lifecycle::SyncApplyOutcome`]), not a row. Pending drops live in the provisioning +//! user's server-side inbox. So the predicate cannot read its own inputs; it takes them, which +//! is also what keeps it pure. +//! +//! [`NotifyInput`] therefore carries **counts and instants only** — no album ids, no titles, no +//! asset ids, nothing a server could author. That is forced rather than chosen: alert text is +//! composed on-device from decrypted state, and a key-free server has no plaintext to compose +//! from. +//! +//! # Delivery is not here +//! +//! This module decides *which classes are true* and *when to arm*. Presentation — a scheduled +//! `UNCalendarNotificationTrigger`, a `NotificationManagerCompat` post, an in-app badge — is +//! native per client, as is the permission prompt (asked the first time a class has something to +//! say, never at launch). The `notification.*` catalog keys land with the implementing client +//! slice, because the i18n guard requires a live consumer; this module emits a class and its +//! parameters, never a string a user reads. +//! +//! # Pre-arming, and what it costs +//! +//! An alert whose trigger is a deadline the device can compute MUST be pre-armed at the moment +//! that deadline becomes known, not evaluated when it expires — otherwise the staleness alert is +//! starved by the very absence of background windows it exists to report. +//! +//! The consequence for this module is the reason [`pre_arm_deadlines()`] is narrower than +//! [`evaluate()`]: an armed OS notification fires **without the app running**, so it cannot be +//! re-checked at fire time. An instant is therefore only returned when the alert is certain to be +//! true when it arrives — see [`AlertClass::pre_armable`] for which classes can be armed at all, +//! and [`pre_arm_deadlines()`] for the three conditions that withhold one from a pre-armable +//! class. +//! +//! Because the answer is a pure function of state, "re-arm on every state change that moves the +//! deadline" reduces on the client to: recompute after any state change, then reconcile the +//! timers against the map. One entry per class from one function is also why two live timers for +//! one class is structurally impossible. +//! +//! # Determinism +//! +//! [`Alert::params`] is a [`BTreeMap`](std::collections::BTreeMap), and [`evaluate()`] emits in +//! [`AlertClass::ALL`] order. Two calls on equal input are equal, byte-for-byte, through +//! `serde`. +//! +//! [Notifications]: https://docs/design/notifications/ + +pub(crate) mod class; +pub(crate) mod evaluate; +pub(crate) mod input; + +pub use class::{Alert, AlertClass, AlertSeverity}; +pub use evaluate::{DAY_SECS, SYNC_STALE_SECS, evaluate, next_deadline, pre_arm_deadlines}; +pub use input::{NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, SyncFacts}; diff --git a/capsule-core/src/sharing/mod.rs b/capsule-core/src/sharing/mod.rs index 1542eab4..b5b0e67e 100644 --- a/capsule-core/src/sharing/mod.rs +++ b/capsule-core/src/sharing/mod.rs @@ -7,7 +7,7 @@ //! wraps it a second time via the password-based KDF, unwrapped **client-side** (the //! server stores and returns only the wrapped material). The serving endpoints live in //! the server's share module; this module owns link generation, the encapsulation -//! crypto, and the recipient-side [`open_scope`] path. +//! crypto, and the recipient-side [`open_scope`](crate::sharing::open_scope) path. //! //! ## Cryptographic shape //! @@ -15,19 +15,21 @@ //! decryption keys around the secret stored in the link ... the password-based KDF adds a //! second encapsulation layer on top of the link secret."* Concretely, per link: //! -//! 1. A fresh random **link secret** (`fragment_secret`, [`LINK_SECRET_LEN`] bytes) is -//! drawn from the CSPRNG. It is the URL fragment `#{secret}` and never reaches the -//! server. -//! 2. The scope's [`ScopeMaterial`] (a single file key, or an album's AMK ledger) is -//! serialized to canonical CBOR and sealed under `HKDF(link_secret, salt=opaque_id)` -//! — the *link-secret encapsulation*. The sealed bytes are opaque to the server. +//! 1. A fresh random **link secret** (`fragment_secret`, +//! [`LINK_SECRET_LEN`](crate::sharing::LINK_SECRET_LEN) bytes) is drawn from the CSPRNG. It +//! is the URL fragment `#{secret}` and never reaches the server. +//! 2. The scope's [`ScopeMaterial`](crate::sharing::ScopeMaterial) (a single file key, or an +//! album's AMK ledger) is serialized to canonical CBOR and sealed under +//! `HKDF(link_secret, salt=opaque_id)` — the *link-secret encapsulation*. The sealed bytes +//! are opaque to the server. //! 3. **If** a passphrase is supplied, that sealed blob is wrapped a **second** time under //! an [Argon2id][pw] key ([`crate::crypto::pwkdf`]) — the passphrase never leaves the //! client, and the server stores only this wrapped form (served from -//! `/s/{opaque-id}/wrapped-secret`). See [`WrappedScope`]. +//! `/s/{opaque-id}/wrapped-secret`). See [`WrappedScope`](crate::sharing::WrappedScope). //! -//! The recipient reverses the layers with [`open_scope`]: Argon2id-unwrap (client-side, -//! iff passphrase-protected), then link-secret-unwrap with the fragment secret. +//! The recipient reverses the layers with [`open_scope`](crate::sharing::open_scope): +//! Argon2id-unwrap (client-side, iff passphrase-protected), then link-secret-unwrap with the +//! fragment secret. //! //! [Share Links]: https://docs/design/share-links/ //! [Cryptography — Keys § Non-registered accounts]: https://docs/design/cryptography/keys/#non-registered-accounts diff --git a/capsule-core/src/sidecar/asset_sidecar.rs b/capsule-core/src/sidecar/asset_sidecar.rs deleted file mode 100644 index 4d306091..00000000 --- a/capsule-core/src/sidecar/asset_sidecar.rs +++ /dev/null @@ -1,352 +0,0 @@ -use std::collections::BTreeMap; - -use ciborium::value::Value; -use serde::{Deserialize, Serialize}; - -use crate::domain::{CaptureTzSource, ImportMode}; -use crate::metadata::AssetType; -use crate::sidecar::StackHint; - -/// CBOR sidecar for a media asset. Unknown fields are preserved verbatim -/// for forward compatibility (Postel's Law). -#[derive(Debug, Clone, PartialEq)] -pub struct AssetSidecar { - // Required fields - pub version: u8, - pub uuid: String, - pub asset_type: AssetType, - pub original_filename: String, - pub import_timestamp: i64, - pub modified_timestamp: i64, - pub hash_sha256: String, - pub file_size: u64, - pub is_deleted: bool, - pub rating: u8, - pub tags: Vec, - pub import_mode: ImportMode, - pub importer_version: String, - pub rawshift_version: String, - - // Optional fields - pub capture_timestamp: Option, - pub capture_utc: Option, - pub capture_tz: Option, - pub capture_tz_source: Option, - pub tz_db_version: Option, - pub width: Option, - pub height: Option, - pub duration_ms: Option, - pub stack_hint: Option, - pub album_id: Option, - pub deleted_at: Option, - pub camera_make: Option, - pub camera_model: Option, - pub gps_lat: Option, - pub gps_lon: Option, - - /// Unknown fields preserved for forward compatibility. - pub unknown_fields: BTreeMap, -} - -/// Re-encode a `Value` into CBOR bytes and deserialize as `T`. -fn value_to Deserialize<'de>>(v: Value) -> Result { - let mut buf = vec![]; - ciborium::ser::into_writer(&v, &mut buf).map_err(|e| e.to_string())?; - ciborium::de::from_reader(buf.as_slice()).map_err(|e| e.to_string()) -} - -/// Serialize `T` to CBOR bytes and deserialize as a `Value`. -fn to_value(v: &T) -> Result { - let mut buf = vec![]; - ciborium::ser::into_writer(v, &mut buf).map_err(|e| e.to_string())?; - ciborium::de::from_reader(buf.as_slice()).map_err(|e| e.to_string()) -} - -impl Serialize for AssetSidecar { - fn serialize(&self, serializer: S) -> Result { - use serde::ser::Error; - - let mut map: Vec<(Value, Value)> = Vec::new(); - - macro_rules! insert { - ($key:expr, $val:expr) => {{ - let v = to_value(&$val).map_err(S::Error::custom)?; - map.push((Value::Text($key.to_string()), v)); - }}; - } - macro_rules! insert_opt { - ($key:expr, $val:expr) => {{ - if let Some(ref inner) = $val { - let v = to_value(inner).map_err(S::Error::custom)?; - map.push((Value::Text($key.to_string()), v)); - } - }}; - } - - insert!("version", self.version); - insert!("uuid", self.uuid); - insert!("asset_type", self.asset_type); - insert!("original_filename", self.original_filename); - insert!("import_timestamp", self.import_timestamp); - insert!("modified_timestamp", self.modified_timestamp); - insert!("hash_sha256", self.hash_sha256); - insert!("file_size", self.file_size); - insert!("is_deleted", self.is_deleted); - insert!("rating", self.rating); - insert!("tags", self.tags); - insert!("import_mode", self.import_mode); - insert!("importer_version", self.importer_version); - insert!("rawshift_version", self.rawshift_version); - insert_opt!("capture_timestamp", self.capture_timestamp); - insert_opt!("capture_utc", self.capture_utc); - insert_opt!("capture_tz", self.capture_tz); - insert_opt!("capture_tz_source", self.capture_tz_source); - insert_opt!("tz_db_version", self.tz_db_version); - insert_opt!("width", self.width); - insert_opt!("height", self.height); - insert_opt!("duration_ms", self.duration_ms); - insert_opt!("stack_hint", self.stack_hint); - insert_opt!("album_id", self.album_id); - insert_opt!("deleted_at", self.deleted_at); - insert_opt!("camera_make", self.camera_make); - insert_opt!("camera_model", self.camera_model); - insert_opt!("gps_lat", self.gps_lat); - insert_opt!("gps_lon", self.gps_lon); - - // Merge unknown fields last so they are preserved verbatim. - for (k, v) in &self.unknown_fields { - map.push((Value::Text(k.clone()), v.clone())); - } - - Value::Map(map).serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for AssetSidecar { - fn deserialize>(deserializer: D) -> Result { - use serde::de::Error; - - let value = Value::deserialize(deserializer)?; - let Value::Map(raw_map) = value else { - return Err(D::Error::custom("expected CBOR map for AssetSidecar")); - }; - - // Collect into a BTreeMap keyed by string for easy lookup. - let mut fields: BTreeMap = BTreeMap::new(); - for (k, v) in raw_map { - if let Value::Text(key) = k { - fields.insert(key, v); - } - // Non-text keys are silently dropped (not expected in our format). - } - - macro_rules! req { - ($key:expr, $t:ty) => {{ - let val = fields - .remove($key) - .ok_or_else(|| D::Error::custom(format!("missing required field: {}", $key)))?; - value_to::<$t>(val).map_err(D::Error::custom)? - }}; - } - macro_rules! opt { - ($key:expr, $t:ty) => {{ - match fields.remove($key) { - None | Some(Value::Null) => None, - Some(v) => Some(value_to::<$t>(v).map_err(D::Error::custom)?), - } - }}; - } - - let version = req!("version", u8); - let uuid = req!("uuid", String); - let asset_type = req!("asset_type", AssetType); - let original_filename = req!("original_filename", String); - let import_timestamp = req!("import_timestamp", i64); - let modified_timestamp = req!("modified_timestamp", i64); - let hash_sha256 = req!("hash_sha256", String); - let file_size = req!("file_size", u64); - let is_deleted = req!("is_deleted", bool); - let rating = req!("rating", u8); - let tags = req!("tags", Vec); - let import_mode = req!("import_mode", ImportMode); - let importer_version = req!("importer_version", String); - let rawshift_version = req!("rawshift_version", String); - - let capture_timestamp = opt!("capture_timestamp", i64); - let capture_utc = opt!("capture_utc", i64); - let capture_tz = opt!("capture_tz", String); - let capture_tz_source = opt!("capture_tz_source", CaptureTzSource); - let tz_db_version = opt!("tz_db_version", String); - let width = opt!("width", u32); - let height = opt!("height", u32); - let duration_ms = opt!("duration_ms", u64); - let stack_hint = opt!("stack_hint", StackHint); - let album_id = opt!("album_id", String); - let deleted_at = opt!("deleted_at", i64); - let camera_make = opt!("camera_make", String); - let camera_model = opt!("camera_model", String); - let gps_lat = opt!("gps_lat", f64); - let gps_lon = opt!("gps_lon", f64); - - // Any remaining fields are unknown — preserve them. - let unknown_fields = fields; - - Ok(AssetSidecar { - version, - uuid, - asset_type, - original_filename, - import_timestamp, - modified_timestamp, - hash_sha256, - file_size, - is_deleted, - rating, - tags, - import_mode, - importer_version, - rawshift_version, - capture_timestamp, - capture_utc, - capture_tz, - capture_tz_source, - tz_db_version, - width, - height, - duration_ms, - stack_hint, - album_id, - deleted_at, - camera_make, - camera_model, - gps_lat, - gps_lon, - unknown_fields, - }) - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::*; - use crate::domain::{CaptureTzSource, DetectionMethod, ImportMode, MemberRole, StackType}; - use crate::metadata::AssetType; - use crate::sidecar::StackHint; - - fn minimal_sidecar() -> AssetSidecar { - AssetSidecar { - version: 1, - uuid: "01956ef3-0000-7000-8000-000000000001".to_string(), - asset_type: AssetType::Photo, - original_filename: "IMG_1234.jpg".to_string(), - import_timestamp: 1720000000, - modified_timestamp: 1720000000, - hash_sha256: "a".repeat(64), - file_size: 1024 * 1024, - is_deleted: false, - rating: 0, - tags: vec![], - import_mode: ImportMode::Copy, - importer_version: "0.1.0".to_string(), - rawshift_version: "0.1.0".to_string(), - capture_timestamp: None, - capture_utc: None, - capture_tz: None, - capture_tz_source: None, - tz_db_version: None, - width: None, - height: None, - duration_ms: None, - stack_hint: None, - album_id: None, - deleted_at: None, - camera_make: None, - camera_model: None, - gps_lat: None, - gps_lon: None, - unknown_fields: BTreeMap::new(), - } - } - - fn cbor_roundtrip(s: &AssetSidecar) -> AssetSidecar { - let mut buf = vec![]; - ciborium::ser::into_writer(s, &mut buf).unwrap(); - ciborium::de::from_reader(buf.as_slice()).unwrap() - } - - #[test] - fn test_minimal_roundtrip() { - let s = minimal_sidecar(); - assert_eq!(s, cbor_roundtrip(&s)); - } - - #[test] - fn test_full_roundtrip() { - let mut s = minimal_sidecar(); - s.capture_timestamp = Some(1719990000); - s.capture_utc = Some(1719986400); - s.capture_tz = Some("America/New_York".to_string()); - s.capture_tz_source = Some(CaptureTzSource::GpsLookup); - s.tz_db_version = Some("2024b".to_string()); - s.width = Some(4032); - s.height = Some(3024); - s.camera_make = Some("Apple".to_string()); - s.camera_model = Some("iPhone 15 Pro".to_string()); - s.gps_lat = Some(40.7128); - s.gps_lon = Some(-74.0060); - s.tags = vec!["vacation".to_string(), "2024".to_string()]; - s.rating = 4; - s.stack_hint = Some(StackHint { - detection_key: "img_1234".to_string(), - detection_method: DetectionMethod::FilenameStem, - member_role: MemberRole::Primary, - stack_type: StackType::RawJpeg, - }); - assert_eq!(s, cbor_roundtrip(&s)); - } - - #[test] - fn test_unknown_field_preservation() { - let s = minimal_sidecar(); - let mut buf = vec![]; - ciborium::ser::into_writer(&s, &mut buf).unwrap(); - - // Deserialize to a raw Value, inject an unknown field, re-serialize. - let mut val: Value = ciborium::de::from_reader(buf.as_slice()).unwrap(); - if let Value::Map(ref mut entries) = val { - entries.push(( - Value::Text("future_field".to_string()), - Value::Text("future_value".to_string()), - )); - } - let mut buf2 = vec![]; - ciborium::ser::into_writer(&val, &mut buf2).unwrap(); - - // Deserialize as AssetSidecar — unknown field must be preserved. - let decoded: AssetSidecar = ciborium::de::from_reader(buf2.as_slice()).unwrap(); - assert_eq!( - decoded.unknown_fields.get("future_field"), - Some(&Value::Text("future_value".to_string())) - ); - - // Re-serialize and re-deserialize — unknown field must survive a second round-trip. - let mut buf3 = vec![]; - ciborium::ser::into_writer(&decoded, &mut buf3).unwrap(); - let decoded2: AssetSidecar = ciborium::de::from_reader(buf3.as_slice()).unwrap(); - assert_eq!( - decoded2.unknown_fields.get("future_field"), - Some(&Value::Text("future_value".to_string())) - ); - } - - #[test] - fn test_large_file_size() { - // Verify u64 round-trips correctly for large values (> i64::MAX would fail, but - // realistic file sizes well within u64 range should work). - let mut s = minimal_sidecar(); - s.file_size = 10 * 1024 * 1024 * 1024; // 10 GiB - assert_eq!(s, cbor_roundtrip(&s)); - } -} diff --git a/capsule-core/src/sidecar/io.rs b/capsule-core/src/sidecar/io.rs index 3bcb9f10..f3505788 100644 --- a/capsule-core/src/sidecar/io.rs +++ b/capsule-core/src/sidecar/io.rs @@ -2,42 +2,9 @@ use std::fs::{self, OpenOptions}; use std::io::{BufReader, BufWriter}; use std::path::Path; -use crate::sidecar::{AssetSidecar, LibraryConfigCbor, LibraryVersionCbor}; +use crate::sidecar::{LibraryConfigCbor, LibraryVersionCbor}; use crate::utils::paths::tmp_path; -pub fn read_sidecar(path: &Path) -> Result> { - let file = fs::File::open(path)?; - let sidecar = ciborium::de::from_reader(BufReader::new(file))?; - Ok(sidecar) -} - -/// Write an unsigned [`AssetSidecar`] to disk (atomic tmp-then-rename). -/// -/// The legacy unsigned sidecar **write path is retired** (`S-G4`): no production code writes -/// unsigned sidecars anymore — imports land on the signed [`SidecarV1`](crate::sidecar::SidecarV1) -/// path via [`Workspace::import_asset_with`](crate::lifecycle::Workspace::import_asset_with) -/// (`S-B2`), and soft-delete/retention ride the signed lifecycle. This writer survives only to -/// build on-disk fixtures for the retained *read* path — the recovery-first index rebuild -/// ([`rebuild_index`](crate::library::rebuild::rebuild_index)) that still ingests unsigned -/// `.cbor` sidecars from pre-signed-path libraries — so it is compiled under `cfg(test)` only. -#[cfg(test)] -pub fn write_sidecar( - path: &Path, - sidecar: &AssetSidecar, -) -> Result<(), Box> { - let tmp = tmp_path(path); - { - let file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp)?; - ciborium::ser::into_writer(sidecar, BufWriter::new(file))?; - } - fs::rename(&tmp, path)?; - Ok(()) -} - pub fn read_library_version( path: &Path, ) -> Result> { @@ -90,64 +57,10 @@ pub fn write_library_config( #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use tempfile::TempDir; use super::*; - use crate::domain::ImportMode; - use crate::metadata::AssetType; - use crate::sidecar::{AssetSidecar, LibraryConfigCbor, LibraryVersionCbor}; - - fn minimal_sidecar() -> AssetSidecar { - AssetSidecar { - version: 1, - uuid: "01956ef3-0000-7000-8000-000000000001".to_string(), - asset_type: AssetType::Photo, - original_filename: "IMG_1234.jpg".to_string(), - import_timestamp: 1720000000, - modified_timestamp: 1720000000, - hash_sha256: "a".repeat(64), - file_size: 1024, - is_deleted: false, - rating: 0, - tags: vec![], - import_mode: ImportMode::Copy, - importer_version: "0.1.0".to_string(), - rawshift_version: "0.1.0".to_string(), - capture_timestamp: None, - capture_utc: None, - capture_tz: None, - capture_tz_source: None, - tz_db_version: None, - width: None, - height: None, - duration_ms: None, - stack_hint: None, - album_id: None, - deleted_at: None, - camera_make: None, - camera_model: None, - gps_lat: None, - gps_lon: None, - unknown_fields: BTreeMap::new(), - } - } - - #[test] - fn test_write_read_sidecar() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("test.cbor"); - let s = minimal_sidecar(); - write_sidecar(&path, &s).unwrap(); - assert!(path.exists(), "sidecar file should exist after write"); - assert!( - !dir.path().join("test.cbor.tmp").exists(), - "temp file should be removed after atomic rename" - ); - let read_back = read_sidecar(&path).unwrap(); - assert_eq!(s, read_back); - } + use crate::sidecar::{LibraryConfigCbor, LibraryVersionCbor}; #[test] fn test_write_read_library_version() { diff --git a/capsule-core/src/sidecar/library_version.rs b/capsule-core/src/sidecar/library_version.rs index e7e4f575..5f3d17eb 100644 --- a/capsule-core/src/sidecar/library_version.rs +++ b/capsule-core/src/sidecar/library_version.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -pub const CURRENT_LIBRARY_VERSION: u8 = 1; +pub(crate) const CURRENT_LIBRARY_VERSION: u8 = 1; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LibraryVersionCbor { diff --git a/capsule-core/src/sidecar/mod.rs b/capsule-core/src/sidecar/mod.rs index 474668d7..1b33f13e 100644 --- a/capsule-core/src/sidecar/mod.rs +++ b/capsule-core/src/sidecar/mod.rs @@ -1,16 +1,15 @@ -pub mod asset_sidecar; -pub mod io; -pub mod library_config; -pub mod library_version; -pub mod sidecar_v1; -pub mod stack_hint; +pub(crate) mod io; +pub(crate) mod library_config; +pub(crate) mod library_version; +pub(crate) mod shape; +pub(crate) mod sidecar_v1; -pub use asset_sidecar::AssetSidecar; pub use io::{ - read_library_config, read_library_version, read_sidecar, write_library_config, - write_library_version, + read_library_config, read_library_version, write_library_config, write_library_version, }; pub use library_config::LibraryConfigCbor; pub use library_version::LibraryVersionCbor; -pub use sidecar_v1::{SIDECAR_SCHEMA_V1, SidecarV1}; -pub use stack_hint::StackHint; +pub use sidecar_v1::{ + AiTag, CameraId, CullFlag, Dimensions, Gps, GpsSource, Lqip, SIDECAR_SCHEMA_V1, SidecarV1, + StackMembership, StackRole, +}; diff --git a/capsule-core/src/sidecar/shape.rs b/capsule-core/src/sidecar/shape.rs new file mode 100644 index 00000000..b980019a --- /dev/null +++ b/capsule-core/src/sidecar/shape.rs @@ -0,0 +1,151 @@ +//! Tell the two sidecar shapes apart **without reading either** (slice `S-D24`). +//! +//! The signed [`SidecarV1`](crate::sidecar::SidecarV1) carries its schema version at CBOR +//! integer key `0` and has no text key `version`; the retired unsigned pre-signed-path shape +//! carried a text key `version` and no integer key at all. The two are disjoint on the wire, +//! so a probe that looks at nothing but those two keys classifies a file exactly, and it +//! builds no model of either shape — which is what keeps it from being a second reader after +//! the unsigned one is deleted. The only consumer of a legacy record's *contents* is the +//! migration verb ([`Workspace::migrate_unsigned_sidecars`]), which owns its own private +//! decoder. +//! +//! [`Workspace::migrate_unsigned_sidecars`]: crate::lifecycle::Workspace::migrate_unsigned_sidecars + +use ciborium::value::Value; + +/// Which on-disk sidecar shape a `{uuid}.cbor` file has. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SidecarShape { + /// A signed `SidecarV1`-family record: integer key `0` is present and carries `schema`. + /// Says nothing about whether the schema is one this build reads. + Signed { + /// The value at integer key `0`. + schema: u16, + }, + /// The retired unsigned pre-signed-path shape: a text `version` key and no key `0`. + /// Readable only by the migration verb's private decoder. + LegacyUnsigned, + /// Neither: not CBOR, not a map, or a map carrying neither discriminating key. + Unknown, +} + +/// Classify sidecar bytes by their discriminating keys alone. +/// +/// Decodes the outer CBOR value once and inspects only the map's keys; every value except +/// the one at integer key `0` is left unexamined. Never fails: undecodable bytes are +/// [`SidecarShape::Unknown`], and so is a `0` key whose value is not a `u16`. +pub(crate) fn probe(bytes: &[u8]) -> SidecarShape { + let Ok(Value::Map(entries)) = ciborium::de::from_reader::(bytes) else { + return SidecarShape::Unknown; + }; + let mut has_version_key = false; + for (key, value) in &entries { + match key { + Value::Integer(i) if i128::from(*i) == 0 => { + return match value { + Value::Integer(v) => u16::try_from(i128::from(*v)) + .map_or(SidecarShape::Unknown, |schema| SidecarShape::Signed { + schema, + }), + _ => SidecarShape::Unknown, + }; + } + Value::Text(t) if t == "version" => has_version_key = true, + _ => {} + } + } + if has_version_key { + SidecarShape::LegacyUnsigned + } else { + SidecarShape::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode(value: &Value) -> Vec { + let mut out = Vec::new(); + ciborium::ser::into_writer(value, &mut out).unwrap(); + out + } + + fn text(s: &str) -> Value { + Value::Text(s.to_string()) + } + + #[test] + fn a_signed_sidecar_is_recognised_by_integer_key_zero() { + let bytes = encode(&Value::Map(vec![ + (Value::Integer(0.into()), Value::Integer(1.into())), + (text("uuid"), text("0195...")), + (text("hash"), Value::Bytes(vec![0; 32])), + ])); + assert_eq!(probe(&bytes), SidecarShape::Signed { schema: 1 }); + } + + /// The probe reports the schema it finds rather than judging it, so a reader can refuse a + /// too-new sidecar with the version in hand. + #[test] + fn a_newer_signed_schema_is_still_signed() { + let bytes = encode(&Value::Map(vec![( + Value::Integer(0.into()), + Value::Integer(7.into()), + )])); + assert_eq!(probe(&bytes), SidecarShape::Signed { schema: 7 }); + } + + #[test] + fn a_legacy_unsigned_sidecar_is_recognised_by_its_version_key() { + let bytes = encode(&Value::Map(vec![ + (text("version"), Value::Integer(1.into())), + (text("uuid"), text("aabbccdd-0000-0000-0000-000000000001")), + (text("hash_sha256"), text(&"a".repeat(64))), + ])); + assert_eq!(probe(&bytes), SidecarShape::LegacyUnsigned); + } + + /// Key `0` wins even when a `version` text key is also present: the signed schema field + /// is the authoritative discriminator, and a legacy map never carries key `0`. + #[test] + fn key_zero_outranks_a_stray_version_key() { + let bytes = encode(&Value::Map(vec![ + (text("version"), Value::Integer(1.into())), + (Value::Integer(0.into()), Value::Integer(1.into())), + ])); + assert_eq!(probe(&bytes), SidecarShape::Signed { schema: 1 }); + } + + #[test] + fn everything_else_is_unknown() { + assert_eq!(probe(b"not cbor at all"), SidecarShape::Unknown); + assert_eq!(probe(&[]), SidecarShape::Unknown); + // A CBOR array, not a map. + assert_eq!( + probe(&encode(&Value::Array(vec![Value::Integer(0.into())]))), + SidecarShape::Unknown + ); + // A map with neither discriminating key. + assert_eq!( + probe(&encode(&Value::Map(vec![(text("uuid"), text("x"))]))), + SidecarShape::Unknown + ); + // Key 0 whose value is not an integer schema. + assert_eq!( + probe(&encode(&Value::Map(vec![( + Value::Integer(0.into()), + text("one") + )]))), + SidecarShape::Unknown + ); + // Key 0 whose value does not fit a u16. + assert_eq!( + probe(&encode(&Value::Map(vec![( + Value::Integer(0.into()), + Value::Integer(70_000.into()) + )]))), + SidecarShape::Unknown + ); + } +} diff --git a/capsule-core/src/sidecar/stack_hint.rs b/capsule-core/src/sidecar/stack_hint.rs deleted file mode 100644 index 4b3a5304..00000000 --- a/capsule-core/src/sidecar/stack_hint.rs +++ /dev/null @@ -1,38 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::domain::{DetectionMethod, MemberRole, StackType}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct StackHint { - pub detection_key: String, - pub detection_method: DetectionMethod, - pub member_role: MemberRole, - pub stack_type: StackType, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::{DetectionMethod, MemberRole, StackType}; - - fn cbor_roundtrip< - T: serde::Serialize + for<'de> serde::Deserialize<'de> + PartialEq + std::fmt::Debug, - >( - val: &T, - ) -> T { - let mut buf = vec![]; - ciborium::ser::into_writer(val, &mut buf).unwrap(); - ciborium::de::from_reader(buf.as_slice()).unwrap() - } - - #[test] - fn test_round_trip() { - let hint = StackHint { - detection_key: "img_1234".to_string(), - detection_method: DetectionMethod::FilenameStem, - member_role: MemberRole::Primary, - stack_type: StackType::RawJpeg, - }; - assert_eq!(hint, cbor_roundtrip(&hint)); - } -} diff --git a/capsule-core/src/utils/hash.rs b/capsule-core/src/utils/hash.rs deleted file mode 100644 index 09c9b41f..00000000 --- a/capsule-core/src/utils/hash.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::fs::File; -use std::io; -use std::path::Path; - -use crate::crypto::hash::{hash_bytes as hash32_bytes, hash_reader}; - -/// SHA-256 hash of a byte slice as a 64-char lowercase hex string. -pub fn hash_bytes(bytes: &[u8]) -> String { - hash32_bytes(bytes).to_hex() -} - -/// SHA-256 hash of a file as a 64-char lowercase hex string. -/// -/// Streams the file in fixed blocks via [`crate::crypto::hash`] rather than reading the -/// whole file into memory, so arbitrarily large originals hash with bounded memory. -pub fn get_file_hash(path: &Path) -> io::Result { - let file = File::open(path)?; - Ok(hash_reader(io::BufReader::new(file))?.to_hex()) -} diff --git a/capsule-core/src/utils/mod.rs b/capsule-core/src/utils/mod.rs index 5609d45f..8118b296 100644 --- a/capsule-core/src/utils/mod.rs +++ b/capsule-core/src/utils/mod.rs @@ -1,2 +1 @@ -pub mod hash; pub mod paths; diff --git a/capsule-core/src/utils/paths.rs b/capsule-core/src/utils/paths.rs index 2a8ecc20..bfe83da9 100644 --- a/capsule-core/src/utils/paths.rs +++ b/capsule-core/src/utils/paths.rs @@ -1,12 +1,29 @@ //! Path helpers with no knowledge of the library layout. //! -//! [`tmp_path`] lives here rather than in [`crate::library::paths`] because it encodes no -//! layout at all — it appends `.tmp` to whatever it is given. Keeping it here lets +//! [`tmp_path`] lives here rather than beside the library's own path helpers because it encodes +//! no layout at all — it appends `.tmp` to whatever it is given. Keeping it here lets //! [`crate::sidecar`] do atomic writes without importing `library`, which was the one real //! `sidecar -> library` edge (the rest of that pair is rustdoc links). use std::path::{Path, PathBuf}; +/// Make a directory entry durable: `fsync` the directory so a `rename`, `mkdir`, or newly +/// created file inside it survives a crash that happens after the call returned. The partner +/// of [`tmp_path`]'s write-then-rename. +/// +/// A Unix primitive: on other platforms this is a documented no-op rather than a failure (a +/// directory cannot be opened as a file there), mirroring `capsule-server`'s blob store. +#[cfg(unix)] +pub(crate) fn sync_dir(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +/// No-op: fsyncing a directory is a Unix primitive. See the Unix variant. +#[cfg(not(unix))] +pub(crate) fn sync_dir(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + /// Appends `.tmp` to any path. /// /// The write-then-rename partner for atomic file replacement: write to `tmp_path(p)`, fsync, diff --git a/capsule-core/src/validation/idempotency.rs b/capsule-core/src/validation/idempotency.rs deleted file mode 100644 index 166aaab3..00000000 --- a/capsule-core/src/validation/idempotency.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Idempotency keys for write surfaces (SSoT: [Threat Model — Idempotency Invariants]). -//! Every write surface has a single idempotency key: a duplicate (same key) is a no-op; a -//! conflict (same key, different content) is a corruption error. These constructors produce -//! a stable canonical key so a server can dedup deterministically. -//! -//! [Threat Model — Idempotency Invariants]: https://docs/design/threat-model/validation/#idempotency-invariants - -use uuid::Uuid; - -use crate::crypto::hash::Hash32; - -/// A stable idempotency key (a canonical string a server can index). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct IdempotencyKey(pub String); - -/// `(owner_id, hash, album_id)` — session creation (`POST /upload`) dedup. -pub fn session_key(owner_id: &Uuid, hash: &Hash32, album_id: &Uuid) -> IdempotencyKey { - IdempotencyKey(format!("session:{owner_id}:{}:{album_id}", hash.to_hex())) -} - -/// `(asset_id, prior_provenance_hash, manifest_hash)` — lifecycle manifest write. -pub fn lifecycle_key( - asset_id: &Uuid, - prior: Option, - manifest_hash: &Hash32, -) -> IdempotencyKey { - let prior = prior.map_or_else(|| "null".into(), |h| h.to_hex()); - IdempotencyKey(format!( - "lifecycle:{asset_id}:{prior}:{}", - manifest_hash.to_hex() - )) -} - -/// `(upload_id, offset, chunk_hash)` — upload chunk (`PATCH /upload/{id}`). -pub fn chunk_key(upload_id: &Uuid, offset: u64, chunk_hash: &Hash32) -> IdempotencyKey { - IdempotencyKey(format!( - "chunk:{upload_id}:{offset}:{}", - chunk_hash.to_hex() - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn same_inputs_produce_the_same_key() { - let owner = Uuid::from_u128(1); - let album = Uuid::from_u128(2); - let h = Hash32([7; 32]); - assert_eq!( - session_key(&owner, &h, &album), - session_key(&owner, &h, &album) - ); - } - - #[test] - fn different_content_produces_a_different_key() { - // Same (asset, prior) but a different manifest hash → a *conflict*, distinguishable - // by the key differing (server treats same-key/different-content as corruption). - let asset = Uuid::from_u128(1); - let prior = Some(Hash32([1; 32])); - let a = lifecycle_key(&asset, prior, &Hash32([2; 32])); - let b = lifecycle_key(&asset, prior, &Hash32([3; 32])); - assert_ne!(a, b); - } - - #[test] - fn null_prior_is_distinct_from_zero_hash() { - let asset = Uuid::from_u128(1); - let mh = Hash32([9; 32]); - let create = lifecycle_key(&asset, None, &mh); - let zero = lifecycle_key(&asset, Some(Hash32([0; 32])), &mh); - assert_ne!(create, zero); - } - - #[test] - fn chunk_key_varies_by_offset() { - let up = Uuid::from_u128(1); - let h = Hash32([5; 32]); - assert_ne!(chunk_key(&up, 0, &h), chunk_key(&up, 4096, &h)); - } -} diff --git a/capsule-core/src/validation/mod.rs b/capsule-core/src/validation/mod.rs index 9ea72137..1664c6fb 100644 --- a/capsule-core/src/validation/mod.rs +++ b/capsule-core/src/validation/mod.rs @@ -2,24 +2,21 @@ //! (SSoT: [Threat Model — Validation Invariants]). //! //! These are **pure, key-less** structural checks: the protocol/capability handshake -//! ([`protocol`]), the server-side manifest envelope ([`structural`]), and idempotency -//! keys ([`idempotency`]). They mirror the client-side checks in -//! [`verify_asset`](crate::crypto::verify_asset), and the server write paths consume them -//! today — the envelope gate, the ops surface, the feed, federation pull, and the drop -//! routes all validate through here. This module used to describe those consumers as +//! ([`protocol`]) and the server-side manifest envelope ([`structural`]). They mirror the +//! client-side checks in [`verify_asset`](fn@crate::crypto::verify_asset), and the server +//! write paths consume them today — the envelope gate, the ops surface, the feed, +//! federation pull, and the drop routes all validate through here. This module used to describe those consumers as //! deferred; they are six live call sites, and the checks are the only thing standing //! between a key-free server and a malformed write. //! //! Upload-transport-specific invariants (chunk offset/4 KiB alignment, cumulative size) -//! live with the deferred upload protocol; the chunk idempotency key is provided here. +//! live with the deferred upload protocol. //! //! [Threat Model — Validation Invariants]: https://docs/design/threat-model/validation/ -pub mod idempotency; pub mod protocol; pub mod structural; -pub use idempotency::IdempotencyKey; pub use protocol::{HandshakeReject, protocol_gate}; pub use structural::{ EnvelopeContext, EnvelopeReject, check_manifest_envelope, check_metadata_blob_envelope, diff --git a/capsule-core/src/validation/structural.rs b/capsule-core/src/validation/structural.rs index 315fbe43..84f5f616 100644 --- a/capsule-core/src/validation/structural.rs +++ b/capsule-core/src/validation/structural.rs @@ -2,7 +2,7 @@ //! (SSoT: [Threat Model — Server-Side Validation Invariants]). The server holds no keys, //! so it cannot verify signatures — but it validates *structure* refuse-by-default. These //! are pure predicates over the manifest core and server-known state; the client mirrors -//! them via [`verify_asset`](crate::crypto::verify_asset). +//! them via [`verify_asset`](fn@crate::crypto::verify_asset). //! //! [Threat Model — Server-Side Validation Invariants]: https://docs/design/threat-model/validation/#server-side-validation-invariants @@ -69,7 +69,7 @@ pub fn prior_hash_matches( } /// Invariant 18: `amk_version` never regresses for an album (server's structural backstop; -/// MLS is the authority on the ceiling — see [`verify_asset`](crate::crypto::verify_asset)). +/// MLS is the authority on the ceiling — see [`verify_asset`](fn@crate::crypto::verify_asset)). pub fn amk_version_monotonic(new: u32, stored: Option) -> bool { match stored { None => true, diff --git a/capsule-core/tests/drop_adopt_kat.rs b/capsule-core/tests/drop_adopt_kat.rs index 38741b83..563b8cc8 100644 --- a/capsule-core/tests/drop_adopt_kat.rs +++ b/capsule-core/tests/drop_adopt_kat.rs @@ -17,8 +17,9 @@ use capsule_core::crypto::authority::ReferenceAuthority; use capsule_core::crypto::encryption::keywrap::{seal_file_key, unseal_file_key}; use capsule_core::crypto::encryption::stream::decrypt_asset_vec; use capsule_core::crypto::hash::hash_bytes; -use capsule_core::crypto::keys::directory::{DeviceEntry, DirectoryCore}; -use capsule_core::crypto::keys::{Amk, AmkVersion, DekKeypair, DeviceDirectory, HybridSigningKey}; +use capsule_core::crypto::keys::{ + Amk, AmkVersion, DekKeypair, DeviceDirectory, DeviceEntry, DirectoryCore, HybridSigningKey, +}; use capsule_core::crypto::primitives::{CRYPTO_SUITE_ID, PROTOCOL_VERSION}; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::{ diff --git a/capsule-core/tests/local_gallery_security.rs b/capsule-core/tests/local_gallery_security.rs index 41b5e8b9..d681aa50 100644 --- a/capsule-core/tests/local_gallery_security.rs +++ b/capsule-core/tests/local_gallery_security.rs @@ -18,7 +18,10 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use capsule_core::library::paths; +use capsule_core::library::{ + ThumbnailSize, media_dir, media_path, meta_cache_path, receipts_path, sidecar_path, + thumbnail_path, tmp_path, transcode_h264_path, transcode_live_path, trash_path, +}; use uuid::Uuid; /// Walk to the workspace root (the nearest ancestor holding `Cargo.lock`). @@ -59,20 +62,20 @@ fn every_paths_module_output_stays_under_the_library_root() { let capture = Some(1_721_001_600_i64); let mut candidates = vec![ - paths::media_dir(root, 2024, 7), - paths::media_path(root, &uuid, "arw", capture), - paths::media_path(root, &uuid, "jpg", None), - paths::sidecar_path(root, &uuid, "jpg", capture), - paths::receipts_path(root, &uuid, capture), - paths::meta_cache_path(root, &uuid), - paths::transcode_h264_path(root, &uuid), - paths::transcode_live_path(root, &uuid), - paths::trash_path(root, &uuid, "jpg"), - paths::thumbnail_path(root, &uuid, paths::ThumbnailSize::Xl), + media_dir(root, 2024, 7), + media_path(root, &uuid, "arw", capture), + media_path(root, &uuid, "jpg", None), + sidecar_path(root, &uuid, "jpg", capture), + receipts_path(root, &uuid, capture), + meta_cache_path(root, &uuid), + transcode_h264_path(root, &uuid), + transcode_live_path(root, &uuid), + trash_path(root, &uuid, "jpg"), + thumbnail_path(root, &uuid, ThumbnailSize::Xl), ]; // The staging path for atomic writes is derived from an under-root path; it must stay under it. - let media = paths::media_path(root, &uuid, "jpg", capture); - candidates.push(paths::tmp_path(&media)); + let media = media_path(root, &uuid, "jpg", capture); + candidates.push(tmp_path(&media)); for path in candidates { assert!( diff --git a/capsule-docs/.gitignore b/capsule-docs/.gitignore index 6240da8b..dfc3d2ef 100644 --- a/capsule-docs/.gitignore +++ b/capsule-docs/.gitignore @@ -1,5 +1,13 @@ # build output dist/ +# generated reference pages (capsule-docs/scripts/gen-reference.mjs) +# +# Rule 2 of design/developer-docs.md: reference pages are generated, never written. A +# committed copy is a second source of truth that can disagree with the artifact it came +# from, and the whole point is that it cannot. The hand-written overview for each surface +# is its sibling file (reference/cli.md), not a file inside these directories. +src/content/docs/reference/cli/ +src/content/docs/reference/api/ # generated types .astro/ diff --git a/capsule-docs/astro.config.mjs b/capsule-docs/astro.config.mjs index 5188a74f..53922189 100644 --- a/capsule-docs/astro.config.mjs +++ b/capsule-docs/astro.config.mjs @@ -3,6 +3,7 @@ import tailwindcss from '@tailwindcss/vite'; // @ts-check import { defineConfig } from 'astro/config'; import starlightLinksValidator from 'starlight-links-validator'; +import { referenceSidebar } from './scripts/reference-groups.mjs'; import rehypeNoTranslate from './src/lib/rehype-notranslate.mjs'; // https://astro.build/config @@ -149,8 +150,13 @@ export default defineConfig({ { // Hand-curated, not autogenerated: generated reference pages must not // determine navigation order. See design/developer-docs.md. + // + // Curated in `scripts/reference-groups.mjs` rather than inline, because + // the generator reads the same list to decide which pages exist. One + // edit moves a page and its navigation entry together, and a group + // with navigation but no page is not expressible. label: 'Reference', - items: [{ slug: 'reference' }], + items: referenceSidebar(), }, ], customCss: ['./src/styles/global.css'], diff --git a/capsule-docs/package.json b/capsule-docs/package.json index 1e21abc3..03aa9971 100644 --- a/capsule-docs/package.json +++ b/capsule-docs/package.json @@ -4,12 +4,12 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "scripts": { - "dev": "astro dev", - "start": "astro dev", - "build": "astro build", - "preview": "wrangler pages dev ./dist", + "dev": "bun scripts/gen-reference.mjs && astro dev", + "start": "bun scripts/gen-reference.mjs && astro dev", + "build": "bun scripts/gen-reference.mjs && astro build", + "preview": "bun run build && wrangler pages dev ./dist", "astro": "astro", - "deploy": "wrangler pages deploy ./dist", + "deploy": "bun run build && wrangler pages deploy ./dist", "test": "vitest run" }, "dependencies": { diff --git a/capsule-docs/planned-modules.txt b/capsule-docs/planned-modules.txt index d78427f0..924565f8 100644 --- a/capsule-docs/planned-modules.txt +++ b/capsule-docs/planned-modules.txt @@ -12,7 +12,5 @@ # there. Everything here is a commitment nobody has met yet — read it as the # module-layer answer to "what has been designed and not built?" -capsule-core::media The Capsule-side owner of decode, metadata extraction and derivative generation, which will consume Rawshift once Rawshift stabilizes. Rawshift is a pinned submodule today and is not a workspace dependency, so nothing consumes it and this module has no body to write yet. Lane B in SLICES.md. -capsule-core::notify Alert classes and their trigger predicates, so every platform evaluates one shared decision function rather than reimplementing the taxonomy. Contract: design/notifications.md. Tier 0 has no server half, so this is client-only work. +capsule-core::media::video The video half of the media module: first-frame still extraction and the H.264 baseline preview transcode. The still half now exists (`capsule-core::media`, slice S-B1 on rawshift-image 0.1.1); this does not, because `rawshift-video` is unpublished and the transcode toolchain touches nothing the still path does. Contract: design/thumbnails.md § Video Previews. Slice S-B5 in SLICES.md. capsule-core::import::camera The PTP/IP tethered-camera source adapter (S-B9). Post-v1; the contract exists so the adapter seam is fixed before anything implements it. -capsule-server::federation Server-to-server federation pull. The whole surface is post-v1 — `capsule-server` has no federation route, no capability-token verifier and no per-peer budget enforcement. diff --git a/capsule-docs/scripts/check-roadmap.mjs b/capsule-docs/scripts/check-roadmap.mjs new file mode 100644 index 00000000..eef83151 --- /dev/null +++ b/capsule-docs/scripts/check-roadmap.mjs @@ -0,0 +1,525 @@ +/** + * `ROADMAP.md` against the manifests that declare the packages. + * + * `SLICES.md` tracks slices; `ROADMAP.md` tracks **packages**, one row each, + * across every toolchain in the repository. A package-level view is only worth + * reading if it cannot quietly fall behind the tree, so this check resolves + * every row against the manifest that declares the package rather than against + * a second committed list. A committed list would be tautological: both files + * are hand-edited, so a package added to neither passes. + * + * The oracles are deliberately regex over manifest *text*, not parsers: + * + * - `Cargo.toml`'s `[workspace] members` + * - `settings.gradle.kts`'s unconditional `include(":x")` plus the + * `project(":x").projectDir = file("y")` that names its directory + * - `capsule-swift/Project.swift`'s `module("Name"` targets and its app target + * - a root-level directory holding `Package.swift` (SwiftPM), `package.json` + * (bun) or `pyproject.toml` (uv) + * - `locales/`, `.gitmodules`, and the `legacy-review//` directories + * + * That keeps this file inside `docs-truth`'s no-dependency, no-toolchain rule + * (`docs-truth.mjs`), which is what lets a docs-only pull request run it without + * paying for a cargo, gradle, tuist or uv resolve. + * + * **A conditional target is invisible here, on purpose.** `Project.swift` wraps + * `CapsuleCatalogFFI` in a `ffiEnabled ? … : []` ternary and + * `settings.gradle.kts` keeps `:cli`/`:desktop` commented out. A regex cannot + * evaluate either condition, so the rule is: only unconditional declarations are + * oracles, and a conditional target earns a row whose `State` says `excluded`. + * `CapsuleCatalogFFI` still matches `module("` and so is required to have a row; + * the commented-out Gradle modules match nothing and so must not have one. + * + * **Package-root discovery recurses exactly one level, and that is a bound rather than an + * oversight.** A `Package.swift` two directories deep would be missed; the alternative is + * an unbounded walk that reads `node_modules`, `target` and `.build` on every docs-only + * pull request. One level covers every shape the tree uses today, and the pruned-directory + * set below is what keeps the walk cheap. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** The package view. */ +const ROADMAP = 'ROADMAP.md'; + +/** The slice tracker whose ids the `Open slices` column cites. */ +const SLICES = 'SLICES.md'; + +/** Columns the package table must carry, in order. */ +const COLUMNS = [ + 'Package', + 'Kind', + 'Owns', + 'State', + 'Gate', + 'Owner docs', + 'Open slices', + 'Next milestone', + 'Notes', +]; + +/** States whose rows are outside every gate, and so may write `—` for `Gate`. */ +const UNGATED_STATES = new Set(['review-only', 'excluded']); + +/** Directories never treated as a package root, whatever they contain. */ +const SKIP_ROOT_DIRS = new Set([ + '.git', + '.github', + 'adr', + 'docs', + 'gradle', + 'images', + 'legacy-review', + 'locales', + 'mise-tasks', + 'node_modules', + 'rawshift', + 'target', +]); + +/** An em-dash cell: "this column does not apply to this row". */ +const NONE = '—'; + +/** Split one Markdown table row into trimmed cells, dropping the outer pipes. */ +function cells(line) { + const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, ''); + return trimmed.split('|').map((cell) => cell.trim()); +} + +/** True for a `| --- | --- |` separator row. */ +function isSeparator(line) { + return /^\|[\s:|-]+\|$/.test(line.trim()); +} + +/** + * Every pipe table in `source`, as `{ header, rows }` where a row carries its + * cells and the 1-based line it sits on. + * + * @param {string} source Markdown document. + * @returns {{ header: string[], rows: { cells: string[], line: number }[] }[]} + */ +export function tables(source) { + const lines = source.split('\n'); + const found = []; + + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].trim().startsWith('|')) continue; + if (!isSeparator(lines[i + 1] ?? '')) continue; + + const header = cells(lines[i]); + const rows = []; + let j = i + 2; + for (; j < lines.length && lines[j].trim().startsWith('|'); j += 1) { + rows.push({ cells: cells(lines[j]), line: j + 1 }); + } + found.push({ header, rows }); + i = j; + } + + return found; +} + +/** + * The state vocabulary the document defines, in document order. + * + * Definitions are the bullet list under the `## States` heading, each of the + * form ``- `name` — meaning``. + * + * @param {string} source `ROADMAP.md`. + * @returns {string[]} + */ +export function definedStates(source) { + const opens = /^##\s+States\b.*$/m.exec(source); + if (!opens) return []; + const body = source.slice(opens.index + opens[0].length); + const closes = /^##\s/m.exec(body); + const section = closes ? body.slice(0, closes.index) : body; + return [...section.matchAll(/^-\s+`([a-z][a-z-]*)`\s+—/gm)].map( + (match) => match[1], + ); +} + +/** Quoted strings on lines that are not comments. */ +function quoted(text, pattern) { + const found = []; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed.startsWith('//')) continue; + for (const match of trimmed.matchAll(pattern)) found.push(match[1]); + } + return found; +} + +/** `[workspace] members` — each member path is a cargo package row. */ +function cargoPackages(root) { + const manifest = join(root, 'Cargo.toml'); + if (!existsSync(manifest)) return []; + const body = readFileSync(manifest, 'utf8'); + // `^members` anchors at line start, which `default-members` cannot match. + const block = /^members\s*=\s*\[([\s\S]*?)^\]/m.exec(body); + if (!block) return []; + return quoted(block[1], /"([^"]+)"/g); +} + +/** + * Unconditional `include(…)`, resolved to the directory `project()` names. + * + * Kotlin's `include` is variadic — `include(":a", ":b")` is one call declaring two modules — + * and the DSL tolerates a space before the parenthesis. Matching only `include(":x")` read + * the file this repository happens to have rather than the file the DSL allows, so a + * multi-argument call would have added modules the gate could not see. + */ +function gradlePackages(root) { + const settings = join(root, 'settings.gradle.kts'); + if (!existsSync(settings)) return []; + const body = readFileSync(settings, 'utf8'); + const included = []; + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('//')) continue; + const call = /^include\s*\(([^)]*)\)/.exec(trimmed); + if (!call) continue; + for (const arg of call[1].matchAll(/"([^"]+)"/g)) included.push(arg[1]); + } + const dirs = new Map(); + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('//')) continue; + const match = + /^project\("([^"]+)"\)\.projectDir\s*=\s*file\("([^"]+)"\)/.exec( + trimmed, + ); + if (match) dirs.set(match[1], match[2]); + } + return included.map((path) => dirs.get(path) ?? path.replace(/^:/, '')); +} + +/** `module("Name"` framework targets plus the single app target. */ +function tuistPackages(root) { + const project = join(root, 'capsule-swift', 'Project.swift'); + if (!existsSync(project)) return []; + const body = readFileSync(project, 'utf8'); + const names = [...body.matchAll(/\bmodule\(\s*"([A-Za-z0-9_]+)"/g)].map( + (match) => match[1], + ); + const app = + /\bappTarget\s*:\s*Target\s*=\s*\.target\(\s*name:\s*"([A-Za-z0-9_]+)"/.exec( + body, + ); + if (app) names.push(app[1]); + return names; +} + +/** Directories worth descending into: not hidden, not pruned. */ +function searchable(root, prefix) { + return readdirSync(join(root, prefix), { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + !entry.name.startsWith('.') && + !SKIP_ROOT_DIRS.has(entry.name), + ) + .map((entry) => (prefix ? `${prefix}/${entry.name}` : entry.name)); +} + +/** + * Directories carrying `manifest`, at the repository root or one level under it. + * + * The returned name is repo-relative, so a nested root is `sub/nested` and that is what its + * `Package` cell must say — the same convention `Cargo.toml` members already use for + * `capsule-cli/entity`. + */ +function manifestDirs(root, manifest) { + const found = []; + for (const dir of searchable(root, '')) { + if (existsSync(join(root, dir, manifest))) { + found.push(dir); + // A package root is not searched for nested roots: a Cargo or bun package + // legitimately contains sub-manifests that are not separate packages. + continue; + } + for (const nested of searchable(root, dir)) { + if (existsSync(join(root, nested, manifest))) found.push(nested); + } + } + return found; +} + +/** `path = x` in `.gitmodules`. */ +function submodules(root) { + const modules = join(root, '.gitmodules'); + if (!existsSync(modules)) return []; + return [ + ...readFileSync(modules, 'utf8').matchAll(/^\s*path\s*=\s*(\S+)/gm), + ].map((match) => match[1]); +} + +/** Each `legacy-review//` directory. */ +function reviewBuckets(root) { + const bucket = join(root, 'legacy-review'); + if (!existsSync(bucket)) return []; + return readdirSync(bucket, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `legacy-review/${entry.name}`); +} + +/** + * Every package the tree declares, as `name -> kind`. + * + * @param {string} root Repository root. + * @returns {Map} + */ +export function declaredPackages(root) { + const declared = new Map(); + const add = (kind) => (name) => declared.set(name, kind); + + cargoPackages(root).forEach(add('cargo')); + gradlePackages(root).forEach(add('gradle')); + tuistPackages(root).forEach(add('tuist')); + manifestDirs(root, 'Package.swift').forEach(add('swiftpm')); + manifestDirs(root, 'package.json').forEach(add('bun')); + manifestDirs(root, 'pyproject.toml').forEach(add('python')); + if (existsSync(join(root, 'locales'))) declared.set('locales', 'catalog'); + submodules(root).forEach(add('submodule')); + reviewBuckets(root).forEach(add('review-bucket')); + + return declared; +} + +/** + * Every `mise run ` name the repository actually has. + * + * Two spellings beyond the obvious one, both of which mise supports and neither of which + * this repository uses yet. A quoted header — `[tasks."docs:build"]` — is how a task name + * carrying a colon is written, and a file task may sit in a subdirectory, where + * `mise-tasks/docs/build` is the task `docs:build`. Missing either would fail loudly rather + * than silently, but it would fail on a correct row, which is the worse of the two ways for + * a gate to be wrong. + */ +export function miseTasks(root) { + const tasks = new Set(); + + for (const manifest of ['mise.toml', 'capsule-swift/mise.toml']) { + const path = join(root, manifest); + if (!existsSync(path)) continue; + for (const match of readFileSync(path, 'utf8').matchAll( + /^\[tasks\.(?:"([^"]+)"|([A-Za-z0-9_:-]+))\]/gm, + )) { + tasks.add(match[1] ?? match[2]); + } + } + + const walk = (dir, prefix) => { + if (!existsSync(dir)) return; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + if (entry.isFile()) tasks.add(prefix + entry.name); + else if (entry.isDirectory()) + walk(join(dir, entry.name), `${prefix + entry.name}:`); + } + }; + walk(join(root, 'mise-tasks'), ''); + + return tasks; +} + +/** + * Every backticked `S-…` id in `ROADMAP.md`, with the 1-based line it sits on. + * + * Deliberately not restricted to the `Open slices` column. The column is where a reader + * looks for a slice, and it is not the only place this file names one: `Next milestone` + * and `Notes` cite slices constantly, and so does the deferred register below the package + * table. Four stale citations in this file were found in exactly those unchecked cells. + */ +export function citedSlices(source) { + const cited = []; + source.split('\n').forEach((line, index) => { + for (const match of line.matchAll(/`(S-[A-Za-z0-9]+)`/g)) { + cited.push({ id: match[1], line: index + 1 }); + } + }); + return cited; +} + +/** Every slice id `SLICES.md` gives a detail block. */ +export function sliceIds(root) { + const path = join(root, SLICES); + if (!existsSync(path)) return new Set(); + return new Set( + [ + ...readFileSync(path, 'utf8').matchAll(/^###\s+(S-[A-Z]+\d+)\s/gm), + ].map((match) => match[1]), + ); +} + +/** + * Resolve every `ROADMAP.md` row against the tree. + * + * @param {string} root Repository root. + * @returns {{ findings: string[], checked: number, unused: string[] }} `unused` names the + * states the vocabulary defines and no row uses; it is reported, never failed. + */ +export function checkRoadmap(root) { + const findings = []; + const path = join(root, ROADMAP); + + if (!existsSync(path)) { + return { + findings: [`${ROADMAP} the package view is missing`], + checked: 0, + }; + } + + const source = readFileSync(path, 'utf8'); + const states = definedStates(source); + if (states.length === 0) { + findings.push(`${ROADMAP} no state vocabulary under \`## States\``); + } + + const parsed = tables(source); + const table = parsed.find((candidate) => candidate.header[0] === 'Package'); + if (!table) { + findings.push( + `${ROADMAP} no package table (its first column is \`Package\`)`, + ); + return { findings, checked: 0 }; + } + + if (table.header.join(' | ') !== COLUMNS.join(' | ')) { + findings.push( + `${ROADMAP} header is \`${table.header.join(' | ')}\`, expected \`${COLUMNS.join(' | ')}\``, + ); + } + + const declared = declaredPackages(root); + const tasks = miseTasks(root); + const slices = sliceIds(root); + const known = new Set(states); + // Every state used anywhere in the document, so the deferred register below + // the package table counts as a user of `deferred`. + const used = new Set(); + for (const candidate of parsed) { + const column = candidate.header.indexOf('State'); + if (column === -1) continue; + for (const row of candidate.rows) { + if (row.cells[column]) + used.add(row.cells[column].replace(/`/g, '')); + } + } + + const seen = new Set(); + + for (const { cells: row, line } of table.rows) { + const at = `${ROADMAP}:${line}`; + + if (row.length !== COLUMNS.length) { + findings.push( + `${at} ${row.length} column(s), expected ${COLUMNS.length}`, + ); + continue; + } + + const [pkg, kind, , state, gate, , open] = row.map((cell) => + cell.replace(/`/g, ''), + ); + + if (seen.has(pkg)) findings.push(`${at} ${pkg} has more than one row`); + seen.add(pkg); + + const kindOf = declared.get(pkg); + if (kindOf === undefined) { + findings.push( + `${at} ${pkg} is not declared by any manifest in the tree`, + ); + } else if (kindOf !== kind) { + findings.push( + `${at} ${pkg} is declared as \`${kindOf}\`, the row says \`${kind}\``, + ); + } + + if (!known.has(state)) { + findings.push( + `${at} state \`${state}\` is not one of ${[...known].map((s) => `\`${s}\``).join(', ')}`, + ); + } + + if (gate === NONE) { + if (!UNGATED_STATES.has(state)) { + findings.push( + `${at} only ${[...UNGATED_STATES].join('/')} rows may leave \`Gate\` empty`, + ); + } + } else { + const task = /^mise run ([A-Za-z0-9_-]+)$/.exec(gate); + if (!task) { + findings.push( + `${at} gate \`${gate}\` is not \`mise run \` or \`${NONE}\``, + ); + } else if (!tasks.has(task[1])) { + findings.push( + `${at} \`mise run ${task[1]}\` is not a task in this repository`, + ); + } + } + + // Shape only. Whether an id *resolves* is settled below, over the whole + // document, so a stale citation in `Notes` is caught the same way as one here. + if (open !== NONE) { + for (const id of open.split(',').map((entry) => entry.trim())) { + if (!/^S-[A-Z]+\d+$/.test(id)) { + findings.push(`${at} \`${id}\` is not a slice id`); + } + } + } + } + + for (const { id, line } of citedSlices(source)) { + if (!/^S-[A-Z]+\d+$/.test(id)) { + findings.push(`${ROADMAP}:${line} \`${id}\` is not a slice id`); + } else if (!slices.has(id)) { + findings.push( + `${ROADMAP}:${line} \`${id}\` has no detail block in ${SLICES}`, + ); + } + } + + for (const [pkg, kind] of declared) { + if (!seen.has(pkg)) { + findings.push(`${ROADMAP} ${kind} package \`${pkg}\` has no row`); + } + } + + // A defined state nothing uses is reported and does **not** fail. Failing on + // it would put steady pressure on whoever is editing the file to give the + // spare term a row — which is a dishonest state assignment, the exact defect + // this whole check exists to prevent. Naming it in the success line keeps the + // vocabulary from rotting unnoticed at no such cost. + const unused = [...known].filter((state) => !used.has(state)); + + return { findings, checked: declared.size, unused }; +} + +/** @param {{ findings: string[], checked: number, unused?: string[] }} result */ +export function reportRoadmap({ findings, checked, unused = [] }) { + const spare = + unused.length === 0 + ? '' + : ` States defined and unused: ${unused.map((state) => `\`${state}\``).join(', ')}.`; + if (findings.length === 0) { + return `roadmap: ${checked} package(s) checked, all rows resolve.${spare}`; + } + return [ + `roadmap: ${findings.length} row(s) in ${ROADMAP} disagree with the tree.`, + '', + ...findings.map((finding) => ` ${finding}`), + '', + `roadmap failed: ${findings.length} unresolved of ${checked} package(s) checked.${spare}`, + ].join('\n'); +} + +export const roadmapCheck = { + name: 'roadmap', + run: checkRoadmap, + report: reportRoadmap, +}; diff --git a/capsule-docs/scripts/check-roadmap.test.mjs b/capsule-docs/scripts/check-roadmap.test.mjs new file mode 100644 index 00000000..0ffcb329 --- /dev/null +++ b/capsule-docs/scripts/check-roadmap.test.mjs @@ -0,0 +1,515 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + checkRoadmap, + citedSlices, + declaredPackages, + definedStates, + miseTasks, + reportRoadmap, + sliceIds, + tables, +} from './check-roadmap.mjs'; + +let root; + +function repo(files) { + root = mkdtempSync(join(tmpdir(), 'capsule-roadmap-')); + for (const [rel, contents] of Object.entries(files)) { + const abs = join(root, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, contents); + } + return root; +} + +afterEach(() => { + if (root) rmSync(root, { recursive: true, force: true }); + root = undefined; +}); + +const STATES = `## States + +- \`frozen\` — ships, contract settled. +- \`stabilizing\` — live and gated. +- \`rebuilding\` — quarantined and being re-landed. +- \`blocked\` — a named dependency gates it. +- \`deferred\` — deliberately unscheduled. +- \`review-only\` — reference material. +- \`excluded\` — outside the shipped build. +`; + +const HEADER = + '| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Notes |\n' + + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |'; + +/** A `ROADMAP.md` whose package table is exactly `rows`. */ +function roadmap(rows) { + return `# Roadmap\n\n${STATES}\n## Packages\n\n${HEADER}\n${rows.join('\n')}\n`; +} + +/** One well-formed row for a cargo package with no open slices. */ +function row(pkg, overrides = {}) { + const cell = { + kind: 'cargo', + owns: 'things', + state: 'stabilizing', + gate: '`mise run check-rust`', + docs: '[d](d.md)', + open: '—', + next: 'later', + notes: '—', + ...overrides, + }; + return `| \`${pkg}\` | ${cell.kind} | ${cell.owns} | ${cell.state} | ${cell.gate} | ${cell.docs} | ${cell.open} | ${cell.next} | ${cell.notes} |`; +} + +/** The minimum tree the check reads besides `ROADMAP.md`. */ +const BASE = { + 'Cargo.toml': '[workspace]\nmembers = [\n "alpha",\n]\n', + 'mise.toml': '[tasks.check-rust]\nrun = "true"\n', + 'SLICES.md': '### S-A1 — a slice\n\n### S-B2 — another slice\n', +}; + +describe('tables', () => { + it('reads a table into a header and rows carrying their line numbers', () => { + const [table] = tables( + 'intro\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n', + ); + expect(table.header).toEqual(['A', 'B']); + expect(table.rows).toEqual([{ cells: ['1', '2'], line: 5 }]); + }); + + it('separates two tables rather than running them together', () => { + const found = tables( + '| A |\n| --- |\n| 1 |\n\n| B |\n| --- |\n| 2 |\n', + ); + expect(found.map((t) => t.header)).toEqual([['A'], ['B']]); + }); + + it('ignores a pipe line that no separator follows', () => { + expect(tables('| not a table |\nprose\n')).toEqual([]); + }); +}); + +describe('definedStates', () => { + it('reads the vocabulary under the States heading, in order', () => { + expect(definedStates(`# R\n\n${STATES}\n## Packages\n`)).toEqual([ + 'frozen', + 'stabilizing', + 'rebuilding', + 'blocked', + 'deferred', + 'review-only', + 'excluded', + ]); + }); + + it('stops at the next section, so a later bullet is not a state', () => { + const source = `${STATES}\n## Packages\n\n- \`sneaky\` — not a state.\n`; + expect(definedStates(source)).not.toContain('sneaky'); + }); + + it('returns nothing when the document defines no vocabulary', () => { + expect(definedStates('# Roadmap\n\n## Packages\n')).toEqual([]); + }); +}); + +describe('declaredPackages', () => { + it('reads workspace members and skips default-members', () => { + const r = repo({ + 'Cargo.toml': + '[workspace]\nmembers = [\n "alpha",\n # "commented",\n "beta/gamma",\n]\ndefault-members = [\n "alpha",\n]\n', + }); + expect([...declaredPackages(r)]).toEqual([ + ['alpha', 'cargo'], + ['beta/gamma', 'cargo'], + ]); + }); + + it('resolves a gradle include to the directory project() names', () => { + const r = repo({ + 'settings.gradle.kts': + 'include(":android")\nproject(":android").projectDir = file("capsule-android")\n', + }); + expect(declaredPackages(r).get('capsule-android')).toBe('gradle'); + }); + + it('reads every path in a variadic gradle include, and tolerates a space', () => { + // Kotlin's `include` is variadic and the DSL allows `include (…)`. Matching only + // `include(":x")` read the file this repository happens to have. + const r = repo({ + 'settings.gradle.kts': + 'include(":android", ":core")\n' + + 'project(":android").projectDir = file("capsule-android")\n' + + 'project(":core").projectDir = file("capsule-core-kotlin")\n' + + 'include (":desktop")\n' + + 'project(":desktop").projectDir = file("capsule-desktop")\n', + }); + expect([...declaredPackages(r).keys()]).toEqual([ + 'capsule-android', + 'capsule-core-kotlin', + 'capsule-desktop', + ]); + }); + + it('does not see a commented-out gradle include', () => { + // `settings.gradle.kts` keeps `:cli`/`:desktop` commented out; a row for + // either would then be an orphan, which is the finding to avoid. + const r = repo({ + 'settings.gradle.kts': + '// include(":desktop")\n// project(":desktop").projectDir = file("capsule-desktop")\n', + }); + expect(declaredPackages(r).size).toBe(0); + }); + + it('reads tuist module targets across line breaks, plus the app target', () => { + const r = repo({ + 'capsule-swift/Project.swift': + 'private func module(\n _ name: String\n) -> [Target] { [] }\n' + + 'let moduleTargets: [Target] = module("CapsuleFoundation")\n' + + ' + module(\n "FeatureAlbums",\n dependencies: []\n )\n' + + 'private let appTarget: Target = .target(\n name: "Capsule",\n product: .app\n)\n', + }); + expect([...declaredPackages(r).keys()]).toEqual([ + 'CapsuleFoundation', + 'FeatureAlbums', + 'Capsule', + ]); + }); + + it('reads a conditional tuist target, which therefore needs a row', () => { + const r = repo({ + 'capsule-swift/Project.swift': + 'let t = ffiEnabled\n ? module(\n "CapsuleCatalogFFI"\n )\n : []\n', + }); + expect(declaredPackages(r).get('CapsuleCatalogFFI')).toBe('tuist'); + }); + + it('finds a package root one level down, named repo-relative', () => { + const r = repo({ + 'apps/viewer/package.json': '{}\n', + 'tools/lint/pyproject.toml': '[project]\n', + }); + const declared = declaredPackages(r); + expect(declared.get('apps/viewer')).toBe('bun'); + expect(declared.get('tools/lint')).toBe('python'); + }); + + it('does not treat a sub-manifest inside a package root as a second package', () => { + // A bun package legitimately carries nested `package.json` files; only the root + // one is the package. + const r = repo({ + 'capsule-web/package.json': '{}\n', + 'capsule-web/vendor/package.json': '{}\n', + }); + expect([...declaredPackages(r).keys()]).toEqual(['capsule-web']); + }); + + it('never descends into a pruned or hidden directory', () => { + const r = repo({ + 'node_modules/left-pad/package.json': '{}\n', + 'target/debug/package.json': '{}\n', + '.cache/x/package.json': '{}\n', + }); + expect(declaredPackages(r).size).toBe(0); + }); + + it('classifies a package root by the manifest it carries', () => { + const r = repo({ + 'capsule-core-swift/Package.swift': '// swift-tools-version:6.0\n', + 'capsule-web/package.json': '{}\n', + 'capsule-vision/pyproject.toml': '[project]\n', + 'locales/en.json': '{}\n', + '.gitmodules': '[submodule "rawshift"]\n\tpath = rawshift\n', + 'legacy-review/server-salvo/REVIEW.md': '', + }); + const declared = declaredPackages(r); + expect(declared.get('capsule-core-swift')).toBe('swiftpm'); + expect(declared.get('capsule-web')).toBe('bun'); + expect(declared.get('capsule-vision')).toBe('python'); + expect(declared.get('locales')).toBe('catalog'); + expect(declared.get('rawshift')).toBe('submodule'); + expect(declared.get('legacy-review/server-salvo')).toBe( + 'review-bucket', + ); + }); +}); + +describe('miseTasks and sliceIds', () => { + it('reads tasks from both manifests and from the file-task directory', () => { + const r = repo({ + 'mise.toml': '[tasks.check-rust]\nrun = "true"\n', + 'capsule-swift/mise.toml': '[tasks.generate]\nrun = "true"\n', + 'mise-tasks/check-swift': '#!/usr/bin/env bash\n', + }); + expect([...miseTasks(r)].sort()).toEqual([ + 'check-rust', + 'check-swift', + 'generate', + ]); + }); + + it('reads a quoted task header and a task in a file-task subdirectory', () => { + // Both are mise spellings this repository does not use yet. Missing either would + // fail a correct row, which is the worse of the two ways for a gate to be wrong. + const r = repo({ + 'mise.toml': '[tasks."docs:build"]\nrun = "true"\n', + 'mise-tasks/docs/publish': '#!/usr/bin/env bash\n', + }); + expect([...miseTasks(r)].sort()).toEqual([ + 'docs:build', + 'docs:publish', + ]); + }); + + it('reads slice ids from detail headings only', () => { + const r = repo({ + 'SLICES.md': + '| S-Z9 | an index row | | | | | |\n\n### S-A1 — a slice\n\n#### S-A2 — not a detail block\n', + }); + expect([...sliceIds(r)]).toEqual(['S-A1']); + }); +}); + +describe('citedSlices', () => { + it('reports every backticked id with its line, wherever it sits', () => { + expect( + citedSlices('prose `S-A1`\n| a | `S-B2`, `S-B3` |\nno ids here\n'), + ).toEqual([ + { id: 'S-A1', line: 1 }, + { id: 'S-B2', line: 2 }, + { id: 'S-B3', line: 2 }, + ]); + }); + + it('ignores an unbackticked mention, which is prose and not a citation', () => { + expect(citedSlices('see S-A1 for this\n')).toEqual([]); + }); +}); + +describe('checkRoadmap', () => { + it('passes a roadmap whose rows all resolve', () => { + const r = repo({ ...BASE, 'ROADMAP.md': roadmap([row('alpha')]) }); + const result = checkRoadmap(r); + expect(result.findings).toEqual([]); + expect(result.checked).toBe(1); + expect(reportRoadmap(result)).toContain('1 package(s) checked'); + }); + + it('fails when a declared package has no row', () => { + const r = repo({ + ...BASE, + 'Cargo.toml': + '[workspace]\nmembers = [\n "alpha",\n "beta",\n]\n', + 'ROADMAP.md': roadmap([row('alpha')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md cargo package `beta` has no row', + ]); + }); + + it('fails on a row naming nothing in the tree', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha'), row('ghost')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:18 ghost is not declared by any manifest in the tree', + ]); + }); + + it('fails when a row claims the wrong kind', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { kind: 'bun' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'alpha is declared as `cargo`, the row says `bun`', + ); + }); + + it('fails on a state outside the defined vocabulary', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { state: 'nearly-done' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'state `nearly-done` is not one of', + ); + }); + + it('fails on a slice id with no detail block', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: '`S-A1`, `S-Z9`' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('fails on a stale citation in Notes, not only in Open slices', () => { + // Restricting resolution to one column is what let four stale citations through. + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { notes: 'blocked behind `S-Z9`' }), + ]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('fails on a stale citation in the deferred register below the table', () => { + const register = + '| Item | Owner docs | State | Notes |\n| --- | --- | --- |\n| a thing | [d](d.md) | deferred | `S-Z9` |'; + const r = repo({ + ...BASE, + 'ROADMAP.md': `${roadmap([row('alpha')])}\n${register}\n`, + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:21 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('reports a stale id once, not once per check', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: '`S-Z9`' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `S-Z9` has no detail block in SLICES.md', + ]); + }); + + it('fails on a slice cell that is not an id at all', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { open: 'lane B' })]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `lane B` is not a slice id', + ]); + }); + + it('fails on a gate naming a task the repository does not have', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { gate: '`mise run check-moon`' }), + ]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 `mise run check-moon` is not a task in this repository', + ]); + }); + + it('fails on a gate that is not a mise invocation', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', { gate: '`cargo test`' })]), + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'gate `cargo test` is not `mise run `', + ); + }); + + it('lets a review-only or excluded row leave the gate empty, and no other', () => { + const gateless = { gate: '—' }; + const ok = repo({ + ...BASE, + 'ROADMAP.md': roadmap([ + row('alpha', { ...gateless, state: 'review-only' }), + ]), + }); + expect(checkRoadmap(ok).findings).toEqual([]); + rmSync(root, { recursive: true, force: true }); + + const bad = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha', gateless)]), + }); + expect(checkRoadmap(bad).findings[0]).toContain( + 'may leave `Gate` empty', + ); + }); + + it('fails on a row with the wrong number of columns', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap(['| `alpha` | cargo | things |']), + }); + // A malformed row is skipped whole, so the package it meant to cover is + // also reported as unrowed. Both findings point at the same repair. + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:17 3 column(s), expected 9', + 'ROADMAP.md cargo package `alpha` has no row', + ]); + }); + + it('fails on a duplicated package row', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': roadmap([row('alpha'), row('alpha')]), + }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md:18 alpha has more than one row', + ]); + }); + + it('fails when the header is not the nine agreed columns', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': `# R\n\n${STATES}\n| Package | Kind | Owns | State | Gate | Owner docs | Open slices | Next milestone | Remarks |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${row('alpha')}\n`, + }); + expect(checkRoadmap(r).findings[0]).toContain( + 'expected `Package | Kind', + ); + }); + + it('reports an unused state without failing, and counts a second table', () => { + // Forcing every defined state into use would push whoever edits the file + // into a dishonest assignment, so this is a report, not a finding. + const r = repo({ ...BASE, 'ROADMAP.md': roadmap([row('alpha')]) }); + const result = checkRoadmap(r); + expect(result.findings).toEqual([]); + expect(result.unused).toContain('deferred'); + expect(reportRoadmap(result)).toContain('States defined and unused'); + }); + + it('counts a state used only by the deferred register', () => { + const register = + '| Item | Owner docs | State | Notes |\n| --- | --- | --- | --- |\n| a thing | [d](d.md) | deferred | later |'; + const r = repo({ + ...BASE, + 'ROADMAP.md': `${roadmap([row('alpha')])}\n${register}\n`, + }); + expect(checkRoadmap(r).unused).not.toContain('deferred'); + }); + + it('fails when the file is missing entirely', () => { + const r = repo(BASE); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md the package view is missing', + ]); + }); + + it('fails when the file carries no package table', () => { + const r = repo({ ...BASE, 'ROADMAP.md': `# R\n\n${STATES}\n` }); + expect(checkRoadmap(r).findings).toEqual([ + 'ROADMAP.md no package table (its first column is `Package`)', + ]); + }); + + it('fails when the file defines no state vocabulary', () => { + const r = repo({ + ...BASE, + 'ROADMAP.md': `# R\n\n## Packages\n\n${HEADER}\n${row('alpha')}\n`, + }); + expect(checkRoadmap(r).findings[0]).toContain('no state vocabulary'); + }); +}); diff --git a/capsule-docs/scripts/docs-truth.mjs b/capsule-docs/scripts/docs-truth.mjs index 7e15a592..33dff816 100644 --- a/capsule-docs/scripts/docs-truth.mjs +++ b/capsule-docs/scripts/docs-truth.mjs @@ -24,6 +24,11 @@ * toolchain: that rule governs regenerating an artifact, and these checks * cross-reference committed text against committed text. * + * That last claim is what excludes the generated `/reference/` pages, which + * `scripts/lib/walk.mjs` prunes by path: they are gitignored build output, so + * they exist on a machine that has built the site and on no CI runner, and a + * check that read them would answer differently in the two places. + * * Usage: `bun capsule-docs/scripts/docs-truth.mjs` from the repository root, * or `mise run check-docs-truth`. */ @@ -33,9 +38,15 @@ import { fileURLToPath } from 'node:url'; import { crossLinksCheck } from './check-cross-links.mjs'; import { endpointCensusCheck } from './check-endpoint-census.mjs'; import { modulePathsCheck } from './check-module-paths.mjs'; +import { roadmapCheck } from './check-roadmap.mjs'; /** Registered checks, run in order. Adding one is a single entry here. */ -const CHECKS = [crossLinksCheck, endpointCensusCheck, modulePathsCheck]; +const CHECKS = [ + crossLinksCheck, + endpointCensusCheck, + modulePathsCheck, + roadmapCheck, +]; function main() { // scripts/ -> capsule-docs/ -> repo root diff --git a/capsule-docs/scripts/gen-reference.mjs b/capsule-docs/scripts/gen-reference.mjs new file mode 100644 index 00000000..e20a987e --- /dev/null +++ b/capsule-docs/scripts/gen-reference.mjs @@ -0,0 +1,1230 @@ +#!/usr/bin/env node + +/** + * gen-reference — emit `/reference/` from the committed description artifacts. + * + * `design/developer-docs.md` fixes the boundary at *artifacts, not toolchains*: the CI + * `docs` job installs bun and nothing else, so this script may never shell out to cargo. It + * reads committed JSON and writes Markdown, which is the whole of its contract. + * + * The pages it writes are ordinary content-collection entries under + * `src/content/docs/reference/`, which is what buys the rest for free — Pagefind indexes + * them, `starlight-links-validator` checks their links, the `PageTitle` override renders + * their status badge, and the notranslate rehype pass marks up their technical terms. A + * mounted OpenAPI application would have had none of that. + * + * They are also **gitignored**, and that is the point of rule 2: a generated page is never + * edited, so committing one only creates a copy that can disagree with its source. Fix the + * clap `about` or the schema description and regenerate. + * + * Six failure modes are deliberately fatal rather than degraded, because + * `developer-docs.md` calls a stale reference page worse than a missing one — a missing page + * is obvious and a wrong one is believed: + * + * 1. an artifact is absent or unparseable — exit naming the path; + * 2. an artifact's `schema` is one this script was not written against — exit rather than + * render a half-understood document; + * 3. an operation matches no group in `reference-groups.mjs` — exit naming it, so a new + * endpoint family cannot publish unlisted; + * 4. a request or response carrier offers **more than one media type** — this renderer + * shows one body per carrier, so a second would be dropped silently and the page would + * claim an endpoint accepts only JSON when it also accepts CBOR; + * 5. a schema composes with **`oneOf`, `allOf`, or `anyOf`** — this renderer flattens a + * schema to a property table, which cannot express a union or an intersection, and + * would render one as an empty or a half-true model; + * 6. artifact prose carries a **setext heading** — see [`demoteHeadings`]. + * + * The last three are unreachable on the committed document today. They are fatal rather + * than deferred because each is a case where the renderer's *silent* answer is a confident + * lie, and a build failure naming the operation is how the first one to appear gets + * handled instead of shipped. + * + * Usage: `bun capsule-docs/scripts/gen-reference.mjs` from anywhere; `package.json` runs it + * before `astro dev` and `astro build`. + */ + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { API_GROUPS, CLI_PAGES, groupForPath } from './reference-groups.mjs'; + +/** Repo-relative path of the committed command-tree artifact. */ +export const CLI_SURFACE = 'capsule-cli/cli-surface.json'; + +/** Command-tree schema version this script understands. */ +const CLI_SCHEMA = 1; + +/** Repo-relative path of the committed Kynos OpenAPI document. */ +export const OPENAPI_DOCUMENT = 'capsule-server/openapi.json'; + +/** + * OpenAPI major.minor this generator renders. + * + * Pinned rather than accepted loosely because `AGENTS.md` requires the served document to be + * 3.2 and forbids emitting a 3.1 or 3.0 one: a document that arrived at 3.1 would mean the + * emitter regressed, and rendering it anyway would publish the regression as documentation. + */ +const OPENAPI_VERSION = '3.2'; + +/** Repo-relative directory the generated CLI pages are written to. */ +const CLI_OUT = 'capsule-docs/src/content/docs/reference/cli'; + +/** Repo-relative directory the generated REST pages are written to. */ +const API_OUT = 'capsule-docs/src/content/docs/reference/api'; + +/** HTTP methods a path item may carry. Anything else in a path item is not an operation. */ +const METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]; + +/** + * How deep a `$ref` chain is followed before deeper models are named but not expanded. + * + * Set above the committed document's needs, not at them. The deepest chain any group + * reaches is 3 — `SyncPageResponse` → `SyncEntry` → `SyncBlobRef` → `WireBlobRole`, on + * `/reference/api/sync/` — so at the previous value of 2 that enum was named on the page + * and defined nowhere. Four documents the complete closure of every group today with a + * level of headroom; the measured cost of the whole closure over the bounded walk is one + * additional schema across all eleven pages. + * + * A model reachable only deeper than this is still *named* on the page, as bare code rather + * than a link, so the bound degrades to "less detail" and never to a link that goes nowhere. + * That is why exceeding it is not fatal, unlike the cases in the module header. + */ +const MAX_SCHEMA_DEPTH = 4; + +/** The banner every generated page carries, as an HTML comment and as prose. */ +const GENERATED_BY = 'capsule-docs/scripts/gen-reference.mjs'; + +/** A line that opens a fenced block: ``` or ~~~ with any info string. */ +const FENCE_OPEN = /^\s*(`{3,}|~{3,})/; + +/** A line that *closes* one: the same run with nothing after it but whitespace. */ +const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/; + +/** + * A setext underline: a run of `=` or `-` alone on its line. + * + * **One character is enough.** CommonMark's `setext heading underline` is `= +` or `- +`, + * not two or more, so `Title` over a lone `-` is an h2 exactly as `Title` over `---` is. + * Requiring two let a single-character underline through the check that exists to catch it, + * which shipped an undemoted heading into a page body — the defect, arriving through the + * guard against the defect. + * + * Up to three leading spaces, and trailing whitespace, both per the spec. + */ +const SETEXT_UNDERLINE = /^\s{0,3}(={1,}|-{1,})\s*$/; + +/** + * Shift every ATX heading in `markdown` down by `offset` levels, clamped at h6. + * + * Description prose in both artifacts is written for its own context and carries its own + * headings — `openapi.json` has operation descriptions opening at `#`. Interpolated + * unchanged, such a heading becomes a second h1 on a page whose h1 is the Starlight title, + * which breaks the document outline and the on-page table of contents. + * + * Fenced blocks are skipped: a `#` on the first column of a shell example is a comment, not + * a heading, and demoting it would corrupt the example. + * + * **ATX only, and a setext heading is fatal.** Rewriting a line based on the line below it + * is a different and more fragile transformation than prefixing hashes — a `---` under a + * paragraph is a thematic break, and over one it is frontmatter — so this function does not + * attempt it. Silently leaving one alone is worse than not supporting it: an `=====` + * underline in an operation description would put an undemoted h1 in the page body, which + * is the exact defect demotion exists to prevent, and it would publish looking fine. + * Neither committed artifact uses one today (verified across all 971 descriptions in the + * OpenAPI document), so the first one to appear stops the build and gets ATX in its doc + * comment. + * + * @param {string} markdown Prose that may contain headings. + * @param {number} offset Levels to add. + * @returns {string} The prose with its headings demoted. + */ +export function demoteHeadings(markdown, offset) { + let fence = null; + /** @type {string[]} */ + const lines = []; + /** Indices of lines that sat inside a fenced block, which is code, not prose. */ + const fenced = new Set(); + markdown.split('\n').forEach((line) => { + const fenceMatch = FENCE_OPEN.exec(line); + if (fence === null) { + if (fenceMatch) { + fence = { + char: fenceMatch[1][0], + length: fenceMatch[1].length, + }; + lines.push(line); + fenced.add(lines.length - 1); + return; + } + } else { + // A *closing* fence carries no info string. Without that anchor a + // ```` ```js ```` line nested inside a ```` ```sh ```` example closes the + // block early, which both demotes the `#` comments inside the example and + // leaves every real heading after it untouched. + const closer = FENCE_CLOSE.exec(line); + if ( + closer && + closer[1][0] === fence.char && + closer[1].length >= fence.length + ) { + fence = null; + } + // Inside a fence, and pushed with a marker the setext scan below reads as + // "not prose": an `=====` in a code example is code. + lines.push(line); + fenced.add(lines.length - 1); + return; + } + const heading = /^(#{1,6})(\s)/.exec(line); + if (!heading) { + lines.push(line); + return; + } + const level = Math.min(6, heading[1].length + offset); + lines.push('#'.repeat(level) + line.slice(heading[1].length)); + }); + + // Checked after the pass so the fence state above decides what is prose. A setext + // underline is a run of `=` or `-` alone on a line, directly under a non-blank one that + // is not itself a heading, a list item, or a table row. + for (let i = 1; i < lines.length; i += 1) { + if (fenced.has(i) || fenced.has(i - 1)) continue; + if (!SETEXT_UNDERLINE.test(lines[i])) continue; + const above = lines[i - 1]; + if (above.trim() === '') continue; + if (/^\s*(#{1,6}\s|[-*+>|]|\d+[.)]\s)/.test(above)) continue; + throw new Error( + `artifact prose carries a setext heading ("${above.trim()}" underlined with ` + + `"${lines[i].trim()}"). This generator demotes ATX headings only, and an ` + + 'undemoted heading in a page body is the defect demotion exists to prevent. ' + + 'Rewrite it as an ATX heading (`## Title`) in the doc comment it comes from.', + ); + } + + return lines.join('\n'); +} + +/** Where the site's content lives, for turning a repo path into a route. */ +const SITE_CONTENT = 'capsule-docs/src/content/docs'; + +/** + * Rewrite links inside artifact prose so they mean the same thing on the site. + * + * The prose in both artifacts is written in its own context — a Rust doc comment or a `clap` + * annotation — and is republished here in another. Two link forms travel badly, and both are + * live in the committed OpenAPI document: + * + * 1. **A repo-relative path to a design document.** `[chunk contract](../../../capsule-docs/…/upload-protocol.md)` + * resolves from the crate source and from nowhere on the site. It has an exact + * equivalent — the Starlight route the same file serves — so it is rewritten, not + * dropped: the reader keeps the reference. + * 2. **A rustdoc intra-doc link.** ``[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes)`` + * is a path rustdoc resolves and no web server does. There is no equivalent, so the + * link is dropped and its text kept. + * + * Absolute URLs, site routes, and anchors are left alone. + * + * The alternative was to fix the annotations in `capsule-server`, which rule 2 would normally + * demand. It is the wrong fix here: those links are correct for rustdoc, which is also a + * published surface, and "correct in the crate, wrong on the site" is a property of + * republishing rather than an error in the source. + * + * @param {string} markdown Prose from an artifact. + * @returns {string} The prose with its links made meaningful on the site. + */ +function rewriteLinks(markdown) { + return markdown.replace( + /(!?\[)([^\]]*)(\]\(\s*)([^)\s]+)(\s*\))/g, + (whole, open, text, mid, target, close) => { + if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(target)) return whole; + const normalized = target.replace(/^(?:\.\.\/)+/, ''); + if ( + normalized.startsWith(`${SITE_CONTENT}/`) && + normalized.endsWith('.md') + ) { + const route = normalized + .slice(`${SITE_CONTENT}/`.length) + .replace(/(?:\/index)?\.md$/, ''); + return `${open}${text}${mid}/${route}/${close}`; + } + // No equivalent: keep the words, drop the link. + return text; + }, + ); +} + +/** + * Prepare artifact prose for a one-line context: rewrite its links, flatten it to a single + * line. Deliberately does **not** escape — see [`escapeCell`]. + * + * @param {string} text + * @returns {string} + */ +function prose(text) { + return rewriteLinks(text) + .replace(/\s*\n\s*/g, ' ') + .trim(); +} + +/** + * Escape one **finished** table cell — after every fragment that composes it has been + * assembled, never fragment by fragment. + * + * That ordering is the whole point. A cell is built from several sources — the help text, + * then `Values: …`, `Default: …`, `Example: …` appended after it — and escaping only the + * first leaves the others raw. A default of `a|b` then opens a column of its own and shifts + * every cell to its right, which is exactly the defect this function exists to prevent and + * exactly the one a per-fragment escape reintroduces. + * + * Two characters are escaped, and only these two: + * + * - `|`, which opens a column. GFM requires the escape inside a code span too, and renders + * it as a bare pipe, so `` `a\|b` `` shows the pipe the artifact meant. + * - `<`, because Markdown passes raw HTML through. `` — the most likely idiom in help + * text for a command line — parses as a tag and vanishes, taking everything up to the + * next `>` with it if it never closes. + * + * **Artifact prose is trusted Markdown.** It comes from Rust doc comments and `clap` + * annotations in this repository, reviewed like any other source, so emphasis, links, and + * inline code in it are the author's intent and are passed through rather than sanitized. + * These two escapes are not a security boundary; they repair characters whose meaning + * *changes* when prose written for a doc comment is republished inside a table. An unclosed + * `<` outside a code span in a doc comment is the author's bug, and it is fixed in the doc + * comment. + * + * Never apply this to generator-authored markup: the `
` in a response body cell is + * markup this file wrote and means to keep. + * + * @param {string} text A fully assembled cell. + * @returns {string} + */ +function escapeCell(text) { + return text.replace(/\|/g, '\\|').replace(/} + */ +export function readCliSurface(root) { + const surface = readArtifact(root, CLI_SURFACE); + if (surface?.schema !== CLI_SCHEMA) { + throw new Error( + `${CLI_SURFACE} declares schema ${surface?.schema}, and this generator was ` + + `written against schema ${CLI_SCHEMA}. Update ${GENERATED_BY} rather than ` + + 'rendering a document it does not understand.', + ); + } + if (typeof surface.name !== 'string' || surface.name === '') { + throw new Error( + `${CLI_SURFACE} carries no command name. An emitter regression producing a ` + + 'well-formed but empty document would otherwise publish a page with an empty ' + + 'heading and a stable badge — the confidently-wrong page this generator ' + + 'exists to make impossible.', + ); + } + if ( + !Array.isArray(surface.subcommands) || + surface.subcommands.length === 0 + ) { + throw new Error( + `${CLI_SURFACE} describes no subcommands. \`capsule\` has several; a document ` + + 'saying otherwise is a regression in the emitter, not a CLI that shrank.', + ); + } + return surface; +} + +/** + * How an argument is spelled on the command line. + * + * A positional is written as its placeholder, an option by its flags. Only a value-taking + * option gets a placeholder — the description artifact suppresses clap's synthesized one + * for flags, and inventing `--force ` here would document a surface that rejects it. + * + * @param {Record} arg + * @returns {string} + */ +function spell(arg) { + const placeholder = `<${(arg.value_names ?? [arg.id.toUpperCase()]).join('> <')}>`; + if (arg.positional) { + return `${placeholder}${arg.repeatable ? '...' : ''}`; + } + const flags = []; + if (arg.short) flags.push(`-${arg.short}`); + if (arg.long) flags.push(`--${arg.long}`); + const spelled = flags.length > 0 ? flags.join(', ') : arg.id; + return arg.takes_value ? `${spelled} ${placeholder}` : spelled; +} + +/** + * Terminate a sentence that does not terminate itself. + * + * `clap` strips the full stop off a doc comment, so help text arrives unpunctuated and the + * facts appended after it ("Repeatable.", "Values: …") would run straight on from the last + * word of the description. + * + * @param {string} text + * @returns {string} + */ +function sentence(text) { + return /[.!?:]$/.test(text) ? text : `${text}.`; +} + +/** + * One argument's description cell: whether it is required and repeatable, what it does, + * what it accepts, and what it defaults to. + * + * @param {Record} arg + * @returns {string} + */ +function describeArg(arg) { + const parts = []; + if (arg.required) parts.push('**Required.**'); + const help = arg.long_help ?? arg.help; + if (help) parts.push(sentence(prose(help))); + // Not appended when the help already says it, or `capsule cull --pick` reads + // "Flag an asset as a keeper (repeatable). Repeatable." + if (arg.repeatable && !/repeatable/i.test(help ?? '')) { + parts.push('Repeatable.'); + } + if (arg.possible_values?.length) { + parts.push( + `Values: ${arg.possible_values.map((value) => `\`${value.name}\``).join(', ')}.`, + ); + } + if (arg.default_values?.length) { + parts.push( + `Default: ${arg.default_values.map((value) => `\`${value}\``).join(', ')}.`, + ); + } + // One pass, over the assembled cell: a `|` in a default or an enumerated value is as + // capable of opening a column as one in the help text. + return escapeCell(parts.join(' ')) || '—'; +} + +/** + * Collapse runs of blank lines outside fenced blocks, and trim the trailing one. + * + * Section assembly leaves a blank run wherever a table ended one block and a heading opened + * the next. Markdown does not care; a reader diffing two generations does, and so does + * markdownlint on any machine that has built the site. Applied outside fences only, because + * a blank run *inside* an example is part of the example — a whole-document regex would have + * the generator quietly editing the code it is quoting. + * + * @param {string} markdown + * @returns {string} + */ +function tidyBlankLines(markdown) { + let fence = null; + const out = []; + for (const line of markdown.split('\n')) { + if (fence === null) { + const open = FENCE_OPEN.exec(line); + if (open) { + fence = { char: open[1][0], length: open[1].length }; + } else if ( + line.trim() === '' && + out.length > 0 && + out[out.length - 1].trim() === '' + ) { + continue; + } + } else { + const closer = FENCE_CLOSE.exec(line); + if ( + closer && + closer[1][0] === fence.char && + closer[1].length >= fence.length + ) { + fence = null; + } + } + out.push(line); + } + return out.join('\n').trimEnd(); +} + +/** + * A Markdown table, or the empty string when there are no rows — an empty table renders as + * a stray header and says nothing. + * + * @param {string[]} headers + * @param {string[][]} rows + * @returns {string} + */ +function table(headers, rows) { + if (rows.length === 0) return ''; + const lines = [ + `| ${headers.join(' | ')} |`, + `| ${headers.map(() => '---').join(' | ')} |`, + ...rows.map((row) => `| ${row.join(' | ')} |`), + ]; + return `${lines.join('\n')}\n`; +} + +/** + * Render one command and, recursively, its subcommands. + * + * @param {Record} command + * @param {string[]} path Command words leading here, including this command's own name. + * @param {number} level Heading level for this command (2 under the Starlight title). + * @returns {string} + */ +function renderCommand(command, path, level) { + const invocation = path.join(' '); + const args = command.args ?? []; + const positionals = args.filter((arg) => arg.positional); + const options = args.filter((arg) => !arg.positional); + const subcommands = command.subcommands ?? []; + + const usage = [invocation]; + for (const arg of positionals) { + const spelled = spell(arg); + usage.push(arg.required ? spelled : `[${spelled}]`); + } + // Required options are spelled out rather than folded into `[OPTIONS]`. `capsule import` + // requires `--library `, and a usage line that hides it hands the reader a command + // that fails to parse — a reference page that is wrong, which is the one thing this + // pipeline exists to prevent. + for (const option of options.filter((candidate) => candidate.required)) { + usage.push(spell(option)); + } + if (options.some((option) => !option.required)) usage.push('[OPTIONS]'); + if (subcommands.length > 0) usage.push(''); + + const sections = [ + `${'#'.repeat(level)} ${invocation}`, + '', + `\`\`\`text\n${usage.join(' ')}\n\`\`\``, + '', + ]; + + // Demoted relative to this command's own heading, so a doc comment that opens at `#` + // nests under the command it describes instead of outranking it. + const about = command.long_about ?? command.about; + if (about) { + sections.push(demoteHeadings(rewriteLinks(about), level), ''); + } + + const positionalTable = table( + ['Argument', 'Description'], + positionals.map((arg) => [`\`${spell(arg)}\``, describeArg(arg)]), + ); + if (positionalTable) sections.push(positionalTable); + + const optionTable = table( + ['Option', 'Description'], + options.map((arg) => [`\`${spell(arg)}\``, describeArg(arg)]), + ); + if (optionTable) sections.push(optionTable); + + if (subcommands.length > 0) { + sections.push( + table( + ['Command', 'Description'], + subcommands.map((subcommand) => [ + `[\`${[...path, subcommand.name].join(' ')}\`](#${[...path, subcommand.name].join('-')})`, + subcommand.about ? cell(subcommand.about) : '—', + ]), + ), + ); + } + + return [ + sections.filter((section) => section !== '').join('\n\n'), + ...subcommands.map((subcommand) => + renderCommand( + subcommand, + [...path, subcommand.name], + Math.min(6, level + 1), + ), + ), + ].join('\n\n'); +} + +/** + * The generated `/reference/cli/commands/` page. + * + * @param {Record} surface The parsed command tree. + * @returns {string} Markdown, frontmatter included. + */ +export function renderCliPage(surface) { + const page = CLI_PAGES[0]; + return `${[ + '---', + `title: ${yamlString(page.label)}`, + `description: ${yamlString(page.description)}`, + 'status: stable', + '---', + '', + ``, + '', + `Generated from \`${CLI_SURFACE}\`, the committed command tree \`capsule-cli\` emits and`, + '`mise run cli-surface-check` keeps current. To change a description on this page, change', + 'the `clap` annotation it comes from and regenerate — this file is build output.', + '', + tidyBlankLines(renderCommand(surface, [surface.name], 2)), + ].join('\n')}\n`; +} + +/** + * The committed Kynos OpenAPI document. + * + * @param {string} root Repository root. + * @returns {Record} + */ +export function readOpenApiDocument(root) { + const document = readArtifact(root, OPENAPI_DOCUMENT); + const version = String(document?.openapi ?? ''); + if (!version.startsWith(`${OPENAPI_VERSION}.`)) { + throw new Error( + `${OPENAPI_DOCUMENT} declares OpenAPI ${version || '(nothing)'}, and this ` + + `generator renders ${OPENAPI_VERSION}. The served document is pinned to ` + + `${OPENAPI_VERSION} with \`openapi_as(SpecVersion::V3_2)\`; a lower version ` + + 'means the emitter regressed, and rendering it would publish the regression.', + ); + } + if (!document.paths || typeof document.paths !== 'object') { + throw new Error(`${OPENAPI_DOCUMENT} declares no paths.`); + } + assertRenderable(document); + return document; +} + +/** Schema keywords this renderer cannot express. */ +const COMPOSITION_KEYWORDS = ['oneOf', 'allOf', 'anyOf']; + +/** + * Fail on anything in the document this renderer would answer wrongly rather than not at + * all. See the fatal list in the module header for why each is fatal. + * + * @param {Record} document + * @throws {Error} naming the operation or the schema. + */ +function assertRenderable(document) { + for (const [path, item] of Object.entries(document.paths)) { + for (const method of METHODS) { + const operation = item?.[method]; + if (!operation) continue; + const at = `${method.toUpperCase()} ${path}`; + + const carriers = [ + ['request body', operation.requestBody], + ...Object.entries(operation.responses ?? {}).map( + ([status, response]) => [`response ${status}`, response], + ), + ]; + for (const [which, carrier] of carriers) { + const media = Object.keys(carrier?.content ?? {}); + if (media.length > 1) { + throw new Error( + `${at}: its ${which} offers ${media.length} media types ` + + `(${media.sort().join(', ')}), and this generator renders one body ` + + 'per carrier. Rendering it would document the endpoint as ' + + 'accepting only the first. Teach ' + + `${GENERATED_BY} to render every media type before the server ` + + 'starts offering a choice.', + ); + } + } + } + } + + for (const [name, schema] of Object.entries( + document.components?.schemas ?? {}, + )) { + assertNoComposition(name, name, schema); + } +} + +/** + * The one branch of a nullable union, or `null` when the schema is not one. + * + * OpenAPI 3.1 has no `nullable` keyword, so an optional *object* is spelled + * `anyOf: [{ $ref }, { type: 'null' }]` — which is what every `Option` over a struct + * emits, `AuthEndpointsResponse.oidc` being the first to reach this generator. That is a + * composition by the letter of the document and not by intent: there is exactly one real + * branch, so it flattens to a property table perfectly well once the null arm is dropped. + * + * Deliberately narrow. A union with two real branches still has no property table and is + * still refused by {@link assertNoComposition} — this recognises the nullable idiom only, + * which is the difference between rendering an `Option` and guessing at a sum type. + * + * @param {unknown} schema + * @returns {Record | null} The non-null branch, or `null`. + */ +function nullableBranch(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) + return null; + for (const word of ['anyOf', 'oneOf']) { + const branches = schema[word]; + if (!Array.isArray(branches)) continue; + const real = branches.filter((branch) => branch?.type !== 'null'); + if (real.length === 1 && real.length < branches.length) return real[0]; + } + return null; +} + +/** + * Refuse `oneOf`/`allOf`/`anyOf` anywhere in a named schema, not only at its root. + * + * Checking the root alone is the shape of the bug it was meant to prevent: a composition one + * level down, in `properties.` or in `items`, falls through `typeOf` to the string + * `object` and renders as a row claiming the field is a plain object. That is a confident + * lie about a union, which is exactly what this check exists to stop, and it passed. + * + * Only *inline* subschemas are walked. A `$ref` is not followed, because its target is a + * named schema that this scan reaches on its own pass — following it would report the same + * composition once per reference, under whichever name happened to be scanned first. + * + * @param {string} name The named schema this subtree belongs to. + * @param {string} at A readable path to the subschema, for the error. + * @param {unknown} schema The subschema. + * @throws {Error} naming the schema and the path within it. + */ +function assertNoComposition(name, at, schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return; + if (schema.$ref) return; + + // An optional object is a composition only by spelling; see `nullableBranch`. Walk into + // the real branch so a genuine union *inside* an optional is still caught. + const nullable = nullableBranch(schema); + if (nullable) { + assertNoComposition(name, at, nullable); + return; + } + + const composed = COMPOSITION_KEYWORDS.filter((word) => schema[word]); + if (composed.length > 0) { + throw new Error( + `schema ${name} composes with ${composed.join(' and ')} at ${at}, which this ` + + 'generator cannot express: it flattens a schema to a property table, ' + + 'and a union or an intersection is not a property table. It would ' + + `render as an empty or a half-true model. Teach ${GENERATED_BY} to ` + + 'render composition before the server starts emitting it.', + ); + } + + for (const [property, subschema] of Object.entries( + schema.properties ?? {}, + )) { + assertNoComposition(name, `${at}.${property}`, subschema); + } + assertNoComposition(name, `${at}[]`, schema.items); + // `additionalProperties` is a schema when it is an object, and `true`/`false` otherwise. + if (typeof schema.additionalProperties === 'object') { + assertNoComposition( + name, + `${at}.additionalProperties`, + schema.additionalProperties, + ); + } +} + +/** + * Bucket every operation in the document into its group, in a stable order. + * + * **Fails on an operation no group claims.** That is the whole value of a hand-curated + * table: a new endpoint family cannot publish under a heading nobody chose, and — the case + * that actually bites — cannot silently fail to publish at all while the build stays green. + * + * @param {Record} document The parsed OpenAPI document. + * @returns {Map, operationId?: string }>>} + * Keyed by group slug, in `API_GROUPS` order, with every group present. + */ +export function bucketOperations(document) { + /** @type {Map} */ + const buckets = new Map(API_GROUPS.map((group) => [group.slug, []])); + + // Sorted rather than taken in document order: JSON object order is an emitter detail, + // and a page whose sections reshuffle when the server's route registration is reordered + // produces a diff nobody can read. + for (const path of Object.keys(document.paths).sort()) { + const group = groupForPath(path); + const item = document.paths[path] ?? {}; + const methods = METHODS.filter((method) => item[method]); + if (!group) { + const named = methods + .map((method) => `${method.toUpperCase()} ${path}`) + .join(', '); + throw new Error( + `no reference group claims ${named || path}. Add its prefix to a group in ` + + 'capsule-docs/scripts/reference-groups.mjs — an endpoint family must not ' + + 'publish under a heading nobody chose, and must not silently fail to publish.', + ); + } + for (const method of methods) { + buckets.get(group.slug).push({ + path, + method: method.toUpperCase(), + operation: item[method], + operationId: item[method].operationId, + }); + } + } + return buckets; +} + +/** + * Render a schema's type as a short string: `string`, `string | null`, `integer[]`, or the + * name of a referenced schema. + * + * @param {Record} schema + * @returns {string} + */ +function typeOf(schema) { + if (!schema || typeof schema !== 'object') return 'unknown'; + if (schema.$ref) return refName(schema.$ref); + // `Option` over an object, spelled as a union with `null`. Rendered as the branch it + // actually carries, so a reader sees `OidcEndpointsResponse | null` rather than `object`. + const nullable = nullableBranch(schema); + if (nullable) return `${typeOf(nullable)} | null`; + if (schema.type === 'array') { + return `${typeOf(schema.items ?? {})}[]`; + } + const type = schema.type; + // OpenAPI 3.1 and later spell nullability as a type union rather than as `nullable`, so + // `type` is an array here and interpolating it directly yields `string,null`. + if (Array.isArray(type)) return type.join(' | '); + if (typeof type === 'string') return type; + if (schema.enum) return 'string'; + return 'object'; +} + +/** + * The schema name a local `$ref` points at. + * + * @param {string} ref + * @returns {string} + */ +function refName(ref) { + return ref.split('/').pop(); +} + +/** + * The set of schema names a page must document, walked from its operations to + * `MAX_SCHEMA_DEPTH`. + * + * Depth-bounded rather than exhaustive so a self-referential or mutually-referential schema + * cannot spin: the current document has no cycle, but a renderer that would hang on one is + * a renderer that fails the day someone adds a tree. + * + * The bound and the cycle guard interact, which is the subtle part — see the comment on + * `seen` below. The rule the page states, and the one implemented here, is: a schema is + * documented when some path reaches it within the bound, whichever path the walk takes + * first. + * + * @param {Record} document + * @param {Array<{ operation: Record }>} operations + * @returns {string[]} Schema names, sorted. + */ +function schemasUsedBy(document, operations) { + /** @type {Map} Schema name -> shallowest depth it was reached at. */ + const seen = new Map(); + + const visit = (schema, depth) => { + if (!schema || typeof schema !== 'object' || depth > MAX_SCHEMA_DEPTH) + return; + if (Array.isArray(schema)) { + for (const entry of schema) visit(entry, depth); + return; + } + if (schema.$ref) { + const name = refName(schema.$ref); + // Keyed on the *shallowest* depth this name has been reached at, not on having + // been seen at all. A plain visited set makes the answer depend on traversal + // order: reach `SyncEntry` at depth 2 first and its children are cut by the + // bound, and the later path that reaches it at depth 1 — where its children are + // in range — is then refused as already-seen. `WireBlobRole` on + // `/reference/api/sync/` was documented or not according to which operation the + // walk happened to read first. Re-expanding on a shallower arrival still + // terminates: a name can only improve `MAX_SCHEMA_DEPTH + 1` times, and a cycle + // never arrives shallower twice. + if (seen.has(name) && seen.get(name) <= depth) return; + seen.set(name, depth); + visit(document.components?.schemas?.[name], depth + 1); + return; + } + for (const value of Object.values(schema)) visit(value, depth); + }; + + for (const { operation } of operations) { + visit(operation.requestBody ?? {}, 0); + visit(operation.responses ?? {}, 0); + visit(operation.parameters ?? [], 0); + } + return [...seen.keys()].sort(); +} + +/** + * The media type and schema of a request or response body, or null when it carries none. + * + * @param {Record | undefined} carrier + * @returns {{ mediaType: string, schema: Record } | null} + */ +function bodyOf(carrier) { + const content = carrier?.content; + if (!content) return null; + // Exactly one, or none: `assertRenderable` has already refused a carrier offering a + // choice, so `sort()[0]` is the only entry rather than an arbitrary pick. + const mediaType = Object.keys(content).sort()[0]; + if (!mediaType) return null; + return { mediaType, schema: content[mediaType]?.schema ?? {} }; +} + +/** + * A schema reference rendered as a link into this page's appendix, when the appendix + * documents it, and as bare code when it does not. + * + * @param {string[]} documented Schema names the page's appendix carries. + * @param {Record} schema + * @returns {string} + */ +function schemaLink(documented, schema) { + const rendered = typeOf(schema); + const bare = rendered.replace(/\[\]$/, ''); + // A nullable type renders as `string | null`, and every use of this is a table cell, so + // the separator has to be escaped or it opens a fourth column and shifts the row. + // GFM requires the escape inside a code span too, and renders it as a bare pipe. + const shown = rendered.replace(/\|/g, '\\|'); + return documented.includes(bare) + ? `[\`${shown}\`](#${bare.toLowerCase()})` + : `\`${shown}\``; +} + +/** + * Render one operation. + * + * @param {{ path: string, method: string, operation: Record }} entry + * @param {string[]} documented Schema names the page's appendix carries. + * @returns {string} + */ +function renderOperation({ path, method, operation }, documented) { + const sections = [`## ${method} ${path}`]; + + if (operation.summary) { + sections.push(demoteHeadings(rewriteLinks(operation.summary), 2)); + } + if (operation.description) { + // Demoted by 3: an operation is an h2, so a description opening at `#` becomes an + // h4 under it rather than a second page title. + sections.push(demoteHeadings(rewriteLinks(operation.description), 3)); + } + + const security = operation.security; + if (Array.isArray(security) && security.length > 0) { + const schemes = security + .flatMap((requirement) => Object.keys(requirement)) + .sort(); + sections.push( + `**Authentication:** required — ${schemes.map((scheme) => `\`${scheme}\``).join(', ')}.`, + ); + } else { + sections.push('**Authentication:** none.'); + } + + const parameters = operation.parameters ?? []; + if (parameters.length > 0) { + sections.push( + table( + ['Parameter', 'In', 'Type', 'Description'], + [...parameters] + .sort( + (a, b) => + a.in.localeCompare(b.in) || + a.name.localeCompare(b.name), + ) + .map((parameter) => [ + `\`${parameter.name}\``, + `\`${parameter.in}\``, + schemaLink(documented, parameter.schema ?? {}), + escapeCell( + [ + parameter.required ? '**Required.**' : '', + parameter.description + ? sentence(prose(parameter.description)) + : '', + parameter.example === undefined + ? '' + : `Example: \`${parameter.example}\`.`, + ] + .filter(Boolean) + .join(' '), + ) || '—', + ]), + ), + ); + } + + const requestBody = bodyOf(operation.requestBody); + if (requestBody) { + sections.push( + `**Request body** (${operation.requestBody.required ? 'required' : 'optional'}, ` + + `\`${requestBody.mediaType}\`): ${schemaLink(documented, requestBody.schema)}`, + ); + } + + const responses = Object.entries(operation.responses ?? {}).sort( + ([a], [b]) => Number(a) - Number(b) || a.localeCompare(b), + ); + if (responses.length > 0) { + sections.push( + table( + ['Status', 'Body', 'Description'], + responses.map(([status, response]) => { + const body = bodyOf(response); + const headers = Object.keys(response.headers ?? {}).sort(); + return [ + `\`${status}\``, + body + ? `${schemaLink(documented, body.schema)}
\`${body.mediaType}\`` + : '—', + escapeCell( + [ + response.description + ? sentence(prose(response.description)) + : '', + headers.length > 0 + ? `Headers: ${headers.map((header) => `\`${header}\``).join(', ')}.` + : '', + ] + .filter(Boolean) + .join(' '), + ) || '—', + ]; + }), + ), + ); + } + + return sections.filter(Boolean).join('\n\n'); +} + +/** + * Render one schema as an appendix entry. + * + * @param {string} name + * @param {Record} document The document, for the schema's own definition. + * @param {string[]} documented Every schema name this page's appendix carries. + * @returns {string} + */ +function renderSchema(name, document, documented) { + const schema = document.components?.schemas?.[name] ?? {}; + const sections = [`### ${name}`]; + + // Through `rewriteLinks` like every other prose site: a model's own doc comment is as + // free to cite a design document by repo path, or an item by its rustdoc path, as a + // handler's is. `TokenResponse` is one of several that do. + if (schema.description) { + sections.push(demoteHeadings(rewriteLinks(schema.description), 3)); + } + + if (schema.enum) { + sections.push( + `One of: ${schema.enum.map((value) => `\`${value}\``).join(', ')}.`, + ); + return sections.join('\n\n'); + } + + const required = new Set(schema.required ?? []); + const properties = Object.entries(schema.properties ?? {}); + if (properties.length === 0) { + sections.push(`Type: \`${typeOf(schema)}\`.`); + return sections.join('\n\n'); + } + + sections.push( + table( + ['Field', 'Type', 'Description'], + properties.map(([field, property]) => [ + `\`${field}\``, + schemaLink(documented, property), + escapeCell( + [ + required.has(field) ? '**Required.**' : '', + property.description + ? sentence(prose(property.description)) + : '', + ] + .filter(Boolean) + .join(' '), + ) || '—', + ]), + ), + ); + return sections.join('\n\n'); +} + +/** + * The generated `/reference/api//` page. + * + * @param {import('./reference-groups.mjs').ApiGroup} group + * @param {Array<{ path: string, method: string, operation: Record }>} operations + * @param {Record} document + * @returns {string} Markdown, frontmatter included. + */ +export function renderApiPage(group, operations, document) { + const documented = schemasUsedBy(document, operations); + + const head = [ + '---', + `title: ${yamlString(group.label)}`, + `description: ${yamlString(group.description)}`, + 'status: stable', + '---', + '', + ``, + '', + `Generated from \`${OPENAPI_DOCUMENT}\`, the OpenAPI ${OPENAPI_VERSION} document`, + '`capsule-server` emits and `mise run openapi-check-kynos` keeps current. To change a', + 'description on this page, change the annotation on the handler or model it comes from', + 'and regenerate — this file is build output. The auth model, error contract, and', + 'conventions common to every endpoint are on the [REST API overview](/reference/api/).', + ].join('\n'); + + const body = operations + .map((entry) => renderOperation(entry, documented)) + .join('\n\n'); + + const appendix = + documented.length === 0 + ? '' + : [ + '## Schemas', + '', + 'The models these endpoints carry. A field whose type names another model links', + 'to it when this page documents that model, which it does when some path from', + `an operation reaches it within ${MAX_SCHEMA_DEPTH} references. A model only`, + 'ever reached deeper than that is named without being expanded.', + '', + documented + .map((name) => renderSchema(name, document, documented)) + .join('\n\n'), + ].join('\n'); + + return `${tidyBlankLines([head, body, appendix].filter(Boolean).join('\n\n'))}\n`; +} + +/** + * Emit every generated reference page under `root`. + * + * Artifacts are read and validated first, before anything is written, so a missing or + * unparseable one leaves no half-written section behind. + * + * @param {string} root Repository root. + * @returns {string[]} Repo-relative paths written, in a stable order. + */ +export function generate(root) { + const surface = readCliSurface(root); + const document = readOpenApiDocument(root); + const buckets = bucketOperations(document); + + /** @type {Array<{ path: string, body: string }>} */ + const pages = [ + { + path: `${CLI_OUT}/${CLI_PAGES[0].slug}.md`, + body: renderCliPage(surface), + }, + ...API_GROUPS.map((group) => ({ + path: `${API_OUT}/${group.slug}.md`, + body: renderApiPage(group, buckets.get(group.slug) ?? [], document), + })), + ]; + + // Cleared, not merged into. A page this run no longer emits — a group renamed, a surface + // dropped — would otherwise stay on disk, and because the directory is gitignored + // `git status` never shows it: Astro would keep routing, indexing, and link-validating a + // page no artifact describes, on this machine and on no CI runner. Only the generated + // directories, so the hand-written overviews beside them survive. + for (const directory of [CLI_OUT, API_OUT]) { + rmSync(join(root, directory), { recursive: true, force: true }); + } + + for (const { path, body } of pages) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), body); + } + return pages.map(({ path }) => path); +} + +function main() { + // scripts/ -> capsule-docs/ -> repo root + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + const written = generate(root); + process.stdout.write( + `gen-reference: wrote ${written.length} page(s)\n${written.map((path) => ` ${path}`).join('\n')}\n`, + ); +} + +// Run only when invoked as a script, so the test file can import the renderers. +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/capsule-docs/scripts/gen-reference.test.mjs b/capsule-docs/scripts/gen-reference.test.mjs new file mode 100644 index 00000000..2a37790b --- /dev/null +++ b/capsule-docs/scripts/gen-reference.test.mjs @@ -0,0 +1,1141 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + bucketOperations, + CLI_SURFACE, + demoteHeadings, + generate, + OPENAPI_DOCUMENT, + readCliSurface, + readOpenApiDocument, + renderApiPage, + renderCliPage, +} from './gen-reference.mjs'; +import { headingAnchors } from './lib/markdown.mjs'; +import { API_GROUPS, groupForPath } from './reference-groups.mjs'; + +/** + * A repo-shaped temporary root: the two description artifacts at the paths the generator + * reads, and the content directory it writes into. + * + * Fixtures rather than the committed artifacts wherever the assertion is about the + * *generator*. The committed ones are used only where the assertion is about this + * repository — that every operation it declares reaches a page. + */ +function fixtureRoot() { + const root = mkdtempSync(join(tmpdir(), 'gen-reference-')); + mkdirSync(join(root, 'capsule-cli'), { recursive: true }); + mkdirSync(join(root, 'capsule-server'), { recursive: true }); + mkdirSync(join(root, 'capsule-docs/src/content/docs/reference'), { + recursive: true, + }); + return root; +} + +const MINIMAL_CLI = { + schema: 1, + name: 'capsule', + about: 'A command line interface for Capsule', + subcommands: [ + { + name: 'import', + about: 'Import files into a local Capsule library', + args: [ + { + id: 'paths', + positional: true, + required: true, + repeatable: true, + takes_value: true, + value_names: ['PATH'], + help: 'Source file or directory to import', + }, + { + id: 'provider', + long: 'provider', + positional: false, + required: false, + repeatable: false, + takes_value: true, + value_names: ['PROVIDER'], + possible_values: [ + { name: 'takeout', help: 'A Takeout export' }, + ], + help: 'Read the source as an export from this service', + }, + { + id: 'force', + long: 'force', + short: 'f', + positional: false, + required: false, + repeatable: false, + takes_value: false, + help: 'Re-import files even if they already exist', + }, + ], + }, + ], +}; + +/** HTTP methods a path item may carry, per OpenAPI. */ +const METHODS = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +let root; + +beforeEach(() => { + root = fixtureRoot(); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function writeCli(surface) { + writeFileSync( + join(root, CLI_SURFACE), + `${JSON.stringify(surface, null, 2)}\n`, + ); +} + +function writeOpenApi(document) { + mkdirSync(join(root, 'capsule-server'), { recursive: true }); + writeFileSync( + join(root, OPENAPI_DOCUMENT), + `${JSON.stringify(document, null, 2)}\n`, + ); +} + +const MINIMAL_OPENAPI = { + openapi: '3.2.0', + info: { title: 'API', version: '0.0.0' }, + paths: { + '/v1/version': { + get: { + summary: 'The protocol range this server speaks.', + operationId: 'version', + responses: { + 200: { + description: 'The range.', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/VersionResponse', + }, + }, + }, + }, + }, + }, + }, + '/v1/auth/login': { + post: { + summary: 'Exchange an email and password for a session.', + description: + 'Leading prose.\n\n# Two statuses, because there are two outcomes\n\nMore prose.', + operationId: 'login_user', + security: [{ bearer: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/LoginRequest', + }, + }, + }, + }, + responses: { + 401: { description: 'Invalid credentials' }, + 200: { + description: 'A session was opened.', + headers: { + 'WWW-Authenticate': { schema: { type: 'string' } }, + }, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TokenResponse', + }, + }, + }, + }, + }, + }, + }, + '/v1/albums/{album_id}/ops': { + post: { + summary: 'Apply a lifecycle write.', + operationId: 'album_ops', + parameters: [ + { + name: 'album_id', + in: 'path', + description: "The album's identifier.", + required: true, + schema: { type: 'string' }, + }, + ], + responses: { 204: { description: 'Applied.' } }, + }, + }, + }, + components: { + securitySchemes: { + bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + }, + schemas: { + VersionResponse: { + type: 'object', + title: 'VersionResponse', + required: ['min'], + properties: { + min: { type: 'integer', description: 'Lowest supported.' }, + }, + }, + LoginRequest: { + type: 'object', + title: 'LoginRequest', + required: ['email'], + properties: { + email: { + type: 'string', + description: 'The account email.', + }, + device_id: { + type: ['string', 'null'], + description: 'Advisory. Gates nothing | really.', + }, + }, + }, + TokenResponse: { + type: 'object', + title: 'TokenResponse', + properties: { + access: { type: 'string' }, + kind: { $ref: '#/components/schemas/TokenKind' }, + }, + }, + TokenKind: { + type: 'string', + enum: ['bearer'], + description: 'What the token is.', + }, + }, + }, +}; + +describe('demoteHeadings', () => { + // `openapi.json` really does carry `# Two statuses, because there are two outcomes` + // inside an operation description. Rendered as-is it injects a second H1 into a page + // whose H1 is the Starlight title, and breaks the document outline for a screen reader. + it('demotes an ATX heading by the offset', () => { + expect(demoteHeadings('# Why it signs you in\n', 3)).toBe( + '#### Why it signs you in\n', + ); + expect(demoteHeadings('## Second\n', 3)).toBe('##### Second\n'); + }); + + it('clamps at h6 rather than emitting a run of seven hashes', () => { + expect(demoteHeadings('##### Deep\n', 3)).toBe('###### Deep\n'); + }); + + it('leaves prose and a hash that is not a heading alone', () => { + expect(demoteHeadings('a #tag and #hash\n', 2)).toBe( + 'a #tag and #hash\n', + ); + expect(demoteHeadings('body text\n', 2)).toBe('body text\n'); + }); + + // A closing fence carries no info string. Treating any ``` run as a closer ends the + // block at the nested opener, which both demotes the example's comments and leaves + // every real heading after it untouched — two failures from one input. + it('does not let a nested fence with an info string close the block', () => { + const source = ['```sh', '# a', '```js', '# b', '```', '# c'].join( + '\n', + ); + expect(demoteHeadings(source, 2)).toBe( + ['```sh', '# a', '```js', '# b', '```', '### c'].join('\n'), + ); + }); + + // Leaving one alone silently is worse than not supporting it: an undemoted h1 in the + // page body is the exact defect demotion exists to prevent, and it would publish + // looking fine. + it('refuses a setext heading rather than silently leaving it undemoted', () => { + expect(() => demoteHeadings('Title\n=====\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n-----\n', 2)).toThrow(/Title/); + }); + + // CommonMark's underline is `= +` / `- +`, not two or more. Requiring two let the + // single-character form through the check that exists to catch it — the defect arriving + // through the guard against the defect. + it('refuses a single-character setext underline', () => { + expect(() => demoteHeadings('Title\n-\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n=\n', 2)).toThrow(/setext/i); + }); + + it('refuses an indented or trailing-spaced single-character underline', () => { + expect(() => demoteHeadings('Title\n -\n', 2)).toThrow(/setext/i); + expect(() => demoteHeadings('Title\n= \n', 2)).toThrow(/setext/i); + }); + + it('does not mistake a thematic break or a table for a setext underline', () => { + expect(() => demoteHeadings('para\n\n---\n', 2)).not.toThrow(); + expect(() => demoteHeadings('| a |\n| --- |\n', 2)).not.toThrow(); + expect(() => demoteHeadings('- item\n---\n', 2)).not.toThrow(); + }); + + it('does not mistake an underline inside a fenced example for one', () => { + expect(() => + demoteHeadings('```text\nTitle\n=====\n```\n', 2), + ).not.toThrow(); + }); + + it('does not demote a hash inside a fenced block', () => { + const source = ['```sh', '# not a heading', '```', '# heading'].join( + '\n', + ); + expect(demoteHeadings(source, 2)).toBe( + ['```sh', '# not a heading', '```', '### heading'].join('\n'), + ); + }); +}); + +describe('readCliSurface', () => { + it('names the missing artifact rather than emitting a stub page', () => { + expect(() => readCliSurface(root)).toThrow( + /capsule-cli\/cli-surface\.json/, + ); + }); + + // A stub would be the "confidently wrong" page `developer-docs.md` exists to prevent: + // it publishes, it looks like reference, and it documents nothing. + it('refuses a schema version it was not written against', () => { + writeCli({ ...MINIMAL_CLI, schema: 2 }); + expect(() => readCliSurface(root)).toThrow(/schema/i); + }); + + // A well-formed but empty document used to render an empty h2, an empty usage fence, and + // a `status: stable` badge — a page that builds green and documents nothing. + it('refuses a document that parses but describes nothing', () => { + writeCli({ schema: 1 }); + expect(() => readCliSurface(root)).toThrow(/no command name/); + writeCli({ schema: 1, name: 'capsule', subcommands: [] }); + expect(() => readCliSurface(root)).toThrow(/no subcommands/); + }); + + it('refuses an unparseable artifact', () => { + writeFileSync(join(root, CLI_SURFACE), '{ not json'); + expect(() => readCliSurface(root)).toThrow(/cli-surface\.json/); + }); +}); + +describe('renderCliPage', () => { + it('renders every command in the tree', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain('## capsule'); + expect(page).toContain('### capsule import'); + expect(page).toContain('Import files into a local Capsule library'); + }); + + it('opens with frontmatter carrying a status the schema accepts', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page.startsWith('---\n')).toBe(true); + expect(page).toMatch(/^status: stable$/m); + expect(page).toMatch(/^title: "Commands"$/m); + }); + + // A generated page is linted like any other on a machine that has built the site, and + // a run of blank lines is the shape section assembly leaves behind. + it('ends with exactly one newline and no run of blank lines', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page.endsWith('\n')).toBe(true); + expect(page.endsWith('\n\n')).toBe(false); + expect(page).not.toMatch(/\n{3,}/); + }); + + it('says it is generated, and by what', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain('gen-reference.mjs'); + }); + + // The page body must not contain an h1: Starlight renders the frontmatter title as the + // page's only h1, and a second one breaks the outline. + it('emits no h1 in the body', () => { + const body = renderCliPage(MINIMAL_CLI) + .split('\n---\n') + .slice(1) + .join('\n---\n'); + expect(body.split('\n').filter((l) => /^# /.test(l))).toEqual([]); + }); + + it('spells a positional, a value-taking option, and a flag differently', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain('`...`'); + expect(page).toContain('`--provider `'); + expect(page).toContain('`-f, --force`'); + }); + + // A usage line that folds a required option into `[OPTIONS]` hands the reader a command + // that fails to parse. `capsule import` really does require `--library `. + it('spells required options in the usage line rather than hiding them', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args.push({ + id: 'library', + long: 'library', + positional: false, + required: true, + repeatable: false, + takes_value: true, + value_names: ['PATH'], + help: 'Path to the library', + }); + expect(renderCliPage(surface)).toContain( + 'capsule import ... --library [OPTIONS]', + ); + }); + + it('renders the usage line from the argument surface', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain( + 'capsule import ... [OPTIONS]', + ); + }); + + // Markdown passes raw HTML through, so an unescaped placeholder disappears from the + // rendered page — and an unclosed one takes the rest of the cell with it. + it('escapes an angle bracket in help text', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[2].help = 'pass a here'; + const page = renderCliPage(surface); + expect(page).toContain('pass a <token> here'); + expect(page).not.toContain('pass a here'); + }); + + // The defect a per-fragment escape reintroduces: the help text is escaped, the fragments + // appended after it are not, and a `|` in a default opens a column of its own. + it('escapes a pipe in a default value and in an enumerated value', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[1].possible_values = [{ name: 'x|y' }]; + surface.subcommands[0].args[1].default_values = ['a|b']; + const page = renderCliPage(surface); + expect(page).toContain('Values: `x\\|y`.'); + expect(page).toContain('Default: `a\\|b`.'); + expect(page).not.toContain('`x|y`'); + expect(page).not.toContain('`a|b`'); + }); + + it('keeps every table row at the width of its header', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[1].default_values = ['a|b']; + surface.subcommands[0].args[2].help = 'takes a | and a '; + for (const line of renderCliPage(surface).split('\n')) { + if (!line.startsWith('|')) continue; + const columns = line.replace(/\\\|/g, '').split('|').length; + expect(columns).toBe(4); + } + }); + + it('does not append "Repeatable." when the help already says it', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].args[0].help = + 'Flag an asset as a keeper (repeatable)'; + const page = renderCliPage(surface); + expect(page).toContain('(repeatable).'); + expect(page).not.toContain('(repeatable). Repeatable.'); + }); + + // The anchors are hand-built by joining command words, and the headings are slugged by + // Starlight. Pinning them to one slugger here catches a divergence in a unit test rather + // than in a link-validator failure at build time. + it('emits only anchors its own headings answer', () => { + const page = renderCliPage(MINIMAL_CLI); + const anchors = headingAnchors(page); + const linked = [...page.matchAll(/\]\(#([^)]+)\)/g)].map( + (match) => match[1], + ); + expect(linked.length).toBeGreaterThan(0); + for (const anchor of linked) { + expect(anchors.has(anchor)).toBe(true); + } + }); + + it('lists an enumerated option value', () => { + expect(renderCliPage(MINIMAL_CLI)).toContain('`takeout`'); + }); + + // `clap` strips the full stop off a doc comment, so without this the appended facts run + // straight on: "…folded into the imported assets Values: `takeout`." + it('terminates help text before appending the facts after it', () => { + const page = renderCliPage(MINIMAL_CLI); + expect(page).toContain( + 'Read the source as an export from this service. Values: `takeout`.', + ); + expect(page).toContain( + '**Required.** Source file or directory to import. Repeatable.', + ); + }); + + it('escapes a pipe so it cannot break out of a table cell', () => { + const page = renderCliPage({ + schema: 1, + name: 'capsule', + subcommands: [ + { + name: 'x', + args: [ + { + id: 'p', + long: 'p', + positional: false, + required: false, + repeatable: false, + takes_value: false, + help: 'reads a | b', + }, + ], + }, + ], + }); + expect(page).toContain('reads a \\| b'); + }); +}); + +describe('generate', () => { + it('is deterministic: two runs produce byte-identical pages', () => { + writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); + const first = generate(root).map((path) => + readFileSync(join(root, path), 'utf8'), + ); + const second = generate(root).map((path) => + readFileSync(join(root, path), 'utf8'), + ); + expect(second).toEqual(first); + expect(first.length).toBeGreaterThan(0); + }); + + it('fails, writing nothing, when an artifact is missing', () => { + expect(() => generate(root)).toThrow(/cli-surface\.json/); + expect( + existsSync( + join( + root, + 'capsule-docs/src/content/docs/reference/cli/commands.md', + ), + ), + ).toBe(false); + }); + + // A page a later run no longer emits stays on disk otherwise, and the directory is + // gitignored, so nothing ever shows it: Astro keeps routing and indexing a page no + // artifact describes, on this machine and on no CI runner. + it('clears output it no longer emits', () => { + writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); + generate(root); + const orphan = join( + root, + 'capsule-docs/src/content/docs/reference/api/retired-group.md', + ); + writeFileSync(orphan, '---\ntitle: Gone\nstatus: stable\n---\n'); + generate(root); + expect(existsSync(orphan)).toBe(false); + }); + + // The overviews are siblings of the generated directories precisely so that clearing + // one cannot take a hand-written page with it. + it('does not clear the hand-written overview beside the generated directory', () => { + writeCli(MINIMAL_CLI); + writeOpenApi(MINIMAL_OPENAPI); + const overview = join( + root, + 'capsule-docs/src/content/docs/reference/cli.md', + ); + writeFileSync(overview, '---\ntitle: CLI\nstatus: draft\n---\n'); + generate(root); + expect(existsSync(overview)).toBe(true); + }); +}); + +describe('link rewriting in artifact prose', () => { + // Both forms are live in the committed OpenAPI document. Rendered unchanged they are + // three broken links that fail `starlight-links-validator` and the docs build with it. + it('rewrites a repo-relative design-doc path to its site route', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'Every rule the [chunk contract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes.'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + '[chunk contract](/design/import/upload-protocol/)', + ); + }); + + it('drops a rustdoc intra-doc link and keeps its text', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'A signature over [`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes), in CBOR.'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + 'A signature over `revoke_all_signing_bytes`, in CBOR.', + ); + expect(rendered).not.toContain('capsule_core::crypto::revoke'); + }); + + // A model's own doc comment is as free to cite a design document by repo path, or an + // item by its rustdoc path, as a handler's is — and the schema appendix is a separate + // render path that had to be wired up for it. + it('rewrites links in a schema-level description too', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.LoginRequest.description = + 'Shaped by the [chunk contract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) and signed with [`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes).'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain( + '[chunk contract](/design/import/upload-protocol/)', + ); + expect(rendered).toContain('signed with `revoke_all_signing_bytes`.'); + expect(rendered).not.toContain('capsule_core::crypto::revoke'); + }); + + it('leaves an absolute URL, a site route, and an anchor alone', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.description = + 'See [a](https://example.invalid/x), [b](/design/i18n/), and [c](#later).'; + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain('[a](https://example.invalid/x)'); + expect(rendered).toContain('[b](/design/i18n/)'); + expect(rendered).toContain('[c](#later)'); + }); +}); + +describe('groupForPath', () => { + it('matches the longest prefix, not the first declared', () => { + expect(groupForPath('/v1/auth/login')?.slug).toBe('auth'); + expect(groupForPath('/v1/albums/{album_id}/ops')?.slug).toBe('albums'); + expect(groupForPath('/s/{opaque_id}/blob/{hash}')?.slug).toBe('shares'); + expect(groupForPath('/d/{opaque_id}')?.slug).toBe('drops'); + }); + + it('returns null for a path no group claims', () => { + expect(groupForPath('/v1/search')).toBe(null); + }); +}); + +describe('bucketOperations', () => { + // The gate that keeps the hand-curated navigation honest: a new endpoint family cannot + // publish unlisted, and cannot silently not publish at all. + it('fails, naming the operation, when an endpoint matches no group', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/search'] = { + get: { summary: 'Search', operationId: 'search', responses: {} }, + }; + expect(() => bucketOperations(document)).toThrow(/GET \/v1\/search/); + expect(() => bucketOperations(document)).toThrow( + /reference-groups\.mjs/, + ); + }); + + it('buckets every operation exactly once', () => { + const buckets = bucketOperations(MINIMAL_OPENAPI); + const total = [...buckets.values()].reduce( + (sum, operations) => sum + operations.length, + 0, + ); + expect(total).toBe(3); + expect(buckets.get('auth')?.[0].operationId).toBe('login_user'); + }); + + it('orders operations within a group by path, then by method', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/aaa'] = { + post: { summary: 'a', operationId: 'a', responses: {} }, + get: { summary: 'b', operationId: 'b', responses: {} }, + }; + const auth = bucketOperations(document).get('auth'); + expect( + auth.map((operation) => `${operation.method} ${operation.path}`), + ).toEqual([ + 'GET /v1/auth/aaa', + 'POST /v1/auth/aaa', + 'POST /v1/auth/login', + ]); + }); +}); + +describe('readOpenApiDocument', () => { + it('names the missing artifact rather than emitting a stub page', () => { + expect(() => readOpenApiDocument(root)).toThrow( + /capsule-server\/openapi\.json/, + ); + }); + + it('refuses a document that is not OpenAPI 3.2', () => { + writeOpenApi({ ...MINIMAL_OPENAPI, openapi: '3.1.0' }); + expect(() => readOpenApiDocument(root)).toThrow(/3\.2/); + }); + + // This renderer shows one body per carrier. Picking the first of several silently would + // document an endpoint that also accepts CBOR as accepting only JSON. + it('refuses a carrier offering more than one media type, naming the operation', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/auth/login'].post.requestBody.content[ + 'application/cbor' + ] = { schema: { $ref: '#/components/schemas/LoginRequest' } }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow( + /POST \/v1\/auth\/login/, + ); + expect(() => readOpenApiDocument(root)).toThrow(/media types/); + }); + + it('refuses a multi-media-type response too', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.paths['/v1/version'].get.responses[200].content[ + 'application/cbor' + ] = { schema: { $ref: '#/components/schemas/VersionResponse' } }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/GET \/v1\/version/); + expect(() => readOpenApiDocument(root)).toThrow(/response 200/); + }); + + // A property table cannot express a union or an intersection, so a composed schema + // would render as an empty or a half-true model. + it.each([ + 'oneOf', + 'allOf', + 'anyOf', + ])('refuses a schema composed with %s, naming the schema', (keyword) => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenResponse = { + title: 'TokenResponse', + [keyword]: [ + { $ref: '#/components/schemas/VersionResponse' }, + { type: 'object' }, + ], + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(new RegExp(keyword)); + }); + + // Checking only the root is the shape of the bug it prevents: a composition one level + // down falls through `typeOf` to `object` and renders as a row claiming the field is a + // plain object — a confident lie about a union. + it.each([ + 'properties.choice', + 'items', + 'additionalProperties', + ])('refuses a composition nested at %s, naming the schema and the path', (where) => { + const document = structuredClone(MINIMAL_OPENAPI); + const composed = { + oneOf: [{ type: 'string' }, { type: 'integer' }], + }; + const target = { type: 'object', title: 'TokenResponse' }; + if (where === 'properties.choice') { + target.properties = { choice: composed }; + } else if (where === 'items') { + target.items = composed; + } else { + target.additionalProperties = composed; + } + document.components.schemas.TokenResponse = target; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(/oneOf/); + }); + + // OpenAPI 3.1 has no `nullable`, so every `Option` over a struct is spelled as a + // union with `null`. That is a composition by the letter and not by intent: one real + // branch flattens to a property table perfectly well. `AuthEndpointsResponse.oidc` was + // the first to reach the generator, and refusing it would have meant no REST reference + // at all for a document the server emits correctly. + it.each([ + 'anyOf', + 'oneOf', + ])('accepts an optional object spelled as %s with null', (keyword) => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenResponse = { + title: 'TokenResponse', + type: 'object', + properties: { + maybe: { + [keyword]: [ + { $ref: '#/components/schemas/VersionResponse' }, + { type: 'null' }, + ], + }, + }, + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).not.toThrow(); + }); + + // The narrowness is the point: dropping the null arm must not become "drop any arm". + it('still refuses a union of two real branches beside null', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenResponse = { + title: 'TokenResponse', + type: 'object', + properties: { + choice: { + anyOf: [ + { type: 'string' }, + { type: 'integer' }, + { type: 'null' }, + ], + }, + }, + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(/anyOf/); + }); + + // A genuine union hiding *inside* an optional is still a union. + it('refuses a composition nested inside a nullable branch', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenResponse = { + title: 'TokenResponse', + type: 'object', + properties: { + maybe: { + anyOf: [ + { + type: 'object', + properties: { + choice: { + oneOf: [ + { type: 'string' }, + { type: 'integer' }, + ], + }, + }, + }, + { type: 'null' }, + ], + }, + }, + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/TokenResponse/); + expect(() => readOpenApiDocument(root)).toThrow(/oneOf/); + }); + + // A `$ref` is scanned on the target's own pass. Following it would report the same + // composition once per reference, under whichever name was scanned first. + it('reports a composed schema once, under its own name', () => { + const document = structuredClone(MINIMAL_OPENAPI); + document.components.schemas.TokenKind = { + title: 'TokenKind', + oneOf: [{ type: 'string' }, { type: 'integer' }], + }; + writeOpenApi(document); + expect(() => readOpenApiDocument(root)).toThrow(/schema TokenKind/); + }); + + it('accepts the committed document', () => { + expect(() => + readOpenApiDocument( + resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'), + ), + ).not.toThrow(); + }); +}); + +describe('renderApiPage', () => { + const group = API_GROUPS.find((entry) => entry.slug === 'auth'); + + function page() { + return renderApiPage( + group, + bucketOperations(MINIMAL_OPENAPI).get('auth'), + MINIMAL_OPENAPI, + ); + } + + it('renders the method and path as the operation heading', () => { + expect(page()).toContain('## POST /v1/auth/login'); + }); + + // `openapi.json` really does carry `# Two statuses, because there are two outcomes`. + // Interpolated unchanged it is a second h1 on the page. + it('demotes a heading inside an operation description', () => { + const rendered = page(); + expect(rendered).toContain( + '#### Two statuses, because there are two outcomes', + ); + const body = rendered.split('\n---\n').slice(1).join('\n---\n'); + expect(body.split('\n').filter((line) => /^# /.test(line))).toEqual([]); + }); + + it('says which operations require authentication', () => { + expect(page()).toMatch(/[Bb]earer/); + }); + + it('renders the request body schema and its fields', () => { + const rendered = page(); + expect(rendered).toContain('LoginRequest'); + expect(rendered).toContain('`email`'); + expect(rendered).toContain('The account email.'); + }); + + it('escapes a pipe inside a schema description', () => { + expect(page()).toContain('Gates nothing \\| really.'); + }); + + // Every use of a type is a table cell, so an unescaped separator opens a fourth column + // and shifts the row — `device_id` in the committed document is exactly this shape. + it('renders a nullable union as an escaped type, not as [object Object]', () => { + const rendered = page(); + expect(rendered).not.toContain('[object Object]'); + expect(rendered).toContain('`string \\| null`'); + expect(rendered).not.toContain('`string | null`'); + }); + + it('keeps every table row at the width of its header', () => { + for (const line of page().split('\n')) { + if (!line.startsWith('|')) continue; + // A cell may legitimately contain an escaped pipe; an unescaped one is a column. + const columns = line.replace(/\\\|/g, '').split('|').length; + expect(columns).toBeLessThanOrEqual(6); + } + }); + + it('resolves a $ref one level and links deeper refs to their anchor', () => { + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'version'), + bucketOperations(MINIMAL_OPENAPI).get('version'), + MINIMAL_OPENAPI, + ); + expect(rendered).toContain('VersionResponse'); + expect(rendered).toContain('Lowest supported.'); + }); + + it('lists responses in ascending status order', () => { + const rendered = page(); + expect(rendered.indexOf('| `200`')).toBeLessThan( + rendered.indexOf('| `401`'), + ); + }); + + it('renders a path parameter', () => { + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'albums'), + bucketOperations(MINIMAL_OPENAPI).get('albums'), + MINIMAL_OPENAPI, + ); + expect(rendered).toContain('`album_id`'); + expect(rendered).toContain("The album's identifier."); + }); + + it('opens with frontmatter the content schema accepts', () => { + const rendered = page(); + expect(rendered.startsWith('---\n')).toBe(true); + expect(rendered).toMatch(/^status: stable$/m); + expect(rendered).toMatch(/^title: "[^"]+"$/m); + }); + + it('emits only anchors its own headings answer', () => { + const rendered = page(); + const anchors = headingAnchors(rendered); + const linked = [...rendered.matchAll(/\]\(#([^)]+)\)/g)].map( + (match) => match[1], + ); + expect(linked.length).toBeGreaterThan(0); + for (const anchor of linked) { + expect(anchors.has(anchor)).toBe(true); + } + }); + + it('ends with exactly one newline and no run of blank lines', () => { + const rendered = page(); + expect(rendered.endsWith('\n')).toBe(true); + expect(rendered.endsWith('\n\n')).toBe(false); + expect(rendered).not.toMatch(/\n{3,}/); + }); +}); + +describe('the schema appendix', () => { + // The depth bound and the cycle guard interact. Keyed on "seen at all", the answer + // depends on traversal order: reach a model at depth 2 first and its children are cut + // by the bound, and the later path that reaches it at depth 1 is then refused as + // already-seen. Keyed on the shallowest depth, both paths get their chance. + it('expands a model when a shallower path reaches it after a deeper one', () => { + const document = structuredClone(MINIMAL_OPENAPI); + const schemas = document.components.schemas; + schemas.Leaf = { + type: 'object', + title: 'Leaf', + properties: { + mark: { type: 'string', description: 'The leaf mark.' }, + }, + }; + // A chain long enough that the deep path runs past MAX_SCHEMA_DEPTH exactly at + // `Tail`, so `Leaf` is out of reach along it. + schemas.Tail = { + type: 'object', + title: 'Tail', + properties: { leaf: { $ref: '#/components/schemas/Leaf' } }, + }; + for (const [name, next] of [ + ['Link3', 'Tail'], + ['Link2', 'Link3'], + ['Link1', 'Link2'], + ]) { + schemas[name] = { + type: 'object', + title: name, + properties: { next: { $ref: `#/components/schemas/${next}` } }, + }; + } + // Walked first (`requestBody` before `responses`): reaches `Tail` too deep to + // expand it. The response then reaches the same name at depth 0. + schemas.LoginRequest.properties.deep = { + $ref: '#/components/schemas/Link1', + }; + document.paths['/v1/auth/login'].post.responses[200].content[ + 'application/json' + ].schema = { $ref: '#/components/schemas/Tail' }; + + const rendered = renderApiPage( + API_GROUPS.find((entry) => entry.slug === 'auth'), + bucketOperations(document).get('auth'), + document, + ); + expect(rendered).toContain('### Tail'); + expect(rendered).toContain('### Leaf'); + expect(rendered).toContain('The leaf mark.'); + }); + + // The concrete instance of that bug in the committed document: `WireBlobRole` is + // reachable from `/reference/api/sync/` and was named on the page while defined nowhere. + it('documents every model the committed sync page links to', () => { + const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + const document = readOpenApiDocument(repoRoot); + const group = API_GROUPS.find((entry) => entry.slug === 'sync'); + const rendered = renderApiPage( + group, + bucketOperations(document).get('sync'), + document, + ); + expect(rendered).toContain('### WireBlobRole'); + const headings = new Set( + [...rendered.matchAll(/^#{2,6} (.+)$/gm)].map((match) => + match[1] + .toLowerCase() + .replace(/[^a-z0-9 -]/g, '') + .replace(/ /g, '-'), + ), + ); + for (const [, anchor] of rendered.matchAll(/\]\(#([^)]+)\)/g)) { + expect(headings.has(anchor)).toBe(true); + } + }); +}); + +describe('the committed artifacts', () => { + // The assertion about *this repository* rather than about the generator: every + // operation the server declares reaches a page. A group table that quietly stopped + // covering a family would fail here as well as in `bucketOperations`. + const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + + it('bucket every declared operation into a group', () => { + const document = readOpenApiDocument(repoRoot); + const declared = Object.entries(document.paths).flatMap(([, item]) => + Object.keys(item).filter((key) => METHODS.has(key)), + ); + const buckets = bucketOperations(document); + const bucketed = [...buckets.values()].reduce( + (sum, operations) => sum + operations.length, + 0, + ); + expect(bucketed).toBe(declared.length); + expect(bucketed).toBeGreaterThan(50); + }); + + // Renders into a temp root seeded from the two committed artifacts, never into the + // working tree. `check-docs` runs `test-docs` and `build-docs` in parallel, and + // `generate` clears its output directories before writing: generating into the real + // tree races the Astro build reading it, which fails intermittently and only under the + // gate. The artifacts are the committed ones, so the assertion is still about this + // repository. + it('generate one page per group plus the CLI page', () => { + copyFileSync(join(repoRoot, CLI_SURFACE), join(root, CLI_SURFACE)); + copyFileSync( + join(repoRoot, OPENAPI_DOCUMENT), + join(root, OPENAPI_DOCUMENT), + ); + + const written = generate(root); + + expect(written).toContain( + 'capsule-docs/src/content/docs/reference/cli/commands.md', + ); + for (const group of API_GROUPS) { + expect(written).toContain( + `capsule-docs/src/content/docs/reference/api/${group.slug}.md`, + ); + } + // Every page the run reported is a page it actually wrote, under the temp root. + for (const path of written) { + expect(existsSync(join(root, path))).toBe(true); + } + expect(written).toHaveLength(API_GROUPS.length + 1); + }); +}); + +describe('tidyBlankLines, through the pages that use it', () => { + // A blank run inside a fenced example is part of the example. A whole-document collapse + // has the generator quietly editing the code it is quoting. + it('keeps a blank run inside a fenced example', () => { + const surface = structuredClone(MINIMAL_CLI); + surface.subcommands[0].long_about = + 'Example:\n\n```sh\ncapsule import a\n\n\ncapsule import b\n```'; + expect(renderCliPage(surface)).toContain( + 'capsule import a\n\n\ncapsule import b', + ); + }); +}); diff --git a/capsule-docs/scripts/lib/walk.mjs b/capsule-docs/scripts/lib/walk.mjs index be17464a..5846d516 100644 --- a/capsule-docs/scripts/lib/walk.mjs +++ b/capsule-docs/scripts/lib/walk.mjs @@ -12,6 +12,16 @@ * not check it out. A walk that descends into it passes on the runner and * fails on any machine that has run `git submodule update` — the same trap * `.markdownlint-cli2.jsonc` documents for its own ignore list. + * + * 3. The generated `/reference/` pages are the same trap in the other + * direction: gitignored build output that exists on any machine that has + * run `mise run build-docs` and on no CI runner, sitting *inside* the + * content tree every check scopes itself to. Walking them makes a check's + * verdict depend on whether the site happens to be built, which is exactly + * the property `docs-truth.mjs` claims not to have when it states its scope + * as committed text against committed text. They are pruned by path rather + * than by basename because `cli/` and `api/` are ordinary directory names + * that other trees are entitled to use. */ import { readdirSync } from 'node:fs'; @@ -30,6 +40,16 @@ const SKIP_DIRS = new Set([ 'target', ]); +/** + * Directory subtrees never descended into, matched on the repo-relative path. + * + * Build output that lands inside a scanned tree, so a basename rule cannot express it. + */ +const SKIP_PREFIXES = [ + 'capsule-docs/src/content/docs/reference/cli/', + 'capsule-docs/src/content/docs/reference/api/', +]; + /** * Yield repo-relative paths of every file under `root` whose name matches * `predicate`, depth-first, with `SKIP_DIRS` pruned and symlinks skipped. @@ -47,7 +67,13 @@ export function walkFiles(root, predicate) { // `isDirectory()`/`isFile()` are false for a symlink, which is how // the `docs` symlink is dropped without a special case for it. if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name)) visit(abs); + const relDir = `${relative(root, abs).split(sep).join('/')}/`; + if ( + !SKIP_DIRS.has(entry.name) && + !SKIP_PREFIXES.some((prefix) => relDir.startsWith(prefix)) + ) { + visit(abs); + } continue; } if (!entry.isFile()) continue; diff --git a/capsule-docs/scripts/reference-groups.mjs b/capsule-docs/scripts/reference-groups.mjs new file mode 100644 index 00000000..0415b6a2 --- /dev/null +++ b/capsule-docs/scripts/reference-groups.mjs @@ -0,0 +1,212 @@ +/** + * The `/reference/` page table — the one place the reference section's shape is decided. + * + * `design/developer-docs.md` requires the `Reference` sidebar to be hand-curated, "in the + * same style as `Design` and for the same reason: generated pages must not be allowed to + * determine navigation order". Autogenerating it from the emitted directory would order + * pages by filename, which is a fact about slugs rather than a decision about reading + * order. + * + * Hand-curated does not have to mean written twice. Both `gen-reference.mjs` and + * `astro.config.mjs` import this file: the generator buckets the description artifacts into + * these pages, the config builds the sidebar from the same list. Editing the order here + * moves the page and its navigation entry together, and a group that has navigation but no + * page — or a page nothing links to — is not expressible. + * + * This module is deliberately data plus two pure functions, with no `node:` imports, so the + * Astro config can import it in the browser-facing build without dragging filesystem code + * along. + */ + +/** + * A generated page. + * + * @typedef {object} ReferencePage + * @property {string} slug Last path segment of the route, and the emitted file's basename. + * @property {string} label Sidebar label and page title. + * @property {string} description Frontmatter description, shown in search results. + */ + +/** + * The CLI pages, in reading order. + * + * One page rather than one per command: `capsule` has 16 commands whose help is a sentence + * each, and sixteen pages of one paragraph would put the whole surface behind sixteen + * clicks. The command tree is small enough to read end to end. + * + * @type {ReferencePage[]} + */ +export const CLI_PAGES = [ + { + slug: 'commands', + label: 'Commands', + description: + 'Every capsule command, argument, and option, generated from the committed command tree.', + }, +]; + +/** + * A generated REST page: one endpoint family, with the path prefixes that belong to it. + * + * @typedef {ReferencePage & { pathPrefixes: string[] }} ApiGroup + */ + +/** + * The REST pages, in reading order — a client's order, not the document's: what version you + * are talking to, what the server advertises, how to authenticate, then the data plane, then + * the account surfaces. + * + * Grouped by hand because there is nothing to group by automatically. The Kynos document + * carries no `tags` on any of its 59 operations, so an autogenerated section would be 59 + * pages ordered by filename — which `design/developer-docs.md` forbids in as many words, + * and which would scatter the four `/v1/upload` operations of one protocol across the + * alphabet. + * + * The names track the surface map in `design/api-surfaces.md`, so a reader who arrives from + * a design document finds the page named after the surface they were reading about. + * + * @type {ApiGroup[]} + */ +export const API_GROUPS = [ + { + slug: 'version', + label: 'Version', + description: + 'The protocol handshake every client performs before it does anything else.', + pathPrefixes: ['/v1/version'], + }, + { + slug: 'well-known', + label: 'Server discovery', + description: + 'What a server publishes about itself: attestation keys, capabilities, deprecations, and revoked tokens.', + pathPrefixes: ['/.well-known/capsule/'], + }, + { + slug: 'auth', + label: 'Authentication and devices', + description: + 'Registration, sign-in, the second factor, session and device management, and key escrow.', + pathPrefixes: ['/v1/auth/'], + }, + { + slug: 'upload', + label: 'Upload', + description: + 'The resumable, encrypted upload protocol: open a session, drive it, and read its receipt.', + pathPrefixes: ['/v1/upload'], + }, + { + slug: 'albums', + label: 'Albums and lifecycle writes', + description: + 'Album creation, the lifecycle write surface, and the membership upgrade handshake.', + pathPrefixes: ['/v1/albums'], + }, + { + slug: 'sync', + label: 'Sync and blob fetch', + description: + 'The change feed a client drains after a cursor, and the range-capable blob endpoint it fetches from.', + pathPrefixes: ['/v1/sync', '/v1/blob/'], + }, + { + slug: 'storage', + label: 'Storage verification', + description: + 'Proving the server still holds what it said it held, and the receipts that attest to it.', + pathPrefixes: ['/v1/storage/', '/v1/assets/'], + }, + { + slug: 'shares', + label: 'Share links', + description: + 'Issuing and revoking a share link, and the unauthenticated surface that serves one.', + pathPrefixes: ['/v1/shares', '/s/'], + }, + { + slug: 'drops', + label: 'Guest drops', + description: + 'Guest upload links, the unauthenticated drop surface, and the owner-side inbox and adoption.', + pathPrefixes: ['/v1/drops', '/d/'], + }, + { + slug: 'quota', + label: 'Quota', + description: 'What the account has used, and what it is allowed.', + pathPrefixes: ['/v1/quota'], + }, + { + slug: 'moderation', + label: 'Moderation', + description: + "The account's own moderation record — every action taken against it, in order.", + pathPrefixes: ['/v1/moderation/'], + }, + { + slug: 'federation', + label: 'Federation', + description: + 'Server-to-server: refreshing a capability a peer holds, and receiving a ' + + 'moderation report from one.', + pathPrefixes: ['/v1/federation/'], + }, +]; + +/** + * The group a path belongs to, by longest matching prefix, or `null` when none matches. + * + * Longest-prefix rather than first-match so the table's *reading* order and its *matching* + * order are independent. First-match would make adding a narrower group — `/v1/auth/totp/` + * under `/v1/auth/` — silently depend on placing it above the broader one, which is a trap + * a reader reordering the table for navigation reasons would spring without noticing. + * + * @param {string} path An OpenAPI path template, e.g. `/v1/albums/{album_id}/ops`. + * @returns {ApiGroup | null} + */ +export function groupForPath(path) { + let best = null; + let bestLength = -1; + for (const group of API_GROUPS) { + for (const prefix of group.pathPrefixes) { + if (path.startsWith(prefix) && prefix.length > bestLength) { + best = group; + bestLength = prefix.length; + } + } + } + return best; +} + +/** + * Sidebar items for the `Reference` group, in the order they are read. + * + * Returns Starlight sidebar entries: the section overview first, then one nested group per + * surface whose own overview leads its generated pages. + * + * @returns {Array<{ slug: string } | { label: string, items: Array<{ slug: string }> }>} + */ +export function referenceSidebar() { + return [ + { slug: 'reference' }, + { + label: 'CLI', + items: [ + { slug: 'reference/cli' }, + ...CLI_PAGES.map((page) => ({ + slug: `reference/cli/${page.slug}`, + })), + ], + }, + { + label: 'REST API', + items: [ + { slug: 'reference/api' }, + ...API_GROUPS.map((group) => ({ + slug: `reference/api/${group.slug}`, + })), + ], + }, + ]; +} diff --git a/capsule-docs/src/content/docs/design/api-surfaces.md b/capsule-docs/src/content/docs/design/api-surfaces.md index b3d664da..6000c84d 100644 --- a/capsule-docs/src/content/docs/design/api-surfaces.md +++ b/capsule-docs/src/content/docs/design/api-surfaces.md @@ -21,9 +21,11 @@ the gate that keeps it current — is [Developer Documentation](/design/develope | Authentication (sessions, TOTP, OIDC) | REST | `capsule-server::auth` | [Authentication](/design/authentication/) | | Resumable upload (`POST /v1/upload`, then `HEAD/PATCH /v1/upload/{id}`) | REST | `capsule-server::upload` | [Upload Protocol](/design/import/upload-protocol/) | | Lifecycle writes (`POST /v1/albums/{album_id}/ops`) | REST | `capsule-server::routes::ops` | [Authorization](/design/authorization/#the-lifecycle-write-surface) | +| Album roster publish (`PUT /v1/albums/{album_id}/roster`) | REST | `capsule-server::membership` | [Threat Model — Validation](/design/threat-model/validation/) (invariant 33) | | Blob fetch (`GET /v1/blob/{hash}`, HTTP `Range`) | REST | `capsule-server::blob` | [Download & Sync](/design/import/download-sync/) | | Sync feed (change discovery after a cursor) | REST | `capsule-server::sync` | [Download & Sync](/design/import/download-sync/) | -| Federation pull | REST | `capsule-server::federation` | [Federation](/design/federation/) | +| Federation capability lifecycle (`POST /v1/albums/{album_id}/capabilities`, `DELETE /v1/albums/{album_id}/capabilities/{jti}`, `POST /v1/federation/capabilities/refresh`) and signed report intake (`POST /v1/federation/reports`) | REST | `capsule-server::federation` | [Federation](/design/federation/) | +| Federation **pull** — no route of its own: a peer reads `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` with a capability in the `bearer` slot | REST | `capsule-server::federation` (the credential and its admission) over `::sync` / `::serve` | [Federation](/design/federation/) | | Share serving (`/s/{opaque_id}`) | REST | `capsule-server::share` | [Share Links](/design/share-links/) | | Guest drops (`POST /d/{opaque_id}`, inbox, adoption) | REST | `capsule-server::drop` | [Web Upload](/design/web-upload/) | | Storage verification (`POST /v1/storage/verify`) | REST | `capsule-server::verify` | [Storage Verification](/design/import/storage-verification/) | @@ -123,16 +125,56 @@ Every public route applies the same headers: | Header | Direction | | --- | --- | -| `X-Capsule-Protocol` | request | -| `X-Capsule-Crypto-Suite` | request for writes | -| `X-Capsule-Sidecar-Schema` | request | -| `X-Capsule-Protocol-Min` | response | -| `X-Capsule-Protocol-Max` | response | -| `X-Capsule-Min-Client-Build` | response | +| `X-Capsule-Protocol` | request, required on every gated route | +| `X-Capsule-Crypto-Suite` | request for writes; validated when present | +| `X-Capsule-Sidecar-Schema` | request on metadata updates; validated when present | +| `X-Capsule-Protocol-Min` | response, on every response of every operation | +| `X-Capsule-Protocol-Max` | response, on every response of every operation | +| `X-Capsule-Min-Client-Build` | response, on every response of every operation; advisory (`0.0.0` = no cutoff) | + +The carriage is two Kynos interceptors in `capsule-server/src/negotiation.rs`, and the split +is the point: `Negotiation` is mounted on the whole router, outside everything that can refuse, +so the three response headers ride a `413`, a `401` and a `426` exactly as they ride a `200` +(an unrouted `404`/`405` is the router's own and carries none — Kynos runs interceptors per +operation, after routing); +the gate is two `Group`s — `ProtocolGate` holding every non-safe operation and +`ProtocolReadGate` every gated `GET`/`HEAD` — so an operation is gated by being mounted inside +one and exempt by being mounted outside both. The two gates are the two halves of the +fail-closed rules: a **write** with a grammatical `X-Capsule-Protocol` outside `[Min, Max]` is +`426`; a **read** with the same header is admitted ("reads of any past version succeed" — and a +future date on a read is admitted too, since the rule is the grammar and nothing else), and a +missing or malformed header is `400 error.request.malformed` on every gated operation. All +three read one protocol window — the upload policy's, built from `PROTOCOL_MIN`/`PROTOCOL_MAX` +at boot — so the window a client is told and the window it is held to cannot be two numbers. A +`426` carries the window on the headers and the stable `error.protocol.version_unsupported` code +in the body; nothing restates the window as a body member. + +**Exempt from the request gate** (and still carrying the response headers), ten operations: + +- `GET /v1/version` — the reachability probe a client hits before it knows the window. +- `GET /.well-known/capsule/attestation-keys`, `GET /.well-known/capsule/server-info`, + `GET /.well-known/capsule/deprecation`, `GET /.well-known/capsule/revoked-jti` — public + discovery, read before any handshake. +- `GET /s/{opaque_id}`, `GET /s/{opaque_id}/wrapped-secret`, `GET /s/{opaque_id}/blob/{hash}` — + [Share Links](/design/share-links/) requires an indistinguishable `404` there, and a `426` + would be a probing oracle. +- `POST /d/{opaque_id}`, `PATCH /d/{opaque_id}/{upload_id}` — the link record pins + `protocol_version` and `crypto_suite_id` at issuance ([Web Upload](/design/web-upload/)), so a + browser guest has nothing to assert. + +`capsule-server/tests/conformance.rs` pins both the gated set and this exempt set against the +emitted document, and walks every operation on the wire, so a route cannot join or leave the +gate by accident. Credentials use `Authorization: Bearer`. Session access tokens and federation capabilities are different token types verified by their owning modules, even though both use the standard HTTP -carriage. +carriage. **The document carries one `bearer` component for both.** The two read primitives a peer +pulls through — `GET /v1/sync` and `GET /v1/blob/{hash}` — register a second Kynos security scheme +under the same component name and a byte-identical description, so every operation's `security` is +the one requirement it always was and the generated client attaches either token type under the one +credential key it knows. A second key would have split one carriage into two for a difference the +wire does not have. `capsule-server/tests/conformance.rs` pins the component set at exactly one +entry. ## Rejection Mapping diff --git a/capsule-docs/src/content/docs/design/authentication.md b/capsule-docs/src/content/docs/design/authentication.md index 96f066aa..b741e545 100644 --- a/capsule-docs/src/content/docs/design/authentication.md +++ b/capsule-docs/src/content/docs/design/authentication.md @@ -79,6 +79,28 @@ Both paths mint the same Capsule [sessions](#session-and-access-tokens) and bind A deployment may enable either or both. Neither path weakens the cryptographic binding: the IdP (or password) authenticates the *session*; the master key never derives from, and is never visible to, the credential verifier. +### Signing In Through an Identity Provider + +Slice `S-N1` (the server) and the SDK half of `S-N2` (`capsule-sdk`'s `begin_oidc_login` / `complete_oidc_login`). Authorization code + PKCE, and nothing else: no implicit flow, no hybrid flow, and — until the CLI's loopback listener and the device grant land (issue #461) — no device authorization grant. + +- **Two requests, one ceremony.** `POST /v1/auth/oidc/authorize` takes the client's own `redirect_uri` and answers the provider's authorization URL, a `state`, and the ceremony's deadline (ten minutes). The client sends the person there and receives the provider's redirect itself — a web app's callback route, a CLI's loopback listener, `ASWebAuthenticationSession` on iOS. `POST /v1/auth/oidc/callback` takes the redirect's `state` and `code` and answers exactly what `POST /v1/auth/login` answers: a token pair, or a `202` second-factor challenge. The session is opened by the same code the password path uses, so a federated sign-in is in every respect the same session. +- **The redirect URI is client-supplied and allow-listed.** Admitted if it equals `OIDC_REDIRECT_URL` exactly, or — when `OIDC_ALLOW_LOOPBACK_REDIRECT` is on, which it is **not** by default — is an `http` URI whose host is the loopback IP literal `127.0.0.1` or `[::1]` on **any** port (RFC 8252 §7.3; `localhost` is deliberately not admitted, per §8.3). The loopback arm is opt-in because it is the one knob that widens where the server will send a person back to; a deployment with a CLI or desktop client turns it on, and the CLI flow (issue #461) tells the operator so. The admitted value is stored with the ceremony and replayed byte for byte to the token endpoint, as RFC 6749 §4.1.3 requires. This one field is what lets a native client complete the flow without a second server surface. A refused URI is `400 error.auth.oidc_redirect_invalid`. +- **Beginning a ceremony is bounded twice**, because it is an unauthenticated write into a store: sixty a minute per redirect host (`429 error.auth.rate_limited`), and the pending-ceremony store's own capacity — ten thousand in memory, expired records purged on every write — answered as `503 error.auth.oidc_at_capacity` when reached. That code is its own, not the `500`'s `error.auth.unavailable`: [the API surfaces contract](/design/api-surfaces/) has clients switch on the code and never on status alone, so "the server said not now, retry in a moment" and "a store could not answer at all" may not share one. +- **The rate-limit key is picked after the redirect is validated, not before.** The redirect URI is caller-supplied, so keying the limiter on its host before the policy has admitted it would let an unauthenticated caller add one row per request to the counter store — refused every time, and counted forever. An admitted redirect is keyed on its host, at most three; every refusal shares one deployment-wide bucket on its own budget, so refusals stay throttled without being able to mint keys. The counter store purges lapsed windows and holds a ceiling besides, **one per key kind rather than one for everything**, because several other limiters on the surface key on something a caller sent and must do so *before* they resolve it — throttling only real ids would make the limiter a free existence oracle. A single shared ceiling would have let a flood against the cheapest of those surfaces deny a first-time key to all the others, single sign-on included; partitioned, it denies only its own. The enrollment redemption additionally shape-checks the presented code before charging, on the same reasoning as the redirect here: a shape check is not an existence check, so it bounds the key without reopening the oracle. +- **The `state` is burned on the first callback, successful or not.** The nonce, the PKCE verifier and the redirect URI live in a single-use ceremony store between the two legs; a replayed `state` — and therefore a stolen code arriving on it — finds nothing. Unknown, spent and expired are one answer, `401 error.auth.oidc_state_invalid`, so the callback is not an oracle. +- **Every ID-token refusal is one code on the wire.** The relying party checks the header algorithm (RS256, ES256 or EdDSA; never `none`, never HMAC), the signature against the provider's published keys, `iss` for exact string equality with `OIDC_ISSUER`, `aud` containing the client id, `azp` when present, `exp`/`nbf`/`iat` with a sixty-second skew, the `nonce` against the one this ceremony issued, and a bounded `sub`. Which check failed reaches the server log; the wire says `401 error.auth.oidc_token_invalid` for all of them. A provider that refuses the exchange is `401 error.auth.oidc_exchange_failed`; a provider that cannot be reached is `500 error.auth.oidc_unavailable`, distinct from `error.auth.unavailable` because "your identity provider is down" and "our session store is down" are different operator actions. +- **Discovery is lazy, and a provider that names another issuer is refused.** The provider's metadata is fetched on first use and cached for a day; nothing is resolved at boot, so an identity provider that is down does not stop a server from serving local auth. A discovery document whose `issuer` is not the configured one is refused (the mix-up defence), and every endpoint must be `https` unless the issuer itself is a loopback IP literal — the development carve-out — under which every plain-HTTP endpoint must itself be loopback, so a provider on this machine cannot send the code off-box in the clear. Signing keys are refetched on an unknown `kid`, at most once a minute, so a stream of forged key ids cannot make the server hammer the provider — and re-read after an hour regardless, because a key the provider *revoked* never produces that evidence; the ceiling is what stops it being honoured. A provider behind a private CA is reached with `OIDC_CA_BUNDLE`, a PEM bundle of additional trust anchors read at boot. +- **Accounts are keyed on `(issuer, subject)`, and never linked by address.** The first sign-in for an unknown pair creates a password-less account. An IdP-asserted `email` that already belongs to an account is `409 error.auth.oidc_address_taken`, never a link: the address is a claim the provider controls, and honouring it as a link key would hand the matching account to anyone who can set an email at the provider — the same class of takeover the [profile surface](#the-profile-surface) refuses when it fixes the login address. The disclosure the `409` makes is the one registration already makes. **Only a verified address counts**, both ways: an address the provider asserts without `email_verified` reserves nothing and collides with nothing, or a person could register somebody else's address at the provider, unverified, and hold its owner out. Deliberately linking an existing account to a provider identity is a separate, authenticated ceremony, out of scope. +- **Deviation, named rather than substituted (issue #460).** Two of the properties above are owed rather than shipped, because the account port has no nullable credential yet and the federated rows do not share the password directory's table: the `409` is checked against *federated* accounts' verified addresses, not yet against password accounts' — and a password-less OIDC account is one no password row exists for, not yet one whose null credential `authenticate` refuses structurally. The test fixture's double encodes the intended contract; the Postgres adapter delivers it. +- **The OIDC door does not consult the password lockout.** The lockout counts failed *credential presentations* against the local directory, and a federated sign-in presents none — the provider already authenticated the person. Refusing single sign-on on a locked local account would let anyone who can guess passwords at `POST /v1/auth/login` lock a person out of the other door too. +- **The second factor is honoured, not bypassed.** A confirmed TOTP enrollment turns the callback into the same `202` challenge the password path issues, completed at `POST /v1/auth/login/verify-totp` with the advisory `cohort_hash` and `device_id` riding that completing request. Bypassing it would let an account that enrolled a factor be signed into without one through a second door. +- **Scopes are `openid email`, with no knob.** The address is the one claim the relying party reads, for the one decision it makes with it. `profile` is not requested: the display name is something the person sets, and asking the provider for it would have the server store a fact it declined to collect at registration. +- **`server-info` publishes `auth.oidc: { authorize, callback }`, or `null`.** Endpoints only — never the issuer, never the client id, never anything user-scoped. The presence of the record is how a login chooser decides whether to offer the path; without `OIDC_ISSUER` the authorize answers `404 error.auth.oidc_not_configured`. + +**Configuration** is six variables, read with the rest in `capsule-server/src/config.rs`: `OIDC_ISSUER` (absent means the path is off; `https`, or `http` on a loopback IP literal for development), `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (optional — absent is a public client, PKCE-only, which is what RFC 8252 §8.5 requires of a native app), `OIDC_REDIRECT_URL` (optional; held to the issuer's scheme rule), `OIDC_ALLOW_LOOPBACK_REDIRECT` (default off) and `OIDC_CA_BUNDLE` (optional; a PEM path, read at boot and refused by name if unusable). Half a relying party — an issuer with no client id, or the reverse — is a startup fault. Under the durable backends `OIDC_ISSUER` is refused by name until the Valkey ceremony store and the Postgres federated-account adapter land (issue #460); the development profile runs it on the in-memory adapters, and `capsule-server/compose.yaml` ships a dex service (`--profile oidc`) with a public `capsule` client to run it against. + +**Deviation from the validation plan, named rather than substituted.** [Validation](#validation) asks for a testcontainer IdP. The suite uses an in-process mock provider on loopback instead, because `mise run test-rust` runs offline and container-free; it speaks the identical wire — discovery JSON, a JWK Set, a form-encoded token `POST`, a signed compact JWS — and exercises key rotation, the refetch floor, a wrong PKCE verifier at the token endpoint, and every claim refusal. The dex service is the manual run against a real provider. + ## Identity and Discovery Patterns borrowed from Matrix 2.0, with one critical departure: **`.well-known/` never enumerates the user list**. A federated setting where a peer can list every user on a server is unacceptable — both from an abuse-surface perspective (spam, harassment-target discovery, account-enumeration attacks) and a privacy perspective. diff --git a/capsule-docs/src/content/docs/design/authorization.md b/capsule-docs/src/content/docs/design/authorization.md index 40ce4185..c97fb09d 100644 --- a/capsule-docs/src/content/docs/design/authorization.md +++ b/capsule-docs/src/content/docs/design/authorization.md @@ -48,6 +48,12 @@ The endpoint is deliberately singular — one closed enum, one gate, one transac The transport row lives in [API Surfaces](/design/api-surfaces/#surface--transport-map). Implementation is planned in `capsule-server::routes::ops` (slice `S-C16`, reusing the upload server's envelope gate). +## Album Membership on the Server + +Album sharing between accounts is an MLS group whose roster the server cannot read, by design. What the server holds instead is the album owner's **signed roster** — the whole member list, each with a role of `reader` or `writer`, under a strictly monotonic `roster_version` and the AMK epoch it reflects, signed by a non-revoked device in the owner's published device directory and published at `PUT /v1/albums/{album_id}/roster` (slice `S-C51`; [invariant 33](/design/threat-model/validation/#server-side-validation-invariants)). Only the owner account publishes; removal is a later roster that omits the member, and the server records the version and epoch at which they vanished rather than deleting the row. The version is bounded above as well as below — at most sixteen past the held one — because monotonicity alone would let a single publish latch the counter where nothing could supersede it and freeze the album's membership permanently; the refusal names the held version, and re-signing the same roster one above it says exactly the same thing. + +That stored fact widens two decisions that were owner-only until it existed. A **writer** on the current roster may write to the album through the upload path and `POST /v1/albums/{album_id}/ops`; the write is filed under the *owner's* namespace — the owner's feed is the one every member's devices read — and billed to the uploader. Any account on the roster, in either role, may fetch the album's blobs; a **former** member receives the `403` [Download & Sync](/design/import/download-sync/) describes; an account the roster never named gets the same `404` an unknown address gets. The roster is a transport control over who the server serves, never a confidentiality control: the server executes what the owner's signed statement permits, and, as below, authorizes nothing itself. + ## The Server Executes But Never Authorizes Per the principle of [trusting the server for storage, never for authorization](/design/cryptography/), the server **carries out** a remote delete or replace but is **never** the authority that permits it. A server-asserted lifecycle change with no valid write-tier signature is rejected by every client. This bounds the damage a compromised or buggy server can do: it can refuse to store data, but it cannot forge its destruction. diff --git a/capsule-docs/src/content/docs/design/backup-recovery.md b/capsule-docs/src/content/docs/design/backup-recovery.md index 658eeaa0..20b86219 100644 --- a/capsule-docs/src/content/docs/design/backup-recovery.md +++ b/capsule-docs/src/content/docs/design/backup-recovery.md @@ -107,6 +107,14 @@ Verification is **local-only**: the client keeps a cached copy of the escrow blo - **Re-arm triggers** (reset to the 7-day step): a new device enrolls (the prompt lands on the *new* device — it has never seen the passphrase); the recovery secret rotates; a restore-from-escrow completes. - Snooze steps are 24 h or 7 d, at most 3 consecutive. This is the `recovery_check_due` [alert class](/design/notifications/#alert-classes): the bounded-snooze-then-badge mechanic, and the rule that no alert blocks a critical flow, are owned by [Notifications](/design/notifications/); the cadence and re-arm triggers above are owned here. Because each step is a deadline the device can compute, the prompt is **pre-armed** ([pre-arm rule](/design/notifications/#the-pre-arm-rule)) rather than evaluated at launch. +**How the escalation reaches the alert.** The class set is closed, so `recovery_check_due` is the +only class that can report a due re-wrap as well as a due check. The scheduler therefore projects +into the shared predicate (`RecoveryCadence::notify_facts`) with the re-wrap state reported as +due **now** — whatever the ladder's next step says, since an explicit "I lost it" is not a +scheduled check — and carries which it is in the alert's `recovery` parameter (`check` or +`rewrap`). A client routes on that parameter: `rewrap` goes to the guided flow below, `check` to +an ordinary verification prompt. + ### On Repeated Failure: Guided Re-Wrap After 3 failures across ≥ 2 app sessions — or an explicit "I lost it" — the client runs the guided rotation flow: mint a fresh ≥128-bit recovery secret, **re-wrap the same master key**, replace the server escrow (a single active escrow; the old blob is deleted, so the lost secret unwraps nothing), re-run the setup-style type-back gate, re-issue Shamir shares if enrolled (old shares explicitly invalidated and surfaced as such), and surface the old-backup-artifact guidance from the [single-root invariant](#single-root-invariant). diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 548962bc..46b9be72 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -21,25 +21,28 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | Domain | Canonical choice | Scope | Exceptions | | --- | --- | --- | --- | -| Datetime | `jiff` | All domain logic — parsing, formatting, arithmetic. Signed and wire formats carry RFC 3339 strings or integer epochs, never a datetime library type, so the pin never touches serialized bytes. | `chrono` remains **only** as the sea-orm column type in `capsule-cli/entity`, converted to jiff at the entity boundary, and in non-buildable review-only server code under `legacy-review/`. (The last buildable non-entity holdout, the frozen GraphQL crate with its async-graphql `chrono` scalars, was removed with slice S-G1.) | +| Datetime | `jiff` | All domain logic — parsing, formatting, arithmetic. Signed and wire formats carry RFC 3339 strings or integer epochs, never a datetime library type, so the pin never touches serialized bytes. | `chrono` remains **only** as the sea-orm column type in `capsule-cli/entity`, converted to jiff at the entity boundary; in `capsule-server-migration`, which cannot avoid it because `sea-orm-migration` pulls sea-orm with its default `with-chrono` feature — which is precisely why `capsule-server` takes that crate as a **dev-dependency only**; and in non-buildable review-only server code under `legacy-review/`. (The last buildable non-entity holdout, the frozen GraphQL crate with its async-graphql `chrono` scalars, was removed with slice S-G1.) | | Error handling | `thiserror` in libraries; `eyre` + `color-eyre` in binaries | Libraries define typed error enums; binaries (CLI, server `main`, xtask) wrap them in reports. | `anyhow` is not used. | | Logging | `tracing` (facade) + `tracing-subscriber` (binaries) | All crates; structured fields and hot-path spans per the traceability rule in `AGENTS.md`. The `log` facade is forbidden in new code. | Remaining `log::` call sites in `capsule-core` / `capsule-core-ffi` migrate in slice S-F6. | | TLS implementation | `rustls` (with `tokio-rustls` as the async adapter) | Wherever Capsule code holds a TLS stack: the SDK's HTTP client, [LAN-peering](/design/peering/) mutual TLS (`tokio-rustls`), server egress, sea-orm's `runtime-tokio-rustls`. The `ring` provider is pinned for the peering stack so it never depends on an ambiguous process-default `CryptoProvider`. Never native-tls/openssl. | **None.** The one exception this row used to carry — `openssl` as a transitive dependency of `webauthn-rs` attestation-certificate verification — goes with passkeys (`S-C56`) and with the `capsule-server` tree that holds them. | -| X.509 leaf generation | `rcgen` | The per-connection self-signed leaf the [LAN-peering](/design/peering/) mTLS handshake presents. The certificate carries no trust of its own — peering is CA-less and identity is decided by the application-layer hybrid check — so the leaf is ephemeral. `ring` provider, matching the rustls pin above. | Server-facing certificates are operator-provisioned, not minted in-process. | +| X.509 leaf generation | `rcgen` | The per-connection self-signed leaf the [LAN-peering](/design/peering/) mTLS handshake presents. The certificate carries no trust of its own — peering is CA-less and identity is decided by the application-layer hybrid check — so the leaf is ephemeral. `ring` provider, matching the rustls pin above. Also a **dev-dependency** of `capsule-server`, with `rustls` and `tokio-rustls` at the SDK's exact pins, for the one test that serves a mock identity provider behind a private CA (`OIDC_CA_BUNDLE`, slice `S-N1`). | Server-facing certificates are operator-provisioned, not minted in-process. | | LAN service discovery | mocked seam (`capsule-sdk::peering::Discovery`) | Peering's mDNS advertisement/browse is behind a trait seam; the opaque, rotating descriptor is pure and unit-tested. A live responder (pure-Rust `mdns-sd`) is the sanctioned implementation to plug in — added by a follow-up slice with its own row, since a live multicast responder is non-deterministic and untestable in CI. | — | | Identifiers | `uuid` — **UUIDv7 for every newly introduced identifier** | Time-ordered v7 is the default (index locality); the assignment of existing ids is owned by [Metadata — Identifiers](/design/metadata/#identifiers). | UUIDv4 where an id must not leak creation time (e.g. `device_id`). Capability-bearing opaque ids (share links, drops) are not UUIDs at all — they carry their own ≥128-bit entropy per their owner docs. | | Async runtime | `tokio` | All async code. | — | | HTTP server | Kynos | All `capsule-server` REST/OpenAPI surfaces, including sync and federation. | No secondary public transport. | | Kynos sourcing | `kynos = { version = "0.1.0", features = ["openapi32"] }` — from **crates.io** | Kynos published 0.1.0 on 2026-08-29, which discharges the repin-on-publish exit this row previously carried: the git dependency and its pinned rev are gone, so a bump is an ordinary reviewed version change rather than a rev audit. Tokio-only, MSRV **1.85**, edition 2024. `openapi32` is a strict, purely additive superset of the default `openapi31`. **Enabling the feature does not by itself make the document 3.2**: `Router::openapi()` emits the *lowest* version expressing the API without loss, deliberately not keyed on the feature, because Cargo unifies features across a dependency graph and a document's version must not follow a flag an unrelated crate turned on. Capsule therefore pins the version explicitly with `openapi_as(SpecVersion::V3_2)` — the case Kynos names as *"a consumer's toolchain pins a version"*, and one that targets rather than downgrades, so an unexpressible construct is an error naming what blocks it, never a document with operations quietly missing. | Master has moved past the 0.1.0 tag (`512adbc7`) — the delta is docs/CI plus `Accept-Language` negotiation, which Capsule does not adopt (error codes are localized client-side, offline). Repin at 0.2 if a released feature is needed. | | HTTP body | `http-body-util` | `capsule-server`'s coded-problem interceptor (`S-C36`) only: it reads a rendered RFC 9457 body back before putting it down again, and Kynos's `Body` is an `http_body::Body` with no inherent collector. Already in the lock file through Kynos and hyper, so it adds nothing to the tree. | Not a general HTTP abstraction: nothing else in Capsule touches a body outside a typed extractor, and a second use is a sign something is bypassing one. | -| HTTP client | `reqwest` (`default-features = false`, `rustls-tls`) | `capsule-sdk` — the sanctioned network path. | — | -| REST client codegen | `spargen` **0.4.0** (in-house, OpenAPI 3.1.x **and 3.2.x**) | `capsule-sdk` **build-dependency only**: `build.rs` lowers the committed `capsule-sdk/openapi.json` — emitted deterministically from the Kynos server's own OpenAPI document — into the typed `rest::Client`, wrapped by `client::AuthenticatedClient` (slice `S-D8`). Its runtime support is embedded into the generated module, so spargen never enters the SDK's runtime tree. Since 0.3.0 it enforces **runtime dependency contracts**, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest — bump those together or generation fails. Its API changed in 0.3: `Config` split into `Spec`/`Build`, and `Report::outcome` is a method (a `Cached` outcome is a success, not a failure). | Progenitor is gone. **The two exclusions this row used to carry are lifted**: object-typed query params and binary bodies both lower correctly as of 0.2.2, so the byte-serving surface is generated rather than hand-written. What *stays* hand-written is orchestration, not parsing — the resumable upload state machine (`S-D1`), token refresh, sync, recovery and protocol-version negotiation. Four Salvo-emitted operations are narrowed with `spargen::omit!` because they are structurally invalid; see the gates table in `SLICES.md`. | +| HTTP client | `reqwest` (`default-features = false`, `rustls-tls`, `json`) | `capsule-sdk` — the sanctioned client network path — and `capsule-server`'s OIDC relying party (slice `S-N1`), the one server egress: the discovery document, the JWK Set and the form-encoded token exchange against the configured identity provider, and nothing else. Pinned once in the workspace manifest; the SDK adds `stream` and `multipart`. The `rustls-tls` feature selects rustls's `ring` provider, the same one the SDK and the peering stack pin, so the two crates that hold a TLS stack agree. | A second server egress is a sign something is bypassing the relying party's adapter. | +| OIDC relying party | `jsonwebtoken` (existing, `aws_lc_rs`) + hand-written discovery, JWKS cache and token exchange in `capsule-server::auth::oidc` | Slice `S-N1`. Signature verification against a JWK Set is the workspace's JWT crate (`JwkSet::find`, `DecodingKey::from_jwk`); the discovery fetch, the key cache with its unknown-`kid` refetch floor, the form `POST` and every claim check are Capsule's, because each of those is a security decision this repository wants legible ([Authentication — OIDC](/design/authentication/#signing-in-through-an-identity-provider)). No crate enters the lock file. | `openidconnect` 4.0.1 was priced and rejected: its manifest declares `chrono` (banned; the exception list above does not include it), the `log` facade (banned; S-F6 removes it), `rsa 0.9.2` carrying RUSTSEC-2023-0071 with no fixed release — which `deny.toml` would not catch, since only the licence check is wired — and duplicate majors of `base64` and `thiserror`. | +| REST client codegen | `spargen` **0.4.0** (in-house, OpenAPI 3.1.x **and 3.2.x**) | `capsule-sdk` **build-dependency only**: `build.rs` lowers the committed `capsule-server/openapi.json` — emitted deterministically from the Kynos server's own types — into the typed `rest::Client`, wrapped by `client::AuthenticatedClient` (slice `S-D8`). Its runtime support is embedded into the generated module, so spargen never enters the SDK's runtime tree. Since 0.3.0 it enforces **runtime dependency contracts**, which set the floors on `bytes`, `reqwest`, `serde` and `serde_json` in the root manifest — bump those together or generation fails. Its API changed in 0.3: `Config` split into `Spec`/`Build`, and `Report::outcome` is a method (a `Cached` outcome is a success, not a failure). | Progenitor is gone. **The two exclusions this row used to carry are lifted**: object-typed query params and binary bodies both lower correctly as of 0.2.2, so the byte-serving surface is generated rather than hand-written. What *stays* hand-written is orchestration, not parsing — the resumable upload state machine (`S-D1`), token refresh, sync, recovery and protocol-version negotiation. Four operations are narrowed with `spargen::omit!` — the four Capsule serves as `application/cbor`, a media type spargen's `classify_media` does not know. The Salvo-era narrowings, which were structurally invalid operations, are gone: Kynos cannot express either defect. See the gates table in `SLICES.md`. | | Second factor | `totp-rs` (`otpauth`, `gen_secret`) | The RFC 6238 codes of the local auth path's second factor (slice `S-C55`), in `capsule-server`'s `auth::totp` alone. The parameters are Capsule's and are published as constants — SHA-1, six digits, a thirty-second step, one step of drift — because an authenticator app assumes all four and a deployment that changed one would issue provisioning URIs that silently mis-generate. What the crate does **not** own is replay: a code is accepted at most once, and that is a compare-and-set in the enrollment store, not an algorithm. | The crate's own `skew` is deliberately unused: Capsule walks the drift window itself because it needs to know *which* step matched, and `check` reports only that one did. | | Constant-time comparison | `subtle` | The one place a secret-derived value is compared byte for byte: the second factor's code check (`S-C55`). A hand-rolled fold is what an optimizer is free to short-circuit, and the resulting code looks correct forever. Already in the tree under `aes-gcm`, so this promotes a transitive dependency rather than adding one. | Password and manifest comparisons do not use it — a password never rises above its adapter, and signature verification is the signature crate's own constant-time path. | | WebAuthn | **none — passkeys are not in v1** | Slice `S-C56`. `webauthn-rs` survives only inside the retiring `capsule-server`, and leaves the workspace with it. | **This is why `openssl` leaves the tree.** `webauthn-attestation-ca` was the sole edge pulling it in, for attestation-certificate parsing — never as a TLS stack, which is the carve-out the TLS row recorded. With passkeys deferred, the carve-out is spent and the TLS rule holds without exception. A rebuild reopens both. | | ORM | `sea-orm` (`sqlx-postgres` on the server, `sqlx-sqlite` in the CLI) | The rebuildable index databases only — sidecars stay canonical per [Principles](/design/principles/). | — | +| Volatile state (Valkey) | `redis` (redis-rs) **1.2.2** — `tokio-comp`, `connection-manager`, `tokio-rustls-comp`, `script` | `capsule-server`'s `store::valkey` and `counter::valkey` (slice `S-C29`'s owed half, #403): the six state ports and the counter port over one `ConnectionManager` — a multiplexed, self-reconnecting connection, no pool. Primitives: hashes for records, sets and one sorted set for the derived indexes, lists for the relay mailboxes, `PEXPIRE` as the collector, and one Lua script (`EVALSHA`, `SCRIPT LOAD` on `NOSCRIPT`) per multi-key mutation or decide-and-write — `claim_finalize`, `consume`, `redeem`, the counter's `hit`. Expiry is decided by the injected `Clock` and written into each record, so the [conformance suite](/design/filesystem/server/#required-services) drives it exactly as it drives the in-memory double; TLS (`rediss://`) terminates in rustls. Container test: `testcontainers-modules` (`valkey`), env-gated. | No `bb8`/`bb8-redis` (pinned in the workspace, consumed nowhere): a pool buys `WATCH`/`MULTI`, which needs an exclusive connection across the read-then-write window the scripts exist to close. No Redis Cluster: scripts derive a few keys from records they read, which is legal only on a standalone server. No `object_store` or generic TTL/CAS crate, per the Rust Architecture Decisions. | | Embedded SQLite | `rusqlite` (`bundled`) | `capsule-core`'s `library.sqlite`. | — | | Vector index | `sqlite-vec` (`vec0`) | The client-local embedding index in `capsule-core`'s `library.sqlite` — per-task `vec0` virtual tables under the [embedding-provenance](/design/ai/#embedding-provenance) invariant. Optional + `native`-gated alongside `rusqlite` (registers as a SQLite auto-extension; not `wasm32`). | Server-side vector-DB idioms (pgvector/HNSW) do not apply — the index is client-local SQLite by design. | +| Still decode / encode | `rawshift-image` **0.1.1** (`default-features = false`, features `jpeg-decode`, `png-decode`, `jxl`, `tiff-decode`, `gif-decode`; the JPEG/PNG **encoders** ride `[dev-dependencies]`, so `jpeg-encoder` and its conjunctive IJG arm stay out of every release binary while remaining in the graph `cargo deny --all-features` inspects — the exception and its NOTICE section stay matched rather than going stale) | `capsule-core::media` behind the `media` feature, which `native` implies (slices `S-B1`, `S-B13`) — format sniffing, pixel decode, EXIF orientation and the derivative byte encode. A **registry** dependency, not the pinned `rawshift/` submodule: that tree is an uninitialised newer v1-in-progress checkout and not a workspace member. Depended on directly rather than through the `rawshift` facade because only the per-crate dependency gives per-format Cargo control, which the crate's own docs recommend and which this row needs — the format set is a licence, build-host and **portability** decision, not a convenience. **Every enabled codec is pure Rust and links no C**: zune for JPEG/PNG decode and encode, `jxl-oxide` for JXL decode, `zune-jpegxl` for the JXL encode that produces the thumbnail tier, plus `tiff` and `gif`. MPL-2.0 (with `rawshift-core`), already allow-listed in `deny.toml`; both are named in the root `NOTICE` MPL list. `jpeg-encoder`'s conjunctive IJG arm was already excepted and is matched again by this row. Tiers, quality and the closed format set are the contract at [Thumbnails](/design/thumbnails/); this row owns the pin. | **`webp` is absent because it does not compile, not because it was not wanted.** It was the first choice — `image/webp` is in the format table and `libwebp` has the exact q=50 knob — but `rawshift-image`'s WebP module passes `*const i8` where `libwebp-sys` 0.14.4 declares `*const c_char`, and `c_char` is `u8` on aarch64, so it is an E0308 on every 64-bit ARM target; the module is compiled by decode *or* encode, so decode-only does not escape it. Every mobile target is aarch64, so enabling it would mean thumbnails on desktop and none on a phone. **Also deliberately absent**, each a toolchain rather than a design gap: `heic` (system libheif), `avif` (`image`'s `avif-native` -> system libdav1d for decode; `ravif` -> `rav1e/asm` -> `nasm` on every x86_64 build host for encode), `svg` (resvg), and the RAW families (`experimental`/`raw-stabilizing`; Canon CR3 pixel decode is unimplemented upstream). And the JXL encode is **lossless** — `zune-jpegxl`'s `JxlSimpleEncoder` — so a thumbnail costs more bytes than the table's q=50 intends; a lossy JXL needs C libjxl (`bindgen` + `pkg-config`). Every one of these is a typed `media::MediaError::UnsupportedFormat` or a recorded per-format deferral, never a silent gap. **Not** on the wasm32 sealing surface: `media` is absent from the `--no-default-features` build, so `cargo tree --target wasm32-unknown-unknown -i rawshift-image` is empty. Rawshift must never wrap Chromahash (`AGENTS.md`); see the LQIP row below. | | LQIP placeholder codec | `chromahash` **0.7.1** | `capsule-core::lqip` (slice `S-B14`) — the only encoder/decoder for the signed sidecar `lqip` field. Imported **directly**, never through Rawshift (`AGENTS.md`), and deliberately outside `capsule-core::media` — the Rawshift-consuming module — so one implementation serves the import pipeline, the uniffi FFI, and `capsule-wasm`. The tier, byte width and versioned fallback are the contract at [Thumbnails — LQIP](/design/thumbnails/#lqip); this row owns only the pin. The `AGENTS.md` gate that read "after its v1 release" is **amended to 0.7.1** — the release the project accepts as ready — and `xtask`'s architecture check stopped forbidding the crate in `2f8beeb`, because a check that forbids an approved dependency has stopped describing a decision and started blocking one. | **`thumbhash` is retired, not excepted.** The Rust crate behind `capsule-core`'s `media` feature and the npm package in `capsule-web` both go; `thumbhash` stays in the architecture check's retired-dependency list so it cannot return. BlurHash was never adopted. | | Free-space probe | `rustix` (Unix, `fs`) + `windows-sys` (Windows, `Win32_Storage_FileSystem`) | `capsule-core::library::available_bytes` — the streaming-import free-space probe (`statvfs` / `GetDiskFreeSpaceEx`). Host-only, behind the `native` feature; the wasm32 sealing build links neither. | — | | Windows TPM (TBS) | `windows-sys` (Windows, `Win32_System_TpmBaseServices`) | `capsule-core::crypto::keys::tbs` — the Windows device-key `HardwareSigner` (slice S-F4). The raw TPM 2.0 command channel (`Tbsi_Context_Create` / `Tbsip_Submit_Command`) the tss-esapi reference (`crypto::keys::tpm`, Linux) wraps; links `tbs.dll` via raw-dylib, so no new crate — an extra feature on the existing `windows-sys` row. `#[cfg(windows)]`-gated; the pure wire codec + mock tests run on any host. | Not tss-esapi on Windows: TBS is native and avoids the `libtss2`/bindgen build. | @@ -80,4 +83,14 @@ Bindings likewise ride the uniffi strategy (S-F1); JNA loads the produced librar ## Validation -The pins are enforced structurally, not by convention: `cargo tree -i chrono -e no-dev` must resolve to `capsule-cli/entity` and sea-orm internals only; `cargo tree -i thumbhash` must resolve to nothing and `rg thumbhash capsule-web` must be empty (the architecture check's retired-dependency list holds the Rust half); `rg 'log::'` outside the S-F6 scope, and any `openssl`/`native-tls` edge outside webauthn-rs, are review-blocking. The per-platform `mise run check-*` gates run the pinned toolchains. +The pins are enforced structurally, not by convention. + +**The chrono gate is per package**, and the distinction is not pedantry. Cargo unifies features across the packages a workspace build selects, and `capsule-cli` inherits sea-orm *with* its defaults — so a workspace-wide `cargo tree -i chrono -e no-dev` lists every member that depends on sea-orm at all, whatever that member's own manifest asks for, and no edit to `capsule-server` can change that while it stays true of `capsule-cli`. The command whose answer is a decision is therefore + +```sh +cargo tree -p -i chrono -e no-dev # must print nothing +``` + +for every crate outside the exceptions above, and `mise run architecture-check` runs it (`xtask`'s `check_chrono_isolation`) rather than leaving it to be typed. The accepted holders are `capsule-cli/entity` and `capsule-server-migration`; the check asserts the second **still** reaches chrono, so the exemption cannot outlive its reason, and it fails closed when `cargo tree` itself fails. + +The rest: `cargo tree -i thumbhash` must resolve to nothing and `rg thumbhash capsule-web` must be empty (the architecture check's retired-dependency list holds the Rust half); `rg 'log::'` outside the S-F6 scope, and any `openssl`/`native-tls` edge outside webauthn-rs, are review-blocking. The per-platform `mise run check-*` gates run the pinned toolchains. diff --git a/capsule-docs/src/content/docs/design/developer-docs.md b/capsule-docs/src/content/docs/design/developer-docs.md index 1d7763d0..c362b0ee 100644 --- a/capsule-docs/src/content/docs/design/developer-docs.md +++ b/capsule-docs/src/content/docs/design/developer-docs.md @@ -11,9 +11,10 @@ resulting page lands. It does not decide which surfaces exist: that is the obeys are [API Practices](/development/api-practices/). Implemented in `capsule-docs/` (Astro + Starlight) plus one description emitter per surface, living -in the crate that owns the surface and driven by its `mise` task. Every surface named below is -**Planned** or **Blocked** — `capsule-docs/src/content/docs/reference/` holds no generated content -today. +in the crate that owns the surface and driven by its `mise` task. Two surfaces have landed: the REST +contract at `/reference/api/` and the command line at `/reference/cli/`, each generated from a +committed description artifact by `capsule-docs/scripts/gen-reference.mjs` and each drift-gated in +the Rust gate. The rest are **Planned**. ## The Problem @@ -49,7 +50,7 @@ Each surface's owning gate emits a **description artifact**: a small, committed, file describing the surface. The docs build reads committed artifacts and nothing else. It never invokes cargo, uniffi, or wasm-bindgen. -`capsule-sdk/openapi.json` already is such an artifact — emitted by a state-free binary, refreshed by +`capsule-server/openapi.json` already is such an artifact — emitted by a state-free binary, refreshed by `mise run openapi-kynos`, and drift-gated by `mise run openapi-check-kynos`. This rule generalizes that shape to every other surface rather than inventing a second mechanism for each. @@ -83,8 +84,8 @@ in the docs gate, which cannot run it. | Surface | Description artifact | Emitted by | Drift gate | Page | Status | | --- | --- | --- | --- | --- | --- | -| REST | Kynos OpenAPI 3.2 document | `capsule-server::openapi()` via an emitter binary | `openapi-check-kynos` | `/reference/api/` | **Emitter and gate landed; rendering blocked** | -| CLI | command-tree JSON, man pages, shell completions | `capsule-cli` (clap) | new `--check` on the dump | `/reference/cli/` | Planned | +| REST | Kynos OpenAPI 3.2 document | `capsule-server::openapi()` via `gen_openapi` | `openapi-check-kynos` | `/reference/api/` | **Landed** | +| CLI | command-tree JSON | `capsule_cli::cli::command_tree()` via `gen_cli_surface` | `cli-surface-check` | `/reference/cli/` | **Landed** | | Rust SDK | rustdoc HTML (uncommitted) | `cargo doc -p capsule-sdk` | broken intra-doc links denied | `/reference/sdk/rust/` → `/reference/crates/` | Planned | | Swift bindings | uniffi surface JSON, dumped from the compiled cdylib | a dump step on `mise-tasks/gen-bindings` | new `--check` on the dump | `/reference/sdk/swift/` | Planned | | Kotlin bindings | as above | as above | as above | `/reference/sdk/kotlin/` | Planned | @@ -96,10 +97,12 @@ Each therefore needs a small committed dump alongside its existing generation st symbol-presence assertions already in `mise-tasks/gen-bindings` are the seed of that dump — they already enumerate the verbs each binding must export — but they assert, they do not yet emit. -**Why REST is blocked.** The committed `capsule-sdk/openapi.json` is emitted from the retired Salvo -server. Its Kynos replacement exposes `openapi() -> Document` but has a single route ported and no -emitter binary, so no Kynos document exists yet. The REST reference is generated from the Kynos -document when there is one; publishing the Salvo-derived file would document a server nothing runs. +**How REST got unblocked.** It was blocked on there being no Kynos document to publish: the +committed contract was emitted from the retired Salvo server, and publishing that would have +documented a server nothing runs. `capsule-server/openapi.json` is now the Kynos document — emitted +by `gen_openapi` from the route types, gated by `openapi-check-kynos` — and `/reference/api/` +renders it. The CLI followed the same shape rather than a second mechanism: a `command_tree()` dump, +a `gen_cli_surface` emitter, and `cli-surface-check` in the same gate. **What is deliberately not a reference surface:** diff --git a/capsule-docs/src/content/docs/design/federation.md b/capsule-docs/src/content/docs/design/federation.md index 712d6ca7..83c761dc 100644 --- a/capsule-docs/src/content/docs/design/federation.md +++ b/capsule-docs/src/content/docs/design/federation.md @@ -6,7 +6,7 @@ status: draft Federation lets an album owned on one Capsule server be shared with users whose accounts live on another. This document covers **server-to-server** federation only; direct device-to-device sync for a single user is [Peering](/design/peering/). -Federation reuses the planned Kynos REST read primitives — `/sync`, `/blob/{hash}`, and the standard manifest envelope. The only new things federation introduces are a **capability token** (the contract that gates which peers may fetch what) and a **per-peer compartmentalization layer**. Capability issuance, verification, the pull path, and per-peer rate budgeting will live in `capsule-server::federation`. +Federation reuses the Kynos REST read primitives — `/sync`, `/blob/{hash}`, and the standard manifest envelope. The only new things federation introduces are a **capability token** (the contract that gates which peers may fetch what) and a **per-peer compartmentalization layer**. Capability issuance, verification, the capability arm of the read path, and per-peer rate budgeting live in `capsule-server::federation`. ## Threat Model @@ -110,10 +110,31 @@ Signed under the home server's signing key — classical Ed25519 only, per the [ 1. **Issuance.** A user on `home.tld` shares an album with `alice@other.tld`. `home.tld` mints a capability token for `other.tld` and delivers it as part of the share-invite message to Alice's client. Alice's client posts the token to `other.tld`; `other.tld` caches it server-side and uses it on every subsequent pull. 2. **Verification.** Capsule (the verifier, `home.tld` in this case) verifies the token offline against its own published signing key — no third-party PKI, no network call to a notary except for key rotation (see [Server Identity and Key Rotation](#server-identity-and-key-rotation)). -3. **Refresh.** A token nearing `exp` is replaced by `other.tld` requesting a new one on Alice's behalf; the request is itself authenticated by the previous token. Idempotency keyed by `(peer_id, jti)` per [Threat Model — Idempotency Invariants](/design/threat-model/validation/#idempotency-invariants). +3. **Refresh.** A token nearing `exp` is replaced by `other.tld` requesting a new one on Alice's behalf; the request is itself authenticated by the previous token. Idempotency keyed by `(peer_id, jti)` per [Threat Model — Idempotency Invariants](/design/threat-model/validation/#idempotency-invariants). **A refresh may never outlive the grant.** "Same peer, same album, same member" does not bound a chain of refreshes: a successor with a fresh `exp` is a grant that outlives the lifetime its owner chose, so a sixty-second capability would become an indefinite one after a single hop and revoking a `jti` the owner never saw would be the only remaining control. The issuing server therefore fixes an **absolute deadline** at the original mint, keeps it on its own record — never as a claim, because the token format is normative and parsed by every peer — and copies it unchanged into every successor. **Renewability is asked for, not assumed:** the default deadline is the first token's own `exp`, so a grant is single-shot unless the owner said otherwise, and each successor is minted for `min(default TTL, deadline − now)` so the last token of a grant ends at the deadline rather than past it. A refresh past it, or of a grant that was never renewable, is `403 error.federation.capability_expired` — the end of the sharing relationship rather than of one token, and a peer's cue to ask the album's owner rather than this server. The refresh also re-checks that the member the grant was minted for is still on the album's roster at the epoch it was granted at; otherwise it is `409 error.federation.member_not_on_roster`, because a successor for a membership that has ended is a token that can never be used. 4. **Revocation.** Revocation is a short TTL (`exp ≤ 24h`) plus a published **revocation list** at `/.well-known/capsule/revoked-jti`. Peers fetch and cache the list with a **maximum staleness of 15 minutes**. A peer holding a revoked-but-not-yet-expired token will still be honored for up to 15 minutes after revocation — this is the deliberate trade-off between revocation latency and revocation-list polling overhead. **List unavailability fails closed:** a verifier that relies on a *cached* copy of an issuer's revocation list and cannot refresh it must reject, past the 15-minute bound, any token whose `jti` it can no longer confirm against a current list — it never honors tokens indefinitely on a stale list. The `exp ≤ 24h` ceiling caps the worst case regardless, but the explicit rule means revocation cannot be outlived by making the list unreachable. (A server verifying its *own* tokens checks its own always-fresh list and is never stale.) Revoked `jti`s are **pruned** from the published list once their `exp` passes — an expired token is rejected unconditionally anyway — so the list stays bounded by at most 24 hours of revocations. 5. **Expiry.** A token past `exp` is rejected unconditionally; the verifier returns `401` and the peer must obtain a fresh token before continuing. +### What a scope hides, and what it does not + +`read-derivative-only` is a **transport** control, exactly as the capability itself is: it decides which bytes this server hands over, not which identifiers a peer learns. A peer holding one receives every asset's full sync entry — including the `role`, `hash` and `size` of the original it may not fetch — and is refused only at `GET /v1/blob/{hash}`, with `403 error.federation.scope_insufficient`. + +That is deliberate, and the alternative is theatre. Every sync entry carries the asset's **signed manifest, as the exact bytes the client uploaded**, and the manifest's `ciphertext_hash` *is* the original's content address. A peer must receive that manifest to verify anything at all, and the server cannot rewrite it — a re-serialized manifest is one detached from its signatures, which is a manifest nobody can verify ([Download & Sync](/design/import/download-sync/#discovering-what-changed)). So filtering the entry's `blobs` array would remove an address that is still present, unfilterable, two fields away, while making the feed inconsistent with the manifest beside it. A control that hides nothing and costs consistency is worse than an honest boundary. + +What a derivative-only grant therefore does **not** promise: that the peer cannot learn an original exists, or its address, or its size. What it does promise, and enforces: the bytes are never served. Anyone needing the stronger property wants a separate album, not a narrower scope — the confidentiality boundary in Capsule is the MLS album key, and it always was. + +A **backup** is outside every scope and is answered `404`, not `403`: it is the owner's own durability artefact rather than part of what was shared, and the feed never names one, so a peer holds no fact about it that a `403` would be acknowledging. + +### Status note (2026-09-09) + +The **serving** half of everything above ships (`S-E2`, `S-E5`, `S-C49`). + +- `POST /v1/albums/{album_id}/capabilities` mints for the album's owner, over the same Ed25519 key `server-info` publishes; `POST /v1/federation/capabilities/refresh` presents the previous token and is idempotent on `(peer, jti)` because the store links a predecessor to its successor and re-signs the stored grant byte-for-byte; `DELETE /v1/albums/{album_id}/capabilities/{jti}` revokes. Revoking is deliberately **not** gated on the deployment federating: turning federation off must not remove an operator's ability to cut a grant already out there. +- The **capability store is the revocation list**. Revoking an issued capability sets its `revoked_at` and publishes its `jti` in one transaction, so "is this `jti` revoked" has one answer. Publishing a member's roster removal cuts every grant made for that member; blocking a peer cuts every grant it holds. An epoch bump that keeps the member cuts nothing — the member still holds their keys — and a takedown cuts nothing either, because it is a per-asset serving hold. +- The **pull is the read path**: `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` accept the capability on the same `bearer` component a session access token rides. Scope is enforced against each blob's server-visible role, and the grant is bound server-side to the epoch its member's membership was granted at, so a member removed and re-admitted later cannot reuse an older token. +- **Signed report intake** (`POST /v1/federation/reports`) and the **server-level blocklist** are built; peer keys are **operator-pinned** rather than TOFU-fetched, because this server has no outbound HTTP client. **Neither is reachable in a real deployment yet**, and for one reason: nothing can pin or block a peer. `boot::assemble` refuses the durable backend outright until [#403](https://github.com/Capsulsaurus/Capsule/issues/403) lands its adapters, so an operator command would only run against `--memory` and forget what it pinned; it is owed with [#476](https://github.com/Capsulsaurus/Capsule/issues/476). Until then intake answers `403 error.federation.peer_unknown` to every peer and the blocklist is empty. The route stays mounted so a peer has a published contract to implement against — what is missing is the command, not the surface. + +What is **owed** (issue #476): the egress pull worker and everything downstream of it — invariant-20 re-validation of what is pulled, the per-`(receiving_user, source_peer)` quota, the breadcrumb index, the soft-fail rejected-hash table, and accepting hints. Of the per-peer budgets below, only **events/hour** ships: bytes/hour and CPU/hour, the error budget, the circuit breaker and the probation tier need a weighted counter the counter port does not have, and nothing measures per-request CPU. `error.federation.circuit_open` is in the catalog and unused until they land. + This capability is a **transport-scoped control, not a confidentiality control**: it gates *who may fetch at all* (rate-limiting, anti-enumeration, clean revocation of a sharing relationship), nothing more. Confidentiality is already enforced by [MLS album membership](/design/cryptography/mls/) — without the album master key, fetched bytes are unreadable. ## Validation at the Boundary diff --git a/capsule-docs/src/content/docs/design/filesystem/maintenance.md b/capsule-docs/src/content/docs/design/filesystem/maintenance.md index e6fc49a6..ad7ad930 100644 --- a/capsule-docs/src/content/docs/design/filesystem/maintenance.md +++ b/capsule-docs/src/content/docs/design/filesystem/maintenance.md @@ -66,6 +66,7 @@ Repair follows directly from the data-integrity principle — *ephemeral data is | Index inconsistency | Index rebuilt from the sidecars and provenance chains — **safe, but not lossless**. Rebuild reconstructs every projection the signed sidecar carries, including the `hidden` register and, from the sibling chain, trash state and album. It cannot recover state that was never written to disk: an importer-formed stack placement lives only in index columns (slice `S-B15`), so a rebuild against a *lost* index loses it. Rebuild is therefore the repair path, never a schema-upgrade path — the catalog [migrates forward in place](/design/versioning/#client-catalog-migration). | | Orphaned sidecar (no original) | Expected when the [sync scope](/design/import/download-sync/#synchronization-scope) is metadata-only — not a fault. Flagged only if the scope says the original should be present locally, in which case the original is re-fetched from the server. | | Orphaned original (no sidecar) | The file is irreplaceable, so it is never deleted. It is moved to `.library/quarantine/` and surfaced to the user; the client attempts to re-derive a minimal sidecar from the file itself and the server index. | +| Unsigned pre-signed-path sidecar | Not a fault, but not readable either: the signed reader is the only reader (`S-D24`), so a rebuild reports the file and indexes nothing for it, and `Workspace::open` lists it as unmigrated. The explicit migration verb (`Workspace::migrate_unsigned_sidecars`) admits the asset as a signed create in place — the legacy bytes are copied verbatim to `.library/quarantine/{uuid}.cbor` with a sibling `.reason.json` first, and the whole legacy record is folded into the signed sidecar's `_unknown`, where the signature covers it. An original that is missing or hashes differently from its legacy record is refused, never re-signed. | | Malformed CBOR sidecar | The bytes are preserved — moved verbatim to `.library/quarantine/{uuid}.cbor` with a sibling `.reason.json` recording the parse error, and surfaced to the user. **Never silent-skipped:** a sidecar whose CBOR does not parse, whose required fields are missing, or whose `sidecar_schema` is above the client's max known is treated as a quarantine surface (see [Threat Model — Quarantine Surfaces](/design/threat-model/scenarios/#quarantine-surfaces)). The client attempts to re-fetch a current sidecar from the server before treating the asset as lost. | | Sidecar signature invalid | Same as malformed: quarantined, never auto-overwritten. The client re-fetches; a persistent failure surfaces the asset as "provenance broken" rather than silently dropping it. | | Corrupt original (hash mismatch) | If the asset also exists on the server, the ciphertext blob is re-fetched and its derivatives re-generated. If the corrupt copy is the only copy — this device was its uploader and it was never synced — it cannot be auto-healed and is surfaced loudly. | diff --git a/capsule-docs/src/content/docs/design/filesystem/server.md b/capsule-docs/src/content/docs/design/filesystem/server.md index b8ef996e..8414605e 100644 --- a/capsule-docs/src/content/docs/design/filesystem/server.md +++ b/capsule-docs/src/content/docs/design/filesystem/server.md @@ -13,7 +13,7 @@ Implementation is planned in `capsule-server::{blob,index}` behind a narrow Caps The server's state is split across **three required systems** — one code path, no optional profiles: - **Blob store** (filesystem) — the encrypted bytes of every asset. -- **PostgreSQL** — the authoritative durable index: ownership, album references, blob references, lifecycle state, and the pending-asset rows uploads create. +- **PostgreSQL 18** — the authoritative durable index: ownership, album references, blob references, lifecycle state, and the pending-asset rows uploads create. The major is part of the contract rather than a deployment preference: `capsule-server/compose.yaml` and `.env.example` ship 18, and the adapter conformance suite runs against `postgres:18` on **glibc** so what is tested is what is deployed. The libc is named because one property depends on it — the index orders asset ids under `COLLATE "C"` so that "asset-id order" means the same thing to the durable adapter and the in-memory double, and a musl image collates `en_US.utf8` byte-for-byte and would pass that case whether the adapter pinned the collation or not. - **Valkey** — volatile session state: upload-session records (offsets, status) keyed `upload:session:{id}`, with the store's native 24-hour TTL as the lifetime **cap** (the ≥1-hour survival floor and pressure-discard semantics are owned by [Upload Protocol — Session Lifetime and Discard](/design/import/upload-protocol/#session-lifetime-and-discard)); [auth session records](/design/authentication/) ride the same store. The durable/volatile split is design, not a tuning knob: the hot upload path — offset increments and status transitions — never touches the durable Postgres asset row, which is written exactly twice per upload (pending row at session creation, `uploaded` flip at finalization). A Postgres-resident session table would be a second implementation of the same contract; Capsule ships exactly one. @@ -36,7 +36,7 @@ Required means required. The server refuses to start without `VALKEY_URL` — it - **`{blob_root}`**: absolute path configured at server startup. The entire tree must be on a single filesystem so that finalization renames are atomic. - **`incoming/`**: live uploads. Each session owns a single append-only file `{upload_id}.bin`; accepted chunks are appended in order, and the 4 KiB chunk alignment keeps every write block-aligned. There is no per-chunk staging and no assembly step. See [Import — Upload Protocol: Append-Only Storage](/design/import/upload-protocol/#append-only-storage). -- **`blobs/`**: the finalized store, **sharded two levels deep on the content hash's own hex prefix**: `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, the filename being the [ciphertext content hash](/design/cryptography/primitives/) with a `.bin` suffix. This is the re-ratification the 2026-07-12 amendment demanded, and it overturns that amendment's flat namespace. The amendment argued the flat layout from the wrong sizing case: *no hot path enumerates the directory* is true and beside the point, because lookup is a `stat` at a known content address and costs the same flat or sharded. The cost lands on the **enumerations**, of which there are three, each a full `readdir` + `stat` of the store — the [integrity scrub](/design/filesystem/maintenance/#server-side-integrity-scrub)'s blob→row pass, the [refcount GC](#deletion-and-garbage-collection)'s orphan sweep, and the [index rebuild](#recovering-the-index-from-blobs-alone) — and a multi-million-entry flat directory is precisely where those hurt. All three are integrity or recovery paths, so they must stay affordable exactly when a deployment is already in trouble. Timing settles the rest: no deployment holds blobs to move, so the shard is free **now** and a version-bumped data move behind `.server/version` forever after. The store in the tree today is still flat; the shard is the target `capsule-server::blob` is built to, not a migration it performs. Shard directories are created on demand at finalization and the rename stays inside `{blob_root}`, so finalization remains atomic. A finalized blob is immutable. +- **`blobs/`**: the finalized store, **sharded two levels deep on the content hash's own hex prefix**: `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, the filename being the [ciphertext content hash](/design/cryptography/primitives/) with a `.bin` suffix. This is the re-ratification the 2026-07-12 amendment demanded, and it overturns that amendment's flat namespace. The amendment argued the flat layout from the wrong sizing case: *no hot path enumerates the directory* is true and beside the point, because lookup is a `stat` at a known content address and costs the same flat or sharded. The cost lands on the **enumerations**, of which there are three, each a full `readdir` + `stat` of the store — the [integrity scrub](/design/filesystem/maintenance/#server-side-integrity-scrub)'s blob→row pass, the [refcount GC](#deletion-and-garbage-collection)'s orphan sweep, and the [index rebuild](#recovering-the-index-from-blobs-alone) — and a multi-million-entry flat directory is precisely where those hurt. All three are integrity or recovery paths, so they must stay affordable exactly when a deployment is already in trouble. Timing settles the rest: no deployment holds blobs to move, so the shard is free **now** and a version-bumped data move behind `.server/version` forever after. The store in the tree shards: `capsule-server::blob`'s filesystem backend writes `blobs/{hash[0:2]}/{hash[2:4]}/{hash}.bin`, creates each shard directory on demand at finalization and fsyncs it, and performs no migration — there are no deployments holding blobs to move. Shard directories are created on demand at finalization and the rename stays inside `{blob_root}`, so finalization remains atomic. A finalized blob is immutable. - **`.server/`**: the server operator's own configuration and schema version. This is plaintext server metadata, not user data — it is the one thing under `{blob_root}` that is not an encrypted blob. ### What the shard does not decide diff --git a/capsule-docs/src/content/docs/design/i18n.md b/capsule-docs/src/content/docs/design/i18n.md index f835717a..3eb3b8c3 100644 --- a/capsule-docs/src/content/docs/design/i18n.md +++ b/capsule-docs/src/content/docs/design/i18n.md @@ -75,7 +75,7 @@ never declared twice. | Target | Output | Status | | --- | --- | --- | -| Rust runtime | `capsule-i18n/src/bundles/.json` + `generated.rs` | Implemented | +| Rust runtime | `capsule-i18n/src/bundles/.json` + `generated.rs` | Implemented (ICU evaluated at runtime, plurals included) | | Web (FormatJS) | `capsule-web/src/i18n/messages/.json` | Implemented | | Android | `capsule-android/.../res/values[-]/strings.xml` | Implemented (literals) | | iOS/macOS | `capsule-swift/Generated/Localizable.xcstrings` + `InfoPlist.xcstrings` | Implemented (ICU compiled to Apple form, plurals included) | @@ -118,11 +118,75 @@ One limitation, and one outright defect: missing key returns the key itself, surfacing the gap rather than an empty string. There is no production-grade pure-Rust ICU MessageFormat *formatter* crate, so the -runtime ships a small interpreter over the same FormatJS grammar the web uses. It -currently handles literal text and `{name}` interpolation — the subset the catalog -exercises today; full `plural`/`select`/`number`/`date` formatting is follow-up. +runtime ships a small interpreter over the same FormatJS grammar the web uses. Native clients use their platform's own ICU machinery, which already covers the -full syntax. +full syntax; the Rust runtime is the one target with no platform underneath it, +so it is the only one that carries CLDR rules itself. + +- `Bundle::format(key, args)` and `format_message_in(locale, template, args)` + render **literal text**, **`{name}` interpolation**, and **`{name, plural, …}`** + with `=N` exact arms, CLDR category arms, `#` for the count, and nesting (an arm + may hold placeholders and further plurals). `format_message(template, args)` is + the locale-free entry point and means English. +- Arm selection comes from `capsule_i18n::plural`, an in-house CLDR **integer + cardinal** table covering the twelve language subtags in `locales/config.json`. + It is the same table `xtask i18n` filters Android `` arms with — + one table, two consumers, reconciled by test in both directions. +- A category the message does not carry falls back to `other`. That fallback is + load-bearing today: every translated plural is still an English `one`/`other` + copy, so a Russian `few` has nowhere else to go. +- **Not implemented, and refused rather than mis-rendered:** `select`, + `selectordinal`, `plural` with `offset:`, a `plural` whose arms do not parse, + `number`/`date` skeletons, and ICU apostrophe quoting (`'#'` for a literal `#`, + which the ahead-of-time generators do not implement either). A construct outside + the subset trips a `debug_assert!` where a developer sees it and is copied through + verbatim in release, so a release build gains no crash on a catalog it could + previously render badly. +- **One exception to the pass-through:** a `plural` whose arms parse but carry no + `other` asserts and then renders its **first arm in CLDR order**. `xtask i18n` + refuses to emit such a message, so it is unreachable from the catalogs; for a + hand-written template that slipped past the generator, rendering some text beats + showing the reader ICU source. An unterminated `{` is likewise not an assertion + case — a lone brace in literal copy ("50% off {sale") cannot be told apart from a + mistyped placeholder — so it is emitted as an ordinary character and scanning + continues past it. +- Numbers render as plain digits — no grouping separators, no locale digit shaping. + Doing that properly is `number` skeleton support, which needs CLDR number data + this runtime does not carry. + +## CLI help text + +The CLI's `--help` output **is** localized, resolved at parser-construction time +(slice `S-I8`). This is the decision that slice existed to make: the "no hardcoded +user-facing strings" contract applies to help pages as much as to any other line +a terminal shows, and the alternative — keeping help English and saying so — would +have left the one surface every new user reads first outside the contract. + +The mechanism, in `capsule-cli/src/cli/help.rs`: + +- Help is still *authored* as `clap` doc comments and attributes, which stay the + single place the English lives. The catalog holds a `cli.help.*` entry per string + under a key derived from the command tree — `cli.help..about`, + `cli.help..long_about`, `cli.help..arg.` and + `…arg..long_help`, where `` is the dot-joined subcommand chain + (`library.init`) and the root is `root`. Nothing is spelled twice. +- Before any argument is parsed, the binary walks the built `clap::Command` tree and + replaces each string with the catalog's message for its key, through the bundle + negotiated from `LC_ALL`/`LC_MESSAGES`/`LANG`. A key the bundle lacks leaves the + derive text in place, so a partially translated locale renders a mix and an + untranslated one renders English — never a raw key. +- A unit test asserts that every `en` entry equals the derive text it replaces, and + that localizing under `en` leaves every help page byte-identical. That test is the + gate for this surface: `i18n-guard` cannot see help text (no `println!` carries it), + so a doc comment edited without its catalog entry fails `cargo test -p capsule-cli` + instead. The committed `cli-surface.json` description artifact is resolved through + an explicitly pinned `en` bundle for the same reason, and is unchanged by + localization. + +**Residual gap:** a `ValueEnum` variant's help (`--filter pick` → "A keeper.") is not +localized. `clap` 4 can re-word a possible value only by replacing the typed +`value_parser` with a `PossibleValuesParser`, which trades typed parsing for a +translated word; the variants stay English until `clap` offers a seam that does not. ## Server error codes @@ -197,7 +261,15 @@ See [Validation Tiers](/design/principles/#validation-tiers). malformed entry (missing `message`) and an unsupported `sourceLocale`. - **Locale negotiation + formatting (unit).** `capsule-i18n` unit tests cover exact/primary-subtag/weighted matching, fallback to the source locale, message - interpolation, and missing-key behavior, against fixed vectors. + interpolation, and missing-key behavior, against fixed vectors. The CLDR plural + table is asserted cell by cell at the boundary counts, and its two halves — + which categories a language *lists* and which its rules *produce* — are pinned + equal. +- **Plural rendering (smoke).** Every plural key in every one of the thirteen + catalogs is rendered at eight boundary counts and asserted to contain no ICU + syntax; and, in `xtask`, to equal the sentence the generated Android + `strings.xml` produces from the same arm. That second one is the runtime/ + ahead-of-time agreement, checked against the real catalogs. - **Bundle load (smoke).** The embedded generated bundle parses and resolves keys (including an `error.*` code round-trip) end-to-end. @@ -214,7 +286,7 @@ unowned future work. - Hardcoded-string migration (web JSX, SwiftUI `Text`, Compose → catalog keys): **slice `S-I1`**. - The twelve-locale catalog rollout + RTL support: **slice `S-I2`**. - README translation pipeline: **slice `S-I3`** (see [README Translation](#readme-translation)). -- Full ICU `select`/`number`/`date` fidelity in the Rust runtime and the Android generator (`plural` is compiled for Apple today and is `S-I6` for Android). +- Full ICU `select`/`selectordinal`/`number`/`date` fidelity, and apostrophe quoting, in the Rust runtime and the Android generator (`plural` is compiled ahead of time for Apple and Android, and evaluated at runtime in Rust — slice `S-I7`). - Add the desktop target once its framework is chosen. (The iOS `.xcstrings` targets are wired and verified.) - Retrofit the remaining server error variants with codes; regenerate the OpenAPI spec / SDK. - Align the FFI `CatalogError` surface with the error-code scheme. diff --git a/capsule-docs/src/content/docs/design/import/download-sync.md b/capsule-docs/src/content/docs/design/import/download-sync.md index a6b2e314..9a42e436 100644 --- a/capsule-docs/src/content/docs/design/import/download-sync.md +++ b/capsule-docs/src/content/docs/design/import/download-sync.md @@ -15,12 +15,15 @@ A client never polls assets individually. It holds a single opaque **sync cursor | Surface | Transport | Purpose | | --------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/sync?cursor=…&page_size=…` | Kynos REST | Returns a page of asset changes (created, metadata-updated, deleted) after `cursor`, with a `next_cursor`. The feed is monotonic and resumable. | +| `GET /v1/sync?album_id=…&cursor=…` | Kynos REST | The same page for one **shared album**, read by its owner, by any account on its current roster (`S-C51`), or by a **federated peer** presenting a capability whose audience is that album (`S-E5`): the owner's sequence filtered to the album, so positions are per-album monotonic; the cursor is bound to `(caller, album)`, or to `(peer, album)` for a peer, which is its own scope so the two can never cross. Anyone else — never a member, removed, or no such album — receives one `403 error.sync.album_access_denied`; a peer that names a different album, or none, gets `403 error.federation.audience_mismatch`, because a peer has no feed of its own. The gaps between an album's positions reveal *how many* changes the owner made elsewhere, and nothing else; that volume-only metadata is accepted rather than paid for with a second, per-album numbering the anti-rewind mark would then have to reconcile. | | `GET /v1/blob/{hash}` | REST (HTTP `Range`) | Fetch a ciphertext blob by its content address; ranged for resumable and partial reads. | Each sync entry carries the asset's signed manifest as **the exact bytes the client uploaded** in its [provenance blob](/design/cryptography/provenance/#asset-manifest), the small encrypted **metadata blob**, and the asset's **blob manifest** — the content hashes of its original and derivative blobs — never original or derivative bytes. The manifest is passed through, not rebuilt: the server also holds an envelope projection of those same fields for its own key-free checks, but re-serializing *that* would hand the client a manifest detached from its signatures, which is a manifest no one can verify. Discovering a thousand new assets costs a few hundred kilobytes. The client decrypts each metadata blob, learns the asset's dimensions, capture date, and LQIP, and only *then* decides what else, if anything, to fetch. A deleted or modified asset arrives as a tombstone or an updated metadata reference; the client reconciles local state against it (see [Synchronization Scope](#synchronization-scope)). **Cursor authenticity.** The opaque sync cursor is **MAC'd by the server** (HMAC-SHA256 — a server-internal construction: the cursor is opaque to clients and never verified by them, so it sits outside the client-facing [primitives inventory](/design/cryptography/primitives/#primitives-inventory)) under a server-only key and verified on every `Sync` call (and [federation pull](/design/federation/#federation-reuses-existing-primitives)), so a client cannot forge or mutate a cursor and a cursor lifted from another context is rejected at the boundary. The MAC is the *authenticity* layer; the per-album monotonic `sync_seq` check below is the independent *anti-rewind* layer. They are separate on purpose: a malicious server can always hand back one of its own *older*, validly-MAC'd cursors, and only the client-held high-water mark defeats that. Together they close the [sync-cursor rewind class](/design/threat-model/scenarios/#damage-scenario--invariant-map). +**Operator note — deploying `S-C51` invalidates every cursor already issued.** The MAC now covers the *scope* a cursor was minted for (the caller, and the album when the page is an album's), so a cursor minted before that change verifies against different input and is refused as inauthentic. Clients recover by themselves — a refused cursor is a re-sync from zero, the same event a server key rotation is — but the recovery is one full feed read per client, so expect a burst of full-feed traffic on the first sync after the upgrade rather than the usual incremental pages. There is nothing for an operator to migrate: cursors are opaque, stateless and server-minted, and the version byte is deliberately unchanged so an old cursor fails as inauthentic rather than as malformed, which is the answer clients already handle. + **Sync feed validation.** Every entry in a `Sync` response carries a `protocol_version` (matching the album's pin) and a per-album monotonic `sync_seq` (a `u64`, strictly increasing per album). The client refuses to apply an entry whose `protocol_version` is above its max known (per the [tightened Postel's Law](/design/principles/#postels-law-asymmetric)) and refuses any page whose `sync_seq` regresses against what the client has already seen for that album — a regressing `sync_seq` indicates a malicious or buggy server attempting to rewind the client's view, and the client surfaces it rather than applying it. ## Stale-Revival Detection @@ -47,9 +50,9 @@ Because every blob is content-addressed, a fetch is skipped entirely when the bl **When an above-tier fetch cannot succeed.** A lazily-fetched representation may be temporarily or permanently unavailable. The client distinguishes the two: a **transient** failure (network drop, `5xx`) retries with backoff and resumes via `Range`; a **permanent** failure (`410 Gone`, a purged origin, or an unreachable [federated home server](/design/federation/#robustness-against-connectivity-loss)) **degrades gracefully** to the best representation already in hand. A **`403`** is neither: it signals an *authorization change*, not a durability loss — the client re-syncs its membership/capability state for the album before retrying, and only then degrades (the asset may have been unshared), so a revocation event is surfaced as such rather than masked as a missing file — preview → thumbnail → LQIP, down to the always-present LQIP — and surfaces a non-destructive "full resolution unavailable" state on the asset. It never thrashes the fetch, and it never removes the asset's metadata or local index entry over a missing derivative. The asset stays listed and re-fetches automatically once the representation becomes reachable again. -**What the home server can actually decide, as of `S-C39`.** `GET /v1/blob/{hash}` is **owner-scoped**: an account fetches the blobs of assets filed under it, and every other caller receives `404` — byte-identical to the answer for an address the server never heard of. That closes a real hole (previously any authenticated account could fetch any live ciphertext whose address it could name) and it fixes the `403`/`404` boundary at the only place that does not leak: a `403` confirms the address is referenced by *somebody*, so it is reserved for a caller the server can see once **had** access, and everyone else is told what an unknown address is told. +**What the home server decides, as of `S-C39` and `S-C51`.** `GET /v1/blob/{hash}` is **membership-scoped**: an account fetches the blobs of assets filed under it and of every album whose current roster names it, in either role; a **former** member — an account the roster once named and no longer does — receives the `403` above; and every other caller receives `404`, byte-identical to the answer for an address the server never heard of. `S-C39` closed a real hole (previously any authenticated account could fetch any live ciphertext whose address it could name) and fixed the `403`/`404` boundary at the only place that does not leak: a `403` confirms the address is referenced by *somebody*, so it is reserved for a caller the server can see once **had** access, and everyone else is told what an unknown address is told. -The `403` itself is therefore **not yet rendered on this path**, and the reason is a missing fact rather than missing code. Album sharing between accounts is an MLS group whose roster the server cannot read by design, and the surfaces that do let a non-owner reach ciphertext — `/s/{opaque_id}/blob/{hash}` and the drop paths — serve from their own capabilities and answer `404` when those are withdrawn. Server-side album membership is owed as `S-C51`, and it is the same fact that keeps album *write* access pinned to the owner. Until it lands, a client written against this contract sees the `403` arm only from surfaces that have a capability to revoke. +The fact behind the `403` is the album owner's **signed roster** (`PUT /v1/albums/{album_id}/roster`), verified against the owner's published device directory and stored as who is a member, since which roster version and AMK epoch, and at which version and epoch a member was removed. The server still cannot read the MLS group — the roster is the owner *telling* it, and it is a transport control over who is handed bytes, never a confidentiality control over who can read them: a former member who kept an epoch's key can still decrypt what they already fetched, which is why the client protocol pairs removal with an AMK epoch bump (the roster carries the new epoch; the server checks only that it does not regress). The surfaces that let a non-account reach ciphertext — `/s/{opaque_id}/blob/{hash}` and the drop paths — serve from their own capabilities and answer `404` when those are withdrawn; they do not route through membership. ## Resumption and Verification diff --git a/capsule-docs/src/content/docs/design/import/pipeline.md b/capsule-docs/src/content/docs/design/import/pipeline.md index 8888227e..6c0249e1 100644 --- a/capsule-docs/src/content/docs/design/import/pipeline.md +++ b/capsule-docs/src/content/docs/design/import/pipeline.md @@ -51,7 +51,7 @@ For each file in the plan, in [upload prioritization](#upload-prioritization) or Step 1–3 can be parallelized across files. The executor is cancellation-aware: a partially-executed plan can be aborted cleanly and resumed (re-running the import re-derives the plan and skips already-completed work via the deterministic planner). -**Status note.** The signed path in step 2 — encrypt, manifest, provenance — is implemented in `capsule-core::lifecycle::Workspace` and writes through to the shared `library.sqlite` index. The `import::executor` drives imports entirely onto this signed path (`S-B2`), and the legacy **unsigned** `AssetSidecar` write path has been removed (`S-G4`). The unsigned sidecar survives only as a *read* model: the recovery-first index rebuild (`capsule-core::library::rebuild`) still ingests unsigned `.cbor` sidecars left by pre-signed-path libraries. The executor's media half is being rebuilt over Rawshift (with Capsule calling Chromahash **0.7.1** directly); the old plaintext decode/extract path is review-only under `legacy-review/core-import-media/`. +**Status note.** The signed path in step 2 — encrypt, manifest, provenance — is implemented in `capsule-core::lifecycle::Workspace` and writes through to the shared `library.sqlite` index. The `import::executor` drives imports entirely onto this signed path (`S-B2`), and the legacy **unsigned** `AssetSidecar` write path has been removed (`S-G4`). The unsigned *read* model is gone too (`S-D24`): the recovery-first index rebuild (`capsule-core::library::rebuild`) reads one shape, the signed `SidecarV1`, and reports an unsigned `.cbor` left by a pre-signed-path library rather than indexing it. Such a library opens, names its unsigned files through `Workspace::unmigrated_sidecars`, and is brought forward by the explicit `Workspace::migrate_unsigned_sidecars` verb, which admits each legacy asset as a signed create (its verbatim bytes preserved under `.library/quarantine/`, its whole legacy record folded into the signed sidecar's `_unknown`) and then rebuilds the index. The executor's media half is being rebuilt over Rawshift (with Capsule calling Chromahash **0.7.1** directly); the old plaintext decode/extract path was deleted, and its live successors are `capsule_core::exif` and `capsule_core::import::{executor_cancellation, progress}`. ## Import-Upload Streaming Mode @@ -126,7 +126,7 @@ What the rest of the system depends on this module for: - `ImportPlan` — the deterministic output of the planner; rendered to the UI for confirmation. Schema fields: `added` (each entry carrying its resolved destination `album_id`), `skipped`, `conflicts`, `total_size`, `import_id` (UUIDv7), and `streaming_recommended` (set at confirmation from the [free-space probe](#plan--confirm), not by the pure planner). - `available_bytes() → u64` — the library volume's free space (a thin `statvfs` / `GetDiskFreeSpaceEx` wrapper in `capsule-core::library`); the input that decides `streaming_recommended`. -- `execute(plan, cancel_token) → ImportExecutionReport` — the executor entry-point, on the signed path (`S-B2`). Honors the cancel token at every file boundary. Returns per-file status. In [streaming mode](#import-upload-streaming-mode) it drives the per-asset import→upload→verify→release window instead of executing-then-uploading in bulk. Its media half is a planned rebuild over Rawshift plus Capsule's direct Chromahash integration, keeping the privacy mapping, encryption, signing, and commit boundaries Capsule-owned; the old plaintext executor is review-only under `legacy-review/core-import-media/`. +- `execute(plan, cancel_token) → ImportExecutionReport` — the executor entry-point, on the signed path (`S-B2`). Honors the cancel token at every file boundary. Returns per-file status. In [streaming mode](#import-upload-streaming-mode) it drives the per-asset import→upload→verify→release window instead of executing-then-uploading in bulk. Its media half is a planned rebuild over Rawshift plus Capsule's direct Chromahash integration, keeping the privacy mapping, encryption, signing, and commit boundaries Capsule-owned; the old plaintext executor was deleted, and its live successors are `capsule_core::exif` and `capsule_core::import::{executor_cancellation, progress}`. - A stable progress event stream so the UI can report per-asset state (queued / processing / encrypting / uploading / done / failed). ## Validation diff --git a/capsule-docs/src/content/docs/design/licensing.md b/capsule-docs/src/content/docs/design/licensing.md index 6368a2f9..a962fdc8 100644 --- a/capsule-docs/src/content/docs/design/licensing.md +++ b/capsule-docs/src/content/docs/design/licensing.md @@ -76,7 +76,7 @@ Licence identity alone does not settle whether a binary is distributable. How th ### The video transcoder seam -`capsule-core::media::video::derivative` defines a `VideoTranscoder` seam with no implementation: core links no media toolchain, and `capsule-sdk` injects a per-platform one — ffmpeg, AVFoundation, or MediaCodec — per [Thumbnails — Video Previews](/design/thumbnails/#video-previews) and slice `S-B5`. It is the most likely route by which copyleft enters Capsule, so the constraint is written before the code: +`capsule-core::media::video::derivative` **will** define a `VideoTranscoder` seam with no implementation — `capsule-core::media` is on `capsule-docs/planned-modules.txt` and the seam retired to `legacy-review/media-pipeline/` with the rest of the stack in slice `S-C59`, so this section is a constraint on the rebuild rather than a description of the tree. Core links no media toolchain, and `capsule-sdk` injects a per-platform one — ffmpeg, AVFoundation, or MediaCodec — per [Thumbnails — Video Previews](/design/thumbnails/#video-previews) and slice `S-B5`. It is the most likely route by which copyleft enters Capsule, so the constraint is written before the code: - **Prefer the platform framework.** AVFoundation (Apple) and MediaCodec (Android) are OS-provided, carry no third-party licence, and are the sanctioned implementations on those platforms. - **ffmpeg, if used, must be an LGPL-2.1 base build.** Never `--enable-gpl`. Never `libx264` or `libx265` — both are GPL, and enabling either makes the whole ffmpeg binary GPL. diff --git a/capsule-docs/src/content/docs/design/metadata.md b/capsule-docs/src/content/docs/design/metadata.md index f1e5ccec..11bb4e6a 100644 --- a/capsule-docs/src/content/docs/design/metadata.md +++ b/capsule-docs/src/content/docs/design/metadata.md @@ -156,7 +156,7 @@ Stripping happens at the moment of export — the encrypted sidecar inside the u Capsule's *own* devices syncing the *same user's* library do **not** trigger this redaction — that is intra-trust, not a boundary crossing. -**Status note.** The strip table is implemented in `capsule_core::metadata::export_policy` and applied **client-side, at the moment a share link is issued** — which is the only place it can be applied, since the server holds no key to the metadata a share serves. The server's complementary guarantee is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (slices `S-C4`, `S-C50`). Together these are the one export surface v1 ships, mandatory and with no opt-out. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. +**Status note.** Stripping is the **issuing client's** obligation, applied at the moment a share link is issued — the only place it can be applied, since the server holds no key to the metadata a share serves. `S-C50` settled *where*; the mandatory, no-opt-out rule survives unchanged and binds the issuing client. **No strip is implemented today.** An export-policy module once existed in the core crate but had zero call sites, and was removed rather than left standing as a documented control nothing enforced; no slice currently owns writing the real one. The server's complementary half **is** built and is containment: a share link serves only the content addresses its own record enumerates, so a stripped share cannot be walked sideways into the unstripped blob (`S-C4`). Together these are the one export surface v1 specifies — mandatory, with no opt-out — of which the containment half ships and the client-side strip does not. The external-backup handoff crossing waits on a client file-export command, which is post-v1; federated peers receive ciphertext, so their strip applies when a share boundary is crossed, not on the pull itself. ## Collaborative Metadata diff --git a/capsule-docs/src/content/docs/design/mls-resilience.md b/capsule-docs/src/content/docs/design/mls-resilience.md index 68832df9..acbb1e71 100644 --- a/capsule-docs/src/content/docs/design/mls-resilience.md +++ b/capsule-docs/src/content/docs/design/mls-resilience.md @@ -49,7 +49,7 @@ Across the failure modes above, Capsule's recovery posture is consistent: Reconciliation is a **single entry-point**, not per-failure-mode calls: the caller asks "bring me current" and the outcome enum reports what happened, including the two cases that escalate to user action or re-bootstrap. ```rust -// in capsule-core::crypto::mls +// in capsule-core::crypto::authority (OpenMlsAuthority) enum ReconcileOutcome { UpToDate, Reconciled { applied_commits: Vec }, @@ -57,8 +57,8 @@ enum ReconcileOutcome { Unrecoverable, // requires re-bootstrap } -fn reconcile_with_server(group: GroupId) -> Result; -fn rekey_group(group: GroupId, reason: RekeyReason) -> Result<(), MlsError>; +fn reconcile_with_server(&mut self, view: ServerChainView) -> Result; +fn rekey_group(&mut self, reason: RekeyReason) -> Result; ``` ## Validation diff --git a/capsule-docs/src/content/docs/design/moderation.md b/capsule-docs/src/content/docs/design/moderation.md index f47f2775..c51a6fd8 100644 --- a/capsule-docs/src/content/docs/design/moderation.md +++ b/capsule-docs/src/content/docs/design/moderation.md @@ -25,14 +25,16 @@ The actual policy surfaces that need design: A report against `alice@other.tld`'s asset is routed to her home server's administrators, since they are the only party that can act on her account. Three mechanics are fixed: - **Authentication.** A federated report MUST be signed by the reporting server's [signing key](/design/federation/#server-identity-and-key-rotation) and is verified before it reaches the admin queue; an unsigned or invalid-signature report is dropped, never surfaced. This makes every report attributable — a server that submits false reports is itself identifiable and blockable. -- **Rate-limiting.** Reports are bounded per `(reporting_server, reported_user)`; exceeding the limit applies backpressure rather than amplifying. Together with signing, this defeats the false-flag / mass-report abuse vector (a flood of forged or spoofed reports against one user). +- **Rate-limiting.** Reports are bounded per `(reporting_server, reported_user)`; exceeding the limit applies backpressure rather than amplifying. A receiving server also bounds a peer's reports **across all accounts** — `reported_user` is a string the peer chooses, so a per-account limit alone is one a peer refreshes by naming a different account — and bounds attempts per *claimed* origin before it looks the peer up at all, which is the only place a bound can sit on an unauthenticated route. +- **Unknown accounts are accepted and dropped.** A report naming an account the receiving server does not host is answered exactly as an accepted one is, and filed nowhere: an unresolvable report is a permanent orphan in an operator's queue, and a *distinct* refusal would turn intake into an account-enumeration surface for any peer holding a pinned key — which is not the same as being trusted with enumeration, since a peer key can be compromised and a peer may be adversarial toward its own users while remaining a legitimate partner. The receiving server logs it for its operator; the rate limits above still apply, so sweeping identifiers costs the peer its allowance. Together with signing, this defeats the false-flag / mass-report abuse vector (a flood of forged or spoofed reports against one user). +- **Wire form and what is signed.** The report is JSON with seven members — `reporting_server`, `reported_user`, `asset_hash`, `album_id`, `reason` (optional), `reported_at` (RFC 3339), and `signature` — and the signature is Ed25519 over the **canonical CBOR of the other six**. One normalization rule, and only one: each of the six has surrounding whitespace trimmed and is otherwise signed exactly as sent. In particular `reporting_server` is signed as the peer wrote it — the receiving server folds case and strips a trailing dot to *look the peer up*, and that canonical form is not what the signature covers — and `reported_at` is signed as its RFC 3339 text, not as a re-rendered instant. A receiving server keeps the signed bytes verbatim beside the row so the signature can be re-verified later; rebuilding them from stored, normalized fields would produce different bytes and an unattributable report. - **Content.** A report carries the alleged asset's **content hash and album pointer — never plaintext or decryption material**. This is the privacy-preserving, operable middle: the home-server admin can locate the asset and, *if* they already hold album access, fetch and view it to act; an admin without album access sees only opaque identifiers, exactly as the E2EE model requires. A report never widens who can read content. ### Blocklists Server-level blocklists, plus per-user blocks that federate: -- **Server-level blocklist.** A server admin publishes a list of peer servers that this server refuses to accept federated requests from. Operates at the [federation capability](/design/federation/#federation-capabilities) layer. +- **Server-level blocklist.** A server admin publishes a list of peer servers that this server refuses to accept federated requests from. Operates at the [federation capability](/design/federation/#federation-capabilities) layer. **Status: enforced, not yet writable.** `blocked_at` is a column on the peer row and every federation boundary consults it — mint, capability presentation, refresh, report intake — but nothing in production can set it: the durable backend refuses to assemble until [#403](https://github.com/Capsulsaurus/Capsule/issues/403) lands its adapters, so an operator command that pinned or blocked a peer could only run against `--memory`, which forgets the moment it exits. The command is owed with [#476](https://github.com/Capsulsaurus/Capsule/issues/476). Until then this list is empty in every real deployment, and this document's own rule cuts both ways: a blocklist nothing consults reads as protection, and so does one nothing can write. - **Per-user block.** A user can block another user; the block is enforced by the blocker's home server — the blocked user is removed from albums shared with the blocker and cannot share new albums with them. Removal is an ordinary MLS `Remove` + AMK epoch bump applied at the blocked user's next sync; the prior epochs' keys they already hold are not retroactively clawed back (consistent with [removal semantics](/design/cryptography/mls/#remove-user-charlie)). A per-user block is **scoped to that user**: it does **not** propagate as a server-wide federation block, so one user (or a coordinated group) cannot weaponize blocks to sever an entire peer server from the federation. Each home server enforces only its own users' blocks. (The MLS `Remove` this rides has landed — `OpenMlsAuthority::block_user`, slice `S-X4`; see the [MLS status note](/design/cryptography/mls/). It removes every device the blocked user holds in one commit, so the epoch bumps once.) - **Blocklist exchange (v2).** A peer-level mechanism for sharing *server-level* blocklists across federated servers (so a malicious server isn't pure whack-a-mole) is **deferred to v2**, but its shape is fixed now: signed, versioned blocklist documents an admin **opts into** consuming from peers they already trust — never auto-applied, and deliberately distinct from per-user blocks (which never propagate). v1 ships only the manual server-level blocklist above. @@ -58,7 +60,7 @@ When a moderation action requires the *home server* to stop serving a specific a - Federated peers fetching the asset receive `410 Gone`. (This deliberately diverges from the [share-link and drop serve paths](/design/share-links/#security-contract), which return an indistinguishable `404` — those must not confirm a capability URL ever existed, while a takedown *intends* to signal removal of content whose existence the peer already knows. The per-surface rule: capability-URL serving → `404`; takedown of known content → `410`.) - The asset's underlying blob is **not** deleted — the user owns the data, and a takedown is a serving constraint, not a destruction; the user can still restore from their own backup. A takedown is therefore **reversible by default** (an admin can lift it). A **legal-hold** variant marks the asset indefinitely unservable where law requires it — lifted only when the legal obligation ends, not at admin discretion — but even then never destroys the user's bytes: the constraint is on the *home server's serving*, not on the data the user holds. - **Storage verification tells the truth about a held asset**: every blob reports `stored` and **not** `retrievable`. That surface exists to answer one question — may a client release its only local copy? — and a server that called held bytes durable would be answering it wrong in the one direction that loses a photo. The honest pair is *we have your bytes, and we will not serve them*. -- **Status note.** Suspension enforcement and the user's own moderation record are **served today** by the Kynos surface, with slice `S-C8`: a suspended account's upload session creation is refused with a structured code, and `GET /v1/moderation/record` is where the user reads what was done and why. Moderation *actions* deliberately have **no wire surface** — this doc names an admin throughout and specifies no way for one to authenticate, so the actions sit behind an operator-driven port, the same shape the garbage collector and the integrity scrub already use. Federated report intake and the server-level blocklist both need the federation-capability layer and are owed with `S-C49`. +- **Status note.** Suspension enforcement and the user's own moderation record are **served today** by the Kynos surface, with slice `S-C8`: a suspended account's upload session creation is refused with a structured code, and `GET /v1/moderation/record` is where the user reads what was done and why. Moderation *actions* deliberately have **no wire surface** — this doc names an admin throughout and specifies no way for one to authenticate, so the actions sit behind an operator-driven port, the same shape the garbage collector and the integrity scrub already use. **Federated report intake and the server-level blocklist are built as of `S-C49`** (2026-09-09), on the federation-capability layer `S-E2` landed — and **neither is reachable in a real deployment yet**, for one shared reason: peer keys are operator-pinned, and no operator command can pin one until the durable backend assembles ([#403](https://github.com/Capsulsaurus/Capsule/issues/403), then [#476](https://github.com/Capsulsaurus/Capsule/issues/476)). Intake therefore answers `403 error.federation.peer_unknown` to every peer today, and the blocklist is empty. The route stays mounted and its contract published, because a peer implementing against this document needs it and the operator command is what is missing rather than the surface. What is built: `POST /v1/federation/reports` verifies the reporting server's Ed25519 signature against the key an operator pinned for it *before* charging the `(reporting_server, reported_user)` budget — so a third party spoofing `reporting_server` cannot spend a real peer's allowance — drops anything unsigned or unattributable, and files an accepted report for an operator to read without touching the reported account's standing. The blocklist is a column on the peer row, consulted at mint, at every capability presentation, at refresh and at intake; blocking also cuts and publishes every live grant the peer holds. Two things stay owed beyond the operator command (issue #476): nothing *fetches* a peer key — TOFU at intake is rejected on its own merits, because the first report from an unknown server is the wrong moment to decide whether to trust it — and the **admin** surface that would read the report queue still waits on the admin authentication model this doc leaves open. - The takedown emits a **server-visible moderation provenance record** the user sees in their audit log — what was taken down, when, and (where policy permits) why — honoring the "[No silent operations](#what-moderation-cannot-do-structural)" rule. A user whose asset stops serving is never left to guess why, and the moderation action is itself auditable after the fact. ## Federation Boundary diff --git a/capsule-docs/src/content/docs/design/module-map.md b/capsule-docs/src/content/docs/design/module-map.md index 0f50199e..79a88721 100644 --- a/capsule-docs/src/content/docs/design/module-map.md +++ b/capsule-docs/src/content/docs/design/module-map.md @@ -16,7 +16,6 @@ whether something exists today, find its slice: `rg 'S-C16' SLICES.md`. | `capsule-core` | Cryptography (including the MLS album authority), canonical CBOR, validation, CRDTs, sidecars, backup, lifecycle, client filesystem, local SQLite and vector index, import scan/plan/execute, culling, LQIP, share and drop crypto, aggregated federation views, ML orchestration | | `capsule-server` | The Kynos REST/OpenAPI application — see [Server Modules](#server-modules) | | `capsule-sdk` | The Spargen-generated REST client plus the orchestration over it Capsule owns: auth and session refresh, the resumable upload state machine, sync, recovery, protocol-version negotiation, LAN peering | -| `capsule-wire` | The response taxonomy shared by server and SDK. Framework-free by construction: `serde` is its only dependency, so neither side's transport choices reach the other | | `capsule-wasm` | The browser sealing surface `capsule-web` loads — share-link open and guest-drop sealing over `capsule-core` with default features off. Built by `mise run build-wasm`; never committed | | `capsule-i18n` + `xtask::i18n` | Canonical ICU catalogs, runtime localization, generated platform catalogs | | `capsule-core-ffi` | UniFFI bindings for native Swift and Kotlin consumers, on one UniFFI version across both surfaces | @@ -90,6 +89,7 @@ separate typed ports; no generic CAS, transfer, or TTL library is introduced. | --- | --- | | REST client | Spargen-generated Rust from a checked-in OpenAPI 3.2 document | | SDK workflows | Capsule-owned authentication, upload, sync, recovery, and protocol-version orchestration | +| Namespaces an app links | Exactly two: `capsule_core_ffi` and `capsule_sdk`. `capsule-core-ffi` is the app umbrella staticlib and links `capsule-sdk`'s uniffi surface into itself, because two Rust staticlibs cannot share a binary — each bundles its own `std` — so any namespace an app needs must ride in that one. A third namespace exists, `capsule_core` behind `capsule-core/ffi`, and it never shares a binary with `capsule_sdk` (the `S-F1` invariant). Recorded as ADR-0005 in the repository's `adr/` directory | | Workspace verbs over FFI | The `capsule_sdk` UniFFI namespace exposes the workspace surface apps need — enroll/open (including a hardware-signer constructor), albums, seal and import, verify, sync-apply, master-key escrow, and device-directory publish. Orchestration and shape only: each verb is one call into `capsule-core`, which keeps every cryptographic step, and the `capsule_core` namespace never shares a binary with it | | Media | Rawshift performs detection, decode/encode, metadata normalization, derivatives, previews, and video work, consumed through `capsule-core::media` | | LQIP | Capsule imports Chromahash **0.7.1** directly; Rawshift has no Chromahash responsibility | @@ -108,8 +108,8 @@ the named acceptance gaps are verified with contract fixtures or a minimal spike | Rawshift | Media detection, decoding/encoding, metadata normalization, derivatives, previews, and video processing | Required format/codec matrix; bounded memory and concurrency; cancellation/progress; malformed-input isolation; deterministic orientation/color/HDR behavior; normalized metadata provenance; mobile/desktop targets; no Chromahash API | | Chromahash **0.7.1** | LQIP encode/decode only, imported directly by Capsule | Deterministic output; wide-gamut/HDR fixtures; decoder fallback behavior; supported FFI targets. The pin and the retired ThumbHash decision are [Dependencies](/design/dependencies/#rust) | | OpenMLS | MLS protocol and cryptographic state transitions | Required cipher suites and credential model; deterministic persistence/restore; external signer integration; epoch/exporter behavior; cross-platform size/performance; Capsule-owned album policy and provenance stay outside it | -| PostgreSQL driver/ORM | Durable server index and default implementations of the two typed state ports | Transactions needed for finalization, row locking, migration strategy, cancellation, typed error mapping, tracing, and adapter conformance. Select the narrowest mature stack after Kynos integration is proven | -| `redis-rs` | Required Valkey adapters for `AuthStateStore` and `UploadSessionStore` | Atomic compare/update and expiry primitives required by each port; cluster behavior; cancellation/timeouts; tracing; behavioural parity with the PostgreSQL and in-memory adapters under one conformance suite — parity is what lets the in-memory double be trusted in tests, not a claim that Valkey is [substitutable](/design/filesystem/server/#required-services) | +| PostgreSQL driver/ORM (`sea-orm`/`sqlx-postgres`) | The durable server records: the asset index, the account cluster, the device-cohort map, the quota ledger and the library's remaining rows. **Not** the two typed state ports — a Postgres-resident session table is [rejected](/design/filesystem/server/#required-services) as a second implementation of one contract | Transactions needed for finalization, row locking, migration strategy, cancellation, typed error mapping, tracing, and adapter conformance — all discharged for the first four adapters, whose suites run against the deterministic double and against a container under `CAPSULE_TEST_POSTGRES=1`. Migrations are applied by a separate binary and `serve` refuses to boot against a schema it was not built for | +| `redis-rs` | Required Valkey adapters for `AuthStateStore`, `UploadSessionStore` and the ceremony stores — the volatile half, and the only production adapter any of them gets | Atomic compare/update and expiry primitives required by each port; cluster behavior; cancellation/timeouts; tracing; behavioural parity with the in-memory double under one conformance suite — parity is what lets that double be trusted in tests, not a claim that Valkey is [substitutable](/design/filesystem/server/#required-services). Parity with the PostgreSQL adapters is not a goal, because no port has both | | RustCrypto, `ciborium`, `rusqlite`, `sqlite-vec`, UniFFI, `wasm-bindgen` | Existing crypto primitives, canonical serialization, local catalog and vector index, native bindings, and the browser boundary | Continue vectors, canonical-byte tests, migration tests, and binding smoke tests; these libraries do not own Capsule protocols or schemas | Explicit non-dependencies: no generic CAS crate, `object_store`, resumable-transfer library, generic @@ -132,7 +132,9 @@ covers (`rg "E2E case N"`), and slices in the repo-root `SLICES.md` reference th 3. **Sync feed pickup.** Upload from device A → device B's feed advances → device B fetches the metadata blob and, per scope, the original. 4. **Federation cross-server pull.** Alice on `home.tld` shares to Bob on `other.tld` → capability - token → Bob's server pulls metadata and blobs → Bob's client renders. + token → Bob's server pulls metadata and blobs → Bob's client renders. (The **server half** and + the SDK's pull over a socket land with `S-E2`/`S-E5`: `capsule-server/tests/federation.rs` and + `capsule-server/tests/sdk_client.rs`. Bob's client rendering is `capsule-e2e`'s.) 5. **LAN peering A→B.** Two devices on one LAN; discovery → TLS handshake → delta-scoped artifact → restore on the receiver → byte-equal libraries. 6. **Backup → restore on a fresh device.** Export a full backup → bootstrap a new device via @@ -149,8 +151,18 @@ covers (`rg "E2E case N"`), and slices in the repo-root `SLICES.md` reference th results afterwards. Entirely within `capsule-core::ml` and the `capsule-core::db` vector index, so it is unaffected by the server rebuild. 11. **Server crash mid-finalization.** Inject a crash between the blob rename and the Postgres - transaction commit; restart; assert the session moves to `FailedProcessing` cleanly, with no - orphaned blob and no zombie pending row. + transaction commit; assert the session moves to `FailedProcessing` cleanly, the asset row is + still `Pending` with no sequence number, and **nothing references the blob** — no dangling + reference, which is the one outcome the finalization order exists to forbid + ([Filesystem — Server](/design/filesystem/server/)). An **orphaned blob is permitted** and is + the safe half of that trade: the bytes are at their content address with a reference count of + zero, the collector marks them, and the client's retry re-references them because + `BlobStore::commit` is idempotent on identical ciphertext. This row previously asked for "no + orphaned blob", which contradicted the design it cites and the test that asserts it. + The crash is injected through the `AssetIndex` port, so no production code carries a test + hook; the **process-restart** variant — a real kill, and a second process over the same blob + root and database — is owed to the remaining durable adapters and belongs to the binary-smoke + tier. 12. **Cross-device enrollment.** Device A authorizes new device B over a verified channel (enrollment code plus safety-code check) → B generates hardware keys → A cross-signs B into the device directory → B joins each album's MLS group → B's library matches A's. Includes one @@ -159,3 +171,26 @@ covers (`rg "E2E case N"`), and slices in the repo-root `SLICES.md` reference th user's native client decapsulates, rewraps the key under the album AMK, and adopts it in place → the asset appears in the library and `verify_asset`-accepts on a second device. The only case exercising the web/WASM client and the wrapped-key path. + +### Status + +Every landed case is a named test (`rg "E2E case N"`); the server-side cases run in the +`capsule-e2e` crate against the real composition root (`boot::assemble` under the memory +profile, bound to an ephemeral port) with the real SDK and a real library, no container and no +environment gate. "Blocked on" names the issue that holds the rest of the case's wording. + +| Case | Named test | Status | Blocked on | +| --- | --- | --- | --- | +| 1 | `capsule-e2e/tests/case_01_auth_sync_query.rs` | landed; the push runs the SDK's own ladder | — | +| 2 | `capsule-e2e/tests/case_02_import_upload_finalize.rs` | landed; the push runs the SDK's own ladder | — | +| 3 | server half `capsule-server/tests/sync.rs`; client half `capsule-e2e/tests/case_03_sync_pickup.rs` | landed; the push runs the SDK's own ladder | B's `verify_asset` needs the album keys (cases 6, 12) | +| 4 | — | not started | federation (#406) | +| 5 | `capsule-sdk/src/peering/tests.rs` | in-process shape | live two-host shape, post-v1 | +| 6 | `capsule-e2e/tests/case_06_backup_restore.rs` | landed; the restored asset reads and its chain walks | #467 (open as the recovered account), #468 (verify: no authority in the artifact) | +| 7 | `capsule-e2e/tests/case_07_lifecycle.rs` | landed; the push runs the SDK's own ladder | — | +| 8 | server leg `capsule-e2e/tests/case_08_upgrade_ceremony.rs`; ceremony `capsule-core/src/crypto/authority/openmls_authority/tests.rs` | server leg landed, and its feed-visibility assertion runs the SDK's own ladder; ceremony in-process | a library cannot sign an intent (private DSK) | +| 9 | `capsule-e2e/tests/protocol_contract.rs` | landed (the UI leg is out of scope) | — | +| 10 | `capsule-core/tests/model_regen_e2e.rs` (`E2E case 10`) | landed | — | +| 11 | | lands with #447 (an in-memory fault decorator on the index) | the process-restart variant (#447 defers it) | +| 12 | server leg `capsule-e2e/tests/case_12_enrollment.rs` | server leg landed; verified-channel half unrepresented (#471) | #471, #467, #405 (MLS join) | +| 13 | server leg `capsule-e2e/tests/case_13_web_drop_adopt.rs`; seal KAT `capsule-core/tests/drop_adopt_kat.rs` | server leg landed to the durable adopted original | #469 (adopt registers nothing to publish) | diff --git a/capsule-docs/src/content/docs/design/notifications.md b/capsule-docs/src/content/docs/design/notifications.md index 1a2fd2f5..4e8df614 100644 --- a/capsule-docs/src/content/docs/design/notifications.md +++ b/capsule-docs/src/content/docs/design/notifications.md @@ -36,10 +36,17 @@ Three distinct things, three words. The collision this table resolves is the rea A hint is a *pull prompt between infrastructure*. A wake is a hint applied at the client edge. An alert is the only one a human ever sees, and it is never produced by a server. -**Where this lives.** Alert classes and their trigger predicates belong in `capsule-core::notify` -(**Planned**) so every platform evaluates one shared decision function; only *delivery* is native -per client. This is the [minimal-divergence split](/design/clients/#design-priorities) applied to -alerts. No server module is planned for v1 — Tier 0 has no server half. +**Where this lives.** Alert classes and their trigger predicates live in `capsule-core::notify` +so every platform evaluates one shared decision function; only *delivery* is native per client. +This is the [minimal-divergence split](/design/clients/#design-priorities) applied to alerts. No +server module is planned for v1 — Tier 0 has no server half. + +**Status.** `capsule-core::notify` is **built** (slice `S-D29`, core half): one pure decision +function returns the classes true at an instant, a second returns the instant to arm per class, +and `capsule-sdk::ffi` carries both to the apps. Every predicate input is caller-supplied, +because the core holds none of the trigger state. What is still owed is the *delivery* half — +the per-platform scheduling and presentation below, the `notification.*` catalog keys, and the +permission request — so no alert on this page reaches a user yet. ## Tier 0 — Local Alerts @@ -57,13 +64,16 @@ write a sentence from. A closed enum. Each class's *trigger predicate and thresholds* stay owned by the doc that defines the condition; this doc owns the class list, the delivery, and the shared snooze/badge mechanics. -| Class | Trigger owner | Pre-armable | -| --- | --- | --- | -| `sync_stale` | [Download & Sync — Notifications](/design/import/download-sync/#notifications) | **Yes** | -| `recovery_check_due` | [Backup — Schedule and Triggers](/design/backup-recovery/#schedule-and-triggers) | **Yes** | -| `quota_soft` / `quota_grace_expiring` | [Quota — Thresholds and States](/design/quota/#thresholds-and-states) | No | -| `quarantine_pending` | [Threat Model — Quarantine Surfaces](/design/threat-model/scenarios/#quarantine-surfaces) | No | -| `drop_pending` | [Web Upload — Drop and Adoption Lifecycle](/design/web-upload/#drop-and-adoption-lifecycle) | No | +Each class carries **parameters** — plain strings a client interpolates into its own catalog +string, never text. They are listed with the class below and settled by the trigger owner. + +| Class | Trigger owner | Parameters | Pre-armable | +| --- | --- | --- | --- | +| `sync_stale` | [Download & Sync — Notifications](/design/import/download-sync/#notifications) | `count`, `days_behind` | **Yes** | +| `recovery_check_due` | [Backup — Schedule and Triggers](/design/backup-recovery/#schedule-and-triggers) | `snooze_budget` (`available` / `spent`), `recovery` (`check` / `rewrap`) | **Yes** | +| `quota_soft` / `quota_grace_expiring` | [Quota — Thresholds and States](/design/quota/#thresholds-and-states) | `grace` (`counting` / `expired`), on the second only | No | +| `quarantine_pending` | [Threat Model — Quarantine Surfaces](/design/threat-model/scenarios/#quarantine-surfaces) | `count` | No | +| `drop_pending` | [Web Upload — Drop and Adoption Lifecycle](/design/web-upload/#drop-and-adoption-lifecycle) | `count` | No | Unknown classes are rejected as structural errors, like every other closed enum ([Schema Rules](/design/threat-model/schema-rules/)). diff --git a/capsule-docs/src/content/docs/design/threat-model/validation.md b/capsule-docs/src/content/docs/design/threat-model/validation.md index 389c2e57..fdabcc33 100644 --- a/capsule-docs/src/content/docs/design/threat-model/validation.md +++ b/capsule-docs/src/content/docs/design/threat-model/validation.md @@ -63,15 +63,19 @@ reintroducing the stale revival that 17 exists to catch, in the code enforcing i ### On federation pull (server-to-server) -- **19.** Capability token verifies under home server's signing key; `exp` in future; `jti` not in revocation list (cached ≤ 15 min). Otherwise `401` / `403`. -- **20.** All checks (1)–(18) re-applied — federation does not unlock looser rules. -- **21.** Per-peer rate budgets unbroken (events/hour, bytes/hour, CPU/hour). Otherwise `429`. +- **19.** Capability token verifies under home server's signing key; `exp` in future; `jti` not in revocation list (cached ≤ 15 min). Otherwise `401` / `403`. **Enforced** (`S-E5`) on `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`. The structural failures — does not verify, expired, not this server's — are the framework's uncoded `401`, because the authenticator has no seam for a coded body; everything a peer can act on is the route's coded `403` from an admitted credential: `error.federation.capability_revoked`, `error.federation.audience_mismatch`, `error.federation.scope_insufficient`, `error.moderation.server_blocked`. A capability that verifies must also be one this server **recorded**, and the record binds the grant to the epoch its member's membership was granted at. +- **20.** All checks (1)–(18) re-applied — federation does not unlock looser rules. **Not yet reachable:** this server never *receives* a federated write, because nothing on it pulls from a peer (issue #476). The invariant binds the egress worker when it lands. +- **21.** Per-peer rate budgets unbroken (events/hour, bytes/hour, CPU/hour). Otherwise `429`. **Events/hour is enforced** through `CounterStore`, keyed on the peer's origin rather than on the capability — a budget per token would be one a peer widens by asking for more tokens — and answered as `429 error.federation.rate_budget_exceeded`. **Bytes/hour and CPU/hour are not**: both need a weighted counter the port does not have, and nothing measures per-request CPU (issue #476). ### On the sync feed, directory publish, and federated reports - **22.** The `sync_cursor` carries a server MAC under a server-only key; a forged or mutated cursor is rejected (`400`). This is the authenticity layer; the client independently enforces per-album `sync_seq` monotonicity (client-side invariants below). Owner: [Import — Download & Sync](/design/import/download-sync/#discovering-what-changed). - **23.** A published `DeviceDirectory` has `directory_version` **strictly greater** than the version currently stored for that user, and the master signature covers it. A non-advancing or regressing publish is rejected (`409`). The signature is checked against the account's **identity anchor** — the identity public key its first published directory arrived with (in the required `X-Capsule-Identity-Key` header), immutable thereafter. A publish presenting a different key is rejected `403`: the document is well-formed and correctly signed, and signed by somebody else. Owner: [Cryptography — Device Directory](/design/cryptography/keys/#device-directory). -- **24.** A federated **report** (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer's report rate budget; otherwise it is dropped before reaching the admin queue. Owner: [Moderation — Federated Reporting](/design/moderation/#federated-reporting). +- **24.** A federated **report** (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer's report rate budget; otherwise it is dropped before reaching the admin queue. **Enforced** (`S-C49`) at `POST /v1/federation/reports`: the signature is Ed25519 over the canonical CBOR of every other field, verified against the peer's operator-pinned key, and it is checked **before** the `(reporting_server, reported_user)` budget is charged so a spoofed `reporting_server` cannot spend a real peer's allowance. A report from a server nobody pinned is `403` — intake is not the moment a peer becomes trusted. Owner: [Moderation — Federated Reporting](/design/moderation/#federated-reporting). + +### On `PUT /v1/albums/{album_id}/roster` (album roster publish) + +- **33.** The signed roster decodes as canonical CBOR, is at most 512 KiB, names the album in the path, lists no account twice and does not list the owner (otherwise `400` — every one of these is decidable from the request alone, so nothing store-held is disclosed); the caller is the album's owner (otherwise `404`, the album ceremonies' "not yours is not found"); its `attested_by_user` is the caller and its `attester_sig` verifies under a **non-revoked** device in the caller's published device directory (otherwise `403`); its `roster_version` is **strictly greater** than the version the server holds, or byte-identical to it (a replay), and its `amk_epoch` does not regress (otherwise `409` carrying `current_version`); and it is **at most sixteen above** the held version — an album with no roster reads as version `0` — because a version nothing could ever supersede would freeze the album's membership for good (otherwise `400 error.album.roster_version_leap`, carrying `current_version` and `max_version` so the owner re-signs the same roster one above what is held). The server stores the consequence — who is a member, with what role, since which version and epoch — and marks a member omitted from a later roster as revoked at that roster's version and epoch rather than deleting the row, so a former member's `403` on the blob route is a stored fact. Owner: [Authorization](/design/authorization/). ### On any write whose bundle carries a metadata blob @@ -163,6 +167,7 @@ Every write surface has a single idempotency key. Duplicates are no-ops; conflic | Share-link / upload-link creation | Client-supplied operation id (UUIDv7) | Retried create returns the already-minted link | | Share-link / upload-link revoke | `link_id` | Second revoke is a no-op | | Drop adoption (`POST /v1/drops/{drop_id}/adopt`) | `drop_id` — the atomic inbox→album promotion (invariant 32) | A retry after success finds the inbox row gone and returns the already-promoted asset | +| Album roster publish (`PUT /v1/albums/{album_id}/roster`) | `(album_id, roster_version)` (invariant 33) | Identical bytes: `200` with `replayed: true`, nothing written. Same version, different bytes: `409 error.album.roster_stale` | A write surface that does not appear here is, by default, **not** idempotent and must be designed before it ships. diff --git a/capsule-docs/src/content/docs/design/thumbnails.md b/capsule-docs/src/content/docs/design/thumbnails.md index 55c211f2..1f54ea23 100644 --- a/capsule-docs/src/content/docs/design/thumbnails.md +++ b/capsule-docs/src/content/docs/design/thumbnails.md @@ -29,6 +29,35 @@ Two derivative tiers per photo asset and one preview tier for video assets: - **WebP** is the last-resort fallback for the rare client lacking AVIF. We deliberately do not fall back to JPEG — WebP covers everything JPEG would. - **H.264 baseline** for video previews — universally decodable, cheap to decode on every platform. AV1 was considered but mobile encode cost is still high in 2026. +:::note[Implementation status — what ships today] +The table above is the **contract**, not an inventory of what is built. As of `#410`, +`capsule-core::media` (on `rawshift-image` 0.1.1, behind the `media` feature that `native` +implies) generates the **thumbnail tier as JXL** and nothing else. Concretely: + +| Tier | Photo formats generated | Missing, and why | +| --- | --- | --- | +| Thumbnail | **JXL**, 256 px long edge, **lossless**; or the `original` sentinel when the source is already inside the cap | **AVIF** needs `nasm` on every x86_64 build host (`ravif` → `rav1e/asm`). **WebP** does not compile: `rawshift-image`'s codec passes `*const i8` where `libwebp-sys` declares `*const c_char`, which is `u8` on aarch64 — every mobile target. | +| Preview | — | Blocked with a *lossy* master codec: a source-resolution lossless still would rival the original in size. | +| Video (either tier) | — | `rawshift-video` is unpublished; slice `S-B5`. | + +The thumbnail's declared **q=50 is advisory today**: the pure-Rust backend is `zune-jpegxl`'s +`JxlSimpleEncoder`, which is lossless, so a thumbnail costs more bytes than the table intends. A +lossy JXL needs C libjxl. That is the one place the build knowingly departs from this table, and +it is asserted by a test rather than left to be discovered. + +Decode is JPEG, PNG, JXL, TIFF and GIF. **HEIC, AVIF, WebP and the RAW families are recognised +and refused** — HEIC and AVIF need system libheif / libdav1d, and WebP shares the aarch64 defect +above in both directions (the crate compiles that module for decode *or* encode). + +**Generated is not yet uploaded.** The server's upload policy fixes a closed content-type set for the protocol version, and `image/jxl` is not in it, so the T1 session for a JXL thumbnail is refused with `error.upload.unsupported_content_type` — every still larger than the 256 px cap, since only a small one gets the byte-free `original` sentinel. That is [#470](https://github.com/Capsulsaurus/Capsule/issues/470), server-side and being fixed separately; the thumbnails on disk and their signed manifests are unaffected. + +None of this is silent. A format with no codec is a typed +`media::MediaError::UnsupportedFormat { format, op }`, and a `(tier, format)` pair with no encoder +is recorded on `media::StillDerivatives::deferred` and counted by +`ImportExecutionSummary::deferred_format_count()` — so the distance between this table and the +build is a number the import run reports. The remainder is tracked as the `S-B1` follow-up. +::: + ### Video Previews The table above stays the SSoT for the video formats; this section only names the implementation seam. Video derivative generation — the first-frame still and the H.264 baseline preview transcode — is its own implementation slice (`S-B5` in the repo-root `SLICES.md`), split from still-image generation (`S-B1`) because transcode brings a distinct toolchain (demux, video decode, H.264/AAC encode) the still path never touches. Both slices sign their outputs identically through the [`DerivativeManifest`](#derivative-provenance) path. @@ -56,7 +85,7 @@ Four calls carry the whole contract, and the module uses no more than these: ### Where LQIP Lives -`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, which retires to `legacy-review/` with the rest of the decode/encode stack: a placeholder scheme every client depends on cannot live inside something scheduled for teardown. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. +`capsule-core::lqip` — a dedicated module, slice `S-B14` in the repo-root `SLICES.md`. It is deliberately **not** in `capsule-core::media`, and the reason outlived the teardown that first prompted it: `media` is `native`-only wherever it exists — it links image codecs and a decode budget sized for a workstation, neither of which a browser has any use for, so a placeholder scheme every client depends on cannot live inside it and still reach the browser. It is equally not in Rawshift — `AGENTS.md` is explicit that Rawshift owns media decoding but must not wrap Chromahash, which Capsule imports directly. `media` is the module that *produces* the pixels this one hashes; it never owns the hash. A small Capsule-owned module outside the retiring stack satisfies both constraints at once, and is reachable from all three places a placeholder is produced or consumed: the import pipeline, the native apps through the uniffi FFI, and the browser through `capsule-wasm`. That is the point of a single home — one implementation for every surface, so a photo's placeholder does not depend on which client happened to import it. @@ -79,6 +108,8 @@ Thumbnails and previews are *ephemeral by recovery posture* (they can always be The full derivative manifest structure and the `derivative-add` / `derivative-replace` action set are owned by [Cryptography — Derivative Provenance](/design/cryptography/provenance/#derivative-provenance) and [Authorization — The Closed Action Set](/design/authorization/#the-closed-action-set); this doc owns only the *format* of the derivative bytes. The two interact at exactly one point: the `DerivativeManifest.format` field names the codec/format from the table above, and the verifying side rejects a manifest whose `format` is not currently recognized (the closed-enum rule from [Threat Model — Schema Rules](/design/threat-model/schema-rules/#schema-evolution-and-field-grammar)). +A receiver never needs a manifest for a tier that was satisfied by the original itself. The signed sidecar carries the asset's pixel `dimensions`, so a receiver can see for itself that an original at or below the thumbnail tier's long edge needs no thumbnail, and must not schedule a regeneration for one; the `format = "original"` sentinel is a **local** record that keeps "this original is small" apart from "this thumbnail is missing" for the client's own rebuild path, and it is not shipped. + A thumbnail whose `DerivativeManifest` fails verification is **regenerated locally from the original** rather than trusted — the [recovery-first principle](/design/principles/) means a derivative is always rebuildable, so refusal-and-regenerate is the safe default. The corrupt copy is discarded (not quarantined — it carries no irreplaceable bytes), and the corresponding regeneration appends a new `derivative-replace` provenance record. ## Validation diff --git a/capsule-docs/src/content/docs/development/local-development.md b/capsule-docs/src/content/docs/development/local-development.md index 68850524..4890215e 100644 --- a/capsule-docs/src/content/docs/development/local-development.md +++ b/capsule-docs/src/content/docs/development/local-development.md @@ -52,17 +52,150 @@ mise run hooks-install # installs the git hooks (hk) ## Running a server locally -**There is no local server today, and that is a known gap rather than a missing instruction.** +`capsule-server` is one binary with subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH + gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is: they +compare the index against the blob store, and the only index adapter written is the in-memory +one. Without it they refuse and say so. It becomes optional when #402 lands. +``` + +### The development profile + +```bash +mise run serve-memory +``` + +That is a server you can point a client at: it binds, prints the address it bound, and answers +every operation. An account registers and signs in — the credential is checked with Argon2id +against a real in-memory account directory, so a wrong password is refused rather than accepted. + +What it is missing is durability. The blob store is a **real** filesystem store under +`target/capsule-server-blobs`; everything else — the index, sessions, albums, the device +directory, quota, the collector's marks — lives in the process and is gone when it exits. That +is not a gap to route around, it is the shape of a profile whose durable half is exactly the one +adapter that has been written. Two consequences worth knowing before they surprise you: + +- After a restart, `capsule-server scrub` will honestly report every blob still on disk as an + orphan, because the index that referenced them is gone. +- `capsule-server gc` can only ever **mark** in this profile. Collection is two passes by + design — a blob that reaches zero references is marked, and swept on a later pass once the + grace window has passed — and the mark store does not outlive the process. -`mise run serve-api` and the compose stack behind it went with the Salvo tree in slice `S-C59`. The Kynos server that replaces it is complete as a *surface* — fifty-nine operations, a committed OpenAPI 3.2 document, and a test suite that drives the real router — and it has **no binary, no configuration loading and no Postgres or Valkey adapter**. Nothing reads `JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet. +The signing key `serve-memory` falls back to is the published example in +`capsule-server/.env.example` — commented out there, so a `cp .env.example .env` cannot silently +produce a forgeable deployment. Every token the task mints under it is forgeable by anyone who has +read this repository, which is why it is `serve-memory` and not `serve`, and why it binds +`127.0.0.1` rather than every interface. Set `JWT_ED25519_DER` yourself and it is used instead: + +```bash +JWT_ED25519_DER="$(openssl genpkey -algorithm ed25519 -outform DER | base64 | tr -d '\n')" \ + mise run serve-memory +``` + +### A configured server + +```bash +cp capsule-server/.env.example capsule-server/.env # then edit it +mise run serve-deps # Postgres 18 + Valkey 9, on loopback +mise run serve +``` -That ordering is deliberate: every port in `capsule-server` has a deterministic in-memory adapter and a conformance suite, because the suite is what a real adapter is written *against*, and a port with two implementations before it has one suite is a port whose implementations will disagree. Until those adapters land, the way to exercise the server is the way its own tests do — in process, with no container: +The template ships with **both secrets commented out** — `JWT_ED25519_DER` and +`ATTESTATION_KEY_SEED` — so a copy you have not finished editing produces a server that refuses +and names what it wants, rather than one that starts under a published key. Uncomment each and +put your own value in. Every other setting is either a working default or optional. + +Nothing in the template is a shell expression, deliberately: the file is read by more than a +shell — `podman --env-file`, compose's `env_file:`, systemd's `EnvironmentFile=` — and those take +a line literally, so a placeholder shaped like `$(...)` would be stored as the value rather than +replaced. + +`serve-deps` and `serve` are separate tasks on purpose: a task that silently starts containers is +a task that leaks them. Bring them down with +`podman compose -f capsule-server/compose.yaml down` (`docker compose` accepts the same file). + +**`mise run serve` does not work yet, and refuses rather than pretending.** The Valkey half is +real: `VALKEY_URL` is connected to and `PING`ed before anything else is assembled, and a server +that cannot be reached is a refusal naming the failure (never the URL). The Postgres adapters are +not written, so with Valkey reachable `serve` exits non-zero naming `DATABASE_URL` and the issue +that will honour it (#402). Without `VALKEY_URL` and without `--memory` it exits 2 naming the +variable — the refusal `capsule-server/src/store/mod.rs` has documented since `S-C29` and nothing +could enforce until there was a boot path. Neither ever silently falls back to the in-memory +adapters, which is the whole point: a deployment that forgot a variable must fail closed. + +The Valkey adapters are proven against a live server by `capsule-server/tests/valkey.rs`, which +runs the same conformance suites the in-memory doubles pass. It is env-gated so `mise run +test-rust` needs no podman: `CAPSULE_TEST_VALKEY=1` starts a `valkey/valkey` container through +testcontainers (`DOCKER_HOST` pointing at the podman socket; `CAPSULE_TEST_VALKEY_TAG` overrides +the image tag), and `CAPSULE_TEST_VALKEY_URL=redis://127.0.0.1:6379` runs it against a server +already up — the one `mise run serve-deps` starts, say. The suite writes under the `capsule:` +namespace, so point it at a database you do not mind sharing with test keys. + +A configured server also has to supply `ATTESTATION_KEY_SEED`. It is **not** derived from +`JWT_ED25519_DER`, and that is deliberate: the attestation key signs custody receipts and has to +be distinct from the key that signs session tokens, or anyone holding the operational key could +manufacture custody evidence — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/). A different HKDF label over +the same input is not a separation. `serve --memory` derives it, because a development server's +whole state is discarded when it exits. + +Every configuration fault is reported in **one** message with exit code 2, so bringing a +deployment up is one read of one log line rather than one restart per variable. + +`capsule-server/.env.example` is the full list of settings. The precedence is command-line flag, +then the environment, then the built-in default; there is no configuration file, and `--config +PATH` is accepted and refused with a sentence saying so. + +### TLS + +The server does not terminate it. HTTPS is the ingress or reverse proxy's job — see +[Cryptography — Failure Modes](/design/cryptography/failure-modes/) — so there is no certificate +setting and Kynos's `tls` feature is off. + +### Logs and reports + +Every log line goes to **stderr**; stdout is a data channel. `serve` writes one +`listening on ` line there (which is how a `--listen 127.0.0.1:0` caller learns its port), +`gen-openapi` writes the path it wrote, and the operator commands write their report. `LOG_FORMAT` +is `pretty` in a debug build and `json` in a release one; `RUST_LOG` is the usual filter. + +### The operator commands + +`gc`, `purge` and `scrub` are the three jobs +[Filesystem — Maintenance](/design/filesystem/maintenance/) describes. They need a blob root and +deliberately **no key material**: a maintenance host that had to hold the production +token-signing key to sweep a directory would be a reason to put the key on a maintenance host. + +They do need `--memory` today, and they say so rather than naming a variable that would not have +helped: all three compare the index against the blob store, and the in-memory one is the only +index adapter written. + +Dry run is the default for the two that write; `--apply` opts in, and the report says which +posture produced it. `scrub` mutates nothing at all and exits non-zero on a non-empty report, +which is what makes it usable as a monitoring probe — and a `--deep` pass that ran out of budget +says so, because a clean report from a pass that stopped early is not a clean store. + +### Without running anything + +To exercise the server the way its own tests do — in process, no socket, no container: ```bash cargo nextest run -p capsule-server ``` -`kynos::test::TestClient` drives a built `Service` directly: no socket, no port, no runtime flavour. One test (`tests/sdk_client.rs`) does bind an ephemeral port, because the property it proves — that the **generated** SDK client round-trips the real router over TCP — is the one an in-process client cannot. +`kynos::test::TestClient` drives a built `Service` directly. Two test files do use a socket: +`capsule-server/tests/sdk_client.rs`, because the property it proves is that the **generated** +SDK client round-trips the real router over TCP, and `capsule-server/tests/binary.rs`, because +the properties it proves — that the binary binds, reports its port, and drains to exit 0 on +SIGTERM — belong to a process rather than to a router. To read the served contract without running anything: @@ -70,9 +203,13 @@ To read the served contract without running anything: mise run openapi-kynos # regenerate capsule-server/openapi.json ``` -### Nothing here needs a container any more +### Nothing here needs a container -The testcontainers section this page used to carry is gone with the crate that needed it. No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite and cannot leak one. (The `containers` nextest group is kept, empty, for the first real adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the expensive way to learn it.) +No test in the workspace starts a container, so `mise run test-rust` has no podman prerequisite +and cannot leak one. `mise run serve-deps` is the only task that starts anything, and it is never +a dependency of another task. (The `containers` nextest group is kept, empty, for the first real +adapter — the one-thread rule it encodes was learned by watching CI flake, and that is the +expensive way to learn it.) ## Git hooks diff --git a/capsule-docs/src/content/docs/guides/google-photos-migration.md b/capsule-docs/src/content/docs/guides/google-photos-migration.md index 6c069d20..98c6d16b 100644 --- a/capsule-docs/src/content/docs/guides/google-photos-migration.md +++ b/capsule-docs/src/content/docs/guides/google-photos-migration.md @@ -267,10 +267,46 @@ Do the same for the **exporter-supplied** metadata, since that is what `--provider takeout` adds: pick a few photos you know are in an album, are favorited, or carry a typed caption in Google Photos, and note them down. -The current CLI has no command that prints an imported asset's caption, rating, -or tags back to you, so this sample is a record to reconcile later rather than -something you can diff today. That is exactly why the guide keeps insisting you -retain the Takeout archive and keep Google Photos alive through the cutover. +Then read each one back out of the library. `capsule show` prints what the +imported asset's **signed sidecar** records, and it takes the SHA-256 you already +have from the spot-hash step — Capsule imports bytes unchanged, so the source +file's hash is the asset's hash. A prefix of eight or more hex characters is +enough; if it happens to match more than one asset, the command refuses and asks +for more of the hash rather than guessing: + +```sh +capsule show --library ./my-library 3b2f9c1e +``` + +```text +Asset 019a2d3c-… + Album: … + Content type: image/jpeg + SHA-256: 3b2f9c1e… + Dimensions: 4032×3024 + Captured: 2021-07-04T18:22:09Z + Imported: 2026-09-02T10:15:42Z + Caption: Grandma's 80th + Rating: 5/5 + User tags: Family reunion 2021 + AI tags: (unset) + GPS: 37.774900, -122.419400 (WGS-84, manual) + Cull flag: neutral + … +``` + +Check the rows against your notes using the mapping table above: the caption is +the Google description, a favorite reads as `5/5`, each album the photo was in is +a user tag, and a GPS fix that came from the Takeout JSON rather than the file's +own EXIF is marked `manual` (a fix is always printed with its datum, so a GCJ-02 +coordinate stored verbatim is never mistaken for WGS-84). A field the export did not carry prints as +`(unset)` rather than being omitted, so a missing caption is something you can +see. A mapping you disagree with is worth catching here: the values live in a +signed sidecar, and changing one later is a signed metadata update per asset. + +Retain the Takeout archive and keep Google Photos alive through the cutover all +the same — the sample tells you the mapping is right, not that every one of tens +of thousands of assets is. ## After you've verified diff --git a/capsule-docs/src/content/docs/reference/api.md b/capsule-docs/src/content/docs/reference/api.md new file mode 100644 index 00000000..3025628d --- /dev/null +++ b/capsule-docs/src/content/docs/reference/api.md @@ -0,0 +1,96 @@ +--- +title: REST API +description: The auth model, negotiation headers, and error contract common to every Capsule endpoint +status: draft +--- + +Capsule's server surface is REST over HTTP, described by a single OpenAPI 3.2 document that +`capsule-server` emits from its own route types. This page is the hand-written half of that +reference: the things true of every endpoint, which a per-endpoint page would otherwise repeat +fifty-nine times. The endpoints themselves are the generated pages listed in the sidebar. + +Read [API Surfaces](/design/api-surfaces/#surface--transport-map) first if you want the map from +a surface to the module that owns it and the design document that explains it. This reference +says what the wire looks like; that one says why. + +## The server holds no keys + +The most important thing to know before reading any endpoint: **every write is sealed on the +client before it is sent, and the sync feed returns opaque envelopes.** The server stores, +addresses, and serves ciphertext, and authorizes who may do so. It cannot read an asset, and no +endpoint accepts a plaintext one. + +That is also why this reference has no try-it panel. A playground could exercise the version +handshake, the auth flows, and blob fetch; everything else would either return ciphertext or +reject an unsealed body, and a reader who succeeded at it would leave believing the API accepts +plaintext. The honest equivalent is the [command line](/reference/cli/), which performs the +sealing. + +## Authentication + +Credentials ride the standard `Authorization: Bearer` header. An access token is short-lived and +issued by `POST /v1/auth/login`; `POST /v1/auth/refresh` rotates the pair. Each generated page +marks every operation as requiring authentication or not, read from the document's own security +requirements rather than from prose here. + +An account with a confirmed second factor does not get a session from `login` — it gets a +challenge, and the sign-in finishes at `POST /v1/auth/login/verify-totp`. A client that treats +`202` as a failure will appear to work until the first user enables TOTP. + +A `401` carries a `WWW-Authenticate` challenge, per RFC 9110. + +## Negotiation + +The protocol-negotiation header contract — what a client sends, what a server answers with — +is [API Surfaces](/design/api-surfaces/#negotiation-across-transports). It is a design +commitment, and it is not yet implemented on every route; what the generated pages show, +under each operation's parameters and response headers, is what the wire carries at this +commit. Read those for the truth about an endpoint today, and the design document for where +it is going. + +`GET /v1/version` is the unauthenticated reachability probe a client performs before the +handshake. It has no failure variant by construction. What a server publishes about itself — +attestation keys, capabilities, announced deprecations, revoked token identifiers — is under +[Server discovery](/reference/api/well-known/). + +## Errors + +Failures are `application/problem+json` (RFC 9457) bodies. Beyond the standard members, every +problem this server renders carries a **`code`**: a stable identifier from the `error.*` catalog +namespace described in [Internationalization](/design/i18n/). Clients localize the code; the +`detail` message stays English. + +**Switch on the code, never on the status alone.** HTTP status is deliberately coarse: + +| Rejection class | HTTP status | +| --- | --- | +| Structural (bad envelope, unknown enum, sizes) | `400` | +| Unauthenticated or expired token | `401` | +| Unauthorized (capability, quota-hard, suspension) | `403` | +| Not found, including indistinguishable-404 surfaces | `404` | +| Stale state (chain, cursor, directory regression) | `409` | +| Payload too large | `413` | +| Unsupported chunk media type | `415` | +| Protocol outside `[Min, Max]` | `426` | +| Rate limited | `429` | + +A `404` is sometimes a deliberate indistinguishability, not an absence: a surface that must not +reveal whether a resource exists to an unauthorized caller answers the same way in both cases. + +## Clients + +Do not hand-write a client. `capsule-sdk` is generated from this same document, and the +generated request and response types are the only ones guaranteed to track it. The layering +rules server code follows are [API Practices](/development/api-practices/). + +## How these pages stay true + +`capsule-server` emits `capsule-server/openapi.json` from its route types — no database, no key +material, no network, because the router is built purely to describe it — and +`mise run openapi-check-kynos` fails the Rust gate if the committed document disagrees with the +server. The documentation build reads that file and nothing else. + +A generated page is never edited. If something on it is wrong, the annotation it came from is +wrong: fix the handler or model documentation, run `mise run openapi-kynos`, and commit the +document. The pipeline and the reasoning behind it are +[Developer Documentation](/design/developer-docs/). diff --git a/capsule-docs/src/content/docs/reference/cli.md b/capsule-docs/src/content/docs/reference/cli.md new file mode 100644 index 00000000..f631c31a --- /dev/null +++ b/capsule-docs/src/content/docs/reference/cli.md @@ -0,0 +1,66 @@ +--- +title: CLI +description: What the capsule command line is for, how to install it, and where its contract lives +status: draft +--- + +`capsule` is the command line for a Capsule library. It is the only client that performs the +whole data plane end to end — it scans and imports files, seals them locally, opens upload +sessions, drains the sync feed, and rebuilds an index from what is on disk — which is why the +examples in this documentation are commands rather than HTTP requests. The server never sees a +key, so a request transcript would show ciphertext going in and ciphertext coming out; a +transcript of `capsule` shows the operation. + +This page is the hand-written half of the CLI reference. [Commands](/reference/cli/commands/) is +the generated half: every command, argument, and option, emitted from the same `clap` definitions +the binary parses with. + +## Install + +Release builds are published as an archive per target on the +[releases page](https://github.com/justin13888/Capsule/releases), each carrying a single +`capsule` executable. From a checkout, `cargo run -p capsule-cli --` runs the same binary +against the working tree. + +## The two things it holds + +A `capsule` invocation reads at most two pieces of durable state, and it helps to know which: + +- **A library** — a directory named by `--library`, holding the encrypted assets, their sidecar + metadata, and a SQLite index that can be rebuilt from the sidecars alone + (`capsule library rebuild`). Every offline command operates on one. +- **A session** — the token pair `capsule auth login` persists, owner-readable, under the + user's configuration directory. Every networked command reads it, and `capsule reset` removes + it. + +Five commands unseal a library, and all five take its passphrase: `capsule import`, +`capsule push`, `capsule cull`, `capsule show`, and `capsule repair capture-time`. Each +accepts `--passphrase-stdin` as well as prompting, so each runs unattended. The +`capsule library` subcommands are not among them — `info` and `rebuild` read the version +file, the sidecars, and the index, none of which is sealed, so they need no passphrase and +offer no flag for one. + +## Where the contract lives + +- The behaviour of the import pipeline is [Import Pipeline](/design/import/pipeline/); what + `capsule push` speaks is the [Upload Protocol](/design/import/upload-protocol/), and what + `capsule sync` drains is [Download & Sync](/design/import/download-sync/). +- The server endpoints behind the networked commands are the + [REST API](/reference/api/) reference. +- Terminal output is localized through the catalogs described in + [Internationalization](/design/i18n/). Help text is not yet: the command tree this reference + is generated from is English, deliberately and by pinning, so the artifact cannot vary with + the machine that emits it. + +## How the generated page stays true + +`capsule-cli` emits `capsule-cli/cli-surface.json` — a description of the command tree, read +straight from the `clap` definitions — and `mise run cli-surface-check` fails the Rust gate if +the committed copy disagrees with the code. The documentation build reads that file and +nothing else; it never runs cargo. So a new option cannot reach users without either appearing +on the generated page or failing CI. + +A generated page is never edited. If something on it is wrong, the annotation it came from is +wrong: fix the `clap` `about` or doc comment, run `mise run cli-surface`, and commit the +artifact. The pipeline and the reasoning behind it are [Developer +Documentation](/design/developer-docs/). diff --git a/capsule-docs/src/content/docs/reference/index.md b/capsule-docs/src/content/docs/reference/index.md index cff217da..dd94b024 100644 --- a/capsule-docs/src/content/docs/reference/index.md +++ b/capsule-docs/src/content/docs/reference/index.md @@ -4,17 +4,40 @@ description: Generated reference for Capsule's developer surfaces status: draft --- -This section will hold generated reference for every Capsule developer surface — the REST contract, -the command line, the Rust SDK, the Swift and Kotlin bindings, and the browser surface. +Reference for every Capsule developer surface. Each section is generated from a **description +artifact**: a small, committed, machine-readable file that the surface's own toolchain emits and +its own gate keeps current. The documentation build reads those files and never invokes cargo, +uniffi, or wasm-bindgen — which is what keeps a reference page from disagreeing with the code it +describes. [Developer Documentation](/design/developer-docs/) is the contract; this page is the +index to it. -**Nothing is published here yet.** The pipeline that emits these pages is specified in -[Developer Documentation](/design/developer-docs/), which names each surface, the artifact it is -generated from, and the gate that proves that artifact current. Until a surface's emitter and drift -gate exist, its page is deliberately absent rather than hand-written and stale. +Reference pages are generated, never written. Every section's hand-written prose is confined to +its overview page — what the surface is for, how to reach it, where its contract lives. If a +generated page is wrong, the annotation in the source is wrong. -In the meantime: +## Published -- The REST surface-to-transport map is [API Surfaces](/design/api-surfaces/), and the contract rules - server code follows are [API Practices](/development/api-practices/). +| Surface | Overview | Generated from | Kept current by | +| --- | --- | --- | --- | +| REST | [REST API](/reference/api/) | `capsule-server/openapi.json` | `mise run openapi-check-kynos` | +| Command line | [CLI](/reference/cli/) | `capsule-cli/cli-surface.json` | `mise run cli-surface-check` | + +## Not published yet + +These surfaces are named here rather than given an empty route, because a dead link is worse +than an honest absence. + +- **Rust SDK** and **workspace rustdoc.** The workspace is `publish = false`, so docs.rs will + never build it; rustdoc is built by the Rust gate and deployed beside this site rather than + committed. Planned as `/reference/sdk/rust/` and `/reference/crates/`. +- **Swift and Kotlin bindings.** The generated bindings are gitignored build output, so the + bun-only documentation build cannot read them; each needs a committed surface dump alongside + its existing generation step first. +- **Browser surface.** The same problem for `capsule_wasm.d.ts`, with the additional constraint + that its drift gate cannot run where the other Rust gates do. + +Until then: + +- The REST surface-to-transport map is [API Surfaces](/design/api-surfaces/), and the contract + rules server code follows are [API Practices](/development/api-practices/). - Code module to owning design doc is the [Module Map](/design/module-map/). -- `capsule --help` is the current source of truth for the command line. diff --git a/capsule-e2e/Cargo.toml b/capsule-e2e/Cargo.toml new file mode 100644 index 00000000..312a988e --- /dev/null +++ b/capsule-e2e/Cargo.toml @@ -0,0 +1,48 @@ +# The bounded E2E surface (design/module-map.md, "E2E Test Surface"): the real `capsule-sdk` +# and the real `capsule-core` library driven over TCP against the real `capsule-server` +# composition root. A test crate rather than a `tests/` directory of one of those crates because +# the cases need all of them at once — case 1 alone needs the CLI's sync orchestration, its +# migrations and sea-orm, none of which belong in the server's or the SDK's dev graph. +# +# Every dependency below is already in `Cargo.lock`; this crate adds no external crate. +[package] +name = "capsule-e2e" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "End-to-end cases over the real SDK, library and server composition root" + +[lib] +name = "capsule_e2e" +path = "src/lib.rs" +doctest = false + +[dependencies] +capsule-core = { path = "../capsule-core" } +capsule-sdk = { path = "../capsule-sdk" } +# The lib target only: `boot::assemble` is the composition root `serve --memory` runs. +capsule-server = { path = "../capsule-server" } +# `server` binds the assembled service to an ephemeral port; the workspace pin carries only the +# OpenAPI feature, and the server crate itself has `server` as a dev-dependency feature. +kynos = { workspace = true, features = ["server"] } +base64 = { workspace = true } +jiff = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = "3" +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true, features = ["v7"] } + +[dev-dependencies] +# The same `capsule-core`, with `test-support` on: case 6 exports its backup at an explicit fast +# Argon2id cost, and the entry point that takes one is compiled out of a production build on +# purpose (a weak cost is a brute-forceable artifact). Declared here rather than beside the +# normal dependency above so resolver 3 keeps the feature out of `cargo build --workspace` — it +# is on only when a test target is being built, which is the only thing this crate has. +capsule-core = { path = "../capsule-core", features = ["test-support"] } +# Case 1's client leg is the CLI's own `remote::sync` / `remote::list` over its SQLite store. +capsule-cli = { path = "../capsule-cli" } +capsule-cli-migration = { path = "../capsule-cli/migration" } +sea-orm = { workspace = true, features = ["sqlx-sqlite", "runtime-tokio-rustls"] } diff --git a/capsule-e2e/src/fixtures.rs b/capsule-e2e/src/fixtures.rs new file mode 100644 index 00000000..7c629d24 --- /dev/null +++ b/capsule-e2e/src/fixtures.rs @@ -0,0 +1,179 @@ +//! Byte-built fixtures, so the repository carries no binary test asset. + +/// A real 8×8 grayscale baseline JPEG carrying an EXIF APP1 segment, built byte by byte. +/// +/// The same construction as the CLI's import round trip +/// (`capsule-cli/tests/import_round_trip.rs`), which a test crate cannot import. An 8×8 still +/// sits inside the thumbnail tier's 256-pixel cap, so the media stack signs the byte-free +/// `original` sentinel for it and the upload bundle carries no derivative bytes — the cheap +/// fixture for every case that is not about derivatives; [`large_synthetic_jpeg`] is the one +/// that produces a real thumbnail. +#[must_use] +pub fn synthetic_jpeg() -> Vec { + synthetic_jpeg_sized(8, 8) +} + +/// A 512×512 still of the same construction: past the thumbnail tier's long-edge cap, so the +/// media stack decodes it and encodes a real JXL thumbnail for the upload ladder's T1 — the +/// still E2E case 2 pushes. +#[must_use] +pub fn large_synthetic_jpeg() -> Vec { + synthetic_jpeg_sized(512, 512) +} + +/// A grayscale baseline JPEG of `width`×`height` (each a multiple of 8) carrying an EXIF APP1 +/// segment, built byte by byte. +/// +/// The EXIF block is a big-endian TIFF structure with three IFDs — IFD0 (make/model + +/// pointers), the Exif SubIFD (`DateTimeOriginal`, `OffsetTimeOriginal`, pixel dimensions) and +/// the GPS IFD — and the image is a genuine baseline JPEG a conformant decoder accepts: every +/// 8×8 block is the shortest legal encoding of an all-zero block (DC category 0, then EOB), two +/// bits each, so the picture is a flat mid-grey. +#[must_use] +pub fn synthetic_jpeg_sized(width: u16, height: u16) -> Vec { + assert!( + width.is_multiple_of(8) && height.is_multiple_of(8) && width > 0 && height > 0, + "dimensions are whole 8×8 blocks" + ); + const ASCII: u16 = 2; + const LONG: u16 = 4; + const RATIONAL: u16 = 5; + + const MAKE: &[u8] = b"Capsule\0"; + const MODEL: &[u8] = b"Synth\0"; + const DATE_TIME_ORIGINAL: &[u8] = b"2019:03:04 05:06:07\0"; + const OFFSET_TIME_ORIGINAL: &[u8] = b"+00:00\0"; + + // Each IFD here holds four entries: 2 count bytes + 4×12 entry bytes + 4 next-IFD bytes. + const IFD_LEN: u32 = 2 + 4 * 12 + 4; + const IFD0_AT: u32 = 8; + const EXIF_IFD_AT: u32 = IFD0_AT + IFD_LEN; + const GPS_IFD_AT: u32 = EXIF_IFD_AT + IFD_LEN; + const DATA_AT: u32 = GPS_IFD_AT + IFD_LEN; + const MAKE_AT: u32 = DATA_AT; + const MODEL_AT: u32 = MAKE_AT + MAKE.len() as u32; + const DTO_AT: u32 = MODEL_AT + MODEL.len() as u32; + const OTO_AT: u32 = DTO_AT + DATE_TIME_ORIGINAL.len() as u32; + // Rationals are 4-byte quantities; one pad byte keeps them aligned. + const LAT_AT: u32 = OTO_AT + OFFSET_TIME_ORIGINAL.len() as u32 + 1; + const LON_AT: u32 = LAT_AT + 24; + + /// One 12-byte IFD entry whose value is an offset into the TIFF block. + fn at(tag: u16, kind: u16, count: u32, offset: u32) -> Vec { + let mut e = Vec::with_capacity(12); + e.extend_from_slice(&tag.to_be_bytes()); + e.extend_from_slice(&kind.to_be_bytes()); + e.extend_from_slice(&count.to_be_bytes()); + e.extend_from_slice(&offset.to_be_bytes()); + e + } + + /// One 12-byte IFD entry whose value fits in the 4 inline bytes. + fn inline(tag: u16, kind: u16, count: u32, value: [u8; 4]) -> Vec { + let mut e = Vec::with_capacity(12); + e.extend_from_slice(&tag.to_be_bytes()); + e.extend_from_slice(&kind.to_be_bytes()); + e.extend_from_slice(&count.to_be_bytes()); + e.extend_from_slice(&value); + e + } + + fn rational(numerator: u32, denominator: u32) -> Vec { + let mut r = Vec::with_capacity(8); + r.extend_from_slice(&numerator.to_be_bytes()); + r.extend_from_slice(&denominator.to_be_bytes()); + r + } + + let mut tiff = Vec::new(); + tiff.extend_from_slice(b"MM"); + tiff.extend_from_slice(&42u16.to_be_bytes()); + tiff.extend_from_slice(&IFD0_AT.to_be_bytes()); + + // IFD0: Make, Model, and the pointers to the two sub-IFDs. + tiff.extend_from_slice(&4u16.to_be_bytes()); + tiff.extend(at(0x010F, ASCII, MAKE.len() as u32, MAKE_AT)); + tiff.extend(at(0x0110, ASCII, MODEL.len() as u32, MODEL_AT)); + tiff.extend(at(0x8769, LONG, 1, EXIF_IFD_AT)); + tiff.extend(at(0x8825, LONG, 1, GPS_IFD_AT)); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + // Exif SubIFD: capture time, its UTC offset, and the pixel dimensions. + tiff.extend_from_slice(&4u16.to_be_bytes()); + tiff.extend(at(0x9003, ASCII, DATE_TIME_ORIGINAL.len() as u32, DTO_AT)); + tiff.extend(at(0x9011, ASCII, OFFSET_TIME_ORIGINAL.len() as u32, OTO_AT)); + tiff.extend(inline(0xA002, LONG, 1, u32::from(width).to_be_bytes())); + tiff.extend(inline(0xA003, LONG, 1, u32::from(height).to_be_bytes())); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + // GPS IFD: 48°51'29.6"N, 2°17'40.2"W. + tiff.extend_from_slice(&4u16.to_be_bytes()); + tiff.extend(inline(0x0001, ASCII, 2, *b"N\0\0\0")); + tiff.extend(at(0x0002, RATIONAL, 3, LAT_AT)); + tiff.extend(inline(0x0003, ASCII, 2, *b"W\0\0\0")); + tiff.extend(at(0x0004, RATIONAL, 3, LON_AT)); + tiff.extend_from_slice(&0u32.to_be_bytes()); + + // The out-of-line values, in the order the offsets above declare. + tiff.extend_from_slice(MAKE); + tiff.extend_from_slice(MODEL); + tiff.extend_from_slice(DATE_TIME_ORIGINAL); + tiff.extend_from_slice(OFFSET_TIME_ORIGINAL); + tiff.push(0); + for (numerator, denominator) in [(48, 1), (51, 1), (296, 10), (2, 1), (17, 1), (402, 10)] { + tiff.extend(rational(numerator, denominator)); + } + assert_eq!( + tiff.len() as u32, + LON_AT + 24, + "the TIFF block must be exactly as long as its own offsets claim" + ); + + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend_from_slice(&tiff); + + let mut jpeg = vec![0xFF, 0xD8]; // SOI + jpeg.extend_from_slice(&[0xFF, 0xE1]); // APP1 + jpeg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpeg.extend_from_slice(&app1); + + // DQT: one flat 8-bit luminance table. + jpeg.extend_from_slice(&[0xFF, 0xDB]); + jpeg.extend_from_slice(&(2u16 + 1 + 64).to_be_bytes()); + jpeg.push(0x00); + jpeg.extend(std::iter::repeat_n(1u8, 64)); + + // SOF0: baseline, 8-bit, one component with no subsampling. + jpeg.extend_from_slice(&[0xFF, 0xC0]); + jpeg.extend_from_slice(&11u16.to_be_bytes()); + jpeg.extend_from_slice(&[0x08]); + jpeg.extend_from_slice(&height.to_be_bytes()); + jpeg.extend_from_slice(&width.to_be_bytes()); + jpeg.extend_from_slice(&[0x01, 0x01, 0x11, 0x00]); + + // DHT: a DC and an AC table each holding a single 1-bit code for symbol 0. + for class_and_id in [0x00u8, 0x10] { + jpeg.extend_from_slice(&[0xFF, 0xC4]); + jpeg.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes()); + jpeg.push(class_and_id); + jpeg.push(1); + jpeg.extend(std::iter::repeat_n(0u8, 15)); + jpeg.push(0x00); + } + + // SOS, then the entropy-coded data: two zero bits per block (DC category 0, then EOB), + // the last partial byte padded with 1 bits as the standard requires. + jpeg.extend_from_slice(&[0xFF, 0xDA]); + jpeg.extend_from_slice(&8u16.to_be_bytes()); + jpeg.extend_from_slice(&[0x01, 0x01, 0x00, 0x00, 0x3F, 0x00]); + let blocks = usize::from(width / 8) * usize::from(height / 8); + let bits = blocks * 2; + jpeg.extend(std::iter::repeat_n(0u8, bits / 8)); + let dangling = bits % 8; + if dangling != 0 { + jpeg.push((1u8 << (8 - dangling)) - 1); + } + + jpeg.extend_from_slice(&[0xFF, 0xD9]); // EOI + jpeg +} diff --git a/capsule-e2e/src/lib.rs b/capsule-e2e/src/lib.rs new file mode 100644 index 00000000..07415d25 --- /dev/null +++ b/capsule-e2e/src/lib.rs @@ -0,0 +1,434 @@ +//! The harness behind the bounded E2E cases (design/module-map.md, "E2E Test Surface"). +//! +//! Three real things, wired the way production wires them, and nothing standing in for any of +//! them: +//! +//! - **The server** is the composition root — [`capsule_server::boot::assemble`] under the +//! memory profile, the same function `capsule-server serve --memory` runs — bound to an +//! ephemeral port. Real argon2 accounts, the real provisioned write authority, a real +//! filesystem blob store under a temp root, the system clock. Not the test-only `Fixture` +//! the server's own suites use: its `SwallowingBlobs`, `TestAuthority` and `ManualClock` are +//! doubles, and a case that passed against them would prove the doubles. +//! - **The client** is `capsule-sdk` as shipped: every request leaves through the SDK's one +//! HTTP client and therefore carries the protocol handshake the server gates on. +//! - **The library** is a real [`capsule_core::lifecycle::Workspace`] on a temp root, with a +//! fast Argon2id parameter set for the *library* passphrase only (the wrap records its own +//! parameters, so nothing under test reads a weaker setting than it would in the field). +//! +//! What the harness adds on top of the SDK is exactly the seams the SDK does not have yet, each +//! recorded as a finding in the pull request that landed this crate: +//! +//! - the **directory publish** ([`Device::publish_directory`]): the server requires the +//! `X-Capsule-Identity-Key` header on every publish and the SDK's `DirectoryClient` does not +//! send it (issue #466), and a directory must name the *server's* account id, which a +//! `Workspace` cannot learn (issue #467). +//! +//! Every test that uses this crate names its case — `E2E case N` — so `rg "E2E case N"` finds +//! it, per the module map's contract. + +pub mod fixtures; +pub mod push; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_core::crypto::keys::{DeviceDirectory, DeviceEntry, DirectoryCore, HybridSigningKey}; +use capsule_core::crypto::primitives::Argon2Params; +use capsule_core::crypto::provenance::record::ProvenanceRecord; +use capsule_core::lifecycle::Workspace; +use capsule_sdk::albums::{AlbumClient, AlbumTransport}; +use capsule_sdk::auth::{AuthClient, Session}; +use capsule_sdk::client::AuthenticatedClient; +use capsule_sdk::sync::{FeedEntry, SyncConsumer, SyncState}; +use capsule_sdk::upload::{UploadClient, UploadTransport}; +use capsule_server::blob::address::{ContentAddress, blob_path}; +use capsule_server::boot::{self, Assembled}; +use capsule_server::config::{Config, Demands, Overrides}; +use tempfile::TempDir; +use uuid::Uuid; + +/// The protocol date this build speaks — the same constant the SDK's transport sends. +pub const PROTOCOL_VERSION: &str = capsule_core::crypto::primitives::PROTOCOL_VERSION; + +/// A PKCS#8 v1 Ed25519 key, base64: the retired deployment's `.env.example` value, which signs +/// nothing anywhere (the server's own binary test uses the same bytes for the same reason). +pub const JWT_ED25519_DER: &str = + "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + +/// Every account's password. The server hashes it with its production argon2 parameters. +pub const PASSWORD: &str = "correct horse battery staple"; + +/// Every library's passphrase, wrapped under [`FAST_KDF`]. +pub const PASSPHRASE: &[u8] = b"library passphrase"; + +/// Fast Argon2id for the fixture libraries — the CLI's own precedent +/// (`capsule-cli/tests/import_round_trip.rs`). The wrapped blob records these parameters and +/// `unwrap` reads them back, so no code under test runs a weaker setting than it would in the +/// field; only the fixture's own unlock is cheap. +pub const FAST_KDF: Argon2Params = Argon2Params { + mem_kib: 64, + t_cost: 1, + p_cost: 1, +}; + +/// The feed page size the harness pulls with; small enough that `has_more` paging is exercised +/// by any case that pushes more than a handful of assets. +pub const PAGE_SIZE: u32 = 64; + +/// The composition root, assembled and listening on an ephemeral port. +/// +/// Holds the [`Assembled`] so a case can reach the operator workers +/// (`assembled.maintenance`) over the same stores the router serves, and the blob root so a +/// case can assert bytes at their content address on disk. +pub struct Server { + base_url: String, + /// The assembled application: `app` for the router, `maintenance` for the operator workers. + pub assembled: Assembled, + /// `BLOB_ROOT`: where the filesystem blob store files finalized bytes. + pub blob_root: TempDir, + serve: tokio::task::JoinHandle<()>, +} + +impl Server { + /// Boot with the server's default protocol window. + pub async fn boot() -> Self { + Self::boot_with(None).await + } + + /// Boot with `PROTOCOL_MIN`/`PROTOCOL_MAX` overridden — the knob case 9 turns to put this + /// build outside the window. + pub async fn boot_with_window(min: &str, max: &str) -> Self { + Self::boot_with(Some((min, max))).await + } + + async fn boot_with(window: Option<(&str, &str)>) -> Self { + let blob_root = tempfile::tempdir().expect("a temp blob root"); + // Exactly what `serve --memory` reads: the memory profile needs two variables and + // derives the rest (cursor MAC key, attestation seed) from the signing key. + let mut env: BTreeMap = BTreeMap::new(); + env.insert( + "BLOB_ROOT".to_owned(), + blob_root.path().display().to_string(), + ); + env.insert("JWT_ED25519_DER".to_owned(), JWT_ED25519_DER.to_owned()); + if let Some((min, max)) = window { + env.insert("PROTOCOL_MIN".to_owned(), min.to_owned()); + env.insert("PROTOCOL_MAX".to_owned(), max.to_owned()); + } + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + let config = Config::load(&env, &overrides, Demands::Serve) + .expect("the memory profile loads from BLOB_ROOT and JWT_ED25519_DER alone"); + let assembled = boot::assemble(&config) + .await + .expect("the composition root assembles under the memory profile"); + let service = assembled.service().expect("the router builds"); + let bound = kynos::server::Server::new(service) + .bind(("127.0.0.1", 0)) + .prepare() + .await + .expect("an ephemeral port binds"); + let address = *bound + .local_addrs() + .first() + .expect("a bound server has an address"); + let serve = tokio::spawn(async move { + let _ = bound.serve().await; + }); + tracing::info!(%address, "e2e server listening"); + Self { + base_url: format!("http://{address}"), + assembled, + blob_root, + serve, + } + } + + /// The API root (`http://127.0.0.1:PORT`): what the generated client, the sync consumer, + /// the recovery client and the upgrade client take. + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// `{root}/v1`: what the verify transport and the blob source take. + #[must_use] + pub fn v1(&self) -> String { + format!("{}/v1", self.base_url) + } + + /// `{root}/v1/auth`: what `AuthClient` and the directory publish take. + #[must_use] + pub fn auth_base(&self) -> String { + format!("{}/v1/auth", self.base_url) + } + + /// `{root}/v1/upload`: the upload transport's root. + #[must_use] + pub fn upload_base(&self) -> String { + format!("{}/v1/upload", self.base_url) + } + + /// `{root}/v1/albums`: the album transport's root. + #[must_use] + pub fn albums_base(&self) -> String { + format!("{}/v1/albums", self.base_url) + } + + /// Where the filesystem blob store files the blob at content address `hex`. + #[must_use] + pub fn blob_path(&self, hex: &str) -> PathBuf { + let address = ContentAddress::parse(hex).expect("a lowercase SHA-256 hex digest"); + blob_path(self.blob_root.path(), &address) + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.serve.abort(); + } +} + +/// One account on one [`Server`], with its live SDK session and its real library. +/// +/// The account's device directory is published by the harness (see +/// [`Device::publish_directory`]) and names two devices: the library's own, so the manifests it +/// signs satisfy invariant 7, and a standalone *proposer* device whose signing key the harness +/// holds — the `Workspace` keeps its device signing key private, and case 8 needs a key that +/// can sign an `UpgradeIntent`. +pub struct Device { + /// The registered e-mail. + pub email: String, + /// The live SDK session; clone it freely, clones share one token store. + pub session: Session, + /// The **server's** id for this account, read from `GET /v1/auth/profile`. Distinct from + /// `workspace.user_id()`, which the library mints locally — there is no seam to align them. + pub user_id: Uuid, + /// The real library. + pub workspace: Workspace, + /// The library root. + pub root: TempDir, + /// Scratch space for files to import. + pub staging: TempDir, + /// The identity key the published directory is signed with. + pub identity: HybridSigningKey, + /// A device signing key the harness holds, listed in the directory as `proposer_id`. + pub proposer: HybridSigningKey, + /// The proposer device's id. + pub proposer_id: Uuid, + directory_version: u64, +} + +impl Device { + /// Register a fresh account, create its library, publish its directory and provision its + /// default album on the server — everything a first upload needs. + pub async fn register(server: &Server, label: &str) -> Self { + let email = format!("{label}-{}@e2e.capsule.test", Uuid::now_v7().simple()); + let auth = AuthClient::new(&server.auth_base()).expect("the auth base parses"); + let session = auth + .register(&email, PASSWORD) + .await + .expect("a fresh account registers"); + let generated = AuthenticatedClient::new(server.base_url(), session.clone()) + .expect("the API root parses"); + let profile = generated + .get_profile(PROTOCOL_VERSION, None) + .await + .expect("the profile answers") + .into_inner(); + let user_id = Uuid::parse_str(&profile.user_id).expect("the account id is a UUID"); + + let root = tempfile::tempdir().expect("a temp library root"); + let staging = tempfile::tempdir().expect("a temp staging dir"); + let mut workspace = + Workspace::create_with_params(root.path(), PASSPHRASE, FAST_KDF).expect("a library"); + let default_album = workspace.default_album_id(); + workspace + .ensure_album(default_album, "Imports") + .expect("the default album's keys exist"); + + let mut device = Self { + email, + session, + user_id, + workspace, + root, + staging, + identity: HybridSigningKey::generate(), + proposer: HybridSigningKey::generate(), + proposer_id: Uuid::now_v7(), + directory_version: 0, + }; + device.publish_directory(server).await; + let albums = AlbumClient::new(AlbumTransport::with_session( + device.session.clone(), + server.albums_base(), + )); + push::ensure_album(&albums, default_album) + .await + .expect("the default album provisions"); + device + } + + /// A second live session on the same account — device B in the two-device cases. + pub async fn login_again(&self, server: &Server) -> Session { + AuthClient::new(&server.auth_base()) + .expect("the auth base parses") + .login(&self.email, PASSWORD) + .await + .expect("the account signs in again") + .into_session() + .expect("the account has no second factor") + } + + /// The generated REST client over this session. + #[must_use] + pub fn generated(&self, server: &Server) -> AuthenticatedClient { + AuthenticatedClient::new(server.base_url(), self.session.clone()) + .expect("the API root parses") + } + + /// The upload client over this session, pinned to this build's protocol date. + #[must_use] + pub fn upload_client(&self, server: &Server) -> UploadClient { + UploadClient::new(UploadTransport::with_session( + self.session.clone(), + server.upload_base(), + PROTOCOL_VERSION, + )) + } + + /// The library's own entry in its device directory. + #[must_use] + pub fn library_device(&self) -> DeviceEntry { + let id = self.workspace.device_id(); + self.workspace + .device_directory() + .device(&id) + .cloned() + .expect("a library lists its own device") + } + + /// The directory the harness publishes for this account: the server's account id, the + /// library's device and the proposer device, signed by [`Device::identity`]. + #[must_use] + pub fn directory(&self) -> DeviceDirectory { + let library = self.library_device(); + DirectoryCore { + user_id: self.user_id, + directory_version: self.directory_version, + updated_at: jiff::Timestamp::now().to_string(), + devices: vec![ + library.clone(), + DeviceEntry { + device_id: self.proposer_id, + dsk_public: self.proposer.verifying_key(), + dek_public: None, + added_at: library.added_at, + revoked_at: None, + }, + ], + } + .sign(&self.identity) + } + + /// Publish the next version of [`Device::directory`], returning the version stored. + /// + /// Sent through the session's HTTP client rather than `capsule_sdk::directory` because the + /// server requires `X-Capsule-Identity-Key` (invariant 23's second clause) and the SDK's + /// publish does not carry it — issue #466. + pub async fn publish_directory(&mut self, server: &Server) -> u64 { + self.directory_version += 1; + let body = capsule_core::cbor::to_canonical_vec(&self.directory()) + .expect("a directory serializes"); + let identity = BASE64.encode(self.identity.verifying_key().to_bytes()); + let url = format!("{}/devices/directory", server.auth_base()); + let response = self + .session + .execute(|http| { + http.post(&url) + .header("content-type", "application/cbor") + .header("x-capsule-identity-key", &identity) + .body(body.clone()) + }) + .await + .expect("the publish reaches the server"); + assert_eq!( + response.status().as_u16(), + 200, + "the directory publish is accepted: {}", + response.text().await.unwrap_or_default() + ); + let stored: serde_json::Value = response.json().await.expect("a JSON body"); + let version = stored["directory_version"] + .as_u64() + .expect("the stored directory version"); + assert_eq!(version, self.directory_version); + version + } + + /// Write the 8×8 synthetic JPEG to staging under `file_name` and import it into the + /// default album, returning the asset id. + pub fn import_jpeg(&mut self, file_name: &str) -> Uuid { + self.import_file(file_name, &fixtures::synthetic_jpeg()) + } + + /// Write `bytes` to staging under `file_name` and import the file into the default album, + /// returning the asset id. + pub fn import_file(&mut self, file_name: &str, bytes: &[u8]) -> Uuid { + let path = self.staging.path().join(file_name); + std::fs::write(&path, bytes).expect("the fixture writes"); + let album = self.workspace.default_album_id(); + self.workspace + .import_asset(album, &path) + .expect("the file imports") + } + + /// The head of `asset_id`'s provenance chain. + #[must_use] + pub fn head_record(&self, asset_id: &Uuid) -> ProvenanceRecord { + self.workspace + .asset(asset_id) + .expect("the asset is in the library") + .chain + .records() + .last() + .cloned() + .expect("a chain is never empty") + } + + /// Everything the feed holds for this account, from the beginning, through the SDK. + pub async fn feed(&self, server: &Server) -> Vec { + feed_from_start(server, self.session.clone()).await + } +} + +/// Pull the whole feed for `session` from cursor zero through the SDK consumer. +pub async fn feed_from_start(server: &Server, session: Session) -> Vec { + let consumer = + SyncConsumer::with_session(server.base_url(), session).expect("the API root parses"); + let mut state = SyncState::new(PROTOCOL_VERSION); + let mut entries = Vec::new(); + loop { + let page = consumer + .pull_into(&mut state, PAGE_SIZE) + .await + .expect("the feed answers"); + let more = page.has_more; + entries.extend(page.entries); + if !more { + return entries; + } + } +} + +/// The feed entry for `asset_id`, if the server publishes it. +#[must_use] +pub fn entry_for<'a>(entries: &'a [FeedEntry], asset_id: &Uuid) -> Option<&'a FeedEntry> { + let wanted = asset_id.to_string().into_bytes(); + entries.iter().find(|entry| entry.asset_id == wanted) +} diff --git a/capsule-e2e/src/push.rs b/capsule-e2e/src/push.rs new file mode 100644 index 00000000..c7876eba --- /dev/null +++ b/capsule-e2e/src/push.rs @@ -0,0 +1,186 @@ +//! Pushing a library asset to the server through the SDK's own ladder, and the lifecycle-op +//! posting that chains onto what the ladder established. +//! +//! **Nothing here uploads a blob.** [`push_asset`] hands `capsule_core`'s [`UploadBundle`] to +//! [`capsule_sdk::push::push_bundle`] and reports what came back; the index tier it ships is +//! the provenance blob and then the sealed metadata blob, which is what makes the server +//! publish the asset (`capsule-server/src/upload/visibility.rs` requires both index-tier +//! roles) and what gives the server a chain head equal to the client's next +//! `prior_provenance_hash`. The harness used to add that provenance rung itself, because the +//! ladder omitted it; issues #464 and #465 fixed the ladder and the decode side, so the rung is +//! gone from here and the cases exercise the shipped path. +//! +//! The lifecycle-op envelope is projected from the head manifest's [`ManifestCore`] rather than +//! from an `UploadBundle`, because a bundle re-derives the original's ciphertext and an adopted +//! (wrapped-key) asset or a tombstone head has nothing to re-derive; the projection is the same +//! one `capsule_sdk::push::envelope_for` makes, field for field. + +use std::collections::HashSet; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_core::crypto::provenance::manifest::ManifestCore; +use capsule_core::lifecycle::UploadBundle; +use capsule_sdk::net::ConnectionClass; +pub use capsule_sdk::push::ensure_album; +use capsule_sdk::push::{AssetPushReport, bundle_blobs, push_bundle}; +use capsule_sdk::rest; +use capsule_sdk::staged::StagedScheduler; +use capsule_sdk::upload::{BlobRole, ManifestEnvelope}; +use uuid::Uuid; + +use crate::{Device, PROTOCOL_VERSION, Server}; + +/// What one push left behind. +pub struct Pushed { + /// The bundle the library produced for the asset's current head. + pub bundle: UploadBundle, + /// The SDK ladder's report — provenance, metadata, derivatives, original. + pub report: AssetPushReport, + /// The content address of the provenance blob the ladder shipped: the server's chain head. + pub provenance_hash: String, +} + +/// Serialize a wire enum (`Action`, `KeyMode`) to its bare protocol string. +fn wire_enum(value: &T) -> String { + serde_json::to_value(value) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .expect("a wire enum serializes to a string") +} + +/// The SDK's [`ManifestEnvelope`] for one blob of the asset whose head is `core`, with +/// `ciphertext_hash` naming **this** blob (the server's invariant-15 consistency rule). +#[must_use] +pub fn sdk_envelope(core: &ManifestCore, blob_hash: &str) -> ManifestEnvelope { + ManifestEnvelope { + crypto_suite_id: core.crypto_suite_id, + protocol_version: core.protocol_version.clone(), + album_id: Some(core.album_id.to_string()), + file_id: core.file_id.to_string(), + amk_version: core.amk_version.0, + ciphertext_hash: blob_hash.to_owned(), + plaintext_size: core.plaintext_size, + chunk_size: core.chunk_size, + key_mode: wire_enum(&core.key_mode), + metadata_blob_hash: core.metadata_blob_hash.map(|h| h.to_hex()), + created_by_user: core.created_by_user.to_string(), + created_by_device: core.created_by_device.to_string(), + client_version: core.client_version.clone(), + timestamp: core.timestamp.clone(), + action: wire_enum(&core.action), + prior_provenance_hash: core.prior_provenance_hash.map(|h| h.to_hex()), + retention_until: core.retention_until.clone(), + } +} + +/// The same projection in the generated type the lifecycle-op and adopt operations take. +#[must_use] +pub fn wire_envelope(core: &ManifestCore, blob_hash: &str) -> rest::types::ManifestEnvelope { + let envelope = sdk_envelope(core, blob_hash); + rest::types::ManifestEnvelope { + crypto_suite_id: i64::from(envelope.crypto_suite_id), + protocol_version: envelope.protocol_version, + album_id: envelope.album_id, + file_id: envelope.file_id, + amk_version: i64::from(envelope.amk_version), + ciphertext_hash: envelope.ciphertext_hash, + plaintext_size: envelope.plaintext_size as i64, + chunk_size: i64::from(envelope.chunk_size), + key_mode: envelope.key_mode, + metadata_blob_hash: envelope.metadata_blob_hash, + original_blob_hash: None, + created_by_user: envelope.created_by_user, + created_by_device: envelope.created_by_device, + client_version: envelope.client_version, + timestamp: envelope.timestamp, + action: envelope.action, + prior_provenance_hash: envelope.prior_provenance_hash, + retention_until: envelope.retention_until, + } +} + +/// Push `asset_id` in full through the SDK ladder under `UploadPolicy::Full` on an unmetered +/// link: the index tier (provenance, then the sealed metadata blob), every derivative, then the +/// original. +pub async fn push_asset(device: &Device, server: &Server, asset_id: &Uuid) -> Pushed { + let bundle = device + .workspace + .upload_bundle(asset_id) + .expect("the library builds an upload bundle for its own asset"); + let client = device.upload_client(server); + let scheduler = StagedScheduler::new( + capsule_core::import::UploadPolicy::Full, + ConnectionClass::Unmetered, + ); + let report = push_bundle(&client, &scheduler, &bundle, &HashSet::new(), false) + .await + .expect("the SDK ladder pushes the bundle"); + // The ladder's own content address for the provenance rung, read back off the same + // function that decided what to upload rather than re-derived here. + let provenance_hash = bundle_blobs(&bundle) + .into_iter() + .find_map(|(blob, hash)| (blob.role == BlobRole::Provenance).then_some(hash)) + .expect("the SDK ladder always carries a provenance rung"); + tracing::info!( + asset_id = %asset_id, + pushed = report.pushed.len(), + %provenance_hash, + "e2e push complete" + ); + Pushed { + bundle, + report, + provenance_hash, + } +} + +/// Post the library's current chain head for `asset_id` as a lifecycle op +/// (`POST /v1/albums/{album}/ops`) through the generated client. +/// +/// The op carries the head record's canonical CBOR as `manifest_cbor` — so the server's new head +/// is that record's hash — and the sealed metadata blob whenever the head manifest binds one +/// (invariant 25: a hash without its bytes, or bytes without a hash, is a `400`). +/// +/// The CBOR is encoded here rather than taken from [`UploadBundle::provenance_blob`] because a +/// bundle re-derives the original's ciphertext from the media file, and the heads this posts — +/// a tombstone, a trash-restore — need no such file to exist. +pub async fn post_lifecycle_head( + device: &Device, + server: &Server, + asset_id: &Uuid, +) -> rest::types::OpResponse { + let asset = device + .workspace + .asset(asset_id) + .expect("the asset is in the library"); + let head_record = device.head_record(asset_id); + let core = &head_record.manifest.core; + let manifest = capsule_core::cbor::to_canonical_vec(&head_record).expect("a record serializes"); + debug_assert_eq!( + capsule_core::crypto::hash::hash_bytes(&manifest), + head_record.record_hash(), + "record_hash is the digest of the canonical record bytes" + ); + let request = rest::types::OpRequest { + manifest_envelope: wire_envelope(core, &core.ciphertext_hash.to_hex()), + manifest_cbor: BASE64.encode(&manifest), + metadata_blob: core + .metadata_blob_hash + .map(|_| BASE64.encode(&asset.metadata_blob)), + }; + let response = device + .generated(server) + .album_lifecycle_op(core.album_id.to_string(), PROTOCOL_VERSION, None, &request) + .await + .unwrap_or_else(|error| panic!("the {:?} op applies: {error}", core.action)) + .into_inner(); + tracing::info!( + asset_id = %asset_id, + action = %response.action, + sync_seq = response.sync_seq, + replayed = response.replayed, + "e2e lifecycle op applied" + ); + response +} diff --git a/capsule-e2e/tests/case_01_auth_sync_query.rs b/capsule-e2e/tests/case_01_auth_sync_query.rs new file mode 100644 index 00000000..0cb928ef --- /dev/null +++ b/capsule-e2e/tests/case_01_auth_sync_query.rs @@ -0,0 +1,84 @@ +//! **E2E case 1** — auth → sync → client-side library query. +//! +//! Sign in → access token → the sync feed returns the account's entries → the client applies +//! them → a local SQLite query lists the expected album. The client leg is the CLI's own +//! orchestration (`capsule_cli::remote::{sync, list}`) over its migrated SQLite store, which +//! is what `capsule sync` and `capsule list` run; the SDK session is the one the CLI would have +//! persisted after `capsule auth login`. + +use capsule_cli::remote::{self, RemoteConfig}; +use capsule_cli::session::SessionStore; +use capsule_e2e::push::push_asset; +use capsule_e2e::{Device, PROTOCOL_VERSION, Server}; +use migration::{Migrator, MigratorTrait as _}; + +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_1_sign_in_sync_and_a_local_query_lists_the_album() { + let server = Server::boot().await; + let mut device = Device::register(&server, "cli-user").await; + let asset = device.import_jpeg("first.jpg"); + push_asset(&device, &server, &asset).await; + + // The CLI's state: a migrated SQLite store and the session `capsule auth login` persists — + // a fresh sign-in on the account, not the registration session. + let signed_in = device.login_again(&server).await; + let home = tempfile::tempdir().expect("a temp CLI home"); + let db_url = format!( + "sqlite://{}?mode=rwc", + home.path().join("library.sqlite").display() + ); + let db = sea_orm::Database::connect(&db_url) + .await + .expect("the CLI store opens"); + Migrator::up(&db, None) + .await + .expect("the CLI migrations run"); + let store = SessionStore::new(home.path().join("session.json")); + let persisted = signed_in.export().await.expect("a live session exports"); + store.save(&persisted).expect("the session persists"); + + let remote = RemoteConfig { + auth_endpoint: server.auth_base(), + sync_endpoint: server.base_url().to_owned(), + upload_endpoint: server.upload_base(), + albums_endpoint: server.albums_base(), + protocol_version: PROTOCOL_VERSION.to_owned(), + }; + let summary = remote::sync(&remote, &store, &db, 256, false, false) + .await + .expect("`capsule sync` completes"); + assert_eq!(summary.applied, 1, "one entry applied: {summary:?}"); + assert_eq!(summary.albums, 1); + assert!(!summary.dry_run); + + // The client-side library query lists the expected album and asset. + let rows = remote::list(&db, false) + .await + .expect("`capsule list` answers"); + assert_eq!(rows.len(), 1); + let row = &rows[0]; + assert_eq!( + row.album_id, + device.workspace.default_album_id().to_string().into_bytes() + ); + assert_eq!(row.asset_id, asset.to_string().into_bytes()); + assert!(row.original_held); + assert!(!row.tombstoned); + + // A second sync is a no-op — the cursor persisted with the page. + let again = remote::sync(&remote, &store, &db, 256, false, false) + .await + .expect("a second sync completes"); + assert_eq!(again.applied, 0, "nothing new: {again:?}"); +} diff --git a/capsule-e2e/tests/case_02_import_upload_finalize.rs b/capsule-e2e/tests/case_02_import_upload_finalize.rs new file mode 100644 index 00000000..0147f09d --- /dev/null +++ b/capsule-e2e/tests/case_02_import_upload_finalize.rs @@ -0,0 +1,143 @@ +//! **E2E case 2** — full import + upload + finalize. +//! +//! Local import → the library's upload bundle → the SDK's staged ladder (the index tier's +//! provenance and metadata blobs, then the JXL thumbnail, then the original) → every blob +//! finalized at its content address under the server's blob root, byte for byte → the server's +//! storage-verify answer is durable → the asset is on the feed with its original held, its +//! derivative referenced and its metadata blob named. +//! +//! The still is 512×512 — past the thumbnail tier's 256-pixel cap — so the media stack decodes +//! it and encodes a real thumbnail, and T1 is a real upload rather than the byte-free sentinel +//! an 8×8 still gets. + +use capsule_core::crypto::hash::Hash32; +use capsule_core::import::UploadTier; +use capsule_e2e::fixtures::large_synthetic_jpeg; +use capsule_e2e::push::push_asset; +use capsule_e2e::{Device, Server, entry_for}; +use capsule_sdk::verify::{AssetQuery, StorageVerifyClient, VerifyTransport}; + +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_2_import_upload_finalize_lands_every_blob_at_its_content_address() { + let server = Server::boot().await; + let mut device = Device::register(&server, "importer").await; + let asset = device.import_file("photo.jpg", &large_synthetic_jpeg()); + + let pushed = push_asset(&device, &server, &asset).await; + let bundle = &pushed.bundle; + assert_eq!(bundle.asset_id, asset); + assert!( + !bundle.derivatives.is_empty(), + "a still past the thumbnail cap yields derivative bytes" + ); + assert!( + bundle.derivatives.iter().all(|d| d.format == "image/jxl"), + "this build encodes thumbnails as JXL: {:?}", + bundle + .derivatives + .iter() + .map(|d| &d.format) + .collect::>() + ); + + // The ladder ran every tier: two T0 rungs (provenance, then metadata), one T1 per + // derivative, then T2. + let mut expected = vec![UploadTier::Index, UploadTier::Index]; + expected.extend(bundle.derivatives.iter().map(|_| UploadTier::Preview)); + expected.push(UploadTier::Original); + assert_eq!(pushed.report.tier_sequence(), expected); + assert_eq!(pushed.report.deferred, 0); + + // Every blob is on disk at its content address under the blob root, byte for byte. + let on_disk = |hex: &str| std::fs::read(server.blob_path(hex)).expect("the blob is filed"); + assert_eq!(on_disk(&bundle.ciphertext_hash.to_hex()), bundle.ciphertext); + let metadata_hash = bundle + .metadata_blob_hash + .expect("a create binds a metadata blob") + .to_hex(); + assert_eq!(on_disk(&metadata_hash), bundle.metadata_blob); + for derivative in &bundle.derivatives { + assert_eq!( + on_disk(&derivative.ciphertext_hash.to_hex()), + derivative.bytes + ); + } + assert_eq!(on_disk(&pushed.provenance_hash), bundle.provenance_blob); + + // The server's own custody answer for the whole set is durable. + let mut hashes = vec![ + bundle.ciphertext_hash, + Hash32::from_hex(&metadata_hash).expect("a digest"), + Hash32::from_hex(&pushed.provenance_hash).expect("a digest"), + ]; + hashes.extend(bundle.derivatives.iter().map(|d| d.ciphertext_hash)); + let verify = StorageVerifyClient::new(VerifyTransport::with_session( + device.session.clone(), + server.v1(), + )); + let verdicts = verify + .verify( + &[AssetQuery { + asset_id: asset, + blob_hashes: hashes.clone(), + }], + false, + ) + .await + .expect("the verify surface answers"); + assert_eq!(verdicts.len(), 1); + let verdict = &verdicts[0]; + assert_eq!(verdict.asset_id, asset); + assert!( + verdict.durable, + "every named blob is stored and indexed: {verdict:?}" + ); + assert_eq!(verdict.blobs.len(), hashes.len()); + + // The feed publishes the asset: original held, derivative referenced, metadata named. + let feed = device.feed(&server).await; + let entry = entry_for(&feed, &asset).expect("the asset is on the feed"); + assert!(entry.original_held, "the original finalized"); + assert_eq!( + entry + .blobs + .original + .as_ref() + .map(|b| b.ciphertext_hash.as_str()), + Some(bundle.ciphertext_hash.to_hex().as_str()) + ); + let derivative_hashes: Vec<&str> = entry + .blobs + .derivatives + .iter() + .filter(|b| b.role == "derivative") + .map(|b| b.ciphertext_hash.as_str()) + .collect(); + for derivative in &bundle.derivatives { + assert!( + derivative_hashes.contains(&derivative.ciphertext_hash.to_hex().as_str()), + "the {} derivative rides the feed: {derivative_hashes:?}", + derivative.format + ); + } + assert_eq!( + String::from_utf8(entry.metadata_blob.clone()).expect("a hex content address"), + metadata_hash, + "the feed names the metadata blob by its content address" + ); + assert_eq!( + entry.manifest_cbor, bundle.provenance_blob, + "the feed serves the provenance blob's bytes unchanged" + ); +} diff --git a/capsule-e2e/tests/case_03_sync_pickup.rs b/capsule-e2e/tests/case_03_sync_pickup.rs new file mode 100644 index 00000000..208d3148 --- /dev/null +++ b/capsule-e2e/tests/case_03_sync_pickup.rs @@ -0,0 +1,87 @@ +//! **E2E case 3** — sync feed pickup, the client half. +//! +//! The server half is named in `capsule-server/tests/sync.rs`. Here device A uploads through +//! the real SDK and library; device B — a second session on the same account, with a fresh +//! `SyncState` at cursor zero — pulls the feed, sees the entry, and fetches the metadata blob +//! and the original by content address, byte-equal to what A's bundle held. +//! +//! B's `verify_asset` is not asserted: verification needs the album keys, which reach a second +//! device through enrollment or backup (cases 6 and 12), not through the feed. + +use capsule_e2e::push::push_asset; +use capsule_e2e::{Device, PAGE_SIZE, PROTOCOL_VERSION, Server, entry_for}; +use capsule_sdk::fetch::{BlobSource as _, HttpBlobSource, RangeOutcome, fetch_blob}; +use capsule_sdk::sync::{SyncConsumer, SyncState}; + +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_3_a_second_device_sees_the_entry_and_fetches_the_bytes() { + let server = Server::boot().await; + let mut a = Device::register(&server, "device-a").await; + let asset = a.import_jpeg("shared.jpg"); + let pushed = push_asset(&a, &server, &asset).await; + + // Device B: same account, its own session, nothing synced yet. + let session_b = a.login_again(&server).await; + let consumer = + SyncConsumer::with_session(server.base_url(), session_b.clone()).expect("a consumer"); + let mut state = SyncState::new(PROTOCOL_VERSION); + assert!(state.cursor().is_start()); + let page = consumer + .pull_into(&mut state, PAGE_SIZE) + .await + .expect("B's first pull"); + assert!(!page.has_more); + assert!(!state.cursor().is_start(), "the cursor advanced"); + let album = a.workspace.default_album_id().to_string().into_bytes(); + assert!( + state.high_water(&album).is_some(), + "the album's high-water mark is set" + ); + + let entry = entry_for(&page.entries, &asset).expect("A's upload is on B's feed"); + assert_eq!(entry.album_id, album); + assert!(entry.original_held); + + // The metadata blob, by the content address the entry carries. The feed states no size for + // it, so B asks for the whole object rather than a range it cannot know the length of. + let source = HttpBlobSource::new(session_b, server.v1()); + let metadata_address = + String::from_utf8(entry.metadata_blob.clone()).expect("a hex content address"); + assert_eq!( + metadata_address, + pushed + .bundle + .metadata_blob_hash + .expect("a create binds a metadata blob") + .to_hex() + ); + let RangeOutcome::Complete { + bytes: metadata_bytes, + } = source.get_range(&metadata_address, 0, None).await + else { + panic!("the metadata blob serves whole"); + }; + assert_eq!(metadata_bytes, pushed.bundle.metadata_blob); + + // The original, by content address and declared size, byte for byte. + let original = entry + .blobs + .original + .as_ref() + .expect("the original is referenced"); + let original_bytes = fetch_blob(&source, &original.ciphertext_hash, original.size) + .await + .expect("the original fetches"); + assert_eq!(original_bytes, pushed.bundle.ciphertext); +} diff --git a/capsule-e2e/tests/case_06_backup_restore.rs b/capsule-e2e/tests/case_06_backup_restore.rs new file mode 100644 index 00000000..74fac453 --- /dev/null +++ b/capsule-e2e/tests/case_06_backup_restore.rs @@ -0,0 +1,117 @@ +//! **E2E case 6** — backup → restore on a fresh device. +//! +//! Export a full backup → bootstrap a new device via passphrase and escrow → import the backup +//! → assert every asset present and verifiable. +//! +//! Two tests, because until a `Workspace` can open *as* a recovered account (issue #467) the +//! escrow and the restore are independent halves: nothing the escrow recovers feeds the +//! restore, and nothing the restore needs comes from the escrow. +//! +//! - **`E2E case 6 (escrow)`**: A escrows its master key at the low-RAM tier through the SDK's +//! `RecoveryClient` over the real route; a second session fetches it byte for byte and +//! `recover_master_key` yields A's key, proved by re-deriving A's default album id. Two +//! Argon2id passes at `DeviceTier::LowRam` (the wrap and the recovery), which is the only +//! memory-hard cost this crate pays: the tier is the thing under test here, so it is spelled +//! out rather than made cheap. +//! - **`E2E case 6 (restore)`**: A exports a backup; a fresh library on a new root imports it +//! under the exporter's verifying key, reads the asset byte for byte and walks its chain. +//! `verify` is asserted to refuse: the artifact carries no album authority (issue #468). +//! The export names [`FAST_KDF`] explicitly, and the import needs no such argument: the +//! artifact records the cost it was wrapped under, so a cheap export is a cheap restore. + +use capsule_core::crypto::keys::MasterKey; +use capsule_core::crypto::primitives::DeviceTier; +use capsule_core::crypto::provenance::record::ProvenanceChain; +use capsule_core::lifecycle::{LifecycleError, Workspace}; +use capsule_e2e::fixtures::synthetic_jpeg; +use capsule_e2e::{Device, FAST_KDF, PASSPHRASE, Server}; +use capsule_sdk::recovery::RecoveryClient; + +const RECOVERY_SECRET: &[u8] = b"seven words the user wrote down somewhere safe"; +const BACKUP_PASSPHRASE: &[u8] = b"backup passphrase"; + +/// **E2E case 6 (escrow)**: the master key round-trips through the real escrow route and +/// recovers on a second device. +#[tokio::test] +async fn e2e_case_6_the_escrow_round_trips_and_recovers_the_master_key() { + let server = Server::boot().await; + let a = Device::register(&server, "device-a").await; + + let escrow = a + .workspace + .escrow_master_key(RECOVERY_SECRET, DeviceTier::LowRam) + .expect("the master key wraps under the recovery secret"); + RecoveryClient::new(a.session.clone(), server.base_url()) + .expect("the API root parses") + .store_escrow(&escrow) + .await + .expect("the escrow stores"); + + // The fresh device: a new session on the account, nothing else. + let session_b = a.login_again(&server).await; + let fetched = RecoveryClient::new(session_b, server.base_url()) + .expect("the API root parses") + .fetch_escrow() + .await + .expect("the escrow fetches"); + let wire = fetched.blob().clone(); + assert_eq!(wire, escrow, "the escrow is ciphertext served verbatim"); + + let master = capsule_core::backup::recover_master_key(&wire, RECOVERY_SECRET) + .expect("the recovery secret opens the escrow"); + assert_eq!( + MasterKey::from_bytes(master).derive_default_album_id(), + a.workspace.default_album_id(), + "the recovered master key is A's: it derives A's default album id" + ); +} + +/// **E2E case 6 (restore)**: a fresh library imports the backup, reads every asset and walks +/// its chain; `verify` refuses for want of the album authority the artifact does not carry. +#[tokio::test] +async fn e2e_case_6_a_fresh_library_restores_the_backup() { + let server = Server::boot().await; + let mut a = Device::register(&server, "device-a").await; + let asset = a.import_jpeg("keepsake.jpg"); + let archive = a.staging.path().join("backup.tar"); + a.workspace + .export_backup_with_params(&archive, BACKUP_PASSPHRASE, FAST_KDF) + .expect("the backup exports"); + let exporter = a.workspace.exporter_verifying_key(); + + let root_b = tempfile::tempdir().expect("a fresh library root"); + let mut b = + Workspace::create_with_params(root_b.path(), PASSPHRASE, FAST_KDF).expect("a library"); + assert!(b.asset_ids().is_empty(), "no prior state"); + let restored = b + .import_backup(&archive, BACKUP_PASSPHRASE, &exporter) + .expect("the backup imports under the exporter's key"); + assert_eq!(restored, 1); + assert_eq!(b.asset_ids(), vec![asset]); + assert_eq!( + b.read_plaintext(&asset).expect("the asset decrypts"), + synthetic_jpeg() + ); + assert!( + b.has_album(&a.workspace.default_album_id()), + "the restore folded A's album keys into the fresh library" + ); + + // The restored chain is structurally intact, and the plaintext above is the manifest's: + // the ciphertext decrypted under the recovered album key to the bytes A imported. + let chain = &b.asset(&asset).expect("the restored asset").chain; + assert_eq!(chain.records().len(), 1); + ProvenanceChain::verify_walk(chain.records()).expect("the restored chain walks"); + + // What the fresh device cannot yet do is run `verify_asset`: the backup artifact carries + // the album's content keys and none of its authority (the admin-signed epoch ledger a + // manifest's write signature is checked against), so the library has nothing to verify + // the signature under. Asserted as the current truth; issue #468. + match b.verify(&asset) { + Err(LifecycleError::NotFound(what)) => assert!( + what.contains("authority"), + "the refusal names the missing authority: {what}" + ), + other => panic!("a restored album has no authority to verify under, got {other:?}"), + } +} diff --git a/capsule-e2e/tests/case_07_lifecycle.rs b/capsule-e2e/tests/case_07_lifecycle.rs new file mode 100644 index 00000000..393353a5 --- /dev/null +++ b/capsule-e2e/tests/case_07_lifecycle.rs @@ -0,0 +1,160 @@ +//! **E2E case 7** — full lifecycle. +//! +//! Create → metadata update → trash → restore → re-delete → hard purge after retention. The +//! provenance chain advances through every transition, and the server refuses purge before +//! `retention_until`. +//! +//! Every transition is authored by the real library, posted as a lifecycle op through the +//! generated client, and observed by an incremental feed reader. The retention floor is the +//! one the library *signed*: a 30-day tombstone is retained by the collector, a zero-day +//! tombstone is purged, on the operator worker over the same stores the router serves. (The +//! server never purges an unsigned floor — absent is "never", not "now".) + +use capsule_e2e::push::{post_lifecycle_head, push_asset}; +use capsule_e2e::{Device, PAGE_SIZE, PROTOCOL_VERSION, Server, entry_for}; +use capsule_sdk::fetch::{FetchError, HttpBlobSource, fetch_blob}; +use capsule_sdk::sync::{ChangeKind, FeedEntry, SyncConsumer, SyncState}; +use capsule_server::gc::{Mode, purge_expired}; +use uuid::Uuid; + +/// The next page of the incremental reader: what changed since the last pull. +async fn next(consumer: &SyncConsumer, state: &mut SyncState) -> Vec { + consumer + .pull_into(state, PAGE_SIZE) + .await + .expect("the feed answers") + .entries +} + +fn kind_of(entries: &[FeedEntry], asset: &Uuid) -> ChangeKind { + entry_for(entries, asset) + .unwrap_or_else(|| panic!("{asset} changed since the last pull")) + .kind +} + +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_7_the_chain_advances_through_every_transition_and_purge_honours_retention() { + let server = Server::boot().await; + let mut a = Device::register(&server, "owner").await; + let kept = a.import_jpeg("kept.jpg"); + let purged = a.import_jpeg("purged.jpg"); + push_asset(&a, &server, &kept).await; + let purged_bundle = push_asset(&a, &server, &purged).await.bundle; + + let consumer = + SyncConsumer::with_session(server.base_url(), a.session.clone()).expect("a consumer"); + let mut state = SyncState::new(PROTOCOL_VERSION); + let created = next(&consumer, &mut state).await; + assert_eq!(kind_of(&created, &kept), ChangeKind::Created); + assert_eq!(kind_of(&created, &purged), ChangeKind::Created); + + // Metadata update: the caption changes the sealed metadata blob and the chain head. + a.workspace + .set_caption(&kept, "the one we keep") + .expect("the caption sets"); + let op = post_lifecycle_head(&a, &server, &kept).await; + assert_eq!(op.action, "metadata-update"); + assert!(!op.replayed); + assert_eq!( + kind_of(&next(&consumer, &mut state).await, &kept), + ChangeKind::Updated + ); + + // Trash with a 30-day signed floor. + a.workspace + .soft_delete(&kept, 30) + .expect("the asset trashes"); + let op = post_lifecycle_head(&a, &server, &kept).await; + assert_eq!(op.action, "delete"); + assert_eq!( + kind_of(&next(&consumer, &mut state).await, &kept), + ChangeKind::Deleted + ); + + // Restore. + a.workspace.restore(&kept).expect("the asset restores"); + let op = post_lifecycle_head(&a, &server, &kept).await; + assert_eq!(op.action, "trash-restore"); + assert_ne!( + kind_of(&next(&consumer, &mut state).await, &kept), + ChangeKind::Deleted + ); + + // Re-delete, again with the 30-day floor; the other asset with a floor that is already due. + a.workspace + .soft_delete(&kept, 30) + .expect("the asset trashes again"); + assert_eq!( + post_lifecycle_head(&a, &server, &kept).await.action, + "delete" + ); + a.workspace + .soft_delete(&purged, 0) + .expect("the asset trashes"); + assert_eq!( + post_lifecycle_head(&a, &server, &purged).await.action, + "delete" + ); + let deleted = next(&consumer, &mut state).await; + assert_eq!(kind_of(&deleted, &kept), ChangeKind::Deleted); + assert_eq!(kind_of(&deleted, &purged), ChangeKind::Deleted); + + // The chain the library holds is the one the server applied: five records for `kept`. + let chain = &a.workspace.asset(&kept).expect("the asset").chain; + assert_eq!(chain.records().len(), 5); + + // A replay of the same head is idempotent, not a stale-chain refusal. + assert!(post_lifecycle_head(&a, &server, &kept).await.replayed); + + // Purge on the operator worker: the 30-day floor is honoured, the due one is purged. + let report = purge_expired(&server.assembled.maintenance.collection, Mode::Apply, 10) + .await + .expect("the purge runs"); + let names = |ids: &[capsule_server::store::AssetId]| -> Vec { + ids.iter().map(ToString::to_string).collect() + }; + assert_eq!( + names(&report.retained), + vec![kept.to_string()], + "{report:?}" + ); + assert_eq!( + names(&report.purged), + vec![purged.to_string()], + "{report:?}" + ); + + // The purged original is gone to a reader; the retained tombstone's bytes still stand. + let source = HttpBlobSource::new(a.session.clone(), server.v1()); + let gone = fetch_blob( + &source, + &purged_bundle.ciphertext_hash.to_hex(), + purged_bundle.ciphertext.len() as u64, + ) + .await + .expect_err("a purged original no longer serves"); + assert!(matches!(gone, FetchError::Gone), "got {gone:?}"); + assert!( + server + .blob_path( + &a.workspace + .upload_bundle(&kept) + .expect("a bundle") + .ciphertext_hash + .to_hex() + ) + .exists(), + "the retained tombstone's bytes are untouched" + ); +} diff --git a/capsule-e2e/tests/case_08_upgrade_ceremony.rs b/capsule-e2e/tests/case_08_upgrade_ceremony.rs new file mode 100644 index 00000000..06d7b5d1 --- /dev/null +++ b/capsule-e2e/tests/case_08_upgrade_ceremony.rs @@ -0,0 +1,127 @@ +//! **E2E case 8** — the album upgrade ceremony, the server leg through the SDK. +//! +//! An admin initiates the upgrade → quiesce: a signed `UpgradeIntent` from a device in the +//! published directory is proposed through `capsule_sdk::upgrade`, the phase reads back +//! in flight, a write that does not name the ceremony is refused with the ceremony's code, +//! the abort clears it, and the same write then lands. +//! +//! The client ceremony (drain → tombstone → fork → replay, and resume-from-crash) stays in +//! `capsule-core`'s in-process suite: a `Workspace` keeps its device signing key private and +//! cannot sign an intent, so the proposer here is the harness-held device the directory names. + +use capsule_core::crypto::primitives::CRYPTO_SUITE_ID; +use capsule_core::crypto::upgrade::{SignedUpgradeIntent, UpgradeIntent}; +use capsule_e2e::push::push_asset; +use capsule_e2e::{Device, PROTOCOL_VERSION, Server, entry_for}; +use capsule_sdk::push::{bundle_blobs, create_request}; +use capsule_sdk::upgrade::UpgradeClient; +use capsule_sdk::upload::UploadError; +use uuid::Uuid; + +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_8_a_proposed_upgrade_quiesces_the_album_until_it_is_aborted() { + let server = Server::boot().await; + let mut a = Device::register(&server, "admin").await; + let album = a.workspace.default_album_id(); + let generated = a.generated(&server); + + let intent = UpgradeIntent { + intent_id: Uuid::now_v7(), + from_protocol_version: PROTOCOL_VERSION.to_owned(), + to_protocol_version: "2030-01-01".to_owned(), + from_suite_id: CRYPTO_SUITE_ID, + to_suite_id: CRYPTO_SUITE_ID, + proposer_user: a.user_id, + proposer_device: a.proposer_id, + deadline_secs: 300, + }; + let intent_id = intent.intent_id; + let proposer_sig = a + .proposer + .sign(&intent.signing_bytes().expect("an intent encodes")); + let signed = capsule_core::cbor::to_canonical_vec(&SignedUpgradeIntent { + intent, + proposer_sig, + }) + .expect("a signed intent serializes"); + + let phase = UpgradeClient::new(a.session.clone(), server.base_url()) + .begin(album, &signed) + .await + .expect("a signed proposal from a directory device is accepted"); + assert_eq!(phase.album_id, album); + assert_eq!(phase.intent_id, Some(intent_id)); + assert_eq!( + phase.in_flight, 0, + "nothing was mid-flight when the album quiesced" + ); + assert!(phase.expires_at.is_some()); + + let read = generated + .album_upgrade_phase(album.to_string(), PROTOCOL_VERSION, None) + .await + .expect("the phase reads") + .into_inner(); + assert_eq!( + read.intent_id.as_deref(), + Some(intent_id.to_string().as_str()) + ); + assert_eq!(read.to_protocol_version.as_deref(), Some("2030-01-01")); + + // A write that does not name the ceremony is refused with its code and the live intent. + let asset = a.import_jpeg("during-quiesce.jpg"); + let bundle = a.workspace.upload_bundle(&asset).expect("a bundle"); + let blobs = bundle_blobs(&bundle); + let (blob, hash) = blobs.first().expect("a T0 blob"); + let request = create_request(&bundle, blob, hash); + assert!(request.intent_id.is_none()); + let refused = a + .upload_client(&server) + .create_session(&request) + .await + .expect_err("a quiescing album refuses a write that names no ceremony"); + match &refused { + UploadError::Rejected { status, code, .. } => { + assert_eq!(*status, 409); + assert_eq!(code.as_deref(), Some("error.upload.album_quiescing")); + } + other => panic!("expected the ceremony's refusal, got {other:?}"), + } + + // Abort: the phase clears and the same write lands. + let aborted = generated + .abort_album_upgrade( + album.to_string(), + intent_id.to_string(), + PROTOCOL_VERSION, + None, + ) + .await + .expect("the proposer aborts") + .into_inner(); + assert_eq!(aborted.intent_id, None); + let cleared = generated + .album_upgrade_phase(album.to_string(), PROTOCOL_VERSION, None) + .await + .expect("the phase reads") + .into_inner(); + assert_eq!(cleared.intent_id, None); + + push_asset(&a, &server, &asset).await; + let feed = a.feed(&server).await; + assert!( + entry_for(&feed, &asset).is_some(), + "the write resumed after the abort" + ); +} diff --git a/capsule-e2e/tests/case_12_enrollment.rs b/capsule-e2e/tests/case_12_enrollment.rs new file mode 100644 index 00000000..8875c5b9 --- /dev/null +++ b/capsule-e2e/tests/case_12_enrollment.rs @@ -0,0 +1,193 @@ +//! **E2E case 12** — cross-device enrollment, the server leg through the SDK. +//! +//! Device A authorizes new device B over a verified channel: fresh local auth → an enrollment +//! code → B redeems it into a relay channel → payloads cross in both directions, each +//! delivered once and never to the wrong mailbox → the initiator closes the channel. Includes +//! one MITM-on-relay abort: the initiator sees a payload that is not the key material it +//! expected and closes, and the enrollee's next drain finds no channel. +//! +//! The client ceremony — B's hardware keys, the safety-code check, A cross-signing B into the +//! directory — is blocked on seams the tree does not have (issues #471 and #467); B's MLS +//! joins and `libraries match` wait on server-side membership (#405). + +use capsule_e2e::{Device, PASSWORD, PROTOCOL_VERSION, Server}; +use capsule_sdk::rest; +use capsule_sdk::rest::types::{ReauthenticateRequest, RedeemRequest, RelayRequest}; + +const TO_ENROLLEE: &str = "to_enrollee"; +const TO_INITIATOR: &str = "to_initiator"; + +/// The enrollee's client: no account yet, but it is a Capsule build and speaks the handshake. +fn enrollee(server: &Server) -> rest::Client { + rest::Client::with_client( + capsule_sdk::net::http_client().expect("the SDK client builds"), + server.base_url(), + ) + .expect("the API root parses") +} + +async fn open_channel(server: &Server, a: &Device) -> String { + let issued = a + .generated(server) + .issue_enrollment_code(PROTOCOL_VERSION, None) + .await + .expect("a freshly authenticated initiator issues a code") + .into_inner(); + assert!(!issued.code.is_empty()); + assert!(!issued.text_fallback.is_empty()); + enrollee(server) + .redeem_enrollment_code(PROTOCOL_VERSION, None, &RedeemRequest { code: issued.code }) + .await + .expect("the enrollee redeems the code") + .into_inner() + .channel_id +} + +fn relay(direction: &str, payload: &str) -> RelayRequest { + RelayRequest { + direction: direction.to_owned(), + payload: payload.to_owned(), + } +} + +#[tokio::test] +async fn e2e_case_12_a_code_opens_a_relay_channel_that_delivers_each_payload_once() { + let server = Server::boot().await; + let a = Device::register(&server, "initiator").await; + let initiator = a.generated(&server); + let b = enrollee(&server); + + // Fresh local auth on the initiator: the password, on the already-authenticated session. + let fresh = initiator + .reauthenticate( + PROTOCOL_VERSION, + None, + &ReauthenticateRequest { + password: PASSWORD.to_owned(), + }, + ) + .await + .expect("the password re-authenticates the session") + .into_inner(); + assert!(!fresh.authenticated_at.is_empty()); + + let channel = open_channel(&server, &a).await; + + // A → B, then B → A; each mailbox holds only its own direction. + initiator + .relay_enrollment_payload( + &channel, + PROTOCOL_VERSION, + None, + &relay(TO_ENROLLEE, "wrapped-album-keys"), + ) + .await + .expect("the initiator relays"); + b.relay_enrollment_payload( + &channel, + PROTOCOL_VERSION, + None, + &relay(TO_INITIATOR, "device-b-public-keys"), + ) + .await + .expect("the enrollee relays"); + let to_b = b + .drain_enrollment_channel(&channel, TO_ENROLLEE, PROTOCOL_VERSION, None) + .await + .expect("the enrollee drains") + .into_inner(); + assert_eq!(to_b.payloads, vec!["wrapped-album-keys"]); + let to_a = initiator + .drain_enrollment_channel(&channel, TO_INITIATOR, PROTOCOL_VERSION, None) + .await + .expect("the initiator drains") + .into_inner(); + assert_eq!(to_a.payloads, vec!["device-b-public-keys"]); + + // Delivered once: both mailboxes are now empty. + for direction in [TO_ENROLLEE, TO_INITIATOR] { + let again = b + .drain_enrollment_channel(&channel, direction, PROTOCOL_VERSION, None) + .await + .expect("a drained mailbox still answers") + .into_inner(); + assert!(again.payloads.is_empty(), "{direction} delivered twice"); + } + + // The initiator closes; the channel is gone for the enrollee. + initiator + .close_enrollment_channel(&channel, PROTOCOL_VERSION, None) + .await + .expect("the initiator closes its channel"); + let closed = b + .drain_enrollment_channel(&channel, TO_ENROLLEE, PROTOCOL_VERSION, None) + .await + .expect_err("a closed channel is not found"); + match closed { + rest::Error::Api(response) => match response.into_inner() { + rest::DrainEnrollmentChannelError::Status404(problem) => { + assert_eq!(problem.code, "error.enrollment.channel_not_found"); + } + other => panic!("expected 404, got {other:?}"), + }, + other => panic!("expected an API refusal, got {other:?}"), + } +} + +#[tokio::test] +async fn e2e_case_12_the_initiator_aborts_on_a_payload_it_did_not_expect() { + let server = Server::boot().await; + let a = Device::register(&server, "initiator").await; + let initiator = a.generated(&server); + let b = enrollee(&server); + // Registration is fresh local auth: the code issues without a separate reauthentication. + let channel = open_channel(&server, &a).await; + + // What B advertised out of band (the safety code the users compare) versus what arrives. + const ADVERTISED: &str = "device-b-public-keys"; + b.relay_enrollment_payload( + &channel, + PROTOCOL_VERSION, + None, + &relay(TO_INITIATOR, "device-m-public-keys"), + ) + .await + .expect("the relay accepts what it is given"); + let arrived = initiator + .drain_enrollment_channel(&channel, TO_INITIATOR, PROTOCOL_VERSION, None) + .await + .expect("the initiator drains") + .into_inner(); + assert_ne!( + arrived.payloads, + vec![ADVERTISED], + "the relay was tampered with" + ); + + // Abort: close, and never send the wrapped keys. + initiator + .close_enrollment_channel(&channel, PROTOCOL_VERSION, None) + .await + .expect("the initiator aborts by closing"); + let aborted = b + .drain_enrollment_channel(&channel, TO_ENROLLEE, PROTOCOL_VERSION, None) + .await + .expect_err("nothing reaches the enrollee after the abort"); + assert!( + matches!( + aborted, + rest::Error::Api(ref response) + if matches!(response.inner(), rest::DrainEnrollmentChannelError::Status404(_)) + ), + "got {aborted:?}" + ); + let relayed_late = b + .relay_enrollment_payload( + &channel, + PROTOCOL_VERSION, + None, + &relay(TO_INITIATOR, ADVERTISED), + ) + .await; + assert!(relayed_late.is_err(), "a closed channel accepts nothing"); +} diff --git a/capsule-e2e/tests/case_13_web_drop_adopt.rs b/capsule-e2e/tests/case_13_web_drop_adopt.rs new file mode 100644 index 00000000..c8ee13c5 --- /dev/null +++ b/capsule-e2e/tests/case_13_web_drop_adopt.rs @@ -0,0 +1,204 @@ +//! **E2E case 13** — web drop → adopt, the server leg. +//! +//! The provisioning user's library issues an upload link; the link is provisioned on the +//! server; a guest with no account and **no protocol handshake** seals a drop to the link's +//! Drop Key and deposits it through the two exempt guest operations; the owner's inbox shows +//! it; the owner's library decapsulates, rewraps the key under the album AMK and adopts it in +//! place; the server adopts the same manifest and holds the drop's bytes as the asset's +//! durable original. The feed leg waits on a library seam recorded at the end of the test. +//! +//! The browser half of the seal is the cross-language KAT (`capsule-core/tests/drop_adopt_kat.rs` +//! and `capsule-web`'s `drop-seal.test.ts`); here the seal runs natively. Verification on a +//! second device waits on key transfer (cases 6 and 12). + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_core::crypto::hash::hash_bytes; +use capsule_core::crypto::primitives::CRYPTO_SUITE_ID; +use capsule_core::crypto::provenance::manifest::KeyMode; +use capsule_core::drop::{DropAdopter as _, LinkCaps, UploadLinkIssuer as _, seal_drop}; +use capsule_e2e::fixtures::synthetic_jpeg; +use capsule_e2e::push::wire_envelope; +use capsule_e2e::{Device, PROTOCOL_VERSION, Server, entry_for}; +use capsule_sdk::rest; +use capsule_sdk::rest::types::{AdoptRequest, CreateDropRequest, ProvisionLinkRequest}; +use capsule_sdk::verify::{AssetQuery, StorageVerifyClient, VerifyTransport}; + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) +} + +#[tokio::test] +async fn e2e_case_13_a_guest_drop_is_deposited_without_a_handshake_and_adopted_in_place() { + let server = Server::boot().await; + let mut owner = Device::register(&server, "owner").await; + let album = owner.workspace.default_album_id(); + let generated = owner.generated(&server); + + // The owner's library issues the link; the server learns its opaque id and Drop Key. + let link = owner + .workspace + .create_link(LinkCaps::default(), None) + .expect("the library issues an upload link"); + let opaque_id = hex(&link.opaque_id); + let provisioned = generated + .provision_link( + PROTOCOL_VERSION, + None, + &ProvisionLinkRequest { + opaque_id: opaque_id.clone(), + drop_pubkey: BASE64.encode(&link.drop_pubkey), + crypto_suite_id: i64::from(CRYPTO_SUITE_ID), + expires_at: None, + max_total_bytes: None, + max_file_count: None, + max_file_size: None, + single_use: Some(false), + passphrase_verifier: None, + }, + ) + .await + .expect("the link provisions") + .into_inner(); + assert_eq!(provisioned.opaque_id, opaque_id); + + // The guest: a bare generated client — no session, no default headers, no handshake. + let guest = rest::Client::new(server.base_url()).expect("the API root parses"); + let plaintext = synthetic_jpeg(); + let sealed = seal_drop(&plaintext, &link.drop_pubkey, "image/jpeg").expect("the drop seals"); + let ciphertext_hash = sealed.descriptor.ciphertext_hash.to_hex(); + let created = guest + .create_drop( + &opaque_id, + &CreateDropRequest { + content_type: "image/jpeg".to_owned(), + size: sealed.ciphertext.len() as i64, + ciphertext_hash: ciphertext_hash.clone(), + kem_ct: BASE64.encode(&sealed.descriptor.kem_ct), + passphrase_proof: None, + suggested_filename: Some("drop.jpg".to_owned()), + }, + ) + .await + .expect("the exempt guest operation admits a client with no handshake") + .into_inner(); + guest + .append_drop_chunk( + &opaque_id, + &created.upload_id, + rest::AppendDropChunkParams { + x_capsule_offset: Some("0".to_owned()), + x_capsule_checksum: Some(hash_bytes(&sealed.ciphertext).to_hex()), + }, + &rest::types::RequestBody4e14fb73::from(sealed.ciphertext.clone()), + ) + .await + .expect("the exempt chunk operation admits the bytes"); + + // The owner's inbox shows the drop, and the bytes are filed at their content address. + let inbox = generated + .list_inbox(PROTOCOL_VERSION, None) + .await + .expect("the inbox answers") + .into_inner(); + assert_eq!(inbox.drops.len(), 1); + let pending = &inbox.drops[0]; + assert_eq!(pending.opaque_id, opaque_id); + assert_eq!(pending.ciphertext_hash, ciphertext_hash); + assert_eq!(pending.size, sealed.ciphertext.len() as i64); + assert!(!pending.adopting); + assert_eq!( + std::fs::read(server.blob_path(&ciphertext_hash)).expect("the drop is filed"), + sealed.ciphertext + ); + + // The owner's library decapsulates and adopts in place: a wrapped-key create manifest. + let drop_id = owner + .workspace + .receive_drop(link.link_id, sealed.clone()) + .expect("the library receives the drop"); + let manifest = owner + .workspace + .adopt(drop_id, album) + .expect("the library adopts the drop"); + let core = &manifest.core; + assert_eq!(core.ciphertext_hash.to_hex(), ciphertext_hash); + assert_eq!(core.key_mode, KeyMode::Wrapped); + assert!( + core.wrapped_file_key.is_some(), + "the guest's key is rewrapped under the AMK" + ); + let asset = core.file_id; + assert_eq!(core.created_by_device, owner.workspace.device_id()); + assert_eq!(core.plaintext_size, plaintext.len() as u64); + + // The server adopts the same manifest: the staged bytes become the asset's original. + let adopted = generated + .adopt_drop( + &pending.drop_id, + PROTOCOL_VERSION, + None, + &AdoptRequest { + album_id: album.to_string(), + asset_id: asset.to_string(), + size: sealed.ciphertext.len() as i64, + hash: ciphertext_hash.clone(), + content_type: "image/jpeg".to_owned(), + crypto_suite_id: i64::from(CRYPTO_SUITE_ID), + protocol_version: core.protocol_version.clone(), + key_mode: "wrapped".to_owned(), + manifest_envelope: wire_envelope(core, &ciphertext_hash), + }, + ) + .await + .expect("the server adopts the drop") + .into_inner(); + assert_eq!(adopted.asset_id, asset.to_string()); + assert!( + generated + .list_inbox(PROTOCOL_VERSION, None) + .await + .expect("the inbox answers") + .into_inner() + .drops + .is_empty(), + "the adopted drop left the inbox" + ); + + // The server holds the drop's bytes as the asset's original: stored, indexed, retrievable. + let verify = StorageVerifyClient::new(VerifyTransport::with_session( + owner.session.clone(), + server.v1(), + )); + let verdicts = verify + .verify( + &[AssetQuery { + asset_id: asset, + blob_hashes: vec![core.ciphertext_hash], + }], + false, + ) + .await + .expect("the verify surface answers"); + assert_eq!(verdicts.len(), 1); + assert!( + verdicts[0].durable, + "the adopted original is durable: {:?}", + verdicts[0] + ); + + // Where the server leg stops: the feed publishes an asset only once it holds the index + // tier — the sealed metadata blob and the provenance record — and the library's adopt + // returns the signed manifest without registering the asset or handing back the metadata + // blob it sealed, so the owner has nothing to publish — issue #469, which the feed leg + // and the second-device verify wait on. + let feed = owner.feed(&server).await; + assert!( + entry_for(&feed, &asset).is_none(), + "an adopted asset with no index tier is not yet published" + ); +} diff --git a/capsule-e2e/tests/protocol_contract.rs b/capsule-e2e/tests/protocol_contract.rs new file mode 100644 index 00000000..ee308535 --- /dev/null +++ b/capsule-e2e/tests/protocol_contract.rs @@ -0,0 +1,217 @@ +//! **E2E case 9** — the cross-version protocol gate, end to end — and the body-less `413`. +//! +//! Case 9's wording: a client whose `protocol_version` falls outside the server's range +//! attempts an upload, receives `426`, and the UI surfaces an actionable error. The UI leg is +//! out of scope here; what the SDK hands the UI is the typed error with the server's window, +//! asserted from three angles: +//! +//! 1. a per-transport pin outside the default window is refused at `POST /v1/upload` with the +//! window the server advertises (`UploadError::UpgradeRequired { min, max }`); +//! 2. a server booted with `PROTOCOL_MIN = PROTOCOL_MAX = 2000-01-01` — the whole +//! `Config` → `boot` → `Negotiation` path — refuses this build's *writes* with `426` and +//! `error.protocol.version_unsupported`, and stamps `X-Capsule-Protocol-Min/Max` on every +//! response, the exempt ones included; +//! 3. a *read* at an out-of-window date succeeds and carries the window (issue #404's +//! decision: reads of any grammatical protocol date are admitted), while a malformed +//! handshake is `400 error.request.malformed`. +//! +//! The `413` contract: Kynos's body-size backstop answers with no problem body, so the SDK +//! reports it as `code: None` rather than minting a code the server never sent. + +use capsule_core::crypto::pwkdf::WrappedSecret; +use capsule_e2e::push::ensure_album; +use capsule_e2e::{Device, PASSWORD, Server}; +use capsule_sdk::auth::{AuthClient, AuthError}; +use capsule_sdk::push::{bundle_blobs, create_request}; +use capsule_sdk::recovery::{RecoveryClient, RecoveryError}; +use capsule_sdk::rest; +use capsule_sdk::upload::{UploadClient, UploadError, UploadTransport}; + +const DEFAULT_MIN: &str = "2026-01-01"; +const DEFAULT_MAX: &str = "2026-12-31"; +const STALE: &str = "1999-01-01"; +const VERSION_UNSUPPORTED: &str = "error.protocol.version_unsupported"; + +/// **E2E case 9**, leg 1: a stale transport pin against the default window. +// Blocked by #467, and only this tree could show it. `capsule-core` mints the library's +// account id locally (`keystore.rs`, `Uuid::now_v7()`) and writes it into every signed +// manifest as `created_by_user`; the server mints its own at registration +// (`auth/registry.rs`). #405 (merged here) refuses an upload whose `created_by_user` is not +// the authenticated caller — correctly: the design keeps **one** account namespace, the +// server's, and `verify_asset` step 6 and `directory::project_version` both enforce it. What +// is missing is the client seam that would let a library open *as* a server account, which is +// exactly what #467 is filed for. Neither #405's branch nor #409's had the other, so neither +// could see this. Un-ignore when #467 lands its constructor. +#[tokio::test] +#[ignore = "#467: a Workspace has no seam to adopt the server's account id, so the \ + real SDK push path cannot satisfy #405's created_by_user == caller rule"] +async fn e2e_case_9_a_stale_pin_is_refused_with_the_servers_window() { + let server = Server::boot().await; + let mut device = Device::register(&server, "stale").await; + let asset = device.import_jpeg("stale.jpg"); + let bundle = device + .workspace + .upload_bundle(&asset) + .expect("a bundle for the asset"); + let blobs = bundle_blobs(&bundle); + let (blob, hash) = blobs.first().expect("a bundle has a T0 blob"); + let request = create_request(&bundle, blob, hash); + + // The pin wins over the transport's default header (`net.rs`): this is the one + // hand-written place the SDK lets a caller speak an older protocol. + let stale = UploadClient::new(UploadTransport::with_session( + device.session.clone(), + server.upload_base(), + STALE, + )); + let refused = stale + .create_session(&request) + .await + .expect_err("a protocol date before the window is refused"); + match refused { + UploadError::UpgradeRequired { min, max, .. } => { + assert_eq!(min.as_deref(), Some(DEFAULT_MIN)); + assert_eq!(max.as_deref(), Some(DEFAULT_MAX)); + } + other => panic!("expected UpgradeRequired, got {other:?}"), + } + + // The same request at this build's date succeeds — the pin was the only difference. + device + .upload_client(&server) + .create_session(&request) + .await + .expect("this build's protocol date is inside the window"); +} + +/// **E2E case 9**, leg 2: a server whose window excludes this build refuses its writes and +/// advertises the window on every response. +#[tokio::test] +async fn e2e_case_9_a_server_outside_this_builds_window_refuses_writes_with_426() { + let server = Server::boot_with_window("2000-01-01", "2000-01-01").await; + + // The first write any client makes — registration — through the SDK's own auth client. + let Err(refused) = AuthClient::new(&server.auth_base()) + .expect("the auth base parses") + .register("nobody@e2e.capsule.test", PASSWORD) + .await + else { + panic!("a write from outside the window is refused"); + }; + match &refused { + AuthError::Unexpected { status, code, .. } => { + assert_eq!(*status, 426); + assert_eq!(code.as_deref(), Some(VERSION_UNSUPPORTED)); + } + other => panic!("expected a 426 with the gate's code, got {other:?}"), + } + assert_eq!(refused.error_code(), Some(VERSION_UNSUPPORTED)); + + // The window rides every response, an exempt operation's included (`GET /v1/version` is + // one of the ten the design exempts, and this client sends no handshake at all). + let exempt = rest::Client::new(server.base_url()).expect("the API root parses"); + let version = exempt.get_version().await.expect("the version is public"); + let header = |name: &str| { + version + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + assert_eq!( + header("x-capsule-protocol-min").as_deref(), + Some("2000-01-01") + ); + assert_eq!( + header("x-capsule-protocol-max").as_deref(), + Some("2000-01-01") + ); +} + +/// **E2E case 9**, leg 3: reads are admitted at any grammatical date and carry the window; +/// a handshake that does not parse is a `400` everywhere the gate stands. +#[tokio::test] +async fn e2e_case_9_reads_at_an_old_protocol_succeed_and_carry_the_window() { + let server = Server::boot().await; + let device = Device::register(&server, "reader").await; + let feed = format!("{}/v1/sync", server.base_url()); + + // An explicit header wins over the transport's default: the request leaves at `1999-01-01`. + let response = device + .session + .execute(|http| http.get(&feed).header("x-capsule-protocol", STALE)) + .await + .expect("the feed answers"); + assert_eq!( + response.status().as_u16(), + 200, + "a read at an old date is admitted" + ); + let header = |name: &str| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + assert_eq!( + header("x-capsule-protocol-min").as_deref(), + Some(DEFAULT_MIN) + ); + assert_eq!( + header("x-capsule-protocol-max").as_deref(), + Some(DEFAULT_MAX) + ); + + let malformed = device + .session + .execute(|http| http.get(&feed).header("x-capsule-protocol", "yesterday")) + .await + .expect("the gate answers"); + assert_eq!(malformed.status().as_u16(), 400); + let problem: serde_json::Value = malformed.json().await.expect("a problem body"); + assert_eq!(problem["code"], "error.request.malformed"); +} + +/// The body-less `413`: the transport backstop carries no problem body, so the SDK reports +/// `code: None` rather than a code the server never sent. +#[tokio::test] +async fn a_body_past_the_transport_limit_reaches_the_sdk_as_a_codeless_413() { + let server = Server::boot().await; + let device = Device::register(&server, "escrow").await; + let recovery = RecoveryClient::new(device.session.clone(), server.base_url()) + .expect("the API root parses"); + + // 33 MiB: one past the 32 MiB `BodySize` backstop. Fast Argon2id parameters, because + // nothing here derives — the bytes never reach the escrow route's own checks. + let oversized = WrappedSecret { + mem_kib: 64, + t_cost: 1, + p_cost: 1, + salt: [0; 32], + nonce: [0; 12], + ciphertext: vec![0; 33 * 1024 * 1024], + }; + let refused = recovery + .store_escrow(&oversized) + .await + .expect_err("a body past the transport limit is refused"); + match &refused { + RecoveryError::Malformed { code, .. } => assert!( + code.is_none(), + "a body-less 413 carries no code for the SDK to relay, got {code:?}" + ), + other => panic!("expected Malformed, got {other:?}"), + } + assert_eq!(refused.error_code(), None); + + // The account is otherwise healthy: the same client provisions and reads as before. + let albums = + capsule_sdk::albums::AlbumClient::new(capsule_sdk::albums::AlbumTransport::with_session( + device.session.clone(), + server.albums_base(), + )); + ensure_album(&albums, device.workspace.default_album_id()) + .await + .expect("the session survives the refusal"); +} diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 318b9021..087c6771 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1727,6 +1727,75 @@ "cli.cull.swept": "Swept {count} rejected asset(s) to trash; recoverable for {retain_days} day(s).", "cli.cull.unknown_asset": "Unknown asset {asset_id} in this library.", "cli.cull.view": "Cull view: {pick} pick, {neutral} neutral, {reject} reject.", + "cli.help.auth.about": "Authentication commands", + "cli.help.auth.login.about": "Login to Capsule", + "cli.help.auth.login.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.login.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.logout.about": "Logout from Capsule", + "cli.help.auth.register.about": "Create a Capsule account and sign in", + "cli.help.auth.register.arg.email": "Account email (prompted when omitted)", + "cli.help.auth.register.arg.password_stdin": "Read the password from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.auth.status.about": "Show authentication status", + "cli.help.cull.about": "Review a local library: flag assets, filter by flag, sweep rejects to trash", + "cli.help.cull.arg.filter": "List the assets carrying one flag instead of only counting them", + "cli.help.cull.arg.library": "Path to the Capsule library", + "cli.help.cull.arg.neutral": "Clear an asset's flag back to the never-flagged default (repeatable)", + "cli.help.cull.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so culling works in scripts and CI where there is no terminal", + "cli.help.cull.arg.pick": "Flag an asset as a keeper (repeatable)", + "cli.help.cull.arg.reject": "Flag an asset for rejection (repeatable)", + "cli.help.cull.arg.retain_days": "Retention window, in days, the sweep's soft delete stamps", + "cli.help.cull.arg.sweep": "Move every rejected asset to trash. The only destructive step, and soft per retention — swept assets stay restorable until the window elapses", + "cli.help.demo.about": "Run the offline end-to-end data-plane showcase (real cryptography, no network)", + "cli.help.demo.arg.image": "A real image/file to import (a small synthetic file is used if omitted)", + "cli.help.demo.arg.workdir": "Working directory for the demo libraries (a temp dir is used if omitted)", + "cli.help.import.about": "Import files into a local Capsule library", + "cli.help.import.arg.force": "Re-import files even if they already exist (duplicate override)", + "cli.help.import.arg.library": "Path to the Capsule library", + "cli.help.import.arg.move": "Move files instead of copying them", + "cli.help.import.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so imports work in scripts and CI where there is no terminal", + "cli.help.import.arg.paths": "Source file or directory to import. Repeatable: a split Takeout export extracted into several folders is imported by naming every part in one run, so a media file and a sidecar that landed in different parts are still paired", + "cli.help.import.arg.provider": "Read the source as an export from this service instead of as a plain directory tree, so its out-of-band metadata (capture time, GPS, captions, favorites, album membership) is folded into the imported assets", + "cli.help.import.arg.push": "Push the library to the server after importing — sugar for a `capsule push` run over the same library. The import itself stays offline", + "cli.help.import.arg.staged": "Stage the follow-on push (`--push`) in tier order, gating the preview and original tiers on the connection class", + "cli.help.library.about": "Manage the local library", + "cli.help.library.info.about": "Show library information", + "cli.help.library.info.arg.path": "Path to the library", + "cli.help.library.init.about": "Create a new Capsule library", + "cli.help.library.init.arg.name": "Human-readable library name", + "cli.help.library.init.arg.path": "Directory for the new library", + "cli.help.library.rebuild.about": "Rebuild the SQLite index from sidecar files", + "cli.help.library.rebuild.arg.path": "Path to the library", + "cli.help.list.about": "List the assets the sync feed has delivered", + "cli.help.list.arg.include_deleted": "Include assets the server has tombstoned (deleted) as well as live ones", + "cli.help.match.about": "Match metadata for current file", + "cli.help.match.arg.path": "Path to the file to match metadata for", + "cli.help.push.about": "Upload a local Capsule library to the server", + "cli.help.push.arg.dry_run": "Report what would be uploaded without opening a single upload session", + "cli.help.push.arg.force": "Re-drive every blob regardless of what the server already holds", + "cli.help.push.arg.library": "Path to the Capsule library to push", + "cli.help.push.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so pushes work in scripts and CI where there is no terminal", + "cli.help.push.arg.staged": "Open the tier sessions in ladder order (index → preview → original), gating the above-index tiers on the connection class, instead of opening all eagerly", + "cli.help.repair.about": "Repair a local library in place, one signed correction per affected asset", + "cli.help.repair.capture-time.about": "Re-read each original's EXIF capture time and report every asset whose signed capture timestamp disagrees with it; with --apply, correct each one as a signed metadata update", + "cli.help.repair.capture-time.arg.apply": "Write the corrections. Without this flag the pass only reports what it would change; each correction is an irreversible signed record on the asset's chain", + "cli.help.repair.capture-time.arg.library": "Path to the Capsule library", + "cli.help.repair.capture-time.arg.limit": "Correct at most this many affected assets in one --apply run (the report still covers the whole library); only meaningful with --apply", + "cli.help.repair.capture-time.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.reset.about": "Reset all local CLI data", + "cli.help.reset.arg.all": "Reset all data", + "cli.help.reset.arg.cache": "Reset cache directory", + "cli.help.reset.arg.config": "Reset configuration", + "cli.help.reset.arg.data": "Reset data directory", + "cli.help.root.about": "A command line interface for Capsule - the photo management platform", + "cli.help.root.long_about": "Capsule CLI provides tools for managing your photos and albums:\n\n- Authentication management\n- Sync local and remote data\n- Check status and list files\n- Manage albums and collections", + "cli.help.show.about": "Show what an imported asset's signed sidecar records: caption, rating, tags, GPS, timestamps", + "cli.help.show.arg.asset": "The asset to show: its id, or a prefix of at least 8 hex characters of its SHA-256 content hash — the same digest `shasum -a 256` prints for the source file", + "cli.help.show.arg.library": "Path to the Capsule library", + "cli.help.show.arg.passphrase_stdin": "Read the library passphrase from stdin instead of prompting, so the command works in scripts and CI where there is no terminal", + "cli.help.status.about": "Show current status", + "cli.help.sync.about": "Sync local and remote data", + "cli.help.sync.arg.dry_run": "Perform a dry run without making changes", + "cli.help.sync.arg.force": "Discard the saved cursor and re-drain the feed from the start. The per-album anti-rewind floor still applies, so this cannot resurrect stale entries", "cli.import.candidates_found": "Found {candidates} candidate(s) ({files} file(s) total).", "cli.import.done": "Done: {imported} imported, {duplicates} duplicate(s), {errors} error(s).", "cli.import.execute_failed": "The import failed: {reason}", @@ -1756,6 +1825,59 @@ "cli.push.in_progress": "Pushing {assets} asset(s) to {endpoint}…", "cli.push.nothing_to_push": "The library holds no assets to push.", "cli.push.up_to_date": "Everything in this library is already on the server.", + "cli.repair.capture_time.checked": "Checked {count} asset(s) against the EXIF capture time of their originals.", + "cli.repair.capture_time.corrected": " {asset_id}: corrected", + "cli.repair.capture_time.drift_notice": "Corrected assets stay in the month directory they were imported into; that drift between directory and capture date is expected after a correction and is not a fault.", + "cli.repair.capture_time.dry_run_notice": "Dry run: nothing was written. Re-run with --apply to correct the affected sidecars.", + "cli.repair.capture_time.failed_asset": "Correcting {asset_id} failed: {reason}. Assets corrected before it stay corrected; nothing after it was touched.", + "cli.repair.capture_time.limit_notice": "Stopped after {corrected} correction(s) because of --limit; re-run to continue.", + "cli.repair.capture_time.limit_requires_apply": "--limit only bounds what --apply writes; give --apply as well, or drop --limit.", + "cli.repair.capture_time.nothing": "Every capture timestamp agrees with its original's EXIF; nothing to repair.", + "cli.repair.capture_time.row": " {asset_id}: recorded {recorded}, EXIF says {recovered} (off by {delta} s)", + "cli.repair.capture_time.row_unparseable": " {asset_id}: recorded {recorded} (not a timestamp), EXIF says {recovered}", + "cli.repair.capture_time.summary": "Capture-time repair: {affected} affected, {corrected} corrected, {agrees} already correct, {no_instant} without a recoverable EXIF instant, {unreadable} unreadable, {trashed} in trash.", + "cli.repair.capture_time.trashed": " Skipped {count} asset(s) in trash; restore an asset first if it should be repaired.", + "cli.repair.capture_time.unreadable": " {asset_id}: original could not be read ({reason}); skipped", + "cli.repair.failed": "Repair failed: {reason}", + "cli.show.album": " Album: {value}", + "cli.show.ambiguous": "{selector} matches {count} assets; give more of the hash, or the full asset id.", + "cli.show.caption": " Caption: {value}", + "cli.show.captured": " Captured: {value}", + "cli.show.content_type": " Content type: {value}", + "cli.show.cull": " Cull flag: {value}", + "cli.show.dimensions": " Dimensions: {value}", + "cli.show.failed": "Show failed: {reason}", + "cli.show.gps": " GPS: {value}", + "cli.show.gps_datum.gcj02": "GCJ-02", + "cli.show.gps_datum.wgs84": "WGS-84", + "cli.show.gps_source.derived": "derived", + "cli.show.gps_source.exif": "EXIF", + "cli.show.gps_source.manual": "manual", + "cli.show.hash": " SHA-256: {value}", + "cli.show.header": "Asset {asset_id}", + "cli.show.hidden": " Hidden: {value}", + "cli.show.imported": " Imported: {value}", + "cli.show.in_trash": " In trash: {value}", + "cli.show.invalid_selector": "{selector} is neither an asset id nor a hex prefix of at least {min} characters of a content hash.", + "cli.show.lqip": " Placeholder: {value}", + "cli.show.provenance_records": " Provenance: {value} signed record(s)", + "cli.show.rating": " Rating: {value}", + "cli.show.stack": " Stack: {value}", + "cli.show.stack_role.member": "member", + "cli.show.stack_role.primary": "primary", + "cli.show.stack_role.proxy": "proxy", + "cli.show.tags_ai": " AI tags: {value}", + "cli.show.tags_user": " User tags: {value}", + "cli.show.unknown_asset": "No asset in this library matches {selector}.", + "cli.show.value.dimensions": "{width}×{height}", + "cli.show.value.gps": "{lat}, {lon} ({datum}, {source})", + "cli.show.value.list_separator": ", ", + "cli.show.value.no": "no", + "cli.show.value.present": "present", + "cli.show.value.rating": "{stars}/5", + "cli.show.value.stack": "{stack_id} ({role})", + "cli.show.value.unset": "(unset)", + "cli.show.value.yes": "yes", "cli.sync.complete": "Sync complete: applied {applied} change(s) across {albums} album(s) in {pages} page(s).", "cli.sync.dry_run_notice": "Dry run: no changes will be persisted.", "cli.sync.failed": "Sync failed: {reason}", @@ -1828,6 +1950,11 @@ "drop.upload_only_badge": "Upload only", "error.album.invalid_id": "This album could not be registered because its identifier is malformed.", "error.album.not_available": "This album isn't available on your account.", + "error.album.roster_attester": "Only a device on the album owner's account can publish its roster.", + "error.album.roster_malformed": "Capsule couldn't read that album roster.", + "error.album.roster_not_found": "That album isn't yours, or doesn't exist.", + "error.album.roster_stale": "The roster you sent is out of step with the one the server holds.", + "error.album.roster_version_leap": "That roster's version is too far ahead of the one the server holds.", "error.album.unavailable": "Capsule couldn't set up that album. Please try again.", "error.album.upgrade_in_flight": "This album is already being upgraded.", "error.album.upgrade_malformed": "Capsule couldn't read that album upgrade request.", @@ -1836,6 +1963,14 @@ "error.auth.account_locked": "This account is locked after too many failed sign-in attempts.", "error.auth.current_password_invalid": "That is not your current password.", "error.auth.invalid_credentials": "Invalid email or password.", + "error.auth.oidc_address_taken": "An account with that email address already exists here. Sign in with its password instead.", + "error.auth.oidc_at_capacity": "Too many sign-ins are already in progress. Please try again in a moment.", + "error.auth.oidc_exchange_failed": "Your identity provider didn't accept that sign-in. Try again.", + "error.auth.oidc_not_configured": "Single sign-on isn't set up on this server.", + "error.auth.oidc_redirect_invalid": "That sign-in can't return to this app.", + "error.auth.oidc_state_invalid": "That sign-in has expired. Start again.", + "error.auth.oidc_token_invalid": "Your identity provider's answer couldn't be verified.", + "error.auth.oidc_unavailable": "Capsule couldn't reach your identity provider just now. Please try again.", "error.auth.password_invalid": "That password cannot be used.", "error.auth.profile_invalid": "That display name cannot be used.", "error.auth.profile_not_found": "That account no longer exists.", @@ -1852,6 +1987,7 @@ "error.auth.totp_not_pending": "There is no two-factor setup waiting to be confirmed.", "error.auth.unavailable": "Capsule couldn't reach your account just now. Please try again.", "error.auth.user_already_exists": "An account with these details already exists.", + "error.blob.access_revoked": "You no longer have access to this album.", "error.blob.gone": "That photo file is no longer available.", "error.blob.not_found": "That photo file isn't on the server.", "error.blob.pending_upload": "The original photo hasn't been uploaded from its device yet.", @@ -1865,6 +2001,7 @@ "error.directory.unsupported_media_type": "That device list couldn't be read.", "error.directory.version_conflict": "This device list is out of date. Capsule will refresh it before continuing.", "error.drop.adoption_refused": "That upload could not be added to the album.", + "error.drop.at_capacity": "This server is handling too many upload links right now. Please try again shortly.", "error.drop.cap_exceeded": "This upload link is full.", "error.drop.cap_exhausted": "This upload link is full. Ask for a new one.", "error.drop.chunk_refused": "That part of the upload could not be accepted. It will be retried.", @@ -1876,6 +2013,7 @@ "error.drop.passphrase_required": "This upload link needs its passphrase.", "error.drop.rate_limited": "Too many attempts. Please wait and try again.", "error.drop.unavailable": "Capsule couldn't reach the upload service. Please try again.", + "error.enrollment.at_capacity": "This server is handling too many enrollment attempts right now. Please try again shortly.", "error.enrollment.channel_not_found": "This device-add session has ended. Start again.", "error.enrollment.code_refused": "That device code didn't work. Generate a new one and try again.", "error.enrollment.local_auth_required": "Confirm it's you on this device to add another device.", @@ -1884,15 +2022,22 @@ "error.escrow.malformed": "The recovery backup could not be saved.", "error.escrow.not_stored": "No recovery backup is saved for this account.", "error.escrow.unavailable": "Capsule couldn't reach the recovery backup. Please try again.", + "error.federation.album_not_found": "That album couldn't be found.", "error.federation.audience_mismatch": "This access grant is for a different album.", "error.federation.capability_expired": "This shared album's access has expired.", "error.federation.capability_invalid": "This shared album's access could not be verified.", + "error.federation.capability_malformed": "That sharing request isn't valid.", "error.federation.capability_revoked": "Access to this shared album has been revoked.", "error.federation.circuit_open": "This source is temporarily backed off after repeated errors.", + "error.federation.member_not_on_roster": "That person isn't on this album's member list.", + "error.federation.not_configured": "This server doesn't share albums with other servers.", + "error.federation.peer_unknown": "That server isn't one this server knows.", "error.federation.rate_budget_exceeded": "This source has reached its request limit. Please wait and try again.", "error.federation.revocations_unavailable": "Capsule couldn't read the revocation list. Please try again.", "error.federation.scope_insufficient": "This access grant does not cover the requested content.", + "error.federation.unavailable": "Capsule couldn't reach the federation records. Please try again.", "error.moderation.account_suspended": "Your account is suspended. You can't upload or share until it's reinstated.", + "error.moderation.report_malformed": "That report isn't valid.", "error.moderation.report_rate_limited": "Too many reports from this source. Please wait and try again.", "error.moderation.report_unsigned": "The moderation report could not be verified.", "error.moderation.server_blocked": "This server is blocked from federating with us.", @@ -1911,6 +2056,7 @@ "error.request.unauthenticated": "Please sign in again.", "error.request.unprocessable": "Some of that request didn't make sense.", "error.request.unsupported_media_type": "Capsule couldn't read that content type.", + "error.share.at_capacity": "This server is handling too many shared links right now. Please try again shortly.", "error.share.malformed": "That share link could not be created.", "error.share.rate_limited": "Too many attempts. Please wait and try again.", "error.share.unavailable": "Capsule couldn't reach that share. Please try again.", @@ -1918,6 +2064,7 @@ "error.storage.deep_rate_limited": "Too many deep storage checks. Please wait and try again.", "error.storage.invalid_request": "The storage-verification request was malformed.", "error.storage.unavailable": "Capsule couldn't check whether your photos are safely stored. Please try again.", + "error.sync.album_access_denied": "You don't have access to that album.", "error.sync.cursor_invalid": "The sync session is out of date. Capsule will resync from the start.", "error.sync.unauthenticated": "Please sign in again to continue syncing.", "error.sync.unavailable": "Capsule could not reach the server to sync. It will try again.", diff --git a/capsule-i18n/src/catalog.rs b/capsule-i18n/src/catalog.rs index ad387bb0..b01deb02 100644 --- a/capsule-i18n/src/catalog.rs +++ b/capsule-i18n/src/catalog.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use crate::format::{Value, format_message}; +use crate::format::{Value, format_message_in}; use crate::generated; /// A localized message bundle: a primary locale plus the source locale as a fallback. @@ -19,7 +19,7 @@ pub struct Bundle { impl Bundle { /// Build a bundle for `locale`, using the source locale as the fallback. /// - /// `locale` should already be a supported tag (see [`crate::negotiate`]); an + /// `locale` should already be a supported tag (see [`negotiate`](fn@crate::negotiate)); an /// unknown locale yields a bundle backed solely by the source catalog. #[must_use] pub fn for_locale(locale: &str) -> Self { @@ -47,12 +47,18 @@ impl Bundle { .map(String::as_str) } - /// Format `key` with `args`. Returns `key` itself when the key is unknown, so a - /// missing message surfaces visibly rather than as an empty string. + /// Format `key` with `args`, using **this bundle's locale** to select plural arms + /// (see [`crate::format_message_in`] for the supported ICU subset). Returns `key` + /// itself when the key is unknown, so a missing message surfaces visibly rather than + /// as an empty string. + /// + /// The locale matters even for a message the bundle fell back to the source catalog + /// for: an untranslated English plural still has to pick the arm the *reader's* + /// language selects, and `other` is the fallback when the message does not carry it. #[must_use] pub fn format(&self, key: &str, args: &[(&str, Value<'_>)]) -> String { if let Some(template) = self.message(key) { - format_message(template, args) + format_message_in(&self.locale, template, args) } else { tracing::warn!(key, locale = %self.locale, "missing i18n message"); key.to_string() diff --git a/capsule-i18n/src/format.rs b/capsule-i18n/src/format.rs index c682eb91..d3fe4c78 100644 --- a/capsule-i18n/src/format.rs +++ b/capsule-i18n/src/format.rs @@ -1,13 +1,58 @@ //! ICU MessageFormat formatting. //! -//! The runtime currently supports the subset Capsule's catalog uses today: literal -//! text and simple `{name}` argument interpolation. Full ICU `plural` / `select` / -//! `number` / `date` formatting is a documented follow-up (see the i18n design doc); -//! until then a complex placeholder is copied through verbatim rather than -//! mis-rendered, so the limitation is visible rather than silently wrong. +//! # The supported subset +//! +//! - **Literal text**, copied through unchanged. +//! - **`{name}` interpolation** from the supplied arguments. An argument that was not +//! supplied leaves its placeholder intact, braces and all, so the gap is visible in the +//! output rather than silently empty. +//! - **`{name, plural, …}`**, with `=N` exact arms, CLDR category arms, `#` for the +//! selected count, and arbitrary nesting: an arm may contain `{name}` placeholders and +//! further plurals. Category selection comes from [`crate::plural`], so the arm chosen +//! is the one the locale's CLDR rules select — the same arm the ahead-of-time +//! generators lower into an Apple String Catalog variation or an Android ``. +//! A category the message does not carry falls back to `other`, which is what lets a +//! catalog whose plurals are still English `one`/`other` copies render correctly in +//! Russian or Arabic. +//! +//! ICU's apostrophe **quoting** (`'#'` for a literal `#`) is not implemented, here or in +//! the ahead-of-time generators, so a literal `#`, `{` or `}` inside a plural arm cannot +//! be escaped. No catalog message needs one, and an apostrophe that is not adjacent to ICU +//! syntax — "couldn't" — is ordinary text under ICU's own rule, so the catalogs are +//! unaffected. +//! +//! Numbers are rendered as plain digits — no grouping separators and no locale digit +//! shaping. That is a deliberate omission rather than an oversight: doing it properly is +//! `number` skeleton support, which needs CLDR number data this runtime does not carry. +//! +//! # What is still refused +//! +//! `select`, `selectordinal`, `plural` with `offset:`, a `plural` whose arms do not parse, +//! and any other `{name, kind, …}` block are **refused**: a `debug_assert!` fires where a +//! developer will see it, and the release build copies the construct through verbatim +//! rather than gaining a new crash on a catalog it could previously render badly. Emitting +//! ICU source to a user is the exact failure Android shipped before slice `S-I6`; the +//! refusal exists so the next construct this runtime cannot express is a test failure +//! instead. +//! +//! One malformed shape is **not** passed through verbatim: a `plural` whose arms parse but +//! carry no `other`. It asserts, and then renders its first arm in CLDR order. `xtask +//! i18n` refuses to generate such a message, so the case is unreachable from the catalogs; +//! for a hand-written template that slipped past it, some text beats message source. +use std::collections::BTreeMap; use std::fmt::{self, Write as _}; +use crate::plural::{Category, category}; + +/// How deeply plurals may nest before the formatter refuses. +/// +/// Recursion is otherwise bounded only by the template, and this formatter is public: a +/// template from outside the catalogs could nest thousands deep and overflow the stack, +/// which is an abort no caller can catch. Catalog messages are flat — the deepest today is +/// one plural holding one `{name}` — so the limit is far above anything real. +const MAX_DEPTH: usize = 32; + /// A value substituted into a `{name}` placeholder. #[derive(Debug, Clone, Copy)] pub enum Value<'a> { @@ -26,65 +71,316 @@ impl fmt::Display for Value<'_> { } } -/// Format `template` by substituting `{name}` placeholders from `args`. +/// Format `template` with English plural rules. /// -/// A placeholder whose body is a bare identifier is replaced with the matching arg -/// (or left intact, braces and all, if no such arg was supplied so the gap is -/// visible). Any other `{...}` — e.g. an ICU `plural` block — is copied through -/// unchanged; see the module docs. +/// The locale-free entry point, for a template that did not come from a bundle. Use +/// [`format_message_in`] — or [`crate::Bundle::format`], which does — whenever the +/// message's locale is known, because the plural arm a count selects depends on it. #[must_use] pub fn format_message(template: &str, args: &[(&str, Value<'_>)]) -> String { + format_message_in("en", template, args) +} + +/// Format `template` for `locale`, substituting `args`. +/// +/// `locale` may be a full tag (`pt-BR`); only its language subtag reaches the plural +/// rules. See the module docs for the supported ICU subset and for what a construct +/// outside it does. +#[must_use] +pub fn format_message_in(locale: &str, template: &str, args: &[(&str, Value<'_>)]) -> String { let mut out = String::with_capacity(template.len()); - let mut chars = template.chars().peekable(); - while let Some(c) = chars.next() { - if c != '{' { - out.push(c); - continue; + render(locale, template, args, None, 0, &mut out); + out +} + +/// Render `template` into `out`. +/// +/// `hash` is the text `#` stands for — the enclosing plural's count, or `None` at the top +/// level, where ICU treats `#` as an ordinary character. +fn render( + locale: &str, + template: &str, + args: &[(&str, Value<'_>)], + hash: Option<&str>, + depth: usize, + out: &mut String, +) { + // Every `{` is paired in one pass up front, rather than by scanning forward from each + // one. Scanning per brace is quadratic on the unmatched path — an unmatched `{` has to + // read the whole remainder to learn there is no partner, and then the next one reads + // it again: measured at n(n+1)/2 bytes, 1.6 s for a template of 80 000 braces. This is + // a `pub` entry point, so that is the same threat model `MAX_DEPTH` is capped for. + let pairs = brace_pairs(template); + let mut next_pair = 0; + let mut i = 0; + while let Some(c) = template[i..].chars().next() { + match c { + '#' => { + match hash { + Some(count) => out.push_str(count), + None => out.push('#'), + } + i += 1; + } + '{' => { + // `pairs` lists the braces in source order and `i` only moves forward, so + // this brace is the entry the cursor is on. + debug_assert_eq!(pairs.get(next_pair).map(|pair| pair.open), Some(i)); + let close = pairs.get(next_pair).and_then(|pair| pair.close); + next_pair += 1; + let Some(close) = close else { + // An unterminated `{` is copied through as an ordinary character, with + // no assertion: the brace may well be literal text in a message that + // never meant to open a placeholder, and nothing distinguishes the two + // readings. Scanning **continues** past it — abandoning the remainder + // would let one stray brace silently swallow every later placeholder. + out.push('{'); + i += 1; + continue; + }; + render_placeholder(locale, &template[i + 1..close], args, depth, out); + i = close + 1; + // The braces inside the body were handled by the recursive render. + while pairs.get(next_pair).is_some_and(|pair| pair.open < i) { + next_pair += 1; + } + } + _ => { + out.push(c); + i += c.len_utf8(); + } } - // Collect the placeholder body up to the matching '}'. - let mut body = String::new(); - let mut closed = false; - for b in chars.by_ref() { - if b == '}' { - closed = true; - break; + } +} + +/// One `{` and the `}` that closes it, if any. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BracePair { + /// Byte index of the `{`. + open: usize, + /// Byte index of the matching `}`, or `None` when the brace is unterminated. + close: Option, +} + +/// Pair up every `{` in `text` in a single pass, in source order. +/// +/// A stack of open braces, so the answer for *all* of them costs one scan of the text +/// rather than one scan per brace. Equivalent to calling [`matching_brace`] at each `{` — +/// pinned by a test — and the reason [`render`] no longer does. +/// +/// A `}` with nothing open is ignored, exactly as [`matching_brace`]'s depth counter +/// ignores it: it cannot close a brace that is not there, and ICU has no escape for one. +fn brace_pairs(text: &str) -> Vec { + #[cfg(test)] + tests::record_brace_scan(text.len()); + let mut pairs: Vec = Vec::new(); + // Indices into `pairs`, innermost last. + let mut open = Vec::new(); + for (index, byte) in text.as_bytes().iter().enumerate() { + match byte { + b'{' => { + open.push(pairs.len()); + pairs.push(BracePair { + open: index, + close: None, + }); + } + b'}' => { + if let Some(slot) = open.pop() { + pairs[slot].close = Some(index); + } } - body.push(b); + _ => {} } - let name = body.trim(); - if closed && is_identifier(name) { - if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { - let _ = write!(out, "{value}"); + } + pairs +} + +/// Render one `{…}` placeholder body (the text between the braces) into `out`. +fn render_placeholder( + locale: &str, + body: &str, + args: &[(&str, Value<'_>)], + depth: usize, + out: &mut String, +) { + let name = body.trim(); + if is_identifier(name) { + if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { + let _ = write!(out, "{value}"); + } else { + // Unknown arg: keep the placeholder so the missing value is obvious. + let _ = write!(out, "{{{body}}}"); + } + return; + } + + let rendered = render_plural(locale, body, args, depth); + // Not a hard panic: a release build must not gain a new crash on a catalog it could + // previously render badly, and every test and debug run is a build where this fires. + // The pass-through below is what production still does — and what makes the construct + // a *developer's* problem instead of a user's. + debug_assert!( + rendered.is_some(), + "capsule-i18n cannot render the ICU construct `{{{body}}}` — it would be printed \ + to the user verbatim. A `plural` whose arms parse is evaluated here; `select`, \ + `selectordinal`, `offset:`, a `plural` whose arms do not, and nesting past \ + {MAX_DEPTH} levels are not, because the per-platform renderers compile those \ + ahead of time (`xtask i18n`) and this runtime has no equivalent." + ); + if let Some(text) = rendered { + out.push_str(&text); + } else { + out.push('{'); + out.push_str(body); + out.push('}'); + } +} + +/// Evaluate `body` as a `name, plural, …` block, or `None` if it is not one this runtime +/// can render. +fn render_plural( + locale: &str, + body: &str, + args: &[(&str, Value<'_>)], + depth: usize, +) -> Option { + if depth >= MAX_DEPTH { + return None; + } + // Two splits only, so a comma inside an arm belongs to the arm. + let mut head = body.splitn(3, ','); + let selector = head.next()?.trim(); + if !is_identifier(selector) || head.next()?.trim() != "plural" { + return None; + } + let arms_source = head.next()?.trim(); + if arms_source.starts_with("offset:") { + return None; + } + let arms = Arms::parse(arms_source)?; + + let argument = args + .iter() + .find(|(key, _)| *key == selector) + .map(|(_, v)| v); + // The count as a number, for selection. A missing argument — or a string that is not + // a number — has no category, so it takes `other`, the arm every message carries. + let count = argument.and_then(|value| match value { + Value::Int(n) => Some(*n), + Value::Str(s) => s.parse::().ok(), + }); + // The count as text, for `#`. Whenever a number was found this is that number, so `#` + // shows exactly the value the arm was selected by (`"007"` selects on 7 and renders + // `7`). A missing argument keeps its placeholder visible, as a missing `{name}` does. + let hash = count.map_or_else( + || argument.map_or_else(|| format!("{{{selector}}}"), ToString::to_string), + |n| n.to_string(), + ); + + let chosen = arms.select(locale, count); + // A plural with no `other` is malformed: `xtask i18n` refuses to generate one, and + // both native resource formats treat it as invalid. Render the first arm in CLDR + // order rather than nothing, so a hand-written template still produces text. + debug_assert!( + chosen.is_some(), + "ICU plural `{{{body}}}` has no `other` arm; every locale selects `other` for \ + some count, so the message cannot render for all inputs." + ); + let arm = chosen.or_else(|| arms.first()).unwrap_or_default(); + + let mut out = String::new(); + render(locale, arm, args, Some(&hash), depth + 1, &mut out); + Some(out) +} + +/// The arms of one `plural` block: exact `=N` matches and CLDR category matches. +struct Arms<'a> { + /// `=N {…}` arms, in source order. ICU tries these before any category. + exact: Vec<(i64, &'a str)>, + /// Category arms, keyed so iteration is in CLDR order. + categories: BTreeMap, +} + +impl<'a> Arms<'a> { + /// Parse `source`, the text after `name, plural,`, or `None` if it is malformed. + fn parse(source: &'a str) -> Option { + let mut exact = Vec::new(); + let mut categories = BTreeMap::new(); + let mut rest = source; + loop { + let trimmed = rest.trim_start(); + if trimmed.is_empty() { + break; + } + let open = trimmed.find('{')?; + let close = matching_brace(trimmed, open)?; + let selector = trimmed[..open].trim(); + let arm = &trimmed[open + 1..close]; + if let Some(literal) = selector.strip_prefix('=') { + exact.push((literal.trim().parse().ok()?, arm)); } else { - // Unknown arg: keep the placeholder so the missing value is obvious. - let _ = write!(out, "{{{body}}}"); + // First wins, matching the `=N` arms' `find`. ICU rejects a duplicate + // arm outright; the two kinds resolving it differently would be worse + // than either answer. + categories.entry(Category::parse(selector)?).or_insert(arm); } - } else { - // Complex (plural/select) or unterminated placeholder. - // - // Emitting it verbatim means the **user reads the message source** — the exact failure - // Android shipped for as long as its renderer had no plural support (slice `S-I6`), and - // that Apple would have shipped before `S-I4`. This runtime is the third place the same - // construct has arrived with nowhere to go, so it refuses loudly where a developer will - // see it rather than quietly where a user will. - // - // `debug_assert!` and not a hard panic: a release build must not gain a new crash on a - // catalog it could previously render badly, and every test and debug run is a build - // where the assertion fires. The pass-through below is what production still does. - debug_assert!( - !closed, - "capsule-i18n cannot render the ICU construct `{{{body}}}` — it would be printed to \ - the user verbatim. The per-platform renderers compile plurals ahead of time \ - (`xtask i18n`); this runtime does not. See slice `S-I7`." - ); - out.push('{'); - out.push_str(&body); - if closed { - out.push('}'); + rest = &trimmed[close + 1..]; + } + (!exact.is_empty() || !categories.is_empty()).then_some(Self { exact, categories }) + } + + /// The arm `count` selects in `locale`: an exact `=N` match, then the CLDR category, + /// then `other`. + fn select(&self, locale: &str, count: Option) -> Option<&'a str> { + if let Some(n) = count { + if let Some((_, arm)) = self.exact.iter().find(|(value, _)| *value == n) { + return Some(arm); + } + if let Some(arm) = self.categories.get(&category(locale, n)) { + return Some(arm); } } + self.categories.get(&Category::Other).copied() } - out + + /// The first arm in CLDR order, for a malformed message with no `other`. + fn first(&self) -> Option<&'a str> { + self.categories + .values() + .next() + .copied() + .or_else(|| self.exact.first().map(|(_, arm)| *arm)) + } +} + +/// The byte index of the `}` matching the `{` at `open`, or `None` if it is unterminated. +/// +/// Brace *matching*, not the first `}`: every ICU plural nests braces, and a scan that +/// stops at the first one cannot see past the opening arm — the same defect that let +/// Android's renderer ship raw ICU (slice `S-I6`). +/// +/// Used by [`Arms::parse`], where the arms are disjoint so the scans add up to one pass +/// over the body. [`render`] uses [`brace_pairs`] instead, because there the same brace +/// can be asked about repeatedly. +fn matching_brace(text: &str, open: usize) -> Option { + #[cfg(test)] + tests::record_brace_scan(text.len() - open); + debug_assert_eq!(text.as_bytes().get(open), Some(&b'{')); + let mut depth = 0usize; + for (offset, byte) in text.as_bytes()[open..].iter().enumerate() { + match byte { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + } + None } /// Whether `s` is a non-empty ASCII identifier (letters, digits, underscore). @@ -94,7 +390,34 @@ fn is_identifier(s: &str) -> bool { #[cfg(test)] mod tests { - use super::{Value, format_message}; + use std::cell::Cell; + + use super::{BracePair, Value, brace_pairs, format_message, format_message_in, matching_brace}; + + thread_local! { + /// Bytes the brace scanners have read on this thread. + /// + /// The cost of pairing braces is the whole point of [`brace_pairs`], and a wall + /// clock cannot pin it: this host runs several builds at once, so a timing budget + /// would be either flaky or so loose it proves nothing. Counting the bytes the + /// scanners actually read is deterministic, and it fails if anyone reintroduces a + /// per-brace rescan. Thread-local because `cargo test` runs tests concurrently on + /// threads (nextest gives each its own process, which is stricter still). + static BRACE_SCAN_BYTES: Cell = const { Cell::new(0) }; + } + + /// Called by both brace scanners in a test build. Not compiled otherwise. + pub(super) fn record_brace_scan(bytes: usize) { + BRACE_SCAN_BYTES.with(|counter| counter.set(counter.get() + bytes)); + } + + /// Bytes scanned since the last call, resetting the counter. + fn take_brace_scan_bytes() -> usize { + BRACE_SCAN_BYTES.with(Cell::take) + } + + /// The shape every plural in `locales/` has today. + const ITEMS: &str = "{count, plural, one {# item} other {# items}}"; #[test] fn literal_passes_through() { @@ -133,32 +456,444 @@ mod tests { ); } + #[test] + fn a_plural_selects_its_arm_and_substitutes_the_count() { + assert_eq!(format_message(ITEMS, &[("count", Value::Int(1))]), "1 item"); + assert_eq!( + format_message(ITEMS, &[("count", Value::Int(7))]), + "7 items" + ); + } + + #[test] + fn a_plural_may_sit_inside_surrounding_text() { + let template = "Deleted {count, plural, one {# photo} other {# photos}} today."; + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + "Deleted 2 photos today." + ); + } + + #[test] + fn an_exact_arm_beats_the_category_it_overlaps() { + let template = "{count, plural, =0 {Nothing selected} =1 {Just this one} one {# item} other {# items}}"; + assert_eq!( + format_message(template, &[("count", Value::Int(0))]), + "Nothing selected" + ); + assert_eq!( + format_message(template, &[("count", Value::Int(1))]), + "Just this one" + ); + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + "2 items" + ); + } + + #[test] + fn the_locale_chooses_the_arm() { + // The whole point of threading a locale: Russian's three forms, English's two. + let ru = "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}"; + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(1))]), + "1 файл" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(3))]), + "3 файла" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(5))]), + "5 файлов" + ); + assert_eq!( + format_message_in("ru", ru, &[("count", Value::Int(21))]), + "21 файл" + ); + // French counts zero as singular; English does not. + let zero = "{count, plural, one {# photo} other {# photos}}"; + assert_eq!( + format_message_in("fr", zero, &[("count", Value::Int(0))]), + "0 photo" + ); + assert_eq!( + format_message_in("en", zero, &[("count", Value::Int(0))]), + "0 photos" + ); + // A region subtag resolves to its language. + assert_eq!( + format_message_in("pt-BR", zero, &[("count", Value::Int(0))]), + "0 photo" + ); + } + + #[test] + fn a_category_the_message_omits_falls_back_to_other() { + // Every translated plural in `locales/` carries only `one` and `other`, including + // the Russian and Arabic ones. Falling back to `other` is what makes those render + // at all — without it, `few` in Russian would have nowhere to go. + assert_eq!( + format_message_in("ru", ITEMS, &[("count", Value::Int(3))]), + "3 items" + ); + assert_eq!( + format_message_in("ar", ITEMS, &[("count", Value::Int(0))]), + "0 items" + ); + } + + #[test] + fn an_arm_may_contain_placeholders_and_further_plurals() { + let template = "{count, plural, one {{name} has # photo} \ + other {{name} has # photos in {albums, plural, \ + one {# album} other {# albums}}}}"; + assert_eq!( + format_message( + template, + &[ + ("count", Value::Int(1)), + ("albums", Value::Int(4)), + ("name", Value::Str("Sam")), + ] + ), + "Sam has 1 photo" + ); + assert_eq!( + format_message( + template, + &[ + ("count", Value::Int(9)), + ("albums", Value::Int(4)), + ("name", Value::Str("Sam")), + ] + ), + // The inner `#` is the inner plural's count, not the outer one. + "Sam has 9 photos in 4 albums" + ); + } + + #[test] + fn a_hash_outside_a_plural_is_a_literal() { + assert_eq!( + format_message("Issue #{id}", &[("id", Value::Int(7))]), + "Issue #7" + ); + } + + #[test] + fn a_string_selector_is_parsed_as_a_number() { + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("1"))]), + "1 item" + ); + // A non-numeric argument has no category, so it takes `other` — but `#` still + // renders what the caller supplied rather than inventing a number. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("lots"))]), + "lots items" + ); + } + + #[test] + fn a_missing_selector_takes_other_and_keeps_the_gap_visible() { + assert_eq!(format_message(ITEMS, &[]), "{count} items"); + } + /// An ICU construct this runtime cannot evaluate is refused, not printed. /// - /// This test previously asserted the opposite — that a plural block is "left verbatim" as a - /// known limitation. Emitting it verbatim means the **user reads the message source**, which is - /// precisely what Android shipped for as long as its renderer lacked plural support (slice - /// `S-I6`). A limitation that renders as output is not a limitation, it is a defect, and a test - /// pinning it made it look deliberate. Retargeted rather than deleted, because the case still - /// needs coverage — only the expected behaviour changed. + /// This test previously targeted `plural`, which is now evaluated; it is retargeted + /// onto `select` rather than deleted, because the property it pins is not about + /// plurals. Emitting a construct verbatim means the **user reads the message source**, + /// which is precisely what Android shipped for as long as its renderer lacked plural + /// support (slice `S-I6`). A limitation that renders as output is not a limitation, it + /// is a defect. + /// + /// `cfg(debug_assertions)`, which it was missing: the assertion is compiled out of a + /// release build, so under `cargo test --release` the test asserted a panic that + /// cannot happen and failed. #[test] + #[cfg(debug_assertions)] #[should_panic(expected = "cannot render the ICU construct")] fn an_unrenderable_icu_construct_is_refused_in_debug_builds() { - let template = "{count, plural, one {# item} other {# items}}"; - let _ = format_message(template, &[("count", Value::Int(2))]); + let template = "{kind, select, photo {Photo} other {Item}}"; + let _ = format_message(template, &[("kind", Value::Str("photo"))]); } - /// Release builds still pass the construct through rather than crashing on a catalog they - /// previously rendered badly — the assertion above is a developer-facing signal, not a - /// production behaviour change. Asserted on the shape the pass-through produces: each `{…}` - /// segment is reconstructed with its braces, so the output equals the input exactly. + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn selectordinal_is_refused() { + let template = "{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"; + let _ = format_message(template, &[("n", Value::Int(3))]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn a_plural_with_an_offset_is_refused() { + let template = "{count, plural, offset:1 one {# other} other {# others}}"; + let _ = format_message(template, &[("count", Value::Int(3))]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "has no `other` arm")] + fn a_plural_without_an_other_arm_is_refused() { + let _ = format_message("{count, plural, one {# item}}", &[("count", Value::Int(5))]); + } + + /// Release builds still pass the construct through rather than crashing on a catalog + /// they previously rendered badly — the assertion above is a developer-facing signal, + /// not a production behaviour change. Asserted on the shape the pass-through produces: + /// each `{…}` segment is reconstructed with its braces, so the output equals the input. #[test] #[cfg(not(debug_assertions))] fn release_builds_pass_the_construct_through_unchanged() { - let template = "{count, plural, one {# item} other {# items}}"; + for template in [ + "{kind, select, photo {Photo} other {Item}}", + "{n, selectordinal, one {#st} other {#th}}", + "{count, plural, offset:1 one {# other} other {# others}}", + ] { + assert_eq!( + format_message(template, &[("count", Value::Int(2))]), + template + ); + } + } + + /// A malformed plural still renders text in release: the first arm in CLDR order, + /// never nothing and never the message source. + #[test] + #[cfg(not(debug_assertions))] + fn release_builds_render_the_first_arm_of_a_plural_with_no_other() { assert_eq!( - format_message(template, &[("count", Value::Int(2))]), - template + format_message("{count, plural, one {# item}}", &[("count", Value::Int(5))]), + "5 item" + ); + } + + #[test] + fn an_unterminated_brace_is_copied_through_without_asserting() { + // Not an assertion case: the brace may be literal text in a message that never + // meant to open a placeholder, and nothing distinguishes the two readings. + assert_eq!(format_message("50% off {sale", &[]), "50% off {sale"); + } + + #[test] + fn an_unterminated_brace_does_not_swallow_what_follows_it() { + // The brace is one character of output, not a decision to stop formatting: a + // stray `{` must not silently blank every later argument. + assert_eq!( + format_message( + "A { stray brace, then {name}", + &[("name", Value::Str("Sam"))] + ), + "A { stray brace, then Sam" + ); + } + + #[test] + fn a_closing_brace_with_no_opener_is_literal_text() { + assert_eq!(format_message("100% } done", &[]), "100% } done"); + } + + /// A template nested far past [`MAX_DEPTH`] refuses instead of recursing. + /// + /// Without the limit, recursion is bounded only by the input: measured on this + /// formatter, 50 000 levels aborts the process with `stack overflow`, which no caller + /// can catch. `format_message` is public, so the input need not be a catalog message. + fn deeply_nested_plural(depth: usize) -> String { + format!( + "{}#{}", + "{count, plural, other {".repeat(depth), + "}}".repeat(depth) + ) + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn nesting_past_the_depth_limit_is_refused_rather_than_overflowing_the_stack() { + let _ = format_message(&deeply_nested_plural(5_000), &[("count", Value::Int(2))]); + } + + #[test] + #[cfg(not(debug_assertions))] + fn release_builds_pass_a_too_deeply_nested_plural_through() { + let template = deeply_nested_plural(5_000); + let rendered = format_message(&template, &[("count", Value::Int(2))]); + assert!(rendered.ends_with("}}"), "the refusal is a pass-through"); + } + + #[test] + fn brace_pairs_agrees_with_matching_brace_at_every_open_brace() { + // The refactor's correctness condition: pairing every brace in one pass must give + // the same answer as asking about each brace on its own. The awkward shapes are + // the point — an unmatched outer brace with a matched one inside (`{ {a} `) is + // where a naive "if one fails they all fail" shortcut would be wrong. + for template in [ + "", + "no braces at all", + "{a}", + "{{a}}", + "{", + "}", + "}{a}", + "{a}}", + "{ {a} ", + "{a} } {b}", + "{a, plural, one {# item} other {# items}}", + "{a, plural, one {{name} has #} other {{name} has {b, plural, other {#}}}}", + "50% off {sale", + "A { stray brace, then {name}", + ] { + let pairs = brace_pairs(template); + let opens: Vec = template + .bytes() + .enumerate() + .filter(|(_, byte)| *byte == b'{') + .map(|(index, _)| index) + .collect(); + let expected: Vec = opens + .iter() + .map(|open| BracePair { + open: *open, + close: matching_brace(template, *open), + }) + .collect(); + assert_eq!(pairs, expected, "disagreement on `{template}`"); + } + } + + #[test] + fn an_unmatched_brace_costs_one_pass_not_one_per_brace() { + // The defect: `render` asked `matching_brace` at every `{`, and an unmatched one + // reads the whole remainder to learn it has no partner — n(n+1)/2 bytes over the + // template, measured at 1.6 s for 80 000 braces in release. `format_message` is + // public, so the input need not be a catalog message. + let template = "{".repeat(100_000); + take_brace_scan_bytes(); + let rendered = format_message(&template, &[]); + let scanned = take_brace_scan_bytes(); + assert_eq!(rendered, template, "every unmatched brace is still emitted"); + assert!( + scanned <= 4 * template.len(), + "scanned {scanned} bytes for a {}-byte template: the per-brace rescan is back", + template.len() + ); + } + + #[test] + fn many_placeholders_cost_one_pass_too() { + // The matched path, and the arm parser behind it: the scans must still add up to a + // constant number of passes over the template, not one per placeholder. + let template = "{name} ".repeat(10_000) + &"{n, plural, one {#} other {#}} ".repeat(1_000); + take_brace_scan_bytes(); + let rendered = format_message( + &template, + &[("name", Value::Str("Sam")), ("n", Value::Int(2))], + ); + let scanned = take_brace_scan_bytes(); + assert!(rendered.starts_with("Sam Sam "), "{rendered:.32}"); + assert!(rendered.ends_with("2 "), "{rendered:.32}"); + assert!( + scanned <= 8 * template.len(), + "scanned {scanned} bytes for a {}-byte template", + template.len() + ); + } + + #[test] + fn the_locale_free_entry_point_uses_english_rules() { + // Asserted against English's actual answer, not against `format_message_in("en")` + // — the latter is how `format_message` is implemented, so it would pass even if + // the English rules were wrong. + assert_eq!( + format_message(ITEMS, &[("count", Value::Int(0))]), + "0 items" + ); + assert_eq!(format_message(ITEMS, &[("count", Value::Int(1))]), "1 item"); + } + + #[test] + fn a_string_count_renders_the_number_it_was_selected_by() { + // Selection and `#` read the same value, so a padded or spaced string cannot + // choose one arm and print another. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str("007"))]), + "7 items" + ); + // Not a number once the spaces count: no category, so `other`, and `#` shows what + // the caller actually passed. + assert_eq!( + format_message(ITEMS, &[("count", Value::Str(" 1 "))]), + " 1 items" + ); + } + + #[test] + fn a_duplicate_arm_resolves_the_same_way_for_both_arm_kinds() { + // ICU rejects a duplicate arm; this runtime takes the first of each kind, so the + // two kinds cannot disagree about which duplicate wins. + assert_eq!( + format_message( + "{count, plural, other {first} other {second}}", + &[("count", Value::Int(2))] + ), + "first" + ); + assert_eq!( + format_message( + "{count, plural, =2 {first} =2 {second} other {o}}", + &[("count", Value::Int(2))] + ), + "first" + ); + } + + #[test] + fn an_arm_may_be_empty_or_contain_a_comma() { + assert_eq!( + format_message( + "{count, plural, one {} other {one, two, many}}", + &[("count", Value::Int(1))] + ), + "" + ); + assert_eq!( + format_message( + "{count, plural, one {} other {one, two, many}}", + &[("count", Value::Int(3))] + ), + "one, two, many" + ); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn an_empty_placeholder_is_refused() { + let _ = format_message("{}", &[]); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn trailing_junk_after_the_last_arm_is_refused() { + let _ = format_message( + "{count, plural, other {items} junk}", + &[("count", Value::Int(2))], + ); + } + + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "cannot render the ICU construct")] + fn a_spaced_offset_is_refused_like_an_unspaced_one() { + let _ = format_message( + "{count, plural, offset : 1 other {# others}}", + &[("count", Value::Int(3))], ); } } diff --git a/capsule-i18n/src/generated.rs b/capsule-i18n/src/generated.rs index a71cd1ac..713113c1 100644 --- a/capsule-i18n/src/generated.rs +++ b/capsule-i18n/src/generated.rs @@ -38,6 +38,21 @@ pub mod error_codes { /// `error.album.not_available` pub const ALBUM_NOT_AVAILABLE: &str = "error.album.not_available"; + /// `error.album.roster_attester` + pub const ALBUM_ROSTER_ATTESTER: &str = "error.album.roster_attester"; + + /// `error.album.roster_malformed` + pub const ALBUM_ROSTER_MALFORMED: &str = "error.album.roster_malformed"; + + /// `error.album.roster_not_found` + pub const ALBUM_ROSTER_NOT_FOUND: &str = "error.album.roster_not_found"; + + /// `error.album.roster_stale` + pub const ALBUM_ROSTER_STALE: &str = "error.album.roster_stale"; + + /// `error.album.roster_version_leap` + pub const ALBUM_ROSTER_VERSION_LEAP: &str = "error.album.roster_version_leap"; + /// `error.album.unavailable` pub const ALBUM_UNAVAILABLE: &str = "error.album.unavailable"; @@ -62,6 +77,30 @@ pub mod error_codes { /// `error.auth.invalid_credentials` pub const AUTH_INVALID_CREDENTIALS: &str = "error.auth.invalid_credentials"; + /// `error.auth.oidc_address_taken` + pub const AUTH_OIDC_ADDRESS_TAKEN: &str = "error.auth.oidc_address_taken"; + + /// `error.auth.oidc_at_capacity` + pub const AUTH_OIDC_AT_CAPACITY: &str = "error.auth.oidc_at_capacity"; + + /// `error.auth.oidc_exchange_failed` + pub const AUTH_OIDC_EXCHANGE_FAILED: &str = "error.auth.oidc_exchange_failed"; + + /// `error.auth.oidc_not_configured` + pub const AUTH_OIDC_NOT_CONFIGURED: &str = "error.auth.oidc_not_configured"; + + /// `error.auth.oidc_redirect_invalid` + pub const AUTH_OIDC_REDIRECT_INVALID: &str = "error.auth.oidc_redirect_invalid"; + + /// `error.auth.oidc_state_invalid` + pub const AUTH_OIDC_STATE_INVALID: &str = "error.auth.oidc_state_invalid"; + + /// `error.auth.oidc_token_invalid` + pub const AUTH_OIDC_TOKEN_INVALID: &str = "error.auth.oidc_token_invalid"; + + /// `error.auth.oidc_unavailable` + pub const AUTH_OIDC_UNAVAILABLE: &str = "error.auth.oidc_unavailable"; + /// `error.auth.password_invalid` pub const AUTH_PASSWORD_INVALID: &str = "error.auth.password_invalid"; @@ -110,6 +149,9 @@ pub mod error_codes { /// `error.auth.user_already_exists` pub const AUTH_USER_ALREADY_EXISTS: &str = "error.auth.user_already_exists"; + /// `error.blob.access_revoked` + pub const BLOB_ACCESS_REVOKED: &str = "error.blob.access_revoked"; + /// `error.blob.gone` pub const BLOB_GONE: &str = "error.blob.gone"; @@ -149,6 +191,9 @@ pub mod error_codes { /// `error.drop.adoption_refused` pub const DROP_ADOPTION_REFUSED: &str = "error.drop.adoption_refused"; + /// `error.drop.at_capacity` + pub const DROP_AT_CAPACITY: &str = "error.drop.at_capacity"; + /// `error.drop.cap_exceeded` pub const DROP_CAP_EXCEEDED: &str = "error.drop.cap_exceeded"; @@ -182,6 +227,9 @@ pub mod error_codes { /// `error.drop.unavailable` pub const DROP_UNAVAILABLE: &str = "error.drop.unavailable"; + /// `error.enrollment.at_capacity` + pub const ENROLLMENT_AT_CAPACITY: &str = "error.enrollment.at_capacity"; + /// `error.enrollment.channel_not_found` pub const ENROLLMENT_CHANNEL_NOT_FOUND: &str = "error.enrollment.channel_not_found"; @@ -206,6 +254,9 @@ pub mod error_codes { /// `error.escrow.unavailable` pub const ESCROW_UNAVAILABLE: &str = "error.escrow.unavailable"; + /// `error.federation.album_not_found` + pub const FEDERATION_ALBUM_NOT_FOUND: &str = "error.federation.album_not_found"; + /// `error.federation.audience_mismatch` pub const FEDERATION_AUDIENCE_MISMATCH: &str = "error.federation.audience_mismatch"; @@ -215,12 +266,24 @@ pub mod error_codes { /// `error.federation.capability_invalid` pub const FEDERATION_CAPABILITY_INVALID: &str = "error.federation.capability_invalid"; + /// `error.federation.capability_malformed` + pub const FEDERATION_CAPABILITY_MALFORMED: &str = "error.federation.capability_malformed"; + /// `error.federation.capability_revoked` pub const FEDERATION_CAPABILITY_REVOKED: &str = "error.federation.capability_revoked"; /// `error.federation.circuit_open` pub const FEDERATION_CIRCUIT_OPEN: &str = "error.federation.circuit_open"; + /// `error.federation.member_not_on_roster` + pub const FEDERATION_MEMBER_NOT_ON_ROSTER: &str = "error.federation.member_not_on_roster"; + + /// `error.federation.not_configured` + pub const FEDERATION_NOT_CONFIGURED: &str = "error.federation.not_configured"; + + /// `error.federation.peer_unknown` + pub const FEDERATION_PEER_UNKNOWN: &str = "error.federation.peer_unknown"; + /// `error.federation.rate_budget_exceeded` pub const FEDERATION_RATE_BUDGET_EXCEEDED: &str = "error.federation.rate_budget_exceeded"; @@ -230,9 +293,15 @@ pub mod error_codes { /// `error.federation.scope_insufficient` pub const FEDERATION_SCOPE_INSUFFICIENT: &str = "error.federation.scope_insufficient"; + /// `error.federation.unavailable` + pub const FEDERATION_UNAVAILABLE: &str = "error.federation.unavailable"; + /// `error.moderation.account_suspended` pub const MODERATION_ACCOUNT_SUSPENDED: &str = "error.moderation.account_suspended"; + /// `error.moderation.report_malformed` + pub const MODERATION_REPORT_MALFORMED: &str = "error.moderation.report_malformed"; + /// `error.moderation.report_rate_limited` pub const MODERATION_REPORT_RATE_LIMITED: &str = "error.moderation.report_rate_limited"; @@ -287,6 +356,9 @@ pub mod error_codes { /// `error.request.unsupported_media_type` pub const REQUEST_UNSUPPORTED_MEDIA_TYPE: &str = "error.request.unsupported_media_type"; + /// `error.share.at_capacity` + pub const SHARE_AT_CAPACITY: &str = "error.share.at_capacity"; + /// `error.share.malformed` pub const SHARE_MALFORMED: &str = "error.share.malformed"; @@ -308,6 +380,9 @@ pub mod error_codes { /// `error.storage.unavailable` pub const STORAGE_UNAVAILABLE: &str = "error.storage.unavailable"; + /// `error.sync.album_access_denied` + pub const SYNC_ALBUM_ACCESS_DENIED: &str = "error.sync.album_access_denied"; + /// `error.sync.cursor_invalid` pub const SYNC_CURSOR_INVALID: &str = "error.sync.cursor_invalid"; diff --git a/capsule-i18n/src/lib.rs b/capsule-i18n/src/lib.rs index ede41b01..ae35ccfa 100644 --- a/capsule-i18n/src/lib.rs +++ b/capsule-i18n/src/lib.rs @@ -2,8 +2,8 @@ //! //! User-facing strings are authored once in the repo-root `locales/` directory as //! [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) -//! JSON, then compiled into this crate by `cargo run -p xtask -- i18n` (see -//! [`generated`]). The same source compiles to the web, iOS, and Android catalogs, +//! JSON, then compiled into this crate by `cargo run -p xtask -- i18n`, which writes the +//! private `generated` module. The same source compiles to the web, iOS, and Android catalogs, //! so a message is written exactly once. //! //! Typical use on the server: negotiate the request's locale, build a [`Bundle`], @@ -25,8 +25,9 @@ mod catalog; mod format; mod generated; mod negotiate; +pub mod plural; pub use catalog::{Bundle, supported_locales}; -pub use format::{Value, format_message}; +pub use format::{Value, format_message, format_message_in}; pub use generated::error_codes; pub use negotiate::negotiate; diff --git a/capsule-i18n/src/plural.rs b/capsule-i18n/src/plural.rs new file mode 100644 index 00000000..68380f10 --- /dev/null +++ b/capsule-i18n/src/plural.rs @@ -0,0 +1,451 @@ +//! CLDR cardinal plural rules for the locales Capsule ships. +//! +//! An ICU `plural` block names its arms by CLDR *category* (`one`, `few`, …), and which +//! category a number selects is a property of the language, not of the message. The +//! per-platform renderers never needed this table: Apple and Android compile a plural +//! ahead of time into a native resource and let the platform's own CLDR data pick the arm +//! at display time (see `xtask i18n`). The Rust runtime has no platform underneath it, so +//! it is the one target that must carry the rules itself — which is why this crate's +//! runtime formatter refused plurals outright until this module existed. +//! +//! # Scope +//! +//! **Integer cardinals only.** Every plural in `locales/` selects on a count, and +//! [`crate::Value`] has no decimal variant, so the CLDR operands reduce to `n = i`, +//! `v = 0`, `f = 0`, `e = 0` and the rules collapse to arithmetic on the absolute value. +//! Ordinals (`selectordinal`) are not implemented and are still refused by the formatter. +//! +//! The table covers exactly the twelve language subtags of `locales/config.json`. An +//! unknown language resolves to [`Category::Other`] rather than panicking — a locale +//! outside the config cannot reach a bundle with translated messages anyway +//! ([`crate::Bundle::for_locale`] falls back to the source catalog) — while +//! [`selectable`] reports `None` for it, so the *generator* can still fail loudly instead +//! of guessing a rule set. + +use std::fmt; + +/// A CLDR plural category, in CLDR's canonical order. +/// +/// The order is meaningful: it is the order an Apple String Catalog, an Android +/// `` resource and this crate all list arms in, so `Ord` sorts arms the way +/// every consumer expects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Category { + /// `zero` — a language with a dedicated form for none (Arabic). + Zero, + /// `one` — the singular. Not always "exactly 1": French counts 0 as `one`. + One, + /// `two` — the dual (Arabic). + Two, + /// `few` — the paucal (Arabic, Russian). + Few, + /// `many` — Russian's third form, and the large-number form of the Romance languages. + Many, + /// `other` — the universal fallback, selectable in every language. + Other, +} + +impl Category { + /// Every category, in CLDR's canonical order. + pub const ALL: &'static [Self] = &[ + Self::Zero, + Self::One, + Self::Two, + Self::Few, + Self::Many, + Self::Other, + ]; + + /// The CLDR keyword, as it is spelled in an ICU message and in every generated + /// platform resource. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Zero => "zero", + Self::One => "one", + Self::Two => "two", + Self::Few => "few", + Self::Many => "many", + Self::Other => "other", + } + } + + /// Parse a CLDR keyword, or `None` if `s` is not one. + #[must_use] + pub fn parse(s: &str) -> Option { + Self::ALL.iter().copied().find(|c| c.as_str() == s) + } +} + +impl fmt::Display for Category { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One language's cardinal rules: which categories it can select, and how. +/// +/// The two halves live together deliberately. `selectable` is what the ahead-of-time +/// generator uses to drop unreachable arms; `select` is what the runtime uses to pick +/// one. Holding them in the same row is what lets a test assert the pair is consistent — +/// that `select` never returns a category `selectable` omits — instead of leaving two +/// tables in two crates to drift, which is exactly what they did before this module. +struct Rules { + /// The language subtag, lowercase. + language: &'static str, + /// The categories this language can select, in CLDR order. + selectable: &'static [Category], + /// The rule itself, over the absolute value of an integer count. + select: fn(u64) -> Category, +} + +/// CLDR cardinal rules for the twelve language subtags in `locales/config.json`. +/// +/// Adding a locale there means adding a row here. Sources are the CLDR +/// `plurals.xml` cardinal rules; each `select` below is the integer specialization +/// (`v = 0`, so `i = n`) of that language's conditions, tried in CLDR order. +const RULES: &[Rules] = &[ + Rules { + language: "ar", + selectable: &[ + Category::Zero, + Category::One, + Category::Two, + Category::Few, + Category::Many, + Category::Other, + ], + select: arabic, + }, + Rules { + language: "de", + selectable: &[Category::One, Category::Other], + select: exactly_one, + }, + Rules { + language: "en", + selectable: &[Category::One, Category::Other], + select: exactly_one, + }, + Rules { + language: "es", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_one, + }, + Rules { + language: "fr", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_zero_or_one, + }, + Rules { + language: "hi", + selectable: &[Category::One, Category::Other], + select: zero_or_one, + }, + Rules { + language: "it", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_one, + }, + Rules { + language: "ja", + selectable: &[Category::Other], + select: always_other, + }, + Rules { + language: "ko", + selectable: &[Category::Other], + select: always_other, + }, + Rules { + language: "pt", + selectable: &[Category::One, Category::Many, Category::Other], + select: romance_zero_or_one, + }, + Rules { + language: "ru", + selectable: &[ + Category::One, + Category::Few, + Category::Many, + Category::Other, + ], + select: russian, + }, + Rules { + language: "zh", + selectable: &[Category::Other], + select: always_other, + }, +]; + +/// The CLDR category `n` selects in `locale`. +/// +/// `locale` may be a full tag (`pt-BR`, `zh-Hans`) — only its language subtag matters. +/// Selection uses the **absolute value** of `n`, as CLDR's `n` operand does, so `-1` +/// selects the same category as `1` and `i64::MIN` cannot overflow. A language with no +/// row in this module's rule table yields [`Category::Other`], the category every language +/// selects and every well-formed ICU plural carries. +#[must_use] +pub fn category(locale: &str, n: i64) -> Category { + rules(locale).map_or(Category::Other, |r| (r.select)(n.unsigned_abs())) +} + +/// The categories `locale`'s language can actually select, in CLDR order, or `None` when +/// the language has no rules here. +/// +/// `None` is a distinct answer from "only `other`" on purpose: the ahead-of-time +/// generator (`xtask i18n`) must refuse a locale it has no rules for rather than emit a +/// single-arm resource for a language that may well need six, while the runtime is happy +/// to fall back. Both callers read this one table. +#[must_use] +pub fn selectable(locale: &str) -> Option<&'static [Category]> { + rules(locale).map(|r| r.selectable) +} + +/// The rule row for `locale`'s language subtag, matched case-insensitively. +fn rules(locale: &str) -> Option<&'static Rules> { + let language = locale.split(['-', '_']).next().unwrap_or(locale); + RULES + .iter() + .find(|r| r.language.eq_ignore_ascii_case(language)) +} + +/// `one` for exactly 1 — English, German. +fn exactly_one(n: u64) -> Category { + if n == 1 { + Category::One + } else { + Category::Other + } +} + +/// `one` for 0 and 1 — Hindi (`i = 0 or n = 1`). +fn zero_or_one(n: u64) -> Category { + if n <= 1 { + Category::One + } else { + Category::Other + } +} + +/// Spanish, Italian: `one` for exactly 1, `many` for a non-zero multiple of a million. +fn romance_one(n: u64) -> Category { + if n == 1 { + Category::One + } else { + romance_many(n) + } +} + +/// French, Portuguese: as [`romance_one`], but 0 is also `one`. +fn romance_zero_or_one(n: u64) -> Category { + if n <= 1 { + Category::One + } else { + romance_many(n) + } +} + +/// The Romance `many`: `e = 0 and i != 0 and i % 1000000 = 0 and v = 0`. +/// +/// This exists for the compact-decimal spellings ("2 millones de fotos"), but CLDR states +/// it over the *integer* operands, so a plain `2000000` selects it too — which is why the +/// generator lists `many` as selectable for these languages and why this is a real arm and +/// not a decorative one. +fn romance_many(n: u64) -> Category { + if n != 0 && n.is_multiple_of(1_000_000) { + Category::Many + } else { + Category::Other + } +} + +/// Russian: `one` for …1 except …11; `few` for …2-4 except …12-14; `many` otherwise. +fn russian(n: u64) -> Category { + let last = n % 10; + let last_two = n % 100; + if last == 1 && last_two != 11 { + Category::One + } else if (2..=4).contains(&last) && !(12..=14).contains(&last_two) { + Category::Few + } else { + Category::Many + } +} + +/// Arabic: `zero` 0, `one` 1, `two` 2, `few` …3-10, `many` …11-99, else `other`. +fn arabic(n: u64) -> Category { + let last_two = n % 100; + match n { + 0 => Category::Zero, + 1 => Category::One, + 2 => Category::Two, + _ if (3..=10).contains(&last_two) => Category::Few, + _ if (11..=99).contains(&last_two) => Category::Many, + _ => Category::Other, + } +} + +/// `other` for every count — Japanese, Korean, Chinese. +fn always_other(_: u64) -> Category { + Category::Other +} + +#[cfg(test)] +mod tests { + use super::{Category, RULES, category, selectable}; + + /// The languages `locales/config.json` ships, as their subtags. + const LANGUAGES: &[&str] = &[ + "ar", "de", "en", "es", "fr", "hi", "it", "ja", "ko", "pt", "ru", "zh", + ]; + + /// The full table, cell by cell: one expected category per (language, count). + /// + /// Written out rather than derived, because a table derived from the implementation + /// asserts nothing. Counts are the CLDR boundary set: the teens and the `x1`/`x2` + /// tails that separate Russian's three forms, the `x00` tail that separates Arabic's + /// `many` from its `other`, and 0, which four of these twelve treat as singular. + const COUNTS: &[i64] = &[0, 1, 2, 3, 5, 11, 21, 100, 101, 1000]; + + #[rustfmt::skip] + const EXPECTED: &[(&str, &[Category])] = { + use Category::{Few, Many, One, Other, Two, Zero}; + &[ + // 0 1 2 3 5 11 21 100 101 1000 + ("ar", &[Zero, One, Two, Few, Few, Many, Many, Other, Other, Other]), + ("de", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("en", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("es", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("fr", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("hi", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("it", &[Other, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ja", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ko", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ("pt", &[One, One, Other, Other, Other, Other, Other, Other, Other, Other]), + ("ru", &[Many, One, Few, Few, Many, Many, One, Many, One, Many]), + ("zh", &[Other, Other, Other, Other, Other, Other, Other, Other, Other, Other]), + ] + }; + + #[test] + fn the_rule_table_covers_exactly_the_shipped_languages() { + let listed: Vec<&str> = RULES.iter().map(|r| r.language).collect(); + assert_eq!( + listed, LANGUAGES, + "add a row when a locale joins config.json" + ); + let expected: Vec<&str> = EXPECTED.iter().map(|(lang, _)| *lang).collect(); + assert_eq!(expected, LANGUAGES); + } + + #[test] + fn every_cell_of_the_cldr_table_is_asserted() { + for (language, row) in EXPECTED { + assert_eq!(row.len(), COUNTS.len(), "{language} row is the wrong width"); + for (n, want) in COUNTS.iter().zip(*row) { + assert_eq!( + category(language, *n), + *want, + "{language} at n={n} selected the wrong category" + ); + } + } + } + + #[test] + fn russian_separates_its_three_forms_past_the_teens() { + assert_eq!(category("ru", 22), Category::Few); + assert_eq!(category("ru", 25), Category::Many); + assert_eq!(category("ru", 111), Category::Many); + assert_eq!(category("ru", 121), Category::One); + } + + #[test] + fn arabic_uses_the_hundreds_tail() { + assert_eq!(category("ar", 103), Category::Few); + assert_eq!(category("ar", 111), Category::Many); + assert_eq!(category("ar", 200), Category::Other); + } + + #[test] + fn the_romance_many_is_the_millions_form_and_nothing_smaller() { + // `many` exists for these four, but nothing below a million reaches it — which is + // why a catalog carrying only `one`/`other` still renders correctly today. + for language in ["es", "fr", "it", "pt"] { + for n in 0i64..=1000 { + assert_ne!( + category(language, n), + Category::Many, + "{language} reached `many` at n={n}" + ); + } + assert_eq!(category(language, 1_000_000), Category::Many); + assert_eq!(category(language, 2_000_000), Category::Many); + assert_eq!(category(language, 1_000_001), Category::Other); + } + } + + #[test] + fn the_selectable_set_is_exactly_what_selection_can_produce() { + // The two halves of a row must agree in both directions, because both are load + // bearing: `xtask i18n` drops an `` the language cannot select, + // and the runtime picks the arm. A category listed but unreachable is a dead arm + // the generator would emit; a category reachable but unlisted is an arm it would + // drop and the runtime would then ask for. + // + // `other` is the exception in one direction only: Russian never selects it for an + // *integer* (its `other` is for fractions), yet every plural resource must carry + // it, so it is always listed. + for language in LANGUAGES { + let selectable = selectable(language).expect("a shipped language has rules"); + assert!(selectable.contains(&Category::Other), "{language}"); + let mut reachable: Vec = (0i64..=2000) + .chain([999_999, 1_000_000, 2_000_000, i64::MAX]) + .map(|n| category(language, n)) + .collect(); + reachable.push(Category::Other); + reachable.sort_unstable(); + reachable.dedup(); + assert_eq!( + reachable, selectable, + "{language}: the rules and the selectable set disagree" + ); + } + } + + #[test] + fn a_region_or_script_subtag_resolves_to_its_language() { + assert_eq!(category("pt-BR", 0), category("pt", 0)); + assert_eq!(category("zh-Hans", 1), Category::Other); + assert_eq!(category("zh-Hant", 1), Category::Other); + assert_eq!(category("EN-gb", 1), Category::One); + assert_eq!(selectable("pt-BR"), selectable("pt")); + } + + #[test] + fn negative_counts_use_the_absolute_value() { + assert_eq!(category("en", -1), Category::One); + assert_eq!(category("ru", -22), Category::Few); + // The one input that would panic under a naive `n.abs()`. + assert_eq!(category("ru", i64::MIN), category("ru", 8)); + } + + #[test] + fn an_unknown_language_falls_back_to_other_but_reports_no_rules() { + assert_eq!(category("nl", 1), Category::Other); + assert_eq!(category("", 1), Category::Other); + assert_eq!(selectable("nl"), None); + } + + #[test] + fn categories_round_trip_through_their_cldr_keyword() { + for want in Category::ALL { + assert_eq!(Category::parse(want.as_str()), Some(*want)); + assert_eq!(want.to_string(), want.as_str()); + } + assert_eq!(Category::parse("=0"), None); + assert_eq!(Category::parse("One"), None); + } +} diff --git a/capsule-i18n/tests/integration.rs b/capsule-i18n/tests/integration.rs index 59451c12..d2b12e3c 100644 --- a/capsule-i18n/tests/integration.rs +++ b/capsule-i18n/tests/integration.rs @@ -1,6 +1,8 @@ //! End-to-end checks against the generated `en` bundle embedded in the crate. -use capsule_i18n::{Bundle, Value, error_codes, format_message, negotiate, supported_locales}; +use capsule_i18n::{ + Bundle, Value, error_codes, format_message, format_message_in, negotiate, supported_locales, +}; #[test] fn source_locale_is_supported() { @@ -55,3 +57,113 @@ fn public_formatter_interpolates() { "Hi, Sam!" ); } + +/// The counts the plural matrix below is asserted over: the CLDR boundaries that separate +/// Russian's three forms and Arabic's five from each other and from `other`. +const COUNTS: &[i64] = &[0, 1, 2, 3, 5, 11, 21, 101]; + +/// One locale's embedded bundle, read from the same file the crate compiles in. +/// +/// The crate exposes no key iterator (a bundle answers look-ups; enumerating its keys is +/// not something a caller needs), so the matrix reads the generated JSON directly. It is +/// the *committed generated* file, so a drifted catalog is `mise run i18n-check`'s +/// failure, not this test's. +fn bundle_messages(locale: &str) -> std::collections::BTreeMap { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src/bundles") + .join(format!("{locale}.json")); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())); + serde_json::from_str(&json).unwrap_or_else(|error| panic!("parse {}: {error}", path.display())) +} + +/// Every plural the shipped catalogs carry renders, in every locale, at every boundary +/// count — no braces, no `plural,`, and the count itself wherever the arm spells `#`. +/// +/// This is the acceptance test for the runtime's plural support: before it, each of these +/// ~1000 renderings returned the ICU message source, which is what a user would have read. +#[test] +fn every_plural_renders_in_every_locale() { + let mut renderings = 0usize; + let mut keys_seen = 0usize; + let source_messages = bundle_messages("en"); + for locale in supported_locales() { + let bundle = Bundle::for_locale(locale); + // A key the locale has not translated resolves through the source catalog, so the + // reader still gets an English plural — under *their* language's rules. That path + // is live (`app.places.preview.accessibility` is `en`-only), so the matrix covers + // the union rather than only what the locale itself carries. + let mut messages = source_messages.clone(); + messages.extend(bundle_messages(locale)); + for (key, source) in messages { + if !source.contains("plural,") { + continue; + } + keys_seen += 1; + // The whole matrix assumes one selector name, which is also what the + // generators' argument plan assumes. Assert it rather than silently + // rendering the `other` arm for a key that renamed its count. + assert!( + source.starts_with("{count, plural,"), + "{locale}/{key}: unexpected plural selector in `{source}`" + ); + for n in COUNTS { + let rendered = bundle.format(&key, &[("count", Value::Int(*n))]); + assert!( + !rendered.contains('{') + && !rendered.contains('}') + && !rendered.contains("plural,"), + "{locale}/{key} at n={n} rendered ICU source: `{rendered}`" + ); + if source.contains('#') { + assert!( + rendered.contains(&n.to_string()), + "{locale}/{key} at n={n} dropped the count: `{rendered}`" + ); + } + renderings += 1; + } + } + } + // 13 locales x 10-11 plural keys x 8 counts. A floor, not an equality, so adding a + // plural to the catalogs does not fail this test — losing one does. + assert!( + keys_seen >= 13 * 10, + "only {keys_seen} plural keys found across the bundles" + ); + assert!( + renderings >= 130 * COUNTS.len(), + "only {renderings} renderings were asserted" + ); +} + +/// A bundle formats with **its own** locale's rules, which is the API gap plural support +/// had to close: `Bundle::format` used to drop `self.locale` on the floor. +#[test] +fn a_bundle_selects_the_arm_its_own_locale_asks_for() { + // French counts zero as singular, English does not — the same catalog message, two + // different arms, chosen only because the bundle knows which locale it is. + let count = [("count", Value::Int(0))]; + assert_eq!( + Bundle::for_locale("fr").format("common.item_count", &count), + "0 item" + ); + assert_eq!( + Bundle::for_locale("en").format("common.item_count", &count), + "0 items" + ); +} + +/// The locale-free `format_message` still exists and still means English. +#[test] +fn the_public_formatter_evaluates_a_plural_with_english_rules() { + let template = "{count, plural, one {# item} other {# items}}"; + assert_eq!( + format_message(template, &[("count", Value::Int(1))]), + "1 item" + ); + assert_eq!( + format_message_in("fr", template, &[("count", Value::Int(0))]), + "0 item" + ); +} diff --git a/capsule-sdk/Cargo.toml b/capsule-sdk/Cargo.toml index c88e62d1..fef7a340 100644 --- a/capsule-sdk/Cargo.toml +++ b/capsule-sdk/Cargo.toml @@ -48,7 +48,7 @@ jiff = { workspace = true } secrecy = { workspace = true } tracing = { workspace = true } # SHA-256 lowercase-hex per-chunk checksum (`X-Capsule-Checksum`); byte-identical -# to the server's `capsule_core::utils::hash::hash_bytes`, so no capsule-core dep. +# to the server's `capsule_core::crypto::hash::hash_bytes`, so no capsule-core dep. sha2 = { workspace = true } hex = { workspace = true } diff --git a/capsule-sdk/build.rs b/capsule-sdk/build.rs index e4432c42..cc527870 100644 --- a/capsule-sdk/build.rs +++ b/capsule-sdk/build.rs @@ -21,7 +21,7 @@ fn main() { build_rest_client(); } -/// Generate the typed REST client from the committed OpenAPI 3.1 schema (slice `S-D8`). +/// Generate the typed REST client from the committed OpenAPI 3.2 schema (slice `S-D8`). fn build_rest_client() { let manifest_dir = PathBuf::from( std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"), @@ -73,8 +73,11 @@ fn build_rest_client() { // spec* — and the alternative is worse in a way worth naming: re-labelling them // `application/octet-stream` to satisfy a generator would tell every client that a document // with a schema it knows is opaque bytes, which is the thing the media type exists to deny. - // `capsule_sdk::directory` already hand-writes two of them for the old reason; the other two - // have no client yet. + // All four are hand-written now, and each one says in its own module doc that spargen is + // the only reason: `capsule_sdk::directory` covers the two device-directory operations, + // `capsule_sdk::upgrade` the proposal, and `capsule_sdk::verify`'s + // `StorageVerifyClient::fetch_receipt` the receipt. Teaching spargen the media type retires + // all four narrowings and all four clients — see the tracking issue on the generator. let omitted = [ spargen::OmitRule::operation(spargen::OmitMethod::Post, "/v1/auth/devices/directory"), spargen::OmitRule::operation( diff --git a/capsule-sdk/src/albums.rs b/capsule-sdk/src/albums.rs index 5e969203..f5d6322c 100644 --- a/capsule-sdk/src/albums.rs +++ b/capsule-sdk/src/albums.rs @@ -27,22 +27,27 @@ pub use crate::upload::StaticToken; // ─── Errors ─────────────────────────────────────────────────────────────────── -/// A failure provisioning an album. +/// A failure on the album surface: provisioning, or publishing a roster (`S-C51`). #[derive(Debug, thiserror::Error)] pub enum AlbumError { /// The HTTP request failed on the wire, or the session could not authorize it. - #[error("album provisioning transport: {0}")] + #[error("album request transport: {0}")] Transport(String), - /// The server refused the provisioning request. - #[error("album provisioning refused with status {status}")] + /// The server refused the request. + #[error("album request refused with status {status}")] Status { /// The HTTP status code. status: u16, /// The stable `error.*` code, when the server supplied one. code: Option, + /// On either roster-version refusal — the `409 error.album.roster_stale` that is behind + /// the server, and the `400 error.album.roster_version_leap` that is too far ahead of it + /// — the version the server holds, which is the one a caller re-signs above. Absent on + /// every other refusal. + current_version: Option, }, /// The response body was missing a field or otherwise unparsable. - #[error("malformed album provisioning response: {0}")] + #[error("malformed album response: {0}")] Malformed(String), } @@ -99,6 +104,9 @@ impl AlbumTransport { /// Build a transport over a fixed bearer token (tests; callers holding a live token). /// Same URL layout as [`Self::with_session`]. + /// + /// `http` **must** come from [`crate::net::http_builder`] or [`crate::net::http_client`]: a + /// client built any other way sends no protocol handshake, and every gated route refuses it. pub fn with_static_token( http: reqwest::Client, base_url: impl Into, @@ -126,8 +134,59 @@ impl AlbumTransport { .map_err(|e| AlbumError::Transport(e.to_string())), } } + + /// A `spargen`-generated client for the API root this transport's album endpoint hangs off, + /// carrying the same credential. + /// + /// The roster publish goes through this rather than through [`Self::send`]: everything that + /// parses or serializes in this repository is generated, and `publish_album_roster` is a + /// JSON operation the document fully describes — request body, success body, and the + /// `409`/`400` problem shapes with their extension members. Only `provision` still hand-writes + /// its DTOs, and only because `POST /v1/albums` predates this seam. + /// + /// The root is derived by trimming the endpoint's `/v1/albums` suffix, because the generated + /// operations carry their own absolute paths while this transport is constructed with the + /// album endpoint (`{origin}/v1/albums`) that `POST {base}` provisions against. + fn generated(&self) -> Result { + let root = self + .base_url + .strip_suffix("/v1/albums") + .unwrap_or(&self.base_url); + let (http, credential) = match &self.auth { + AlbumAuth::Session(session) => { + let session = session.clone(); + // The session's own pre-flight refresh and single-flight coalescing, consulted + // per request; the reactive `401` replay is the caller's, below. + let provider: crate::rest::TokenProvider = std::sync::Arc::new(move || { + let session = session.clone(); + Box::pin(async move { + session + .bearer() + .await + .map_err(|error| crate::rest::AuthError::new(error.to_string())) + }) + }); + ( + crate::net::http_client() + .map_err(|error| AlbumError::Transport(error.to_string()))?, + crate::rest::Credential::Provider(provider), + ) + } + AlbumAuth::Static { http, token } => ( + http.clone(), + crate::rest::Credential::Bearer(token.clone().into()), + ), + }; + Ok(crate::rest::Client::with_client(http, root) + .map_err(|error| AlbumError::Transport(error.to_string()))? + .with_credential(BEARER_SCHEME, credential)) + } } +/// The security-scheme key the document declares for the bearer JWT; the generated client +/// attaches the registered credential to every operation whose `security` names it. +const BEARER_SCHEME: &str = "bearer"; + // ─── Wire DTOs (mirror the server's transport JSON) ─────────────────────────── /// The `POST /v1/albums` request body. One field, deliberately: the server's body is strict, @@ -143,6 +202,8 @@ struct ProvisionAlbumResponseWire { created: bool, } +/// The one field `provision` reads off a refusal. The roster publish reads its problems through +/// the generated client's typed error instead, which is why nothing here describes extensions. #[derive(Deserialize)] struct ApiErrorWire { #[serde(default)] @@ -159,6 +220,24 @@ pub struct ProvisionedAlbum { pub created: bool, } +/// What the server holds for an album after a roster publish (`S-C51`). +/// +/// `replayed` is informational: the same bytes again are a success that wrote nothing, exactly +/// as re-provisioning is, so a client that lost an acknowledgement re-PUTs without branching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishedRoster { + /// The album, echoed. + pub album_id: Uuid, + /// The roster version the server holds after this call. + pub roster_version: u64, + /// The AMK epoch that roster reflects. + pub amk_epoch: u64, + /// How many members it names, the owner excluded. + pub member_count: u64, + /// Whether this call replayed the roster already held. + pub replayed: bool, +} + // ─── Client ─────────────────────────────────────────────────────────────────── /// The album-provisioning client. @@ -204,6 +283,7 @@ impl AlbumClient { return Err(AlbumError::Status { status: status.as_u16(), code, + current_version: None, }); } @@ -224,6 +304,165 @@ impl AlbumClient { created: wire.created, }) } + + /// Publish `signed` as the roster of the album it names (`S-C51`). + /// + /// Orchestration only, and deliberately thin: the roster is signed in + /// `capsule_core::crypto::membership` by one of the owner's devices, base64-encoded, and + /// handed to the **generated** `publish_album_roster` operation, so every byte that is + /// parsed or serialized on this path comes from the committed OpenAPI document. Idempotent + /// under `(album_id, roster_version)`: the same bytes again succeed with `replayed`. + /// + /// Two refusals a caller acts on: a `409` (`error.album.roster_stale`) means the server holds + /// a roster this one does not supersede, and a `400 error.album.roster_version_leap` means + /// the version is too far *ahead* of the held one. Both carry + /// [`current_version`](AlbumError::Status), and the repair for both is the same — re-sign the + /// roster one above it. + /// + /// Under a session, a `401` is refreshed once and replayed, exactly as the sync feed does: + /// the credential provider's pre-flight refresh cannot cover a token revoked mid-flight. + /// + /// # Errors + /// + /// [`AlbumError::Transport`] when the request did not complete, [`AlbumError::Status`] with + /// the server's `error.*` code when it was refused, [`AlbumError::Malformed`] when the roster + /// could not be encoded or the response could not be read. + #[instrument(skip(self, signed), fields(album_id = %signed.roster.album_id, roster_version = signed.roster.roster_version))] + pub async fn publish_roster( + &self, + signed: &capsule_core::crypto::membership::SignedAlbumRoster, + ) -> Result { + use base64::Engine as _; + + let album_id = signed.roster.album_id; + let bytes = capsule_core::cbor::to_canonical_vec(signed) + .map_err(|e| AlbumError::Malformed(format!("roster encoding: {e}")))?; + let body = crate::rest::types::RosterRequest { + roster_cbor: base64::engine::general_purpose::STANDARD.encode(bytes), + }; + + let client = self.transport.generated()?; + let wire = match publish(&client, album_id, &body).await { + Ok(wire) => wire, + Err(error) if is_unauthenticated(&error) => match &self.transport.auth { + AlbumAuth::Session(session) => { + tracing::info!("the roster publish answered 401; refreshing once and retrying"); + session.refresh().await?; + publish(&client, album_id, &body) + .await + .map_err(publish_refusal)? + } + // A fixed token cannot be refreshed, so retrying would ask the same question + // twice. + AlbumAuth::Static { .. } => return Err(publish_refusal(error)), + }, + Err(error) => return Err(publish_refusal(error)), + }; + + let echoed = Uuid::parse_str(&wire.album_id) + .map_err(|e| AlbumError::Malformed(format!("response album_id: {e}")))?; + if echoed != album_id { + return Err(AlbumError::Malformed(format!( + "server echoed album {echoed}, not the requested {album_id}" + ))); + } + tracing::info!( + roster_version = wire.roster_version, + replayed = wire.replayed, + "album roster published" + ); + Ok(PublishedRoster { + album_id: echoed, + roster_version: counter(wire.roster_version, "roster_version")?, + amk_epoch: counter(wire.amk_epoch, "amk_epoch")?, + member_count: counter(wire.member_count, "member_count")?, + replayed: wire.replayed, + }) + } +} + +/// One call of the generated roster operation. +/// +/// The protocol date is a required parameter of every gated operation in the document, so the +/// generated signature asks for it; the value is this build's own, the same one the transport +/// sends as a default header. +async fn publish( + client: &crate::rest::Client, + album_id: Uuid, + body: &crate::rest::types::RosterRequest, +) -> Result< + crate::rest::types::RosterResponse, + crate::rest::Error, +> { + Ok(client + .publish_album_roster( + album_id.hyphenated().to_string(), + capsule_core::crypto::primitives::PROTOCOL_VERSION, + None, + body, + ) + .await? + .into_inner()) +} + +/// Whether the refusal was the credential's. +fn is_unauthenticated(error: &crate::rest::Error) -> bool { + matches!( + error, + crate::rest::Error::Api(response) + if matches!( + response.inner(), + crate::rest::PublishAlbumRosterError::Status401(_) + ) + ) +} + +/// Map the generated operation's typed error onto this module's. +/// +/// The `code` is what a caller switches on, and `current_version` is what the two version +/// refusals — the `409` that is behind and the `400` that is too far ahead — both carry so the +/// caller can re-sign one above what the server holds. +fn publish_refusal(error: crate::rest::Error) -> AlbumError { + use crate::rest::PublishAlbumRosterError as Refusal; + + let crate::rest::Error::Api(response) = error else { + return AlbumError::Transport(error.to_string()); + }; + let status = response.status().as_u16(); + let (code, current_version) = match response.into_inner() { + Refusal::Status400(problem) => ( + Some(problem.code.clone()), + problem + .current_version + .and_then(|held| u64::try_from(held).ok()), + ), + Refusal::Status409(problem) => ( + Some(problem.code.clone()), + problem + .current_version + .and_then(|held| u64::try_from(held).ok()), + ), + // The body-less refusal: a request past the transport's size backstop. + Refusal::Status413 => (None, None), + Refusal::Status401(problem) + | Refusal::Status403(problem) + | Refusal::Status404(problem) + | Refusal::Status415(problem) + | Refusal::Status422(problem) + | Refusal::Status426(problem) + | Refusal::Status500(problem) => (Some(problem.code.clone()), None), + }; + tracing::warn!(status, ?code, ?current_version, "roster publish refused"); + AlbumError::Status { + status, + code, + current_version, + } +} + +/// A counter the document types as a signed integer, as the SDK speaks it. +fn counter(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| AlbumError::Malformed(format!("{field}: {value} is negative"))) } #[cfg(test)] diff --git a/capsule-sdk/src/albums/tests.rs b/capsule-sdk/src/albums/tests.rs index 16bb318b..021a1c3b 100644 --- a/capsule-sdk/src/albums/tests.rs +++ b/capsule-sdk/src/albums/tests.rs @@ -19,6 +19,13 @@ //! | `a_malformed_id_carries_the_invalid_id_code` | the 400 path | //! | `an_echoed_mismatch_is_malformed` | the server cannot silently rebind another album | //! | `the_request_is_authorized` | the bearer rides every call | +//! | `publish_roster_sends_the_signed_bytes_verbatim` | the roster on the wire is the one the device signed (`S-C51`) | +//! | `a_published_roster_reports_what_the_server_holds` | the success mapping, replay included | +//! | `a_stale_roster_carries_the_distinct_code` | the `409` is switchable by code | +//! | `a_roster_echo_mismatch_is_malformed` | the server cannot silently answer for another album | +//! | `a_version_leap_carries_the_held_version_too` | the `400` too-far-ahead refusal is switchable and carries `current_version` | +//! | `the_widest_version_the_server_can_name_still_decodes` | the recovery hint survives at the top of the server's range, where an unbounded counter would not | +//! | `the_roster_publish_goes_through_the_generated_operation` | the path, method and body come from the committed contract, not from a hand-written request | use std::sync::{Arc, Mutex}; @@ -236,3 +243,261 @@ async fn the_request_is_authorized() { "every provisioning call rides the caller's bearer" ); } + +// ─── Roster publish (`S-C51`) ───────────────────────────────────────────────── + +/// A roster for [`album`] at `version`, signed by a fresh device key. +fn signed_roster(version: u64) -> capsule_core::crypto::membership::SignedAlbumRoster { + use capsule_core::crypto::keys::{AmkVersion, HybridSigningKey}; + use capsule_core::crypto::membership::{AlbumRoster, MemberRole, RosterMember}; + + let roster = AlbumRoster { + album_id: album(), + roster_version: version, + amk_epoch: AmkVersion(2), + attested_by_user: Uuid::from_u128(0xA11CE), + attested_by_device: Uuid::from_u128(0xD1), + attested_at: "2026-09-02T00:00:00Z".to_owned(), + members: vec![RosterMember { + user_id: Uuid::from_u128(0xB0B), + role: MemberRole::Writer, + }], + }; + capsule_core::crypto::membership::SignedAlbumRoster::sign( + roster, + &HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]), + ) + .expect("a roster signs") +} + +/// The canonical success body the server sends for a roster publish. +fn held(id: Uuid, version: u64, replayed: bool) -> MockResponse { + MockResponse::new(200, "OK").json_body(format!( + r#"{{"album_id":"{id}","roster_version":{version},"amk_epoch":2,"member_count":1,"replayed":{replayed}}}"# + )) +} + +/// **The bytes on the wire are the bytes the device signed.** The client base64-encodes the +/// canonical CBOR and changes nothing: a re-serialization would be a roster whose signature no +/// longer verifies, and the server decides a replay on these exact bytes. +#[tokio::test] +async fn publish_roster_sends_the_signed_bytes_verbatim() { + use base64::Engine as _; + + let id = album(); + let signed = signed_roster(3); + let (server, seen) = recording(move |_| held(id, 3, false)).await; + // At the production layout — `{origin}/v1/albums` — so the path is the server's own. + let client = AlbumClient::new(AlbumTransport::with_static_token( + reqwest::Client::new(), + format!("{}/v1/albums", server.base_url().trim_end_matches('/')), + StaticToken("test-token".into()), + )); + client.publish_roster(&signed).await.expect("publish"); + + let requests = seen.lock().expect("recorded requests"); + assert_eq!(requests[0].method, "PUT"); + assert_eq!( + requests[0].path, + format!("/v1/albums/{}/roster", id.hyphenated()) + ); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("JSON"); + let object = body.as_object().expect("a JSON object"); + assert_eq!(object.keys().collect::>(), vec!["roster_cbor"]); + let bytes = base64::engine::general_purpose::STANDARD + .decode(body["roster_cbor"].as_str().expect("a string")) + .expect("standard base64"); + let expected = capsule_core::cbor::to_canonical_vec(&signed).expect("encodes"); + assert_eq!( + bytes, expected, + "the wire carries the canonical encoding of exactly what was signed" + ); + assert_eq!( + capsule_core::cbor::canonicalize(&bytes).expect("decodes"), + bytes, + "and those bytes are canonical, which is the form the server stores and replays on" + ); + assert_eq!( + body["roster_cbor"].as_str().expect("a string"), + base64::engine::general_purpose::STANDARD.encode(&expected), + "standard base64 with padding, the alphabet the server decodes" + ); + assert!( + requests[0].header("authorization").is_some(), + "the bearer rides the roster publish too" + ); +} + +#[tokio::test] +async fn a_published_roster_reports_what_the_server_holds() { + let id = album(); + let (server, _) = recording(move |_| held(id, 3, true)).await; + let result = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect("publish"); + assert_eq!( + result, + PublishedRoster { + album_id: id, + roster_version: 3, + amk_epoch: 2, + member_count: 1, + replayed: true, + } + ); +} + +/// The `409` is the one refusal a client acts on differently — re-sync and republish above the +/// version the server names — so its code must come through. +#[tokio::test] +async fn a_stale_roster_carries_the_distinct_code() { + let (server, _) = recording(|_| { + MockResponse::new(409, "Conflict").json_body( + r#"{"type":"about:blank","title":"Roster stale","status":409,"detail":"the server holds roster version 4, which this does not supersede","code":"error.album.roster_stale","current_version":4}"#.to_owned(), + ) + }) + .await; + let error = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect_err("a stale roster is refused"); + assert_eq!(error.error_code(), Some(error_codes::ALBUM_ROSTER_STALE)); + assert!( + matches!( + error, + AlbumError::Status { + status: 409, + current_version: Some(4), + .. + } + ), + "the held version rides the refusal, so the caller can republish above it: {error:?}" + ); +} + +/// A server answering for a different album than the one asked about is a malformed answer, +/// not a success with the wrong id in it. +#[tokio::test] +async fn a_roster_echo_mismatch_is_malformed() { + let other = Uuid::parse_str("0198f3c2-9c4a-7b3d-8f21-4d7c9a1b2eff").expect("a uuid"); + let (server, _) = recording(move |_| held(other, 3, false)).await; + let error = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect_err("a mismatched echo is refused"); + assert!(matches!(error, AlbumError::Malformed(_)), "{error:?}"); +} + +/// The other version refusal: a roster so far *ahead* of the held one that the server would be +/// latched if it took it. A `400` rather than the `409`, and it carries the held version for the +/// same reason — the caller re-signs one above it. +#[tokio::test] +async fn a_version_leap_carries_the_held_version_too() { + let (server, _) = recording(|_| { + MockResponse::new(400, "Bad Request").json_body( + r#"{"type":"about:blank","title":"Roster version leap","status":400,"detail":"roster version 9999 is past 17","code":"error.album.roster_version_leap","declared":9999,"current_version":1,"max_version":17}"#.to_owned(), + ) + }) + .await; + let error = client_for(&server) + .publish_roster(&signed_roster(9999)) + .await + .expect_err("a leap is refused"); + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_ROSTER_VERSION_LEAP) + ); + assert!( + matches!( + error, + AlbumError::Status { + status: 400, + current_version: Some(1), + .. + } + ), + "the held version rides this refusal too: {error:?}" + ); +} + +/// **The wire shape is the contract's, not this module's.** `publish_roster` is orchestration +/// over the generated `publish_album_roster` operation, so the method, the path and the required +/// protocol header come from `capsule-server/openapi.json` rather than from a hand-written +/// request this module could drift. +#[tokio::test] +async fn the_roster_publish_goes_through_the_generated_operation() { + let id = album(); + let (server, seen) = recording(move |_| held(id, 3, false)).await; + let client = AlbumClient::new(AlbumTransport::with_static_token( + reqwest::Client::new(), + format!("{}/v1/albums", server.base_url().trim_end_matches('/')), + StaticToken("test-token".into()), + )); + client + .publish_roster(&signed_roster(3)) + .await + .expect("publish"); + + let requests = seen.lock().expect("recorded requests"); + assert_eq!(requests[0].method, "PUT"); + assert_eq!( + requests[0].path, + format!("/v1/albums/{}/roster", id.hyphenated()) + ); + assert_eq!( + requests[0].header("x-capsule-protocol"), + Some(capsule_core::crypto::primitives::PROTOCOL_VERSION), + "the generated operation carries the protocol date the document declares required" + ); + assert_eq!( + requests[0].header("authorization"), + Some("Bearer test-token") + ); +} + +/// **The refusal has to survive at the top of the server's range.** +/// +/// spargen lowers every integer in the contract as `i64` and emits no `u64` at all, so a +/// counter above `i64::MAX` would not be an API error at all — the generated client would fail +/// to *decode* the body, the typed error would never be built, and `code` and `current_version` +/// would be replaced by an undifferentiated transport failure. The server bounds every counter +/// it can emit at `MAX_ROSTER_VERSION` (`i64::MAX`) precisely so this holds; the case pins the +/// boundary rather than a comfortable value in the middle of the range, and the declared +/// version — the one number a caller controls and the server therefore cannot bound — is not an +/// extension member at all. +#[tokio::test] +async fn the_widest_version_the_server_can_name_still_decodes() { + let ceiling = i64::MAX as u64; + let body = format!( + r#"{{"type":"about:blank","title":"Roster version leap","status":400,"detail":"roster version 18446744073709551615 is past {ceiling}, the highest this album will accept while it holds version {ceiling}","code":"error.album.roster_version_leap","current_version":{ceiling},"max_version":{ceiling}}}"# + ); + let (server, _) = + recording(move |_| MockResponse::new(400, "Bad Request").json_body(body.clone())).await; + + let error = client_for(&server) + .publish_roster(&signed_roster(u64::MAX)) + .await + .expect_err("a leap is refused"); + + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_ROSTER_VERSION_LEAP), + "the structured code survives at the boundary: {error:?}" + ); + assert!( + matches!( + error, + AlbumError::Status { + status: 400, + current_version: Some(held), + .. + } if held == ceiling + ), + "and so does the recovery hint: {error:?}" + ); + assert!( + !matches!(error, AlbumError::Transport(_)), + "a decode failure would have collapsed this into a transport error" + ); +} diff --git a/capsule-sdk/src/auth.rs b/capsule-sdk/src/auth.rs index 6c7db931..20067491 100644 --- a/capsule-sdk/src/auth.rs +++ b/capsule-sdk/src/auth.rs @@ -24,6 +24,13 @@ //! ladder lands with `S-D10`, but the `401`-retry-once and pre-flight refresh here //! are the parts the session store owns. //! +//! The OIDC legs (slice `S-N2`: [`AuthClient::begin_oidc_login`] and +//! [`AuthClient::complete_oidc_login`]) are **not** hand-rolled: neither is token +//! orchestration, so the exemption above does not cover them, and they call the generated +//! [`rest::Client`] — every body and every response parsed by generated code, with only the +//! mapping into [`LoginOutcome`] and [`AuthError`] written here. The browser leg between the +//! two is the platform's: a loopback listener on the CLI, `ASWebAuthenticationSession` on iOS. +//! //! ## Testing //! //! The wire flows (login/refresh/logout, `401` recovery, error mapping) are proven @@ -36,6 +43,7 @@ use std::sync::Arc; +use capsule_core::crypto::primitives::PROTOCOL_VERSION; use capsule_i18n::error_codes; use jiff::Timestamp; use secrecy::{ExposeSecret, SecretString}; @@ -43,6 +51,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, RwLock}; use tracing::instrument; +use crate::rest; + /// Default pre-flight refresh window: refresh once the access token is within this /// many seconds of expiry, so an in-flight request never races the boundary. const DEFAULT_REFRESH_SKEW_SECS: i64 = 30; @@ -112,6 +122,42 @@ pub enum AuthError { /// [`LoginOutcome`], because a second factor is the system working rather than a failure. #[error("this account requires a second factor; complete the sign-in with a code")] SecondFactorRequired, + + /// The server has no identity provider (`error.auth.oidc_not_configured`). + /// + /// A client that read `auth.oidc: null` from `server-info` never sees this; one that offered + /// the option anyway does. + #[error("single sign-on is not configured on this server")] + OidcNotConfigured, + /// The redirect URI this client asked for is not one the server admits + /// (`error.auth.oidc_redirect_invalid`). A client or deployment misconfiguration. + #[error("the server will not send a person back to this redirect URI")] + OidcRedirectInvalid, + /// The callback was refused: the ceremony expired or was replayed, the provider refused the + /// exchange, or the ID token failed a check. Every one means "start the sign-in again", so + /// they are one variant; `code` says which for a log line. + #[error("the sign-in through the identity provider was refused ({})", code.as_deref().unwrap_or("no code"))] + OidcRejected { + /// The server's `error.auth.oidc_*` code, when it sent one. + code: Option, + }, + /// The identity provider asserted an address that already has a local account + /// (`error.auth.oidc_address_taken`). Never linked: the person signs in with that account's + /// password instead. + #[error("an account with that address already exists; sign in with its password")] + OidcAddressTaken, + /// The generated client could not reach the server, or could not build the request. + /// + /// The generated client classifies its transport failures itself (DNS, connection, TLS, + /// timeout, redirect policy) and they are not `reqwest::Error`s, so they cannot ride + /// [`AuthError::Transport`]; the class and the endpoint are what a caller acts on. + #[error("could not reach {endpoint}: {detail}")] + Network { + /// Which auth endpoint was being reached. + endpoint: &'static str, + /// The generated client's own description. + detail: String, + }, /// A server response the client does not model. #[error("unexpected {status} response from {endpoint}: {detail}")] Unexpected { @@ -144,7 +190,10 @@ impl AuthError { match self { Self::InvalidCredentials => Some(error_codes::AUTH_INVALID_CREDENTIALS), Self::RateLimited { .. } => Some(error_codes::AUTH_RATE_LIMITED), - Self::Unexpected { code, .. } => code.as_deref(), + Self::OidcNotConfigured => Some(error_codes::AUTH_OIDC_NOT_CONFIGURED), + Self::OidcRedirectInvalid => Some(error_codes::AUTH_OIDC_REDIRECT_INVALID), + Self::OidcAddressTaken => Some(error_codes::AUTH_OIDC_ADDRESS_TAKEN), + Self::OidcRejected { code } | Self::Unexpected { code, .. } => code.as_deref(), _ => None, } } @@ -158,6 +207,8 @@ enum Endpoint { VerifyTotp, Refresh, Logout, + OidcAuthorize, + OidcCallback, } impl Endpoint { @@ -168,11 +219,13 @@ impl Endpoint { Self::VerifyTotp => "login/verify-totp", Self::Refresh => "refresh", Self::Logout => "logout", + Self::OidcAuthorize => "oidc/authorize", + Self::OidcCallback => "oidc/callback", } } /// What a `401` from this endpoint means. - fn unauthorized_error(self) -> AuthError { + fn unauthorized_error(self, code: Option) -> AuthError { match self { // Registration does not authenticate an existing session, so a `401` from // it is not a real ceremony outcome; treat it as a credential rejection. @@ -183,6 +236,32 @@ impl Endpoint { // the password — which is what `SessionExpired` says. Neither is a *credential* // rejection, because the password already verified to get this far. Self::VerifyTotp | Self::Refresh | Self::Logout => AuthError::SessionExpired, + // The callback's three `401`s — a spent state, a refused exchange, a refused token — + // all mean "start again"; the code is kept for the log. The authorize declares no + // `401`, so one from it is a server this client does not model. + Self::OidcCallback => AuthError::OidcRejected { code }, + Self::OidcAuthorize => AuthError::Unexpected { + status: 401, + endpoint: self.name(), + detail: String::new(), + code, + }, + } + } + + /// The typed refusals only the OIDC endpoints make, matched on the catalog code. + fn oidc_refusal(self, status: u16, code: Option<&str>) -> Option { + match (self, status, code) { + (Self::OidcAuthorize, 404, Some(error_codes::AUTH_OIDC_NOT_CONFIGURED)) => { + Some(AuthError::OidcNotConfigured) + } + (Self::OidcAuthorize, 400, Some(error_codes::AUTH_OIDC_REDIRECT_INVALID)) => { + Some(AuthError::OidcRedirectInvalid) + } + (Self::OidcCallback, 409, Some(error_codes::AUTH_OIDC_ADDRESS_TAKEN)) => { + Some(AuthError::OidcAddressTaken) + } + _ => None, } } } @@ -304,6 +383,10 @@ struct AuthEndpoints { verify_totp: String, refresh: String, logout: String, + /// The server root the generated client is built on, for the operations that go through + /// it: the auth base with its `/v1/auth` suffix removed, or the base itself when it carries + /// none (the in-crate mock serves the generated paths at its root). + server_root: String, } impl AuthEndpoints { @@ -321,10 +404,30 @@ impl AuthEndpoints { verify_totp: format!("{trimmed}/login/verify-totp"), refresh: format!("{trimmed}/refresh"), logout: format!("{trimmed}/logout"), + server_root: trimmed + .strip_suffix("/v1/auth") + .unwrap_or(trimmed) + .to_owned(), }) } } +/// A begun sign-in through the identity provider (`S-N2`). +/// +/// The platform sends the person to `authorization_url`, receives the provider's redirect at +/// the `redirect_uri` it named, and hands the redirect's `code` with this `state` to +/// [`AuthClient::complete_oidc_login`]. Good once, and until `expires_by`. +/// +/// No `Debug`: the state is the key to the pending ceremony and the URL carries it. +pub struct OidcAuthorization { + /// Where to send the person. Carries the whole authorization request in its query. + pub authorization_url: String, + /// The `state` the provider's redirect will echo. + pub state: SecretString, + /// The absolute Unix-seconds instant the ceremony stops being redeemable. + pub expires_by: u64, +} + /// What a password login answered with (`S-C55`). /// /// Two variants because the server has two outcomes and says so with a status: `200` with a @@ -378,6 +481,9 @@ impl LoginOutcome { pub struct AuthClient { http: reqwest::Client, base: Arc, + /// The generated client over the same transport, for the operations that are not token + /// orchestration (the OIDC legs). + rest: Arc, clock: Arc, refresh_skew_secs: i64, /// The advisory device-cohort hash to ride every session-creation request @@ -391,9 +497,10 @@ pub struct AuthClient { impl AuthClient { /// Build a client against the auth base URL (e.g. `https://api.example.com/auth`). pub fn new(base_url: &str) -> Result { - let http = reqwest::Client::builder() - .build() - .map_err(AuthError::Transport)?; + // The SDK's one HTTP client: every request this client sends — and every request a + // `Session` built from it executes on behalf of the upload, album and verify paths — + // carries the protocol handshake the server's gate requires. + let http = crate::net::http_client().map_err(AuthError::Transport)?; Self::from_parts( base_url, Arc::new(SystemClock), @@ -404,15 +511,26 @@ impl AuthClient { /// Assemble a client from explicit parts (clock + HTTP client + skew). Used by /// [`AuthClient::new`] and by tests that inject a controllable clock. + /// + /// `http` **must** come from [`crate::net::http_builder`] or [`crate::net::http_client`]: a + /// client built any other way sends no protocol handshake, and every gated route refuses it. fn from_parts( base_url: &str, clock: Arc, http: reqwest::Client, refresh_skew_secs: i64, ) -> Result { + let base = AuthEndpoints::from_base(base_url)?; + let rest = rest::Client::with_client(http.clone(), &base.server_root).map_err(|e| { + AuthError::InvalidBaseUrl { + url: base_url.to_string(), + reason: e.to_string(), + } + })?; Ok(Self { http, - base: Arc::new(AuthEndpoints::from_base(base_url)?), + base: Arc::new(base), + rest: Arc::new(rest), clock, refresh_skew_secs, cohort_hash: None, @@ -460,29 +578,160 @@ impl AuthClient { .send() .await?; - // `202 Accepted` — the password verified and the sign-in is not finished. Read from the - // **status**, which is where the server puts the distinction; a body flag would be a - // second place for the two to disagree. Before `S-C63` this fell through to - // `read_tokens` and surfaced as `MalformedResponse`, which told a user with a second - // factor that their server was broken. + let outcome = self.read_login_outcome(Endpoint::Login, response).await?; + tracing::info!("login answered"); + Ok(outcome) + } + + /// Begin a sign-in through the server's identity provider (`S-N2`). + /// + /// `redirect_uri` is where the provider will send the person back — this client's own + /// callback, which the server admits if it is the deployment's configured one or a loopback + /// IP literal on any port (the shape a CLI's or desktop app's listener has). What comes back + /// is where to send the person and the `state` to present with the resulting `code`. + /// + /// # Errors + /// + /// [`AuthError::OidcNotConfigured`] when the server has no provider, + /// [`AuthError::OidcRedirectInvalid`] when it refuses the redirect. + #[instrument(skip_all)] + pub async fn begin_oidc_login( + &self, + redirect_uri: &str, + ) -> Result { + tracing::info!("beginning a sign-in through the identity provider"); + let body = self + .rest + .begin_oidc_login( + PROTOCOL_VERSION.to_owned(), + None, + &rest::types::OidcAuthorizeRequest { + redirect_uri: redirect_uri.to_owned(), + }, + ) + .await + .map_err(|error| { + map_rest_error(Endpoint::OidcAuthorize, error, |refused| match refused { + rest::BeginOidcLoginError::Status400(problem) + | rest::BeginOidcLoginError::Status404(problem) + | rest::BeginOidcLoginError::Status415(problem) + | rest::BeginOidcLoginError::Status422(problem) + | rest::BeginOidcLoginError::Status426(problem) + | rest::BeginOidcLoginError::Status429(problem) + | rest::BeginOidcLoginError::Status500(problem) + | rest::BeginOidcLoginError::Status503(problem) => Some(*problem), + rest::BeginOidcLoginError::Status413 => None, + }) + })? + .into_inner(); + Ok(OidcAuthorization { + authorization_url: body.authorization_url, + state: SecretString::from(body.state), + expires_by: u64::try_from(body.expires_by).unwrap_or(0), + }) + } + + /// Finish a sign-in through the identity provider with what its redirect carried (`S-N2`). + /// + /// Answers a [`LoginOutcome`] exactly as [`login`](AuthClient::login) does — a session, or a + /// second-factor challenge for an account that enrolled one — and the configured cohort hash + /// rides this request, because this is the one that opens the session. + /// + /// # Errors + /// + /// [`AuthError::OidcRejected`] for a spent or expired `state`, a refused exchange or a + /// refused ID token (start again); [`AuthError::OidcAddressTaken`] when the provider asserted + /// an address that already has a local account. + #[instrument(skip_all)] + pub async fn complete_oidc_login( + &self, + state: &SecretString, + code: &str, + ) -> Result { + tracing::info!( + cohort_emitted = self.cohort().is_some(), + "completing a sign-in through the identity provider" + ); + let answer = self + .rest + .complete_oidc_login( + PROTOCOL_VERSION.to_owned(), + None, + &rest::types::OidcCallbackRequest { + state: state.expose_secret().to_owned(), + code: code.to_owned(), + cohort_hash: self.cohort().map(str::to_owned), + device_id: None, + }, + ) + .await + .map_err(|error| { + map_rest_error(Endpoint::OidcCallback, error, |refused| match refused { + rest::CompleteOidcLoginError::Status400(problem) + | rest::CompleteOidcLoginError::Status401(problem) + | rest::CompleteOidcLoginError::Status409(problem) + | rest::CompleteOidcLoginError::Status415(problem) + | rest::CompleteOidcLoginError::Status422(problem) + | rest::CompleteOidcLoginError::Status426(problem) + | rest::CompleteOidcLoginError::Status500(problem) => Some(*problem), + rest::CompleteOidcLoginError::Status413 => None, + }) + })? + .into_inner(); + // The status is the discriminator, as on the password login; the generated enum is + // exactly that status made a type. + let outcome = match answer { + rest::CompleteOidcLoginResponse::Status202(challenge) => { + tracing::info!("the identity provider sign-in needs a second factor"); + LoginOutcome::SecondFactorRequired { + mfa_token: SecretString::from(challenge.mfa_token), + expires_by: u64::try_from(challenge.expires_by).unwrap_or(0), + } + } + rest::CompleteOidcLoginResponse::Status200(pair) => { + let tokens = TokenSet::from_wire( + Endpoint::OidcCallback, + TokenResponseBody { + access_token: pair.access_token, + refresh_token: pair.refresh_token, + expires_by: u64::try_from(pair.expires_by).unwrap_or(0), + }, + )?; + tracing::info!("the identity provider sign-in succeeded; session established"); + LoginOutcome::Session(self.session_with_tokens(tokens)) + } + }; + Ok(outcome) + } + + /// A `200` is a session and a `202` is a challenge, on every route that opens a session. + /// + /// Read from the **status**, which is where the server puts the distinction; a body flag + /// would be a second place for the two to disagree. Before `S-C63` the `202` fell through to + /// `read_tokens` and surfaced as `MalformedResponse`, which told a user with a second factor + /// that their server was broken. + async fn read_login_outcome( + &self, + endpoint: Endpoint, + response: reqwest::Response, + ) -> Result { if response.status() == reqwest::StatusCode::ACCEPTED { let challenge: SecondFactorChallengeBody = response .json() .await .map_err(|e| AuthError::MalformedResponse { - endpoint: Endpoint::Login.name(), + endpoint: endpoint.name(), reason: e.to_string(), })?; - tracing::info!("login needs a second factor"); + tracing::info!("the sign-in needs a second factor"); return Ok(LoginOutcome::SecondFactorRequired { mfa_token: SecretString::from(challenge.mfa_token), expires_by: challenge.expires_by, }); } - - let tokens = read_tokens(Endpoint::Login, response).await?; - tracing::info!("login succeeded; session established"); + let tokens = read_tokens(endpoint, response).await?; + tracing::info!("the sign-in succeeded; session established"); Ok(LoginOutcome::Session(self.session_with_tokens(tokens))) } @@ -671,6 +920,23 @@ impl Session { Ok(()) } + /// Refresh because the server **rejected** `stale`, and return the token that replaced + /// it (single-flight). + /// + /// The reactive counterpart to [`bearer`](Session::bearer)'s pre-flight refresh, and the + /// primitive [`crate::client::AuthenticatedClient`]'s `401` layer drives. Passing the + /// exact token the server refused is the whole point: `ensure_refreshed`'s `Rejected` + /// arm compares it against the store, so a caller whose stale token has *already* been + /// rotated by a concurrent refresh gets the fresh token back with no second network + /// call. [`refresh`](Session::refresh) cannot serve this — it re-reads the *current* + /// token and would therefore refresh again on top of that rotation, spending a + /// single-use refresh token the server has already closed. + #[instrument(skip_all)] + pub(crate) async fn refresh_rejected(&self, stale: &str) -> Result { + self.ensure_refreshed(RefreshTrigger::Rejected(stale.to_owned())) + .await + } + /// Revoke the session server-side and clear the local store. Idempotent: a /// server that no longer honors the token (or an already-empty store) still /// resolves to a cleared, logged-out session. @@ -702,11 +968,11 @@ impl Session { } /// A currently-valid **bearer access token** for injecting into a request the - /// SDK does not build with [`Session::execute`] — notably the sync feed's gRPC - /// call metadata, where the token rides `authorization` metadata rather than a - /// `reqwest` header. Pre-flight-refreshes exactly like [`Session::execute`]; - /// callers that get an `Unauthenticated`/`401` back re-[`refresh`](Session::refresh) - /// and read a fresh token once. + /// SDK does not build with [`Session::execute`] — notably the generated REST client's + /// token-provider seam ([`crate::client::AuthenticatedClient`]), where the token is + /// attached by the client rather than by this module. Pre-flight-refreshes exactly like + /// [`Session::execute`]; the reactive half — a `401` the pre-flight check could not + /// foresee — is [`refresh_rejected`](Session::refresh_rejected)'s. #[instrument(skip_all)] pub async fn bearer(&self) -> Result { self.valid_access_token().await @@ -799,18 +1065,35 @@ async fn read_tokens( /// Map a non-success response to a typed [`AuthError`], capturing the server's /// `error.*` code and `Retry-After` where present. async fn error_from_response(endpoint: Endpoint, response: reqwest::Response) -> AuthError { - let status = response.status(); - let retry_after = response - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(|raw| raw.trim().parse::().ok()); + let status = response.status().as_u16(); + let retry_after = retry_after_of(response.headers()); let api_error = response.json::().await.ok(); let code = api_error.as_ref().and_then(|body| body.code.clone()); let detail = api_error.map_or_else(String::new, |body| body.error); + error_for(endpoint, status, code, detail, retry_after) +} + +/// The `Retry-After` seconds a response carries, if it carries one. +fn retry_after_of(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|raw| raw.trim().parse::().ok()) +} - match status.as_u16() { - 401 => endpoint.unauthorized_error(), +/// One status → variant mapping for both the hand-rolled and the generated paths. +fn error_for( + endpoint: Endpoint, + status: u16, + code: Option, + detail: String, + retry_after: Option, +) -> AuthError { + if let Some(refusal) = endpoint.oidc_refusal(status, code.as_deref()) { + return refusal; + } + match status { + 401 => endpoint.unauthorized_error(code), 423 => AuthError::AccountLocked, 429 => AuthError::RateLimited { retry_after_secs: retry_after.unwrap_or(0), @@ -824,6 +1107,49 @@ async fn error_from_response(endpoint: Endpoint, response: reqwest::Response) -> } } +/// Map a generated-client failure to a typed [`AuthError`]. +/// +/// `problem` extracts the coded problem a documented refusal carries, so the status → variant +/// mapping is the one the hand-rolled path uses; the generated client's own classes — transport, +/// timeout, protocol, redirect, construction — become [`AuthError::Network`], an undocumented +/// status [`AuthError::Unexpected`], and a body that did not decode [`AuthError::MalformedResponse`]. +fn map_rest_error( + endpoint: Endpoint, + error: rest::Error, + problem: impl FnOnce(E) -> Option, +) -> AuthError { + match error { + rest::Error::Api(refused) => { + let status = refused.status().as_u16(); + let retry_after = retry_after_of(refused.headers()); + match problem(refused.into_inner()) { + Some(problem) => error_for( + endpoint, + status, + Some(problem.code), + problem.detail.unwrap_or_default(), + retry_after, + ), + None => error_for(endpoint, status, None, String::new(), retry_after), + } + } + rest::Error::UnexpectedStatus { status, body, .. } => AuthError::Unexpected { + status: status.as_u16(), + endpoint: endpoint.name(), + detail: String::from_utf8_lossy(&body).into_owned(), + code: None, + }, + rest::Error::Decode { path, .. } => AuthError::MalformedResponse { + endpoint: endpoint.name(), + reason: path, + }, + other => AuthError::Network { + endpoint: endpoint.name(), + detail: other.to_string(), + }, + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -1533,6 +1859,194 @@ mod tests { assert!(matches!(error, AuthError::NotAuthenticated)); } + // ── OIDC (S-N2) ─────────────────────────────────────────────────────────── + + /// An RFC 9457 problem carrying the stable code, as the server's coded-problem interceptor + /// renders one — the generated client parses refusals into this shape. + fn problem(status: u16, code: &str) -> MockResponse { + MockResponse::json( + status, + serde_json::json!({ + "type": "about:blank", + "title": "refused", + "status": status, + "detail": "the double refuses on purpose", + "code": code, + }) + .to_string(), + ) + } + + /// A server with an identity provider, answering the two OIDC routes and recording what + /// the callback received. + fn oidc_handler(captured: Arc>>) -> Handler { + Arc::new(move |req: MockRequest| { + let captured = captured.clone(); + Box::pin(async move { + match req.path.as_str() { + "/v1/auth/oidc/authorize" => MockResponse::json( + 200, + serde_json::json!({ + "authorization_url": "https://idp.test/authorize?state=state-1", + "state": "state-1", + "expires_by": 1_893_456_000, + }) + .to_string(), + ), + "/v1/auth/oidc/callback" => { + // The generated client sends the handshake header on every request. + assert_eq!( + req.headers.get("x-capsule-protocol").map(String::as_str), + Some(PROTOCOL_VERSION) + ); + *captured.lock().unwrap() = serde_json::from_str(&req.body).ok(); + MockResponse::json(200, token_json("access-1", "refresh-1", far_future())) + } + _ => MockResponse::json(404, r#"{"error":"x"}"#), + } + }) + }) + } + + /// The two legs round-trip, and the cohort rides the callback rather than the authorize. + #[tokio::test] + async fn an_oidc_login_begins_completes_and_carries_the_cohort_on_the_callback() { + let captured = Arc::new(std::sync::Mutex::new(None)); + let server = start_mock(oidc_handler(captured.clone())).await; + let client = AuthClient::new(&server.base_url) + .unwrap() + .with_cohort_hash("a-particular-machine".to_owned()); + + let begun = client + .begin_oidc_login("http://127.0.0.1:4242/callback") + .await + .unwrap(); + assert_eq!( + begun.authorization_url, + "https://idp.test/authorize?state=state-1" + ); + assert_eq!(begun.state.expose_secret(), "state-1"); + assert_eq!(begun.expires_by, 1_893_456_000); + + let session = finished( + client + .complete_oidc_login(&begun.state, "code-1") + .await + .unwrap(), + ); + assert!(session.is_authenticated().await); + + let body = captured + .lock() + .unwrap() + .clone() + .expect("callback body captured"); + assert_eq!(body["state"], "state-1"); + assert_eq!(body["code"], "code-1"); + assert_eq!(body["cohort_hash"], "a-particular-machine"); + } + + /// The callback's `202` is a second-factor challenge, as the password login's is. + #[tokio::test] + async fn an_oidc_callback_can_answer_a_second_factor_challenge() { + let handler: Handler = Arc::new(move |req| { + Box::pin(async move { + match req.path.as_str() { + "/v1/auth/oidc/callback" => MockResponse::json( + 202, + r#"{"mfa_token":"challenge-1","expires_by":1893456000}"#, + ), + _ => MockResponse::json(404, r#"{"error":"x"}"#), + } + }) + }); + let server = start_mock(handler).await; + let client = AuthClient::new(&server.base_url).unwrap(); + let outcome = client + .complete_oidc_login(&SecretString::from("state-1"), "code-1") + .await + .unwrap(); + assert!(matches!(outcome, LoginOutcome::SecondFactorRequired { .. })); + } + + /// Each OIDC refusal maps to its typed variant on the catalog code, and the `401`s to one. + #[tokio::test] + async fn oidc_refusals_map_to_typed_errors_on_their_codes() { + let handler: Handler = Arc::new(move |req| { + Box::pin(async move { + match (req.path.as_str(), req.body.contains("evil")) { + ("/v1/auth/oidc/authorize", true) => { + problem(400, "error.auth.oidc_redirect_invalid") + } + ("/v1/auth/oidc/authorize", false) => { + problem(404, "error.auth.oidc_not_configured") + } + ("/v1/auth/oidc/callback", _) if req.body.contains("taken") => { + problem(409, "error.auth.oidc_address_taken") + } + ("/v1/auth/oidc/callback", _) => problem(401, "error.auth.oidc_state_invalid"), + _ => MockResponse::json(404, r#"{"error":"x"}"#), + } + }) + }); + let server = start_mock(handler).await; + let client = AuthClient::new(&server.base_url).unwrap(); + + let error = client + .begin_oidc_login("https://evil.example.test/cb") + .await + .err() + .expect("refused"); + assert!(matches!(error, AuthError::OidcRedirectInvalid), "{error:?}"); + assert_eq!( + error.error_code(), + Some(error_codes::AUTH_OIDC_REDIRECT_INVALID) + ); + + let error = client + .begin_oidc_login("http://127.0.0.1:4242/cb") + .await + .err() + .expect("refused"); + assert!(matches!(error, AuthError::OidcNotConfigured), "{error:?}"); + assert_eq!( + error.error_code(), + Some(error_codes::AUTH_OIDC_NOT_CONFIGURED) + ); + + let error = expect_login_err( + client + .complete_oidc_login(&SecretString::from("state-1"), "taken") + .await, + ); + assert!(matches!(error, AuthError::OidcAddressTaken), "{error:?}"); + assert_eq!( + error.error_code(), + Some(error_codes::AUTH_OIDC_ADDRESS_TAKEN) + ); + + let error = expect_login_err( + client + .complete_oidc_login(&SecretString::from("state-1"), "code-1") + .await, + ); + assert!(matches!(error, AuthError::OidcRejected { .. }), "{error:?}"); + assert_eq!( + error.error_code(), + Some(error_codes::AUTH_OIDC_STATE_INVALID) + ); + } + + /// The generated client is built on the server root: the auth base minus `/v1/auth`. + #[test] + fn the_server_root_is_the_auth_base_without_its_suffix() { + let endpoints = AuthEndpoints::from_base("https://api.example.test/v1/auth/").unwrap(); + assert_eq!(endpoints.server_root, "https://api.example.test"); + assert_eq!(endpoints.login, "https://api.example.test/v1/auth/login"); + let bare = AuthEndpoints::from_base("http://127.0.0.1:4242").unwrap(); + assert_eq!(bare.server_root, "http://127.0.0.1:4242"); + } + #[test] fn rejects_invalid_base_url() { assert!(matches!( diff --git a/capsule-sdk/src/client.rs b/capsule-sdk/src/client.rs index ca9f6971..3ba4d092 100644 --- a/capsule-sdk/src/client.rs +++ b/capsule-sdk/src/client.rs @@ -11,14 +11,33 @@ //! touch a raw token. The refresh/expiry/single-flight logic is reused wholesale from //! [`crate::auth`]; nothing is duplicated here. //! +//! # Both halves of the refresh contract (slice `S-D17`) +//! +//! The token provider is the **proactive** half: it refreshes when the stored token is within +//! its skew of expiry, before the request leaves. That cannot cover a token the server stops +//! honouring early — a revocation mid-flight, or a clock the two ends disagree about — so +//! [`RefreshOn401`] is the **reactive** half: an [`rest::HttpBackend`] wrapping +//! [`rest::ReqwestBackend`] that, on a `401`, refreshes once through the session and replays +//! the request exactly once. The two are complementary and neither duplicates the other; the +//! refresh itself is still `auth`'s single-flight gate. +//! +//! It sits at the transport seam rather than in each caller, so **every** generated operation +//! is covered by one layer that survives regeneration — no generated code is touched, and +//! there is no per-call retry loop to keep in step. +//! //! Scope: this covers the plain request/response REST surfaces the OpenAPI schema declares //! (auth/session, quota, storage-verify, receipts, devices, escrow, …). The stateful upload -//! protocol ([`crate::upload`]) and the gRPC sync feed ([`crate::sync`]) stay hand-written — -//! they are deliberately *not* routed through the generated client. +//! protocol ([`crate::upload`]) stays hand-written. The sync feed ([`crate::sync`]) *is* a +//! generated operation (`GET /v1/sync`), but [`crate::sync::SyncConsumer`] drives it under its +//! own cursor/anti-rewind state machine and builds its own client, because it also serves a +//! static-token mode that has no session to refresh. use std::ops::Deref; use std::sync::Arc; +use reqwest::header::{AUTHORIZATION, HeaderValue}; +use secrecy::ExposeSecret; + use crate::auth::Session; use crate::rest::{self, Client, Credential}; @@ -46,7 +65,10 @@ pub enum ClientError { /// Cheap to build; holds one [`rest::Client`](crate::rest::Client) whose bearer credential is /// an async provider backed by the session. Because the provider is consulted per request, /// token rotation (refresh) is picked up with no rebuild. Deref-transparent: call any -/// generated operation directly, e.g. `client.get_quota().await`. +/// generated operation directly, e.g. `client.get_quota(PROTOCOL_VERSION, None).await` — every +/// gated operation takes the protocol date as its first argument, because the document +/// declares `X-Capsule-Protocol` required there (issue #404); the transport sends the same value +/// as a default header regardless. pub struct AuthenticatedClient { base_url: String, session: Session, @@ -101,11 +123,113 @@ impl Deref for AuthenticatedClient { } } +/// The bearer prefix the `Authorization` header carries, and the only credential shape +/// [`RefreshOn401`] recognises as a token it can refresh. +const BEARER_PREFIX: &str = "Bearer "; + +/// The reactive half of the refresh contract (slice `S-D17`): an [`rest::HttpBackend`] that, +/// on a `401`, refreshes the session once and replays the request exactly once. +/// +/// # Why the transport seam and not [`rest::Middleware`] +/// +/// A middleware receives a `Next`, and `Next::run` takes `self` by value while `Next` is +/// neither `Clone` nor constructible outside the generated runtime. A middleware therefore +/// *cannot* send a second time, which is the one thing this layer must do. The backend seam +/// has no such constraint, and spargen's own `RetryBackend` is the precedent — including the +/// `Request::try_clone()`-returns-`None` rule for one-shot bodies. +/// +/// # Exactly once, by construction +/// +/// The replay is straight-line code, not a loop with a counter: one send, one refresh, one +/// replay, and whatever the replay answers is returned as-is. A second `401` is surfaced. +struct RefreshOn401 { + inner: Arc, + session: Session, +} + +// `Session` is not `Debug` (it holds token material), and `HttpBackend` requires `Debug` so the +// generated `ClientCore` stays printable. The manual impl names the layer and the backend under +// it, and shows nothing of the session. +impl std::fmt::Debug for RefreshOn401 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RefreshOn401") + .field("inner", &self.inner) + .finish_non_exhaustive() + } +} + +impl rest::HttpBackend for RefreshOn401 { + fn execute(&self, request: reqwest::Request) -> rest::ExecuteFuture<'_> { + // Own clones of the `Arc`/`Session` so the returned future is self-contained, matching + // the seam's expectations (and `RetryBackend`'s shape). + let inner = self.inner.clone(); + let session = self.session.clone(); + Box::pin(async move { + // A one-shot streaming body cannot be resent intact. Execute the original once and + // return: half a body on the wire twice is worse than a `401` the caller can see. + let Some(mut replay) = request.try_clone() else { + tracing::debug!("request body is not replayable; a 401 will not be retried"); + return inner.execute(request).await; + }; + + let response = inner.execute(request).await?; + if response.status() != reqwest::StatusCode::UNAUTHORIZED { + return Ok(response); + } + + // An operation carrying no bearer has nothing to refresh — that `401` is the + // server's answer about the request, not about a stale token. + let Some(stale) = bearer_of(&replay) else { + tracing::debug!("a 401 arrived on a request carrying no bearer; not retrying"); + return Ok(response); + }; + + let fresh = match session.refresh_rejected(&stale).await { + Ok(fresh) => fresh, + // Deliberately the *original* `401`, not a synthetic transport error: the + // generated operation then maps it to its typed `Status401` and the caller + // reads the server's own `error.*` code — which matters because an unreadable + // revocation ledger is also rendered as `401`, and only that code separates an + // outage from an expiry. + Err(error) => { + tracing::warn!(%error, "a 401 could not be recovered; surfacing it"); + return Ok(response); + } + }; + let Ok(header) = + HeaderValue::from_str(&format!("{BEARER_PREFIX}{}", fresh.expose_secret())) + else { + tracing::warn!( + "the refreshed token is not a valid header value; surfacing the 401" + ); + return Ok(response); + }; + replay.headers_mut().insert(AUTHORIZATION, header); + tracing::info!("the typed client's 401 was refreshed; replaying once"); + inner.execute(replay).await + }) + } +} + +/// The bearer token a prepared request carries, if any. `None` for an unauthenticated +/// operation, and for any credential shape this layer cannot refresh. +fn bearer_of(request: &reqwest::Request) -> Option { + request + .headers() + .get(AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix(BEARER_PREFIX) + .map(str::to_owned) +} + /// Wire a generated client to `base_url` with a bearer credential that pulls a fresh access -/// token from `session` on demand (pre-flight refresh + single-flight live in the session). +/// token from `session` on demand (the proactive half), executing through [`RefreshOn401`] +/// (the reactive half). fn build_client(base_url: &str, session: Session) -> Result { + let provider_session = session.clone(); let provider: rest::TokenProvider = Arc::new(move || { - let session = session.clone(); + let session = provider_session.clone(); // The session yields a currently-valid bearer, refreshing pre-flight if the stored // token is within its refresh skew of expiry; the failure is mapped into spargen's // provider-error type so a dead session is a request-construction error, not a 401. @@ -117,7 +241,14 @@ fn build_client(base_url: &str, session: Session) -> Result }) }); - let client = Client::with_client(reqwest_client(), base_url) + // `with_backend` builds requests on a default `reqwest::Client` and *executes* them + // through the backend, so the executing client — and with it the TLS stack, the redirect + // policy and the timeouts — is still `reqwest_client()`, one layer down. + let backend: Arc = Arc::new(RefreshOn401 { + inner: Arc::new(rest::ReqwestBackend::new(reqwest_client())), + session, + }); + let client = Client::with_backend(backend, base_url) .map_err(|e| ClientError::InvalidBaseUrl { url: base_url.to_string(), reason: e.to_string(), @@ -126,12 +257,33 @@ fn build_client(base_url: &str, session: Session) -> Result Ok(client) } -/// The generated client's transport: rustls only (the SDK's `reqwest` has no default features -/// and only `rustls-tls`), matching the rest of the SDK's network stack. +/// The generated client's transport: the SDK's one HTTP client +/// ([`crate::net::http_client`]) — rustls only, carrying the protocol handshake on every request +/// it sends, the generated operations included. +/// +/// **One per process, shared.** A `reqwest::Client` owns a connection pool, and cloning it +/// shares that pool; building a new one throws the pool away. The FFI's escrow verbs construct +/// a fresh [`AuthenticatedClient`] per call (the API root is a per-call argument), so a +/// per-client transport would mean a fresh TLS handshake for every escrow read on a device +/// that does several during one cadence prompt. Nothing here is configured per instance — +/// `http_client` takes no arguments and its protocol headers are the same on every request — +/// so there is nothing to vary: the same client serves them all. +/// +/// **What that does not fix.** `Client::with_backend` still builds its *own* default +/// `reqwest::Client` internally, one per [`AuthenticatedClient`]. That one only assembles +/// requests — every byte is executed through the backend below, and therefore through this +/// shared client — so it opens no connection and costs nothing on the wire; what it costs is +/// one throwaway allocation per construction. Removing even that needs a +/// `with_client_and_backend` constructor spargen does not expose, which is generator work of +/// exactly the same kind as the `application/cbor` gap, and lands where that lands. fn reqwest_client() -> reqwest::Client { - reqwest::Client::builder() - .build() - .expect("a default rustls reqwest client is always constructible") + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED + .get_or_init(|| { + crate::net::http_client() + .expect("a default rustls reqwest client is always constructible") + }) + .clone() } #[cfg(test)] @@ -154,6 +306,8 @@ mod tests { struct Recorded { path: String, authorization: Option, + protocol: Option, + crypto_suite: Option, } struct MockResponse { @@ -248,6 +402,8 @@ mod tests { requests.lock().unwrap().push(Recorded { path: path.clone(), authorization: headers.get("authorization").cloned(), + protocol: headers.get("x-capsule-protocol").cloned(), + crypto_suite: headers.get("x-capsule-crypto-suite").cloned(), }); let response = handler(path).await; @@ -320,6 +476,46 @@ mod tests { assert_eq!(version.version.as_str(), "9.9.9"); } + /// Every request the typed client sends carries the protocol handshake (issue #404) — + /// proving the transport-level default reaches the wire through the generated operation + /// with no argument at the call site, on an operation the server does not even gate. + #[tokio::test] + async fn every_request_carries_the_protocol_handshake() { + let handler: Handler = Arc::new(|_| { + Box::pin(async move { + MockResponse { + status: 200, + body: r#"{"name":"capsule-api","version":"9.9.9"}"#.to_string(), + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + client.get_version().await.unwrap(); + + let requests = server.requests.lock().unwrap(); + let version = requests + .iter() + .find(|r| r.path == "/v1/version") + .expect("version endpoint was hit"); + assert_eq!( + version.protocol.as_deref(), + Some(capsule_core::crypto::primitives::PROTOCOL_VERSION), + "the protocol date this build speaks must ride every request" + ); + assert_eq!( + version.crypto_suite.as_deref(), + Some( + capsule_core::crypto::primitives::CRYPTO_SUITE_ID + .to_string() + .as_str() + ), + "and so must the suite it seals under" + ); + } + /// An authenticated operation carries the session's access token as a bearer header — /// proving the token-provider seam attaches the credential the schema's `security` /// requirement names. @@ -343,7 +539,11 @@ mod tests { let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); - let quota = client.get_quota().await.unwrap().into_inner(); + let quota = client + .get_quota(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .unwrap() + .into_inner(); assert_eq!(quota.used, 0); let requests = server.requests.lock().unwrap(); @@ -358,6 +558,252 @@ mod tests { ); } + /// An RFC 9457 problem body shaped as the generated `CodedProblem`, so a documented + /// non-success status parses into the operation's typed error rather than a decode + /// failure. + fn problem(status: u16, code: &str) -> String { + serde_json::json!({ + "type": "about:blank", + "title": "Unauthorized", + "status": status, + "detail": "the access token was refused", + "code": code, + }) + .to_string() + } + + /// How many requests the mock saw for `path`. + fn hits(server: &MockServer, path: &str) -> usize { + server + .requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path == path) + .count() + } + + /// The bearer each request for `path` carried, in order. + fn bearers(server: &MockServer, path: &str) -> Vec> { + server + .requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path == path) + .map(|r| r.authorization.clone()) + .collect() + } + + // ── S-D17: the reactive half ──────────────────────────────────────────────────────── + + /// **The slice's Done-when.** A token that is valid as far as the *client* can tell and + /// refused by the server — the race the pre-flight check cannot close — is refreshed once + /// and the call replayed once, and the replay carries the rotated token. + #[tokio::test] + async fn a_401_is_refreshed_once_and_the_call_replayed() { + let seen = Arc::new(AtomicUsize::new(0)); + let quota_calls = seen.clone(); + let handler: Handler = Arc::new(move |path| { + let quota_calls = quota_calls.clone(); + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + // The first attempt is refused; the replay is honoured. A server that + // revoked the session mid-flight looks exactly like this. + "/v1/quota" => { + if quota_calls.fetch_add(1, Ordering::SeqCst) == 0 { + MockResponse { + status: 401, + body: problem( + 401, + capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED, + ), + } + } else { + MockResponse { + status: 200, + body: r#"{"state":"ok","used":5}"#.to_string(), + } + } + } + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + // Far-future expiry: the pre-flight check is satisfied, so the *only* thing that can + // rescue this call is the reactive layer. + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let quota = client + .get_quota(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .unwrap() + .into_inner(); + assert_eq!( + quota.used, 5, + "the replayed call is the one the caller sees" + ); + + assert_eq!(hits(&server, "/refresh"), 1, "exactly one refresh"); + assert_eq!( + hits(&server, "/v1/quota"), + 2, + "one attempt, one replay — no loop" + ); + assert_eq!( + bearers(&server, "/v1/quota"), + vec![ + Some("Bearer access-1".to_string()), + Some("Bearer access-2".to_string()), + ], + "the replay must carry the rotated token, not the one the server just refused" + ); + } + + /// A server that refuses every credential is answered with exactly one replay, and the + /// second `401` reaches the caller as the operation's typed error carrying the server's + /// own `error.*` code. `exactly once` is the property: not twice, not a loop. + #[tokio::test] + async fn a_persistent_401_is_surfaced_after_exactly_one_replay() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + "/v1/quota" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED), + }, + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let error = client + .get_quota(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .expect_err("a credential the server never honours must fail"); + let rest::Error::Api(response) = &error else { + panic!("expected the operation's typed API error, got {error:?}"); + }; + let rest::GetQuotaError::Status401(problem) = response.inner() else { + panic!("expected a typed 401, got {:?}", response.inner()); + }; + assert_eq!( + problem.code, + capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED + ); + + assert_eq!(hits(&server, "/refresh"), 1); + assert_eq!( + hits(&server, "/v1/quota"), + 2, + "exactly one replay — a retry loop would keep going" + ); + } + + /// When the refresh itself fails, the caller gets the **server's** `401` back rather than + /// a synthesized transport error — so the typed `Status401` mapping still fires and the + /// `error.*` code survives. That code is the only thing separating an expired token from + /// an unreadable revocation ledger, which the server also renders as `401`. + #[tokio::test] + async fn a_401_whose_refresh_fails_keeps_the_servers_own_401() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + // The refresh token is gone too: nothing here can be rescued. + "/refresh" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::AUTH_SESSION_EXPIRED), + }, + "/v1/quota" => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::AUTH_UNAVAILABLE), + }, + _ => MockResponse { + status: 404, + body: "{}".to_string(), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + let error = client + .get_quota(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .expect_err("nothing can rescue this"); + let rest::Error::Api(response) = &error else { + panic!("a failed refresh must not mask the 401 as a transport error: {error:?}"); + }; + let rest::GetQuotaError::Status401(problem) = response.inner() else { + panic!("expected a typed 401, got {:?}", response.inner()); + }; + assert_eq!( + problem.code, + capsule_i18n::error_codes::AUTH_UNAVAILABLE, + "the code the caller reads is the one the *operation* answered, not the refresh's" + ); + assert_eq!( + hits(&server, "/v1/quota"), + 1, + "a refresh that failed produces nothing worth replaying" + ); + } + + /// An unauthenticated operation's `401` is the server's answer about the request, not + /// about a stale token: there is no bearer to refresh, so nothing is refreshed and + /// nothing is replayed. + #[tokio::test] + async fn an_unauthenticated_401_is_never_retried() { + let handler: Handler = Arc::new(|path| { + Box::pin(async move { + match path.as_str() { + "/refresh" => MockResponse { + status: 200, + body: token_json("access-2", "refresh-2", far_future()), + }, + _ => MockResponse { + status: 401, + body: problem(401, capsule_i18n::error_codes::REQUEST_UNAUTHENTICATED), + }, + } + }) + }); + let server = start_mock(handler).await; + let session = session_with(&server.base_url, "access-1", "refresh-1", far_future()); + let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); + + // `get_version` declares no security requirement, so the generated client attaches no + // credential at all. + client + .get_version() + .await + .expect_err("the mock refuses everything"); + assert_eq!(hits(&server, "/v1/version"), 1, "one request, no replay"); + assert_eq!(hits(&server, "/refresh"), 0, "and no refresh"); + assert_eq!(bearers(&server, "/v1/version"), vec![None]); + } + /// The session's pre-flight refresh fires *through the revived client*: with a stored /// access token already past expiry, the first typed call refreshes once (via the token /// provider), and the API request carries the rotated token — not the stale one. @@ -398,7 +844,11 @@ mod tests { ); let client = AuthenticatedClient::new(&server.base_url, session).unwrap(); - let quota = client.get_quota().await.unwrap().into_inner(); + let quota = client + .get_quota(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .unwrap() + .into_inner(); assert_eq!(quota.used, 7); assert_eq!( diff --git a/capsule-sdk/src/federation.rs b/capsule-sdk/src/federation.rs new file mode 100644 index 00000000..3edd416c --- /dev/null +++ b/capsule-sdk/src/federation.rs @@ -0,0 +1,537 @@ +//! [`FederationPull`] — a peer server pulling one shared album from its home server (`S-E2`). +//! +//! # There is no federation protocol to speak +//! +//! design/federation.md introduces **no new data protocol**: a peer pulls through exactly the +//! primitives a client pulls through, `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, with a +//! capability token in the `Authorization: Bearer` slot instead of a session access token. So +//! this module is *orchestration over generated calls* and contains no parser: the page comes +//! back through [`SyncConsumer::pull_album`], the bytes through the generated `get_blob` fed +//! into the same self-verifying [`RangedFetcher`](crate::fetch::RangedFetcher) every download +//! uses, and the lifecycle calls are the generated `refresh_capability` and `revoked_jti`. +//! +//! # The pull is gated on a fresh revocation list, and fails closed +//! +//! A capability is a bearer token: the only thing that can take one back before it expires is +//! the home server's published list at `/.well-known/capsule/revoked-jti`. So a puller does not +//! present a token it has not recently checked. [`FederationPull`] refreshes its snapshot when +//! the one it holds is older than the list's own `max_staleness_seconds`, refuses to pull when +//! the snapshot cannot be refreshed past that bound ([`FederationError::ListStale`]), and +//! refuses immediately when the token's `jti` is on the list +//! ([`FederationError::Revoked`]) — the same fail-closed rule the server-side verifier applies. +//! +//! Nothing here caches a decision it could re-derive: the snapshot is the list as published, +//! and the answer is recomputed from it on every call. +//! +//! # Refresh replaces the credential in place +//! +//! `POST /v1/federation/capabilities/refresh` presents the *previous* capability and answers the +//! successor; the predecessor is revoked as the successor is issued, so a puller that kept using +//! it would refuse itself on the next poll. [`FederationPull::refresh`] therefore swaps the held +//! token, its `jti` and both clients over together, under one lock. + +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use tracing::instrument; + +use crate::fetch::{BlobSource, RangeOutcome}; +use crate::sync::{SyncConsumer, SyncCursor, SyncError, SyncPage}; +use crate::{net, rest}; + +/// The scheme key the generated client attaches a bearer under. +/// +/// The same one a session token rides: the server registers one `bearer` component for both +/// token types, which is what keeps a capability presentable through the generated client at all. +const BEARER_SCHEME: &str = "bearer"; + +/// Why a federated pull could not proceed. +#[derive(Debug, thiserror::Error)] +pub enum FederationError { + /// The capability's `jti` is on the home server's published revocation list. + /// + /// Terminal for this token. The peer asks the album's owner for a fresh grant, or stops. + #[error("this capability has been revoked by its issuer")] + Revoked, + /// The revocation list could not be refreshed inside its own staleness bound. + /// + /// **Not** a reason to keep pulling: a list a peer cannot refresh is a list that may have + /// revoked this token, and honouring the token anyway is exactly the failure the bound + /// exists to prevent. + #[error("the revocation list is staler than its own bound allows")] + ListStale, + /// The home server refused, or could not be reached. + #[error("the home server answered {code}: {detail}")] + Refused { + /// The stable `error.*` code, where the answer carried one. + code: String, + /// The server's own description. + detail: String, + }, + /// The transport failed, or a URL could not be built. + #[error("the pull could not be transported: {0}")] + Transport(String), + /// The feed answered, and the page did not survive validation. + #[error(transparent)] + Feed(#[from] SyncError), +} + +/// The revocation list as the home server last published it. +#[derive(Debug, Clone)] +pub struct RevocationSnapshot { + /// Every `jti` the issuer currently refuses. + pub revoked: Vec, + /// How long the issuer says a copy of this list may be relied on. + pub max_staleness: Duration, + /// When this peer fetched it, on the local monotonic clock. + /// + /// The *local* clock and not the list's `generated_at`: a peer deciding freshness from a + /// timestamp the issuer wrote would be trusting the party whose revocations it is checking + /// to be honest about their age. + fetched_at: Instant, +} + +impl RevocationSnapshot { + /// Whether this copy is still inside the issuer's own bound at `now`. + #[must_use] + pub fn is_fresh(&self, now: Instant) -> bool { + now.duration_since(self.fetched_at) <= self.max_staleness + } + + /// Whether the issuer currently refuses `jti`. + #[must_use] + pub fn refuses(&self, jti: &str) -> bool { + self.revoked.iter().any(|revoked| revoked == jti) + } +} + +/// What the puller currently holds: the credential, and the clients built over it. +struct Held { + token: String, + jti: String, + sync: SyncConsumer, + blobs: CapabilityBlobSource, + client: rest::Client, +} + +/// A peer server pulling one album it holds a capability for. +pub struct FederationPull { + base_url: String, + album_id: String, + held: RwLock, + snapshot: RwLock>, +} + +impl FederationPull { + /// A puller against `base_url`, presenting `token` for `album_id`. + /// + /// `jti` is the token's own identifier as the home server minted it — the key the revocation + /// list is checked against. It is passed in rather than parsed out of the token, because + /// this module holds no JWT parser and a client that read its own credential's claims would + /// be trusting a value it never verified. + /// + /// # Errors + /// + /// [`FederationError::Transport`] when `base_url` is not a URL a client can hang paths off. + pub fn new( + base_url: &str, + album_id: impl Into, + token: impl Into, + jti: impl Into, + ) -> Result { + let token = token.into(); + let jti = jti.into(); + Ok(Self { + base_url: base_url.to_owned(), + album_id: album_id.into(), + held: RwLock::new(Held::build(base_url, token, jti)?), + snapshot: RwLock::new(None), + }) + } + + /// The `jti` of the capability currently held. + #[must_use] + pub fn jti(&self) -> String { + read(&self.held).jti.clone() + } + + /// The token currently held, for a caller that persists it across restarts. + #[must_use] + pub fn token(&self) -> String { + read(&self.held).token.clone() + } + + /// Fetch the issuer's revocation list and keep it as this puller's snapshot. + /// + /// # Errors + /// + /// [`FederationError::Refused`] or [`FederationError::Transport`]; the snapshot is left + /// as it was, and the next [`Self::admit`] will refuse once the old one goes stale. + #[instrument(skip(self))] + pub async fn poll_revocations(&self) -> Result { + let client = read(&self.held).client.clone(); + let list = client + .revoked_jti() + .await + .map_err(|error| { + // The list declares one coded refusal, so the generated error is a newtype + // over the problem rather than an enum of statuses. + let code = match &error { + rest::Error::Api(response) => Some(response.inner().0.code.clone()), + _ => None, + }; + refusal("the revocation list", code, &error.to_string()) + })? + .into_inner(); + // Fail **closed** on a bound this client cannot read: a negative or absurd + // `max_staleness_seconds` means the snapshot is stale the instant it is taken, so the + // next `admit` re-polls rather than honouring the list forever. `u64::MAX` here would + // have read as fail-open the day the schema widened. + let max_staleness = + Duration::from_secs(u64::try_from(list.max_staleness_seconds).unwrap_or(0)); + let snapshot = RevocationSnapshot { + revoked: list + .revoked + .into_iter() + .map(|token| token.jti.clone()) + .collect(), + max_staleness, + fetched_at: Instant::now(), + }; + tracing::debug!( + revoked = snapshot.revoked.len(), + max_staleness = ?snapshot.max_staleness, + "refreshed the issuer's revocation list" + ); + *write(&self.snapshot) = Some(snapshot.clone()); + Ok(snapshot) + } + + /// Refuse unless the held capability is admissible right now. + /// + /// Refreshes the snapshot when the held one is past the issuer's own bound. Every pull goes + /// through here, so a revoked grant stops the pull rather than being discovered one refusal + /// at a time. + /// + /// # Errors + /// + /// [`FederationError::Revoked`] when the issuer refuses this `jti`; + /// [`FederationError::ListStale`] when the list could not be refreshed inside its bound. + pub async fn admit(&self) -> Result<(), FederationError> { + let fresh = read(&self.snapshot) + .as_ref() + .filter(|snapshot| snapshot.is_fresh(Instant::now())) + .cloned(); + let snapshot = match fresh { + Some(snapshot) => snapshot, + None => self.poll_revocations().await.map_err(|error| { + tracing::warn!(%error, "the revocation list could not be refreshed; refusing to pull"); + FederationError::ListStale + })?, + }; + if snapshot.refuses(&read(&self.held).jti) { + tracing::info!("the issuer has revoked this capability; the pull stops"); + return Err(FederationError::Revoked); + } + Ok(()) + } + + /// Pull one page of the album this capability covers. + /// + /// # Errors + /// + /// As [`Self::admit`], plus whatever the feed answered. + #[instrument(skip(self, cursor), fields(album = %self.album_id))] + pub async fn page( + &self, + cursor: &SyncCursor, + page_size: u32, + ) -> Result { + self.admit().await?; + let sync = read(&self.held).sync.clone(); + Ok(sync.pull_album(cursor, page_size, &self.album_id).await?) + } + + /// Fetch one blob the page named, verified against its own address. + /// + /// The bytes are self-verifying: [`crate::fetch::fetch_blob`] hashes what arrives and + /// refuses anything that is not the address asked for, so a home server cannot substitute + /// content for a peer any more than it can for its own client. + /// + /// # Errors + /// + /// As [`Self::admit`], plus [`FederationError::Refused`] carrying the fetch's own reason — + /// `error.federation.scope_insufficient` for a blob outside the grant's scope among them. + #[instrument(skip(self), fields(album = %self.album_id))] + pub async fn blob(&self, hash: &str, expected_len: u64) -> Result, FederationError> { + self.admit().await?; + let blobs = read(&self.held).blobs.clone(); + crate::fetch::fetch_blob(&blobs, hash, expected_len) + .await + .map_err(|error| FederationError::Refused { + // The stable code the server sent, not a prose rendering of it: the doc above + // promises `error.federation.scope_insufficient` here, and a caller that had to + // string-match a message to find it would be matching on a message. + code: error.error_code().unwrap_or_default().to_owned(), + detail: error.to_string(), + }) + } + + /// Exchange the held capability for its successor, and pull with that from now on. + /// + /// Idempotent at the server: a replay of the same predecessor answers the same successor, + /// so a puller that crashed between the call and persisting the answer gets the same token + /// back rather than a second grant. + /// + /// # Errors + /// + /// [`FederationError::Refused`] when the issuer will not continue the grant — a revoked + /// predecessor, a blocked peer, a spent budget — or [`FederationError::Transport`]. + #[instrument(skip(self))] + pub async fn refresh(&self) -> Result { + let client = read(&self.held).client.clone(); + let refreshed = client + .refresh_capability(capsule_core::crypto::primitives::PROTOCOL_VERSION, None) + .await + .map_err(|error| match error { + rest::Error::Api(response) => match response.into_inner() { + rest::RefreshCapabilityError::Status403(problem) + | rest::RefreshCapabilityError::Status429(problem) + | rest::RefreshCapabilityError::Status400(problem) + | rest::RefreshCapabilityError::Status500(problem) => { + FederationError::Refused { + code: problem.code.clone(), + detail: problem.detail.clone().unwrap_or_default(), + } + } + other => FederationError::Refused { + code: String::new(), + detail: other.to_string(), + }, + }, + other => FederationError::Transport(other.to_string()), + })? + .into_inner(); + + let held = Held::build( + &self.base_url, + refreshed.token.clone(), + refreshed.jti.clone(), + )?; + // The predecessor is revoked the moment the successor is issued, so the swap has to be + // one act: a puller holding one client on the old token and another on the new would + // refuse itself on whichever request lost the race. + *write(&self.held) = held; + // The list a moment ago did not carry the predecessor; it does now. Dropped rather than + // patched, so the next pull re-reads it from the issuer. + *write(&self.snapshot) = None; + tracing::info!(jti = %refreshed.jti, replayed = refreshed.replayed, "refreshed the capability"); + Ok(refreshed.token) + } +} + +impl std::fmt::Debug for FederationPull { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never the token: a `Debug` that printed a live credential is how one reaches a log. + formatter + .debug_struct("FederationPull") + .field("base_url", &self.base_url) + .field("album_id", &self.album_id) + .field("jti", &read(&self.held).jti) + .finish_non_exhaustive() + } +} + +impl Held { + fn build(base_url: &str, token: String, jti: String) -> Result { + Ok(Self { + sync: SyncConsumer::with_static_token(base_url, token.clone()) + .map_err(|error| FederationError::Transport(error.to_string()))?, + blobs: CapabilityBlobSource::new(base_url, token.clone())?, + client: client_for(base_url, &token)?, + token, + jti, + }) + } +} + +/// A generated client for `base_url` carrying `token` under the bearer scheme. +fn client_for(base_url: &str, token: &str) -> Result { + let http = net::http_client().map_err(|error| FederationError::Transport(error.to_string()))?; + Ok(rest::Client::with_client(http, base_url) + .map_err(|error| FederationError::Transport(error.to_string()))? + .with_credential( + BEARER_SCHEME, + rest::Credential::Bearer(token.to_owned().into()), + )) +} + +/// A refusal from the home server, carrying the stable code when the answer had one. +fn refusal(doing: &str, code: Option, detail: &str) -> FederationError { + tracing::info!(%doing, ?code, %detail, "the home server refused a federated call"); + FederationError::Refused { + code: code.unwrap_or_default(), + detail: detail.to_owned(), + } +} + +/// Read a lock, recovering from a poisoned one. +fn read(lock: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { + lock.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Write a lock, recovering from a poisoned one. +fn write(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { + lock.write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// A [`BlobSource`] over the **generated** `get_blob`, presenting a capability. +/// +/// Not [`HttpBlobSource`](crate::fetch::HttpBlobSource): that one is raw `reqwest` over an +/// `S-D7` session, and a peer has no session. Everything that parses or serializes here is +/// generated — the `Range` parameter, the byte body and every declared refusal — and what is +/// hand-written is the mapping from a status to the fetcher's own outcome. +#[derive(Clone)] +pub struct CapabilityBlobSource { + client: Arc, +} + +impl CapabilityBlobSource { + /// A source against `base_url`, presenting `token`. + /// + /// # Errors + /// + /// [`FederationError::Transport`] when `base_url` is not a URL a client can hang paths off. + pub fn new(base_url: &str, token: impl Into) -> Result { + Ok(Self { + client: Arc::new(client_for(base_url, &token.into())?), + }) + } +} + +impl std::fmt::Debug for CapabilityBlobSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CapabilityBlobSource") + } +} + +impl BlobSource for CapabilityBlobSource { + async fn get_range(&self, hash: &str, start: u64, max_len: Option) -> RangeOutcome { + // A zero-length window would be malformed; the fetcher never asks for one when bytes + // remain, so it is read as the open-ended remainder. + let range = match max_len { + Some(len) if len > 0 => format!("bytes={start}-{}", start + len - 1), + _ => format!("bytes={start}-"), + }; + let params = rest::GetBlobParams { + range: Some(range), + ..rest::GetBlobParams::default() + }; + match self + .client + .get_blob( + hash.to_owned(), + capsule_core::crypto::primitives::PROTOCOL_VERSION, + params, + ) + .await + { + Ok(response) => { + let bytes = match response.into_inner() { + rest::GetBlobResponse::Status200(bytes) + | rest::GetBlobResponse::Status206(bytes) => bytes, + }; + RangeOutcome::Complete { + bytes: bytes.to_vec(), + } + } + Err(rest::Error::Api(response)) => { + let (status, code) = describe(response.into_inner()); + RangeOutcome::Status { status, code } + } + Err(error) => { + tracing::debug!(%error, "a federated blob range request failed in transport"); + RangeOutcome::Status { + status: 0, + code: None, + } + } + } + } +} + +/// The status and the stable code a declared blob refusal carries. +/// +/// Exhaustive over the generated enum on purpose: a status the contract adds later is a compile +/// error here rather than a silent `0` the fetcher would read as a transport failure. +fn describe(error: rest::GetBlobError) -> (u16, Option) { + let coded = + |status: u16, problem: Box| (status, Some(problem.code.clone())); + match error { + rest::GetBlobError::Status304 => (304, None), + rest::GetBlobError::Status400(problem) => coded(400, problem), + rest::GetBlobError::Status401(problem) => coded(401, problem), + rest::GetBlobError::Status403(problem) => coded(403, problem), + rest::GetBlobError::Status404(problem) => coded(404, problem), + rest::GetBlobError::Status409(problem) => coded(409, problem), + rest::GetBlobError::Status410(problem) => coded(410, problem), + rest::GetBlobError::Status413 => (413, None), + rest::GetBlobError::Status429(problem) => coded(429, problem), + rest::GetBlobError::Status500(problem) => coded(500, problem), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(revoked: &[&str], max_staleness: Duration) -> RevocationSnapshot { + RevocationSnapshot { + revoked: revoked.iter().map(|jti| (*jti).to_owned()).collect(), + max_staleness, + fetched_at: Instant::now(), + } + } + + #[test] + fn a_snapshot_refuses_the_jtis_it_carries_and_no_others() { + let held = snapshot(&["one", "two"], Duration::from_mins(15)); + assert!(held.refuses("one")); + assert!(held.refuses("two")); + assert!(!held.refuses("three")); + } + + #[test] + fn a_snapshot_is_fresh_only_inside_the_issuers_own_bound() { + // The bound is the issuer's, carried on the list itself, and it is measured on the + // peer's own clock — the party being checked does not get to say how old its list is. + let held = snapshot(&[], Duration::from_mins(15)); + assert!(held.is_fresh(held.fetched_at)); + assert!(held.is_fresh(held.fetched_at + Duration::from_mins(15))); + assert!(!held.is_fresh(held.fetched_at + Duration::from_mins(15) + Duration::from_secs(1))); + + // A bound of zero is a list that is stale the instant after it is read, which is what a + // server publishing `max_staleness_seconds: 0` is asking for. + let strict = snapshot(&[], Duration::ZERO); + assert!(!strict.is_fresh(strict.fetched_at + Duration::from_millis(1))); + } + + #[test] + fn the_debug_rendering_never_carries_the_token() { + let pull = FederationPull::new( + "https://home.test/v1", + "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60", + "a.very.secret.token", + "01937b7c-0000-7000-8000-0000000000aa", + ) + .expect("the base url is a url"); + let rendered = format!("{pull:?}"); + assert!(!rendered.contains("a.very.secret.token"), "{rendered}"); + assert!( + rendered.contains("01937b7c-0000-7000-8000-0000000000aa"), + "{rendered}" + ); + } +} diff --git a/capsule-sdk/src/fetch.rs b/capsule-sdk/src/fetch.rs index 941fa50e..d47d8968 100644 --- a/capsule-sdk/src/fetch.rs +++ b/capsule-sdk/src/fetch.rs @@ -223,8 +223,17 @@ pub enum FetchError { Gone, /// `403` — an authorization change, not a durability loss. Re-sync membership /// then retry; only then degrade (the asset may have been unshared). + /// + /// Carries the stable code, because the `403`s on this route no longer say one thing: an + /// account's `error.blob.access_revoked` means re-sync membership, while a federated + /// peer's `error.federation.scope_insufficient` means the grant never covered this blob and + /// re-syncing anything will not change that. A caller that could not tell them apart would + /// retry the second forever. #[error("blob authorization changed")] - AuthorizationChanged, + AuthorizationChanged { + /// Stable `error.*` code, when the refusal carried one. + code: Option, + }, /// `error.blob.pending_upload` — the original has not landed yet /// (`awaiting-original`); show the badge, never a failure, and re-fetch when /// the feed flips `original_held`. Explicitly distinct from `410 Gone`. @@ -259,7 +268,7 @@ impl FetchError { pub fn error_code(&self) -> Option<&str> { match self { Self::PendingUpload => Some(error_codes::BLOB_PENDING_UPLOAD), - Self::Rejected { code, .. } => code.as_deref(), + Self::AuthorizationChanged { code } | Self::Rejected { code, .. } => code.as_deref(), _ => None, } } @@ -278,7 +287,7 @@ fn classify_status(status: u16, code: Option) -> FetchError { return FetchError::PendingUpload; } match status { - 403 => FetchError::AuthorizationChanged, + 403 => FetchError::AuthorizationChanged { code }, 404 | 410 => FetchError::Gone, 0 => FetchError::Transient("transport error".to_string()), s if (500..600).contains(&s) => FetchError::Transient(format!("server status {s}")), @@ -554,7 +563,7 @@ where representation: desired, bytes, }, - Err(FetchError::AuthorizationChanged) => { + Err(FetchError::AuthorizationChanged { .. }) => { tracing::info!("403 on fetch — re-syncing album membership before retrying"); on_authorization_change().await; match fetch_representation(source, asset, desired).await { @@ -673,7 +682,7 @@ impl BlobSource for HttpBlobSource { } /// SHA-256 of the ciphertext as bare lowercase hex — byte-identical to the -/// server's content address (`capsule_core::utils::hash::hash_bytes`). +/// server's content address (`capsule_core::crypto::hash::hash_bytes`). fn hash_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); diff --git a/capsule-sdk/src/ffi.rs b/capsule-sdk/src/ffi.rs index a3cd9a63..95a6b821 100644 --- a/capsule-sdk/src/ffi.rs +++ b/capsule-sdk/src/ffi.rs @@ -57,13 +57,23 @@ pub use workspace::{ FfiWorkspace, }; +/// The local-alert surface (`S-D29`): the shared `capsule_core::notify` predicate, flattened. +/// Separated from both blocks above because it touches neither a transport nor a workspace — +/// it is a pure function of caller-supplied device state, and the two free exports say so. +mod notify; + +pub use notify::{ + FfiAlert, FfiAlertClass, FfiAlertSeverity, FfiClassDeadline, FfiNotifyInput, FfiQuotaAdvisory, + evaluate_alerts, next_alert_deadline, pre_arm_deadlines, +}; + // ─── Errors ────────────────────────────────────────────────────────────────── /// Every failure the FFI flows can surface, flattened by originating layer. Each /// variant carries the stable `error.*` catalog `code` (when one applies — clients /// localize it) and the English detail `message` (stays English), mirroring the /// SDK's `{ error, code }` contract so foreign apps switch on the code, never a -/// bare HTTP/gRPC status. +/// bare HTTP status. #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum FfiError { /// An authentication flow (login/register/refresh/logout) failed. @@ -99,8 +109,12 @@ pub enum FfiError { message: String, }, /// A master-key escrow flow (store/fetch) failed. - #[error("escrow failed: {message}")] + #[error("escrow failed ({code:?}): {message}")] Escrow { + /// Stable `error.*` catalog code, when the server supplied one — `error.escrow.*` + /// separates "you have no recovery backup" (a setup prompt) from "we could not read + /// it" (a retry), which is the whole reason the catalog distinguishes them. + code: Option, /// English detail (developer/log message). message: String, }, @@ -131,12 +145,25 @@ impl From for FfiError { impl From for FfiError { fn from(err: RecoveryError) -> Self { - // An auth failure under an escrow call keeps its auth identity so callers can trigger - // interactive re-authentication, exactly as the upload mapping does. - if let RecoveryError::Auth(auth) = err { - return auth.into(); + // A refused credential under an escrow call keeps its auth identity so callers can + // trigger interactive re-authentication, exactly as the upload mapping does. Before + // the escrow calls moved onto the generated client this arrived as + // `RecoveryError::Auth`; routing `Unauthorized` here is what stops the move from + // silently downgrading "sign in again" into "escrow failed". + if let RecoveryError::Unauthorized { code, detail } = err { + return Self::Auth { + code, + message: detail, + }; + } + // A malformed argument is the caller's, not the escrow surface's. + if let RecoveryError::InvalidBaseUrl { .. } = err { + return Self::InvalidArgument { + message: err.to_string(), + }; } Self::Escrow { + code: err.error_code().map(str::to_owned), message: err.to_string(), } } @@ -758,7 +785,7 @@ impl FfiSession { Ok(page.into()) } - /// Store or replace this account's **master-key escrow blob** (`PUT /backup/escrow`). + /// Store or replace this account's **master-key escrow blob** (`PUT /v1/auth/escrow`). /// `blob` is the opaque canonical CBOR /// [`FfiWorkspace::escrow_blob`](FfiWorkspace::escrow_blob) minted — the master key itself /// never crosses this boundary in either direction. @@ -767,26 +794,35 @@ impl FfiSession { /// secret a rotation retired unwraps nothing. /// /// `api_base_url` is the API root the session authenticates against (the per-call endpoint - /// convention this surface already uses for `sync_pull`). + /// convention this surface already uses for `sync_pull`). A URL operation paths cannot hang + /// off is [`FfiError::InvalidArgument`]; a refused credential is [`FfiError::Auth`], so a + /// caller re-authenticates rather than retrying; everything else is + /// [`FfiError::Escrow`] with the server's `error.escrow.*` code when it sent one. pub async fn escrow_put(&self, api_base_url: String, blob: Vec) -> Result<(), FfiError> { let blob = capsule_core::cbor::from_slice(&blob).map_err(|e| FfiError::InvalidArgument { message: format!("escrow blob is not a canonical WrappedSecret: {e}"), })?; - RecoveryClient::new(self.session.clone(), &api_base_url) + RecoveryClient::new(self.session.clone(), &api_base_url)? .store_escrow(&blob) .await?; Ok(()) } - /// Fetch this account's escrow blob (`GET /backup/escrow`) as opaque canonical CBOR — the + /// Fetch this account's escrow blob (`GET /v1/auth/escrow`) as opaque canonical CBOR — the /// bytes [`FfiWorkspace::verify_escrow_blob`](FfiWorkspace::verify_escrow_blob) checks and - /// a recovery flow unwraps. Fails with an `Escrow` error when no escrow is enrolled yet. + /// a recovery flow unwraps. + /// + /// No escrow enrolled yet is [`FfiError::Escrow`] carrying `error.escrow.not_stored` — the + /// code that separates "set up a recovery key" from "we could not read the one you have". + /// A refused credential is [`FfiError::Auth`]. pub async fn escrow_get(&self, api_base_url: String) -> Result, FfiError> { - let cache = RecoveryClient::new(self.session.clone(), &api_base_url) + let cache = RecoveryClient::new(self.session.clone(), &api_base_url)? .fetch_escrow() .await?; capsule_core::cbor::to_canonical_vec(cache.blob()).map_err(|e| FfiError::Escrow { + // A local encode failure is ours, not the server's: no catalog code applies. + code: None, message: format!("encoding the fetched escrow blob failed: {e}"), }) } diff --git a/capsule-sdk/src/ffi/notify.rs b/capsule-sdk/src/ffi/notify.rs new file mode 100644 index 00000000..cf060d86 --- /dev/null +++ b/capsule-sdk/src/ffi/notify.rs @@ -0,0 +1,674 @@ +//! The local-alert surface across the FFI boundary (slice `S-D29`, core half): the flattened +//! mirror of [`capsule_core::notify`] plus the two functions the apps call. +//! +//! # Why free functions +//! +//! [`evaluate_alerts`], [`pre_arm_deadlines`] and [`next_alert_deadline`] are free +//! `#[uniffi::export]` functions rather +//! than methods on +//! [`FfiWorkspace`](crate::ffi::FfiWorkspace). The workspace holds none of the predicate's +//! inputs — there is no persisted last-sync instant, no client-side quota type, and no +//! quarantine table — so a method would take the same [`FfiNotifyInput`] and then lock a mutex +//! it never reads. The predicate is a pure function of caller-supplied state, and the boundary +//! says so. +//! +//! # Shape +//! +//! [`FfiNotifyInput`] is **flat**: uniffi records nest, but a foreign caller assembling five +//! optional sub-records to ask one question is worse than a struct whose fields are each +//! independently `None`. Presence is explicit — `last_completed_sync` present means the sync +//! facts are known, `recovery_next_due` present means recovery is set up, `quota_state` present +//! means a quota response has been seen. +//! +//! Timestamps cross as **RFC 3339 strings**, following the `changed_at` precedent on +//! [`FfiSyncApplyOutcome`](crate::ffi::FfiSyncApplyOutcome): Kotlin and Swift each have their own +//! instant type and no shared integer convention worth guessing at. A string that does not parse +//! is [`FfiError::InvalidArgument`], never a panic. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use capsule_core::notify::{ + self, Alert, AlertClass, AlertSeverity, NotifyInput, QuotaAdvisory, QuotaFacts, RecoveryFacts, + SyncFacts, +}; +use jiff::Timestamp; + +use super::FfiError; + +/// Every alert class, flattened for the bindings. A closed enum on both sides of the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiAlertClass { + /// Two weeks without a completed sync while changes remain un-synced. + SyncStale, + /// A recovery-secret verification check is due. + RecoveryCheckDue, + /// Storage crossed the soft limit and is below the hard limit. + QuotaSoft, + /// Storage is over the hard limit — the grace window is counting, or has closed. + QuotaGraceExpiring, + /// Items are sitting on a quarantine surface awaiting a human. + QuarantinePending, + /// Guest drops are awaiting review and adoption. + DropPending, +} + +impl From for FfiAlertClass { + fn from(class: AlertClass) -> Self { + match class { + AlertClass::SyncStale => Self::SyncStale, + AlertClass::RecoveryCheckDue => Self::RecoveryCheckDue, + AlertClass::QuotaSoft => Self::QuotaSoft, + AlertClass::QuotaGraceExpiring => Self::QuotaGraceExpiring, + AlertClass::QuarantinePending => Self::QuarantinePending, + AlertClass::DropPending => Self::DropPending, + } + } +} + +/// How prominently a client presents an alert. **Neither variant gates anything** — no alert +/// blocks sync, unlock, upload, or any critical flow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiAlertSeverity { + /// Informational: nothing is failing, and nothing is about to. + Advisory, + /// Something is already degraded or is being refused. + Warning, +} + +impl From for FfiAlertSeverity { + fn from(severity: AlertSeverity) -> Self { + match severity { + AlertSeverity::Advisory => Self::Advisory, + AlertSeverity::Warning => Self::Warning, + } + } +} + +/// The quota state, as `GET /v1/quota` reports it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum FfiQuotaAdvisory { + /// Below the soft limit. All uploads succeed normally. + Ok, + /// At or above the soft limit and below the hard limit. Uploads succeed; the client warns. + SoftWarning, + /// At or above the hard limit. New uploads are rejected; the grace window is counting. + HardExceeded, + /// Over the hard limit for longer than the grace window; metadata-growth writes are + /// refused too. + GraceExpired, + /// An admin or billing action rather than a threshold. Raises no quota alert. + Suspended, +} + +impl From for QuotaAdvisory { + fn from(state: FfiQuotaAdvisory) -> Self { + match state { + FfiQuotaAdvisory::Ok => Self::Ok, + FfiQuotaAdvisory::SoftWarning => Self::SoftWarning, + FfiQuotaAdvisory::HardExceeded => Self::HardExceeded, + FfiQuotaAdvisory::GraceExpired => Self::GraceExpired, + FfiQuotaAdvisory::Suspended => Self::Suspended, + } + } +} + +/// One alert that is true at an instant. +/// +/// Never a localized string: `class` selects the app's own `notification.*` catalog key and +/// `params` are interpolated into it, so a server can neither supply nor influence the words a +/// user reads. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct FfiAlert { + /// Which alert this is. + pub class: FfiAlertClass, + /// How prominently to present it. + pub severity: FfiAlertSeverity, + /// The instant whose passing made this alert true (RFC 3339), for the two pre-armable + /// classes; `None` for the three whose condition is server-held. + pub deadline: Option, + /// Catalog parameters — `count`, `days_behind`, `grace`, and the pair + /// `recovery_check_due` always carries: `snooze_budget` (`available` / `spent`) and + /// `recovery` (`check` / `rewrap`). + pub params: HashMap, +} + +impl From for FfiAlert { + fn from(alert: Alert) -> Self { + Self { + class: alert.class.into(), + severity: alert.severity.into(), + deadline: alert.deadline.map(|d| d.to_string()), + params: alert.params.into_iter().collect(), + } + } +} + +/// One class and the instant its local notification should be armed for. +/// +/// A class absent from [`pre_arm_deadlines`]'s result has no timer to hold: cancel whatever it +/// has. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct FfiClassDeadline { + /// The class whose timer this is — also the `notification.*` catalog key the app renders + /// when it fires. + pub class: FfiAlertClass, + /// When to fire it (RFC 3339). + pub deadline: String, +} + +/// The device-held state the predicate decides from, flattened for the bindings. +/// +/// Counts and instants only — no album id, no title, no asset id. An all-default value (every +/// `Option` `None`, every count `0`, an empty map) is the "just installed, learned nothing" +/// input, and yields no alerts and no deadline. +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct FfiNotifyInput { + /// When the last **completed** sync finished (RFC 3339). `None` on a device that has never + /// completed one — which raises no `sync_stale`, because the alert is about a *stale* sync + /// and not a missing one. When `None`, `unsynced_changes` is ignored. + #[uniffi(default = None)] + pub last_completed_sync: Option, + /// Changes still waiting to reach the server, including originals still pending under a + /// staged upload policy. + #[uniffi(default = 0)] + pub unsynced_changes: u64, + /// When the next recovery-verification prompt becomes due (RFC 3339). Project it from the + /// scheduler with + /// [`RecoveryCadence::notify_facts`](crate::recovery::RecoveryCadence::notify_facts) rather + /// than computing it here. `None` before recovery is set up, which ignores the other three + /// `recovery_*` fields. + #[uniffi(default = None)] + pub recovery_next_due: Option, + /// When an active snooze on the recovery prompt expires (RFC 3339), if one is active. A + /// snooze ending *before* `recovery_next_due` does not make the check due earlier. This is + /// the canonical place to snooze the recovery check — not + /// [`suppressed_until`](Self::suppressed_until), which is for the other five classes. + #[uniffi(default = None)] + pub recovery_snoozed_until: Option, + /// Whether the consecutive-snooze budget is spent — the class has degraded to a persistent, + /// non-blocking badge: still reported, no longer pre-armed. + #[uniffi(default = false)] + pub recovery_snooze_budget_spent: bool, + /// Whether the scheduler has escalated to the guided re-wrap (repeated failures, or the user + /// declaring the secret lost). Surfaces as the alert's `recovery` parameter, so the app + /// routes into the re-wrap flow instead of rendering a routine verification prompt. + #[uniffi(default = false)] + pub recovery_rewrap_due: bool, + /// The state from the last `GET /v1/quota`. `None` before the first one. + #[uniffi(default = None)] + pub quota_state: Option, + /// How many items sit on the client's quarantine surfaces awaiting a human. **Excludes + /// pending drops** — they have their own class, so counting them here raises both. + #[uniffi(default = 0)] + pub quarantine_pending: u64, + /// How many guest drops are awaiting review and adoption. + #[uniffi(default = 0)] + pub drops_pending: u64, + /// Per-class **snooze**: class wire name (`sync_stale`, …) to the RFC 3339 instant the + /// snooze runs until, exclusive. A class snoozed past `now` reports nothing, and its alarm + /// is **deferred to the snooze end** rather than cancelled — a class snoozed after it fired + /// must fire again when the snooze expires. Use [`disabled`](Self::disabled) to turn a class + /// off; do not encode that as a far-future instant here. An unrecognized class name is an + /// [`FfiError::InvalidArgument`]. + /// + /// **This map is for the other five classes.** Recovery snoozing belongs in + /// [`recovery_snoozed_until`](Self::recovery_snoozed_until), which the cadence scheduler + /// owns together with the budget that bounds it. An entry here for `recovery_check_due` is + /// honoured as a fallback (the later of the two wins) but is not the canonical channel. + #[uniffi(default)] + pub suppressed_until: HashMap, + /// Per-class **disable**: the wire names of the classes the user turned off. They report + /// nothing and hold no alarm, at any instant. Disabling suppresses the warning and never + /// the behavior. An unrecognized class name is an [`FfiError::InvalidArgument`]. + #[uniffi(default)] + pub disabled: Vec, +} + +impl FfiNotifyInput { + /// Parse the foreign record into the core input, rejecting every malformed field rather + /// than defaulting past it: a mistyped instant that silently became "never" would suppress + /// an alert forever, which is exactly the failure this alert surface exists to prevent. + fn parse(self) -> Result { + let sync = self + .last_completed_sync + .map(|raw| { + Ok::<_, FfiError>(SyncFacts { + last_completed_sync: parse_instant(&raw, "last_completed_sync")?, + unsynced_changes: self.unsynced_changes, + }) + }) + .transpose()?; + + // Parsed unconditionally, and *before* the `recovery_next_due` branch: a malformed + // snooze instant is a malformed field whether or not the due date happens to be present, + // and a validation that only runs on one code path is the one that lets a typo through. + let snoozed_until = self + .recovery_snoozed_until + .as_deref() + .map(|raw| parse_instant(raw, "recovery_snoozed_until")) + .transpose()?; + let recovery = self + .recovery_next_due + .map(|raw| { + Ok::<_, FfiError>(RecoveryFacts { + next_due: parse_instant(&raw, "recovery_next_due")?, + snoozed_until, + snooze_budget_spent: self.recovery_snooze_budget_spent, + rewrap_due: self.recovery_rewrap_due, + }) + }) + .transpose()?; + + let mut suppressed = BTreeMap::new(); + for (name, raw) in self.suppressed_until { + suppressed.insert( + parse_class(&name, "suppressed_until")?, + parse_instant(&raw, "suppressed_until")?, + ); + } + let mut disabled = BTreeSet::new(); + for name in self.disabled { + disabled.insert(parse_class(&name, "disabled")?); + } + + Ok(NotifyInput { + sync, + recovery, + quota: self.quota_state.map(|state| QuotaFacts { + state: state.into(), + }), + quarantine_pending: self.quarantine_pending, + drops_pending: self.drops_pending, + suppressed, + disabled, + }) + } +} + +/// Parse one alert-class wire name against the closed enum, naming the field it came from. +fn parse_class(name: &str, field: &str) -> Result { + AlertClass::from_wire(name).ok_or_else(|| FfiError::InvalidArgument { + message: format!("{field}: `{name}` is not an alert class"), + }) +} + +/// Parse one RFC 3339 instant, naming the field so a foreign caller can find its own bug. +fn parse_instant(raw: &str, field: &str) -> Result { + raw.parse::() + .map_err(|err| FfiError::InvalidArgument { + message: format!("{field}: `{raw}` is not an RFC 3339 timestamp: {err}"), + }) +} + +/// Every alert that is true at `now`, in delivery order. +/// +/// Pure and offline: no network call, no library open, no clock read — `now` is the caller's, so +/// the same input always gives the same answer and an app can drive it from a test clock. +/// +/// # Errors +/// +/// [`FfiError::InvalidArgument`] if `now` or any instant in `input` is not RFC 3339, or if +/// `suppressed_until` names something that is not an alert class. +#[uniffi::export] +pub fn evaluate_alerts(input: FfiNotifyInput, now: String) -> Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::evaluate(&input.parse()?, now) + .into_iter() + .map(FfiAlert::from) + .collect()) +} + +/// The instant to arm a local notification for, **per class** — the call an app schedules its +/// `UNCalendarNotificationTrigger` / `AlarmManager` alarms from. +/// +/// Reconcile your timers against the result: a class present here should hold exactly one alarm +/// at the returned instant, and a class absent from it should hold none. Recompute after **any** +/// state change; that is the whole of the arm / re-arm / cancel rule on the client side. +/// +/// Only the two classes whose deadline a device can compute alone ever appear. The other three +/// depend on server state and surface at next app launch — a real gap the design accepts. +/// +/// # Errors +/// +/// As [`evaluate_alerts`]. +#[uniffi::export] +pub fn pre_arm_deadlines( + input: FfiNotifyInput, + now: String, +) -> Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::pre_arm_deadlines(&input.parse()?, now) + .into_iter() + .map(|(class, deadline)| FfiClassDeadline { + class: class.into(), + deadline: deadline.to_string(), + }) + .collect()) +} + +/// The earliest instant in [`pre_arm_deadlines`] (RFC 3339), or `None` when there is nothing to +/// arm. +/// +/// For a host that can hold only one timer. **An app that schedules per class wants +/// [`pre_arm_deadlines`]**: the minimum discards the later class's alarm, and on a device the app +/// never runs on again that alert is simply lost. +/// +/// # Errors +/// +/// As [`evaluate_alerts`]. +#[uniffi::export] +pub fn next_alert_deadline(input: FfiNotifyInput, now: String) -> Result, FfiError> { + let now = parse_instant(&now, "now")?; + Ok(notify::next_deadline(&input.parse()?, now).map(|deadline| deadline.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed base instant, and the two-week threshold expressed against it. + const BASE: &str = "2023-11-14T22:13:20Z"; + const BASE_PLUS_14D: &str = "2023-11-28T22:13:20Z"; + + fn stale_input() -> FfiNotifyInput { + FfiNotifyInput { + last_completed_sync: Some(BASE.to_owned()), + unsynced_changes: 3, + ..FfiNotifyInput::default() + } + } + + /// The whole round trip: a foreign record in, alert classes and an RFC 3339 deadline out. + #[test] + fn evaluate_alerts_round_trips_the_boundary() { + let alerts = evaluate_alerts(stale_input(), BASE_PLUS_14D.to_owned()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::SyncStale); + assert_eq!(alerts[0].severity, FfiAlertSeverity::Warning); + assert_eq!(alerts[0].deadline.as_deref(), Some(BASE_PLUS_14D)); + assert_eq!(alerts[0].params["count"], "3"); + assert_eq!(alerts[0].params["days_behind"], "14"); + } + + /// One second before the threshold nothing fires, and the deadline to arm is the threshold. + #[test] + fn next_alert_deadline_arms_the_threshold() { + let before = "2023-11-28T22:13:19Z".to_owned(); + assert!( + evaluate_alerts(stale_input(), before.clone()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(stale_input(), before) + .unwrap() + .as_deref(), + Some(BASE_PLUS_14D) + ); + // Once it has fired there is nothing left to schedule. + assert_eq!( + next_alert_deadline(stale_input(), BASE_PLUS_14D.to_owned()).unwrap(), + None + ); + } + + /// The default record is the "learned nothing" input on both functions. + #[test] + fn default_input_is_silent_across_the_boundary() { + assert!( + evaluate_alerts(FfiNotifyInput::default(), BASE.to_owned()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(FfiNotifyInput::default(), BASE.to_owned()).unwrap(), + None + ); + assert!( + pre_arm_deadlines(FfiNotifyInput::default(), BASE.to_owned()) + .unwrap() + .is_empty() + ); + } + + /// Quota and count classes cross with their parameters and without a deadline. + #[test] + fn quota_and_count_classes_cross_the_boundary() { + let input = FfiNotifyInput { + quota_state: Some(FfiQuotaAdvisory::GraceExpired), + quarantine_pending: 2, + drops_pending: 1, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, BASE.to_owned()).unwrap(); + let classes: Vec<_> = alerts.iter().map(|a| a.class).collect(); + assert_eq!( + classes, + [ + FfiAlertClass::QuotaGraceExpiring, + FfiAlertClass::QuarantinePending, + FfiAlertClass::DropPending, + ] + ); + for alert in &alerts { + assert_eq!(alert.deadline, None); + } + assert_eq!(alerts[0].params["grace"], "expired"); + assert_eq!(alerts[1].params["count"], "2"); + assert_eq!(alerts[2].params["count"], "1"); + } + + /// A disabled class crosses as a wire name and leaves every answer empty. + #[test] + fn a_disabled_class_crosses_as_a_wire_name() { + let mut input = stale_input(); + input.disabled.push("sync_stale".to_owned()); + assert!( + evaluate_alerts(input.clone(), BASE_PLUS_14D.to_owned()) + .unwrap() + .is_empty() + ); + assert_eq!( + next_alert_deadline(input.clone(), BASE_PLUS_14D.to_owned()).unwrap(), + None + ); + assert!( + pre_arm_deadlines(input, BASE_PLUS_14D.to_owned()) + .unwrap() + .is_empty() + ); + } + + /// Every malformed input is a typed `InvalidArgument`, never a panic and never a default. + #[test] + fn malformed_input_is_invalid_argument() { + let cases: Vec<(FfiNotifyInput, String, &str)> = vec![ + (FfiNotifyInput::default(), "not-a-time".to_owned(), "now"), + ( + FfiNotifyInput { + last_completed_sync: Some("yesterday".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "last_completed_sync", + ), + ( + FfiNotifyInput { + recovery_next_due: Some("soon".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "recovery_next_due", + ), + ( + FfiNotifyInput { + recovery_next_due: Some(BASE.to_owned()), + recovery_snoozed_until: Some("later".to_owned()), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "recovery_snoozed_until", + ), + ( + FfiNotifyInput { + suppressed_until: HashMap::from([( + "telemetry_ready".to_owned(), + BASE.to_owned(), + )]), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "not an alert class", + ), + ( + FfiNotifyInput { + disabled: vec!["telemetry_ready".to_owned()], + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "disabled", + ), + ( + FfiNotifyInput { + suppressed_until: HashMap::from([( + "sync_stale".to_owned(), + "whenever".to_owned(), + )]), + ..FfiNotifyInput::default() + }, + BASE.to_owned(), + "suppressed_until", + ), + ]; + for (input, now, needle) in cases { + let err = evaluate_alerts(input.clone(), now.clone()) + .expect_err("malformed input must not be accepted"); + match &err { + FfiError::InvalidArgument { message } => { + assert!(message.contains(needle), "{message} does not name {needle}"); + } + other => panic!("expected InvalidArgument, got {other:?}"), + } + // The same rejection on the deadline function; neither is the lenient one. + assert!(matches!( + next_alert_deadline(input, now), + Err(FfiError::InvalidArgument { .. }) + )); + } + } + + /// Each class gets its own armed instant. The single-value convenience keeps only the + /// earliest, which is why an app that schedules per class must not use it. + #[test] + fn pre_arm_deadlines_arms_each_class_independently_across_the_boundary() { + let recovery_due = "2024-02-12T22:13:20Z"; // ~90 d after BASE, well after the 14 d mark + let input = FfiNotifyInput { + recovery_next_due: Some(recovery_due.to_owned()), + ..stale_input() + }; + + let armed = pre_arm_deadlines(input.clone(), BASE.to_owned()).unwrap(); + assert_eq!( + armed, + vec![ + FfiClassDeadline { + class: FfiAlertClass::SyncStale, + deadline: BASE_PLUS_14D.to_owned(), + }, + FfiClassDeadline { + class: FfiAlertClass::RecoveryCheckDue, + deadline: recovery_due.to_owned(), + }, + ] + ); + assert_eq!( + next_alert_deadline(input, BASE.to_owned()) + .unwrap() + .as_deref(), + Some(BASE_PLUS_14D), + "the convenience collapses to the earliest and loses the recovery alarm" + ); + } + + /// A snooze defers the alarm to its end; a disable cancels it outright. + #[test] + fn a_snooze_defers_the_alarm_and_a_disable_cancels_it() { + let snooze_end = "2023-12-01T22:13:20Z"; // 3 days after the threshold + let mut snoozed = stale_input(); + snoozed + .suppressed_until + .insert("sync_stale".to_owned(), snooze_end.to_owned()); + let armed = pre_arm_deadlines(snoozed, BASE_PLUS_14D.to_owned()).unwrap(); + assert_eq!(armed.len(), 1); + assert_eq!(armed[0].deadline, snooze_end, "the snooze end is re-armed"); + + let mut disabled = stale_input(); + disabled.disabled.push("sync_stale".to_owned()); + assert!( + pre_arm_deadlines(disabled, BASE.to_owned()) + .unwrap() + .is_empty(), + "a disabled class holds no alarm" + ); + } + + /// A malformed snooze instant is rejected whether or not the due date is present — the + /// leniency a one-code-path validation would have allowed. + #[test] + fn a_malformed_snooze_is_rejected_without_a_due_date() { + let input = FfiNotifyInput { + recovery_next_due: None, + recovery_snoozed_until: Some("later".to_owned()), + ..FfiNotifyInput::default() + }; + let err = evaluate_alerts(input.clone(), BASE.to_owned()) + .expect_err("a malformed field is malformed with or without its neighbour"); + assert!(matches!(err, FfiError::InvalidArgument { .. })); + assert!(matches!( + pre_arm_deadlines(input, BASE.to_owned()), + Err(FfiError::InvalidArgument { .. }) + )); + } + + /// The re-wrap escalation crosses as a parameter, so an app never renders "time for your + /// periodic check" at the moment the user has declared the secret lost. + #[test] + fn the_rewrap_escalation_crosses_the_boundary() { + for (rewrap, expected) in [(false, "check"), (true, "rewrap")] { + let input = FfiNotifyInput { + recovery_next_due: Some(BASE.to_owned()), + recovery_rewrap_due: rewrap, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, BASE.to_owned()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::RecoveryCheckDue); + assert_eq!(alerts[0].params["recovery"], expected); + } + } + + /// The projection from the SDK's own scheduler composes with the exported function, which + /// is the wiring an app actually uses. + #[test] + fn recovery_cadence_projection_composes_with_the_export() { + use crate::recovery::RecoveryCadence; + + let base: Timestamp = BASE.parse().unwrap(); + let cadence = RecoveryCadence::armed_at_setup(base); + let due = cadence.next_due(); + let facts = cadence.notify_facts(due); + + let input = FfiNotifyInput { + recovery_next_due: Some(facts.next_due.to_string()), + recovery_snoozed_until: facts.snoozed_until.map(|t| t.to_string()), + recovery_snooze_budget_spent: facts.snooze_budget_spent, + recovery_rewrap_due: facts.rewrap_due, + ..FfiNotifyInput::default() + }; + let alerts = evaluate_alerts(input, due.to_string()).unwrap(); + assert_eq!(alerts.len(), 1); + assert_eq!(alerts[0].class, FfiAlertClass::RecoveryCheckDue); + assert_eq!(alerts[0].params["snooze_budget"], "available"); + assert_eq!(alerts[0].params["recovery"], "check"); + } +} diff --git a/capsule-sdk/src/ffi/tests.rs b/capsule-sdk/src/ffi/tests.rs index 4cf8612f..b5623a9a 100644 --- a/capsule-sdk/src/ffi/tests.rs +++ b/capsule-sdk/src/ffi/tests.rs @@ -17,10 +17,11 @@ //! publish. Every verb reaches `capsule-core` for its crypto; what is under test here //! is the wiring, the shapes, and the verdicts. //! -//! `sync_pull` itself is gRPC and is exercised by the native harness against the real -//! server; its Rust-side shape is compiled here (the surface builds) but not -//! behaviorally driven — the sync-apply test below feeds `apply_sync_entry` the exact -//! three byte strings a feed entry carries, which is the half `S-P1` owns. +//! `sync_pull` itself rides the generated `GET /v1/sync` operation (`S-D28` retired the gRPC +//! feed) and is exercised over a socket in `capsule-server/tests/sdk_client.rs` against the +//! real router; its Rust-side shape is compiled here (the surface builds) but not behaviorally +//! driven — the sync-apply test below feeds `apply_sync_entry` the exact three byte strings a +//! feed entry carries, which is the half `S-P1` owns. use std::sync::Arc; @@ -294,6 +295,12 @@ fn enroll(root: &std::path::Path) -> Arc { /// The escrow endpoints are **stateful** — a `PUT` stores the bytes verbatim and a `GET` /// serves them back, exactly as the single-active-escrow contract says — so `escrow_put` /// and `escrow_get` can be asserted as a real round trip rather than two isolated calls. +/// +/// They are served on `/v1/auth/escrow` under the API root, which is the path the committed +/// document declares and the generated client therefore requests. The `PUT` answers a JSON +/// `StoreEscrowResponse` and the empty-escrow `GET` answers an RFC 9457 problem, because a +/// generated operation decodes both — a bare `204` or a body-less `404` would arrive as a +/// decode failure rather than as the typed outcome this test is asserting. async fn flow_server() -> MockServer { let escrow: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); MockServer::start( @@ -310,17 +317,36 @@ async fn flow_server() -> MockServer { .to_string(), ), ("PATCH", "/upload/sess-1") => MockResponse::new(200, "OK"), - ("PUT", "/api/backup/escrow") => { - if let Ok(mut stored) = escrow.lock() { + ("PUT", "/api/v1/auth/escrow") => { + let replaced = if let Ok(mut stored) = escrow.lock() { + let replaced = !stored.is_empty(); stored.clone_from(&req.body); - } - MockResponse::new(204, "No Content") + replaced + } else { + false + }; + MockResponse::new(200, "OK").json_body( + serde_json::json!({ + "stored_at": "2026-01-01T00:00:00Z", + "replaced": replaced, + }) + .to_string(), + ) } - ("GET", "/api/backup/escrow") => { + ("GET", "/api/v1/auth/escrow") => { let stored = escrow.lock().map(|s| s.clone()).unwrap_or_default(); if stored.is_empty() { // Nothing enrolled yet — the typed `NotEnrolled` path. - MockResponse::new(404, "Not Found") + MockResponse::new(404, "Not Found").json_body( + serde_json::json!({ + "type": "about:blank", + "title": "Not found", + "status": 404, + "detail": "no escrow has been stored for this account", + "code": "error.escrow.not_stored", + }) + .to_string(), + ) } else { let mut response = MockResponse::new(200, "OK") .header("Content-Type", "application/octet-stream"); @@ -403,12 +429,28 @@ async fn ffi_enroll_album_seal_upload_sync_apply_round_trip() { let blobs = workspace.upload_blobs(asset.clone()).unwrap(); assert_eq!( blobs.iter().map(|b| b.tier.as_str()).collect::>(), - vec!["index", "original"], - "T0 (metadata) precedes T2 (original); no derivatives without a codec" + vec!["index", "index", "original"], + "T0 is two blobs — provenance then metadata, the pair the server needs before it may \ + publish the asset — and precedes T2; no derivatives without a codec" + ); + assert!( + matches!( + ( + &blobs[0].request.blob_role, + &blobs[1].request.blob_role, + &blobs[2].request.blob_role, + ), + ( + FfiBlobRole::Provenance, + FfiBlobRole::Metadata, + FfiBlobRole::Original + ) + ), + "the index tier is provenance then metadata; the original is T2" ); // Keep the wire bytes the feed would carry for this asset before consuming the blobs. - let metadata_blob = blobs[0].bytes.clone(); - let ciphertext = blobs[1].bytes.clone(); + let metadata_blob = blobs[1].bytes.clone(); + let ciphertext = blobs[2].bytes.clone(); for blob in blobs { // Every envelope names *this* blob's content address (the server's invariant-15 // consistency rule) while carrying the head manifest's fields verbatim. @@ -425,7 +467,9 @@ async fn ffi_enroll_album_seal_upload_sync_apply_round_trip() { // 5. Sync-apply: exactly the three byte strings a feed entry carries. let album_bytes = uuid::Uuid::parse_str(&album).unwrap().as_bytes().to_vec(); - let manifest_cbor = workspace.signed_manifest(asset.clone()).unwrap(); + // The feed carries the provenance record, not the bare manifest — the record is what the + // server's chain head hashes and what `apply_sync_entry` decodes. + let manifest_cbor = workspace.provenance_head(asset.clone()).unwrap(); let entry = || FfiSyncEntry { album_id: album_bytes.clone(), manifest_cbor: manifest_cbor.clone(), @@ -489,7 +533,7 @@ async fn ffi_sync_apply_quarantines_a_tampered_entry() { let outcome = workspace .apply_sync_entry(FfiSyncEntry { album_id: uuid::Uuid::parse_str(&album).unwrap().as_bytes().to_vec(), - manifest_cbor: workspace.signed_manifest(asset).unwrap(), + manifest_cbor: workspace.provenance_head(asset).unwrap(), metadata_blob: blobs[0].bytes.clone(), original_ciphertext: ciphertext, local_chain_head: None, @@ -691,9 +735,9 @@ async fn ffi_p256_hardware_signer_constructor_reaches_the_same_flow() { let outcome = workspace .apply_sync_entry(FfiSyncEntry { album_id: uuid::Uuid::parse_str(&album).unwrap().as_bytes().to_vec(), - manifest_cbor: workspace.signed_manifest(asset).unwrap(), - metadata_blob: blobs[0].bytes.clone(), - original_ciphertext: blobs[1].bytes.clone(), + manifest_cbor: workspace.provenance_head(asset).unwrap(), + metadata_blob: blobs[1].bytes.clone(), + original_ciphertext: blobs[2].bytes.clone(), local_chain_head: None, }) .unwrap(); @@ -752,7 +796,8 @@ fn ffi_workspace_surfaces_errors_instead_of_panicking() { // An unknown asset is a typed workspace error. let missing = uuid::Uuid::now_v7().to_string(); assert!(workspace.read_plaintext(missing.clone()).is_err()); - assert!(workspace.signed_manifest(missing).is_err()); + assert!(workspace.signed_manifest(missing.clone()).is_err()); + assert!(workspace.provenance_head(missing).is_err()); // A short chain head is refused before any verification runs. match workspace.apply_sync_entry(FfiSyncEntry { album_id: uuid::Uuid::now_v7().as_bytes().to_vec(), diff --git a/capsule-sdk/src/ffi/workspace.rs b/capsule-sdk/src/ffi/workspace.rs index 7dff7384..d336b5dc 100644 --- a/capsule-sdk/src/ffi/workspace.rs +++ b/capsule-sdk/src/ffi/workspace.rs @@ -35,14 +35,15 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use capsule_core::crypto::keys::hardware::{HardwareSigner, HardwareSignerError}; -use capsule_core::crypto::keys::{P256HybridSigningKey, Signer}; +use capsule_core::crypto::keys::{ + HardwareSigner, HardwareSignerError, P256HybridSigningKey, Signer, +}; use capsule_core::crypto::primitives::DeviceTier; use capsule_core::crypto::verify_asset::VerifyOutcome; use capsule_core::lifecycle::{ QuarantineReason, RemoteAssetFacts, RemoteEntry, SyncApplyOutcome, Workspace, }; -use capsule_core::sidecar::sidecar_v1::SidecarV1; +use capsule_core::sidecar::SidecarV1; use uuid::Uuid; use super::{FfiError, FfiUploadRequest}; @@ -463,8 +464,8 @@ fn uuid_from_bytes(field: &str, bytes: &[u8]) -> Result { /// The ladder tier's stable lowercase name (`index` / `preview` / `original`) — the same three /// rungs the download-sync tier ladder names, so an app's progress UI can key on one vocabulary /// in both directions. -fn tier_name(tier: capsule_core::import::upload::UploadTier) -> String { - use capsule_core::import::upload::UploadTier; +fn tier_name(tier: capsule_core::import::UploadTier) -> String { + use capsule_core::import::UploadTier; match tier { UploadTier::Index => "index", UploadTier::Preview => "preview", @@ -643,6 +644,11 @@ impl FfiWorkspace { /// and the bytes. Feed each straight to /// [`FfiSession::upload`](crate::ffi::FfiSession::upload). /// + /// The index tier (`T0`) is **two** blobs — the provenance record then the sealed metadata + /// blob — because the server publishes an asset to other devices only once it holds both + /// roles. Push all of them: an app that stopped after the metadata blob would upload an + /// asset no device, its own included, could ever see. + /// /// The original's ciphertext is re-derived from the manifest's recorded nonce prefix and /// gated on the manifest's own content address, so what reaches the network is what the /// signed manifest vouches for. @@ -683,12 +689,40 @@ impl FfiWorkspace { self.with(|ws| Ok(ws.asset_ids().iter().map(Uuid::to_string).collect())) } - /// The asset's **head signed manifest** as opaque canonical CBOR — the exact document a - /// receiving device runs through [`apply_sync_entry`](Self::apply_sync_entry). + /// The asset's **head provenance record** as opaque canonical CBOR — the exact bytes the + /// `provenance` blob carries, the feed serves back as `manifest_cbor`, and a receiving + /// device runs through [`apply_sync_entry`](Self::apply_sync_entry). + /// + /// The record wraps the head signed manifest with its chain position. That wrapper is not + /// optional: the server's chain head is the SHA-256 of these bytes while a client's next + /// `prior_provenance_hash` is the record's own hash, so the bare manifest is the one + /// encoding under which no lifecycle op can ever chain. Canonical CBOR is deterministic, so + /// the manifest inside is byte-identical to its own signed bytes — nothing is re-authored + /// and the two signatures still verify over exactly what they covered. + /// + /// Use [`signed_manifest`](Self::signed_manifest) when the *manifest* is what is wanted. + pub fn provenance_head(&self, asset_id: String) -> Result, FfiError> { + let asset_id = parse_uuid("asset_id", &asset_id)?; + self.with(|ws| { + Ok(ws + .upload_bundle(&asset_id) + .map_err(|e| FfiError::Workspace { + message: format!("reading the provenance head of {asset_id}: {e}"), + })? + .provenance_blob) + }) + } + + /// The asset's **head signed manifest** as opaque canonical CBOR. /// /// This is a serialization of an already-signed structure, not a re-authoring: the two /// signatures the manifest carries are covered by these bytes, which is why they travel /// verbatim and are never re-modeled on any wire. + /// + /// **Not what the sync feed carries** — that is + /// [`provenance_head`](Self::provenance_head), the record that wraps this manifest with its + /// chain position. Handing these bytes to + /// [`apply_sync_entry`](Self::apply_sync_entry) is refused as malformed. pub fn signed_manifest(&self, asset_id: String) -> Result, FfiError> { let asset_id = parse_uuid("asset_id", &asset_id)?; self.with(|ws| { diff --git a/capsule-sdk/src/lib.rs b/capsule-sdk/src/lib.rs index be738cd2..2bbb60a6 100644 --- a/capsule-sdk/src/lib.rs +++ b/capsule-sdk/src/lib.rs @@ -3,21 +3,31 @@ //! //! # REST client generation (spargen; slice `S-D8` in the repo-root `SLICES.md`) //! -//! The typed REST client ([`rest`]) is generated from the server's **OpenAPI 3.1** schema by +//! The typed REST client ([`rest`]) is generated from the server's **OpenAPI 3.2** schema by //! `spargen`, our in-house generator, at build time (see `build.rs`). The previous progenitor //! pipeline is gone deliberately: progenitor consumes OpenAPI 3.0 only, which forced a lossy -//! 3.1→3.0 schema down-conversion — a standing source of drift and failures. We do not -//! downgrade schemas. [`client::AuthenticatedClient`] wraps the generated `Client`, composing -//! it with [`auth`]'s session/token store so callers issue typed calls and never juggle raw -//! tokens. The hand-written upload/sync surfaces ([`upload`], [`sync`]) stay hand-written — -//! their protocols are too stateful for request/response codegen; the generated client covers -//! the plain request/response surfaces (auth, quota, storage-verify, receipts, devices, …). +//! down-conversion of the document the server actually emits — a standing source of drift and +//! failures. We do not downgrade schemas. [`client::AuthenticatedClient`] wraps the generated +//! `Client`, composing it with [`auth`]'s session/token store so callers issue typed calls and +//! never juggle raw tokens. +//! +//! The generated client covers the plain request/response surfaces (auth, quota, +//! storage-verify, receipts, devices, escrow, the sync feed, …). What stays hand-written is +//! *orchestration*, never a second parser: +//! +//! - [`upload`] — the resumable upload state machine, whose protocol is too stateful for +//! request/response codegen; +//! - [`sync`] — the cursor and anti-rewind state machine **over** the generated `GET /v1/sync` +//! operation (`S-D28` retired the gRPC feed; the wire here is generated like every other); +//! - [`directory`], [`upgrade`], and [`verify`]'s receipt fetch — the four `application/cbor` +//! operations `spargen` 0.4 cannot lower, narrowed out in `build.rs`. pub mod albums; pub mod auth; pub mod client; pub mod cohort; pub mod directory; +pub mod federation; pub mod fetch; pub mod net; pub mod peering; @@ -25,6 +35,11 @@ pub mod push; pub mod recovery; pub mod staged; pub mod sync; +/// The album-upgrade proposal client (`S-C24`). Hand-written for one reason only: its request +/// body is `application/cbor`, which `spargen` 0.4 cannot lower, so `build.rs` narrows the +/// operation out of the generated client. The `GET` and `DELETE` on the same path are JSON and +/// *are* generated — reach them through [`client::AuthenticatedClient`]. +pub mod upgrade; pub mod upload; pub mod verify; @@ -32,14 +47,14 @@ pub mod verify; #[cfg(test)] mod testmock; -/// The typed REST client generated by `spargen` from the server's committed OpenAPI **3.1** +/// The typed REST client generated by `spargen` from the server's committed OpenAPI **3.2** /// schema (`openapi.json`), emitted into `OUT_DIR` by `build.rs` and included verbatim here /// (slice `S-D8`). One `async` method per operation, typed models, typed errors, and an /// embedded freestanding `reqwest` runtime — `spargen` never enters the runtime dependency -/// tree. Do not edit: regenerate the schema with `mise run openapi`; the client re-generates -/// on every build. The ergonomic, session-composed entry point is -/// [`client::AuthenticatedClient`]; reach for [`rest::Client`] directly only for -/// unauthenticated calls. +/// tree. Do not edit: regenerate the schema with `mise run openapi-kynos` (and +/// `mise run openapi-check-kynos` gates it); the client re-generates on every build. The +/// ergonomic, session-composed entry point is [`client::AuthenticatedClient`]; reach for +/// [`rest::Client`] directly only for unauthenticated calls. pub mod rest { #![allow( clippy::all, diff --git a/capsule-sdk/src/net.rs b/capsule-sdk/src/net.rs index 1abeb525..8f7589ff 100644 --- a/capsule-sdk/src/net.rs +++ b/capsule-sdk/src/net.rs @@ -16,7 +16,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; -use capsule_core::import::upload::UploadTier; +use capsule_core::import::UploadTier; use tracing::instrument; /// The closed connection-class enum, evaluated continuously on-device. @@ -721,9 +721,59 @@ pub const DIAL_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// (which would double server load): there is no per-request fan-out anywhere in /// the SDK; a request rides exactly one dialed connection. pub fn dial_client() -> reqwest::Result { - reqwest::Client::builder() - .connect_timeout(DIAL_CONNECT_TIMEOUT) - .build() + http_builder().connect_timeout(DIAL_CONNECT_TIMEOUT).build() +} + +// ─── The one HTTP client ───────────────────────────────────────────────────── + +/// The request half of the protocol handshake, as default headers for a `reqwest` client. +/// +/// Every route the server gates requires `X-Capsule-Protocol` and refuses without it +/// (`capsule-server/src/negotiation.rs`, issue #404), and `X-Capsule-Crypto-Suite` names the +/// suite this build seals under. Both are constants of the build, so they belong on the +/// transport once rather than on every call: a `reqwest` default header rides every request +/// the client sends, the spargen-generated operations included. A header set explicitly on a +/// request still wins, which is how the hand-written upload path keeps pinning a per-transport +/// protocol date. +/// +/// `X-Capsule-Sidecar-Schema` is deliberately absent: the design scopes it to metadata updates, +/// and a schema number is a property of one write rather than of the transport. +#[must_use] +pub fn protocol_headers() -> reqwest::header::HeaderMap { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + + let mut headers = HeaderMap::with_capacity(2); + headers.insert( + HeaderName::from_static("x-capsule-protocol"), + HeaderValue::from_static(capsule_core::crypto::primitives::PROTOCOL_VERSION), + ); + headers.insert( + HeaderName::from_static("x-capsule-crypto-suite"), + HeaderValue::from(capsule_core::crypto::primitives::CRYPTO_SUITE_ID), + ); + headers +} + +/// The builder every SDK transport starts from: rustls only (the SDK's `reqwest` has no +/// default features and only `rustls-tls`) and the protocol handshake as default headers. +/// +/// One builder rather than one client because [`dial_client`] adds a connect timeout on top +/// and the auth, sync and typed clients do not; what they share is the handshake, and this is +/// the one place it is installed. A transport built any other way sends no handshake and is +/// refused by every gated route, which is why nothing in this crate calls +/// `reqwest::Client::builder()` directly outside tests. +pub fn http_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder().default_headers(protocol_headers()) +} + +/// The plain SDK client: [`http_builder`], built. +/// +/// # Errors +/// +/// Whatever `reqwest` refuses to build with — in practice nothing, since the builder carries +/// no configuration a platform can lack. +pub fn http_client() -> reqwest::Result { + http_builder().build() } #[cfg(test)] @@ -1069,4 +1119,32 @@ mod tests { fn dial_client_builds() { assert!(dial_client().is_ok()); } + + /// Every SDK transport carries the two build constants the server's gate reads. + #[test] + fn the_handshake_headers_are_the_build_constants() { + let headers = protocol_headers(); + assert_eq!( + headers + .get("x-capsule-protocol") + .and_then(|v| v.to_str().ok()), + Some(capsule_core::crypto::primitives::PROTOCOL_VERSION) + ); + assert_eq!( + headers + .get("x-capsule-crypto-suite") + .and_then(|v| v.to_str().ok()), + Some( + capsule_core::crypto::primitives::CRYPTO_SUITE_ID + .to_string() + .as_str() + ) + ); + assert_eq!( + headers.len(), + 2, + "the sidecar schema is a property of one write" + ); + assert!(http_client().is_ok()); + } } diff --git a/capsule-sdk/src/push.rs b/capsule-sdk/src/push.rs index 2b74e93c..05a55b0e 100644 --- a/capsule-sdk/src/push.rs +++ b/capsule-sdk/src/push.rs @@ -14,6 +14,28 @@ //! returns the authoritative offset; across an asset's blobs, a `duplicate_blob` answer is a //! merge, not an error. Re-running a push against an unchanged library is therefore a no-op. //! +//! # The index tier is two blobs, not one +//! +//! `T0` is **provenance and metadata**, in that order. The server publishes an asset to other +//! devices only once it holds both index-tier roles +//! (`capsule_server::upload::visibility::INDEX_TIER_ROLES`), and the head of the server-side +//! provenance chain — what a later lifecycle op's `prior_provenance_hash` must equal — is the +//! SHA-256 of the provenance blob's bytes. A ladder that shipped only the metadata blob +//! therefore uploaded an asset that no device could ever see and no op could ever chain onto: +//! the bytes were on the server and the asset was not in anybody's library. +//! +//! The provenance blob is the canonical CBOR of the chain's head `ProvenanceRecord` +//! ([`UploadBundle::provenance_blob`]), which `capsule-core` encodes once so the SDK and the +//! FFI cannot disagree about it. It goes **first** within `T0`: it is the blob the server reads +//! `prior_provenance_hash`, `action` and `retention_until` out of, so sending it first is the +//! order in which the asset's own claims arrive before the bytes they describe. +//! +//! **Every blob this module ships is ciphertext.** The original is re-derived from its +//! manifest's nonce prefix, the metadata blob is carried sealed, and **derivative blobs are +//! encrypted too** — `capsule-core` re-derives each one from the plaintext it holds locally +//! using the prefix that derivative's signed manifest recorded. Nothing here decrypts, encrypts, +//! or inspects a blob; it moves opaque bytes. +//! //! **One deviation from "the envelope mirrors the signed manifest", and it is the server's //! rule:** invariant 15 requires `manifest_envelope.ciphertext_hash == hash` (the top-level //! declared content address of *this* blob). A bundle's metadata and derivative blobs are not @@ -22,7 +44,8 @@ use std::collections::HashSet; -use capsule_core::import::upload::UploadTier; +use capsule_core::crypto::hash::hash_bytes; +use capsule_core::import::UploadTier; use capsule_core::lifecycle::UploadBundle; use serde::Serialize; use tracing::instrument; @@ -33,9 +56,9 @@ use crate::upload::{ BlobRole, CreateUploadRequest, ManifestEnvelope, UploadClient, UploadError, UploadOutcome, }; -/// The content type a blob that is opaque ciphertext declares. The sealed metadata blob is -/// AMK ciphertext, not an image — the server's closed content-type enum (invariant 5) admits -/// exactly this for it. +/// The content type a blob the server may not parse declares. The sealed metadata blob is AMK +/// ciphertext and the provenance blob is a signed CBOR document with no media type of its own — +/// the server's closed content-type enum (invariant 5) admits exactly this for both. const OPAQUE_CONTENT_TYPE: &str = "application/octet-stream"; // ─── Blob view over a bundle ────────────────────────────────────────────────── @@ -55,14 +78,30 @@ pub struct BundleBlob<'a> { pub bytes: &'a [u8], } -/// Every transferable blob of `bundle`, in ladder order: the sealed metadata blob (T0, the -/// index tier that makes the asset visible), each derivative (T1), then the original (T2). +/// Every transferable blob of `bundle`, in ladder order: the **index tier** (T0) — the +/// provenance blob then the sealed metadata blob — each derivative (T1), then the original +/// (T2). /// -/// A bundle whose head action binds no metadata blob (`delete`, `trash-restore`, …) simply has -/// no T0 blob — the ladder is whatever the manifest actually commits to, never a fabrication. +/// The provenance blob is unconditional: a managed asset always has a chain head, and the +/// server needs that blob both to publish the asset and to hold a chain head for the next +/// lifecycle op. The metadata blob is conditional — a head action that binds none (`delete`, +/// `trash-restore`, …) simply contributes no metadata rung, because the ladder is whatever the +/// manifest actually commits to and never a fabrication. #[must_use] pub fn bundle_blobs(bundle: &UploadBundle) -> Vec<(BundleBlob<'_>, String)> { - let mut blobs = Vec::with_capacity(2 + bundle.derivatives.len()); + let mut blobs = Vec::with_capacity(3 + bundle.derivatives.len()); + blobs.push(( + BundleBlob { + tier: UploadTier::Index, + role: BlobRole::Provenance, + content_type: OPAQUE_CONTENT_TYPE, + bytes: &bundle.provenance_blob, + }, + // The content address of the provenance blob is `record_hash()` by definition — the + // digest of the canonical record — so it is derived from the bytes rather than read off + // a field. Nothing signs it: it *is* the chain head, and the chain is what signs. + hash_bytes(&bundle.provenance_blob).to_hex(), + )); if let Some(hash) = &bundle.metadata_blob_hash { blobs.push(( BundleBlob { diff --git a/capsule-sdk/src/push/tests.rs b/capsule-sdk/src/push/tests.rs index e2f831b7..9aa5a2ca 100644 --- a/capsule-sdk/src/push/tests.rs +++ b/capsule-sdk/src/push/tests.rs @@ -99,8 +99,9 @@ fn manifest_envelope_mirrors_the_signed_manifest() { } } -/// **The ladder.** A bundle's blobs come out strictly T0 (metadata index) → T1 (derivatives) → -/// T2 (original), and each blob's declared size and role match what it actually carries. +/// **The ladder.** A bundle's blobs come out strictly T0 (the index tier: provenance, then the +/// metadata blob) → T1 (derivatives) → T2 (original), and each blob's declared size and role +/// match what it actually carries. #[test] fn tier_blobs_are_ladder_ordered() { let (_dir, bundle) = real_bundle(); @@ -109,8 +110,8 @@ fn tier_blobs_are_ladder_ordered() { let tiers: Vec<_> = asset.ladder_ordered().iter().map(|b| b.tier).collect(); assert_eq!( tiers, - vec![UploadTier::Index, UploadTier::Original], - "a CLI-shaped import has a metadata index and an original, no derivatives" + vec![UploadTier::Index, UploadTier::Index, UploadTier::Original], + "a CLI-shaped import has a two-blob index tier and an original, no derivatives" ); assert!( tiers.windows(2).all(|w| w[0] <= w[1]), @@ -118,21 +119,65 @@ fn tier_blobs_are_ladder_ordered() { ); let blobs = bundle_blobs(&bundle); - assert_eq!(blobs[0].0.role, BlobRole::Metadata); + assert_eq!( + blobs.iter().map(|(b, _)| b.role).collect::>(), + vec![BlobRole::Provenance, BlobRole::Metadata, BlobRole::Original], + "provenance leads the index tier: it is the blob the server reads the asset's own \ + claims out of" + ); assert_eq!(blobs[0].0.content_type, "application/octet-stream"); - assert_eq!(blobs[0].1, bundle.metadata_blob_hash.unwrap().to_hex()); - assert_eq!(blobs.last().unwrap().0.role, BlobRole::Original); + assert_eq!(blobs[0].0.bytes, bundle.provenance_blob.as_slice()); + assert_eq!(blobs[1].0.content_type, "application/octet-stream"); + assert_eq!(blobs[1].1, bundle.metadata_blob_hash.unwrap().to_hex()); assert_eq!(blobs.last().unwrap().1, bundle.ciphertext_hash.to_hex()); - // Server truth prunes the ladder: a held original leaves only the index outstanding. + // Server truth prunes the ladder: a held original leaves the index tier outstanding. let held: HashSet = [bundle.ciphertext_hash.to_hex()].into_iter().collect(); let remaining = remaining_tiers(&asset, &held); assert_eq!( remaining.blobs.iter().map(|b| b.tier).collect::>(), - vec![UploadTier::Index] + vec![UploadTier::Index, UploadTier::Index] ); } +/// **The rung the ladder used to omit** (issue #464). The provenance blob is uploaded under +/// the `provenance` role, its bytes are the canonical CBOR of the chain head, and its content +/// address is `record_hash()` — the value a later lifecycle op's `prior_provenance_hash` must +/// equal. +/// +/// Without it the server never holds both index-tier roles, so a pushed asset is invisible on +/// the feed to every device including the pusher's, and its row has no chain head for an op to +/// build on. Both are silent: the upload succeeds. +#[test] +fn the_index_tier_carries_the_provenance_rung() { + use capsule_core::crypto::hash::hash_bytes; + use capsule_core::crypto::provenance::ProvenanceRecord; + + let (_dir, bundle) = real_bundle(); + let blobs = bundle_blobs(&bundle); + let (rung, hash) = &blobs[0]; + + assert_eq!(rung.role, BlobRole::Provenance); + assert_eq!(rung.tier, UploadTier::Index); + + let record: ProvenanceRecord = + capsule_core::cbor::from_slice(rung.bytes).expect("the rung is a provenance record"); + assert_eq!(record.asset_id, bundle.asset_id); + assert_eq!( + *hash, + record.record_hash().to_hex(), + "the rung's content address is record_hash(), which is what makes the server's chain \ + head and a client's next prior_provenance_hash the same number" + ); + assert_eq!(hash_bytes(rung.bytes).to_hex(), *hash); + + // The envelope the server checks against invariant 15 names *this* blob. + let request = create_request(&bundle, rung, hash); + assert_eq!(request.hash, request.manifest_envelope.ciphertext_hash); + assert_eq!(request.blob_role, BlobRole::Provenance); + assert_eq!(request.size, bundle.provenance_blob.len() as u64); +} + /// **`duplicate_blob` is a merge, not a failure.** The server answers `409` + /// `error.upload.duplicate_blob` for a blob it already holds; the push resolves it as an /// `AlreadyStored` outcome and carries on with the rest of the ladder. @@ -176,7 +221,7 @@ async fn duplicate_blob_resolves_as_merge_not_error() { let client = server.client(&bundle.protocol_version); let scheduler = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Full, + capsule_core::import::UploadPolicy::Full, ConnectionClass::Unmetered, ); let report = push_bundle(&client, &scheduler, &bundle, &HashSet::new(), false) @@ -185,14 +230,14 @@ async fn duplicate_blob_resolves_as_merge_not_error() { assert_eq!( report.tier_sequence(), - vec![UploadTier::Index, UploadTier::Original], + vec![UploadTier::Index, UploadTier::Index, UploadTier::Original], "the whole ladder still runs" ); assert!(matches!( report.pushed[0].outcome, TierSessionOutcome::Uploaded { .. } )); - match &report.pushed[1].outcome { + match &report.pushed[2].outcome { TierSessionOutcome::AlreadyStored { asset_ref } => { assert_eq!(asset_ref, "asset-77", "the merge carries the existing ref"); } @@ -200,7 +245,7 @@ async fn duplicate_blob_resolves_as_merge_not_error() { panic!("expected AlreadyStored (merge), got {other:?}") } } - assert_eq!(creates.load(Ordering::SeqCst), 2, "one create per blob"); + assert_eq!(creates.load(Ordering::SeqCst), 3, "one create per blob"); } /// **Re-running a push is a no-op.** With every blob in the server-truth `held` set, nothing is @@ -222,7 +267,7 @@ async fn a_fully_held_bundle_pushes_nothing() { .collect(); let client = server.client(&bundle.protocol_version); let scheduler = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Full, + capsule_core::import::UploadPolicy::Full, ConnectionClass::Unmetered, ); @@ -246,7 +291,7 @@ fn a_staged_policy_defers_the_original_on_a_metered_link() { let (_dir, bundle) = real_bundle(); let asset = staged_asset(&bundle); let metered = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Staged, + capsule_core::import::UploadPolicy::Staged, ConnectionClass::Metered, ); assert_eq!( @@ -255,12 +300,13 @@ fn a_staged_policy_defers_the_original_on_a_metered_link() { .iter() .map(|b| b.tier) .collect::>(), - vec![UploadTier::Index], - "a metered link escapes the index only" + vec![UploadTier::Index, UploadTier::Index], + "a metered link escapes the index tier only — both of its blobs, since an asset the \ + server cannot publish is not worth the metered bytes either half costs" ); let unmetered = StagedScheduler::new( - capsule_core::import::upload::UploadPolicy::Staged, + capsule_core::import::UploadPolicy::Staged, ConnectionClass::Unmetered, ); assert_eq!( diff --git a/capsule-sdk/src/recovery/cadence.rs b/capsule-sdk/src/recovery/cadence.rs index 743a71d8..9003f70d 100644 --- a/capsule-sdk/src/recovery/cadence.rs +++ b/capsule-sdk/src/recovery/cadence.rs @@ -17,6 +17,7 @@ //! //! [Backup — Recovery Verification Cadence]: https://docs/design/backup-recovery/#recovery-verification-cadence +use capsule_core::notify::RecoveryFacts; use jiff::Timestamp; use serde::{Deserialize, Serialize}; @@ -270,6 +271,68 @@ impl RecoveryCadence { pub fn declare_lost(&mut self) { self.declared_lost = true; } + + /// Project the scheduler into the flat facts the shared alert predicate consumes + /// ([`capsule_core::notify`], slice `S-D29`). + /// + /// The direction matters: `capsule-sdk` depends on `capsule-core` and never the reverse, so + /// the predicate cannot read this scheduler. It is handed a projection instead, and this is + /// the only place that mapping exists — the ladder, the re-arm triggers, and the snooze + /// accounting stay owned here, and `capsule_core::notify` never recomputes them. + /// + /// It is derived from [`state`](Self::state) rather than from the fields, so the alert and + /// the prompt the UX renders can never disagree about whether a check is due. Two mappings + /// are worth stating: + /// + /// - [`VerificationState::Badge`] reports `snooze_budget_spent`, which is what stops the + /// alert being pre-armed as a notification while leaving the condition reported — the + /// badge is persistent and non-blocking, and never escalates back into an alert. + /// - [`VerificationState::RewrapDue`] is due **now**, whatever the ladder says: repeated + /// failure or an explicit "I lost it" is not a scheduled check. The alert class set is + /// closed, so `recovery_check_due` is the only class that can carry it — which is why the + /// escalation also rides [`RecoveryFacts::rewrap_due`], surfacing as the alert's + /// `recovery` parameter. Without it the alert would be byte-identical to the routine + /// ninety-day check, and the client could not know to route into + /// [`RecoveryClient::guided_rewrap`](crate::recovery::RecoveryClient::guided_rewrap) + /// rather than a plain verification prompt. + #[must_use] + pub fn notify_facts(&self, now: Timestamp) -> RecoveryFacts { + match self.state(now) { + VerificationState::Verified { next_due } => RecoveryFacts { + next_due, + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }, + VerificationState::Due => RecoveryFacts { + next_due: self.next_due, + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: false, + }, + VerificationState::Snoozed { + until, + snoozes_used, + } => RecoveryFacts { + next_due: self.next_due, + snoozed_until: Some(until), + snooze_budget_spent: snoozes_used >= MAX_CONSECUTIVE_SNOOZES, + rewrap_due: false, + }, + VerificationState::Badge => RecoveryFacts { + next_due: self.next_due, + snoozed_until: None, + snooze_budget_spent: true, + rewrap_due: false, + }, + VerificationState::RewrapDue => RecoveryFacts { + next_due: now, + snoozed_until: None, + snooze_budget_spent: false, + rewrap_due: true, + }, + } + } } /// Add a signed second offset to a timestamp, saturating at the representable bounds @@ -285,6 +348,8 @@ fn add_secs(base: Timestamp, secs: i64) -> Timestamp { #[cfg(test)] mod tests { + use capsule_core::notify::{self, AlertClass, NotifyInput}; + use super::*; /// A fixed, round base instant well away from the timestamp bounds. @@ -486,4 +551,111 @@ mod tests { let back: RecoveryCadence = serde_json::from_str(&json).unwrap(); assert_eq!(cad, back); } + + // ── `S-D29` projection ────────────────────────────────────────────────── + + /// The projection walks every [`VerificationState`], and the fact it hands the shared + /// predicate agrees with the state the UX renders at the same instant. + #[test] + fn notify_facts_projects_every_state() { + let due = BASE + INITIAL_INTERVAL_SECS; + + // Verified → the prompt is scheduled, nothing is snoozed, budget intact. + let cad = RecoveryCadence::armed_at_setup(ts(BASE)); + let facts = cad.notify_facts(ts(BASE)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + + // Due → the same `next_due`, now in the past, so the predicate fires. + let facts = cad.notify_facts(ts(due)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + + // Snoozed within budget → the snooze end is carried, the budget is not spent. + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.snooze(ts(due), SnoozeDuration::OneDay); + let facts = cad.notify_facts(ts(due)); + assert_eq!(facts.snoozed_until, Some(ts(due + DAY_SECS))); + assert!(!facts.snooze_budget_spent); + + // The budget spends on the third consecutive snooze, and the last one still holds. + let mut at = due; + for _ in 1..MAX_CONSECUTIVE_SNOOZES { + at += DAY_SECS; + cad.snooze(ts(at), SnoozeDuration::OneDay); + } + let facts = cad.notify_facts(ts(at)); + assert_eq!(facts.snoozed_until, Some(ts(at + DAY_SECS))); + assert!(facts.snooze_budget_spent); + + // Badge → the snooze has expired with the budget spent: reported, never pre-armed. + let after = at + DAY_SECS; + assert_eq!(cad.state(ts(after)), VerificationState::Badge); + let facts = cad.notify_facts(ts(after)); + assert_eq!(facts.next_due, ts(due)); + assert_eq!(facts.snoozed_until, None); + assert!(facts.snooze_budget_spent); + + // None of the scheduled states is a re-wrap escalation. + for at in [BASE, due, at, after] { + assert!(!cad.notify_facts(ts(at)).rewrap_due, "at {at}"); + } + } + + /// `RewrapDue` is due now, whatever the ladder says: an explicit "I lost it" is not a + /// scheduled check, and the closed class set has only `recovery_check_due` to carry it. + #[test] + fn notify_facts_reports_rewrap_due_immediately() { + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.declare_lost(); + assert_eq!(cad.state(ts(BASE)), VerificationState::RewrapDue); + + // The ladder still says "not for 7 days"; the projection says "now". + assert_eq!(cad.next_due(), ts(BASE + INITIAL_INTERVAL_SECS)); + let facts = cad.notify_facts(ts(BASE)); + assert_eq!(facts.next_due, ts(BASE)); + assert_eq!(facts.snoozed_until, None); + assert!(!facts.snooze_budget_spent); + assert!( + facts.rewrap_due, + "the escalation must be distinguishable from a routine check" + ); + + // And it reaches the alert as a parameter, not just as an earlier due date. + let input = NotifyInput { + recovery: Some(facts), + ..NotifyInput::default() + }; + let alert = notify::evaluate(&input, ts(BASE)) + .into_iter() + .find(|a| a.class == AlertClass::RecoveryCheckDue) + .expect("due now"); + assert_eq!(alert.params["recovery"], "rewrap"); + } + + /// The projection is the whole of the wiring: what the scheduler says and what the shared + /// predicate decides never disagree about whether a check is due. + #[test] + fn notify_facts_agrees_with_the_rendered_state() { + let mut cad = RecoveryCadence::armed_at_setup(ts(BASE)); + cad.snooze(ts(BASE + INITIAL_INTERVAL_SECS), SnoozeDuration::OneWeek); + + for offset in [0, INITIAL_INTERVAL_SECS - 1, INITIAL_INTERVAL_SECS] { + let now = ts(BASE + offset); + let input = NotifyInput { + recovery: Some(cad.notify_facts(now)), + ..NotifyInput::default() + }; + let fired = notify::evaluate(&input, now) + .iter() + .any(|a| a.class == AlertClass::RecoveryCheckDue); + let rendered_due = matches!( + cad.state(now), + VerificationState::Due | VerificationState::Badge | VerificationState::RewrapDue + ); + assert_eq!(fired, rendered_due, "at +{offset}s"); + } + } } diff --git a/capsule-sdk/src/recovery/mod.rs b/capsule-sdk/src/recovery/mod.rs index 5dd4c554..7846c98b 100644 --- a/capsule-sdk/src/recovery/mod.rs +++ b/capsule-sdk/src/recovery/mod.rs @@ -3,7 +3,7 @@ //! Re-Wrap]). //! //! This module owns the **client half** of the master-key recovery story that the -//! server escrow surface (slice `S-C12`, `PUT`/`GET /backup/escrow`) and the core +//! server escrow surface (slice `S-C12`, `PUT`/`GET /v1/auth/escrow`) and the core //! crypto ([`capsule_core::backup`]) make possible. It has two cohesive halves: //! //! - **[`cadence`]** — the pure, network-free scheduler and prompt state machine @@ -19,11 +19,27 @@ //! [`WrappedSecret`] — byte-identical to what the server stores verbatim and to what the //! core restore path unwraps. //! +//! # The wire is the generated client, not a path this module builds +//! +//! Both escrow operations are `application/octet-stream` in each direction, which is a media +//! type `spargen` lowers, so both are **generated** and neither is narrowed out in +//! `build.rs`. [`RecoveryClient`] therefore orchestrates +//! [`AuthenticatedClient::fetch_escrow`](crate::rest::Client::fetch_escrow) and +//! [`store_escrow`](crate::rest::Client::store_escrow) and hand-writes no request. That is +//! not a preference: `AGENTS.md` requires that everything which parses or serializes is +//! generated, *including* the byte-serving endpoints, and the reason is this module's own +//! history. It used to build `{api_root}/backup/escrow` from a `const` — the Salvo document's +//! path — and when the contract was re-sourced from Kynos to `/v1/auth/escrow` nothing +//! noticed, because a route in a string constant is checked by no gate and this module's own +//! mock answered whichever path it was handed. +//! //! [Backup — Recovery Verification Cadence]: https://docs/design/backup-recovery/#recovery-verification-cadence //! [§ On Repeated Failure: Guided Re-Wrap]: https://docs/design/backup-recovery/#on-repeated-failure-guided-re-wrap pub mod cadence; +use std::sync::Arc; + pub use cadence::{ BACKOFF_INTERVAL_SECS, CAP_INTERVAL_SECS, INITIAL_INTERVAL_SECS, MAX_CONSECUTIVE_SNOOZES, REWRAP_FAILURE_THRESHOLD, REWRAP_MIN_SESSIONS, RearmTrigger, RecoveryCadence, SnoozeDuration, @@ -33,40 +49,118 @@ use capsule_core::backup::{VerifyOutcome, split_seed_2of3, verify_recovery_secre use capsule_core::crypto::primitives::{Argon2Params, DeviceTier}; use capsule_core::crypto::pwkdf::{self, WrappedSecret}; use capsule_core::crypto::rng; +use capsule_i18n::error_codes; use tracing::instrument; -use crate::auth::{AuthError, Session}; - -/// The escrow endpoint path, appended to the caller's API base. -const ESCROW_PATH: &str = "backup/escrow"; +use crate::auth::Session; +use crate::client::{AuthenticatedClient, ClientError}; +use crate::rest; /// Everything the networked recovery flows can fail with. Callers switch on the typed -/// variant, never a bare HTTP status. +/// variant (or its stable `error.*` code), never a bare HTTP status. #[derive(Debug, thiserror::Error)] pub enum RecoveryError { - /// The authenticated request itself failed (transport, session expiry, refresh). - #[error(transparent)] - Auth(#[from] AuthError), - /// Reading the escrow response body off the wire failed. - #[error("reading escrow response body failed: {0}")] - Body(#[source] reqwest::Error), - /// The caller has no escrow stored yet (server returned `404`). Enroll one first. + // There is deliberately no `Auth(AuthError)` variant any more. The session used to build + // these requests itself, so a dead session surfaced as its own typed `AuthError`; the + // generated client attaches the bearer through a token-provider seam that flattens our + // `AuthError` to a string, so the same event now arrives as `Unauthorized` — which is + // where the FFI's `Auth` mapping reads it from. An unconstructible variant would be a + // promise no code path can keep. + /// The API root is not a URL the generated client can hang operation paths off. + #[error("invalid base URL {url:?}: {reason}")] + InvalidBaseUrl { + /// The offending URL. + url: String, + /// Why the generated client rejected it. + reason: String, + }, + /// The call never reached a server answer: DNS, connection refused, a TLS handshake, a + /// timeout, a reset mid-body, or a refusal this client could not parse. Transient — the + /// cadence's next tick tries again. + /// + /// Note that reqwest classifies *every* failure of the request it executes as a request + /// error, so the generated taxonomy's `RequestConstruction` class carries connection + /// failures as well as genuine pre-flight ones; both land here. + #[error("the escrow endpoint could not be reached: {0}")] + Transport(String), + /// The credential was refused (`401`/`403`) and a refresh did not recover it — the user + /// must re-authenticate. + /// + /// `code` is whatever the server stamped on the problem body. Today Capsule stamps one + /// code (`error.request.unauthenticated`) on every `401` it renders, so this does not yet + /// separate an expired token from an unreadable revocation ledger; the field carries the + /// code so that it will the moment the server distinguishes them. + #[error("the escrow endpoint refused the credential: {detail}")] + Unauthorized { + /// The stable `error.*` catalog code from the problem body, when the failure came + /// with one. `None` when the credential could not be produced at all — there was no + /// server answer to carry a code. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The caller has no escrow stored yet (server returned a coded `404`). Enroll one first. #[error("no escrow stored for this account")] NotEnrolled, + /// The server refused the blob as one that cannot be an escrow at any version — empty or + /// past the coarse ceiling (`400`), not the declared media type (`415`), or past the + /// transport's body limit (`413`). Retrying the same bytes changes nothing. + #[error("the server rejected the escrow blob: {detail}")] + Malformed { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The escrow store could not answer (`500`). Transient, and coded + /// `error.escrow.unavailable` — which is why it is not folded into + /// [`Transport`](RecoveryError::Transport): a caller that localizes codes has one to show. + #[error("the escrow store could not answer: {detail}")] + Unavailable { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, /// The escrow bytes could not be (de)serialized as the canonical `WrappedSecret`. #[error("escrow blob codec error: {0}")] Codec(String), /// The (re-)wrap of the master key under the fresh secret failed in core. #[error("re-wrapping the master key failed: {0}")] Wrap(String), - /// The server returned an unmodeled status. + /// The server returned a status this client does not model as an escrow outcome — an + /// unmodeled status, the body-size backstop's body-less `413` on a read, or the protocol + /// gate's `426` (a write from outside the server's window, issue #404). #[error("unexpected {status} response from the escrow endpoint")] Unexpected { /// The HTTP status code the server returned. status: u16, + /// The stable `error.*` catalog code the response carried, when it came with a coded + /// problem body — `error.protocol.version_unsupported` on a `426`, the one that means + /// "update the client". `None` when there was no body to read a code from. + code: Option, }, } +impl RecoveryError { + /// The stable `error.*` catalog code a client localizes, when one applies. The English + /// [`Display`](std::fmt::Display) form stays the developer/log detail. + #[must_use] + pub fn error_code(&self) -> Option<&str> { + match self { + Self::Unauthorized { code, .. } + | Self::Malformed { code, .. } + | Self::Unavailable { code, .. } + | Self::Unexpected { code, .. } => code.as_deref(), + // The one code this module states rather than reads. `NotEnrolled` is a *state* + // ("this account has escrowed nothing"), not a message, and the server's own code + // for that state is this constant — see `capsule-server/src/routes/escrow.rs`. + Self::NotEnrolled => Some(error_codes::ESCROW_NOT_STORED), + _ => None, + } + } +} + /// A client-side cached copy of the server escrow blob. /// /// Fetched at enrollment and refreshed opportunistically (SSoT § Local Verification); @@ -208,68 +302,88 @@ pub struct GuidedRewrap { } /// The networked recovery client: escrow cache/refresh, stale-cache-aware local -/// verification, and the guided re-wrap. It borrows an authenticated [`Session`], so -/// every call rides the SDK's bearer/refresh machinery. +/// verification, and the guided re-wrap. +/// +/// It holds one [`AuthenticatedClient`], so every call rides the generated operation paths +/// and the SDK's bearer/refresh machinery, and this module states no route of its own. The +/// client is behind an [`Arc`] only so [`RecoveryClient`] stays [`Clone`] — the cadence hands +/// one client to several prompts. There is deliberately no repoint/session-swap accessor: +/// `AuthenticatedClient`'s own take `&mut self` and are unreachable through the `Arc`, and an +/// escrow client that changed origin mid-cadence would be a way to pull one account's escrow +/// into another's cache. Build a new one instead. #[derive(Clone)] pub struct RecoveryClient { - session: Session, - escrow_url: String, + client: Arc, } impl RecoveryClient { - /// Build a recovery client against the API base URL (the same base the auth session - /// authenticates against, e.g. `https://api.example.com`). - #[must_use] - pub fn new(session: Session, api_base_url: &str) -> Self { - let escrow_url = format!("{}/{ESCROW_PATH}", api_base_url.trim_end_matches('/')); - Self { - session, - escrow_url, - } + /// Build a recovery client against the **API root** — the origin the generated operation + /// paths hang off (e.g. `https://api.example.com`), which is the same base + /// [`AuthenticatedClient`] and [`crate::sync::SyncConsumer`] take. + /// + /// # Errors + /// + /// [`RecoveryError::InvalidBaseUrl`] when `api_base_url` is not a URL operation paths can + /// hang off. Fallible where the old hand-written `format!` was not, because the generated + /// client parses the base once at construction rather than per call. + pub fn new(session: Session, api_base_url: &str) -> Result { + let client = + AuthenticatedClient::new(api_base_url, session).map_err(|error| match error { + ClientError::InvalidBaseUrl { url, reason } => { + RecoveryError::InvalidBaseUrl { url, reason } + } + })?; + Ok(Self { + client: Arc::new(client), + }) } - /// Fetch the current escrow blob from the server (`GET /backup/escrow`) into a fresh + /// Fetch the current escrow blob from the server (`GET /v1/auth/escrow`) into a fresh /// [`EscrowCache`]. `404` maps to [`RecoveryError::NotEnrolled`]. #[instrument(skip_all)] pub async fn fetch_escrow(&self) -> Result { - let response = self.session.execute(|c| c.get(&self.escrow_url)).await?; - let status = response.status(); - match status { - reqwest::StatusCode::OK => { - let bytes = response.bytes().await.map_err(RecoveryError::Body)?; - tracing::debug!(len = bytes.len(), "fetched escrow blob"); - EscrowCache::from_wire(&bytes) - } - reqwest::StatusCode::NOT_FOUND => Err(RecoveryError::NotEnrolled), - other => Err(RecoveryError::Unexpected { - status: other.as_u16(), - }), - } + // The protocol date is a required parameter of every gated operation in the document, + // so the generated signature asks for it; the value is the build's own, the same one the + // transport's default header carries. The suite and sidecar schema ride the transport. + let bytes = self + .client + .fetch_escrow( + capsule_core::crypto::primitives::PROTOCOL_VERSION, + rest::FetchEscrowParams::default(), + ) + .await + .map_err(fetch_escrow_error)? + .into_inner(); + tracing::debug!(len = bytes.len(), "fetched escrow blob"); + EscrowCache::from_wire(&bytes) } - /// Store or replace the caller's escrow blob (`PUT /backup/escrow`). Single active + /// Store or replace the caller's escrow blob (`PUT /v1/auth/escrow`). Single active /// escrow: the server overwrites any prior blob in the same transaction (S-C12). + /// + /// The canonical CBOR goes on the wire verbatim; `replaced` and `stored_at` are logged + /// rather than returned, because no caller has asked for them yet and a return type is + /// harder to widen than a log line. #[instrument(skip_all)] pub async fn store_escrow(&self, blob: &WrappedSecret) -> Result<(), RecoveryError> { let body = capsule_core::cbor::to_canonical_vec(blob) .map_err(|e| RecoveryError::Codec(e.to_string()))?; - let response = self - .session - .execute(|c| { - c.put(&self.escrow_url) - .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") - .body(body.clone()) - }) - .await?; - let status = response.status(); - if status.is_success() { - tracing::info!("escrow blob stored (single active escrow: any prior blob replaced)"); - Ok(()) - } else { - Err(RecoveryError::Unexpected { - status: status.as_u16(), - }) - } + let stored = self + .client + .store_escrow( + capsule_core::crypto::primitives::PROTOCOL_VERSION, + rest::StoreEscrowParams::default(), + &rest::types::RequestBody::from(body), + ) + .await + .map_err(store_escrow_error)? + .into_inner(); + tracing::info!( + stored_at = %stored.stored_at, + replaced = stored.replaced, + "escrow blob stored (single active escrow: any prior blob replaced)" + ); + Ok(()) } /// Local recovery-secret verification with the **stale-cache rule** (SSoT § Local @@ -367,6 +481,168 @@ impl RecoveryClient { } } +/// Map a `GET /v1/auth/escrow` refusal onto its typed variant. +/// +/// One readable status table rather than a match buried in the request path. The *inner* +/// match is exhaustive over the generated enum, so a status the document adds to this +/// operation stops the build here; a new `rest::Error` **class** still falls through to +/// [`wire_error`]'s catch-all. +fn fetch_escrow_error(error: rest::Error) -> RecoveryError { + match error { + rest::Error::Api(response) => match response.into_inner() { + rest::FetchEscrowError::Status404(_) => RecoveryError::NotEnrolled, + // The protocol gate's malformed-handshake answer (issue #404): a read is admitted at + // any grammatical protocol date, so the only `400` this operation renders is a + // request whose handshake headers did not parse. Unreachable from this client — the + // transport always sends the build's own — and carried with its code rather than + // swallowed, so a caller that localizes codes still has the server's. + rest::FetchEscrowError::Status400(problem) => RecoveryError::Malformed { + code: Some(problem.code.clone()), + detail: detail(&problem), + }, + rest::FetchEscrowError::Status401(problem) + | rest::FetchEscrowError::Status403(problem) => refused(&problem), + rest::FetchEscrowError::Status500(problem) => unavailable(&problem), + // Declared by the transport backstop and unreachable on a body-less `GET`; kept + // honest rather than folded into a class it does not belong to. + rest::FetchEscrowError::Status413 => RecoveryError::Unexpected { + status: 413, + code: None, + }, + }, + other => wire_error(&other), + } +} + +/// Map a `PUT /v1/auth/escrow` refusal onto its typed variant. +fn store_escrow_error(error: rest::Error) -> RecoveryError { + match error { + rest::Error::Api(response) => match response.into_inner() { + // `400` and `415` are the same answer to the caller: these bytes are not an + // escrow, and sending them again will not help. + rest::StoreEscrowError::Status400(problem) + | rest::StoreEscrowError::Status415(problem) => RecoveryError::Malformed { + code: Some(problem.code.clone()), + detail: detail(&problem), + }, + // `426` is the protocol gate refusing a write from outside the server's window + // (issue #404). It says nothing about the *blob*, so it is not `Malformed`; it is + // an outcome this module does not model, carried with the server's own code — + // `error.protocol.version_unsupported`, the one that means "update the client" — + // so a caller localizing codes reads the gate's judgement, not this client's. + rest::StoreEscrowError::Status426(problem) => RecoveryError::Unexpected { + status: 426, + code: Some(problem.code.clone()), + }, + rest::StoreEscrowError::Status401(problem) + | rest::StoreEscrowError::Status403(problem) => refused(&problem), + rest::StoreEscrowError::Status500(problem) => unavailable(&problem), + // The body-size backstop carries no problem body at all, so there is no code to + // carry and this client does not invent one. Every other code in this module is + // the server's own, and a code minted here would assert that the server said + // something it did not — a client localizing it would be reading the SDK's guess + // as the server's judgement. The English detail says what happened instead; the + // variant already says "these bytes will not do, do not resend them". + rest::StoreEscrowError::Status413 => RecoveryError::Malformed { + code: None, + detail: "the escrow blob exceeds the server's request-body limit".to_owned(), + }, + }, + other => wire_error(&other), + } +} + +/// A refused credential, carrying the problem body's stable code. `CodedProblem.code` is a +/// required member, so a refusal that parsed always has one. +fn refused(problem: &rest::types::CodedProblem) -> RecoveryError { + RecoveryError::Unauthorized { + code: Some(problem.code.clone()), + detail: detail(problem), + } +} + +/// The store could not answer — transient, and the caller's cadence retries. +fn unavailable(problem: &rest::types::CodedProblem) -> RecoveryError { + RecoveryError::Unavailable { + code: Some(problem.code.clone()), + detail: detail(problem), + } +} + +/// The problem body's English detail, or its code when the server sent no detail. +fn detail(problem: &rest::types::CodedProblem) -> String { + problem + .detail + .clone() + .unwrap_or_else(|| problem.code.clone()) +} + +/// Map the generated client's non-`Api` taxonomy classes: an undocumented status keeps its +/// number, and everything else is a wire failure with its source chain preserved. +fn wire_error(error: &rest::Error) -> RecoveryError +where + E: std::error::Error + 'static, +{ + match error { + rest::Error::UnexpectedStatus { status, .. } => RecoveryError::Unexpected { + status: status.as_u16(), + code: None, + }, + // `RequestConstruction` is **not** a pre-flight-only class. reqwest builds every + // failure of the request it executes with `error::request(..)`, so `is_request()` is + // true for connection-refused, DNS and TLS failures too, and the generated taxonomy + // routes all of them here alongside the genuine pre-flight ones. Splitting them by + // class alone would report an unreachable server as "sign in again", which on an + // offline device is the one remedy that cannot work. + // + // The *source* discriminates them: a bearer provider that could not produce a token is + // boxed as the generated runtime's own `AuthError`, and nothing else on this path is. + // A dead session must reach a caller as an auth failure rather than as a transport + // blip, because the two have opposite remedies. + rest::Error::RequestConstruction(inner) => { + if std::error::Error::source(inner) + .and_then(|source| source.downcast_ref::()) + .is_some() + { + RecoveryError::Unauthorized { + code: None, + detail: describe(error), + } + } else { + RecoveryError::Transport(describe(error)) + } + } + // A *declared* refusal whose body was not the coded problem the document promises. + // Deliberately a wire failure and not [`RecoveryError::NotEnrolled`], even though an + // uncoded `404` is its commonest shape: reading any `404` as "this account has + // escrowed nothing" is exactly what let a wrong route look like an empty escrow for a + // whole slice. An intermediary answering `404 text/html` is a broken path, not an + // enrollment state. + rest::Error::Decode { path, .. } => RecoveryError::Transport(format!( + "the escrow endpoint answered a refusal this client could not parse ({path})" + )), + other => RecoveryError::Transport(describe(other)), + } +} + +/// Render a generated-client failure together with its source chain. The taxonomy's own +/// `Display` names the class and, for the wire classes, little else (`"transport failed"`, +/// `"request construction failed"`) — the source chain is where the reqwest/hyper reason a log +/// reader needs actually lives. +fn describe(error: &rest::Error) -> String +where + E: std::error::Error + 'static, +{ + let mut rendered = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + rendered.push_str(": "); + rendered.push_str(&cause.to_string()); + source = cause.source(); + } + rendered +} + #[cfg(test)] mod tests { use std::future::Future; @@ -392,9 +668,19 @@ mod tests { // ── Binary-capable mock escrow server ───────────────────────────────────── // - // The escrow surface is `application/octet-stream` in both directions, so unlike the - // auth mock (JSON strings) this one stores and serves raw bytes. A single shared - // slot models the server's single-active-escrow row. + // The escrow surface is `application/octet-stream` on the way out, so unlike the auth + // mock (JSON strings) this one stores and serves raw bytes. A single shared slot models + // the server's single-active-escrow row. + // + // Two things it must now do that it did not have to when this module built its own URL. + // It **routes on the path**, answering `501` to anything that is not `/v1/auth/escrow`, + // because a mock that replies to whatever it is handed is exactly why a wrong route + // survived here. And its refusals carry a real RFC 9457 problem body: the generated + // client decodes a documented non-success status into `CodedProblem`, so a bare status + // with an empty body would arrive as a decode failure rather than the typed variant. + + /// The one path the escrow operations are served on, per the committed document. + const ESCROW_ROUTE: &str = "/v1/auth/escrow"; #[derive(Clone, Default)] struct EscrowStore { @@ -403,14 +689,54 @@ mod tests { struct MockRequest { method: String, + path: String, body: Vec, } struct MockResponse { status: u16, + content_type: &'static str, body: Vec, } + impl MockResponse { + /// An `application/octet-stream` payload — the escrow itself. + fn bytes(status: u16, body: Vec) -> Self { + Self { + status, + content_type: "application/octet-stream", + body, + } + } + + /// A JSON payload — `StoreEscrowResponse`, which the generated client decodes. + fn json(status: u16, body: serde_json::Value) -> Self { + Self { + status, + content_type: "application/json", + body: body.to_string().into_bytes(), + } + } + + /// An RFC 9457 problem, shaped as `CodedProblem` so the generated client can parse it + /// into the operation's typed error. + fn problem(status: u16, code: &str, detail: &str) -> Self { + Self { + status, + content_type: "application/problem+json", + body: serde_json::json!({ + "type": "about:blank", + "title": "Refused", + "status": status, + "detail": detail, + "code": code, + }) + .to_string() + .into_bytes(), + } + } + } + type BoxFut = Pin + Send>>; type Handler = Arc BoxFut + Send + Sync>; @@ -452,11 +778,9 @@ mod tests { let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); let mut lines = head.split("\r\n"); let request_line = lines.next().unwrap_or_default(); - let method = request_line - .split_whitespace() - .next() - .unwrap_or_default() - .to_string(); + let mut request_parts = request_line.split_whitespace(); + let method = request_parts.next().unwrap_or_default().to_string(); + let path = request_parts.next().unwrap_or_default().to_string(); let mut content_length = 0usize; let mut authorized = false; @@ -484,17 +808,19 @@ mod tests { // Every escrow call is owner-scoped: reject anything without a bearer token. let response = if authorized { - handler(MockRequest { method, body }).await + handler(MockRequest { method, path, body }).await } else { - MockResponse { - status: 401, - body: Vec::new(), - } + MockResponse::problem( + 401, + error_codes::REQUEST_UNAUTHENTICATED, + "no bearer credential", + ) }; let payload = format!( - "HTTP/1.1 {} STATUS\r\ncontent-type: application/octet-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + "HTTP/1.1 {} STATUS\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", response.status, + response.content_type, response.body.len() ); let mut out = payload.into_bytes(); @@ -505,33 +831,42 @@ mod tests { } /// A handler backed by the shared single-active-escrow slot: `PUT` overwrites it, - /// `GET` serves it verbatim (or 404). + /// `GET` serves it verbatim (or `404`). + /// + /// Anything off `/v1/auth/escrow` is `501`, which arrives as + /// [`RecoveryError::Unexpected`] rather than as a plausible-looking `NotEnrolled` — so a + /// route regression fails loudly here instead of reading as "no escrow stored". fn escrow_handler(store: EscrowStore) -> Handler { Arc::new(move |req| { let store = store.clone(); Box::pin(async move { + if req.path != ESCROW_ROUTE { + return MockResponse::bytes(501, Vec::new()); + } match req.method.as_str() { "PUT" => { - *store.blob.lock().unwrap() = Some(req.body); - MockResponse { - status: 204, - body: Vec::new(), - } + let replaced = store.blob.lock().unwrap().replace(req.body).is_some(); + MockResponse::json( + 200, + serde_json::json!({ + "stored_at": "2026-01-01T00:00:00Z", + "replaced": replaced, + }), + ) } "GET" => match store.blob.lock().unwrap().clone() { - Some(bytes) => MockResponse { - status: 200, - body: bytes, - }, - None => MockResponse { - status: 404, - body: Vec::new(), - }, - }, - _ => MockResponse { - status: 405, - body: Vec::new(), + Some(bytes) => MockResponse::bytes(200, bytes), + None => MockResponse::problem( + 404, + error_codes::ESCROW_NOT_STORED, + "no escrow has been stored for this account", + ), }, + _ => MockResponse::problem( + 405, + error_codes::REQUEST_METHOD_NOT_ALLOWED, + "the escrow surface serves GET and PUT", + ), } }) }) @@ -550,6 +885,20 @@ mod tests { .unwrap() } + /// A session over the mock whose access token expired an hour ago, so any call + /// pre-flight-refreshes — and the escrow mock serves no `/refresh`, so that refresh fails. + /// The result is a [`Session`] that cannot produce a bearer at all. + fn dead_session_for(base: &str) -> Session { + AuthClient::new(base) + .unwrap() + .resume(PersistedSession { + access_token: "test-access".to_string().into(), + refresh_token: "test-refresh".to_string().into(), + access_expires_at_unix: jiff::Timestamp::now().as_second() - 3_600, + }) + .unwrap() + } + fn wrap(master: &[u8; 32], secret: &[u8]) -> WrappedSecret { pwkdf::wrap_with(master, secret, fast_params()).unwrap() } @@ -560,7 +909,7 @@ mod tests { async fn escrow_store_fetch_round_trip() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x11u8; 32]; let blob = wrap(&master, b"correct horse battery staple"); @@ -578,7 +927,7 @@ mod tests { #[tokio::test] async fn fetch_without_escrow_is_not_enrolled() { let base = start_mock(escrow_handler(EscrowStore::default())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); assert!(matches!( client.fetch_escrow().await, Err(RecoveryError::NotEnrolled) @@ -591,7 +940,7 @@ mod tests { async fn verify_correct_secret_against_cache() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x22u8; 32]; let blob = wrap(&master, b"the-right-secret"); @@ -614,7 +963,7 @@ mod tests { async fn verify_refreshes_stale_cache_then_passes() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store.clone())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x33u8; 32]; // Enroll and cache the OLD wrap. @@ -646,7 +995,7 @@ mod tests { async fn verify_wrong_secret_fails_after_refresh() { let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x44u8; 32]; let blob = wrap(&master, b"real-secret"); @@ -675,11 +1024,11 @@ mod tests { /// surfaced as data. #[tokio::test] async fn guided_rewrap_keeps_master_key_and_blob_hashes() { - use capsule_core::utils::hash::hash_bytes; + use capsule_core::crypto::hash::hash_bytes; let store = EscrowStore::default(); let base = start_mock(escrow_handler(store)).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x55u8; 32]; let old_secret = b"the-old-lost-secret"; @@ -693,8 +1042,8 @@ mod tests { // Fixture "asset" ciphertext blobs — re-wrap must not touch these. let asset_a = b"encrypted-asset-ciphertext-A".to_vec(); let asset_b = b"encrypted-asset-ciphertext-B".to_vec(); - let hash_a_before = hash_bytes(&asset_a); - let hash_b_before = hash_bytes(&asset_b); + let hash_a_before = hash_bytes(&asset_a).to_hex(); + let hash_b_before = hash_bytes(&asset_b).to_hex(); // Run the guided re-wrap (fast params, Shamir enrolled). let rewrap = client @@ -713,8 +1062,8 @@ mod tests { assert!(recover_master_key(refetched.blob(), old_secret).is_err()); // Re-wrap touched only the wrap: asset blob hashes are byte-identical. - assert_eq!(hash_bytes(&asset_a), hash_a_before); - assert_eq!(hash_bytes(&asset_b), hash_b_before); + assert_eq!(hash_bytes(&asset_a).to_hex(), hash_a_before); + assert_eq!(hash_bytes(&asset_b).to_hex(), hash_b_before); // Shamir re-issued, old shares invalidated; the new shares reconstruct the new // seed (2 of 3). @@ -741,7 +1090,7 @@ mod tests { #[tokio::test] async fn guided_rewrap_no_shamir_when_not_enrolled() { let base = start_mock(escrow_handler(EscrowStore::default())).await; - let client = RecoveryClient::new(session_for(&base), &base); + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); let master = [0x66u8; 32]; client.store_escrow(&wrap(&master, b"old")).await.unwrap(); @@ -752,6 +1101,202 @@ mod tests { assert!(rewrap.shamir.is_none()); } + /// The mock's route guard is live: a client pointed one segment off the documented path + /// gets a loud `Unexpected`, not a plausible-looking `NotEnrolled`. + /// + /// This is the regression this slice exists for, asserted as a property of the *test + /// harness*: without it the mock would answer any path at all, and a wrong route would + /// once again read as "this account has escrowed nothing" — which is what let + /// `backup/escrow` survive a contract re-source. + #[tokio::test] + async fn a_call_off_the_documented_route_is_not_mistaken_for_an_empty_escrow() { + let base = start_mock(escrow_handler(EscrowStore::default())).await; + let client = + RecoveryClient::new(session_for(&base), &format!("{base}/not-the-contract")).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("a path the server does not serve is not an empty escrow"); + assert!( + matches!(error, RecoveryError::Unexpected { status: 501, .. }), + "got {error:?}" + ); + } + + /// A `400` refusal becomes the typed `Malformed` and carries **the server's own** code — + /// not the `Unexpected { status }` the hand-written path used to collapse it into, and not + /// a constant this module guessed. + #[tokio::test] + async fn a_refused_blob_is_malformed_with_the_servers_code() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { + MockResponse::problem( + 400, + error_codes::ESCROW_MALFORMED, + "the escrow blob is empty", + ) + }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .store_escrow(&wrap(&[0x77u8; 32], b"whatever")) + .await + .expect_err("the server refused the blob"); + assert!( + matches!(error, RecoveryError::Malformed { .. }), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some(error_codes::ESCROW_MALFORMED)); + } + + /// The store answering `500` is its own variant carrying `error.escrow.unavailable`, not a + /// bare transport failure: a client that localizes codes has one to show, and the cadence + /// can tell "the server is unwell" from "the network is gone". + #[tokio::test] + async fn an_unavailable_store_keeps_its_catalog_code() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { + MockResponse::problem( + 500, + error_codes::ESCROW_UNAVAILABLE, + "the escrow could not be read", + ) + }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client.fetch_escrow().await.expect_err("the store is down"); + assert!( + matches!(error, RecoveryError::Unavailable { .. }), + "got {error:?}" + ); + assert_eq!(error.error_code(), Some(error_codes::ESCROW_UNAVAILABLE)); + } + + /// **An unreachable server is not an expired session.** reqwest reports a refused + /// connection as a *request* error, which the generated taxonomy files under + /// `RequestConstruction` next to the genuine pre-flight failures — so classifying that + /// whole class as an auth failure would tell an offline device to sign in again, the one + /// remedy that cannot work without a network. + #[tokio::test] + async fn an_unreachable_endpoint_is_a_transport_failure_not_an_auth_one() { + // Bind, read the port, then drop the listener: the address is now certain to refuse. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + // The session is built against a live mock, so the session itself is healthy and the + // only thing wrong is the escrow origin. + let live = start_mock(escrow_handler(EscrowStore::default())).await; + let client = RecoveryClient::new(session_for(&live), &format!("http://{addr}")).unwrap(); + + let error = client + .fetch_escrow() + .await + .expect_err("nothing is listening there"); + assert!( + matches!(error, RecoveryError::Transport(_)), + "an unreachable endpoint must be transport, got {error:?}" + ); + assert_eq!(error.error_code(), None); + } + + /// **A session that cannot mint a bearer is an auth failure, not a network one.** + /// + /// This is the other side of `an_unreachable_endpoint_is_a_transport_failure_not_an_auth_one` + /// and it pins the discrimination that separates them. Both arrive as the generated + /// taxonomy's `RequestConstruction`; the only thing telling them apart is the boxed source, + /// which is the runtime's own `AuthError` when — and only when — the bearer provider is + /// what failed. Without this case, a spargen change to how a provider failure is boxed + /// would silently demote every expired refresh token to `Transport`, and the FFI would tell + /// a user to retry where it must tell them to sign in again. The escrow calls themselves + /// never leave the process here: there is no bearer to send them with. + #[tokio::test] + async fn a_session_that_cannot_mint_a_bearer_is_an_auth_failure() { + let base = start_mock(escrow_handler(EscrowStore::default())).await; + let client = RecoveryClient::new(dead_session_for(&base), &base).unwrap(); + + let error = client + .fetch_escrow() + .await + .expect_err("the session cannot produce a token"); + assert!( + matches!(error, RecoveryError::Unauthorized { code: None, .. }), + "a dead session must reach the caller as an auth failure, got {error:?}" + ); + assert_eq!( + error.error_code(), + None, + "no server answered, so there is no catalog code to carry" + ); + + // And the same on the write path, which has a body to construct and still fails before + // it is sent. + let error = client + .store_escrow(&wrap(&[0x99u8; 32], b"whatever")) + .await + .expect_err("the session cannot produce a token"); + assert!( + matches!(error, RecoveryError::Unauthorized { code: None, .. }), + "got {error:?}" + ); + } + + /// A refusal whose body is not the coded problem the document promises — an intermediary + /// answering a bare `404`, say — is a broken path, not an empty escrow. + /// + /// This is the deliberate class change the route fix rests on: the hand-written client + /// read *any* `404` as `NotEnrolled`, which is exactly why a wrong route looked like an + /// account that had escrowed nothing. + #[tokio::test] + async fn an_uncoded_404_is_not_read_as_an_empty_escrow() { + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { MockResponse::bytes(404, b"not found".to_vec()) }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("an unparseable refusal is not an enrollment state"); + assert!( + matches!(error, RecoveryError::Transport(_)), + "got {error:?}" + ); + } + + /// A refused credential carries the problem body's code straight through, so a client + /// localizes what the *server* said rather than what this module assumed. + #[tokio::test] + async fn a_refused_credential_keeps_the_problem_code() { + // No bearer reaches the mock's handler at all: it answers `401` at the door, which is + // precisely the shape a revoked token produces. + let handler: Handler = Arc::new(|_req| { + Box::pin(async move { + MockResponse::problem( + 401, + error_codes::REQUEST_UNAUTHENTICATED, + "the access token was refused", + ) + }) + }); + let base = start_mock(handler).await; + let client = RecoveryClient::new(session_for(&base), &base).unwrap(); + let error = client + .fetch_escrow() + .await + .expect_err("the credential was refused"); + assert!( + matches!(error, RecoveryError::Unauthorized { .. }), + "got {error:?}" + ); + assert_eq!( + error.error_code(), + Some(error_codes::REQUEST_UNAUTHENTICATED) + ); + } + /// The minted secret clears the ≥128-bit entropy floor (256-bit) and never prints /// its material. #[test] diff --git a/capsule-sdk/src/staged.rs b/capsule-sdk/src/staged.rs index 7d609237..e1851975 100644 --- a/capsule-sdk/src/staged.rs +++ b/capsule-sdk/src/staged.rs @@ -38,7 +38,7 @@ use std::collections::HashSet; -use capsule_core::import::upload::{UploadPolicy, UploadTier}; +use capsule_core::import::{UploadPolicy, UploadTier}; use tracing::instrument; use crate::net::ConnectionClass; diff --git a/capsule-sdk/src/sync.rs b/capsule-sdk/src/sync.rs index 02035411..f7391405 100644 --- a/capsule-sdk/src/sync.rs +++ b/capsule-sdk/src/sync.rs @@ -138,7 +138,9 @@ pub struct FeedEntry { pub kind: ChangeKind, /// The asset id. pub asset_id: Vec, - /// The signed `AssetManifest` as opaque canonical CBOR (verified by core). + /// The asset's head **provenance record** as opaque canonical CBOR — the `provenance` + /// blob's bytes, served back unchanged, carrying the signed `AssetManifest` inside it + /// (decoded and verified by core's `apply_remote_entry`). Never re-encoded here. pub manifest_cbor: Vec, /// The encrypted metadata blob's **content address**, as its UTF-8 bytes; empty when the /// entry carries none (a tombstone). @@ -470,15 +472,28 @@ impl SyncConsumer { /// retries, then a visible failure — no configuration hot-loops. #[instrument(skip(self, cursor), fields(page_size, entries))] pub async fn pull(&self, cursor: &SyncCursor, page_size: u32) -> Result { + self.pull_scoped(cursor, page_size, None).await + } + + /// The body both [`Self::pull`] and [`Self::pull_album`] run: one album or the whole feed. + async fn pull_scoped( + &self, + cursor: &SyncCursor, + page_size: u32, + album_id: Option<&str>, + ) -> Result { let mut engine: RetryEngine = RetryClass::Interactive.engine(); let response = loop { - match self.call(cursor, page_size).await { + match self.call(cursor, page_size, album_id).await { Ok(page) => break page, Err(error) if is_unauthenticated(&error) => match &self.auth { SyncAuth::Session(session) => { tracing::info!("the feed answered 401; refreshing once and retrying"); session.refresh().await?; - break self.call(cursor, page_size).await.map_err(map_error)?; + break self + .call(cursor, page_size, album_id) + .await + .map_err(map_error)?; } SyncAuth::Static => return Err(map_error(error)), }, @@ -500,6 +515,28 @@ impl SyncConsumer { Ok(page) } + /// Pull one page of **one album** after `cursor`. + /// + /// The album arm of the same operation (`S-C51`, `S-E5`): an account reads it as the album's + /// owner or a member of its current roster, and a federated peer reads it under a capability + /// whose audience is that album. Same retry and same refresh-once behaviour as + /// [`Self::pull`]; the only difference is the parameter, because the *server* is where the + /// two arms differ and the client has one feed. + /// + /// # Errors + /// + /// As [`Self::pull`], plus the album refusals the server renders — a peer's revoked or + /// out-of-audience capability among them. + #[instrument(skip(self, cursor), fields(page_size, entries))] + pub async fn pull_album( + &self, + cursor: &SyncCursor, + page_size: u32, + album_id: &str, + ) -> Result { + self.pull_scoped(cursor, page_size, Some(album_id)).await + } + /// Pull the next page for `state` (using its stored cursor), validate and apply it, and /// return it. The one call that ties the opaque-cursor round-trip to the anti-rewind layer. #[instrument(skip(self, state), fields(page_size))] @@ -518,8 +555,11 @@ impl SyncConsumer { &self, cursor: &SyncCursor, page_size: u32, + album_id: Option<&str>, ) -> Result> { let params = rest::SyncFeedParams { + // Absent is the caller's own feed; present is one album's page. + album_id: album_id.map(str::to_owned), // The cursor is round-tripped verbatim. Empty means "from the beginning", which the // server spells as an absent parameter rather than an empty one. cursor: cursor @@ -527,14 +567,27 @@ impl SyncConsumer { .filter(|value| !value.is_empty()) .map(str::to_owned), page_size: Some(i64::from(page_size)), + // The suite and the sidecar schema are validated when present and a feed pull has + // no use for either; the suite already rides the transport's default headers. + ..rest::SyncFeedParams::default() }; - Ok(self.client.sync_feed(params).await?.into_inner()) + // The protocol date is a required parameter of every gated operation in the document, + // so the generated signature asks for it; the value is the build's own, the same one the + // transport's default header carries. + Ok(self + .client + .sync_feed(capsule_core::crypto::primitives::PROTOCOL_VERSION, params) + .await? + .into_inner()) } } /// A generated client for `base_url` carrying `credential` under the bearer scheme. fn build_client(base_url: &str, credential: rest::Credential) -> Result { - let client = rest::Client::with_client(reqwest::Client::new(), base_url) + // The SDK's one HTTP client, so the feed pull carries the protocol handshake. + let http = + crate::net::http_client().map_err(|error| SyncError::Transport(error.to_string()))?; + let client = rest::Client::with_client(http, base_url) .map_err(|error| SyncError::Transport(error.to_string()))? .with_credential(BEARER_SCHEME, credential); Ok(client) @@ -585,9 +638,16 @@ fn map_error(error: rest::Error) -> SyncError { match error { rest::Error::Api(response) => { let (code, message) = match response.into_inner() { + // The 400 includes the protocol gate's malformed-handshake answer (issue #404). + // There is no 426 to map: the feed is a read, and a read is admitted at any + // grammatical protocol date — the window rides the response headers instead. + // The 429 is a federated peer's events budget (`S-E5`): a capability puller + // over its hour. Rejected rather than retried, because the window is an hour + // and the interactive retry class would give up long before it turned. rest::SyncFeedError::Status400(problem) | rest::SyncFeedError::Status401(problem) | rest::SyncFeedError::Status403(problem) + | rest::SyncFeedError::Status429(problem) | rest::SyncFeedError::Status500(problem) => ( Some(problem.code.clone()), problem.detail.clone().unwrap_or_default(), diff --git a/capsule-sdk/src/upgrade.rs b/capsule-sdk/src/upgrade.rs new file mode 100644 index 00000000..f779a6eb --- /dev/null +++ b/capsule-sdk/src/upgrade.rs @@ -0,0 +1,596 @@ +//! Proposing an **album upgrade** — the client half of the ceremony's one server-side step +//! (slice `S-C24`, over the `S-D28` wire). +//! +//! [Versioning — Album Upgrade Ceremony] is a client ceremony carried on MLS application +//! messages the server cannot read. Four of its steps are the server's, and the first is the +//! one this module drives: `POST /v1/albums/{album_id}/upgrade` hands the server a **signed +//! `UpgradeIntent`**, which quiesces the album (a v_old client that never saw the proposal is +//! precisely the party that will not stop writing on its own) and starts the deadline on the +//! server's own clock (so a skewed member clock can neither extend nor shorten the window). +//! +//! Two rules shape this module, and both are the directory client's rules for the same reason: +//! +//! - **The signed bytes travel verbatim.** `intent_cbor` is the canonical CBOR +//! `capsule_core::crypto::upgrade::SignedUpgradeIntent` produced, and it is written to the +//! body unchanged. Re-encoding it here would detach it from the signature the server checks +//! against the proposing device's DSK in the account's published directory, and the failure +//! would look like a forged proposal. +//! - **Nothing cryptographic happens here.** The intent is built and signed in `capsule-core`; +//! this module is the wire and its refusals. +//! +//! # Why hand-written, and what would retire it +//! +//! The request body is `application/cbor`, and `spargen` 0.4's `classify_media` does not know +//! that media type, so `capsule-sdk/build.rs` narrows the operation out of the generated client +//! (`S-D28`) — the *surface* is narrowed, the document is never mutilated. This module is +//! therefore the orchestration `AGENTS.md` permits over a wire it cannot generate, and it is +//! the fourth and last such client: `capsule_sdk::directory` hand-writes two and +//! [`crate::verify::StorageVerifyClient::fetch_receipt`] the third. Teaching spargen the media +//! type retires all four; nothing in this repository can. +//! +//! The **other two** operations on this path — `GET` (read the phase) and `DELETE` (end the +//! ceremony) — are plain JSON and *are* generated. Call them through +//! [`crate::client::AuthenticatedClient`]; this module deliberately does not duplicate them. +//! +//! [Versioning — Album Upgrade Ceremony]: https://docs/design/versioning/#album-upgrade-ceremony + +use jiff::Timestamp; +use serde::Deserialize; +use tracing::instrument; +use uuid::Uuid; + +use crate::auth::{AuthError, Session}; + +/// The media type the intent is *signed* in, and therefore the only one it may be sent as. +const CBOR: &str = "application/cbor"; + +/// The phase response's media type — the answer is a plain JSON document. +const JSON: &str = "application/json"; + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +/// Everything a proposal can fail with. Callers switch on the typed variant, or on its stable +/// `error.*` code, and never on a bare status. +/// +/// Every refusal carries the code the **server** stamped rather than one this module inferred +/// from the status, because the ceremony's refusals are the ones a user actually reads: "the +/// album is already upgrading" and "your device is not an admin" are different sentences. +#[derive(Debug, thiserror::Error)] +pub enum UpgradeError { + /// The authenticated request itself failed (transport, session expiry, refresh). + #[error(transparent)] + Auth(#[from] AuthError), + /// The server refused the body as one it cannot read as a signed intent (`400`), refused + /// the media type (`415`), or refused its size (`413`). Retrying the same bytes changes + /// nothing — rebuild and re-sign the intent. + #[error("the server rejected the upgrade intent: {detail}")] + Malformed { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The credential was refused (`401`). + #[error("the upgrade surface refused the credential: {detail}")] + Unauthorized { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The intent is not signed by a device in the caller's **published** directory (`403`), + /// so the server cannot tell that an admin device really asked for this. Publish the + /// directory holding the proposing device first ([`crate::directory`]). + #[error("the upgrade intent's proposer could not be verified: {detail}")] + NotProposer { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// No such album, or not this caller's (`404`) — one answer for both, deliberately, so the + /// surface discloses nothing about albums the caller does not own. + #[error("no such album: {detail}")] + NotFound { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// A different ceremony already holds this album (`409`), and only one may. Read the phase + /// (the generated `GET` on the same path) and either join that ceremony or wait for its + /// deadline; a fresh proposal *replaces* an expired one rather than conflicting with it. + #[error("album is already upgrading under {intent_id:?}: {detail}")] + InFlight { + /// The ceremony that holds the album, as the server reported it. + intent_id: Option, + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// A collaborator could not answer (`500`). Transient. + #[error("the upgrade could not be recorded: {detail}")] + Unavailable { + /// The stable `error.*` catalog code the refusal carried. + code: Option, + /// English detail from the problem body. + detail: String, + }, + /// The response body was not the phase document the contract declares. + #[error("malformed upgrade phase response: {0}")] + MalformedResponse(String), + /// The server returned an unmodeled status. + #[error("unexpected {status} response from the album-upgrade endpoint")] + Unexpected { + /// The HTTP status code the server returned. + status: u16, + }, +} + +impl UpgradeError { + /// The stable `error.*` catalog code a client localizes, when one applies. The English + /// [`Display`](std::fmt::Display) form stays the developer/log detail. + #[must_use] + pub fn error_code(&self) -> Option<&str> { + match self { + Self::Auth(auth) => auth.error_code(), + Self::Malformed { code, .. } + | Self::Unauthorized { code, .. } + | Self::NotProposer { code, .. } + | Self::NotFound { code, .. } + | Self::InFlight { code, .. } + | Self::Unavailable { code, .. } => code.as_deref(), + _ => None, + } + } +} + +// ─── Wire DTOs (mirror the server's transport JSON) ─────────────────────────── + +/// `UpgradePhaseResponse`, as the server serializes it. The three optional members are absent +/// when no ceremony is in flight — which also covers *expired*, because the deadline passing +/// aborts the upgrade and leaves nothing to be in. +#[derive(Debug, Deserialize)] +struct UpgradePhaseWire { + album_id: String, + #[serde(default)] + intent_id: Option, + #[serde(default)] + to_protocol_version: Option, + #[serde(default)] + expires_at: Option, + in_flight: u64, +} + +/// The members this module reads off an RFC 9457 problem body. `intent_id` is the `409`'s +/// extension; the rest are the coded-problem shape every Capsule refusal renders. +#[derive(Debug, Default, Deserialize)] +struct ProblemWire { + #[serde(default)] + code: Option, + #[serde(default)] + detail: Option, + #[serde(default)] + intent_id: Option, +} + +/// The ceremony an album is in, as the proposal answered it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradePhase { + /// The album, echoed by the server. + pub album_id: Uuid, + /// The ceremony now in flight, or `None` when the album is in normal operation. + pub intent_id: Option, + /// The protocol version the fork will be pinned to, when a ceremony is in flight. + pub to_protocol_version: Option, + /// When the window closes, on the **server's** clock — never the client's. + pub expires_at: Option, + /// How many upload sessions are still in flight against this album. + /// + /// The drain signal of the ceremony's step 3: the proposer waits for zero. A count and not + /// a listing, because the proposer needs to know *whether* to wait and has no business + /// seeing other members' upload identifiers to find out. + pub in_flight: u64, +} + +// ─── Client ─────────────────────────────────────────────────────────────────── + +/// The album-upgrade proposal client. Borrows an authenticated [`Session`], so every call +/// rides the SDK's bearer/refresh machinery and no token is handled here. +#[derive(Clone)] +pub struct UpgradeClient { + session: Session, + base_url: String, +} + +impl UpgradeClient { + /// Build a client against the **API root** — the origin the operation paths hang off (e.g. + /// `https://api.example.com`), the same base [`crate::client::AuthenticatedClient`], + /// [`crate::sync::SyncConsumer`] and [`crate::recovery::RecoveryClient`] take. + /// + /// Note that [`crate::directory`] and [`crate::verify`] take a *deeper* base instead. That + /// divergence is real and is noticed in `capsule-server/tests/sdk_client.rs`; it is not + /// this module's to close, and a new module choosing the root is how it narrows. + #[must_use] + pub fn new(session: Session, api_base_url: &str) -> Self { + Self { + session, + base_url: api_base_url.trim_end_matches('/').to_owned(), + } + } + + /// Propose the upgrade `intent_cbor` describes for `album_id`, returning the ceremony the + /// server now holds. + /// + /// `intent_cbor` is the canonical CBOR of a signed `UpgradeIntent` and is sent **verbatim** + /// — this method never re-encodes it, because the signature is over exactly those bytes. + /// + /// # Errors + /// + /// [`UpgradeError::InFlight`] when another ceremony already holds the album (only one may), + /// [`UpgradeError::NotProposer`] when the signing device is not in the published directory, + /// and the rest of [`UpgradeError`] for the remaining refusals. + #[instrument(skip(self, intent_cbor), fields(album_id = %album_id, bytes = intent_cbor.len()))] + pub async fn begin( + &self, + album_id: Uuid, + intent_cbor: &[u8], + ) -> Result { + let url = format!( + "{}/v1/albums/{}/upgrade", + self.base_url, + album_id.hyphenated() + ); + let body = intent_cbor.to_vec(); + let response = self + .session + .execute(|http| { + http.post(&url) + .header(reqwest::header::CONTENT_TYPE, CBOR) + .header(reqwest::header::ACCEPT, JSON) + .body(body.clone()) + }) + .await?; + + let status = response.status(); + if !status.is_success() { + let problem = response.json::().await.unwrap_or_default(); + let error = refusal(status.as_u16(), problem); + tracing::warn!( + status = status.as_u16(), + code = ?error.error_code(), + "album-upgrade proposal refused" + ); + return Err(error); + } + + let wire: UpgradePhaseWire = response + .json() + .await + .map_err(|e| UpgradeError::MalformedResponse(e.to_string()))?; + let phase = decode_phase(wire)?; + tracing::info!( + intent_id = ?phase.intent_id, + in_flight = phase.in_flight, + expires_at = ?phase.expires_at, + "album upgrade proposed; the album is quiesced" + ); + Ok(phase) + } +} + +/// Map a refusal onto its typed variant, keeping the code the server stamped. +/// +/// One readable status table rather than a match buried in the request path. +/// +/// `413` is the transport's body backstop and carries no problem body at all, so it has no +/// code — and this client does not mint one. Every code here is the code the *server* stamped; +/// a code invented on this side would assert that the server said something it did not, and a +/// client localizing it would read the SDK's guess as the server's judgement. The variant +/// already carries the actionable half ("these bytes will not do"), and the English detail +/// carries the reason. +fn refusal(status: u16, problem: ProblemWire) -> UpgradeError { + let ProblemWire { + code, + detail, + intent_id, + } = problem; + let detail = detail.unwrap_or_default(); + match status { + 400 | 415 => UpgradeError::Malformed { code, detail }, + 401 => UpgradeError::Unauthorized { code, detail }, + 403 => UpgradeError::NotProposer { code, detail }, + 404 => UpgradeError::NotFound { code, detail }, + 409 => UpgradeError::InFlight { + intent_id, + code, + detail, + }, + 413 => UpgradeError::Malformed { + code: None, + detail: "the signed upgrade intent exceeds the server's body limit".to_owned(), + }, + 500 => UpgradeError::Unavailable { code, detail }, + other => UpgradeError::Unexpected { status: other }, + } +} + +/// Parse the phase document into its typed shape. +/// +/// The ids become [`Uuid`]s and the deadline a [`jiff::Timestamp`], so a caller compares +/// instants rather than strings — the deadline is the one field in this ceremony where a +/// string comparison would be a correctness bug rather than an inconvenience. +fn decode_phase(wire: UpgradePhaseWire) -> Result { + let album_id = Uuid::parse_str(&wire.album_id) + .map_err(|e| UpgradeError::MalformedResponse(format!("response album_id: {e}")))?; + let intent_id = wire + .intent_id + .as_deref() + .map(Uuid::parse_str) + .transpose() + .map_err(|e| UpgradeError::MalformedResponse(format!("response intent_id: {e}")))?; + let expires_at = wire + .expires_at + .as_deref() + .map(str::parse::) + .transpose() + .map_err(|e| UpgradeError::MalformedResponse(format!("response expires_at: {e}")))?; + Ok(UpgradePhase { + album_id, + intent_id, + to_protocol_version: wire.to_protocol_version, + expires_at, + in_flight: wire.in_flight, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use capsule_i18n::error_codes; + + use super::*; + use crate::auth::{AuthClient, PersistedSession}; + use crate::testmock::{MockRequest, MockResponse, MockServer}; + + const ALBUM: &str = "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60"; + const INTENT: &str = "019a0000-0000-7000-8000-00000000cafe"; + + /// The bytes a real client would hand over: opaque, and deliberately not valid UTF-8 so a + /// re-encoding anywhere on the path would show up. + fn signed_intent() -> Vec { + let mut bytes = b"signed-upgrade-intent".to_vec(); + bytes.extend_from_slice(&[0x00, 0xff, 0xa5]); + bytes + } + + fn album() -> Uuid { + Uuid::parse_str(ALBUM).expect("the literal is a uuid") + } + + /// A session over `base` with a far-future token, so no refresh ever fires and the mock + /// needs no `/refresh` endpoint. + fn session_for(base: &str) -> Session { + AuthClient::new(base) + .expect("a base url") + .resume(PersistedSession { + access_token: "test-access".to_string().into(), + refresh_token: "test-refresh".to_string().into(), + access_expires_at_unix: jiff::Timestamp::now().as_second() + 3_600, + }) + .expect("a session resumes from any pair") + } + + fn phase_json() -> String { + serde_json::json!({ + "album_id": ALBUM, + "intent_id": INTENT, + "to_protocol_version": "2030-01-01", + "expires_at": "2030-01-01T00:05:00Z", + "in_flight": 0, + }) + .to_string() + } + + /// An RFC 9457 problem, as `capsule-server`'s interceptor renders one. + fn problem(status: u16, reason: &str, code: &str, detail: &str) -> MockResponse { + MockResponse::new(status, reason).json_body( + serde_json::json!({ + "type": "about:blank", + "title": reason, + "status": status, + "detail": detail, + "code": code, + }) + .to_string(), + ) + } + + /// The signed bytes reach the documented path, in the documented media type, unchanged — + /// and the phase decodes into typed ids and a real instant. + #[tokio::test] + async fn a_proposal_sends_the_signed_bytes_verbatim_and_decodes_the_phase() { + let intent = signed_intent(); + let expected = intent.clone(); + let seen = Arc::new(AtomicUsize::new(0)); + let counter = seen.clone(); + let server = MockServer::start(move |req: &MockRequest| { + counter.fetch_add(1, Ordering::SeqCst); + assert_eq!(req.method, "POST"); + assert_eq!(req.path, format!("/v1/albums/{ALBUM}/upgrade")); + assert_eq!( + req.header("content-type"), + Some(CBOR), + "the intent must be sent in the media type it was signed in" + ); + assert_eq!( + req.body, expected, + "a re-encoded intent no longer verifies under the proposer's DSK" + ); + assert!( + req.header("authorization") + .is_some_and(|v| v.starts_with("Bearer ")), + "the proposal is owner-scoped" + ); + MockResponse::new(200, "OK").json_body(phase_json()) + }) + .await; + + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let phase = client + .begin(album(), &intent) + .await + .expect("the proposal is accepted"); + + assert_eq!(seen.load(Ordering::SeqCst), 1, "exactly one request"); + assert_eq!(phase.album_id, album()); + assert_eq!( + phase.intent_id, + Some(Uuid::parse_str(INTENT).expect("a uuid")) + ); + assert_eq!(phase.to_protocol_version.as_deref(), Some("2030-01-01")); + assert_eq!( + phase.expires_at, + Some("2030-01-01T00:05:00Z".parse().expect("an instant")) + ); + assert_eq!(phase.in_flight, 0); + } + + /// A second ceremony is refused with the live `intent_id` and the code a client localizes + /// — the refusal an admin actually reads, so neither may be flattened into a status. + #[tokio::test] + async fn a_second_proposal_is_refused_with_the_live_ceremony() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(409, "Conflict").json_body( + serde_json::json!({ + "type": "about:blank", + "title": "Upgrade in flight", + "status": 409, + "detail": format!("album is already upgrading under {INTENT}"), + "code": error_codes::ALBUM_UPGRADE_IN_FLIGHT, + "intent_id": INTENT, + }) + .to_string(), + ) + }) + .await; + + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("only one ceremony may hold an album"); + let UpgradeError::InFlight { intent_id, .. } = &error else { + panic!("expected an in-flight refusal, got {error:?}"); + }; + assert_eq!(intent_id.as_deref(), Some(INTENT)); + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_UPGRADE_IN_FLIGHT) + ); + } + + /// Every refusal the operation declares maps to its own variant and keeps the server's + /// code. A status collapsed into the wrong variant would tell an admin to fix the wrong + /// thing — re-sign an intent that was fine, or wait out a ceremony that does not exist. + #[tokio::test] + async fn each_declared_refusal_keeps_its_own_identity() { + for (status, reason, code) in [ + (400u16, "Bad Request", error_codes::ALBUM_UPGRADE_MALFORMED), + (403, "Forbidden", error_codes::ALBUM_UPGRADE_PROPOSER), + (404, "Not Found", error_codes::ALBUM_UPGRADE_NOT_FOUND), + (500, "Internal Server Error", error_codes::ALBUM_UNAVAILABLE), + ] { + let server = + MockServer::start(move |_req: &MockRequest| problem(status, reason, code, "no")) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("the server refused"); + assert_eq!(error.error_code(), Some(code), "status {status}: {error:?}"); + let matched = match status { + 400 => matches!(error, UpgradeError::Malformed { .. }), + 403 => matches!(error, UpgradeError::NotProposer { .. }), + 404 => matches!(error, UpgradeError::NotFound { .. }), + 500 => matches!(error, UpgradeError::Unavailable { .. }), + _ => false, + }; + assert!(matched, "status {status} took the wrong variant: {error:?}"); + } + } + + /// The body-size backstop carries no problem body, so the client supplies the variant — + /// and **no code**. Minting one here would put words in the server's mouth, and every other + /// code this module reports is the server's own. + #[tokio::test] + async fn a_body_too_large_carries_no_invented_code() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(413, "Payload Too Large") + }) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("the server refused the size"); + assert!( + matches!(error, UpgradeError::Malformed { code: None, .. }), + "got {error:?}" + ); + assert_eq!( + error.error_code(), + None, + "a code the server never sent is not this client's to supply" + ); + } + + /// An undeclared status is surfaced as itself rather than guessed at. + #[tokio::test] + async fn an_undeclared_status_is_surfaced_as_unexpected() { + let server = + MockServer::start(move |_req: &MockRequest| MockResponse::new(418, "I'm a teapot")) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("418 is not in the contract"); + assert!( + matches!(error, UpgradeError::Unexpected { status: 418 }), + "got {error:?}" + ); + assert_eq!(error.error_code(), None); + } + + /// A phase document whose deadline is not an instant is a malformed *response*, not a + /// silent `None` — a dropped deadline would make a client think the ceremony never expires. + #[tokio::test] + async fn an_unparseable_deadline_is_a_malformed_response() { + let server = MockServer::start(move |_req: &MockRequest| { + MockResponse::new(200, "OK").json_body( + serde_json::json!({ + "album_id": ALBUM, + "intent_id": INTENT, + "expires_at": "next tuesday", + "in_flight": 0, + }) + .to_string(), + ) + }) + .await; + let client = UpgradeClient::new(session_for(&server.base_url()), &server.base_url()); + let error = client + .begin(album(), &signed_intent()) + .await + .expect_err("a deadline that is not an instant is not a deadline"); + assert!( + matches!(error, UpgradeError::MalformedResponse(_)), + "got {error:?}" + ); + } +} diff --git a/capsule-sdk/src/upload.rs b/capsule-sdk/src/upload.rs index a15687a3..054e736a 100644 --- a/capsule-sdk/src/upload.rs +++ b/capsule-sdk/src/upload.rs @@ -309,6 +309,9 @@ impl UploadTransport { /// Build a transport over a fixed bearer token (tests; callers that already /// hold a live token). Same URL layout as [`Self::with_session`]. + /// + /// `http` **must** come from [`crate::net::http_builder`] or [`crate::net::http_client`]: a + /// client built any other way sends no protocol handshake, and every gated route refuses it. pub fn with_static_token( http: reqwest::Client, base_url: impl Into, diff --git a/capsule-sdk/src/verify.rs b/capsule-sdk/src/verify.rs index 73952fc4..0680799c 100644 --- a/capsule-sdk/src/verify.rs +++ b/capsule-sdk/src/verify.rs @@ -106,6 +106,9 @@ impl VerifyTransport { } /// Build a transport over a fixed bearer token (tests; callers holding a live token). + /// + /// `http` **must** come from [`crate::net::http_builder`] or [`crate::net::http_client`]: a + /// client built any other way sends no protocol handshake, and every gated route refuses it. pub fn with_static_token( http: reqwest::Client, base_url: impl Into, diff --git a/capsule-server/.env.example b/capsule-server/.env.example new file mode 100644 index 00000000..ab01764b --- /dev/null +++ b/capsule-server/.env.example @@ -0,0 +1,208 @@ +# Every setting `capsule-server` reads. Copy to `.env` and export it, or set these in your +# process manager — there is no configuration file: `--config PATH` is accepted and refused, and +# `capsule-server/src/config.rs` records why. +# +# Precedence, highest first: command-line flag, then the environment, then the built-in default. +# `FOO=` with an empty value counts as unset. +# +# A missing or malformed setting is reported with **every** other fault in one message and exit +# code 2, so bringing a deployment up is one read of one log line rather than one restart per +# variable. + +# ── Required to serve ──────────────────────────────────────────────────────────────────────── +# +# PKCS#8 v1 DER-encoded Ed25519 private key, base64. Access and refresh tokens are signed with +# it, and `.well-known/capsule/server-info` publishes the public half **derived from it** — so +# there is no second copy to paste wrongly. +# +# openssl genpkey -algorithm ed25519 -outform DER | base64 -w 0 +# +# **Deliberately left commented out.** The value below is the retired deployment's own example: +# it is public, anyone who has read this repository can forge tokens under it, and a +# `cp .env.example .env` that silently produced a forgeable deployment is exactly the accident +# worth preventing. Commented, the server refuses and names the variable, which is the right +# outcome for a configuration you have not finished writing. +# +# `mise run serve-memory` falls back to this same key for development, and binds loopback only. +# JWT_ED25519_DER=MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF + +# Where ciphertext blobs are written. There is no object store: this filesystem path *is* the +# blob backend, and `capsule-server gc|purge|scrub` read the same tree. +# +# Under `target/` so it is already git-ignored and `cargo clean` takes it with it. That is the +# right trade for the development profile, whose index does not survive a restart either — a +# blob whose index row is gone is an orphan, which is exactly what `scrub` will tell you. +# A real deployment points this at durable storage. +# +# There is deliberately **no default**: a server that silently wrote blobs into the current +# directory would be worse than one that refuses to start. `UPLOAD_DIR` is the retired name and +# is still honoured, with a warning. +BLOB_ROOT=./target/capsule-server-blobs + +# ── The listener ───────────────────────────────────────────────────────────────────────────── +# +# `SERVER_HOST` must be an IP address to bind. `--listen HOST:PORT` overrides both. +# Port 0 asks the operating system to choose, and the chosen address is written to stdout. +SERVER_HOST=127.0.0.1 +SERVER_PORT=3000 + +# This deployment's canonical origin — the `server_id` every published record carries, and the +# issuer an authenticator app shows beside a second-factor code. In production, your public +# domain (e.g. api.capsule.example). +SERVER_DOMAIN=localhost + +# The absolute base URL clients reach the versioned API at. `server-info` derives the published +# auth endpoints by appending to it, so the `/v1` prefix is not optional. +# Default: http://{SERVER_DOMAIN}:{SERVER_PORT}/v1 +# API_BASE_URL=https://api.capsule.example/v1 + +# Where federated peers pull from, published as `server-info.federation_url`. Federation reuses +# the versioned API itself — `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` under a capability +# bearer — so the value is this deployment's API base URL. **Unset means this server does not +# federate**: the record publishes no endpoint, and minting, refreshing or revoking a capability +# and federated report intake all refuse with `error.federation.not_configured`. A capability +# minted while it was set still verifies; configuration does not un-mint a token. +# FEDERATION_URL=https://api.capsule.example/v1 + +# TLS is **not** terminated here. `design/cryptography/failure-modes.md` puts HTTPS on the +# ingress or reverse proxy; there is no certificate setting and Kynos's `tls` feature is off. + +# ── Backends ───────────────────────────────────────────────────────────────────────────────── +# +# Postgres and Valkey are both required for a deployment, and both URLs are read. +# +# **DATABASE_URL is read** (#402). A durable `serve` opens the pool from it, and refuses to start +# against a database whose schema is not the one the binary was built for — naming the command +# that fixes it. The server never migrates on its own: run +# +# capsule-server-migration up +# +# once per deployment, before the replicas roll. A server that migrated on start would run the +# same schema change once per replica during a rolling deploy. +# +# **VALKEY_URL is read** (#403). `capsule-server serve` connects to it before anything else is +# assembled, proves it answers `PING`, and refuses to boot — naming the failure, never the URL, +# which carries the password — if it does not. `redis://` or `rediss://` (TLS in rustls); the +# session, upload-session, ceremony, cohort and counter stores live there. +# +# - `capsule-server serve` with neither `VALKEY_URL` nor `--memory` refuses and names the +# variable, which is the refusal `store/mod.rs` has always documented; +# - `capsule-server serve --memory` (`mise run serve-memory`) runs on the in-crate in-memory +# adapters over a real filesystem blob store. An explicit development act, never a fallback. +# +# `mise run serve-deps` brings both services up from capsule-server/compose.yaml. +DATABASE_URL=postgresql://capsule:capsule@localhost:5432/capsule +VALKEY_URL=redis://127.0.0.1:6379 + +# `memory` selects the in-memory adapters, exactly as `--memory` does. The flag is the primary +# spelling; this exists for a process manager that cannot add an argument. +# CAPSULE_PROFILE=memory + +# ── The rest of the key material ───────────────────────────────────────────────────────────── +# +# The sync-cursor MAC key is HKDF-SHA256-derived from JWT_ED25519_DER when unset, which is the +# right default for a single-server deployment: a cursor MAC and a session token are the same +# trust domain — both are operational secrets this server holds to authenticate its own output — +# so deriving one from the other gives nobody anything they did not already have. Set it +# explicitly only to rotate it independently, or to share it across replicas. +# +# SYNC_CURSOR_MAC_KEY= # base64, exactly 32 bytes +# +# **ATTESTATION_KEY_SEED is required for a real deployment, and is deliberately not derived.** +# The attestation key signs custody receipts and has to be *distinct* from the token-signing key: +# a receipt that verified under the operational key would let anything holding that key +# manufacture custody evidence. Deriving this seed from JWT_ED25519_DER would collapse exactly +# that distinction — a different HKDF label over the same input is not a separation — so `serve` +# without `--memory` refuses until it is set. `serve --memory` derives it, because a development +# server's whole state is discarded when it exits. +# +# openssl rand -base64 64 | tr -d '\n' +# +# **Deliberately left commented out**, exactly as JWT_ED25519_DER above is, and for a second +# reason as well. This file is read by more than a shell: `podman --env-file`, compose's +# `env_file:`, systemd's `EnvironmentFile=` and a Kubernetes ConfigMap all take a line +# literally — no expansion, no command substitution — so a placeholder shaped like a shell +# expression would be *stored* as the value. `capsule-server gc|purge|scrub` would then refuse a +# malformed seed, even though those commands are built to need no key material at all. +# +# The placeholder is plain text for the same reason: sourced by bash, `$(...)` would run and +# print `command not found`, which is noise at best and an execution seam at worst. +# +# base64, 32 or 64 bytes; 32 is expanded to 64, domain-separated. +# ATTESTATION_KEY_SEED=replace-with-your-own-base64-seed + +# ── Single sign-on (OIDC relying party) ────────────────────────────────────────────────────── +# +# Absent means the path is off: `server-info` publishes `auth.oidc: null` and the authorize +# answers 404. Set an issuer and a client id together — half a relying party is refused. The +# issuer is `https`, or `http` on a loopback IP literal for development (never `localhost`); +# the dex service in compose.yaml (`--profile oidc`) is the local one. +# OIDC_ISSUER=http://127.0.0.1:5556/dex +# OIDC_CLIENT_ID=capsule +# +# Optional. Absent is a public client, PKCE-only — what a CLI or desktop app is (RFC 8252 §8.5). +# OIDC_CLIENT_SECRET= +# +# The web client's callback, admitted exactly. Held to the issuer's scheme rule. +# OIDC_REDIRECT_URL=https://app.capsule.example/oidc/callback +# +# Admit a redirect to `http://127.0.0.1:{any port}/…` or `http://[::1]:{any port}/…` — what a +# CLI's or desktop client's loopback listener needs (RFC 8252 §7.3). **Off by default**: it is +# the one knob that widens where the server will send a person back to. Turn it on for a +# deployment with such a client; the `capsule auth login --oidc` flow (issue #461) will say so. +# OIDC_ALLOW_LOOPBACK_REDIRECT=false +# +# A PEM bundle of additional trust anchors for reaching a provider behind a private CA. Read +# once at boot and refused by name — never by content — if it is missing, not a certificate +# bundle, or empty. Added to the public roots, never replacing them. +# OIDC_CA_BUNDLE=/etc/capsule/idp-ca.pem + +# ── The protocol window ────────────────────────────────────────────────────────────────────── +# +# Both ends inclusive, `YYYY-MM-DD`, validated as dates, and both published on every response as +# `X-Capsule-Protocol-Min`/`-Max`. A write with a protocol date outside the window is refused +# with 426; a read is admitted at any date. The defaults are the policy's year window +# (`capsule-server/src/upload/policy.rs`), which the version `capsule-core` speaks sits inside; +# `PROTOCOL_MIN=PROTOCOL_MAX` is a legitimate choice. Narrow `PROTOCOL_MIN` only with a +# deprecation announcement behind it. +# PROTOCOL_MIN=2026-01-01 +# PROTOCOL_MAX=2026-12-31 + +# The semver client build below which this server will stop answering, published on every +# response as `X-Capsule-Min-Client-Build`. Advisory: nothing refuses on it, and `0.0.0` — the +# default — means no cutoff has been announced. MAJOR.MINOR.PATCH, validated. +# MIN_CLIENT_BUILD=0.0.0 + +# ── Operational knobs ──────────────────────────────────────────────────────────────────────── +# +# How long a blob sits at zero references before `gc` may sweep it. The design's range is 24-72 +# hours: long enough for an in-flight finalization retry to re-reference it, short enough that an +# orphan is not held forever. `gc --grace-window-hours` overrides it. +# GC_GRACE_WINDOW_HOURS=24 + +# How long a shutdown may take to drain. 25 seconds by default, under the usual 30-second +# orchestrator termination window. +# SHUTDOWN_TIMEOUT_SECONDS=25 + +# The accepted-connection ceiling, across every listener. +# MAX_CONNECTIONS=10000 + +# The account lockout, which is two numbers: how many consecutive failures inside the window lock +# an account, and how long it then stays locked, measured from the last counted failure. +# +# It decays because nothing else can clear it: there is no unlock operation on any surface, and +# `login`, `reauthenticate` and `password` each refuse a locked account before verifying +# anything — so a lockout that never expired would be a permanently lost account. An attempt made +# *during* a lockout is refused without extending it, so nobody can hold somebody else's account +# shut by hammering the endpoint. A threshold of zero is refused rather than clamped. +# LOCKOUT_MAX_ATTEMPTS=10 +# LOCKOUT_WINDOW_SECONDS=900 + +# `json` (one object per event, for a log shipper) or `pretty` (for a person). Defaults to +# `pretty` in a debug build and `json` in a release one. Everything is written to **stderr**, so +# stdout stays a data channel: `gen-openapi` writes a path there and the operator commands write +# their report. +# LOG_FORMAT=json + +# The usual `tracing` filter. Defaults to `debug` in a debug build and `info` in a release one. +# RUST_LOG=info diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index a79b4def..d134a8c0 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -44,9 +44,6 @@ kynos = { workspace = true } # refuse-by-default invariants — and the crypto types they read. The default `native` feature pulls # SQLite, sqlite-vec, OpenMLS and libcrux, none of which a key-free server touches. capsule-core = { path = "../capsule-core", default-features = false } -# The framework-free wire contracts (slice `S-C27`). The response taxonomy lives here so it -# outlives whichever framework renders it. -capsule-wire = { path = "../capsule-wire" } serde = { workspace = true } # The state ports (slice `S-C29`). `thiserror` because these are a library surface; # `jiff` because every record and every TTL is a time and chrono is banned; `tracing` because @@ -73,7 +70,18 @@ jsonwebtoken = { workspace = true } # the three features a ranged read and a durable append need, and nothing more. Already the # workspace's async runtime (design/dependencies.md, "Async runtime") and already in this # crate's tree through kynos — this promotes it from a dev-dependency, it does not add a crate. -tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +# `rt-multi-thread` and `macros` on top of those three for the binary: `serve` runs an accept +# loop that has to make progress while a request is awaiting the disk, and `#[tokio::main]` is +# the attribute that starts it. Kynos's `server` feature already unifies `net`/`signal`/`rt` in; +# these two are named here rather than relied on through it, so a Kynos feature change cannot +# silently take the binary's runtime away. +tokio = { workspace = true, features = [ + "fs", + "io-util", + "rt", + "rt-multi-thread", + "macros", +] } # The `.reason.json` a quarantined blob keeps beside it (design/filesystem/server.md). The # encoding belongs to the filesystem *adapter*, not to the port's record — which derives no # serde traits, exactly as the state ports' records do not — so this is used in one file. @@ -106,13 +114,98 @@ totp-rs = { workspace = true } # design/dependencies.md. subtle = { workspace = true } -# `gen_openapi` only. Both are already in the lock file via `capsule-api`'s equivalent binary, -# and this mirrors it deliberately: two committed documents, two identical drift guards, so the -# changeover at parity is a re-point rather than a new mechanism. +# Argon2id for the development profile's account adapter (`auth::credential`, +# `auth::accounts_memory`). Already a workspace dependency (consumed by `capsule-core` for the +# escrow key wrap) and already pinned by the Argon2id row in design/cryptography/primitives.md, +# so this adds no crate and opens no new domain. It is the *server-side password verification* +# parameter set rather than the escrow KDF's tiered one — see `auth::credential` for why those +# are unrelated numbers. The Postgres adapter (#402) uses the same helper. +argon2 = { workspace = true } +# The Valkey adapters behind the state ports and the counter port (`store::valkey`, +# `counter::valkey`; slice `S-C29`'s owed half, #403). `redis-rs` is the crate the Rust +# Architecture Decisions name for exactly this; the workspace pin carries `tokio-comp` and +# `connection-manager`, and two features are added here: `tokio-rustls-comp`, so a `rediss://` +# URL terminates TLS in rustls and never in native-tls, and `script`, because every multi-key +# mutation is one Lua script rather than a `WATCH`/`MULTI` round trip a multiplexed connection +# could not hold. No `bb8`: one `ConnectionManager` is a self-reconnecting multiplexed +# connection, which is the whole of what a server talking to one Valkey needs. See the Volatile +# state row in design/dependencies.md. +redis = { workspace = true, features = ["tokio-rustls-comp", "script"] } +# The OIDC relying party's egress (slice `S-N1`): the discovery document, the JWK Set and the +# form-encoded token exchange, against the configured identity provider and nothing else. The +# one outbound HTTP client in this crate. Already in the lock file through `capsule-sdk`, so this +# promotes an edge rather than adding a crate; the HTTP-client row in design/dependencies.md +# carries the scope. rustls-tls only, like everywhere else Capsule holds a TLS stack. +reqwest = { workspace = true } +# The `capsule-server` binary: subcommand parsing, and the error report a startup failure prints. +# `clap` and `color-eyre` were already here for the `gen_openapi` binary this replaces; the +# binary is now one `capsule-server` with `serve | gc | purge | scrub | gen-openapi` +# subcommands, for the reason `capsule-cli` is one binary — four executables would each carry +# their own copy of the config loader and the adapter seam. clap = { version = "4.6.1", features = ["derive"] } color-eyre = "0.6.5" +# The Postgres adapters for the durable ports (#402). `default-features = false` is +# load-bearing rather than tidy: sea-orm's defaults include `with-chrono`, and +# design/dependencies.md makes `cargo tree -i chrono -e no-dev` a review-blocking gate that must +# keep resolving to `capsule-cli/entity` and sea-orm's own internals. Without a datetime feature +# there is no Rust binding for `TIMESTAMPTZ` at all, which is why every instant in the server's +# schema is a `BIGINT` of epoch microseconds converted in `postgres::time`. +# +# Three features and no more: `macros` for the `FromQueryResult` derive the adapters' private row +# structs use, `sqlx-postgres` for the driver, `runtime-tokio-rustls` because the workspace's TLS +# rule is rustls only. Already a workspace dependency with a row in design/dependencies.md (ORM), +# consumed until now only by `capsule-cli/entity`. +# +# Spelled out rather than `workspace = true`, and only because cargo forbids the one thing this +# needs: a member may add features to an inherited dependency but may not turn the workspace's +# defaults **off**. `capsule-cli` and `capsule-cli/entity` inherit sea-orm *with* its defaults — +# that is exactly why the chrono gate names `capsule-cli/entity` — so flipping the workspace +# entry would change their build. The version is the workspace pin, restated; keep the two in +# step when sea-orm is repinned. +sea-orm = { version = "1.1.20", default-features = false, features = [ + "macros", + "sqlx-postgres", + "runtime-tokio-rustls", +] } +# The log stream the binary installs (`cli::install_tracing`). `env-filter` for `RUST_LOG` and +# `json` for the one-object-per-event rendering a log shipper wants; both are on in the workspace +# pin, and the crate already has a row in design/dependencies.md. Written to **stderr**, so +# `gen-openapi` and the operator commands keep a parseable stdout. +tracing-subscriber = { workspace = true } [dev-dependencies] +# The server's schema, applied by the Postgres conformance harness. A **dev-dependency only**, +# and that is the decision rather than an oversight: `sea-orm-migration` pulls sea-orm with its +# default `with-chrono` feature, so a normal dependency here would break the +# `cargo tree -i chrono -e no-dev` gate at design/dependencies.md. The cost is that `serve` +# cannot migrate — it reads `seaql_migrations` and refuses to boot on a mismatch +# (`postgres::assert_schema_current`), which is also the safer rollout: a server that migrates on +# start migrates once per replica during a rolling deploy. +capsule-server-migration = { path = "migration" } +# The containers the conformance suites run against: a Postgres for `postgres::testing`, behind +# `CAPSULE_TEST_POSTGRES=1`, and a Valkey for `tests/valkey.rs`, behind `CAPSULE_TEST_VALKEY=1` +# (or `CAPSULE_TEST_VALKEY_URL` for a server already running). Both suites run the same cases the +# in-memory doubles pass, so `mise run test-rust` still needs no podman and `.config/nextest.toml` +# puts both in the one-thread `containers` group. +# +# Both crates were pinned at the workspace and consumed by nothing — `xtask`'s +# `PLANNED_WORKSPACE_DEPENDENCIES` sanctioned them as planned because "no test starts a +# container yet". These are the tests that start one, so the two entries leave that list. +# Dev-only: the served binary links neither. +# +# `postgres` and `redis` are on the workspace pin; `valkey` is added here because it is the image +# `capsule-server/compose.yaml` runs and no other member needs it. +testcontainers = { workspace = true } +testcontainers-modules = { workspace = true, features = ["valkey"] } + +# Throwaway directories on disk. `tests/binary.rs` gives the spawned server a blob root of its +# own and deletes it afterwards; `tests/sdk_client.rs` puts a **real** `capsule_core::Workspace` +# there, because the only way to prove the upload ladder puts an asset on this server's feed is +# to push the ladder's own input — a hand-built bundle would agree with a wrong ladder. +# Already the workspace's scratch-directory crate (`capsule-core`, `capsule-sdk`, +# `capsule-core-ffi` all dev-depend on it) and already in the lock file. +tempfile = "3" + # `test-util` carries `kynos::test::TestClient`, which drives a built `Service` in-process — # no socket, no port, no runtime flavour — and the two conformance assertions the suite is # built around. `server` is on top of it for exactly one test: `tests/sdk_client.rs` binds the @@ -128,9 +221,19 @@ capsule-sdk = { path = "../capsule-sdk" } # to look at them. Test-only, and the only place this crate touches the type. secrecy = { workspace = true } # `macros` and `rt-multi-thread` on top of what the blob store's adapter already needs: the -# `#[tokio::test]` attribute and a runtime to drive it. Test-only, so the served binary carries -# neither. -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# `#[tokio::test]` attribute and a runtime to drive it. `net` for the in-process mock identity +# provider `tests/support/idp.rs` binds on loopback, so the OIDC relying party is exercised +# over a real socket. Test-only, so the served binary carries none of the three. +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } +# The TLS half of the mock identity provider, for the `OIDC_CA_BUNDLE` case: a private CA and +# a leaf it signs (`rcgen`), served by `tokio-rustls`, so "the relying party trusts the operator's +# CA and nothing else" is proven over a real handshake. The same three crates, versions and +# features `capsule-sdk` pins for the peering stack - `ring` provider throughout, matching the +# rustls `reqwest` links - so nothing new enters the lock file. Dev-only: the served binary +# terminates no TLS (design/cryptography/failure-modes.md). +rcgen = { version = "0.13", default-features = false, features = ["ring", "crypto"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } # The feed's manifest bytes arrive base64-encoded, and a test that asserts byte equality has to # decode them. A normal dependency of the crate since `S-C2`; listed here because the suite # uses it directly. @@ -145,3 +248,4 @@ totp-rs = { workspace = true } # rather than reading a checked-in PEM, because a private key in the repository is a private # key somebody eventually reuses. `ring` is a normal dependency since `S-C2` (the cursor MAC); # it stays listed here only so the intent of the test usage is recorded. + diff --git a/capsule-server/README.md b/capsule-server/README.md index 0d443cc8..f746cbb8 100644 --- a/capsule-server/README.md +++ b/capsule-server/README.md @@ -25,18 +25,43 @@ microservices. `routes` is the only module that knows about HTTP; everything und framework-free and testable without a router, which is why the operator workers (`gc`, `scrub`) have no wire surface at all. -Authentication state and upload-session state stay behind separate Capsule-owned ports with -Postgres, Valkey and deterministic in-memory adapters. There is no generic CAS, transfer or TTL +Authentication state and upload-session state stay behind separate Capsule-owned ports whose +adapters are Valkey and a deterministic in-memory double — not Postgres, which +[Filesystem — Server](../capsule-docs/src/content/docs/design/filesystem/server.md) rejects for a +session table. The durable records go to Postgres. There is no generic CAS, transfer or TTL abstraction, and none is planned. -## Every adapter is in-memory +## Every port has a double, and four of them have Postgres besides -Every port here has a deterministic in-memory adapter and a conformance suite, and **no Postgres, -Valkey or filesystem adapter is written** except the blob store's. That is an ordering rather than -an omission: the contract and its suite are what a real adapter is written *against*, and a port -with two implementations before it has one suite is a port whose implementations will disagree. +Every port here has a deterministic in-memory adapter and a conformance suite, and that ordering +was deliberate rather than an omission: the contract and its suite are what a real adapter is +written *against*, and a port with two implementations before it has one suite is a port whose +implementations will disagree. -It is also why this crate's whole test suite runs without a container. +Four ports now have the second implementation (#402) — the asset index, the account cluster, the +device-cohort map and the quota ledger — each passing the same case list as its double, against a +Postgres container. The remaining durable ports are #446's; the volatile ones are Valkey's (#403). + +**The test suite still runs without a container.** Every Postgres case is gated on +`CAPSULE_TEST_POSTGRES=1` and prints one line naming itself when it is skipped: + +```sh +cargo nextest run -p capsule-server # green, and says what it did not prove +CAPSULE_TEST_POSTGRES=1 cargo nextest run -p capsule-server -E 'test(postgres_conformance)' +``` + +On a rootless podman host that also needs `DOCKER_HOST` pointing at the user socket and +`CAPSULE_TEST_CONTAINER_USERNS=keep-id`; `capsule_server::postgres::testing` says why. + +Two of the in-memory adapters live beside the ports rather than in `tests/support/`: `auth::accounts_memory` +and `auth::totp`'s `InMemoryTotp`. The account ports' docs say a double in `src/` would be "a fake +credential directory shipped inside the server binary", and that reasoning is about a **double** — +`tests/support/mod.rs`'s, which accepts whatever password it was told to accept. These verify with +the same Argon2id helper (`auth::credential`) a Postgres adapter will, store PHC strings and no +plaintext, take the timing-equalized miss, and lock an account out after enough failures — for a +window, because no route and no operator command can clear a lockout, so one that never expired +would be a permanently lost account. What they lack is durability, which is what makes them a +development profile rather than a deployment. ## Running the tests @@ -60,8 +85,62 @@ mise run openapi-kynos # regenerate mise run openapi-check-kynos # verify no drift ``` +## Running it + +```bash +mise run serve-memory # a server you can point a client at +``` + +One binary, several subcommands: + +```text +capsule-server [--config PATH] + serve [--listen HOST:PORT] [--memory] [--blob-root PATH] + gc [--apply] [--grace-window-hours N] --memory --blob-root PATH + purge [--apply] [--limit N] --memory --blob-root PATH + scrub [--deep] [--budget BYTES] --memory --blob-root PATH + gen-openapi [FILE] [--check] + +`--memory` is written as required on the three operator commands because today it is — and the +durable index is no longer why. Both workers read a store that has no durable adapter: the +collector marks a blob on one pass and sweeps it on a later one, so a mark store that forgets can +only ever mark (#446), and the scrub reconciles the index against the upload sessions, which is +how it tells a live transfer from an orphan (#403). Without `--memory` they refuse and say so. +``` + +`config` reads every setting an operator decides — command-line flag over environment over +default — and reports **every** fault in one message, because an operator otherwise restarts the +process once per variable. `capsule-server/.env.example` is the full list. There is no +configuration file; `--config PATH` is accepted and refused with a sentence saying why. + +A real deployment supplies **two** independent secrets: `JWT_ED25519_DER` signs session tokens, +and `ATTESTATION_KEY_SEED` signs custody receipts. The second is deliberately not derived from +the first — a receipt that verified under the operational key would let anything holding that key +manufacture custody evidence, and a different HKDF label over the same input is not a separation. +`serve --memory` derives it, because a development server's whole state is discarded on exit. + +`boot::assemble` is the one composition root. `--memory` takes every in-crate adapter over a real +filesystem blob store; anything else refuses, so a deployment that forgot `VALKEY_URL` fails +closed rather than coming up on state it loses at the next restart. + +Logs go to stderr. stdout is a data channel: `serve` writes one `listening on ` line there, +which is how a `--listen 127.0.0.1:0` caller learns its port. + +`gc`, `purge` and `scrub` need a blob root and no key material at all. Dry run is the default for +the two that write; `scrub` mutates nothing and exits non-zero on a non-empty report. + ## What is owed -There is **no binary, no configuration loading, and no Postgres or Valkey adapter.** Nothing reads -`JWT_ED25519_DER`, `SYNC_CURSOR_MAC_KEY` or `ATTESTATION_KEY_SEED` yet, so there is no way to run -this server outside its own tests. See `SLICES.md`, lane C. +**No Valkey adapter, and nine durable ports still without a Postgres one.** `DATABASE_URL` is +read: a durable `serve` opens the pool from it and refuses a schema it was not built for, naming +`capsule-server-migration up`. `VALKEY_URL` is not read yet, so a durable `serve` gets through +the Postgres half and then refuses, naming #403 — the session, upload-session, ceremony and +counter state. The remaining durable adapters (albums, the device directory, moderation, shares, +drops, escrow, revocations, the collector's marks, receipts, TOTP) are #446. + +Refusing rather than filling those ports with in-memory adapters is the point: +[Filesystem — Server](../capsule-docs/src/content/docs/design/filesystem/server.md) says required +means required, and a server that came up holding session state it will lose on the next restart +is worse than one that does not start. What `--memory` therefore buys is not durability — the +blob store and, on the durable path, Postgres are the durable halves — but a running surface to +write the remaining adapters against and to point a client at. diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml new file mode 100644 index 00000000..d06770b8 --- /dev/null +++ b/capsule-server/compose.yaml @@ -0,0 +1,135 @@ +# The two external services a Capsule deployment needs, for local development. +# +# Bring them up with `mise run serve-deps` and the server up with `mise run serve`. The two are +# deliberately separate tasks: a task that silently starts containers is a task that leaks them. +# +# **Valkey is read (#403); Postgres is not yet (#402).** `capsule-server serve` connects to +# `VALKEY_URL` and then refuses naming `DATABASE_URL` until the Postgres adapters land, so the +# way to run a server is still `mise run serve-memory`. This file exists now because the compose stack went +# with the Salvo tree in `S-C59` and re-deriving it later is re-doing work — and because +# dependabot needs a live file to track the images against. +# +# There is deliberately **no object store**. Blobs are written to the filesystem `BLOB_ROOT`, +# which is the blob backend and not a cache in front of one (design/filesystem/server.md); a +# MinIO service used to sit here and was never wired to anything. +# +# Podman-first: the `:Z,U` volume labels relabel for SELinux and chown to the container user, and +# `docker compose` accepts both. Every image is fully qualified, because podman prompts for a +# registry otherwise. + +services: + postgres: + image: docker.io/library/postgres:18 + ports: + # Loopback only. `"5432:5432"` publishes on every interface, and these are development + # credentials in a checked-in file — a database anyone on the network can open with + # capsule/capsule. + - "127.0.0.1:5432:5432" + environment: + # Development credentials, matching capsule-server/.env.example's DATABASE_URL. Override + # them in the environment or a .env file; nothing here is a secret worth keeping. + POSTGRES_USER: capsule + POSTGRES_PASSWORD: capsule + POSTGRES_DB: capsule + healthcheck: + # `serve-deps` returns as soon as compose has started the containers, so a developer who + # runs `mise run serve` immediately afterwards would otherwise race the database's own + # startup. `pg_isready` is what makes "up" mean "accepting connections". + test: ["CMD-SHELL", "pg_isready -U capsule -d capsule"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - postgres_data:/var/lib/postgresql/data:Z,U + + valkey: + image: docker.io/valkey/valkey:9.0.4 + ports: + # Loopback only, and here it is load-bearing rather than tidy: `--protected-mode no` below + # is safe *because* nothing outside this machine can reach the port, and the two have to + # agree. + - "127.0.0.1:6379:6379" + environment: + # Carried over from the retired deployment verbatim, because these are the flags the + # session and upload-session stores were sized against: `volatile-lru` so a key with a TTL + # is what gets evicted under pressure and a key without one never is, `appendonly` with + # `everysec` so a restart loses at most a second of session state rather than all of it, + # and the `lazyfree-*` set so an eviction does not block the command that triggered it. + # `protected-mode no` is a development-only concession, and it is only a concession because + # the port above is published to loopback rather than to every interface. + # + # These flags do reach the server. `VALKEY_EXTRA_FLAGS` is often described as a Bitnami + # convention, but the official image honours it too: `valkey/valkey:9.0.4`'s own + # `/usr/local/bin/docker-entrypoint.sh` ends with `exec "$@" $VALKEY_EXTRA_FLAGS`, + # unquoted, so the variable word-splits into arguments of `valkey-server`. + VALKEY_EXTRA_FLAGS: "--maxmemory 4G --maxmemory-policy volatile-lru --save 900 1 300 10 --appendonly yes --appendfsync everysec --no-appendfsync-on-rewrite yes --auto-aof-rewrite-percentage 100 --auto-aof-rewrite-min-size 64mb --lazyfree-lazy-eviction yes --lazyfree-lazy-expire yes --lazyfree-lazy-server-del yes --replica-lazy-flush yes --protected-mode no --tcp-keepalive 60 --loglevel notice --slowlog-log-slower-than 10000 --slowlog-max-len 128 --io-threads 4" + healthcheck: + test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - valkey_data:/data:Z,U + + # A development identity provider for the OIDC relying party (slice `S-N1`, issue #407). Not a + # dependency of the server — a deployment without `OIDC_ISSUER` never talks to it — so it is a + # separate profile: `podman compose -f capsule-server/compose.yaml --profile oidc up -d dex`. + # + # Point the server at it with + # + # OIDC_ISSUER=http://127.0.0.1:5556/dex + # OIDC_CLIENT_ID=capsule + # OIDC_ALLOW_LOOPBACK_REDIRECT=true + # + # and no client secret: `capsule` is a **public** client, which is what a CLI or desktop app + # is (RFC 8252 §8.5), and dex admits `http://127.0.0.1:*` and `http://localhost:*` redirects + # for public clients without listing them — so the CLI's ephemeral loopback listener works + # unconfigured at dex, and `OIDC_ALLOW_LOOPBACK_REDIRECT=true` (off by default) admits the + # same on Capsule's side. The issuer is plain `http` on loopback, the one carve-out + # `auth::oidc::discovery` makes; a real deployment's issuer is `https`. + # + # Sign in as `admin@example.com` / `password` (the bcrypt below is dex's own documented hash + # for that word). Development credentials in a checked-in file, like Postgres's above; the + # port is loopback-only for the same reason. + # + # The dex configuration is inline (`configs.*.content`) rather than a file beside this one, + # so the stack stays one file. docker compose ≥ 2.23.1 and podman-compose ≥ 1.2.0 read it. + dex: + image: ghcr.io/dexidp/dex:v2.44.0 + profiles: ["oidc"] + command: ["dex", "serve", "/etc/dex/config.yaml"] + ports: + - "127.0.0.1:5556:5556" + configs: + - source: dex_config + target: /etc/dex/config.yaml + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5556/dex/healthz || exit 1"] + interval: 5s + timeout: 3s + retries: 12 + +configs: + dex_config: + content: | + issuer: http://127.0.0.1:5556/dex + storage: + type: memory + web: + http: 0.0.0.0:5556 + oauth2: + skipApprovalScreen: true + staticClients: + - id: capsule + name: Capsule (development) + public: true + enablePasswordDB: true + staticPasswords: + - email: admin@example.com + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: admin + userID: 08a8684b-db88-4b73-90a9-3cd1661f5466 + +volumes: + postgres_data: + valkey_data: diff --git a/capsule-server/migration/Cargo.toml b/capsule-server/migration/Cargo.toml new file mode 100644 index 00000000..8ef53340 --- /dev/null +++ b/capsule-server/migration/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "capsule-server-migration" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +# The server's schema, as an operator binary and as a library the conformance harness applies. +# +# `capsule-server` takes this crate as a **dev-dependency only**, which is a deliberate +# constraint rather than an accident of layering: `sea-orm-migration` pulls `sea-orm` with its +# default features, `with-chrono` among them, and design/dependencies.md makes +# `cargo tree -i chrono -e no-dev` a review-blocking gate. Keeping the migrator out of the +# server's normal dependency graph is what keeps that gate resolving to `capsule-cli/entity` +# and sea-orm's own internals. The consequence is recorded where it is felt: +# `capsule_server::postgres::assert_schema_current` refuses to boot on a pending migration and +# names the command below, rather than running `Migrator::up` on `serve`. + +[lib] +# Not `migration`: `capsule-cli/migration` already publishes a lib of that name, and two libs +# called `migration` in one workspace is legal and a trap for anybody writing `use migration::`. +name = "server_migration" +path = "src/lib.rs" + +[[bin]] +name = "capsule-server-migration" +path = "src/main.rs" + +[dependencies] +# The runtime `cli::run_cli` drives. Already the workspace's async runtime. +tokio = { workspace = true } +# The migrator itself, pinned at the workspace with `sqlx-postgres` and `runtime-tokio-rustls` +# — the server's schema is PostgreSQL's, and the TLS rule is rustls only. See the ORM row in +# design/dependencies.md; this crate opens no new dependency domain. +sea-orm-migration = { workspace = true } diff --git a/capsule-server/migration/src/lib.rs b/capsule-server/migration/src/lib.rs new file mode 100644 index 00000000..6059f67a --- /dev/null +++ b/capsule-server/migration/src/lib.rs @@ -0,0 +1,46 @@ +//! The server's PostgreSQL schema, one migration per ordinal. +//! +//! # What the six ordinals cover +//! +//! The four durable ports issue #402 lands adapters for — the asset index, the account +//! cluster, the device-cohort map and the quota ledger — plus album membership (`S-C51`, #405) +//! and federation's capabilities, revocation list and peers (`S-E2`, #406). +//! The remaining durable ports keep their +//! in-memory adapters and gain ordinals with their adapters, so a migration never describes a +//! table nothing reads. +//! +//! # Every instant is a `BIGINT` of microseconds since the Unix epoch +//! +//! Not `TIMESTAMPTZ`. Binding one needs sea-orm's `with-chrono` or `with-time`, and both are +//! refused: `chrono` is banned outside `capsule-cli/entity` by a gate +//! (design/dependencies.md), and `time` would be a third datetime crate with no row in that +//! table. `capsule_server::postgres::time` converts at the adapter boundary, exactly as the +//! CLI converts at its entity boundary. What that costs is SQL date arithmetic; every expiry +//! and retention comparison in these tables is an ordering on one column, and integers order +//! identically. + +pub use sea_orm_migration::prelude::*; + +mod m20260902_000001_asset_index; +mod m20260902_000002_accounts; +mod m20260902_000003_cohorts; +mod m20260902_000004_quota; +mod m20260902_000005_album_membership; +mod m20260902_000006_federation; + +/// The server's migrator. +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20260902_000001_asset_index::Migration), + Box::new(m20260902_000002_accounts::Migration), + Box::new(m20260902_000003_cohorts::Migration), + Box::new(m20260902_000004_quota::Migration), + Box::new(m20260902_000005_album_membership::Migration), + Box::new(m20260902_000006_federation::Migration), + ] + } +} diff --git a/capsule-server/migration/src/m20260902_000001_asset_index.rs b/capsule-server/migration/src/m20260902_000001_asset_index.rs new file mode 100644 index 00000000..4bdb1233 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000001_asset_index.rs @@ -0,0 +1,292 @@ +//! The asset index (`S-C37`): the rows, the blobs they hold, the manifests they have moved +//! past, the per-owner sequence, and the applied-manifest ledger. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // The one sequence, per **owner**. Never a Postgres `SEQUENCE` or a `bigserial`: + // `nextval` is non-transactional, so two concurrent finalizations get 5 and 6 and a + // reader who sees 6 commit first can page past 5 forever. A counter row updated inside + // the allocating transaction makes allocation order equal commit order, which is the + // whole of `S-C21`'s fix. + manager + .create_table( + Table::create() + .table(OwnerSequences::Table) + .if_not_exists() + .col( + ColumnDef::new(OwnerSequences::OwnerId) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(OwnerSequences::NextSeq) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Assets::Table) + .if_not_exists() + .col( + ColumnDef::new(Assets::AssetId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Assets::OwnerId).text().not_null()) + .col(ColumnDef::new(Assets::AlbumId).text().not_null()) + .col(ColumnDef::new(Assets::ProtocolVersion).text().not_null()) + .col(ColumnDef::new(Assets::CryptoSuiteId).integer().not_null()) + .col(ColumnDef::new(Assets::State).text().not_null()) + // Null is the ordinary case: a hold is placed by an admin action and never + // by a write path. + .col(ColumnDef::new(Assets::Hold).text().null()) + // Null while the row is pending — a row nothing can see occupies no place + // in the feed, which is what keeps an abandoned half-bundle from consuming + // a number. + .col(ColumnDef::new(Assets::SyncSeq).big_integer().null()) + .col(ColumnDef::new(Assets::FirstSeq).big_integer().null()) + // Invariant 17's stored chain head: SHA-256 over the signed manifest, and + // deliberately not the provenance blob's content address — the two are + // equal today and are not the same identifier (`S-C31`). + .col(ColumnDef::new(Assets::ChainHead).binary().null()) + .col( + ColumnDef::new(Assets::AmkVersion) + .big_integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Assets::RetentionUntil).big_integer().null()) + .col(ColumnDef::new(Assets::CreatedAt).big_integer().not_null()) + .col(ColumnDef::new(Assets::UpdatedAt).big_integer().not_null()) + .to_owned(), + ) + .await?; + + // The feed's own index: every page is "this owner, above this number, in order". + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_owner_sync_seq") + .table(Assets::Table) + .col(Assets::OwnerId) + .col(Assets::SyncSeq) + .to_owned(), + ) + .await?; + // The duplicate lookup's, which is owner- **and** album-scoped for two different + // reasons (see `AssetIndex::find_by_address`). + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_owner_album") + .table(Assets::Table) + .col(Assets::OwnerId) + .col(Assets::AlbumId) + .to_owned(), + ) + .await?; + // The purge worker's input: tombstoned rows, oldest change first. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_assets_state_updated_at") + .table(Assets::Table) + .col(Assets::State) + .col(Assets::UpdatedAt) + .to_owned(), + ) + .await?; + + // `(asset_id, role, address)` is the primary key, and that is what makes a retried + // finalization free: the insert is a no-op rather than a second row. + manager + .create_table( + Table::create() + .table(AssetBlobs::Table) + .if_not_exists() + .col(ColumnDef::new(AssetBlobs::AssetId).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Role).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Address).text().not_null()) + .col(ColumnDef::new(AssetBlobs::Size).big_integer().not_null()) + .primary_key( + Index::create() + .col(AssetBlobs::AssetId) + .col(AssetBlobs::Role) + .col(AssetBlobs::Address), + ) + .foreign_key( + ForeignKey::create() + .name("fk_asset_blobs_asset") + .from(AssetBlobs::Table, AssetBlobs::AssetId) + .to(Assets::Table, Assets::AssetId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + // `find_reference` and `reference_count` both start from an address. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_asset_blobs_address") + .table(AssetBlobs::Table) + .col(AssetBlobs::Address) + .to_owned(), + ) + .await?; + + // The manifests a chain has moved past (`S-C52`). These count as references, so the + // collector does not reclaim the server's own rebuttal evidence. `Position` keeps the + // order the port contracts — oldest first — rather than leaving it to the backend. + manager + .create_table( + Table::create() + .table(AssetSuperseded::Table) + .if_not_exists() + .col(ColumnDef::new(AssetSuperseded::AssetId).text().not_null()) + .col( + ColumnDef::new(AssetSuperseded::Position) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(AssetSuperseded::Address).text().not_null()) + .primary_key( + Index::create() + .col(AssetSuperseded::AssetId) + .col(AssetSuperseded::Position), + ) + .foreign_key( + ForeignKey::create() + .name("fk_asset_superseded_asset") + .from(AssetSuperseded::Table, AssetSuperseded::AssetId) + .to(Assets::Table, Assets::AssetId) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_asset_superseded_address") + .table(AssetSuperseded::Table) + .col(AssetSuperseded::Address) + .to_owned(), + ) + .await?; + + // The whole idempotency store for a lifecycle write: a replay needs the number the + // first application minted, and everything else in the response is derivable from the + // manifest itself. The retired implementation kept the serialized response body in a + // table — a second copy of something derivable, and therefore a second thing that can + // be wrong. + manager + .create_table( + Table::create() + .table(AppliedManifests::Table) + .if_not_exists() + .col( + ColumnDef::new(AppliedManifests::ManifestHash) + .binary() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(AppliedManifests::SyncSeq) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AppliedManifests::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AssetSuperseded::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AssetBlobs::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Assets::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(OwnerSequences::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Assets { + Table, + AssetId, + OwnerId, + AlbumId, + ProtocolVersion, + CryptoSuiteId, + State, + Hold, + SyncSeq, + FirstSeq, + ChainHead, + AmkVersion, + RetentionUntil, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum AssetBlobs { + Table, + AssetId, + Role, + Address, + Size, +} + +#[derive(DeriveIden)] +enum AssetSuperseded { + Table, + AssetId, + Position, + Address, +} + +#[derive(DeriveIden)] +enum OwnerSequences { + Table, + OwnerId, + NextSeq, +} + +#[derive(DeriveIden)] +enum AppliedManifests { + Table, + ManifestHash, + SyncSeq, +} diff --git a/capsule-server/migration/src/m20260902_000002_accounts.rs b/capsule-server/migration/src/m20260902_000002_accounts.rs new file mode 100644 index 00000000..4e6c58f6 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000002_accounts.rs @@ -0,0 +1,93 @@ +//! The account cluster (`S-C53`, `S-C54`): one table behind four ports. +//! +//! `AccountRegistry`, `AccountDirectory`, `AccountProfiles` and `PasswordChange` are four ports +//! because they answer four questions with four disclosure contracts — not because they are +//! four stores. One row holds every fact all four read. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Accounts::Table) + .if_not_exists() + .col( + ColumnDef::new(Accounts::UserId) + .text() + .not_null() + .primary_key(), + ) + // Compared **verbatim**, which is why there is no folded column beside it. + // Case folding is a normalization policy no port describes, and inventing + // one here would make the durable adapter disagree with the in-memory one + // about who `Foo@example.test` is — a divergence the shared conformance + // suite exists to make impossible. Saying otherwise is a decision about + // identity, and it belongs to a slice that argues for it. + .col(ColumnDef::new(Accounts::Email).text().not_null()) + .col(ColumnDef::new(Accounts::DisplayName).text().null()) + // The Argon2id PHC string `auth::credential` produces. Never a password, + // and never read above the adapter. + .col(ColumnDef::new(Accounts::Credential).text().not_null()) + // The lockout the directory port makes the adapter's own state: a column on + // the account row, not a windowed counter. Rate limiting is `S-C32` and has + // no port in this crate. + .col( + ColumnDef::new(Accounts::Failures) + .big_integer() + .not_null() + .default(0), + ) + // The lockout's clock. Without it the count is a one-way door: no surface in + // this server can clear a lockout — `login`, `reauthenticate` and `password` + // all refuse on `Locked` before verifying anything, and no operator command + // reaches this row — so a permanent lockout is a permanently lost account. + .col(ColumnDef::new(Accounts::LastFailureAt).big_integer().null()) + .col(ColumnDef::new(Accounts::CreatedAt).big_integer().not_null()) + .col(ColumnDef::new(Accounts::UpdatedAt).big_integer().not_null()) + .to_owned(), + ) + .await?; + + // What decides `Registration::AlreadyExists`. A unique index rather than a read + // followed by a write: two registrations racing on one address must not both believe + // they own it, and the port is explicit that the check and the write are one operation. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_accounts_email") + .table(Accounts::Table) + .col(Accounts::Email) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Accounts::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Accounts { + Table, + UserId, + Email, + DisplayName, + Credential, + Failures, + LastFailureAt, + CreatedAt, + UpdatedAt, +} diff --git a/capsule-server/migration/src/m20260902_000003_cohorts.rs b/capsule-server/migration/src/m20260902_000003_cohorts.rs new file mode 100644 index 00000000..79342cf3 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000003_cohorts.rs @@ -0,0 +1,75 @@ +//! The durable device-cohort map (`S-C13`). +//! +//! Advisory storage, structurally: nothing here is read by an authorization path, and the port +//! offers no lookup that could tempt one. The map outlives sessions deliberately — a cohort +//! becomes worth knowing exactly when the sessions that carried it have expired. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DeviceCohorts::Table) + .if_not_exists() + .col(ColumnDef::new(DeviceCohorts::UserId).text().not_null()) + .col(ColumnDef::new(DeviceCohorts::CohortHash).text().not_null()) + .col( + ColumnDef::new(DeviceCohorts::FirstSeen) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(DeviceCohorts::LastSeen) + .big_integer() + .not_null(), + ) + // The composite key **is** the idempotence the port states: seeing the same + // cohort twice is one row, not two, so `observe` is an upsert rather than a + // read followed by a decision. + .primary_key( + Index::create() + .col(DeviceCohorts::UserId) + .col(DeviceCohorts::CohortHash), + ) + .to_owned(), + ) + .await?; + + // The listing's order is part of the contract — oldest first sighting first — because + // it is a user-visible surface. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_device_cohorts_user_first_seen") + .table(DeviceCohorts::Table) + .col(DeviceCohorts::UserId) + .col(DeviceCohorts::FirstSeen) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DeviceCohorts::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum DeviceCohorts { + Table, + UserId, + CohortHash, + FirstSeen, + LastSeen, +} diff --git a/capsule-server/migration/src/m20260902_000004_quota.rs b/capsule-server/migration/src/m20260902_000004_quota.rs new file mode 100644 index 00000000..be7c680b --- /dev/null +++ b/capsule-server/migration/src/m20260902_000004_quota.rs @@ -0,0 +1,101 @@ +//! The quota ledger (`S-C6`): attribution by content address, and the over-limit clock. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Keyed on the **content address**, globally, and that is the security property rather + // than a normalization: a blob shared between two uploaders counts against only the + // first, so re-uploading blobs whose addresses you already know cannot exhaust somebody + // else's quota. The primary key is what makes the check and the debit one operation. + manager + .create_table( + Table::create() + .table(QuotaAttributions::Table) + .if_not_exists() + .col( + ColumnDef::new(QuotaAttributions::Address) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(QuotaAttributions::UserId).text().not_null()) + .col( + ColumnDef::new(QuotaAttributions::Size) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(QuotaAttributions::ChargedAt) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + // What `usage` sums over. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_quota_attributions_user") + .table(QuotaAttributions::Table) + .col(QuotaAttributions::UserId) + .to_owned(), + ) + .await?; + + // Only the clock. A user's total is **derived** by summing their attributions rather + // than stored beside them, because a stored total is a second copy of a derivable fact + // and a total that drifts low hands somebody free storage. What cannot be derived is + // *when* an account crossed the hard limit and has not been under it since — that is + // the one column here. + manager + .create_table( + Table::create() + .table(QuotaUsage::Table) + .if_not_exists() + .col( + ColumnDef::new(QuotaUsage::UserId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(QuotaUsage::OverSince).big_integer().null()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(QuotaUsage::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(QuotaAttributions::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum QuotaAttributions { + Table, + Address, + UserId, + Size, + ChargedAt, +} + +#[derive(DeriveIden)] +enum QuotaUsage { + Table, + UserId, + OverSince, +} diff --git a/capsule-server/migration/src/m20260902_000005_album_membership.rs b/capsule-server/migration/src/m20260902_000005_album_membership.rs new file mode 100644 index 00000000..e0e7fd34 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000005_album_membership.rs @@ -0,0 +1,138 @@ +//! Album membership (`S-C51`): the roster the owner attested, and who it makes a member. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // One row per album: the roster the server currently accepts, with the signed document + // verbatim. A replay is decided on the document's bytes, and an operator can re-verify + // what was accepted against the owner's directory of the day. + manager + .create_table( + Table::create() + .table(AlbumRosters::Table) + .if_not_exists() + .col( + ColumnDef::new(AlbumRosters::AlbumId) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(AlbumRosters::RosterVersion) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::AmkEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::AttestedByDevice) + .text() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::ReceivedAt) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(AlbumRosters::Document).binary().not_null()) + .to_owned(), + ) + .await?; + + // One row per account that has ever been on one of the album's rosters. A member the + // owner removes keeps their row with the two `revoked_*` columns set: the blob route's + // `403` is reserved for a caller the server can see once *had* access, and a deleted row + // would make a former member indistinguishable from a stranger. + manager + .create_table( + Table::create() + .table(AlbumMembers::Table) + .if_not_exists() + .col(ColumnDef::new(AlbumMembers::AlbumId).text().not_null()) + .col(ColumnDef::new(AlbumMembers::UserId).text().not_null()) + .col(ColumnDef::new(AlbumMembers::Role).text().not_null()) + .col( + ColumnDef::new(AlbumMembers::SinceVersion) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumMembers::GrantedEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumMembers::RevokedAtVersion) + .big_integer() + .null(), + ) + .col( + ColumnDef::new(AlbumMembers::RevokedEpoch) + .big_integer() + .null(), + ) + .primary_key( + Index::create() + .col(AlbumMembers::AlbumId) + .col(AlbumMembers::UserId), + ) + .to_owned(), + ) + .await?; + // "Which albums is this account on" — the sync side's question, and the one the + // primary key does not answer. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_album_members_user") + .table(AlbumMembers::Table) + .col(AlbumMembers::UserId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AlbumMembers::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AlbumRosters::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum AlbumRosters { + Table, + AlbumId, + RosterVersion, + AmkEpoch, + AttestedByDevice, + ReceivedAt, + Document, +} + +#[derive(DeriveIden)] +enum AlbumMembers { + Table, + AlbumId, + UserId, + Role, + SinceVersion, + GrantedEpoch, + RevokedAtVersion, + RevokedEpoch, +} diff --git a/capsule-server/migration/src/m20260902_000006_federation.rs b/capsule-server/migration/src/m20260902_000006_federation.rs new file mode 100644 index 00000000..d5e029c7 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000006_federation.rs @@ -0,0 +1,242 @@ +//! Federation (`S-E2`, `S-E5`): the capabilities this server issued, the revocation list it +//! publishes, and the peers an operator has pinned or blocked. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // One row per capability this server minted. The record carries what the token does not: + // the roster member the grant was made for and the epoch their membership was granted + // at, which is what a presentation re-checks so a member removed and re-admitted later + // cannot reuse an older grant. `refreshed_to` links a predecessor to its successor and + // is what makes a replayed refresh idempotent without an idempotency table. + manager + .create_table( + Table::create() + .table(FederationCapabilities::Table) + .if_not_exists() + .col( + ColumnDef::new(FederationCapabilities::Jti) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(FederationCapabilities::AlbumId) + .text() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::PeerId) + .text() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::MemberId) + .text() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::Scope) + .text() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::GrantedEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::MinProtocolVersion) + .text() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::IssuedAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::ExpiresAt) + .big_integer() + .not_null(), + ) + // The absolute deadline of the whole grant, fixed at the original mint and + // copied unchanged into every successor. `expires_at` is one token's life + // and a refresh replaces it; this is the column a refresh cannot move, and + // it is what keeps a chain of refreshes from outliving the lifetime the + // album's owner chose. Equal to `expires_at` for a grant nobody made + // renewable, which is the default. + .col( + ColumnDef::new(FederationCapabilities::NotAfter) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(FederationCapabilities::RevokedAt) + .big_integer() + .null(), + ) + .col( + ColumnDef::new(FederationCapabilities::RefreshedTo) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + // "Every live grant over this album" — what a roster change consults — and "every live + // grant this peer holds" — what a block cascades over. Neither is the primary key's + // question. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_federation_capabilities_album") + .table(FederationCapabilities::Table) + .col(FederationCapabilities::AlbumId) + .to_owned(), + ) + .await?; + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_federation_capabilities_peer") + .table(FederationCapabilities::Table) + .col(FederationCapabilities::PeerId) + .to_owned(), + ) + .await?; + + // The published list, and its own table rather than a view over the one above: a + // revocation is also accepted for a `jti` no record backs — an operator cutting a token + // named in a peer's report, or one that predates this store — and the list must carry + // it either way. `expires_at` is what the list is pruned by, so an entry never outlives + // the token it is about. + manager + .create_table( + Table::create() + .table(FederationRevokedJti::Table) + .if_not_exists() + .col( + ColumnDef::new(FederationRevokedJti::Jti) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(FederationRevokedJti::ExpiresAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(FederationRevokedJti::RevokedAt) + .big_integer() + .not_null(), + ) + .to_owned(), + ) + .await?; + // The published record is read in expiry order and pruned by it on every read. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_federation_revoked_jti_expiry") + .table(FederationRevokedJti::Table) + .col(FederationRevokedJti::ExpiresAt) + .to_owned(), + ) + .await?; + + // The peers this server knows. `signing_key` is nullable because a block may name a + // server nobody ever pinned — an operator blocking a server they never wanted to hear + // from is legitimate — and `blocked_at` is the blocklist itself, a column rather than a + // table because the blocklist operates at the federation-capability layer. + manager + .create_table( + Table::create() + .table(FederationPeers::Table) + .if_not_exists() + .col( + ColumnDef::new(FederationPeers::ServerId) + .text() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(FederationPeers::SigningKey).binary().null()) + .col( + ColumnDef::new(FederationPeers::FirstSeenAt) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(FederationPeers::BlockedAt) + .big_integer() + .null(), + ) + .col(ColumnDef::new(FederationPeers::Note).text().null()) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(FederationPeers::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(FederationRevokedJti::Table).to_owned()) + .await?; + manager + .drop_table( + Table::drop() + .table(FederationCapabilities::Table) + .to_owned(), + ) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum FederationCapabilities { + Table, + Jti, + AlbumId, + PeerId, + MemberId, + Scope, + GrantedEpoch, + MinProtocolVersion, + IssuedAt, + ExpiresAt, + NotAfter, + RevokedAt, + RefreshedTo, +} + +#[derive(DeriveIden)] +enum FederationRevokedJti { + Table, + Jti, + ExpiresAt, + RevokedAt, +} + +#[derive(DeriveIden)] +enum FederationPeers { + Table, + ServerId, + SigningKey, + FirstSeenAt, + BlockedAt, + Note, +} diff --git a/capsule-server/migration/src/main.rs b/capsule-server/migration/src/main.rs new file mode 100644 index 00000000..1c3d5c3c --- /dev/null +++ b/capsule-server/migration/src/main.rs @@ -0,0 +1,13 @@ +//! The operator's migration command. +//! +//! `capsule-server serve` deliberately does **not** run migrations: it reads `seaql_migrations` +//! and refuses to start when the schema is not the one it was built against +//! (`capsule_server::postgres::assert_schema_current`). A server that migrates on start runs +//! the migration once per replica on a rolling deploy, which is a schema change racing itself. + +use sea_orm_migration::prelude::*; + +#[tokio::main] +async fn main() { + cli::run_cli(server_migration::Migrator).await; +} diff --git a/capsule-server/openapi.json b/capsule-server/openapi.json index 3e80f9f5..fd76b680 100644 --- a/capsule-server/openapi.json +++ b/capsule-server/openapi.json @@ -5,33 +5,45 @@ "version": "0.0.0" }, "paths": { - "/v1/version": { - "get": { - "summary": "Reports the server's name and version.", - "description": "Unauthenticated and side-effect free. Clients use it as a reachability probe before\nattempting a protocol handshake, so it must stay cheap and must never fail for a reason\nthe caller could act on — there is no failure variant, and the return type says so.", - "operationId": "get_version", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionResponse" - } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, "/v1/auth/register": { "post": { "summary": "Create an account, and open its first session.", "description": "# Why it signs you in\n\nThe alternative is `201` with no body and a client that immediately posts the same\ncredentials to `/v1/auth/login`, which is one more round trip for one more chance to fail and\nnothing gained. It also makes the CLI's `capsule register` mean what a person expects: after\nit, you are registered *and* signed in.\n\n# What it does not do\n\n**It does not publish a device directory**, and the account is therefore unable to upload\nuntil its client publishes one. That is not an omission here: `S-C20` removed the\naccount-creation fallback for invariant 7's floor precisely so that \"was this device in the\ndirectory\" has an honest answer for a brand-new account, and the honest answer is *no*. A\nclient's first action after registering is `POST /v1/auth/devices/directory`.\n\n**It is not rate-limited**, and that is a real gap rather than an oversight — see\n[`crate::auth::registry`] for the fact the limiter is waiting on. This is the one\nunauthenticated write on the surface.\n\n# `200`, where Salvo answered `201`\n\nKynos's `Created` requires a `Location` — a `201` that does not say *where* tells a client\nsomething exists and not how to reach it, which is a defect the type refuses to let you\ncommit. This server exposes no URL for an account: `GET /v1/auth/profile` is among the\noperations `S-C53` records as unported. Inventing a location to satisfy a status would be\ninventing a surface, so the status moved instead. What a caller actually needs — the token\npair — is in the body either way.", "operationId": "register_user", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { @@ -45,6 +57,32 @@ "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -55,6 +93,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -65,6 +129,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -75,6 +165,32 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { @@ -85,6 +201,32 @@ }, "409": { "description": "Account already exists", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -95,6 +237,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -104,7 +272,69 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } } } } @@ -114,6 +344,40 @@ "summary": "Exchange an email and password for a session — or for a second-factor challenge.", "description": "The two advisory identifiers a client may send — `cohort_hash` and `device_id` — are recorded\non the session for the devices listing and gate nothing; an unusable one is dropped rather\nthan refused.\n\n# Two statuses, because there are two outcomes\n\nAn account with a confirmed second factor (`S-C55`) gets **`202`** and a short-lived\nchallenge: the credentials were accepted and the request is not complete. No session is\nopened, no cohort is recorded and no refresh token is minted, because none of those may exist\nfor an authentication that has not finished — and the client's advisory identifiers ride the\n*completing* request instead, since that is what creates the session they describe.\n\nThe retired surface got this wrong in the most consequential way available: it had all four\nTOTP operations and its login never issued a challenge, so a confirmed second factor gated\nnothing at all.", "operationId": "login_user", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { @@ -127,6 +391,32 @@ "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -137,6 +427,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -147,16 +463,68 @@ }, "422": { "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "A session was opened; here is its token pair.", + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "A session was opened; here is its token pair.", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { @@ -167,6 +535,32 @@ }, "202": { "description": "The password verified; a second factor is required to finish.", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { @@ -177,6 +571,32 @@ }, "401": { "description": "Invalid credentials", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -187,6 +607,32 @@ }, "423": { "description": "Account locked", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -197,6 +643,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -206,7 +678,69 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } } } } @@ -216,6 +750,40 @@ "summary": "Exchange a refresh token for a new pair, rotating the session.", "description": "The presented session is **closed** and a new one opened in its place, so a refresh token is\ngood exactly once. The session's advisory provenance — its cohort hash and device id — is\ncarried across the rotation, or the devices listing would lose track of a device every time\nits tokens turned over.", "operationId": "refresh_token", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { @@ -229,6 +797,32 @@ "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -239,6 +833,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -249,6 +869,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -259,6 +905,32 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { @@ -269,46 +941,66 @@ }, "401": { "description": "Session expired", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/logout": { - "post": { - "summary": "End the session the presented access token was issued against.", - "description": "Idempotent: a session that is already closed, expired, or was never opened produces the same\nanswer, because \"there is no longer a session\" is what the caller asked for.", - "operationId": "logout", - "responses": { - "401": { - "description": "Unauthorized", + "500": { + "description": "Internal server error", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -319,21 +1011,63 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -341,23 +1075,49 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - }, - "security": [ - { - "bearer": [] - } - ] + } } }, - "/v1/auth/logout/all/challenge": { + "/v1/auth/logout": { "post": { - "summary": "Issue a single-use challenge for a global sign-out.", - "description": "**Authenticated by a session token, unlike the revoke itself.** That is not a contradiction\nof the ceremony's asymmetry: a challenge is worthless without the identity key, so handing\none to a stolen token costs nothing — while issuing them unauthenticated would make this an\noracle for whether an account exists. The account comes from the credential and never from a\nrequest field, so a caller cannot ask for somebody else's challenge.", - "operationId": "revoke_all_challenge", + "summary": "End the session the presented access token was issued against.", + "description": "Idempotent: a session that is already closed, expired, or was never opened produces the same\nanswer, because \"there is no longer a session\" is what the caller asked for.", + "operationId": "logout", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { "401": { "description": "Unauthorized", @@ -369,6 +1129,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -381,6 +1165,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -389,18 +1199,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/RevokeChallengeResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -410,44 +1265,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/logout/all": { - "post": { - "summary": "Close every session for the account the proof establishes.", - "description": "**No `Auth`, deliberately.** design/authentication.md gates this on proof of master-key\npossession *instead of* a session token, and the reason is the damage scenario: an attacker\nholding a stolen token could otherwise invoke \"log out of all devices\" and lock the\nlegitimate user out of every device they own. Requiring the identity key means a stolen\ntoken can revoke only itself. The account is established by the burned challenge, so there\nis no account field for a caller to aim at either.\n\nThe caller's own session goes with the rest. That is the ceremony, not an oversight.", - "operationId": "revoke_all", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RevokeAllRequest" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } } }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -456,38 +1329,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/RevokeAllResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "401": { - "description": "Master-key proof required", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -495,18 +1364,54 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - } + }, + "security": [ + { + "bearer": [] + } + ] } }, - "/v1/auth/devices": { - "get": { - "summary": "List the caller's live sessions and the cohorts they group under.", - "description": "Scoped by credential with no path parameter, for the same reason the escrow is: the only\naccount entitled to a session ledger is its own, and making that structural beats enforcing\nit.", - "operationId": "list_devices", + "/v1/auth/logout/all/challenge": { + "post": { + "summary": "Issue a single-use challenge for a global sign-out.", + "description": "**Authenticated by a session token, unlike the revoke itself.** That is not a contradiction\nof the ceremony's asymmetry: a challenge is worthless without the identity key, so handing\none to a stolen token costs nothing — while issuing them unauthenticated would make this an\noracle for whether an account exists. The account comes from the credential and never from a\nrequest field, so a caller cannot ask for somebody else's challenge.", + "operationId": "revoke_all_challenge", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { "401": { "description": "Unauthorized", @@ -518,6 +1423,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -530,6 +1459,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -540,16 +1495,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DevicesResponse" + "$ref": "#/components/schemas/RevokeChallengeResponse" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -559,65 +1566,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/devices/{session_id}": { - "delete": { - "summary": "Revoke one of the caller's sessions.", - "description": "Any live token may do this, including for the session making the request — signing this\ndevice out is a legitimate thing to ask for, and refusing it would only push a client into\ncalling `logout` and hoping the two behave the same.\n\n**Only the caller's own sessions.** The ownership check is against the record the store\nreturns rather than against a separate lookup, so there is no window between checking and\nclosing, and a session id belonging to another account answers exactly as an unknown one\ndoes.", - "operationId": "revoke_session", - "parameters": [ - { - "name": "session_id", - "in": "path", - "description": "The session's identifier.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -626,21 +1630,34 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -648,9 +1665,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -660,69 +1674,84 @@ ] } }, - "/v1/auth/devices/directory": { + "/v1/auth/logout/all": { "post": { - "summary": "Publish the caller's signed device directory.", - "description": "The bytes are stored verbatim; the server decodes them to read `directory_version` and\nnothing else. The monotonicity comparison is the store's, not this handler's — see\n[`crate::directory`] for why a read-compare-write here would be a rollback window.", - "operationId": "publish_device_directory", + "summary": "Close every session for the account the proof establishes.", + "description": "**No `Auth`, deliberately.** design/authentication.md gates this on proof of master-key\npossession *instead of* a session token, and the reason is the damage scenario: an attacker\nholding a stolen token could otherwise invoke \"log out of all devices\" and lock the\nlegitimate user out of every device they own. Requiring the identity key means a stolen\ntoken can revoke only itself. The account is established by the burned challenge, so there\nis no account field for a caller to aim at either.\n\nThe caller's own session goes with the rest. That is the ceremony, not an oversight.", + "operationId": "revoke_all", "parameters": [ { - "name": "X-Capsule-Identity-Key", + "name": "X-Capsule-Protocol", "in": "header", - "description": "The account's identity public key, standard base64 over the hybrid `classical ‖ ml`\nlayout. Required: invariant 23's second clause is undefined without it.", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], "requestBody": { "content": { - "application/cbor": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/RevokeAllRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -732,37 +1761,33 @@ } }, "415": { - "description": "Unsupported media type", - "content": { - "application/problem+json": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/PublishDirectoryResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Directory version conflict", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/DirectoryConflictProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -771,66 +1796,34 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/devices/directory/{user_id}": { - "get": { - "summary": "Fetch a user's signed device directory, verbatim.", - "description": "The response body is the exact bytes the owner signed. Re-encoding them would detach the\ndocument from its signature, and the failure would look like the *publisher's* bug.", - "operationId": "fetch_device_directory", - "parameters": [ - { - "name": "user_id", - "in": "path", - "description": "The account id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "422": { + "description": "Unprocessable Entity", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -841,62 +1834,66 @@ }, "200": { "description": "OK", - "content": { - "application/cbor": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/RevokeAllResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/escrow": { - "get": { - "summary": "Fetch the caller's wrapped master key, verbatim.", - "description": "The bytes are what a client runs its KDF against, so they come back exactly as they went in.\nThe server never derives, unwraps or re-encodes: a re-encoded wrap is a wrap that no longer\nopens, and the failure would look like a lost master key.", - "operationId": "fetch_escrow", - "responses": { "401": { - "description": "Unauthorized", + "description": "Master-key proof required", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -907,39 +1904,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/octet-stream": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -949,93 +1941,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - }, - "put": { - "summary": "Store the caller's wrapped master key, replacing whatever they had.", - "description": "`PUT`, because there is exactly one escrow per account and this is its address. Storing over\nan existing escrow is the guided re-wrap, and it deletes the old blob in the same operation —\nthe lost recovery secret must stop working, which is the entire point of rotating.", - "operationId": "store_escrow", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "415": { - "description": "Unsupported media type", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Malformed request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/StoreEscrowResponse" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -1043,16 +2004,8 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] + } } }, "/v1/auth/reauthenticate": { @@ -1060,6 +2013,40 @@ "summary": "Prove a credential again on the current session, without opening a new one.", "description": "**The only way to satisfy the freshness gate `S-C7` enforces**, and it exists because\nwithout it the gate is unusable: `authenticated_at` is deliberately *not* reset by a refresh,\nso a user signed in an hour ago would otherwise have to sign out entirely to add a device —\nand the session they abandoned would linger in their own devices listing.\n\nIt does not mint tokens and does not rotate the session. The caller keeps the credential\nthey already hold; what changes is one timestamp on the record behind it.\n\n# Errors\n\nThe same refusals as a sign-in, for the same reasons: a wrong password is\n`401 error.auth.invalid_credentials`, a locked account is `403`, and the account directory\nfailing is `500`. A caller that guessed a password here learns exactly what it would learn\nat `/v1/auth/login`, and no more.", "operationId": "reauthenticate", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { @@ -1081,6 +2068,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1093,6 +2104,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1103,6 +2140,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1113,6 +2176,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1123,6 +2212,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1133,6 +2248,32 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { @@ -1143,6 +2284,32 @@ }, "423": { "description": "Account locked", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1153,6 +2320,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1162,74 +2355,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/profile": { - "get": { - "summary": "The caller's own profile.", - "description": "There is no `{user_id}` segment, for the reason the escrow surface has none: the account\ncomes from the credential, so reading somebody else's profile is not a forbidden request but\nan unrepresentable one. A directory of *other* people's public facts already exists and is a\ndifferent surface — `GET /v1/auth/devices/directory/{user_id}` — which publishes keys and\nnothing else.", - "operationId": "get_profile", - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProfileResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -1237,9 +2418,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -1247,21 +2425,56 @@ "bearer": [] } ] - }, - "patch": { - "summary": "Edit the caller's own profile.", - "description": "`PATCH`, because the body is a partial: what it does not mention, it does not change. An\nempty body is a valid request and answers `200` with the profile unchanged — a client that\nsent nothing asked for nothing, and refusing it would make \"save\" fail on a form nobody\nedited.", - "operationId": "update_profile", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateProfileRequest" - } + } + }, + "/v1/auth/devices/{session_id}": { + "delete": { + "summary": "Revoke one of the caller's sessions.", + "description": "Any live token may do this, including for the session making the request — signing this\ndevice out is a legitimate thing to ask for, and refusing it would only push a client into\ncalling `logout` and hoping the two behave the same.\n\n**Only the caller's own sessions.** The ownership check is against the record the store\nreturns rather than against a separate lookup, so there is no window between checking and\nclosing, and a session id belonging to another account answers exactly as an unknown one\ndoes.", + "operationId": "revoke_session", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "The session's identifier.", + "required": true, + "schema": { + "type": "string" } }, - "required": true - }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { "401": { "description": "Unauthorized", @@ -1273,6 +2486,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1285,6 +2522,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1295,26 +2558,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -1323,18 +2592,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProfileResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1345,6 +2659,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1354,117 +2694,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/password": { - "post": { - "summary": "Replace the password this account's sessions are opened with.", - "description": "# Every *other* session ends\n\nA password change whose point is that a credential has leaked would be worthless if the\nsessions opened with the leaked credential kept working. So the change closes every session\nof the account — and then re-opens the caller's own, **under its own session id**, so the\nperson doing the rotation is not signed out of the device they are doing it on while\neverybody else is.\n\nRe-opening the same id rather than minting a new one is what lets this answer `204` with no\nbody: the caller's existing token pair keeps working, because the session it names is still\nthere. Returning a fresh pair was considered and rejected — it would make this a second token\nmint with none of `POST /v1/auth/refresh`'s rotation discipline, for no gain.\n\nThe re-opened record's `authenticated_at` is **now**, and that is not bookkeeping: presenting\nthe current password *is* a credential presentation, so a freshness gate (`S-C7`) measuring\nfrom anything earlier would be measuring from the wrong moment.\n\n# Why the order is verify, write, revoke\n\nVerification first, because a wrong current password must change nothing. The write next,\nbecause a revocation that ran before it would sign everybody out and then fail. The\nrevocation last, and its failure is **logged and not returned**: the password is already\nchanged, so answering `500` would tell the caller the rotation did not happen when it did,\nand they would try again with a current password that is no longer current.", - "operationId": "change_password", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "423": { - "description": "Account locked", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -1472,9 +2757,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -1484,93 +2766,63 @@ ] } }, - "/v1/auth/totp/enroll": { + "/v1/auth/devices/directory": { "post": { - "summary": "Start enrolling an authenticator.", - "description": "Answers the `otpauth://` URI the app scans. Nothing is gated yet: until a code confirms the\nsecret, sign-in is unchanged — which is what stops a mis-scanned QR code from locking\nsomebody out of their own account.", - "operationId": "totp_enroll", - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", - "required": true, - "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EnrollmentResponse" - } - } + "summary": "Publish the caller's signed device directory.", + "description": "The bytes are stored verbatim; the server decodes them to read `directory_version` and\nnothing else. The monotonicity comparison is the store's, not this handler's — see\n[`crate::directory`] for why a read-compare-write here would be a rollback window.", + "operationId": "publish_device_directory", + "parameters": [ + { + "name": "X-Capsule-Identity-Key", + "in": "header", + "description": "The account's identity public key, standard base64 over the hybrid `classical ‖ ml`\nlayout. Required: invariant 23's second clause is undefined without it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] } }, - "409": { - "description": "Already active", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ { - "bearer": [] + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } - ] - } - }, - "/v1/auth/totp/verify-enrollment": { - "post": { - "summary": "Confirm an enrollment with a live code.", - "description": "The confirming code is **spent**: its step goes straight into the replay ledger, so it cannot\nalso complete a sign-in a moment later. That is the one place the ledger's first entry comes\nfrom, and skipping it would leave the newest code in the account's history unused.", - "operationId": "totp_verify_enrollment", + ], "requestBody": { "content": { - "application/json": { + "application/cbor": { "schema": { - "$ref": "#/components/schemas/CodeRequest" + "type": "string", + "format": "binary" } } }, @@ -1587,6 +2839,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1599,6 +2875,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1609,16 +2911,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported Media Type", + }, "content": { "application/problem+json": { "schema": { @@ -1627,31 +2945,142 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" } } } }, - "204": { - "description": "the request succeeded and there is no content to send" + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishDirectoryResponse" + } + } + } }, "409": { - "description": "Nothing pending", + "description": "Directory version conflict", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DirectoryConflictProblem" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1661,7 +3090,69 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } } }, "security": [ @@ -1671,21 +3162,45 @@ ] } }, - "/v1/auth/totp/disable": { - "post": { - "summary": "Remove the second factor, on presentation of a live code.", - "description": "**A session is not enough.** The whole point of the factor is that a stolen access token is\ninsufficient, and a disable that took only a token would let the token turn off the control\nthat makes it insufficient.", - "operationId": "totp_disable", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CodeRequest" - } + "/v1/auth/escrow": { + "get": { + "summary": "Fetch the caller's wrapped master key, verbatim.", + "description": "The bytes are what a client runs its KDF against, so they come back exactly as they went in.\nThe server never derives, unwraps or re-encodes: a re-encoded wrap is a wrap that no longer\nopens, and the failure would look like a lost master key.", + "operationId": "fetch_escrow", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "required": true - }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { "401": { "description": "Unauthorized", @@ -1697,6 +3212,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1709,16 +3248,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -1727,18 +3282,71 @@ } } }, - "415": { - "description": "Unsupported Media Type", + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "format": "binary" } } } }, - "422": { - "description": "Unprocessable Entity", + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1747,12 +3355,35 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "409": { - "description": "Not enrolled", - "content": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" @@ -1760,8 +3391,63 @@ } } }, - "500": { - "description": "Internal server error", + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1769,9 +3455,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -1779,46 +3462,93 @@ "bearer": [] } ] - } - }, - "/v1/auth/login/verify-totp": { - "post": { - "summary": "Complete a sign-in with a code.", - "description": "This is where the session is opened — not `POST /v1/auth/login`, which for an account with a\nsecond factor opens nothing. The advisory `cohort_hash` and `device_id` ride *this* request\nfor the same reason: the session they describe is created here.", - "operationId": "totp_verify_login", + }, + "put": { + "summary": "Store the caller's wrapped master key, replacing whatever they had.", + "description": "`PUT`, because there is exactly one escrow per account and this is its address. Storing over\nan existing escrow is the guided re-wrap, and it deletes the old blob in the same operation —\nthe lost recovery secret must stop working, which is the entire point of rotating.", + "operationId": "store_escrow", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/VerifyLoginRequest" + "type": "string", + "format": "binary" } } }, "required": true }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -1827,38 +3557,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/TokenResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "401": { - "description": "Challenge expired", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "429": { - "description": "Too many attempts", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -1867,28 +3593,32 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/auth/devices/enroll": { - "post": { - "summary": "Issue a one-time enrollment code for the caller's account.", - "description": "Gated on a recent credential presentation, not merely on a valid session — a stolen token\nmust not be able to enroll a rogue device. See [`crate::enrollment`] for exactly how much\nthat gate can mean.", - "operationId": "issue_enrollment_code", - "responses": { - "401": { - "description": "Unauthorized", + "415": { + "description": "Unsupported media type", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -1899,8 +3629,34 @@ } } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Malformed request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -1911,73 +3667,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EnrollmentCodeResponse" + "$ref": "#/components/schemas/StoreEscrowResponse" } } } }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/auth/devices/enroll/redeem": { - "post": { - "summary": "Redeem a code for a relay channel.", - "description": "**Unauthenticated, necessarily.** Device B has no account, no session and no key material —\nit is a phone that has just scanned a QR code. The code is the only thing it holds, so the\ncode is the credential.", - "operationId": "redeem_enrollment_code", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RedeemRequest" - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -1986,38 +3737,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ChannelResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Code refused", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "429": { - "description": "Too many attempts", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2025,41 +3801,127 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - } + }, + "security": [ + { + "bearer": [] + } + ] } }, - "/v1/auth/devices/enroll/channel/{channel_id}": { + "/v1/auth/profile": { "get": { - "summary": "Take everything pending in one of a channel's mailboxes.", - "description": "Destructive: a relayed payload is delivered once. Draining one direction leaves the other\nuntouched, so the two devices do not consume each other's mail.", - "operationId": "drain_enrollment_channel", + "summary": "The caller's own profile.", + "description": "There is no `{user_id}` segment, for the reason the escrow surface has none: the account\ncomes from the credential, so reading somebody else's profile is not a forbidden request but\nan unrepresentable one. A directory of *other* people's public facts already exists and is a\ndifferent surface — `GET /v1/auth/devices/directory/{user_id}` — which publishes keys and\nnothing else.", + "operationId": "get_profile", "parameters": [ { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "direction", - "in": "query", - "description": "`to_initiator` or `to_enrollee`.", - "required": true, + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], "responses": { - "400": { - "description": "Bad Request", + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2070,16 +3932,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DrainResponse" + "$ref": "#/components/schemas/ProfileResponse" } } } }, "404": { - "description": "Channel not found", + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2090,6 +4004,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2099,165 +4039,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - } - }, - "post": { - "summary": "Append a payload to one of a channel's two mailboxes.", - "description": "Unauthenticated and gated by the handle alone. The relay is a dumb pipe by design — see\n[`crate::enrollment`] — and the safety-code check is what defends the ceremony.", - "operationId": "relay_enrollment_payload", - "parameters": [ - { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RelayRequest" - } - } - }, - "required": true - }, - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Channel not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - }, - "delete": { - "summary": "Close a channel and drop both mailboxes with it.", - "description": "**The initiator's, and authenticated.** A close is the one relay operation that is not\nidempotent from the other device's point of view — it ends the ceremony — so leaving it on\nthe handle alone would make an abandoned QR code a denial of service. The account is checked\nagainst the channel's recorded initiator, and a channel belonging to another account answers\nexactly as an unknown one does.", - "operationId": "close_enrollment_channel", - "parameters": [ - { - "name": "channel_id", - "in": "path", - "description": "The handle a redeemed code returned.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Malformed handshake", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Channel not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2265,9 +4102,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -2275,18 +4109,50 @@ "bearer": [] } ] - } - }, - "/v1/albums": { - "post": { - "summary": "Bind an album id to the authenticated caller.", - "description": "Idempotent: the same id from a second device, or after a recovery, is a success that writes\nnothing.", - "operationId": "provision_album", + }, + "patch": { + "summary": "Edit the caller's own profile.", + "description": "`PATCH`, because the body is a partial: what it does not mention, it does not change. An\nempty body is a valid request and answers `200` with the profile unchanged — a client that\nsent nothing asked for nothing, and refusing it would make \"save\" fail on a form nobody\nedited.", + "operationId": "update_profile", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProvisionAlbumRequest" + "$ref": "#/components/schemas/UpdateProfileRequest" } } }, @@ -2303,6 +4169,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2315,6 +4205,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2325,6 +4241,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2335,16 +4277,32 @@ }, "415": { "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -2353,28 +4311,34 @@ } } }, - "201": { - "description": "The album was created and bound to the caller", - "content": { - "application/json": { + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProvisionAlbumResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "The album id was already provisioned to this account; nothing was written", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProvisionAlbumResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2383,56 +4347,70 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/albums/{album_id}/upgrade": { - "get": { - "summary": "Read the ceremony's phase and the drain count.", - "description": "The one call a proposer polls between steps 2 and 4. `in_flight` reaching zero is the signal\nthat the tombstone may be committed.", - "operationId": "album_upgrade_phase", - "parameters": [ - { - "name": "album_id", - "in": "path", - "description": "The album's id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "200": { + "description": "OK", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ProfileResponse" } } } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2441,8 +4419,34 @@ } } }, - "400": { - "description": "Bad Request", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2451,28 +4455,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2480,9 +4519,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -2490,28 +4526,52 @@ "bearer": [] } ] - }, + } + }, + "/v1/auth/password": { "post": { - "summary": "Put an album into upgrade quiescence.", - "description": "Idempotent under its own `intent_id`: versioning.md is explicit that the same `UpgradeIntent`\nnever produces two forks, and a proposer that lost an acknowledgement re-POSTs the same bytes.", - "operationId": "begin_album_upgrade", + "summary": "Replace the password this account's sessions are opened with.", + "description": "# Every *other* session ends\n\nA password change whose point is that a credential has leaked would be worthless if the\nsessions opened with the leaked credential kept working. So the change closes every session\nof the account — and then re-opens the caller's own, **under its own session id**, so the\nperson doing the rotation is not signed out of the device they are doing it on while\neverybody else is.\n\nRe-opening the same id rather than minting a new one is what lets this answer `204` with no\nbody: the caller's existing token pair keeps working, because the session it names is still\nthere. Returning a fresh pair was considered and rejected — it would make this a second token\nmint with none of `POST /v1/auth/refresh`'s rotation discipline, for no gain.\n\nThe re-opened record's `authenticated_at` is **now**, and that is not bookkeeping: presenting\nthe current password *is* a credential presentation, so a freshness gate (`S-C7`) measuring\nfrom anything earlier would be measuring from the wrong moment.\n\n# Why the order is verify, write, revoke\n\nVerification first, because a wrong current password must change nothing. The write next,\nbecause a revocation that ran before it would sign everybody out and then fail. The\nrevocation last, and its failure is **logged and not returned**: the password is already\nchanged, so answering `500` would tell the caller the rotation did not happen when it did,\nand they would try again with a current password that is no longer current.", + "operationId": "change_password", "parameters": [ { - "name": "album_id", - "in": "path", - "description": "The album's id.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], "requestBody": { "content": { - "application/cbor": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ChangePasswordRequest" } } }, @@ -2528,6 +4588,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2540,6 +4624,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2550,6 +4660,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2559,8 +4695,34 @@ } }, "415": { - "description": "Unsupported media type", - "content": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" @@ -2568,18 +4730,99 @@ } } }, - "200": { - "description": "OK", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "404": { - "description": "Not found", + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "423": { + "description": "Account locked", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2588,8 +4831,34 @@ } } }, - "409": { - "description": "Upgrade in flight", + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2600,6 +4869,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2609,7 +4904,69 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } } }, "security": [ @@ -2617,28 +4974,44 @@ "bearer": [] } ] - }, - "delete": { - "summary": "Abort a ceremony, returning the album to normal operation.", - "description": "Named by `intent_id` in the path's own query so that aborting is a statement about *which*\nupgrade — a caller that does not hold the live id gets a `409` rather than the power to\ncancel somebody else's ceremony.", - "operationId": "abort_album_upgrade", + } + }, + "/v1/auth/totp/enroll": { + "post": { + "summary": "Start enrolling an authenticator.", + "description": "Answers the `otpauth://` URI the app scans. Nothing is gated yet: until a code confirms the\nsecret, sign-in is unchanged — which is what stops a mis-scanned QR code from locking\nsomebody out of their own account.", + "operationId": "totp_enroll", "parameters": [ { - "name": "album_id", - "in": "path", - "description": "The album's id.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "intent_id", - "in": "query", - "description": "The ceremony to abort.", - "required": true, + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], @@ -2653,6 +5026,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2665,16 +5062,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -2685,26 +5098,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpgradePhaseResponse" + "$ref": "#/components/schemas/EnrollmentResponse" } } } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "409": { + "description": "Already active", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Upgrade in flight", + }, "content": { "application/problem+json": { "schema": { @@ -2715,6 +5170,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2724,44 +5205,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/quota": { - "get": { - "summary": "Report the authenticated uploader's storage-quota snapshot.", - "description": "Scoped to the caller, and to nobody else: quota is accounted to the *uploader*, and one\naccount's storage use is not another's business.", - "operationId": "get_quota", - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "403": { - "description": "Forbidden", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2770,18 +5269,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/QuotaResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -2789,9 +5304,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -2801,10 +5313,55 @@ ] } }, - "/v1/moderation/record": { - "get": { - "summary": "Serve the caller's own moderation record.", - "operationId": "moderation_record", + "/v1/auth/totp/verify-enrollment": { + "post": { + "summary": "Confirm an enrollment with a live code.", + "description": "The confirming code is **spent**: its step goes straight into the replay ledger, so it cannot\nalso complete a sign-in a moment later. That is the one place the ledger's first entry comes\nfrom, and skipping it would leave the newest code in the account's history unused.", + "operationId": "totp_verify_enrollment", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodeRequest" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -2816,6 +5373,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -2828,6 +5409,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2836,18 +5443,70 @@ } } }, - "200": { - "description": "OK", + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ModerationRecordResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "500": { - "description": "Internal server error", + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2856,101 +5515,200 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/.well-known/capsule/attestation-keys": { - "get": { - "summary": "Serve this server's storage-attestation keys and their append-only history.", - "description": "Cacheable and unauthenticated. It changes only when a key rotates, and a client that pinned\na stale copy still resolves every receipt signed before it fetched — which is the property\nthe append-only ordering buys.", - "operationId": "attestation_keys", - "responses": { - "200": { - "description": "OK", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AttestationKeysResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/server-info": { - "get": { - "summary": "Serve this server's public, server-scoped facts.", - "description": "Unauthenticated by contract: a client deciding whether it can talk to this server at all has\nno credential yet, and a peer resolving the key that verifies a capability token must not\nneed one from the server whose claims it is checking.", - "operationId": "server_info", - "responses": { - "200": { - "description": "OK", + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "409": { + "description": "Nothing pending", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ServerInfoResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/deprecation": { - "get": { - "summary": "Serve the announced deprecation cutoffs.", - "description": "The same announcements `server-info` carries, at their own path because that is the URL the\n`Warning:` header on a below-cutoff response points a human at, and because a client polling\nfor a cutoff should not have to refetch the whole discovery record to find one.", - "operationId": "deprecation_announcements", - "responses": { - "200": { - "description": "OK", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/DeprecationsResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/.well-known/capsule/revoked-jti": { - "get": { - "summary": "Serve the federation capability revocation list.", - "description": "Bounded by at most 24 hours of revocations, because an entry past the token's own `exp` is\npruned and a capability token cannot be minted to live longer than that. Public: a peer\nchecking whether a token it holds is still good is, by construction, not yet authenticated\nhere, and the record names no user — only opaque `jti`s.\n\n# Errors\n\nReturns `503` if the revocation list cannot be read. Deliberately *not* an empty list: an\nempty list is the strongest possible claim this endpoint can make — nothing is revoked — and\nserving it on a storage failure would turn an outage into a silent un-revocation of every\ntoken, which is exactly what the peer-side fail-closed rule exists to prevent.", - "operationId": "revoked_jti", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/RevokedJtiResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "503": { - "description": "Revocation list unavailable", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -2958,29 +5716,51 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - } + }, + "security": [ + { + "bearer": [] + } + ] } }, - "/v1/upload": { + "/v1/auth/totp/disable": { "post": { - "summary": "Open an upload session for one blob of an asset bundle.", - "description": "Runs the refuse-by-default envelope battery — invariants 1–8 and the top-level↔envelope\nconsistency family — **before** anything is written, then stages the session's file and\nrecords the session. A request whose `(owner, hash, album)` tuple already has an active\nsession gets that session back rather than a second one.", - "operationId": "create_upload", + "summary": "Remove the second factor, on presentation of a live code.", + "description": "**A session is not enough.** The whole point of the factor is that a stolen access token is\ninsufficient, and a disable that took only a token would let the token turn off the control\nthat makes it insufficient.", + "operationId": "totp_disable", "parameters": [ { "name": "X-Capsule-Protocol", "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], @@ -2988,7 +5768,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateUploadRequest" + "$ref": "#/components/schemas/CodeRequest" } } }, @@ -3005,6 +5785,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3017,6 +5821,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3027,6 +5857,32 @@ }, "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3037,8 +5893,34 @@ }, "415": { "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { "schema": { "$ref": "#/components/schemas/CodedProblem" } @@ -3047,6 +5929,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3055,130 +5963,164 @@ } } }, - "201": { - "description": "Upload session created", + "204": { + "description": "the request succeeded and there is no content to send", "headers": { - "Location": { - "description": "Where the session lives.", - "required": false, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Suggested-Chunk-Size": { - "description": "The starting chunk size.", - "required": false, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Offset": { - "description": "The authoritative offset, on a resumed session.", - "required": false, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "409": { + "description": "Not enrolled", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CreateUploadResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "200": { - "description": "The active session for these bytes, to resume", + "500": { + "description": "Internal server error", "headers": { - "Location": { - "description": "Where the session lives.", - "required": false, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "string", - "null" - ] + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Suggested-Chunk-Size": { - "description": "The starting chunk size.", - "required": false, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Offset": { - "description": "The authoritative offset, on a resumed session.", - "required": false, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CreateUploadResponse" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "409": { - "description": "Album quiescing", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/DuplicateBlobProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "426": { "description": "Protocol version unsupported", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "File too large", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3195,67 +6137,84 @@ ] } }, - "/v1/upload/{id}": { - "delete": { - "summary": "Cancel a session: its record, its accepted chunks and its staged bytes, together.", - "description": "Refused while finalization is running — it is not interruptible — and refused once the\nsession is terminal, because there is nothing left to cancel and the receipt is what a\nclient should read instead.", - "operationId": "cancel_upload", + "/v1/auth/login/verify-totp": { + "post": { + "summary": "Complete a sign-in with a code.", + "description": "This is where the session is opened — not `POST /v1/auth/login`, which for an account with a\nsecond factor opens nothing. The advisory `cohort_hash` and `device_id` ride *this* request\nfor the same reason: the session they describe is created here.", + "operationId": "totp_verify_login", "parameters": [ { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "X-Capsule-Protocol", + "name": "X-Capsule-Crypto-Suite", "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyLoginRequest" + } + } + }, + "required": true + }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -3264,21 +6223,34 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "426": { - "description": "Protocol version unsupported", - "content": { - "application/problem+json": { + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Upload session not found", + }, "content": { "application/problem+json": { "schema": { @@ -3287,8 +6259,34 @@ } } }, - "409": { - "description": "Session not active", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3297,64 +6295,68 @@ } } }, - "500": { - "description": "Internal server error", + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - }, - "head": { - "summary": "Report a session's progress and state.", - "description": "The resumption primitive: a client that lost a connection, an acknowledgement, or a process\nasks here and learns the authoritative offset, the declared length and the session's state.\nThe answer carries **no body** — HTTP forbids one on `HEAD`, which is why the protocol puts\nall three on headers.", - "operationId": "head_upload", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "X-Capsule-Protocol", - "in": "header", - "description": "The protocol date the client speaks.\n\nRead as a string rather than a typed value so that a malformed one is *this* surface's\ncoded `400` rather than the framework's uncoded one.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "responses": { "401": { - "description": "Unauthorized", + "description": "Challenge expired", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3365,8 +6367,34 @@ } } }, - "403": { - "description": "Forbidden", + "429": { + "description": "Too many attempts", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3375,8 +6403,34 @@ } } }, - "400": { - "description": "Bad Request", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3385,65 +6439,63 @@ } } }, - "200": { - "description": "Progress and state on X-Capsule-* headers, no body", + "413": { + "description": "the request body exceeds the configured limit", "headers": { - "X-Capsule-Offset": { - "description": "The next byte the server expects.", - "required": true, - "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" - } - }, - "X-Capsule-Content-Length": { - "description": "The declared total, fixed at creation.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "X-Capsule-Upload-Status": { - "description": "Where the session is in its state machine.", + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Cache-Control": { - "description": "`no-store`: progress is not cacheable.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "426": { "description": "Protocol version unsupported", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Upload session not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3451,90 +6503,86 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] - }, - "patch": { - "summary": "Append a chunk, and finalize when it completes the declared size.", - "description": "Every rule the [chunk\ncontract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes is\nchecked before a byte is written, and the checksum is verified against the received bytes\n*first*, so a chunk corrupted in transit persists nothing.", - "operationId": "append_chunk", + } + } + }, + "/v1/auth/oidc/authorize": { + "post": { + "summary": "Begin a sign-in through the identity provider.", + "description": "Unauthenticated: this is how a person *becomes* a session. Nothing about the account is\nknown yet — the ceremony carries fresh random `state`, `nonce` and PKCE material and the\nadmitted redirect URI, and the record behind the `state` lives for ten minutes.\n\nBounded twice, because it is an unauthenticated write into a store: a budget per redirect\nhost ([`budgets::OIDC_AUTHORIZE`]) answers `429` before anything is done, and the store's\nown ceiling answers `503` when it is nevertheless full.", + "operationId": "begin_oidc_login", "parameters": [ { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "X-Capsule-Protocol", + "name": "X-Capsule-Crypto-Suite", "in": "header", - "description": "The protocol date the client speaks.", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, { - "name": "X-Capsule-Offset", + "name": "X-Capsule-Sidecar-Schema", "in": "header", - "description": "Where in the blob this chunk starts.", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "X-Capsule-Checksum", - "in": "header", - "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/OidcAuthorizeRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3545,18 +6593,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -3565,8 +6629,34 @@ } } }, - "415": { - "description": "Unsupported media type", + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3575,62 +6665,70 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send", + "200": { + "description": "OK", "headers": { - "X-Capsule-Offset": { - "description": "The next byte the server expects.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "426": { - "description": "Protocol version unsupported", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "$ref": "#/components/schemas/OidcAuthorizationResponse" } } } }, "404": { - "description": "Upload session not found", - "content": { - "application/problem+json": { + "description": "Single sign-on not configured", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Offset mismatch", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/OffsetMismatchProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "Chunk too large", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3638,45 +6736,33 @@ } } } - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/upload/sessions": { - "get": { - "summary": "Every upload the caller can resume.", - "description": "Oldest first, which is the order the store promises and the order a client wants: the oldest\nin-flight session is the one closest to eviction.", - "operationId": "list_upload_sessions", - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Return only sessions in this state.\n\nOne of `pending`, `uploading`, `waiting_for_processing`, `completed`,\n`failed_processing` — the same tokens the `X-Capsule-Upload-Status` header carries, so a\nclient filters on the value it was already given rather than on a second vocabulary.", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + }, + "429": { + "description": "Too many sign-ins", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3687,18 +6773,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "503": { + "description": "Sign-in capacity reached", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -3707,18 +6809,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SessionsResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3728,105 +6846,62 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/upload/{id}/receipt": { - "get": { - "summary": "Fetch the custody receipt for a finalized upload.", - "operationId": "get_upload_receipt", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "The session's identifier, as `POST /v1/upload` returned it.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "description": "the request body exceeds the configured limit", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "200": { - "description": "OK", - "content": { - "application/cbor": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Receipt not available", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3834,31 +6909,46 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] + } } }, - "/v1/albums/{album_id}/ops": { + "/v1/auth/oidc/callback": { "post": { - "summary": "Apply one signed lifecycle manifest to an album's asset.", - "description": "The whole battery runs before anything is written, and a rejection writes nothing —\nincluding the blobs the bundle carries, which are stored only after the manifest has passed\nevery check the server can make without a key.", - "operationId": "album_lifecycle_op", + "summary": "Finish a sign-in with what the provider's redirect carried.", + "description": "The `state` is burned first and whatever happens next: a ceremony that survived a failed\ncallback would be a ceremony an attacker could retry a stolen code against. Then the code is\nexchanged and the ID token verified by the provider adapter, the identity is resolved to an\naccount — created on first sight, keyed on `(issuer, subject)`, never linked by address — and\nthe session is opened exactly as a password sign-in opens one, second factor included.", + "operationId": "complete_oidc_login", "parameters": [ { - "name": "album_id", - "in": "path", - "description": "The album's identifier.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], @@ -3866,45 +6956,41 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OpRequest" + "$ref": "#/components/schemas/OidcCallbackRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -3915,6 +7001,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3925,6 +7037,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3934,37 +7072,105 @@ } }, "200": { - "description": "OK", + "description": "A session was opened; here is its token pair.", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OpResponse" + "$ref": "#/components/schemas/TokenResponse" } } } }, - "426": { - "description": "Upgrade required", - "content": { - "application/problem+json": { + "202": { + "description": "The password verified; a second factor is required to finish.", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Stale revival", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/StaleRevivalProblem" + "$ref": "#/components/schemas/SecondFactorChallenge" } } } }, - "500": { - "description": "Internal server error", + "401": { + "description": "Sign-in expired", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -3973,62 +7179,32 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/sync": { - "get": { - "summary": "Returns the changes in the caller's library after `cursor`.", - "description": "Read-only and idempotent: two calls with the same cursor return the same page, because the\ncursor names a position rather than consuming one. That is what makes a lost response\nharmless and a retry free.", - "operationId": "sync_feed", - "parameters": [ - { - "name": "cursor", - "in": "query", - "description": "The opaque cursor a previous page returned. Absent means \"from the beginning\".", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "page_size", - "in": "query", - "description": "How many entries to return. Clamped into the range this server serves.\n\n`u32` and not `usize`: Kynos refuses to describe a platform-width integer, and it is\nright to — a schema whose bounds depend on the server's pointer size is a schema no\nclient can rely on.", - "required": false, - "schema": { - "type": [ - "integer", - "null" - ], - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "409": { + "description": "Address already registered", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4039,18 +7215,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4059,18 +7251,63 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SyncPageResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "500": { - "description": "Internal server error", + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4078,65 +7315,46 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] + } } }, - "/v1/blob/{hash}": { - "get": { - "summary": "Fetch a ciphertext blob by its content address, ranged.", - "description": "Opaque octets: the server holds no key and this route never learns what it is serving. Any\nauthenticated account may fetch any live address — see [`crate::serve`] for why that is a\ncapability model rather than a hole, and for the `403` the contract describes and nothing\nimplements.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).", - "operationId": "get_blob", + "/v1/auth/devices/enroll": { + "post": { + "summary": "Issue a one-time enrollment code for the caller's account.", + "description": "Gated on a recent credential presentation, not merely on a valid session — a stolen token\nmust not be able to enroll a rogue device. See [`crate::enrollment`] for exactly how much\nthat gate can mean.", + "operationId": "issue_enrollment_code", "parameters": [ { - "name": "hash", - "in": "path", - "description": "The blob's ciphertext content address, lowercase hex.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "Range", + "name": "X-Capsule-Protocol", "in": "header", - "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, "schema": { "type": "string", - "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" - }, - "example": "bytes=0-1023" - }, - { - "name": "If-Range", - "in": "header", - "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", - "schema": { - "type": "string" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "If-None-Match", + "name": "X-Capsule-Crypto-Suite", "in": "header", - "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, { - "name": "If-Modified-Since", + "name": "X-Capsule-Sidecar-Schema", "in": "header", - "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], @@ -4151,6 +7369,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4163,16 +7405,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4182,100 +7440,134 @@ } }, "200": { - "description": "the whole representation", + "description": "OK", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/EnrollmentCodeResponse" } } } }, - "206": { - "description": "the part the request asked for", + "500": { + "description": "Internal server error", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": "string" - } - }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", - "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "413": { + "description": "the request body exceeds the configured limit", "headers": { - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "409": { - "description": "Upload in progress", + }, "content": { "application/problem+json": { "schema": { @@ -4284,18 +7576,34 @@ } } }, - "410": { - "description": "Gone", - "content": { - "application/problem+json": { + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -4303,9 +7611,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -4315,54 +7620,84 @@ ] } }, - "/v1/storage/verify": { + "/v1/auth/devices/enroll/redeem": { "post": { - "summary": "Confirm that the server holds the copies a client is about to stop holding.", - "description": "A pure read: it writes no blob, no index row and no verdict. Soundness against a racing\ncollection comes from the standing GC grace window rather than from a per-request lease,\nwhich is why nothing here takes one.", - "operationId": "verify_storage", + "summary": "Redeem a code for a relay channel.", + "description": "**Unauthenticated, necessarily.** Device B has no account, no session and no key material —\nit is a phone that has just scanned a QR code. The code is the only thing it holds, so the\ncode is the credential.", + "operationId": "redeem_enrollment_code", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StorageVerifyRequest" + "$ref": "#/components/schemas/RedeemRequest" } } }, "required": true }, "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -4373,6 +7708,32 @@ }, "415": { "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4383,6 +7744,32 @@ }, "422": { "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4393,61 +7780,66 @@ }, "200": { "description": "OK", - "content": { - "application/json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/StorageVerifyResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ChannelResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/assets/{asset_id}/receipts": { - "get": { - "summary": "Fetch every custody receipt covering one asset.", - "operationId": "get_asset_receipts", - "parameters": [ - { - "name": "asset_id", - "in": "path", - "description": "The asset id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "401": { - "description": "Unauthorized", + "404": { + "description": "Code refused", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4458,92 +7850,68 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" + "429": { + "description": "Too many attempts", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/AssetReceiptsResponse" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/EnrollmentRateLimitedProblem" } } } }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/v1/shares": { - "post": { - "summary": "Register a share link the caller's client has issued.", - "operationId": "issue_share", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IssueShareRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4554,58 +7922,63 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "201": { - "description": "The share link is registered and servable", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/IssueShareResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -4613,45 +7986,94 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] + } } }, - "/v1/shares/{opaque_id}": { - "delete": { - "summary": "Revoke one of the caller's links.", - "description": "Idempotent from the caller's side and **indistinguishable**: a link that was never theirs, a\nlink that does not exist, and a link they already revoked are all `204`. Revocation is the\none operation where saying \"there was nothing to revoke\" would be a lookup.", - "operationId": "revoke_share", + "/v1/auth/devices/enroll/channel/{channel_id}": { + "get": { + "summary": "Take everything pending in one of a channel's mailboxes.", + "description": "Destructive: a relayed payload is delivered once. Draining one direction leaves the other\nuntouched, so the two devices do not consume each other's mail.", + "operationId": "drain_enrollment_channel", "parameters": [ { - "name": "opaque_id", + "name": "channel_id", "in": "path", - "description": "The opaque id.", + "description": "The handle a redeemed code returned.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "direction", + "in": "query", + "description": "`to_initiator` or `to_enrollee`.", "required": true, "schema": { "type": "string" } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } ], "responses": { - "401": { - "description": "Unauthorized", + "400": { + "description": "Bad Request", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -4662,98 +8084,70 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DrainResponse" } } } }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/CodedProblem" - } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/s/{opaque_id}": { - "get": { - "summary": "What a viewer needs to begin, for a live link.", - "operationId": "share_metadata", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "404": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/SharedMetadataResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "429": { - "description": "Too many requests", + }, "content": { "application/problem+json": { "schema": { @@ -4764,60 +8158,32 @@ }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/s/{opaque_id}/wrapped-secret": { - "get": { - "summary": "The passphrase-wrapped scope material, when there is one.", - "description": "A link with no passphrase answers `404` rather than `204` or an empty body: whether a link is\npassphrase-protected is already disclosed by the metadata record, and a *second* way to ask\nthe same question with a different shape is a second thing to keep consistent.", - "operationId": "share_wrapped_secret", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/octet-stream": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { "type": "string", - "format": "binary" + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -4826,94 +8192,123 @@ } } }, - "429": { - "description": "Too many requests", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } } - } - }, - "/s/{opaque_id}/blob/{hash}": { - "get": { - "summary": "Ciphertext for one of the link's blobs, ranged.", - "description": "The membership check is the security property: a link serves the addresses its record\nenumerates and nothing else, so it cannot be walked sideways into the album's unstripped\nmetadata. A blob the link does not name is the same `404` as a link that does not exist.", - "operationId": "share_blob", + }, + "post": { + "summary": "Append a payload to one of a channel's two mailboxes.", + "description": "Unauthenticated and gated by the handle alone. The relay is a dumb pipe by design — see\n[`crate::enrollment`] — and the safety-code check is what defends the ceremony.", + "operationId": "relay_enrollment_payload", "parameters": [ { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "hash", + "name": "channel_id", "in": "path", - "description": "The blob's content address.", + "description": "The handle a redeemed code returned.", "required": true, "schema": { "type": "string" } }, { - "name": "Range", + "name": "X-Capsule-Protocol", "in": "header", - "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, "schema": { "type": "string", - "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" - }, - "example": "bytes=0-1023" - }, - { - "name": "If-Range", - "in": "header", - "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", - "schema": { - "type": "string" + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "If-None-Match", + "name": "X-Capsule-Crypto-Suite", "in": "header", - "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, { - "name": "If-Modified-Since", + "name": "X-Capsule-Sidecar-Schema", "in": "header", - "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, "schema": { - "type": "string" + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelayRequest" + } + } + }, + "required": true + }, "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4922,101 +8317,135 @@ } } }, - "200": { - "description": "the whole representation", + "415": { + "description": "Unsupported Media Type", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "206": { - "description": "the part the request asked for", + "422": { + "description": "Unprocessable Entity", "headers": { - "Accept-Ranges": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "ETag": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { - "schema": { - "type": "string" - } - }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "204": { + "description": "the request succeeded and there is no content to send", "headers": { - "ETag": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "Last-Modified": { + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "429": { - "description": "Too many requests", + }, "content": { "application/problem+json": { "schema": { @@ -5027,45 +8456,30 @@ }, "500": { "description": "Internal server error", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/drops/links": { - "post": { - "summary": "Provision an upload link.", - "operationId": "provision_link", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProvisionLinkRequest" - } - } - }, - "required": true - }, - "responses": { - "401": { - "description": "Unauthorized", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5076,58 +8490,63 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "201": { - "description": "The upload link is provisioned and accepting drops", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProvisionLinkResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -5135,32 +8554,54 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] - } - }, - "/v1/drops/links/{opaque_id}": { + } + }, "delete": { - "summary": "Revoke one of the caller's links.", - "description": "Indistinguishable and idempotent, for the same reason a share revocation is: saying \"there\nwas nothing to revoke\" would be a lookup.", - "operationId": "revoke_link", + "summary": "Close a channel and drop both mailboxes with it.", + "description": "**The initiator's, and authenticated.** A close is the one relay operation that is not\nidempotent from the other device's point of view — it ends the ceremony — so leaving it on\nthe handle alone would make an abandoned QR code a denial of service. The account is checked\nagainst the channel's recorded initiator, and a channel belonging to another account answers\nexactly as an unknown one does.", + "operationId": "close_enrollment_channel", "parameters": [ { - "name": "opaque_id", + "name": "channel_id", "in": "path", - "description": "The opaque id.", + "description": "The handle a redeemed code returned.", "required": true, "schema": { "type": "string" } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } ], "responses": { @@ -5174,6 +8615,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5186,29 +8651,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -5217,66 +8685,34 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/d/{opaque_id}": { - "post": { - "summary": "Open a drop session through a link.", - "description": "Invariants 26–30 in order: the link admits the file and reserves its caps in one store\noperation, then the owner's quota is charged, then the declaration is checked.", - "operationId": "create_drop", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDropRequest" - } - } - }, - "required": true - }, - "responses": { "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "415": { - "description": "Unsupported Media Type", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "422": { - "description": "Unprocessable Entity", + }, "content": { "application/problem+json": { "schema": { @@ -5285,28 +8721,63 @@ } } }, - "201": { - "description": "A drop session is open and accepting chunks", - "content": { - "application/json": { + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CreateDropResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, "404": { - "description": "Not found", - "content": { - "application/problem+json": { + "description": "Channel not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "403": { - "description": "Passphrase required", + }, "content": { "application/problem+json": { "schema": { @@ -5315,8 +8786,34 @@ } } }, - "409": { - "description": "Link capacity exhausted", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5326,27 +8823,62 @@ } }, "413": { - "description": "File too large", - "content": { - "application/problem+json": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/FileTooLargeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } } }, - "429": { - "description": "Too many requests", - "content": { - "application/problem+json": { + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -5355,82 +8887,100 @@ } } } - } + }, + "security": [ + { + "bearer": [] + } + ] } }, - "/d/{opaque_id}/{upload_id}": { - "patch": { - "summary": "Append one chunk to a drop session.", - "description": "The link is the credential: possession of the opaque id, plus a session that belongs to it.\nEverything after that is [`crate::upload::chunk::append`] — the album path's own function.", - "operationId": "append_drop_chunk", + "/v1/albums": { + "post": { + "summary": "Bind an album id to the authenticated caller.", + "description": "Idempotent: the same id from a second device, or after a recovery, is a success that writes\nnothing.", + "operationId": "provision_album", "parameters": [ { - "name": "opaque_id", - "in": "path", - "description": "The opaque id of the link the session belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "upload_id", - "in": "path", - "description": "The session id.", + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, { - "name": "X-Capsule-Offset", + "name": "X-Capsule-Crypto-Suite", "in": "header", - "description": "Where in the blob this chunk starts.", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } }, { - "name": "X-Capsule-Checksum", + "name": "X-Capsule-Sidecar-Schema", "in": "header", - "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", "required": false, "schema": { - "type": [ - "string", - "null" - ] + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/ProvisionAlbumRequest" } } }, "required": true }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "415": { - "description": "Unsupported media type", + }, "content": { "application/problem+json": { "schema": { @@ -5439,22 +8989,34 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send", + "403": { + "description": "Forbidden", "headers": { - "X-Capsule-Offset": { - "description": "Where the session is now.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "integer", - "minimum": 0.0, - "format": "uint64" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -5463,8 +9025,34 @@ } } }, - "409": { - "description": "Chunk refused", + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5473,8 +9061,34 @@ } } }, - "500": { - "description": "Internal server error", + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5483,27 +9097,32 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - } - } - }, - "/v1/drops": { - "get": { - "summary": "The caller's pending drops.", - "operationId": "list_inbox", - "responses": { - "401": { - "description": "Unauthorized", + "422": { + "description": "Unprocessable Entity", "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5514,28 +9133,106 @@ } } }, - "403": { - "description": "Forbidden", + "201": { + "description": "The album was created and bound to the caller", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ProvisionAlbumResponse" } } } }, "200": { - "description": "OK", + "description": "The album id was already provisioned to this account; nothing was written", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InboxResponse" + "$ref": "#/components/schemas/ProvisionAlbumResponse" } } } }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5545,7 +9242,69 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } } }, "security": [ @@ -5555,32 +9314,54 @@ ] } }, - "/v1/drops/{drop_id}/adopt": { - "post": { - "summary": "Adopt a pending drop into an album.", - "description": "Invariant 32. The manifest re-runs the create battery — a drop that skipped it would be the\none write on this server that entered an album unvalidated — and its `ciphertext_hash` must\nname a blob in **the caller's own inbox**, which is what stops an adoption from minting an\nasset over somebody else's bytes.\n\nThe row is **claimed, written, then settled**. Across two ports there is no transaction, and\nthe two failure directions are not equal: writing first and deleting after can duplicate a\nphoto, taking first and failing to write loses one. A claim leaves a crash visible in the\nowner's own inbox instead, marked `adopting`.", - "operationId": "adopt_drop", + "/v1/albums/{album_id}/upgrade": { + "get": { + "summary": "Read the ceremony's phase and the drain count.", + "description": "The one call a proposer polls between steps 2 and 4. `in_flight` reaching zero is the signal\nthat the tombstone may be committed.", + "operationId": "album_upgrade_phase", "parameters": [ { - "name": "drop_id", + "name": "album_id", "in": "path", - "description": "The drop's identifier.", + "description": "The album's id.", "required": true, "schema": { "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AdoptRequest" - } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } }, - "required": true - }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], "responses": { "401": { "description": "Unauthorized", @@ -5592,28 +9373,32 @@ "type": "string" }, "example": "Bearer" - } - }, - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "400": { - "description": "Bad Request", + }, "content": { "application/problem+json": { "schema": { @@ -5622,8 +9407,34 @@ } } }, - "415": { - "description": "Unsupported Media Type", + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5632,8 +9443,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5644,16 +9481,68 @@ }, "200": { "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AdoptResponse" + "$ref": "#/components/schemas/UpgradePhaseResponse" } } } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5664,6 +9553,32 @@ }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5673,7 +9588,33 @@ } }, "413": { - "description": "the request body exceeds the configured limit" + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } } }, "security": [ @@ -5681,24 +9622,65 @@ "bearer": [] } ] - } - }, - "/v1/drops/{drop_id}": { - "delete": { - "summary": "Discard a pending drop.", - "description": "The bytes become unreferenced and the collector reclaims them; the link's cap is **not**\nrefunded, because the drop did happen — a guest deposited a file and the owner chose not to\nkeep it, which is not the same as a link slot never having been used.", - "operationId": "discard_drop", + }, + "post": { + "summary": "Put an album into upgrade quiescence.", + "description": "Idempotent under its own `intent_id`: versioning.md is explicit that the same `UpgradeIntent`\nnever produces two forks, and a proposer that lost an acknowledgement re-POSTs the same bytes.", + "operationId": "begin_album_upgrade", "parameters": [ { - "name": "drop_id", + "name": "album_id", "in": "path", - "description": "The drop's identifier.", + "description": "The album's id.", "required": true, "schema": { "type": "string" } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } } ], + "requestBody": { + "content": { + "application/cbor": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -5710,6 +9692,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5722,6 +9728,32 @@ }, "403": { "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5732,19 +9764,32 @@ }, "400": { "description": "Bad Request", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "404": { - "description": "Not found", + }, "content": { "application/problem+json": { "schema": { @@ -5753,8 +9798,34 @@ } } }, - "500": { - "description": "Internal server error", + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5763,1247 +9834,14169 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Upgrade in flight", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, "security": [ { "bearer": [] } ] - } - } - }, - "components": { - "schemas": { - "VersionResponse": { + }, + "delete": { + "summary": "Abort a ceremony, returning the album to normal operation.", + "description": "Named by `intent_id` in the path's own query so that aborting is a statement about *which*\nupgrade — a caller that does not hold the live id gets a `409` rather than the power to\ncancel somebody else's ceremony.", + "operationId": "abort_album_upgrade", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "intent_id", + "in": "query", + "description": "The ceremony to abort.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Upgrade in flight", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/albums/{album_id}/roster": { + "put": { + "summary": "Publish the caller's roster for one of their albums.", + "operationId": "publish_album_roster", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RosterRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/RosterVersionLeapProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RosterResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Roster stale", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/RosterStaleProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/upload": { + "post": { + "summary": "Open an upload session for one blob of an asset bundle.", + "description": "Runs the refuse-by-default envelope battery — invariants 1–8 and the top-level↔envelope\nconsistency family — **before** anything is written, then stages the session's file and\nrecords the session. A request whose `(owner, hash, album)` tuple already has an active\nsession gets that session back rather than a second one.", + "operationId": "create_upload", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "Upload session created", + "headers": { + "Location": { + "description": "Where the session lives.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + "X-Capsule-Suggested-Chunk-Size": { + "description": "The starting chunk size.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Offset": { + "description": "The authoritative offset, on a resumed session.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadResponse" + } + } + } + }, + "200": { + "description": "The active session for these bytes, to resume", + "headers": { + "Location": { + "description": "Where the session lives.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + "X-Capsule-Suggested-Chunk-Size": { + "description": "The starting chunk size.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Offset": { + "description": "The authoritative offset, on a resumed session.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadResponse" + } + } + } + }, + "409": { + "description": "Album quiescing", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/DuplicateBlobProblem" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "File too large", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/upload/{id}": { + "delete": { + "summary": "Cancel a session: its record, its accepted chunks and its staged bytes, together.", + "description": "Refused while finalization is running — it is not interruptible — and refused once the\nsession is terminal, because there is nothing left to cancel and the receipt is what a\nclient should read instead.", + "operationId": "cancel_upload", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Session not active", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "head": { + "summary": "Report a session's progress and state.", + "description": "The resumption primitive: a client that lost a connection, an acknowledgement, or a process\nasks here and learns the authoritative offset, the declared length and the session's state.\nThe answer carries **no body** — HTTP forbids one on `HEAD`, which is why the protocol puts\nall three on headers.", + "operationId": "head_upload", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "Progress and state on X-Capsule-* headers, no body", + "headers": { + "X-Capsule-Offset": { + "description": "The next byte the server expects.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Content-Length": { + "description": "The declared total, fixed at creation.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Upload-Status": { + "description": "Where the session is in its state machine.", + "required": true, + "schema": { + "type": "string" + } + }, + "Cache-Control": { + "description": "`no-store`: progress is not cacheable.", + "required": true, + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + }, + "patch": { + "summary": "Append a chunk, and finalize when it completes the declared size.", + "description": "Every rule the [chunk\ncontract](../../../capsule-docs/src/content/docs/design/import/upload-protocol.md) fixes is\nchecked before a byte is written, and the checksum is verified against the received bytes\n*first*, so a chunk corrupted in transit persists nothing.", + "operationId": "append_chunk", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Offset", + "in": "header", + "description": "Where in the blob this chunk starts.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Checksum", + "in": "header", + "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Offset": { + "description": "The next byte the server expects.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Upload session not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Offset mismatch", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/OffsetMismatchProblem" + } + } + } + }, + "413": { + "description": "Chunk too large", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/albums/{album_id}/ops": { + "post": { + "summary": "Apply one signed lifecycle manifest to an album's asset.", + "description": "The whole battery runs before anything is written, and a rejection writes nothing —\nincluding the blobs the bundle carries, which are stored only after the manifest has passed\nevery check the server can make without a key.", + "operationId": "album_lifecycle_op", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's identifier.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpResponse" + } + } + } + }, + "426": { + "description": "Upgrade required", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProtocolRangeProblem" + } + } + } + }, + "409": { + "description": "Stale revival", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/StaleRevivalProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/storage/verify": { + "post": { + "summary": "Confirm that the server holds the copies a client is about to stop holding.", + "description": "A pure read: it writes no blob, no index row and no verdict. Soundness against a racing\ncollection comes from the standing GC grace window rather than from a per-request lease,\nwhich is why nothing here takes one.", + "operationId": "verify_storage", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageVerifyRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageVerifyResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/shares": { + "post": { + "summary": "Register a share link the caller's client has issued.", + "operationId": "issue_share", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueShareRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "The share link is registered and servable", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueShareResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/shares/{opaque_id}": { + "delete": { + "summary": "Revoke one of the caller's links.", + "description": "Idempotent from the caller's side and **indistinguishable**: a link that was never theirs, a\nlink that does not exist, and a link they already revoked are all `204`. Revocation is the\none operation where saying \"there was nothing to revoke\" would be a lookup.", + "operationId": "revoke_share", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/links": { + "post": { + "summary": "Provision an upload link.", + "operationId": "provision_link", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionLinkRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "The upload link is provisioned and accepting drops", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionLinkResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/links/{opaque_id}": { + "delete": { + "summary": "Revoke one of the caller's links.", + "description": "Indistinguishable and idempotent, for the same reason a share revocation is: saying \"there\nwas nothing to revoke\" would be a lookup.", + "operationId": "revoke_link", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/{drop_id}/adopt": { + "post": { + "summary": "Adopt a pending drop into an album.", + "description": "Invariant 32. The manifest re-runs the create battery — a drop that skipped it would be the\none write on this server that entered an album unvalidated — and its `ciphertext_hash` must\nname a blob in **the caller's own inbox**, which is what stops an adoption from minting an\nasset over somebody else's bytes.\n\nThe row is **claimed, written, then settled**. Across two ports there is no transaction, and\nthe two failure directions are not equal: writing first and deleting after can duplicate a\nphoto, taking first and failing to write loses one. A claim leaves a crash visible in the\nowner's own inbox instead, marked `adopting`.", + "operationId": "adopt_drop", + "parameters": [ + { + "name": "drop_id", + "in": "path", + "description": "The drop's identifier.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdoptResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops/{drop_id}": { + "delete": { + "summary": "Discard a pending drop.", + "description": "The bytes become unreferenced and the collector reclaims them; the link's cap is **not**\nrefunded, because the drop did happen — a guest deposited a file and the owner chose not to\nkeep it, which is not the same as a link slot never having been used.", + "operationId": "discard_drop", + "parameters": [ + { + "name": "drop_id", + "in": "path", + "description": "The drop's identifier.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/albums/{album_id}/capabilities": { + "post": { + "summary": "Mint a capability letting one peer server pull one album.", + "description": "The token is in the response and nowhere else: this server keeps the record, never the\ncredential.", + "operationId": "issue_capability", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MintCapabilityRequest" + } + } + }, + "required": true + }, + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "The capability was minted", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MintedCapabilityResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Member not on roster", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/albums/{album_id}/capabilities/{jti}": { + "delete": { + "summary": "Revoke one capability of one album.", + "description": "Idempotent, and silent about what it did: a `jti` that is not a live capability of this\nalbum — never issued, already revoked, or another album's — is the same `204` a revocation\nis, so the operation is not a probe over identifiers.", + "operationId": "revoke_capability", + "parameters": [ + { + "name": "album_id", + "in": "path", + "description": "The album's id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jti", + "in": "path", + "description": "The capability's `jti`.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/federation/capabilities/refresh": { + "post": { + "summary": "Exchange a capability for its successor.", + "description": "The credential **is** the capability being refreshed; a session token has nothing to refresh\nhere and is refused.", + "operationId": "refresh_capability", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshedCapabilityResponse" + } + } + } + }, + "409": { + "description": "Member not on roster", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Rate budget exceeded", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/federation/reports": { + "post": { + "summary": "File a signed moderation report from a peer server.", + "description": "# Its only reachable answer today is `403`\n\nA report is verified against the peer's **operator-pinned** key, and nothing can pin one:\n[`boot::assemble`](crate::boot::assemble) refuses the durable backend until #403 lands its\nadapters, so an operator command that pinned a peer could only run against `serve --memory`\nand would forget the moment it exited. The command is owed with #476. Until it lands this\noperation answers `403 error.federation.peer_unknown` to every real peer.\n\nIt is mounted anyway, deliberately: a peer implementing against the published contract needs\nthe operation to exist and to answer honestly, and what is missing is the command, not the\nsurface. What is *not* acceptable is a route that reads as protection it cannot provide —\nhence this paragraph, and the matching status notes in design/moderation.md and\ndesign/federation.md.\n\n# No bearer, and why that is not \"unauthenticated\"\n\nThe reporting peer holds no capability here — it is reporting *this* server's content, not\npulling it — so there is nothing to present. What it does hold is a key an operator has\n**pinned**, and the report carries its own Ed25519 signature over the canonical CBOR of every\nother field. A report from a server nobody has pinned is `403`: intake is not the moment a\npeer becomes trusted (design/federation.md's TOFU is explicitly not done here).\n\n# The order the checks run in\n\nBounds, then how much may be asked for at all, then who is speaking, then whether they are\nwelcome, then whether they really said it, then whose account it is, then whether they have\nsaid it too often.\n\nEvery field is length-capped first, before a store is read or a byte is keyed on. Then\n[`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) — keyed on the\n*claimed* origin, so it bounds one origin looping rather than a caller cycling origins, which\nis the most this server can do without a trusted client address. Everything after it is a\nstore read and an Ed25519 verification, and this is the only place a bound on that work can\nsit.\n\nThe **policy** budgets are charged last, after the signature verifies, so a third party\nspoofing `reporting_server` cannot spend a real peer's allowance. Two of them: the contract's\nper-`(server, account)` limit, and a per-peer ceiling that ignores the account, because\n`reported_user` is a string the peer chooses and a peer cycling accounts would otherwise mint\nitself a fresh allowance each time.\n\nWhat is *not* bounded is bytes parsed per request: a per-operation body cap cannot be\nexpressed against this framework, and the reason is recorded on\n[`MAX_FEDERATION_BODY_BYTES`](crate::limits::MAX_FEDERATION_BODY_BYTES) (issue #478).\n\n# What accepting one does\n\nIt writes a row an operator will read ([`ModerationStore::pending_reports`]) and **nothing\nelse**. A peer's report is an input to a decision, never a decision: no standing changes, no\nserving hold appears, and the reported account sees nothing — because nothing has been done\nto them.\n\n# `202` whether or not the account exists\n\nA report naming an account this server does not host is **accepted on the wire and dropped**,\nwith a `warn` for the operator. It is not filed: an unresolvable report is a permanent orphan\nrow that nobody can act on, which is the reason the check exists at all.\n\nThe answer is deliberately the same one a filed report gets. An earlier version refused with a\ndistinct coded `404`, and that manufactured an account-enumeration oracle out of a check that\ndid not need one: a pinned peer could walk identifiers and read existence off the status line.\n\"Pinned\" is not \"trusted with enumeration\" — a peer key can be compromised, and a peer can be\nadversarial toward its own users while remaining an operator's legitimate partner — and this\ncodebase treats exists-versus-does-not as a first-order defect nearly everywhere else\n([`crate::routes::enroll`]'s indistinguishable code refusal, the album ceremonies' \"not yours\nis not found\", [`crate::serve::authority`]'s `404`/`403` boundary).\n\nProbing is not free even so: every budget above is charged before this point is reached, so a\npeer sweeping identifiers spends its allowance doing it and an operator sees the `warn`.", + "operationId": "submit_federated_report", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FederatedReportRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "202": { + "description": "The report was accepted for review", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FederatedReportResponse" + } + } + } + }, + "401": { + "description": "Report unsigned", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Peer unknown", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Report rate limited", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "426": { + "description": "Protocol version unsupported", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + } + } + }, + "/v1/auth/devices": { + "get": { + "summary": "List the caller's live sessions and the cohorts they group under.", + "description": "Scoped by credential with no path parameter, for the same reason the escrow is: the only\naccount entitled to a session ledger is its own, and making that structural beats enforcing\nit.", + "operationId": "list_devices", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DevicesResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/auth/devices/directory/{user_id}": { + "get": { + "summary": "Fetch a user's signed device directory, verbatim.", + "description": "The response body is the exact bytes the owner signed. Re-encoding them would detach the\ndocument from its signature, and the failure would look like the *publisher's* bug.", + "operationId": "fetch_device_directory", + "parameters": [ + { + "name": "user_id", + "in": "path", + "description": "The account id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/cbor": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/quota": { + "get": { + "summary": "Report the authenticated uploader's storage-quota snapshot.", + "description": "Scoped to the caller, and to nobody else: quota is accounted to the *uploader*, and one\naccount's storage use is not another's business.", + "operationId": "get_quota", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuotaResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/moderation/record": { + "get": { + "summary": "Serve the caller's own moderation record.", + "operationId": "moderation_record", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModerationRecordResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/upload/sessions": { + "get": { + "summary": "Every upload the caller can resume.", + "description": "Oldest first, which is the order the store promises and the order a client wants: the oldest\nin-flight session is the one closest to eviction.", + "operationId": "list_upload_sessions", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Return only sessions in this state.\n\nOne of `pending`, `uploading`, `waiting_for_processing`, `completed`,\n`failed_processing` — the same tokens the `X-Capsule-Upload-Status` header carries, so a\nclient filters on the value it was already given rather than on a second vocabulary.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionsResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/upload/{id}/receipt": { + "get": { + "summary": "Fetch the custody receipt for a finalized upload.", + "operationId": "get_upload_receipt", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The session's identifier, as `POST /v1/upload` returned it.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/cbor": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Receipt not available", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/sync": { + "get": { + "summary": "Returns the changes in the caller's library after `cursor`.", + "description": "Read-only and idempotent: two calls with the same cursor return the same page, because the\ncursor names a position rather than consuming one. That is what makes a lost response\nharmless and a retry free.", + "operationId": "sync_feed", + "parameters": [ + { + "name": "cursor", + "in": "query", + "description": "The opaque cursor a previous page returned. Absent means \"from the beginning\".", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "page_size", + "in": "query", + "description": "How many entries to return. Clamped into the range this server serves.\n\n`u32` and not `usize`: Kynos refuses to describe a platform-width integer, and it is\nright to — a schema whose bounds depend on the server's pointer size is a schema no\nclient can rely on.", + "required": false, + "schema": { + "type": [ + "integer", + "null" + ], + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32" + } + }, + { + "name": "album_id", + "in": "query", + "description": "One album's page rather than the caller's own feed (`S-C51`).\n\nFor the album's owner or any account on its current roster. Positions are the owner's\nsequence numbers filtered to the album, and the cursor is bound to `(caller, album)`, so\nit cannot be presented on the caller's own feed or on another album. Absent: the caller's\nown library, as before.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncPageResponse" + } + } + } + }, + "429": { + "description": "Rate budget exceeded", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/blob/{hash}": { + "get": { + "summary": "Fetch a ciphertext blob by its content address, ranged.", + "description": "Opaque octets: the server holds no key and this route never learns what it is serving. An\naccount fetches the blobs of its own assets and of the albums it is currently a member of; a\nformer member is told `403`, and everyone else is told what an unknown address is told —\nsee [`crate::serve`] for the boundary and its reasons.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).\n\nA federated peer fetches here with a capability instead of a session token (`S-E5`), through\nthe same resolution and the same authority.", + "operationId": "get_blob", + "parameters": [ + { + "name": "hash", + "in": "path", + "description": "The blob's ciphertext content address, lowercase hex.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Range", + "in": "header", + "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "schema": { + "type": "string", + "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" + }, + "example": "bytes=0-1023" + }, + { + "name": "If-Range", + "in": "header", + "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "the whole representation", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "the part the request asked for", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "Content-Range": { + "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "required": true, + "content": { + "text/plain": { + "schema": { + "type": "string", + "pattern": "^bytes \\d+-\\d+/\\d+$" + } + } + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "the client's copy is current", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Upload in progress", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "410": { + "description": "Gone", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Rate budget exceeded", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/assets/{asset_id}/receipts": { + "get": { + "summary": "Fetch every custody receipt covering one asset.", + "operationId": "get_asset_receipts", + "parameters": [ + { + "name": "asset_id", + "in": "path", + "description": "The asset id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetReceiptsResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/drops": { + "get": { + "summary": "The caller's pending drops.", + "operationId": "list_inbox", + "parameters": [ + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InboxResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "400": { + "description": "Malformed handshake", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/version": { + "get": { + "summary": "Reports the server's name and version.", + "description": "Unauthenticated and side-effect free. Clients use it as a reachability probe before\nattempting a protocol handshake, so it must stay cheap and must never fail for a reason\nthe caller could act on — there is no failure variant, and the return type says so.", + "operationId": "get_version", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/attestation-keys": { + "get": { + "summary": "Serve this server's storage-attestation keys and their append-only history.", + "description": "Cacheable and unauthenticated. It changes only when a key rotates, and a client that pinned\na stale copy still resolves every receipt signed before it fetched — which is the property\nthe append-only ordering buys.", + "operationId": "attestation_keys", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttestationKeysResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/server-info": { + "get": { + "summary": "Serve this server's public, server-scoped facts.", + "description": "Unauthenticated by contract: a client deciding whether it can talk to this server at all has\nno credential yet, and a peer resolving the key that verifies a capability token must not\nneed one from the server whose claims it is checking.", + "operationId": "server_info", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerInfoResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/deprecation": { + "get": { + "summary": "Serve the announced deprecation cutoffs.", + "description": "The same announcements `server-info` carries, at their own path because that is the URL the\n`Warning:` header on a below-cutoff response points a human at, and because a client polling\nfor a cutoff should not have to refetch the whole discovery record to find one.", + "operationId": "deprecation_announcements", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeprecationsResponse" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/.well-known/capsule/revoked-jti": { + "get": { + "summary": "Serve the federation capability revocation list.", + "description": "Bounded by at most 24 hours of revocations, because an entry past the token's own `exp` is\npruned and a capability token cannot be minted to live longer than that. Public: a peer\nchecking whether a token it holds is still good is, by construction, not yet authenticated\nhere, and the record names no user — only opaque `jti`s.\n\n# Errors\n\nReturns `503` if the revocation list cannot be read. Deliberately *not* an empty list: an\nempty list is the strongest possible claim this endpoint can make — nothing is revoked — and\nserving it on a storage failure would turn an outage into a silent un-revocation of every\ntoken, which is exactly what the peer-side fail-closed rule exists to prevent.", + "operationId": "revoked_jti", + "responses": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevokedJtiResponse" + } + } + } + }, + "503": { + "description": "Revocation list unavailable", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/s/{opaque_id}": { + "get": { + "summary": "What a viewer needs to begin, for a live link.", + "operationId": "share_metadata", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedMetadataResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ShareMetadataRateLimitedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/s/{opaque_id}/wrapped-secret": { + "get": { + "summary": "The passphrase-wrapped scope material, when there is one.", + "description": "A link with no passphrase answers `404` rather than `204` or an empty body: whether a link is\npassphrase-protected is already disclosed by the metadata record, and a *second* way to ask\nthe same question with a different shape is a second thing to keep consistent.", + "operationId": "share_wrapped_secret", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ShareSecretRateLimitedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/s/{opaque_id}/blob/{hash}": { + "get": { + "summary": "Ciphertext for one of the link's blobs, ranged.", + "description": "The membership check is the security property: a link serves the addresses its record\nenumerates and nothing else, so it cannot be walked sideways into the album's unstripped\nmetadata. A blob the link does not name is the same `404` as a link that does not exist.", + "operationId": "share_blob", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "hash", + "in": "path", + "description": "The blob's content address.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "Range", + "in": "header", + "description": "The part of the representation to transfer, per RFC 9110 section 14.2. A field this operation cannot apply is ignored and the whole representation is sent.", + "schema": { + "type": "string", + "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" + }, + "example": "bytes=0-1023" + }, + { + "name": "If-Range", + "in": "header", + "description": "The entity tag the client's partial copy came from, per RFC 9110 section 13.1.5. The `Range` is honoured only if it matches this representation under the strong comparison; otherwise the whole representation is sent.", + "schema": { + "type": "string" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "The entity tag the client already holds, per RFC 9110 section 13.1.2", + "schema": { + "type": "string" + } + }, + { + "name": "If-Modified-Since", + "in": "header", + "description": "The date the client's copy carries, per RFC 9110 section 13.1.3", + "schema": { + "type": "string" + } + } + ], + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "the whole representation", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "206": { + "description": "the part the request asked for", + "headers": { + "Accept-Ranges": { + "schema": { + "type": "string" + } + }, + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "Content-Range": { + "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "required": true, + "content": { + "text/plain": { + "schema": { + "type": "string", + "pattern": "^bytes \\d+-\\d+/\\d+$" + } + } + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "304": { + "description": "the client's copy is current", + "headers": { + "ETag": { + "schema": { + "type": "string" + } + }, + "Last-Modified": { + "schema": { + "type": "string" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ShareBlobRateLimitedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + }, + "/d/{opaque_id}": { + "post": { + "summary": "Open a drop session through a link.", + "description": "Invariants 26–30 in order: the link admits the file and reserves its caps in one store\noperation, then the owner's quota is charged, then the declaration is checked.", + "operationId": "create_drop", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDropRequest" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "201": { + "description": "A drop session is open and accepting chunks", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDropResponse" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Passphrase required", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Link capacity exhausted", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "File too large", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/FileTooLargeProblem" + } + } + } + }, + "429": { + "description": "Too many requests", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/DropRateLimitedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + } + } + } + }, + "/d/{opaque_id}/{upload_id}": { + "patch": { + "summary": "Append one chunk to a drop session.", + "description": "The link is the credential: possession of the opaque id, plus a session that belongs to it.\nEverything after that is [`crate::upload::chunk::append`] — the album path's own function.", + "operationId": "append_drop_chunk", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id of the link the session belongs to.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "upload_id", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Offset", + "in": "header", + "description": "Where in the blob this chunk starts.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Checksum", + "in": "header", + "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Offset": { + "description": "Where the session is now.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Chunk refused", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "RegisterRequest": { + "properties": { + "email": { + "type": "string", + "description": "The address the account is identified by." + }, + "password": { + "type": "string", + "description": "The password that will authenticate this account's **sessions**.\n\nNever the master key's input: the master key does not derive from it and is never visible\nto the credential verifier. Hashed by the registry adapter and never retained, logged, or\nechoed." + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "description": "The `POST /v1/auth/register` body.\n\nDeliberately the *smallest* thing that can create an account: an address and a password. No\ndisplay name, no profile, no invitation code — every one of those would be a field the server\nstores about a person, and this server's whole posture is that it stores as little as it can." + }, + "Problem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status" + ], + "title": "Problem Details", + "description": "An RFC 9457 problem detail." + }, + "TokenResponse": { + "properties": { + "access_token": { + "type": "string", + "description": "The short-lived credential for ordinary requests." + }, + "refresh_token": { + "type": "string", + "description": "The long-lived credential that buys new pairs from `POST /v1/auth/refresh`." + }, + "token_type": { + "type": "string", + "description": "Always `Bearer`." + }, + "expires_by": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The **absolute** Unix-seconds instant `access_token` stops being honoured.\n\nAbsolute rather than a duration, which is what the field has always carried despite its\nname; the SDK depends on it." + } + }, + "type": "object", + "required": [ + "access_token", + "refresh_token", + "token_type", + "expires_by" + ], + "description": "A freshly issued token pair.\n\nThe field names and `expires_by`'s meaning are a live client contract — `capsule-sdk`'s\n`TokenResponseBody` reads exactly these — so they are preserved verbatim from the Salvo\nsurface. `Debug` is hand-written; both tokens are bearer credentials.\n\n`Deserialize` is derived so the suite reads the pair back through the same type the server\nwrote — a test that pulled `access_token` out of a `serde_json::Value` would still pass if\nthe field were renamed on the way out." + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "description": "The account's email address." + }, + "password": { + "type": "string", + "description": "The account's password.\n\nVerified by the account directory and never retained, logged, or echoed." + }, + "cohort_hash": { + "type": [ + "string", + "null" + ], + "description": "An advisory device-cohort hash grouping one physical device's re-enrollments\n(slice `S-C13`).\n\nLegibility metadata only: no authorization path reads it, and an unusable value is\ndropped rather than refused — a sign-in must not fail over a field that gates nothing." + }, + "device_id": { + "type": [ + "string", + "null" + ], + "description": "The directory device the client claims to be (slice `S-N3`), as a UUID.\n\nClient-asserted and unverified. Dropped, not refused, when it is not a usable UUID, for\nthe same reason as `cohort_hash`." + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "description": "Credentials, plus the two advisory identifiers a client may volunteer.\n\n`Debug` is hand-written. A derived one would print the password into any log line, panic\nmessage or `tracing` field that formatted the request — which is the single worst thing this\nfile could do, and is one `#[derive(Debug)]` away at all times.\nNo `#[schema(min_length = ...)]` on either credential, deliberately. Kynos 0.1.0 publishes a\nstring constraint into the document but does not enforce it on the request path — an empty\npassword reaches the handler — so declaring one would put a promise in the contract that the\nserver does not keep, which is the exact class of drift this rebuild exists to remove. Length\nis a body-size concern and belongs to a limits middleware; it is recorded as owed rather than\nasserted here." + }, + "SecondFactorChallenge": { + "properties": { + "mfa_token": { + "type": "string", + "description": "The token to present alongside the code." + }, + "expires_by": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The **absolute** Unix-seconds instant the challenge stops being honoured.\n\nAbsolute rather than a duration, matching `TokenResponse::expires_by`, so a client has\none convention rather than two." + } + }, + "type": "object", + "required": [ + "mfa_token", + "expires_by" + ], + "description": "A half-finished sign-in.\n\n`Debug` is hand-written: the token is a credential, even though it authenticates nothing on\nits own." + }, + "RefreshRequest": { + "properties": { + "refresh_token": { + "type": "string", + "description": "The refresh token issued by a previous login or refresh.\n\nUnconstrained in the schema for the reason [`LoginRequest`] records: an empty one is a\ntoken that does not verify, which is a 401 the handler already answers correctly." + } + }, + "type": "object", + "required": [ + "refresh_token" + ], + "description": "The refresh token being exchanged for a new pair.\n\n`Debug` is hand-written, for the same reason as [`LoginRequest`]: this field is a live\ncredential." + }, + "RevokeChallengeResponse": { + "properties": { + "challenge": { + "type": "string", + "description": "The single-use token. Burned on the first attempt, successful or not." + }, + "expires_at": { + "type": "string", + "description": "When it stops being redeemable, RFC 3339." + } + }, + "type": "object", + "required": [ + "challenge", + "expires_at" + ], + "description": "The challenge a global sign-out is signed over." + }, + "RevokeAllRequest": { + "properties": { + "challenge": { + "type": "string", + "description": "The challenge that was issued." + }, + "proof": { + "type": "string", + "description": "The account identity key's hybrid signature over\n[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes),\ncanonical CBOR, base64." + } + }, + "type": "object", + "required": [ + "challenge", + "proof" + ], + "description": "A master-key proof over an issued challenge." + }, + "RevokeAllResponse": { + "properties": { + "revoked": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many sessions were closed — the caller's own among them.\n\nCounted from the records the store actually removed, never from a separately maintained\nindex. The Salvo implementation read a per-user set that `revoke_session` did not clean\nup, so this number inflated by one for every prior refresh; `S-C29` made the record and\nits listing entry one fact, so there is nothing left to disagree." + } + }, + "type": "object", + "required": [ + "revoked" + ], + "description": "What a global sign-out closed." + }, + "ReauthenticateRequest": { + "properties": { + "password": { + "type": "string", + "description": "The account's password." + } + }, + "type": "object", + "required": [ + "password" + ], + "description": "A password, re-presented on a session that already exists." + }, + "ReauthenticateResponse": { + "properties": { + "authenticated_at": { + "type": "string", + "description": "The moment the credential was accepted, RFC 3339.\n\nReturned so a client can decide locally whether a gated operation will be admitted,\nrather than discovering it from a `403` in the middle of a ceremony." + } + }, + "type": "object", + "required": [ + "authenticated_at" + ], + "description": "When the re-authenticated session's freshness window last opened." + }, + "PublishDirectoryResponse": { + "properties": { + "directory_version": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The version now stored, which equals the submitted one." + } + }, + "type": "object", + "required": [ + "directory_version" + ], + "description": "The accepted version, echoed so a client knows what is now in force." + }, + "StoreEscrowResponse": { + "properties": { + "stored_at": { + "type": "string", + "description": "When the server accepted it, RFC 3339.\n\nEchoed so a client can tell whether a cached copy is current — the stale-cache rule,\nwhich exists because a rotation from another device would otherwise manufacture false\nverification failures on this one." + }, + "replaced": { + "type": "boolean", + "description": "Whether this displaced an earlier escrow.\n\nA rotation and a first escrow are different events for a client: one completes account\nsetup, and the other means the previous recovery secret has stopped working." + } + }, + "type": "object", + "required": [ + "stored_at", + "replaced" + ], + "description": "What storing an escrow did." + }, + "UpdateProfileRequest": { + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "The display name to set, clear (`null`), or leave alone (absent)." + } + }, + "type": "object", + "description": "A partial edit of the caller's profile.\n\n`display_name` is a **doubly** optional field on the wire, and the two levels mean different\nthings: an absent key leaves the name alone, and an explicit `null` clears it. That is what\n`#[serde(default, deserialize_with = …)]` over an `Option>` buys, and it is\nthe whole reason this body is not `deny_unknown_fields`-plus-a-flat-option: a flat one cannot\ntell \"I did not mention the name\" from \"remove the name\", so every partial update would wipe\na field the caller never sent." + }, + "ProfileResponse": { + "properties": { + "user_id": { + "type": "string", + "description": "The account identifier every manifest and every session names." + }, + "email": { + "type": "string", + "description": "The address this account signs in with.\n\nRead-only on this surface. Changing it needs proof that the caller controls the new\naddress, and this server has no way to obtain one; see [`crate::auth::profile`]." + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "The name the account chose to be shown as, if it chose one.\n\nAbsent rather than `null` when unset, so a client's \"has a name\" test is a key test." + }, + "created_at": { + "type": "string", + "description": "When the account was created, RFC 3339." + } + }, + "type": "object", + "required": [ + "user_id", + "email", + "created_at" + ], + "description": "An account's profile as it is served.\n\n`Deserialize` is derived so the suite reads it back through the same type the server wrote —\na test pulling `display_name` out of a `serde_json::Value` would still pass if the field were\nrenamed on the way out." + }, + "ChangePasswordRequest": { + "properties": { + "current_password": { + "type": "string", + "description": "The password currently in use, which authorizes the change.\n\nVerified through the same directory method a sign-in uses, so a locked account is locked\nhere too." + }, + "new_password": { + "type": "string", + "description": "The password to replace it with." + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "description": "The two passwords a rotation needs.\n\n`Debug` is hand-written for the reason `routes::auth`'s bodies are: a derived one would print\nboth credentials into any log line that formatted the request." + }, + "EnrollmentResponse": { "properties": { - "name": { - "type": "string", - "description": "The server package name." - }, - "version": { + "provisioning_uri": { "type": "string", - "description": "The server package version." + "description": "The `otpauth://` URI an authenticator app scans.\n\nIt carries the shared secret, so it is a credential: served once, over the authenticated\nchannel, and never fetchable again. Losing it before confirming means enrolling again,\nwhich is why a *pending* enrollment is replaceable without ceremony." } }, "type": "object", "required": [ - "name", - "version" + "provisioning_uri" ], - "description": "Identifies the running server.\n\nDeliberately incurious: a name and a version, no build host, no commit, no uptime, no\nfeature list. This endpoint is unauthenticated, so everything it returns is public, and a\nkey-free server has no reason to hand an anonymous caller a fingerprint of its deployment.\nExact client build identification runs the other way (`S-D15`) — clients tell the server\nwhat they are, not the reverse." + "description": "A freshly issued, unconfirmed enrollment." }, - "RegisterRequest": { + "CodeRequest": { "properties": { - "email": { - "type": "string", - "description": "The address the account is identified by." - }, - "password": { + "totp_code": { "type": "string", - "description": "The password that will authenticate this account's **sessions**.\n\nNever the master key's input: the master key does not derive from it and is never visible\nto the credential verifier. Hashed by the registry adapter and never retained, logged, or\nechoed." + "description": "The code the authenticator app is showing." } }, "type": "object", "required": [ - "email", - "password" + "totp_code" ], - "description": "The `POST /v1/auth/register` body.\n\nDeliberately the *smallest* thing that can create an account: an address and a password. No\ndisplay name, no profile, no invitation code — every one of those would be a field the server\nstores about a person, and this server's whole posture is that it stores as little as it can." + "description": "A six-digit code, and nothing else." }, - "Problem": { + "VerifyLoginRequest": { "properties": { - "type": { - "type": "string" - }, - "title": { - "type": "string" + "mfa_token": { + "type": "string", + "description": "The challenge issued by `POST /v1/auth/login`." }, - "status": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16" + "totp_code": { + "type": "string", + "description": "The code the authenticator app is showing." }, - "detail": { - "type": "string" + "cohort_hash": { + "type": [ + "string", + "null" + ], + "description": "An advisory device-cohort hash grouping one physical device's re-enrollments (`S-C13`)." }, - "instance": { - "type": "string" + "device_id": { + "type": [ + "string", + "null" + ], + "description": "The directory device the client claims to be (`S-N3`), as a UUID." } }, - "additionalProperties": true, "type": "object", "required": [ - "type", - "status" + "mfa_token", + "totp_code" ], - "title": "Problem Details", - "description": "An RFC 9457 problem detail." + "description": "Completing a sign-in with a second factor.\n\nIt carries the same two advisory identifiers `LoginRequest` does, because *this* is the\nrequest that opens the session: without them a TOTP sign-in would land in the devices view as\nan unknown, ungrouped device (`S-N3`)." }, - "TokenResponse": { + "OidcAuthorizeRequest": { "properties": { - "access_token": { + "redirect_uri": { "type": "string", - "description": "The short-lived credential for ordinary requests." - }, - "refresh_token": { + "description": "Where the provider should send the person back: the client's own callback.\n\nAdmitted if it is the deployment's configured redirect URL exactly, or a loopback IP\nliteral (`http://127.0.0.1:{port}/…`, `http://[::1]:{port}/…`) on any port when the\ndeployment allows loopback redirects — the shape a CLI's or desktop app's listener has\n(RFC 8252 §7.3). Stored with the ceremony and replayed byte for byte to the token endpoint." + } + }, + "type": "object", + "required": [ + "redirect_uri" + ], + "description": "The `POST /v1/auth/oidc/authorize` body." + }, + "OidcAuthorizationResponse": { + "properties": { + "authorization_url": { "type": "string", - "description": "The long-lived credential that buys new pairs from `POST /v1/auth/refresh`." + "description": "The provider's authorization endpoint with the whole request in its query: `response_type`,\n`client_id`, `redirect_uri`, `scope`, `state`, `nonce`, `code_challenge`,\n`code_challenge_method`." }, - "token_type": { + "state": { "type": "string", - "description": "Always `Bearer`." + "description": "The `state` the provider will echo on the redirect. Present it, with the `code`, to the\ncallback. Good once, and until `expires_by`." }, "expires_by": { "type": "integer", "minimum": 0.0, "format": "uint64", - "description": "The **absolute** Unix-seconds instant `access_token` stops being honoured.\n\nAbsolute rather than a duration, which is what the field has always carried despite its\nname; the SDK depends on it." + "description": "The **absolute** Unix-seconds instant the ceremony stops being redeemable." } }, "type": "object", "required": [ - "access_token", - "refresh_token", - "token_type", + "authorization_url", + "state", "expires_by" ], - "description": "A freshly issued token pair.\n\nThe field names and `expires_by`'s meaning are a live client contract — `capsule-sdk`'s\n`TokenResponseBody` reads exactly these — so they are preserved verbatim from the Salvo\nsurface. `Debug` is hand-written; both tokens are bearer credentials.\n\n`Deserialize` is derived so the suite reads the pair back through the same type the server\nwrote — a test that pulled `access_token` out of a `serde_json::Value` would still pass if\nthe field were renamed on the way out." + "description": "A begun ceremony: where to send the person, and the `state` that comes back." }, - "LoginRequest": { + "OidcCallbackRequest": { "properties": { - "email": { + "state": { "type": "string", - "description": "The account's email address." + "description": "The `state` the authorize answered with, as the redirect echoed it." }, - "password": { + "code": { "type": "string", - "description": "The account's password.\n\nVerified by the account directory and never retained, logged, or echoed." + "description": "The authorization `code` the redirect carried." }, "cohort_hash": { "type": [ "string", "null" ], - "description": "An advisory device-cohort hash grouping one physical device's re-enrollments\n(slice `S-C13`).\n\nLegibility metadata only: no authorization path reads it, and an unusable value is\ndropped rather than refused — a sign-in must not fail over a field that gates nothing." + "description": "An advisory device-cohort hash (slice `S-C13`). Legibility metadata only; an unusable\nvalue is dropped rather than refused." }, "device_id": { "type": [ "string", "null" ], - "description": "The directory device the client claims to be (slice `S-N3`), as a UUID.\n\nClient-asserted and unverified. Dropped, not refused, when it is not a usable UUID, for\nthe same reason as `cohort_hash`." + "description": "The directory device the client claims to be (slice `S-N3`), as a UUID. Dropped, not\nrefused, when it is not a usable UUID." } }, "type": "object", "required": [ - "email", - "password" + "state", + "code" ], - "description": "Credentials, plus the two advisory identifiers a client may volunteer.\n\n`Debug` is hand-written. A derived one would print the password into any log line, panic\nmessage or `tracing` field that formatted the request — which is the single worst thing this\nfile could do, and is one `#[derive(Debug)]` away at all times.\nNo `#[schema(min_length = ...)]` on either credential, deliberately. Kynos 0.1.0 publishes a\nstring constraint into the document but does not enforce it on the request path — an empty\npassword reaches the handler — so declaring one would put a promise in the contract that the\nserver does not keep, which is the exact class of drift this rebuild exists to remove. Length\nis a body-size concern and belongs to a limits middleware; it is recorded as owed rather than\nasserted here." + "description": "The `POST /v1/auth/oidc/callback` body: what the provider's redirect carried, plus the two\nadvisory identifiers a client may volunteer for the session this request opens.\n\nStrict, like [`OidcAuthorizeRequest`]: a client that forwards the provider's whole redirect\nquery — `error`, `error_description`, `iss` (RFC 9207) — gets a `422` naming the field rather\nthan a callback that quietly ignored what the provider said." }, - "SecondFactorChallenge": { + "EnrollmentCodeResponse": { "properties": { - "mfa_token": { + "code": { "type": "string", - "description": "The token to present alongside the code." + "description": "The full-entropy code the QR payload carries." }, - "expires_by": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The **absolute** Unix-seconds instant the challenge stops being honoured.\n\nAbsolute rather than a duration, matching `TokenResponse::expires_by`, so a client has\none convention rather than two." + "text_fallback": { + "type": "string", + "description": "The shorter transcribable numeric fallback.\n\nDeliberately weaker than the QR payload and safe because it never stands alone:\nredemption is single-use and expires, and channel integrity rests on the safety-code\ncheck rather than on this value." + }, + "expires_at": { + "type": "string", + "description": "When both spellings stop being redeemable, RFC 3339." } }, "type": "object", "required": [ - "mfa_token", - "expires_by" + "code", + "text_fallback", + "expires_at" ], - "description": "A half-finished sign-in.\n\n`Debug` is hand-written: the token is a credential, even though it authenticates nothing on\nits own." + "description": "A freshly issued enrollment code." }, - "RefreshRequest": { + "RedeemRequest": { "properties": { - "refresh_token": { + "code": { "type": "string", - "description": "The refresh token issued by a previous login or refresh.\n\nUnconstrained in the schema for the reason [`LoginRequest`] records: an empty one is a\ntoken that does not verify, which is a 401 the handler already answers correctly." + "description": "Either spelling of the issued code." } }, "type": "object", "required": [ - "refresh_token" + "code" ], - "description": "The refresh token being exchanged for a new pair.\n\n`Debug` is hand-written, for the same reason as [`LoginRequest`]: this field is a live\ncredential." + "description": "The code a device presents." }, - "RevokeChallengeResponse": { + "ChannelResponse": { "properties": { - "challenge": { + "channel_id": { "type": "string", - "description": "The single-use token. Burned on the first attempt, successful or not." + "description": "The handle both devices relay through. Possession of it *is* the capability." }, "expires_at": { "type": "string", - "description": "When it stops being redeemable, RFC 3339." + "description": "When the channel closes on its own, RFC 3339." } }, "type": "object", "required": [ - "challenge", + "channel_id", "expires_at" ], - "description": "The challenge a global sign-out is signed over." + "description": "The channel a redeemed code opens." }, - "RevokeAllRequest": { + "RelayRequest": { "properties": { - "challenge": { + "direction": { "type": "string", - "description": "The challenge that was issued." + "description": "Which mailbox to append to: `to_initiator` or `to_enrollee`." }, - "proof": { + "payload": { "type": "string", - "description": "The account identity key's hybrid signature over\n[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes),\ncanonical CBOR, base64." + "description": "The opaque payload. The server never inspects it." } }, "type": "object", "required": [ - "challenge", - "proof" + "direction", + "payload" ], - "description": "A master-key proof over an issued challenge." + "description": "One relayed payload." }, - "RevokeAllResponse": { + "ProvisionAlbumRequest": { "properties": { - "revoked": { + "album_id": { + "type": "string", + "description": "The client-derived album id, as a canonical lowercase hyphenated UUID." + } + }, + "type": "object", + "required": [ + "album_id" + ], + "description": "The provisioning request." + }, + "ProvisionAlbumResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "protocol_version": { + "type": "string", + "description": "The protocol date the album is pinned to — the server's, fixed at creation." + }, + "created": { + "type": "boolean", + "description": "Whether this call created the album. Advisory; both answers mean the same thing." + } + }, + "type": "object", + "required": [ + "album_id", + "protocol_version", + "created" + ], + "description": "What provisioning did." + }, + "UpgradePhaseResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "intent_id": { + "type": [ + "string", + "null" + ], + "description": "The ceremony in flight, or absent when the album is in normal operation.\n\nAbsent also covers *expired*: the deadline passing aborts the upgrade, so there is nothing\nleft to be in." + }, + "to_protocol_version": { + "type": [ + "string", + "null" + ], + "description": "The protocol version the fork will be pinned to, when a ceremony is in flight." + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "When the window closes, RFC 3339, on the **server's** clock." + }, + "in_flight": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many upload sessions are still in flight against this album.\n\nThe drain signal of versioning.md step 3: the proposer waits for zero. A count rather than\na listing, because the proposer needs to know *whether* to wait and has no business seeing\nother members' upload identifiers to find out." + } + }, + "type": "object", + "required": [ + "album_id", + "in_flight" + ], + "description": "The ceremony this album is in, as a client polls it." + }, + "RosterRequest": { + "properties": { + "roster_cbor": { + "type": "string", + "description": "The signed roster, as standard base64 of its canonical CBOR encoding." + } + }, + "type": "object", + "required": [ + "roster_cbor" + ], + "description": "The publish request." + }, + "RosterResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "roster_version": { "type": "integer", "minimum": 0.0, "format": "uint64", - "description": "How many sessions were closed — the caller's own among them.\n\nCounted from the records the store actually removed, never from a separately maintained\nindex. The Salvo implementation read a per-user set that `revoke_session` did not clean\nup, so this number inflated by one for every prior refresh; `S-C29` made the record and\nits listing entry one fact, so there is nothing left to disagree." + "description": "The roster version the server holds after this call." + }, + "amk_epoch": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The AMK epoch that roster reflects." + }, + "member_count": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many members the held roster names, the owner excluded." + }, + "replayed": { + "type": "boolean", + "description": "Whether this call was a replay of the roster already held. Advisory: both answers mean\n\"the server holds this roster\"." } }, "type": "object", "required": [ - "revoked" + "album_id", + "roster_version", + "amk_epoch", + "member_count", + "replayed" ], - "description": "What a global sign-out closed." + "description": "What the server now holds for the album." }, - "SessionView": { + "WireBlobRole": { + "type": "string", + "enum": [ + "original", + "derivative", + "metadata", + "provenance", + "backup" + ], + "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." + }, + "ManifestEnvelope": { "properties": { - "session_id": { + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite the blob was sealed under. Must equal the top-level declaration." + }, + "protocol_version": { "type": "string", - "description": "The session's identifier — what a revoke names." + "description": "The protocol date the manifest was written under (`YYYY-MM-DD`)." }, - "created_at": { + "album_id": { + "type": [ + "string", + "null" + ], + "description": "The album the asset belongs to. Must equal the top-level declaration." + }, + "file_id": { "type": "string", - "description": "When this session *record* was minted, RFC 3339.\n\nA refresh rotates the session, so after one this is the rotation time and not the\nsign-in. `authenticated_at` is the field that answers \"when did you last sign in\"." + "description": "The asset this blob belongs to — the same id across the bundle's members." }, - "authenticated_at": { + "amk_version": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The album-key epoch the manifest was written under." + }, + "ciphertext_hash": { "type": "string", - "description": "When the user last proved a credential on this session's lineage, RFC 3339.\n\nCarried forward across refreshes, so it is the one timestamp here that means what a\nuser reading a devices list expects \"signed in\" to mean. It is also what the\ncross-device add's freshness gate reads (`S-C7`), so a client can show why an add is\nabout to ask for a password again." + "description": "The ciphertext content hash, lowercase hex. Must equal the top-level `hash`.\n\n**This names the blob this session is uploading, not the manifest's own\n`ciphertext_hash`.** For the original the two coincide; for a metadata or provenance\nsession they do not, and the projection reuses the manifest's field name for a per-blob\ndeclaration. Invisible for a `create`, because the bundle is assembled in a pending row\nnobody can see and no member has to name another. It is not invisible for a `replace`,\nwhich is why [`Self::original_blob_hash`] exists (`S-C43`)." }, - "last_active_at": { + "plaintext_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The plaintext length the manifest commits to." + }, + "chunk_size": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The STREAM plaintext chunk size." + }, + "key_mode": { "type": "string", - "description": "When it was last seen, RFC 3339.\n\nEqual to `created_at` until `S-C48` puts the session ledger on the request path. A\nclient must not label this \"last used\" before then." + "description": "`derived` or `wrapped`." }, - "user_agent": { + "metadata_blob_hash": { "type": [ "string", "null" ], - "description": "The `User-Agent` the opening ceremony carried, if any." + "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." }, - "ip_address": { + "original_blob_hash": { "type": [ "string", "null" ], - "description": "The address the opening ceremony came from, if any." + "description": "The content hash of the bundle's **original** blob, when the manifest commits to one\n(`S-C43`).\n\nThe manifest's own `ciphertext_hash`, under a name that cannot be confused with\n[`Self::ciphertext_hash`]'s per-session meaning. Optional on the wire and **required on a\n`replace`**: a replace re-points roles that already have bytes, so it has to be applied\nas one act, and the only member of the bundle that can carry the whole change is the\nmanifest — which therefore has to be able to name the original it commits to.\n\nA `create` may omit it. Its bundle is assembled incrementally in a row nobody can see,\nso no member needs to name another and requiring it would be a wire change for no gain." }, - "cohort_hash": { + "created_by_user": { + "type": "string", + "description": "The account that created the asset." + }, + "created_by_device": { + "type": "string", + "description": "The device that created it, as a UUID — invariant 7's subject." + }, + "client_version": { + "type": "string", + "description": "The client build that wrote the manifest." + }, + "timestamp": { + "type": "string", + "description": "The manifest's self-asserted RFC3339 timestamp — invariants 7 and 8's subject." + }, + "action": { + "type": "string", + "description": "The lifecycle action. `create` or `replace` on this surface — the two that move blob\nbytes — and see [`GateReject::ActionNotAllowed`] for the rest." + }, + "prior_provenance_hash": { "type": [ "string", "null" ], - "description": "The advisory cohort this session asserted, if any. Grouping only." + "description": "The provenance chain position this write continues from." }, - "device_id": { + "retention_until": { "type": [ "string", "null" ], - "description": "The directory device the client claimed to be (`S-N3`), if any.\n\nA different identifier space from `cohort_hash`: this names one directory device, the\ncohort groups re-enrollments of one physical device. Both are client-asserted; neither\ngates anything." - }, - "current": { - "type": "boolean", - "description": "Whether this is the session making the request.\n\nSo a client can label \"this device\" without comparing tokens it should not be handling,\nand so revoking the current session is a deliberate act rather than an accident." + "description": "The retention floor the manifest carries, when it carries one." } }, "type": "object", "required": [ - "session_id", - "created_at", - "authenticated_at", - "last_active_at", - "current" + "crypto_suite_id", + "protocol_version", + "file_id", + "amk_version", + "ciphertext_hash", + "plaintext_size", + "chunk_size", + "key_mode", + "created_by_user", + "created_by_device", + "client_version", + "timestamp", + "action" ], - "description": "One live session." + "description": "The server-visible mirror of the signed manifest's envelope fields, as declared at\n`POST /v1/upload`.\n\nStrict (`deny_unknown_fields`) like the rest of the transport JSON. The Postel asymmetry\nthe design draws — tolerant inside documents that outlive us, strict on the wire we own —\nputs unknown-key tolerance in the *signed CBOR interiors*, never in this JSON projection." }, - "CohortView": { + "CreateUploadRequest": { "properties": { - "cohort_hash": { - "type": "string", - "description": "The advisory hash." + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The ciphertext length in bytes. Immutable for the session's life." }, - "first_seen": { + "hash": { "type": "string", - "description": "The first time this account was seen under it, RFC 3339.\n\nWhat lets a client say *\"a device you've used before\"* about a session whose own\n`device_id` is new — which is the entire reason the map is durable." + "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." }, - "last_seen": { + "content_type": { "type": "string", - "description": "The most recent time, RFC 3339." - } - }, - "type": "object", - "required": [ - "cohort_hash", - "first_seen", - "last_seen" - ], - "description": "One cohort this account has been seen under." - }, - "DevicesResponse": { - "properties": { - "sessions": { - "items": { - "$ref": "#/components/schemas/SessionView" - }, - "type": "array", - "description": "Every live session, oldest first." + "description": "The media type, from the closed enum this protocol version fixes." }, - "cohorts": { - "items": { - "$ref": "#/components/schemas/CohortView" - }, - "type": "array", - "description": "Every cohort this account has ever been seen under, oldest first sighting first.\n\nServed **beside** the sessions rather than folded into them, because a cohort outlives\nthe sessions that carried it: a reinstall's new session groups with a cohort whose other\nsessions expired months ago, and a client that only had per-session cohorts could not\nsay \"you have used this device before\"." - } - }, - "type": "object", - "required": [ - "sessions", - "cohorts" - ], - "description": "The session ledger." - }, - "PublishDirectoryResponse": { - "properties": { - "directory_version": { + "crypto_suite_id": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "The version now stored, which equals the submitted one." - } - }, - "type": "object", - "required": [ - "directory_version" - ], - "description": "The accepted version, echoed so a client knows what is now in force." - }, - "StoreEscrowResponse": { - "properties": { - "stored_at": { - "type": "string", - "description": "When the server accepted it, RFC 3339.\n\nEchoed so a client can tell whether a cached copy is current — the stale-cache rule,\nwhich exists because a rotation from another device would otherwise manufacture false\nverification failures on this one." - }, - "replaced": { - "type": "boolean", - "description": "Whether this displaced an earlier escrow.\n\nA rotation and a first escrow are different events for a client: one completes account\nsetup, and the other means the previous recovery secret has stopped working." - } - }, - "type": "object", - "required": [ - "stored_at", - "replaced" - ], - "description": "What storing an escrow did." - }, - "ReauthenticateRequest": { - "properties": { - "password": { - "type": "string", - "description": "The account's password." - } - }, - "type": "object", - "required": [ - "password" - ], - "description": "A password, re-presented on a session that already exists." - }, - "ReauthenticateResponse": { - "properties": { - "authenticated_at": { - "type": "string", - "description": "The moment the credential was accepted, RFC 3339.\n\nReturned so a client can decide locally whether a gated operation will be admitted,\nrather than discovering it from a `403` in the middle of a ceremony." - } - }, - "type": "object", - "required": [ - "authenticated_at" - ], - "description": "When the re-authenticated session's freshness window last opened." - }, - "ProfileResponse": { - "properties": { - "user_id": { - "type": "string", - "description": "The account identifier every manifest and every session names." + "format": "uint16", + "description": "The crypto suite the blob was sealed under." }, - "email": { + "protocol_version": { "type": "string", - "description": "The address this account signs in with.\n\nRead-only on this surface. Changing it needs proof that the caller controls the new\naddress, and this server has no way to obtain one; see [`crate::auth::profile`]." + "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." }, - "display_name": { + "blob_role": { + "$ref": "#/components/schemas/WireBlobRole", + "description": "The blob's role in its bundle." + }, + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The unencrypted manifest fields the server validates." + }, + "album_id": { "type": [ "string", "null" ], - "description": "The name the account chose to be shown as, if it chose one.\n\nAbsent rather than `null` when unset, so a client's \"has a name\" test is a key test." + "description": "The album the asset is filed into.\n\nOptional on the wire because the contract reserves the shape for owner-scoped kinds and\nfor the album-upgrade ceremony; **required by this server**, which has no way to check\ninvariant 6 without one and refuses rather than skipping it." }, - "created_at": { - "type": "string", - "description": "When the account was created, RFC 3339." - } - }, - "type": "object", - "required": [ - "user_id", - "email", - "created_at" - ], - "description": "An account's profile as it is served.\n\n`Deserialize` is derived so the suite reads it back through the same type the server wrote —\na test pulling `display_name` out of a `serde_json::Value` would still pass if the field were\nrenamed on the way out." - }, - "UpdateProfileRequest": { - "properties": { - "display_name": { + "owner_id": { "type": [ "string", "null" ], - "description": "The display name to set, clear (`null`), or leave alone (absent)." - } - }, - "type": "object", - "description": "A partial edit of the caller's profile.\n\n`display_name` is a **doubly** optional field on the wire, and the two levels mean different\nthings: an absent key leaves the name alone, and an explicit `null` clears it. That is what\n`#[serde(default, deserialize_with = …)]` over an `Option>` buys, and it is\nthe whole reason this body is not `deny_unknown_fields`-plus-a-flat-option: a flat one cannot\ntell \"I did not mention the name\" from \"remove the name\", so every partial update would wipe\na field the caller never sent." - }, - "ChangePasswordRequest": { - "properties": { - "current_password": { - "type": "string", - "description": "The password currently in use, which authorizes the change.\n\nVerified through the same directory method a sign-in uses, so a locked account is locked\nhere too." + "description": "The owner the asset is filed under, when the client wants to say so.\n\nAdvisory, never decisive: the asset is filed under the **album's** owner, which the write\nauthority answers from the album record — the uploader when it is their album, the owner\nwhen the uploader is a writer on its roster (`S-C51`). A declared owner that is anyone\nelse, the uploading member included, is refused `error.upload.owner_not_permitted`." }, - "new_password": { - "type": "string", - "description": "The password to replace it with." + "intent_id": { + "type": [ + "string", + "null" + ], + "description": "The album-upgrade intent this write belongs to, when it belongs to one.\n\nCarried onto the session verbatim and read by nobody in this port; the ceremony that\ngives it meaning is `S-C24`." } }, "type": "object", "required": [ - "current_password", - "new_password" + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "blob_role", + "manifest_envelope" ], - "description": "The two passwords a rotation needs.\n\n`Debug` is hand-written for the reason `routes::auth`'s bodies are: a derived one would print\nboth credentials into any log line that formatted the request." + "description": "The body of `POST /v1/upload`.\n\nStrict (`deny_unknown_fields`): an unknown field is a client bug and is refused rather than\nignored. Plaintext metadata — a filename, a capture date, dimensions — is deliberately\nabsent: it rides the encrypted metadata blob and never the wire request." }, - "EnrollmentResponse": { + "CreateUploadResponse": { "properties": { - "provisioning_uri": { + "id": { "type": "string", - "description": "The `otpauth://` URI an authenticator app scans.\n\nIt carries the shared secret, so it is a credential: served once, over the authenticated\nchannel, and never fetchable again. Losing it before confirming means enrolling again,\nwhich is why a *pending* enrollment is replaceable without ceremony." - } - }, - "type": "object", - "required": [ - "provisioning_uri" - ], - "description": "A freshly issued, unconfirmed enrollment." - }, - "CodeRequest": { - "properties": { - "totp_code": { + "description": "The session's identifier." + }, + "upload_url": { "type": "string", - "description": "The code the authenticator app is showing." + "description": "Where to send chunks." + }, + "suggested_chunk_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "A starting chunk size. A suggestion only — the client owns adaptation." } }, "type": "object", "required": [ - "totp_code" + "id", + "upload_url", + "suggested_chunk_size" ], - "description": "A six-digit code, and nothing else." + "description": "What a client needs to start sending bytes." }, - "VerifyLoginRequest": { + "OpRequest": { "properties": { - "mfa_token": { - "type": "string", - "description": "The challenge issued by `POST /v1/auth/login`." + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The server-visible projection of the signed manifest's fields, exactly as\n`POST /v1/upload` carries it. Its `album_id` must equal the path segment and its\n`action` must be one this surface accepts." }, - "totp_code": { + "manifest_cbor": { "type": "string", - "description": "The code the authenticator app is showing." - }, - "cohort_hash": { - "type": [ - "string", - "null" - ], - "description": "An advisory device-cohort hash grouping one physical device's re-enrollments (`S-C13`)." + "description": "The signed manifest itself, base64 of the canonical CBOR.\n\nStored verbatim as the asset's new provenance blob, so the feed serves the exact bytes\nthe client signed (`S-C30`) for a lifecycle write as it already does for an upload. The\nserver does not parse it: base64 is a transport encoding, and `decode(encode(b)) == b`." }, - "device_id": { + "metadata_blob": { "type": [ "string", "null" ], - "description": "The directory device the client claims to be (`S-N3`), as a UUID." + "description": "The encrypted metadata blob, base64, present exactly when the action carries one.\n\nIts content hash must equal the manifest's committed `metadata_blob_hash`\n(invariant 25). The server holds no key and never reads it." } }, "type": "object", "required": [ - "mfa_token", - "totp_code" + "manifest_envelope", + "manifest_cbor" ], - "description": "Completing a sign-in with a second factor.\n\nIt carries the same two advisory identifiers `LoginRequest` does, because *this* is the\nrequest that opens the session: without them a TOTP sign-in would land in the devices view as\nan unknown, ungrouped device (`S-N3`)." + "description": "The signed manifest bundle a lifecycle write carries." }, - "EnrollmentCodeResponse": { + "OpResponse": { "properties": { - "code": { + "asset_id": { "type": "string", - "description": "The full-entropy code the QR payload carries." + "description": "The asset the op chained onto." }, - "text_fallback": { - "type": "string", - "description": "The shorter transcribable numeric fallback.\n\nDeliberately weaker than the QR payload and safe because it never stands alone:\nredemption is single-use and expires, and channel integrity rests on the safety-code\ncheck rather than on this value." + "sync_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The feed position it occupies. On a replay, the position the *first* application took." }, - "expires_at": { + "action": { "type": "string", - "description": "When both spellings stop being redeemable, RFC 3339." + "description": "The action that was applied." + }, + "replayed": { + "type": "boolean", + "description": "Whether this response is a replay of an already-applied manifest.\n\nAdvisory, and deliberately not something a correct client needs: the other three fields\nare identical either way, which is what \"byte-identical prior response\" means." } }, "type": "object", "required": [ - "code", - "text_fallback", - "expires_at" + "asset_id", + "sync_seq", + "action", + "replayed" ], - "description": "A freshly issued enrollment code." + "description": "What a lifecycle write did." }, - "RedeemRequest": { + "AssetVerifyRequest": { "properties": { - "code": { + "asset_id": { "type": "string", - "description": "Either spelling of the issued code." + "description": "The asset." + }, + "blob_hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Every content address the client would be trusting the server with. The verdict is a\nconjunction over exactly these, so a client asks about what it is about to delete." } }, "type": "object", "required": [ - "code" + "asset_id", + "blob_hashes" ], - "description": "The code a device presents." + "description": "One asset to verify, with the exact copies the client is relying on." }, - "ChannelResponse": { + "StorageVerifyRequest": { "properties": { - "channel_id": { - "type": "string", - "description": "The handle both devices relay through. Possession of it *is* the capability." + "assets": { + "items": { + "$ref": "#/components/schemas/AssetVerifyRequest" + }, + "type": "array", + "description": "The assets to verify." }, - "expires_at": { - "type": "string", - "description": "When the channel closes on its own, RFC 3339." + "deep": { + "type": "boolean", + "description": "Also re-read and re-hash the bytes (`S-C41`).\n\nAbsent or `false` is the structural check: ask the index and the store whether the bytes\nare there. `true` additionally re-hashes them, which is the only way to catch silent\ncorruption — `stored` is a question about the filesystem, and a corrupt blob is still\nstored.\n\n**Rate-limited per account**, because a deep scan reads and hashes every declared blob\nand an unbounded one is an I/O-amplification attack costing the caller one small JSON\nbody. Past the budget the *structural* verdict still comes back and each blob's `deep`\nreads `rate_limited`: throwing away a good structural answer because the optional half\nwas throttled would make the limiter cost more than it saves." } }, "type": "object", "required": [ - "channel_id", - "expires_at" + "assets" ], - "description": "The channel a redeemed code opens." + "description": "The `POST /v1/storage/verify` body." }, - "RelayRequest": { + "BlobVerdictResponse": { "properties": { - "direction": { + "hash": { "type": "string", - "description": "Which mailbox to append to: `to_initiator` or `to_enrollee`." + "description": "The address, as the client declared it." }, - "payload": { + "role": { "type": "string", - "description": "The opaque payload. The server never inspects it." + "description": "The role the asset holds it under — `unknown` for a hash the asset does not hold." + }, + "stored": { + "type": "boolean", + "description": "The bytes are present at that address." + }, + "indexed": { + "type": "boolean", + "description": "A live asset of the caller's references the address." + }, + "retrievable": { + "type": "boolean", + "description": "Nothing is withholding it." + }, + "deep": { + "type": [ + "string", + "null" + ], + "description": "What a deep scan found: `intact`, `corrupt`, or `rate_limited` (`S-C41`).\n\n**Absent when no deep scan ran**, and the absence is load-bearing: it is the difference\nbetween \"we did not look at the bytes\" and \"we looked and they were fine\", and a client\ndeciding whether to release its only copy has to be able to tell those apart." } }, "type": "object", "required": [ - "direction", - "payload" + "hash", + "role", + "stored", + "indexed", + "retrievable" ], - "description": "One relayed payload." + "description": "One declared blob's verdict." }, - "DrainResponse": { + "StorageVerdictResponse": { "properties": { - "payloads": { + "asset_id": { + "type": "string", + "description": "The asset the client asked about." + }, + "durable": { + "type": "boolean", + "description": "Every declared blob is stored ∧ indexed ∧ retrievable. **This is the field that gates a\ndeletion**, so it is false whenever the server cannot say otherwise." + }, + "blobs": { "items": { - "type": "string" + "$ref": "#/components/schemas/BlobVerdictResponse" }, "type": "array", - "description": "The payloads in arrival order, removed by this call. Possibly empty." + "description": "One entry per declared hash, in declaration order and never shortened." + }, + "checked_at": { + "type": "string", + "description": "The server's own clock at verification, RFC 3339. Never the client's." } }, "type": "object", "required": [ - "payloads" + "asset_id", + "durable", + "blobs", + "checked_at" ], - "description": "Everything pending in one mailbox." + "description": "One asset's verdict." }, - "ProvisionAlbumRequest": { + "StorageVerifyResponse": { "properties": { - "album_id": { - "type": "string", - "description": "The client-derived album id, as a canonical lowercase hyphenated UUID." + "verdicts": { + "items": { + "$ref": "#/components/schemas/StorageVerdictResponse" + }, + "type": "array", + "description": "One verdict per requested asset, in request order." } }, "type": "object", "required": [ - "album_id" + "verdicts" ], - "description": "The provisioning request." + "description": "The `POST /v1/storage/verify` response." }, - "ProvisionAlbumResponse": { + "IssueShareRequest": { "properties": { - "album_id": { - "type": "string", - "description": "The album, echoed." - }, - "protocol_version": { + "opaque_id": { "type": "string", - "description": "The protocol date the album is pinned to — the server's, fixed at creation." + "description": "The 128-bit opaque id, 32 lowercase hex characters, drawn from the client's CSPRNG.\n\nMinted by the client rather than the server because the client is what knows the\nfragment secret the id is paired with; the server checks its shape and stores it." }, - "created": { - "type": "boolean", - "description": "Whether this call created the album. Advisory; both answers mean the same thing." - } - }, - "type": "object", - "required": [ - "album_id", - "protocol_version", - "created" - ], - "description": "What provisioning did." - }, - "UpgradePhaseResponse": { - "properties": { - "album_id": { + "metadata_hash": { "type": "string", - "description": "The album, echoed." + "description": "The metadata blob a viewer starts from. Must appear in `serves`." }, - "intent_id": { - "type": [ - "string", - "null" - ], - "description": "The ceremony in flight, or absent when the album is in normal operation.\n\nAbsent also covers *expired*: the deadline passing aborts the upgrade, so there is nothing\nleft to be in." + "serves": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Every blob this link may serve, and nothing else.\n\nEnumerated by the issuing client, which is what makes the boundary-crossing strip\nstick: the client points the link at blobs it prepared for export, and the server has no\npath from an opaque id to anything outside this set." }, - "to_protocol_version": { + "wrapped_secret": { "type": [ "string", "null" ], - "description": "The protocol version the fork will be pinned to, when a ceremony is in flight." + "description": "The passphrase-wrapped scope material, base64, when the link is passphrase-protected.\n\nOpaque to this server. The passphrase never crosses the wire — unwrap is client-side." }, "expires_at": { "type": [ "string", "null" ], - "description": "When the window closes, RFC 3339, on the **server's** clock." - }, - "in_flight": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "How many upload sessions are still in flight against this album.\n\nThe drain signal of versioning.md step 3: the proposer waits for zero. A count rather than\na listing, because the proposer needs to know *whether* to wait and has no business seeing\nother members' upload identifiers to find out." + "description": "When the link stops being live, RFC 3339. Absent means no expiry." } }, "type": "object", "required": [ - "album_id", - "in_flight" + "opaque_id", + "metadata_hash", + "serves" ], - "description": "The ceremony this album is in, as a client polls it." + "description": "A link the owner's client has issued." }, - "QuotaResponse": { + "IssueShareResponse": { "properties": { - "used": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Bytes charged to the caller." - }, - "soft_limit": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "Where the warning starts, or absent on an unlimited deployment." - }, - "hard_limit": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "Where uploads stop, or absent on an unlimited deployment." - }, - "state": { + "opaque_id": { "type": "string", - "description": "The classified state: `ok`, `soft_warning`, `hard_exceeded`, `grace_expired`." + "description": "The opaque id, echoed." } }, "type": "object", "required": [ - "used", - "state" + "opaque_id" ], - "description": "A user's quota snapshot." + "description": "Confirmation that a link is now servable." }, - "ModerationEventResponse": { + "ProvisionLinkRequest": { "properties": { - "action": { + "opaque_id": { "type": "string", - "description": "What was done: `suspended`, `reinstated`, `taken_down`, `legal_hold`, `hold_lifted`." + "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." }, - "asset_id": { + "drop_pubkey": { + "type": "string", + "description": "The Drop Key's public half, base64. Opaque here — the server never decapsulates." + }, + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The suite a drop must be sealed under." + }, + "expires_at": { "type": [ "string", "null" ], - "description": "The asset, when the action was about one rather than about the account." - }, - "at": { - "type": "string", - "description": "When it happened, RFC 3339." + "description": "When the link stops accepting drops, RFC 3339." }, - "reason": { + "max_total_bytes": { "type": [ - "string", + "integer", "null" ], - "description": "Why, where policy permits.\n\nAbsent is a real answer — a legal hold may come with an obligation not to disclose it —\nand reads as \"we are not able to say\", which is honest where a fabricated reason would\nnot be." - } - }, - "type": "object", - "required": [ - "action", - "at" - ], - "description": "One thing that was done to the account." - }, - "ModerationRecordResponse": { - "properties": { - "standing": { - "type": "string", - "description": "`active` or `suspended`." + "minimum": 0.0, + "format": "uint64", + "description": "Cumulative bytes across every drop on this link." }, - "suspended_since": { + "max_file_count": { "type": [ - "string", + "integer", "null" ], - "description": "When a suspension began, RFC 3339. Absent while the account is active." - }, - "events": { - "items": { - "$ref": "#/components/schemas/ModerationEventResponse" - }, - "type": "array", - "description": "Everything done to this account, oldest first.\n\nA reinstatement does not erase the suspension it lifted: the record is what a user reads\nto understand their own account, and one that deleted its own history would leave them\nunable to see that anything ever happened." - } - }, - "type": "object", - "required": [ - "standing", - "events" - ], - "description": "The caller's moderation record." - }, - "PublishedKeyResponse": { - "properties": { - "key_id": { - "type": "string", - "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." - }, - "public": { - "type": "string", - "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "How many files the link may deposit." }, - "algorithm": { - "type": "string", - "description": "The signature algorithm this key is used with." + "max_file_size": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "The largest single file." }, - "active_from": { - "type": "string", - "description": "When it began signing, RFC 3339." + "single_use": { + "type": "boolean", + "description": "Whether the link dies after its first successful drop." }, - "active_to": { + "passphrase_verifier": { "type": [ "string", "null" ], - "description": "When it stopped, or absent while it is the active key." + "description": "An Argon2id **verifier**, base64, when the link is passphrase-gated.\n\nA verifier and never a passphrase: this is an abuse gate the server checks, which is why\nit is stored here at all — unlike a share link's passphrase, which protects decryption\nand which the server never sees in any form." } }, "type": "object", "required": [ - "key_id", - "public", - "algorithm", - "active_from" + "opaque_id", + "drop_pubkey", + "crypto_suite_id" ], - "description": "One published attestation key." + "description": "A link the owner is provisioning." }, - "AttestationKeysResponse": { + "ProvisionLinkResponse": { "properties": { - "server_id": { + "opaque_id": { "type": "string", - "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." - }, - "keys": { - "items": { - "$ref": "#/components/schemas/PublishedKeyResponse" - }, - "type": "array", - "description": "Every key this server has signed with, oldest first, the active one last." + "description": "The opaque id, echoed." } }, "type": "object", "required": [ - "server_id", - "keys" + "opaque_id" ], - "description": "The `.well-known/capsule/attestation-keys` record." + "description": "Confirmation that a link is live." }, - "AuthEndpointsResponse": { + "AdoptRequest": { "properties": { - "login": { + "album_id": { + "type": "string", + "description": "The album to adopt into." + }, + "asset_id": { + "type": "string", + "description": "The asset the drop becomes." + }, + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The blob's declared size — the inbox row's, restated and checked against it." + }, + "hash": { "type": "string", - "description": "Where a session is opened." + "description": "The ciphertext hash, which must name **this drop's** blob." }, - "refresh": { + "content_type": { "type": "string", - "description": "Where an access token is rotated." + "description": "The declared content type." }, - "logout": { + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite." + }, + "protocol_version": { "type": "string", - "description": "Where a session is ended." + "description": "The protocol version the manifest is written against." + }, + "key_mode": { + "type": "string", + "description": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." + }, + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The signed manifest envelope, verbatim." } }, "type": "object", "required": [ - "login", - "refresh", - "logout" + "album_id", + "asset_id", + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "key_mode", + "manifest_envelope" ], - "description": "The auth ceremony's endpoints." + "description": "The owner's signed `create` over a drop already in their inbox.\n\nThe same shape a `POST /v1/upload` create carries, minus everything about transferring bytes:\nthe blob is already committed, so there is no size to negotiate and no session to open. What\nremains is the manifest, which is the whole point — a drop becomes an asset only when the\n**owner** signs for it." }, - "ProtocolWindowResponse": { + "AdoptResponse": { "properties": { - "min": { - "type": "string", - "description": "The oldest version still accepted for writes." - }, - "max": { + "asset_id": { "type": "string", - "description": "The newest version this server speaks." + "description": "The asset the drop became." } }, "type": "object", "required": [ - "min", - "max" + "asset_id" ], - "description": "The accepted `protocol_version` range." + "description": "What adoption produced." }, - "DeprecationResponse": { + "WireScope": { + "type": "string", + "enum": [ + "read", + "read-derivative-only" + ], + "description": "What a capability permits, on the wire.\n\nA mirror of [`Scope`] rather than the type itself, for the reason\n[`WireBlobRole`](crate::routes::upload::WireBlobRole) is one: the domain enum is not a schema\ntype, and the wire spelling is a contract that should not move when an internal name does." + }, + "MintCapabilityRequest": { "properties": { - "min_protocol_version": { + "peer": { "type": "string", - "description": "The lowest `protocol_version` that remains accepted after the cutoff." + "description": "The peer server the grant is for, as its own `server-info` names it (`other.tld`)." }, - "announced_at": { + "member": { "type": "string", - "description": "When the announcement was first published, RFC 3339." + "description": "The roster member whose access the grant carries, as the owner listed them." }, - "cutoff": { - "type": "string", - "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." + "scope": { + "$ref": "#/components/schemas/WireScope", + "description": "What the grant permits." }, - "detail_url": { + "ttl_seconds": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "How long **one token** should live, in seconds. Clamped to the 24-hour ceiling; absent is\nsix hours." + }, + "renewable_until": { "type": [ "string", "null" ], - "description": "Where a human reads what to do about it." + "description": "The absolute deadline the whole grant dies at, RFC 3339 — and the only thing that makes\nit **renewable**.\n\nAbsent, the default, is a grant that cannot be refreshed at all: it lives exactly\n`ttl_seconds` and then the owner mints again if they still mean to share. Present, it\nmust be in the future and at most ninety days out." } }, "type": "object", "required": [ - "min_protocol_version", - "announced_at", - "cutoff" + "peer", + "member", + "scope" ], - "description": "One announced deprecation cutoff." + "description": "The mint request." }, - "ServerInfoResponse": { + "MintedCapabilityResponse": { "properties": { - "server_id": { + "token": { "type": "string", - "description": "This server's canonical origin." + "description": "The signed capability, to be carried as `Authorization: Bearer`." }, - "api_base_url": { + "jti": { "type": "string", - "description": "Where the versioned API lives." + "description": "Its identifier, and the key it is revoked by." }, - "auth": { - "$ref": "#/components/schemas/AuthEndpointsResponse", - "description": "Where a client performs the auth ceremony." + "album_id": { + "type": "string", + "description": "The album it scopes to." }, - "federation_url": { - "type": [ - "string", - "null" - ], - "description": "Where federated peers talk to this server. Absent when it does not federate." + "peer": { + "type": "string", + "description": "The peer it was minted for." }, - "protocol_version": { - "$ref": "#/components/schemas/ProtocolWindowResponse", - "description": "The `protocol_version` range accepted for writes today, both ends inclusive." + "member": { + "type": "string", + "description": "The roster member whose access it carries." }, - "signing_key": { + "scope": { + "$ref": "#/components/schemas/WireScope", + "description": "What it permits." + }, + "issued_at": { "type": "string", - "description": "The raw Ed25519 public key this server's tokens verify under, base64." + "description": "When it was minted, RFC 3339." }, - "signing_algorithm": { + "expires_at": { "type": "string", - "description": "The signature algorithm that key is used with." + "description": "When **this token** stops being honoured, RFC 3339." }, - "deprecations": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Announced deprecation cutoffs, in announcement order. Empty when none is pending." - } - }, - "type": "object", - "required": [ - "server_id", - "api_base_url", - "auth", - "protocol_version", - "signing_key", - "signing_algorithm", - "deprecations" - ], - "description": "The `.well-known/capsule/server-info` record.\n\nServer-scoped facts only. The registry's rule — *never a user list* — is structural here:\nthis type holds no user-shaped field, so there is nothing for a future edit to leak through." - }, - "DeprecationsResponse": { - "properties": { - "announcements": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Every announced cutoff, in announcement order." - } - }, - "type": "object", - "required": [ - "announcements" - ], - "description": "The `.well-known/capsule/deprecation` record." - }, - "RevokedTokenResponse": { - "properties": { - "jti": { + "not_after": { "type": "string", - "description": "The token's `jti` claim." + "description": "When the **whole grant** dies, RFC 3339. Equal to `expires_at` when it is not renewable." }, - "expires_at": { + "renewable": { + "type": "boolean", + "description": "Whether a refresh may issue a successor from this grant.\n\nStated plainly rather than left to be inferred from the two timestamps above: how long\nan owner is sharing for is the decision this response reports back to them." + }, + "min_protocol_version": { "type": "string", - "description": "The token's own `exp`, RFC 3339. After this the entry is pruned." + "description": "The album's pinned protocol date, which the peer must speak to pull." } }, "type": "object", "required": [ + "token", "jti", - "expires_at" + "album_id", + "peer", + "member", + "scope", + "issued_at", + "expires_at", + "not_after", + "renewable", + "min_protocol_version" ], - "description": "One revoked capability token." + "description": "A freshly minted capability.\n\nThe token is returned **once**. Nothing on this server can produce it again — a stored grant\nre-signs byte-for-byte, but only the refresh operation does that, and only for its holder." }, - "RevokedJtiResponse": { + "RefreshedCapabilityResponse": { "properties": { - "generated_at": { + "token": { "type": "string", - "description": "When this snapshot was taken, RFC 3339.\n\nPart of the record rather than left to an HTTP `Date`, because the staleness rule a peer\napplies is a property of the list's content — a verifier reasoning from a transport\nheader would be trusting a cache to be honest about its own age." + "description": "The successor token." }, - "max_staleness_seconds": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "How stale a cached copy of this list may be before it stops being usable, in seconds.\n\nPublished so the rule is discoverable rather than a constant every peer implementation\nhas to have read the same document to know." + "jti": { + "type": "string", + "description": "Its identifier." }, - "revoked": { - "items": { - "$ref": "#/components/schemas/RevokedTokenResponse" - }, - "type": "array", - "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." + "expires_at": { + "type": "string", + "description": "When this token stops being honoured, RFC 3339." + }, + "not_after": { + "type": "string", + "description": "When the whole grant dies, RFC 3339 — unchanged by this or any refresh." + }, + "replayed": { + "type": "boolean", + "description": "Whether this call issued the successor, or answered one an earlier call already issued.\n\nAdvisory. A peer never branches on it: both answers mean \"here is the token to keep\npulling with\"." } }, "type": "object", "required": [ - "generated_at", - "max_staleness_seconds", - "revoked" - ], - "description": "The `.well-known/capsule/revoked-jti` record." - }, - "WireBlobRole": { - "type": "string", - "enum": [ - "original", - "derivative", - "metadata", - "provenance", - "backup" + "token", + "jti", + "expires_at", + "not_after", + "replayed" ], - "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." + "description": "A refreshed capability." }, - "ManifestEnvelope": { + "FederatedReportRequest": { "properties": { - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite the blob was sealed under. Must equal the top-level declaration." - }, - "protocol_version": { - "type": "string", - "description": "The protocol date the manifest was written under (`YYYY-MM-DD`)." - }, - "album_id": { - "type": [ - "string", - "null" - ], - "description": "The album the asset belongs to. Must equal the top-level declaration." - }, - "file_id": { + "reporting_server": { "type": "string", - "description": "The asset this blob belongs to — the same id across the bundle's members." - }, - "amk_version": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "The album-key epoch the manifest was written under." + "description": "The peer filing the report, as its own `server-info` names it." }, - "ciphertext_hash": { + "reported_user": { "type": "string", - "description": "The ciphertext content hash, lowercase hex. Must equal the top-level `hash`.\n\n**This names the blob this session is uploading, not the manifest's own\n`ciphertext_hash`.** For the original the two coincide; for a metadata or provenance\nsession they do not, and the projection reuses the manifest's field name for a per-blob\ndeclaration. Invisible for a `create`, because the bundle is assembled in a pending row\nnobody can see and no member has to name another. It is not invisible for a `replace`,\nwhich is why [`Self::original_blob_hash`] exists (`S-C43`)." - }, - "plaintext_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The plaintext length the manifest commits to." - }, - "chunk_size": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "The STREAM plaintext chunk size." + "description": "The account on this server the report is about." }, - "key_mode": { + "asset_hash": { "type": "string", - "description": "`derived` or `wrapped`." + "description": "The content address of the asset complained about." }, - "metadata_blob_hash": { - "type": [ - "string", - "null" - ], - "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." + "album_id": { + "type": "string", + "description": "The album it was pulled from." }, - "original_blob_hash": { + "reason": { "type": [ "string", "null" ], - "description": "The content hash of the bundle's **original** blob, when the manifest commits to one\n(`S-C43`).\n\nThe manifest's own `ciphertext_hash`, under a name that cannot be confused with\n[`Self::ciphertext_hash`]'s per-session meaning. Optional on the wire and **required on a\n`replace`**: a replace re-points roles that already have bytes, so it has to be applied\nas one act, and the only member of the bundle that can carry the whole change is the\nmanifest — which therefore has to be able to name the original it commits to.\n\nA `create` may omit it. Its bundle is assembled incrementally in a row nobody can see,\nso no member needs to name another and requiring it would be a wire change for no gain." + "description": "A short reason, where the peer gives one." }, - "created_by_user": { + "reported_at": { "type": "string", - "description": "The account that created the asset." + "description": "When the peer says it was reported, RFC 3339." }, - "created_by_device": { + "signature": { "type": "string", - "description": "The device that created it, as a UUID — invariant 7's subject." + "description": "The peer's Ed25519 signature over the canonical CBOR of the fields above, base64." + } + }, + "type": "object", + "required": [ + "reporting_server", + "reported_user", + "asset_hash", + "album_id", + "reported_at", + "signature" + ], + "description": "A moderation report one peer server files against an account on this one.\n\nEvery field except `signature` is covered by the signature, in canonical CBOR — see\n[`ReportClaim`](crate::federation::ReportClaim)." + }, + "FederatedReportResponse": { + "properties": { + "report_id": { + "type": "string", + "description": "This server's identifier for the report." }, - "client_version": { + "received_at": { "type": "string", - "description": "The client build that wrote the manifest." + "description": "When this server accepted it, RFC 3339." + } + }, + "type": "object", + "required": [ + "report_id", + "received_at" + ], + "description": "An accepted report.\n\nThe identifier is this server's, so an operator and the reporting peer can talk about one\nreport. Nothing about the reported account is echoed — accepting a report says nothing about\nwhether it is true, and a body that reported on the account's standing would say it does." + }, + "SessionView": { + "properties": { + "session_id": { + "type": "string", + "description": "The session's identifier — what a revoke names." }, - "timestamp": { + "created_at": { "type": "string", - "description": "The manifest's self-asserted RFC3339 timestamp — invariants 7 and 8's subject." + "description": "When this session *record* was minted, RFC 3339.\n\nA refresh rotates the session, so after one this is the rotation time and not the\nsign-in. `authenticated_at` is the field that answers \"when did you last sign in\"." }, - "action": { + "authenticated_at": { "type": "string", - "description": "The lifecycle action. `create` or `replace` on this surface — the two that move blob\nbytes — and see [`GateReject::ActionNotAllowed`] for the rest." + "description": "When the user last proved a credential on this session's lineage, RFC 3339.\n\nCarried forward across refreshes, so it is the one timestamp here that means what a\nuser reading a devices list expects \"signed in\" to mean. It is also what the\ncross-device add's freshness gate reads (`S-C7`), so a client can show why an add is\nabout to ask for a password again." }, - "prior_provenance_hash": { + "last_active_at": { + "type": "string", + "description": "When it was last seen, RFC 3339.\n\nEqual to `created_at` until `S-C48` puts the session ledger on the request path. A\nclient must not label this \"last used\" before then." + }, + "user_agent": { "type": [ "string", "null" ], - "description": "The provenance chain position this write continues from." + "description": "The `User-Agent` the opening ceremony carried, if any." }, - "retention_until": { + "ip_address": { "type": [ "string", "null" ], - "description": "The retention floor the manifest carries, when it carries one." + "description": "The address the opening ceremony came from, if any." + }, + "cohort_hash": { + "type": [ + "string", + "null" + ], + "description": "The advisory cohort this session asserted, if any. Grouping only." + }, + "device_id": { + "type": [ + "string", + "null" + ], + "description": "The directory device the client claimed to be (`S-N3`), if any.\n\nA different identifier space from `cohort_hash`: this names one directory device, the\ncohort groups re-enrollments of one physical device. Both are client-asserted; neither\ngates anything." + }, + "current": { + "type": "boolean", + "description": "Whether this is the session making the request.\n\nSo a client can label \"this device\" without comparing tokens it should not be handling,\nand so revoking the current session is a deliberate act rather than an accident." } }, "type": "object", "required": [ - "crypto_suite_id", - "protocol_version", - "file_id", - "amk_version", - "ciphertext_hash", - "plaintext_size", - "chunk_size", - "key_mode", - "created_by_user", - "created_by_device", - "client_version", - "timestamp", - "action" + "session_id", + "created_at", + "authenticated_at", + "last_active_at", + "current" ], - "description": "The server-visible mirror of the signed manifest's envelope fields, as declared at\n`POST /v1/upload`.\n\nStrict (`deny_unknown_fields`) like the rest of the transport JSON. The Postel asymmetry\nthe design draws — tolerant inside documents that outlive us, strict on the wire we own —\nputs unknown-key tolerance in the *signed CBOR interiors*, never in this JSON projection." + "description": "One live session." }, - "CreateUploadRequest": { + "CohortView": { "properties": { - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The ciphertext length in bytes. Immutable for the session's life." + "cohort_hash": { + "type": "string", + "description": "The advisory hash." }, - "hash": { + "first_seen": { "type": "string", - "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." + "description": "The first time this account was seen under it, RFC 3339.\n\nWhat lets a client say *\"a device you've used before\"* about a session whose own\n`device_id` is new — which is the entire reason the map is durable." }, - "content_type": { + "last_seen": { "type": "string", - "description": "The media type, from the closed enum this protocol version fixes." + "description": "The most recent time, RFC 3339." + } + }, + "type": "object", + "required": [ + "cohort_hash", + "first_seen", + "last_seen" + ], + "description": "One cohort this account has been seen under." + }, + "DevicesResponse": { + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionView" + }, + "type": "array", + "description": "Every live session, oldest first." }, - "crypto_suite_id": { + "cohorts": { + "items": { + "$ref": "#/components/schemas/CohortView" + }, + "type": "array", + "description": "Every cohort this account has ever been seen under, oldest first sighting first.\n\nServed **beside** the sessions rather than folded into them, because a cohort outlives\nthe sessions that carried it: a reinstall's new session groups with a cohort whose other\nsessions expired months ago, and a client that only had per-session cohorts could not\nsay \"you have used this device before\"." + } + }, + "type": "object", + "required": [ + "sessions", + "cohorts" + ], + "description": "The session ledger." + }, + "DrainResponse": { + "properties": { + "payloads": { + "items": { + "type": "string" + }, + "type": "array", + "description": "The payloads in arrival order, removed by this call. Possibly empty." + } + }, + "type": "object", + "required": [ + "payloads" + ], + "description": "Everything pending in one mailbox." + }, + "QuotaResponse": { + "properties": { + "used": { "type": "integer", - "maximum": 65535.0, "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite the blob was sealed under." - }, - "protocol_version": { - "type": "string", - "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." - }, - "blob_role": { - "$ref": "#/components/schemas/WireBlobRole", - "description": "The blob's role in its bundle." + "format": "uint64", + "description": "Bytes charged to the caller." }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The unencrypted manifest fields the server validates." + "soft_limit": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "Where the warning starts, or absent on an unlimited deployment." }, - "album_id": { + "hard_limit": { "type": [ - "string", + "integer", "null" ], - "description": "The album the asset is filed into.\n\nOptional on the wire because the contract reserves the shape for owner-scoped kinds and\nfor the album-upgrade ceremony; **required by this server**, which has no way to check\ninvariant 6 without one and refuses rather than skipping it." + "minimum": 0.0, + "format": "uint64", + "description": "Where uploads stop, or absent on an unlimited deployment." }, - "owner_id": { + "state": { + "type": "string", + "description": "The classified state: `ok`, `soft_warning`, `hard_exceeded`, `grace_expired`." + } + }, + "type": "object", + "required": [ + "used", + "state" + ], + "description": "A user's quota snapshot." + }, + "ModerationEventResponse": { + "properties": { + "action": { + "type": "string", + "description": "What was done: `suspended`, `reinstated`, `taken_down`, `legal_hold`, `hold_lifted`." + }, + "asset_id": { "type": [ "string", "null" ], - "description": "The owner the asset is filed under, when it is not the uploader.\n\nRefused when it is anyone but the uploader: an on-behalf upload needs a verified\nrelationship, and the port that would answer for one does not exist here." + "description": "The asset, when the action was about one rather than about the account." }, - "intent_id": { + "at": { + "type": "string", + "description": "When it happened, RFC 3339." + }, + "reason": { "type": [ "string", "null" ], - "description": "The album-upgrade intent this write belongs to, when it belongs to one.\n\nCarried onto the session verbatim and read by nobody in this port; the ceremony that\ngives it meaning is `S-C24`." + "description": "Why, where policy permits.\n\nAbsent is a real answer — a legal hold may come with an obligation not to disclose it —\nand reads as \"we are not able to say\", which is honest where a fabricated reason would\nnot be." } }, "type": "object", "required": [ - "size", - "hash", - "content_type", - "crypto_suite_id", - "protocol_version", - "blob_role", - "manifest_envelope" + "action", + "at" ], - "description": "The body of `POST /v1/upload`.\n\nStrict (`deny_unknown_fields`): an unknown field is a client bug and is refused rather than\nignored. Plaintext metadata — a filename, a capture date, dimensions — is deliberately\nabsent: it rides the encrypted metadata blob and never the wire request." + "description": "One thing that was done to the account." }, - "CreateUploadResponse": { + "ModerationRecordResponse": { "properties": { - "id": { + "standing": { "type": "string", - "description": "The session's identifier." + "description": "`active` or `suspended`." }, - "upload_url": { - "type": "string", - "description": "Where to send chunks." + "suspended_since": { + "type": [ + "string", + "null" + ], + "description": "When a suspension began, RFC 3339. Absent while the account is active." }, - "suggested_chunk_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "A starting chunk size. A suggestion only — the client owns adaptation." + "events": { + "items": { + "$ref": "#/components/schemas/ModerationEventResponse" + }, + "type": "array", + "description": "Everything done to this account, oldest first.\n\nA reinstatement does not erase the suspension it lifted: the record is what a user reads\nto understand their own account, and one that deleted its own history would leave them\nunable to see that anything ever happened." } }, "type": "object", "required": [ - "id", - "upload_url", - "suggested_chunk_size" + "standing", + "events" ], - "description": "What a client needs to start sending bytes." + "description": "The caller's moderation record." }, "SessionSummary": { "properties": { @@ -7080,61 +24073,6 @@ ], "description": "The listing." }, - "OpRequest": { - "properties": { - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The server-visible projection of the signed manifest's fields, exactly as\n`POST /v1/upload` carries it. Its `album_id` must equal the path segment and its\n`action` must be one this surface accepts." - }, - "manifest_cbor": { - "type": "string", - "description": "The signed manifest itself, base64 of the canonical CBOR.\n\nStored verbatim as the asset's new provenance blob, so the feed serves the exact bytes\nthe client signed (`S-C30`) for a lifecycle write as it already does for an upload. The\nserver does not parse it: base64 is a transport encoding, and `decode(encode(b)) == b`." - }, - "metadata_blob": { - "type": [ - "string", - "null" - ], - "description": "The encrypted metadata blob, base64, present exactly when the action carries one.\n\nIts content hash must equal the manifest's committed `metadata_blob_hash`\n(invariant 25). The server holds no key and never reads it." - } - }, - "type": "object", - "required": [ - "manifest_envelope", - "manifest_cbor" - ], - "description": "The signed manifest bundle a lifecycle write carries." - }, - "OpResponse": { - "properties": { - "asset_id": { - "type": "string", - "description": "The asset the op chained onto." - }, - "sync_seq": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The feed position it occupies. On a replay, the position the *first* application took." - }, - "action": { - "type": "string", - "description": "The action that was applied." - }, - "replayed": { - "type": "boolean", - "description": "Whether this response is a replay of an already-applied manifest.\n\nAdvisory, and deliberately not something a correct client needs: the other three fields\nare identical either way, which is what \"byte-identical prior response\" means." - } - }, - "type": "object", - "required": [ - "asset_id", - "sync_seq", - "action", - "replayed" - ], - "description": "What a lifecycle write did." - }, "WireChangeKind": { "type": "string", "enum": [ @@ -7262,381 +24200,478 @@ ], "description": "A page of the feed." }, - "AssetVerifyRequest": { + "AssetReceipt": { + "properties": { + "receipt_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "Strictly monotonic per server. The chain position this receipt cannot be moved from." + }, + "server_id": { + "type": "string", + "description": "This server's canonical origin — what binds the receipt to one server." + }, + "server_key_id": { + "type": "string", + "description": "The attestation key fingerprint that signed, hex. Survives rotation, which is why the\nkey is named rather than assumed." + }, + "prior_receipt_hash": { + "type": [ + "string", + "null" + ], + "description": "SHA-256 of the previous receipt in the server's log, hex. Absent for the first receipt\nthis server ever issued." + }, + "upload_id": { + "type": "string", + "description": "The upload session that produced custody." + }, + "blob_role": { + "type": "string", + "description": "`original`, `derivative`, `metadata` or `provenance`." + }, + "ciphertext_hash": { + "type": "string", + "description": "The server-recomputed ciphertext content address, hex." + }, + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "Ciphertext size in bytes." + }, + "envelope_hash": { + "type": [ + "string", + "null" + ], + "description": "SHA-256 of the asset's signed manifest, hex — present on the `provenance` receipt and\nabsent on every other, because the manifest commits to the rest." + }, + "received_at": { + "type": "string", + "description": "The server's trusted clock at the finalization commit, RFC 3339." + }, + "receipt_cbor": { + "type": "string", + "description": "The full signed receipt as canonical CBOR, base64.\n\n**This is the receipt.** Verify the hybrid signature over these bytes under the key\n`server_key_id` names, from `/.well-known/capsule/attestation-keys`; everything above is\na reading of them." + } + }, + "type": "object", + "required": [ + "receipt_seq", + "server_id", + "server_key_id", + "upload_id", + "blob_role", + "ciphertext_hash", + "size", + "received_at", + "receipt_cbor" + ], + "description": "One custody receipt, decoded, beside the bytes that were signed." + }, + "AssetReceiptsResponse": { "properties": { "asset_id": { "type": "string", - "description": "The asset." + "description": "The asset the chain belongs to, echoed so a client batching requests can tell the\nanswers apart." }, - "blob_hashes": { + "receipts": { "items": { - "type": "string" + "$ref": "#/components/schemas/AssetReceipt" }, "type": "array", - "description": "Every content address the client would be trusting the server with. The verdict is a\nconjunction over exactly these, so a client asks about what it is about to delete." + "description": "Every receipt covering the asset, in `receipt_seq` order." } }, "type": "object", "required": [ "asset_id", - "blob_hashes" + "receipts" ], - "description": "One asset to verify, with the exact copies the client is relying on." + "description": "The chain." }, - "StorageVerifyRequest": { + "InboxEntryResponse": { "properties": { - "assets": { + "drop_id": { + "type": "string", + "description": "The drop's identifier, which adoption and discard name." + }, + "opaque_id": { + "type": "string", + "description": "The link it arrived through." + }, + "ciphertext_hash": { + "type": "string", + "description": "The ciphertext's content address." + }, + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many bytes." + }, + "content_type": { + "type": "string", + "description": "The guest's declared content type." + }, + "kem_ct": { + "type": "string", + "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + }, + "suggested_filename": { + "type": [ + "string", + "null" + ], + "description": "Guest-supplied and **unverified**.\n\nA guest chose this text. A client rendering it treats it as untrusted input — it is the\none field on this surface an anonymous party authored." + }, + "received_at": { + "type": "string", + "description": "When it landed, RFC 3339." + }, + "adopting": { + "type": "boolean", + "description": "Whether an adoption currently holds this row.\n\nSurfaced rather than hidden: a crash between claim and settle leaves a row here, and an\nowner who cannot see it cannot act on it." + } + }, + "type": "object", + "required": [ + "drop_id", + "opaque_id", + "ciphertext_hash", + "size", + "content_type", + "kem_ct", + "received_at", + "adopting" + ], + "description": "One drop waiting for the owner." + }, + "InboxResponse": { + "properties": { + "drops": { "items": { - "$ref": "#/components/schemas/AssetVerifyRequest" + "$ref": "#/components/schemas/InboxEntryResponse" }, "type": "array", - "description": "The assets to verify." - }, - "deep": { - "type": "boolean", - "description": "Also re-read and re-hash the bytes (`S-C41`).\n\nAbsent or `false` is the structural check: ask the index and the store whether the bytes\nare there. `true` additionally re-hashes them, which is the only way to catch silent\ncorruption — `stored` is a question about the filesystem, and a corrupt blob is still\nstored.\n\n**Rate-limited per account**, because a deep scan reads and hashes every declared blob\nand an unbounded one is an I/O-amplification attack costing the caller one small JSON\nbody. Past the budget the *structural* verdict still comes back and each blob's `deep`\nreads `rate_limited`: throwing away a good structural answer because the optional half\nwas throttled would make the limiter cost more than it saves." + "description": "Everything waiting, oldest first." } }, "type": "object", "required": [ - "assets" + "drops" ], - "description": "The `POST /v1/storage/verify` body." + "description": "The owner's pending drops." }, - "BlobVerdictResponse": { + "VersionResponse": { "properties": { - "hash": { + "name": { "type": "string", - "description": "The address, as the client declared it." + "description": "The server package name." }, - "role": { + "version": { "type": "string", - "description": "The role the asset holds it under — `unknown` for a hash the asset does not hold." + "description": "The server package version." + } + }, + "type": "object", + "required": [ + "name", + "version" + ], + "description": "Identifies the running server.\n\nDeliberately incurious: a name and a version, no build host, no commit, no uptime, no\nfeature list. This endpoint is unauthenticated, so everything it returns is public, and a\nkey-free server has no reason to hand an anonymous caller a fingerprint of its deployment.\nExact client build identification runs the other way (`S-D15`) — clients tell the server\nwhat they are, not the reverse." + }, + "PublishedKeyResponse": { + "properties": { + "key_id": { + "type": "string", + "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." }, - "stored": { - "type": "boolean", - "description": "The bytes are present at that address." + "public": { + "type": "string", + "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." }, - "indexed": { - "type": "boolean", - "description": "A live asset of the caller's references the address." + "algorithm": { + "type": "string", + "description": "The signature algorithm this key is used with." }, - "retrievable": { - "type": "boolean", - "description": "Nothing is withholding it." + "active_from": { + "type": "string", + "description": "When it began signing, RFC 3339." }, - "deep": { + "active_to": { "type": [ "string", "null" ], - "description": "What a deep scan found: `intact`, `corrupt`, or `rate_limited` (`S-C41`).\n\n**Absent when no deep scan ran**, and the absence is load-bearing: it is the difference\nbetween \"we did not look at the bytes\" and \"we looked and they were fine\", and a client\ndeciding whether to release its only copy has to be able to tell those apart." + "description": "When it stopped, or absent while it is the active key." } }, "type": "object", "required": [ - "hash", - "role", - "stored", - "indexed", - "retrievable" + "key_id", + "public", + "algorithm", + "active_from" ], - "description": "One declared blob's verdict." + "description": "One published attestation key." }, - "StorageVerdictResponse": { + "AttestationKeysResponse": { "properties": { - "asset_id": { + "server_id": { "type": "string", - "description": "The asset the client asked about." - }, - "durable": { - "type": "boolean", - "description": "Every declared blob is stored ∧ indexed ∧ retrievable. **This is the field that gates a\ndeletion**, so it is false whenever the server cannot say otherwise." + "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." }, - "blobs": { + "keys": { "items": { - "$ref": "#/components/schemas/BlobVerdictResponse" + "$ref": "#/components/schemas/PublishedKeyResponse" }, "type": "array", - "description": "One entry per declared hash, in declaration order and never shortened." - }, - "checked_at": { - "type": "string", - "description": "The server's own clock at verification, RFC 3339. Never the client's." + "description": "Every key this server has signed with, oldest first, the active one last." } }, "type": "object", "required": [ - "asset_id", - "durable", - "blobs", - "checked_at" + "server_id", + "keys" ], - "description": "One asset's verdict." + "description": "The `.well-known/capsule/attestation-keys` record." }, - "StorageVerifyResponse": { + "OidcEndpointsResponse": { "properties": { - "verdicts": { - "items": { - "$ref": "#/components/schemas/StorageVerdictResponse" - }, - "type": "array", - "description": "One verdict per requested asset, in request order." + "authorize": { + "type": "string", + "description": "Where a client asks for an authorization URL." + }, + "callback": { + "type": "string", + "description": "Where a client presents the `state` and `code` the provider's redirect carried." } }, "type": "object", "required": [ - "verdicts" + "authorize", + "callback" ], - "description": "The `POST /v1/storage/verify` response." + "description": "The OIDC ceremony's endpoints (slice `S-N1`)." }, - "AssetReceipt": { + "AuthEndpointsResponse": { "properties": { - "receipt_seq": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Strictly monotonic per server. The chain position this receipt cannot be moved from." - }, - "server_id": { - "type": "string", - "description": "This server's canonical origin — what binds the receipt to one server." - }, - "server_key_id": { - "type": "string", - "description": "The attestation key fingerprint that signed, hex. Survives rotation, which is why the\nkey is named rather than assumed." - }, - "prior_receipt_hash": { - "type": [ - "string", - "null" - ], - "description": "SHA-256 of the previous receipt in the server's log, hex. Absent for the first receipt\nthis server ever issued." - }, - "upload_id": { + "login": { "type": "string", - "description": "The upload session that produced custody." + "description": "Where a session is opened." }, - "blob_role": { + "refresh": { "type": "string", - "description": "`original`, `derivative`, `metadata` or `provenance`." + "description": "Where an access token is rotated." }, - "ciphertext_hash": { + "logout": { "type": "string", - "description": "The server-recomputed ciphertext content address, hex." - }, - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Ciphertext size in bytes." + "description": "Where a session is ended." }, - "envelope_hash": { - "type": [ - "string", - "null" + "oidc": { + "anyOf": [ + { + "$ref": "#/components/schemas/OidcEndpointsResponse" + }, + { + "type": "null" + } ], - "description": "SHA-256 of the asset's signed manifest, hex — present on the `provenance` receipt and\nabsent on every other, because the manifest commits to the rest." - }, - "received_at": { - "type": "string", - "description": "The server's trusted clock at the finalization commit, RFC 3339." - }, - "receipt_cbor": { - "type": "string", - "description": "The full signed receipt as canonical CBOR, base64.\n\n**This is the receipt.** Verify the hybrid signature over these bytes under the key\n`server_key_id` names, from `/.well-known/capsule/attestation-keys`; everything above is\na reading of them." + "description": "Where a sign-in through an external identity provider begins and ends, or `null` when\nthis deployment has none. Always present, so a client reads one field rather than\nprobing for one." } }, "type": "object", "required": [ - "receipt_seq", - "server_id", - "server_key_id", - "upload_id", - "blob_role", - "ciphertext_hash", - "size", - "received_at", - "receipt_cbor" + "login", + "refresh", + "logout" ], - "description": "One custody receipt, decoded, beside the bytes that were signed." + "description": "The auth ceremony's endpoints." }, - "AssetReceiptsResponse": { + "ProtocolWindowResponse": { "properties": { - "asset_id": { + "min": { "type": "string", - "description": "The asset the chain belongs to, echoed so a client batching requests can tell the\nanswers apart." + "description": "The oldest version still accepted for writes." }, - "receipts": { - "items": { - "$ref": "#/components/schemas/AssetReceipt" - }, - "type": "array", - "description": "Every receipt covering the asset, in `receipt_seq` order." + "max": { + "type": "string", + "description": "The newest version this server speaks." } }, "type": "object", "required": [ - "asset_id", - "receipts" + "min", + "max" ], - "description": "The chain." + "description": "The accepted `protocol_version` range." }, - "IssueShareRequest": { + "DeprecationResponse": { "properties": { - "opaque_id": { + "min_protocol_version": { "type": "string", - "description": "The 128-bit opaque id, 32 lowercase hex characters, drawn from the client's CSPRNG.\n\nMinted by the client rather than the server because the client is what knows the\nfragment secret the id is paired with; the server checks its shape and stores it." + "description": "The lowest `protocol_version` that remains accepted after the cutoff." }, - "metadata_hash": { + "announced_at": { "type": "string", - "description": "The metadata blob a viewer starts from. Must appear in `serves`." + "description": "When the announcement was first published, RFC 3339." }, - "serves": { - "items": { - "type": "string" - }, - "type": "array", - "description": "Every blob this link may serve, and nothing else.\n\nEnumerated by the issuing client, which is what makes the boundary-crossing strip\nstick: the client points the link at blobs it prepared for export, and the server has no\npath from an opaque id to anything outside this set." + "cutoff": { + "type": "string", + "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." }, - "wrapped_secret": { + "detail_url": { "type": [ "string", "null" ], - "description": "The passphrase-wrapped scope material, base64, when the link is passphrase-protected.\n\nOpaque to this server. The passphrase never crosses the wire — unwrap is client-side." + "description": "Where a human reads what to do about it." + } + }, + "type": "object", + "required": [ + "min_protocol_version", + "announced_at", + "cutoff" + ], + "description": "One announced deprecation cutoff." + }, + "ServerInfoResponse": { + "properties": { + "server_id": { + "type": "string", + "description": "This server's canonical origin." }, - "expires_at": { + "api_base_url": { + "type": "string", + "description": "Where the versioned API lives." + }, + "auth": { + "$ref": "#/components/schemas/AuthEndpointsResponse", + "description": "Where a client performs the auth ceremony." + }, + "federation_url": { "type": [ "string", "null" ], - "description": "When the link stops being live, RFC 3339. Absent means no expiry." + "description": "Where federated peers talk to this server. Absent when it does not federate." + }, + "protocol_version": { + "$ref": "#/components/schemas/ProtocolWindowResponse", + "description": "The `protocol_version` range accepted for writes today, both ends inclusive." + }, + "signing_key": { + "type": "string", + "description": "The raw Ed25519 public key this server's tokens verify under, base64." + }, + "signing_algorithm": { + "type": "string", + "description": "The signature algorithm that key is used with." + }, + "deprecations": { + "items": { + "$ref": "#/components/schemas/DeprecationResponse" + }, + "type": "array", + "description": "Announced deprecation cutoffs, in announcement order. Empty when none is pending." } }, "type": "object", "required": [ - "opaque_id", - "metadata_hash", - "serves" + "server_id", + "api_base_url", + "auth", + "protocol_version", + "signing_key", + "signing_algorithm", + "deprecations" ], - "description": "A link the owner's client has issued." + "description": "The `.well-known/capsule/server-info` record.\n\nServer-scoped facts only. The registry's rule — *never a user list* — is structural here:\nthis type holds no user-shaped field, so there is nothing for a future edit to leak through." }, - "IssueShareResponse": { + "DeprecationsResponse": { "properties": { - "opaque_id": { - "type": "string", - "description": "The opaque id, echoed." + "announcements": { + "items": { + "$ref": "#/components/schemas/DeprecationResponse" + }, + "type": "array", + "description": "Every announced cutoff, in announcement order." } }, "type": "object", "required": [ - "opaque_id" + "announcements" ], - "description": "Confirmation that a link is now servable." + "description": "The `.well-known/capsule/deprecation` record." }, - "SharedMetadataResponse": { + "RevokedTokenResponse": { "properties": { - "metadata_hash": { + "jti": { "type": "string", - "description": "The metadata blob's content address; fetch it from `/s/{opaque_id}/blob/{hash}`." + "description": "The token's `jti` claim." }, - "passphrase_protected": { - "type": "boolean", - "description": "Whether a passphrase is required before the scope material can be opened.\n\nThe one property of the link this path discloses, and it has to: a viewer cannot know to\nask for a passphrase otherwise. It says nothing about *what* the link points at." + "expires_at": { + "type": "string", + "description": "The token's own `exp`, RFC 3339. After this the entry is pruned." } }, "type": "object", "required": [ - "metadata_hash", - "passphrase_protected" + "jti", + "expires_at" ], - "description": "What a viewer needs to start." + "description": "One revoked capability token." }, - "ProvisionLinkRequest": { + "RevokedJtiResponse": { "properties": { - "opaque_id": { - "type": "string", - "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." - }, - "drop_pubkey": { + "generated_at": { "type": "string", - "description": "The Drop Key's public half, base64. Opaque here — the server never decapsulates." + "description": "When this snapshot was taken, RFC 3339.\n\nPart of the record rather than left to an HTTP `Date`, because the staleness rule a peer\napplies is a property of the list's content — a verifier reasoning from a transport\nheader would be trusting a cache to be honest about its own age." }, - "crypto_suite_id": { + "max_staleness_seconds": { "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The suite a drop must be sealed under." - }, - "expires_at": { - "type": [ - "string", - "null" - ], - "description": "When the link stops accepting drops, RFC 3339." - }, - "max_total_bytes": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "Cumulative bytes across every drop on this link." - }, - "max_file_count": { - "type": [ - "integer", - "null" - ], "maximum": 4294967295.0, "minimum": 0.0, "format": "uint32", - "description": "How many files the link may deposit." - }, - "max_file_size": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "The largest single file." - }, - "single_use": { - "type": "boolean", - "description": "Whether the link dies after its first successful drop." + "description": "How stale a cached copy of this list may be before it stops being usable, in seconds.\n\nPublished so the rule is discoverable rather than a constant every peer implementation\nhas to have read the same document to know." }, - "passphrase_verifier": { - "type": [ - "string", - "null" - ], - "description": "An Argon2id **verifier**, base64, when the link is passphrase-gated.\n\nA verifier and never a passphrase: this is an abuse gate the server checks, which is why\nit is stored here at all — unlike a share link's passphrase, which protects decryption\nand which the server never sees in any form." + "revoked": { + "items": { + "$ref": "#/components/schemas/RevokedTokenResponse" + }, + "type": "array", + "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." } }, "type": "object", "required": [ - "opaque_id", - "drop_pubkey", - "crypto_suite_id" + "generated_at", + "max_staleness_seconds", + "revoked" ], - "description": "A link the owner is provisioning." + "description": "The `.well-known/capsule/revoked-jti` record." }, - "ProvisionLinkResponse": { + "SharedMetadataResponse": { "properties": { - "opaque_id": { + "metadata_hash": { "type": "string", - "description": "The opaque id, echoed." + "description": "The metadata blob's content address; fetch it from `/s/{opaque_id}/blob/{hash}`." + }, + "passphrase_protected": { + "type": "boolean", + "description": "Whether a passphrase is required before the scope material can be opened.\n\nThe one property of the link this path discloses, and it has to: a viewer cannot know to\nask for a passphrase otherwise. It says nothing about *what* the link points at." } }, "type": "object", "required": [ - "opaque_id" + "metadata_hash", + "passphrase_protected" ], - "description": "Confirmation that a link is live." + "description": "What a viewer needs to start." }, "CreateDropRequest": { "properties": { @@ -7688,165 +24723,305 @@ "type": "string", "description": "The session id, and the last path segment of the chunk endpoint." }, - "suggested_chunk_size": { + "suggested_chunk_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The chunk size to start with." + } + }, + "type": "object", + "required": [ + "upload_id", + "suggested_chunk_size" + ], + "description": "The session a guest uploads into." + }, + "CodedProblem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { + "type": "string", + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "CodedProblem", + "description": "An RFC 9457 problem detail." + }, + "ProtocolRangeProblem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { + "type": "string", + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "protocol_min": { + "type": "string", + "description": "The oldest protocol date this server still speaks (`YYYY-MM-DD`)." + }, + "protocol_max": { + "type": "string", + "description": "The newest protocol date this server speaks (`YYYY-MM-DD`)." + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "ProtocolRangeProblem", + "description": "An RFC 9457 problem detail." + }, + "DuplicateBlobProblem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { + "type": "string", + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "existing_asset": { + "type": "string", + "description": "The asset already holding these exact bytes in the same album. Structured so a client merges rather than re-parsing a sentence (slice `S-C22`)." + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "DuplicateBlobProblem", + "description": "An RFC 9457 problem detail." + }, + "OffsetMismatchProblem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { + "type": "string", + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "offset": { "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The chunk size to start with." + "description": "The offset the server is actually at, so a client resumes from it instead of asking again." } }, + "additionalProperties": true, "type": "object", "required": [ - "upload_id", - "suggested_chunk_size" + "type", + "status", + "code" ], - "description": "The session a guest uploads into." + "title": "OffsetMismatchProblem", + "description": "An RFC 9457 problem detail." }, - "InboxEntryResponse": { + "DirectoryConflictProblem": { "properties": { - "drop_id": { - "type": "string", - "description": "The drop's identifier, which adoption and discard name." - }, - "opaque_id": { - "type": "string", - "description": "The link it arrived through." + "type": { + "type": "string" }, - "ciphertext_hash": { - "type": "string", - "description": "The ciphertext's content address." + "title": { + "type": "string" }, - "size": { + "status": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "How many bytes." - }, - "content_type": { - "type": "string", - "description": "The guest's declared content type." + "format": "uint16" }, - "kem_ct": { - "type": "string", - "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + "detail": { + "type": "string" }, - "suggested_filename": { - "type": [ - "string", - "null" - ], - "description": "Guest-supplied and **unverified**.\n\nA guest chose this text. A client rendering it treats it as untrusted input — it is the\none field on this surface an anonymous party authored." + "instance": { + "type": "string" }, - "received_at": { + "code": { "type": "string", - "description": "When it landed, RFC 3339." + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "adopting": { - "type": "boolean", - "description": "Whether an adoption currently holds this row.\n\nSurfaced rather than hidden: a crash between claim and settle leaves a row here, and an\nowner who cannot see it cannot act on it." - } - }, - "type": "object", - "required": [ - "drop_id", - "opaque_id", - "ciphertext_hash", - "size", - "content_type", - "kem_ct", - "received_at", - "adopting" - ], - "description": "One drop waiting for the owner." - }, - "InboxResponse": { - "properties": { - "drops": { - "items": { - "$ref": "#/components/schemas/InboxEntryResponse" - }, - "type": "array", - "description": "Everything waiting, oldest first." + "submitted": { + "type": "integer", + "description": "The directory version the request carried." + }, + "stored": { + "type": "integer", + "description": "The version the server holds. A client re-signs above this one." } }, + "additionalProperties": true, "type": "object", "required": [ - "drops" + "type", + "status", + "code" ], - "description": "The owner's pending drops." + "title": "DirectoryConflictProblem", + "description": "An RFC 9457 problem detail." }, - "AdoptRequest": { + "RosterVersionLeapProblem": { "properties": { - "album_id": { - "type": "string", - "description": "The album to adopt into." + "type": { + "type": "string" }, - "asset_id": { - "type": "string", - "description": "The asset the drop becomes." + "title": { + "type": "string" }, - "size": { + "status": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "The blob's declared size — the inbox row's, restated and checked against it." - }, - "hash": { - "type": "string", - "description": "The ciphertext hash, which must name **this drop's** blob." + "format": "uint16" }, - "content_type": { - "type": "string", - "description": "The declared content type." + "detail": { + "type": "string" }, - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite." + "instance": { + "type": "string" }, - "protocol_version": { + "code": { "type": "string", - "description": "The protocol version the manifest is written against." + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "key_mode": { - "type": "string", - "description": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." + "current_version": { + "type": "integer", + "format": "uint64", + "description": "The roster version the server holds; `0` when it holds none." }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The signed manifest envelope, verbatim." + "max_version": { + "type": "integer", + "format": "uint64", + "description": "The highest version this album would have accepted. A client re-signs the same roster at `current_version + 1`; a version nothing could supersede would freeze the album's membership." } }, + "additionalProperties": true, "type": "object", "required": [ - "album_id", - "asset_id", - "size", - "hash", - "content_type", - "crypto_suite_id", - "protocol_version", - "key_mode", - "manifest_envelope" + "type", + "status", + "code" ], - "description": "The owner's signed `create` over a drop already in their inbox.\n\nThe same shape a `POST /v1/upload` create carries, minus everything about transferring bytes:\nthe blob is already committed, so there is no size to negotiate and no session to open. What\nremains is the manifest, which is the whole point — a drop becomes an asset only when the\n**owner** signs for it." + "title": "RosterVersionLeapProblem", + "description": "An RFC 9457 problem detail." }, - "AdoptResponse": { + "RosterStaleProblem": { "properties": { - "asset_id": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "code": { "type": "string", - "description": "The asset the drop became." + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "current_version": { + "type": "integer", + "format": "uint64", + "description": "The roster version the server holds. A client re-syncs and republishes above it." } }, + "additionalProperties": true, "type": "object", "required": [ - "asset_id" + "type", + "status", + "code" ], - "description": "What adoption produced." + "title": "RosterStaleProblem", + "description": "An RFC 9457 problem detail." }, - "CodedProblem": { + "StaleRevivalProblem": { "properties": { "type": { "type": "string" @@ -7869,6 +25044,13 @@ "code": { "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "chain_head": { + "type": [ + "string", + "null" + ], + "description": "The manifest hash the asset's chain is actually at. Absent when the conflict is not a chain conflict, which is why it is nullable." } }, "additionalProperties": true, @@ -7878,10 +25060,10 @@ "status", "code" ], - "title": "CodedProblem", + "title": "StaleRevivalProblem", "description": "An RFC 9457 problem detail." }, - "ProtocolRangeProblem": { + "FileTooLargeProblem": { "properties": { "type": { "type": "string" @@ -7905,13 +25087,9 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "protocol_min": { - "type": "string", - "description": "The oldest protocol date this server still speaks (`YYYY-MM-DD`)." - }, - "protocol_max": { - "type": "string", - "description": "The newest protocol date this server speaks (`YYYY-MM-DD`)." + "limit": { + "type": "integer", + "description": "The largest file this drop link accepts, in bytes." } }, "additionalProperties": true, @@ -7921,10 +25099,10 @@ "status", "code" ], - "title": "ProtocolRangeProblem", + "title": "FileTooLargeProblem", "description": "An RFC 9457 problem detail." }, - "DuplicateBlobProblem": { + "DropRateLimitedProblem": { "properties": { "type": { "type": "string" @@ -7948,9 +25126,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "existing_asset": { - "type": "string", - "description": "The asset already holding these exact bytes in the same album. Structured so a client merges rather than re-parsing a sentence (slice `S-C22`)." + "retry_after": { + "type": "integer", + "format": "uint64", + "description": "When the caller may retry, as Unix seconds. From the limiter's own window when a budget is spent, and an upper bound of one window when the limiter is at capacity (slice `S-C32`)." } }, "additionalProperties": true, @@ -7960,10 +25139,10 @@ "status", "code" ], - "title": "DuplicateBlobProblem", + "title": "DropRateLimitedProblem", "description": "An RFC 9457 problem detail." }, - "OffsetMismatchProblem": { + "EnrollmentRateLimitedProblem": { "properties": { "type": { "type": "string" @@ -7987,9 +25166,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "offset": { + "retry_after": { "type": "integer", - "description": "The offset the server is actually at, so a client resumes from it instead of asking again." + "format": "uint64", + "description": "When the caller may retry, as Unix seconds. From the limiter's own window when a budget is spent, and an upper bound of one window when the limiter is at capacity (slice `S-C32`)." } }, "additionalProperties": true, @@ -7999,10 +25179,10 @@ "status", "code" ], - "title": "OffsetMismatchProblem", + "title": "EnrollmentRateLimitedProblem", "description": "An RFC 9457 problem detail." }, - "DirectoryConflictProblem": { + "ShareMetadataRateLimitedProblem": { "properties": { "type": { "type": "string" @@ -8026,13 +25206,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "submitted": { - "type": "integer", - "description": "The directory version the request carried." - }, - "stored": { + "retry_after": { "type": "integer", - "description": "The version the server holds. A client re-signs above this one." + "format": "uint64", + "description": "When the caller may retry, as Unix seconds. From the limiter's own window when a budget is spent, and an upper bound of one window when the limiter is at capacity (slice `S-C32`)." } }, "additionalProperties": true, @@ -8042,10 +25219,10 @@ "status", "code" ], - "title": "DirectoryConflictProblem", + "title": "ShareMetadataRateLimitedProblem", "description": "An RFC 9457 problem detail." }, - "StaleRevivalProblem": { + "ShareSecretRateLimitedProblem": { "properties": { "type": { "type": "string" @@ -8069,12 +25246,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "chain_head": { - "type": [ - "string", - "null" - ], - "description": "The manifest hash the asset's chain is actually at. Absent when the conflict is not a chain conflict, which is why it is nullable." + "retry_after": { + "type": "integer", + "format": "uint64", + "description": "When the caller may retry, as Unix seconds. From the limiter's own window when a budget is spent, and an upper bound of one window when the limiter is at capacity (slice `S-C32`)." } }, "additionalProperties": true, @@ -8084,10 +25259,10 @@ "status", "code" ], - "title": "StaleRevivalProblem", + "title": "ShareSecretRateLimitedProblem", "description": "An RFC 9457 problem detail." }, - "FileTooLargeProblem": { + "ShareBlobRateLimitedProblem": { "properties": { "type": { "type": "string" @@ -8111,9 +25286,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "limit": { + "retry_after": { "type": "integer", - "description": "The largest file this drop link accepts, in bytes." + "format": "uint64", + "description": "When the caller may retry, as Unix seconds. From the limiter's own window when a budget is spent, and an upper bound of one window when the limiter is at capacity (slice `S-C32`)." } }, "additionalProperties": true, @@ -8123,7 +25299,7 @@ "status", "code" ], - "title": "FileTooLargeProblem", + "title": "ShareBlobRateLimitedProblem", "description": "An RFC 9457 problem detail." } }, diff --git a/capsule-server/src/album/authority.rs b/capsule-server/src/album/authority.rs index c8721d51..b31df3ff 100644 --- a/capsule-server/src/album/authority.rs +++ b/capsule-server/src/album/authority.rs @@ -8,10 +8,12 @@ //! # Invariant 6, from the album store (`S-C25`) //! //! An album is writable by the account it was provisioned to, pinned to the protocol the server -//! spoke when it was created. Sharing widens that set — an album writable by a *member* rather -//! than only its owner — and that is `S-C4`/`S-C5`'s to add. Until then an unprovisioned or -//! somebody-else's album is [`AlbumWriteAccess::Denied`], which is the safe direction: a write -//! that should have been allowed is refused, never the reverse. +//! spoke when it was created — and, since `S-C51`, by a **writer** on its current roster +//! ([`crate::membership`]). Either way the write is filed under the *owner's* namespace, which is +//! the one every member's devices read. A reader, a former member, a stranger and an +//! unprovisioned id are all [`AlbumWriteAccess::Denied`], one answer, which is the safe direction: +//! a write that should have been allowed is refused, never the reverse, and the refusal says +//! nothing about which of the four it was. //! //! # Invariant 7, from the published device directory (`S-C20`) //! @@ -40,14 +42,16 @@ use uuid::Uuid; use super::AlbumStore; use crate::directory::DeviceDirectoryStore; -use crate::store::{AlbumId, OwnerId, UserId}; -use crate::upload::{AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority}; +use crate::membership::{MemberRole, Membership, MembershipStore}; +use crate::store::{AlbumId, UserId}; +use crate::upload::{AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority, WriteRole}; /// The write authority the server runs on. #[derive(Debug, Clone)] pub struct ProvisionedAuthority { albums: Arc, directories: Arc, + members: Arc, clock: Arc, } @@ -60,11 +64,13 @@ impl ProvisionedAuthority { pub fn new( albums: Arc, directories: Arc, + members: Arc, clock: Arc, ) -> Self { Self { albums, directories, + members, clock, } } @@ -78,33 +84,74 @@ fn unavailable(error: &crate::store::StoreError) -> AuthorityError { AuthorityError::unavailable(error.to_string()) } +impl ProvisionedAuthority { + /// The capacity `caller`'s roster seat gives them on `album`, if any. + /// + /// Only a *writer* member writes; a reader, a former member and a stranger are one `None`, + /// which the caller renders as the same `Denied` an unprovisioned album gets. + async fn member_role( + &self, + album: &AlbumId, + caller: &UserId, + ) -> Result, AuthorityError> { + let membership = self + .members + .membership(album, caller) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer"); + unavailable(&error) + })?; + Ok(match membership { + Membership::Member { + role: MemberRole::Writer, + .. + } => Some(WriteRole::Member), + Membership::Member { .. } | Membership::Revoked(_) | Membership::Never => None, + }) + } +} + impl WriteAuthority for ProvisionedAuthority { fn album_write_access<'a>( &'a self, - owner: &'a OwnerId, + caller: &'a UserId, album: &'a AlbumId, ) -> AuthorityFuture<'a, AlbumWriteAccess> { Box::pin(async move { - let record = self.albums.read(album).await.map_err(|error| { + let Some(record) = self.albums.read(album).await.map_err(|error| { tracing::error!(%error, %album, "the album store could not answer"); unavailable(&error) - })?; + })? + else { + // Unprovisioned. One answer with every other refusal: the id is client-derived + // and unguessable, and distinguishing would say whether it is taken. + return Ok(AlbumWriteAccess::Denied); + }; + + let role = if record.owner_id.as_str() == caller.as_str() { + WriteRole::Owner + } else { + // Somebody else's album: the roster decides (`S-C51`). + match self.member_role(album, caller).await? { + Some(role) => role, + None => return Ok(AlbumWriteAccess::Denied), + } + }; + let now = self.clock.now(); - Ok(match record { - Some(record) if &record.owner_id == owner => AlbumWriteAccess::Writable { - // `S-C24`: an expired ceremony is reported as none, because the deadline - // passing *is* the abort. Nothing has to run to clear it, which is what stops - // a proposer who vanished from freezing an album forever. - quiescing_under: record - .upgrade - .as_ref() - .filter(|quiescence| !quiescence.is_expired(now)) - .map(|quiescence| quiescence.intent.intent_id), - protocol_pin: record.protocol_version, - }, - // Unprovisioned, or somebody else's. One answer: the id is client-derived and - // unguessable, and distinguishing the two would say whether it is taken. - _ => AlbumWriteAccess::Denied, + Ok(AlbumWriteAccess::Writable { + owner_id: record.owner_id, + role, + // `S-C24`: an expired ceremony is reported as none, because the deadline + // passing *is* the abort. Nothing has to run to clear it, which is what stops + // a proposer who vanished from freezing an album forever. + quiescing_under: record + .upgrade + .as_ref() + .filter(|quiescence| !quiescence.is_expired(now)) + .map(|quiescence| quiescence.intent.intent_id), + protocol_pin: record.protocol_version, }) }) } diff --git a/capsule-server/src/album/tests.rs b/capsule-server/src/album/tests.rs index 7088795b..afc4b179 100644 --- a/capsule-server/src/album/tests.rs +++ b/capsule-server/src/album/tests.rs @@ -6,7 +6,7 @@ use super::authority::ProvisionedAuthority; use super::*; use crate::directory::{DeviceDirectoryStore, InMemoryDeviceDirectory, PublishedDirectory}; use crate::store::UserId; -use crate::upload::{AlbumWriteAccess, WriteAuthority}; +use crate::upload::{AlbumWriteAccess, WriteAuthority, WriteRole}; /// The account every case provisions under. fn owner() -> OwnerId { @@ -14,6 +14,11 @@ fn owner() -> OwnerId { } /// A derived album id. +/// The owner, as the account that calls. +fn caller() -> UserId { + UserId::new(owner().as_str()) +} + fn album() -> AlbumId { AlbumId::new("0198f3c2-9c4a-7b3d-8f21-4d7c9a1b2e35") } @@ -94,8 +99,9 @@ fn directory( added_at: &str, revoked_at: Option<&str>, ) -> Vec { - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; - use capsule_core::crypto::keys::{DeviceDirectory, DeviceEntry, DirectoryCore}; + use capsule_core::crypto::keys::{ + DeviceDirectory, DeviceEntry, DirectoryCore, HybridSigningKey, + }; let signing = HybridSigningKey::generate(); let entry = DeviceEntry { @@ -123,6 +129,7 @@ fn authority( ProvisionedAuthority::new( albums, directories, + Arc::new(crate::membership::InMemoryMembership::new()), std::sync::Arc::new(crate::store::SystemClock), ) } @@ -135,10 +142,12 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { assert_eq!( authority - .album_write_access(&owner(), &album()) + .album_write_access(&caller(), &album()) .await .expect("the authority answers"), AlbumWriteAccess::Writable { + owner_id: owner(), + role: WriteRole::Owner, quiescing_under: None, protocol_pin: "2026-01-01".to_owned() }, @@ -146,7 +155,7 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { ); assert_eq!( authority - .album_write_access(&OwnerId::new("somebody-else"), &album()) + .album_write_access(&UserId::new("somebody-else"), &album()) .await .expect("the authority answers"), AlbumWriteAccess::Denied, @@ -154,7 +163,7 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { assert_eq!( authority .album_write_access( - &owner(), + &caller(), &AlbumId::new("0198f3c2-0000-7b3d-8f21-4d7c9a1b2e35") ) .await @@ -258,3 +267,83 @@ async fn an_account_with_no_published_directory_has_no_floor() { the accounts most likely to be wrong about their devices" ); } + +/// A writer on the roster writes under the owner's namespace; everyone else is one `Denied`. +/// +/// The widening `S-C25` deferred, answered from the membership port (`S-C51`). A reader, a former +/// member and a stranger get the same answer an unprovisioned album gets, so the refusal says +/// nothing about the roster. +#[tokio::test] +async fn a_writer_member_writes_under_the_owner_and_nobody_else_writes_at_all() { + use crate::membership::{InMemoryMembership, MemberRole, MembershipStore as _, RosterRecord}; + + let albums = Arc::new(InMemoryAlbums::new()); + albums.provision(record(&owner())).await.expect("provision"); + let members = Arc::new(InMemoryMembership::new()); + let writer = UserId::new("member-writer"); + let reader = UserId::new("member-reader"); + let former = UserId::new("member-former"); + let roster = |version: u64, epoch: u64| RosterRecord { + album_id: album(), + roster_version: version, + amk_epoch: epoch, + attested_by_device: uuid::Uuid::from_u128(0xD1), + received_at: jiff::Timestamp::UNIX_EPOCH, + document: format!("v{version}").into_bytes(), + }; + members + .apply_roster( + roster(1, 1), + vec![ + (writer.clone(), MemberRole::Writer), + (reader.clone(), MemberRole::Reader), + (former.clone(), MemberRole::Writer), + ], + ) + .await + .expect("applied"); + members + .apply_roster( + roster(2, 2), + vec![ + (writer.clone(), MemberRole::Writer), + (reader.clone(), MemberRole::Reader), + ], + ) + .await + .expect("applied"); + let authority = ProvisionedAuthority::new( + albums, + Arc::new(InMemoryDeviceDirectory::new()), + members, + std::sync::Arc::new(crate::store::SystemClock), + ); + + assert_eq!( + authority + .album_write_access(&writer, &album()) + .await + .expect("the authority answers"), + AlbumWriteAccess::Writable { + owner_id: owner(), + role: WriteRole::Member, + quiescing_under: None, + protocol_pin: "2026-01-01".to_owned() + }, + "a writer member is filed under the owner, with the album's own pin" + ); + for (who, why) in [ + (reader, "a reader may read and not write"), + (former, "a former member is a stranger to the write path"), + (UserId::new("stranger"), "an account never on the roster"), + ] { + assert_eq!( + authority + .album_write_access(&who, &album()) + .await + .expect("the authority answers"), + AlbumWriteAccess::Denied, + "{why}" + ); + } +} diff --git a/capsule-server/src/app.rs b/capsule-server/src/app.rs index ecc54486..378a98a6 100644 --- a/capsule-server/src/app.rs +++ b/capsule-server/src/app.rs @@ -30,6 +30,7 @@ use kynos::security::Authenticates; use crate::album::AlbumContext; use crate::attestation::AttestationContext; +use crate::auth::oidc::OidcContext; use crate::auth::{AccessToken, AuthContext, TotpContext}; use crate::counter::CounterContext; use crate::directory::DeviceDirectoryContext; @@ -37,6 +38,8 @@ use crate::discovery::DiscoveryContext; use crate::drop::DropContext; use crate::enrollment::EnrollmentContext; use crate::escrow::EscrowContext; +use crate::federation::{FederationContext, ReadBearer}; +use crate::membership::MembershipContext; use crate::moderation::ModerationContext; use crate::quota::QuotaContext; use crate::serve::ServeContext; @@ -61,6 +64,8 @@ pub struct App { auth: AuthContext, /// The second factor's collaborators (`S-C55`). totp: TotpContext, + /// The OIDC relying party's collaborators (`S-N1`). + oidc: OidcContext, /// The upload module's collaborators. upload: UploadContext, /// The sync feed's collaborators. @@ -73,6 +78,8 @@ pub struct App { directories: DeviceDirectoryContext, /// The album-provisioning module's collaborators. albums: AlbumContext, + /// The album-membership module's collaborators (`S-C51`). + membership: MembershipContext, /// The quota module's collaborators. quota: QuotaContext, /// The custody-receipt module's collaborators. @@ -81,6 +88,8 @@ pub struct App { discovery: DiscoveryContext, /// The master-key escrow's collaborators. escrow: EscrowContext, + /// The federation module's collaborators (`S-E2`, `S-E5`). + federation: FederationContext, /// The cross-device add's collaborators. enrollment: EnrollmentContext, /// The moderation record's collaborators. @@ -106,6 +115,8 @@ pub struct Modules { pub auth: AuthContext, /// The second factor's collaborators (`S-C55`). pub totp: TotpContext, + /// The OIDC relying party's collaborators (`S-N1`). + pub oidc: OidcContext, /// The upload module's collaborators. pub upload: UploadContext, /// The sync feed's collaborators. @@ -118,6 +129,8 @@ pub struct Modules { pub directories: DeviceDirectoryContext, /// The album-provisioning module's collaborators. pub albums: AlbumContext, + /// The album-membership module's collaborators (`S-C51`). + pub membership: MembershipContext, /// The quota module's collaborators. pub quota: QuotaContext, /// The custody-receipt module's collaborators. @@ -126,6 +139,8 @@ pub struct Modules { pub discovery: DiscoveryContext, /// The master-key escrow's collaborators. pub escrow: EscrowContext, + /// The federation module's collaborators (`S-E2`, `S-E5`). + pub federation: FederationContext, /// The cross-device add's collaborators. pub enrollment: EnrollmentContext, /// The moderation record's collaborators. @@ -139,21 +154,29 @@ pub struct Modules { } impl App { + /// The authentication module, for the read scheme that delegates to it first. + pub(crate) fn auth(&self) -> &AuthContext { + &self.auth + } + /// Assembles the application from its modules. pub fn new(modules: Modules) -> Self { let Modules { auth, totp, + oidc, upload, sync, serve, verify, directories, albums, + membership, quota, attestation, discovery, escrow, + federation, enrollment, moderation, share, @@ -163,16 +186,19 @@ impl App { Self { auth, totp, + oidc, upload, sync, serve, verify, directories, albums, + membership, quota, attestation, discovery, escrow, + federation, enrollment, moderation, share, @@ -194,3 +220,16 @@ impl Authenticates for App { &self.auth } } + +/// The federation module verifies the bearer the two read primitives accept two principals on. +/// +/// Its authenticator asks [`AuthContext`] first and the capability codec second, so a session +/// token on `GET /v1/sync` or `GET /v1/blob/{hash}` is admitted exactly as it is everywhere +/// else (`S-E5`). +impl Authenticates for App { + type Authenticator = FederationContext; + + fn authenticator(&self) -> &Self::Authenticator { + &self.federation + } +} diff --git a/capsule-server/src/attestation/mod.rs b/capsule-server/src/attestation/mod.rs index fae69982..64e1d9ae 100644 --- a/capsule-server/src/attestation/mod.rs +++ b/capsule-server/src/attestation/mod.rs @@ -45,7 +45,7 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use capsule_core::crypto::hash::{Hash32, hash_bytes}; -use capsule_core::crypto::keys::hybrid_sig::{HybridSignature, HybridSigningKey}; +use capsule_core::crypto::keys::{HybridSignature, HybridSigningKey}; use capsule_core::crypto::receipts::{CustodyReceipt, CustodyReceiptCore}; use jiff::Timestamp; diff --git a/capsule-server/src/auth/accounts_memory.rs b/capsule-server/src/auth/accounts_memory.rs new file mode 100644 index 00000000..3d148b23 --- /dev/null +++ b/capsule-server/src/auth/accounts_memory.rs @@ -0,0 +1,778 @@ +//! [`InMemoryAccounts`] — the deterministic account store the development profile runs on. +//! +//! # Why this exists in `src/` when three port modules say it would not +//! +//! [`directory`](super::directory), [`registry`](super::registry) and +//! [`profile`](super::profile) each record the same reason for having no adapter: *"the real one +//! is Postgres, the test one is a double, and a double in `src/` is a fake credential directory +//! shipped inside the server binary."* That reasoning is about a **double** — specifically +//! `tests/support/mod.rs`'s, which "accepts whatever password it was told to accept" and +//! therefore must never be linkable by a server. +//! +//! This is not that. It verifies with the same Argon2id helper the Postgres adapter will use +//! ([`credential`](super::credential)), it stores PHC strings and no plaintext, it takes the +//! timing-equalized miss, and it locks an account out after enough failures. What it is missing +//! is **durability**, which is what makes it a development profile rather than a deployment: +//! every account registered against it is gone when the process exits. `capsule-server serve` +//! reaches it only through `--memory`, which is an explicit operator act +//! ([`Backends::Memory`](crate::config::Backends)). +//! +//! The alternative was a fail-closed stub: four ports that answer `Unavailable`, so +//! `POST /v1/auth/register` and `POST /v1/auth/login` return their declared refusal until #402 +//! lands. That was rejected once the cost was measured — `argon2` is already a workspace +//! dependency with a design-doc row, and the credential helper this needs is the one #402 +//! reuses, so nothing is written twice — and the gain is large: a `mise run serve-memory` you +//! can actually sign in to is the difference between a server a client developer can point at +//! and a surface they can only read. +//! +//! # Where the hashing happens relative to the lock +//! +//! Argon2id is deliberately expensive — tens of milliseconds — and this adapter holds a +//! `Mutex`. Every operation therefore computes or checks its hash **outside** the critical +//! section and touches the map only to read a snapshot or to write a result. Holding the lock +//! across a hash would serialize every account operation in the process behind the slowest +//! primitive in it. +//! +//! The cost of that is a read-modify-write gap in the failed-attempt counter, so two +//! simultaneous wrong passwords can be recorded as one. That is the right trade for a +//! development adapter and it is *not* the trade a Postgres adapter should make: there the +//! increment is one statement, which is why the port asks the adapter for the bookkeeping rather +//! than describing how to do it. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use jiff::{SignedDuration, Timestamp}; + +use super::credential::{CredentialError, Credentials}; +use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::store::{Clock, UserId}; + +/// How many consecutive failures put an account into [`Authentication::Locked`]. +/// +/// Ten, and it is a **lockout** rather than a rate limit: the port is explicit that `Locked` is +/// account state the adapter owns, and that rate limiting is a counter with no port anywhere in +/// this crate. Cleared by a success and by a password change, which the port requires — leaving +/// it behind would bar somebody from an account they just proved they own. +pub const MAX_FAILED_ATTEMPTS: u32 = 10; + +/// One account, as this adapter holds it. +#[derive(Debug, Clone)] +struct Account { + /// The address it signs in with, verbatim as it was registered. + email: String, + /// The id every session and every manifest names. + user_id: UserId, + /// The Argon2id PHC string. Never a password. + stored: String, + /// The name it chose to be shown as. + display_name: Option, + /// When it was created. + created_at: Timestamp, + /// Consecutive failed credential presentations, since the last success or decay. + failures: u32, + /// When the most recent one was, if there has been one. + /// + /// The lockout's whole clock. Without it the count is a one-way door: **nothing in this + /// server can clear a lockout.** `login`, `reauthenticate` and `password` each ask the + /// directory first and refuse on `Locked` before verifying anything, there is no unlock + /// operation on any surface, and no operator command reaches this state — so a permanent + /// lockout is a permanently lost account. + last_failure_at: Option, +} + +impl Account { + /// Whether enough recent failures have accumulated to refuse a correct password. + /// + /// Recent is the operative word. The window is measured from the last **counted** failure, so + /// a person who mistyped their password ten times and walked away gets back in. + fn locked(&self, now: Timestamp, threshold: u32, window: SignedDuration) -> bool { + self.failures >= threshold + && self + .last_failure_at + .is_some_and(|at| now.duration_since(at) < window) + } + + /// The profile view of this account. + fn profile(&self) -> ProfileRecord { + ProfileRecord { + user_id: self.user_id.clone(), + email: self.email.clone(), + display_name: self.display_name.clone(), + created_at: self.created_at, + } + } +} + +/// Accounts held in this process, keyed by the address they registered with. +/// +/// Addresses are compared **verbatim**, exactly as the suite's double compares them. Case +/// folding would be a normalization policy this port does not describe, and a policy invented +/// here is a policy the Postgres adapter would have to guess at: `Foo@example.test` and +/// `foo@example.test` are two accounts until a slice says otherwise, and saying otherwise is a +/// decision about identity rather than about storage. +#[derive(Debug)] +pub struct InMemoryAccounts { + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + accounts: Mutex>, +} + +impl InMemoryAccounts { + /// An empty directory over `credentials`, locking an account out for `lockout_window` after + /// `lockout_attempts` consecutive failures ([`MAX_FAILED_ATTEMPTS`] by default). + /// + /// The verifier is passed in rather than constructed here because building one costs an + /// Argon2id hash (the decoy), and a composition root that builds several adapters should pay + /// that once. The clock is injected for the reason every other adapter in this crate injects + /// one: expiry that reads the wall clock directly is expiry a test can only assert by + /// sleeping. + pub fn new( + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + ) -> Self { + Self { + credentials, + clock, + lockout_attempts, + lockout_window, + accounts: Mutex::new(BTreeMap::new()), + } + } + + /// How many accounts are held, for a caller that logs the profile it came up on. + pub fn len(&self) -> usize { + self.accounts().len() + } + + /// Whether no account has been registered yet. + pub fn is_empty(&self) -> bool { + self.accounts().is_empty() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later account lookup into a second panic, and the invariant this map holds is a + /// `BTreeMap`'s own rather than one a half-finished write could break. + fn accounts(&self) -> MutexGuard<'_, BTreeMap> { + self.accounts.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// A snapshot of the account `email` names, if there is one. + fn by_email(&self, email: &str) -> Option { + self.accounts().get(email).cloned() + } + + /// A snapshot of the account `user` names, if there is one. + fn by_id(&self, user: &UserId) -> Option { + self.accounts() + .values() + .find(|held| &held.user_id == user) + .cloned() + } + + /// Record the outcome of a credential presentation against `email`. + /// + /// One place, so the reset-on-success half cannot be forgotten at one of the two call sites. + fn record(&self, email: &str, granted: bool) { + let now = self.clock.now(); + let window = self.lockout_window; + if let Some(held) = self.accounts().get_mut(email) { + if granted { + held.failures = 0; + held.last_failure_at = None; + return; + } + // A failure after the window has passed starts a fresh run rather than tipping a + // stale count over. Otherwise ten mistypes spread over a year would lock an account + // on the tenth, which is not a guessing run and not what the ceiling is counting. + if held + .last_failure_at + .is_some_and(|at| now.duration_since(at) >= window) + { + held.failures = 0; + } + held.failures = held.failures.saturating_add(1); + held.last_failure_at = Some(now); + if held.failures == self.lockout_attempts { + tracing::warn!( + user = %held.user_id, + failures = held.failures, + window = %window, + "an account reached the failed-attempt ceiling and is locked out" + ); + } + } + } + + /// Decide `password` against a snapshot, and record what happened. + /// + /// The shared body of the two [`AccountDirectory`] methods: they differ only in how they + /// find the account, and that is exactly the difference the port wants them to have. + fn decide( + &self, + held: Option, + password: &str, + ) -> Result { + let Some(held) = held else { + // The timing-equalized miss. Refusing here without doing the work would leak the + // difference between an unknown address and a wrong password in the response time, + // whatever the body said. + self.credentials.absorb_miss(password); + return Ok(Authentication::Refused); + }; + if held.locked(self.clock.now(), self.lockout_attempts, self.lockout_window) { + // Still absorbed: a locked account that returned instantly would tell an attacker + // which addresses they have already spent attempts on. + self.credentials.absorb_miss(password); + return Ok(Authentication::Locked); + } + let granted = self + .credentials + .verify(password, &held.stored) + .map_err(unavailable)?; + self.record(&held.email, granted); + if granted { + Ok(Authentication::Granted(held.user_id)) + } else { + Ok(Authentication::Refused) + } + } +} + +/// A credential fault is a directory fault: no decision was reached. +/// +/// It is [`DirectoryError::Unavailable`] rather than a refusal because a stored hash this server +/// cannot read is a broken row, and answering "your password is wrong" would send somebody round +/// a loop that cannot succeed. +fn unavailable(error: CredentialError) -> DirectoryError { + tracing::error!(%error, "a stored credential could not be processed"); + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +impl AccountDirectory for InMemoryAccounts { + fn authenticate<'a>( + &'a self, + email: &'a str, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_email(email), password) }) + } + + fn authenticate_user<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { self.decide(self.by_id(user), password) }) + } +} + +impl AccountRegistry for InMemoryAccounts { + fn create<'a>( + &'a self, + email: &'a str, + password: &'a str, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, Registration> { + Box::pin(async move { + // Hashed before the lock, so the check-and-write below is short. The cost of that + // ordering is a hash computed for an address that turns out to be taken, which is + // the cheap direction to be wrong in. + let stored = self.credentials.hash(password).map_err(unavailable)?; + // One critical section, as the port requires: a caller that read, saw nothing and + // then wrote has a window in which a second registration for the same address + // lands, and both would believe they own it. + let mut accounts = self.accounts(); + if accounts.contains_key(email) { + return Ok(Registration::AlreadyExists); + } + accounts.insert( + email.to_owned(), + Account { + email: email.to_owned(), + user_id: user.clone(), + stored, + display_name: None, + created_at: at, + failures: 0, + last_failure_at: None, + }, + ); + tracing::info!(%user, "an account was created in the in-memory directory"); + Ok(Registration::Created(user.clone())) + }) + } +} + +impl AccountProfiles for InMemoryAccounts { + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.by_id(user).as_ref().map(Account::profile)) }) + } + + fn update<'a>( + &'a self, + user: &'a UserId, + update: &'a ProfileUpdate, + ) -> DirectoryFuture<'a, Option> { + Box::pin(async move { + // One critical section, as the port requires: a read-modify-write a caller could + // interleave is an edit from another device silently clobbered. + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(None); + }; + if let Some(display_name) = update.display_name.clone() { + held.display_name = display_name; + } + Ok(Some(held.profile())) + }) + } +} + +impl PasswordChange for InMemoryAccounts { + fn set_password<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + _at: Timestamp, + ) -> DirectoryFuture<'a, PasswordChanged> { + Box::pin(async move { + let stored = self.credentials.hash(password).map_err(unavailable)?; + let mut accounts = self.accounts(); + let Some(held) = accounts.values_mut().find(|held| &held.user_id == user) else { + return Ok(PasswordChanged::NoSuchAccount); + }; + held.stored = stored; + // The port requires it: a change is a successful credential presentation, and + // leaving the lockout behind would bar somebody from an account they just proved + // they own. + held.failures = 0; + held.last_failure_at = None; + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jiff::{SignedDuration, Timestamp}; + + use super::{Credentials, InMemoryAccounts, MAX_FAILED_ATTEMPTS}; + use crate::auth::directory::{AccountDirectory, Authentication}; + use crate::auth::profile::{AccountProfiles, PasswordChange, PasswordChanged, ProfileUpdate}; + use crate::auth::registry::{AccountRegistry, Registration}; + use crate::store::UserId; + use crate::store::memory::ManualClock; + + const EMAIL: &str = "somebody@example.test"; + const PASSWORD: &str = "correct horse battery staple"; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// A directory with one registered account, over a clock the test drives. + async fn seeded_on(clock: Arc) -> InMemoryAccounts { + let accounts = InMemoryAccounts::new( + Credentials::new().expect("the platform hashes"), + clock, + MAX_FAILED_ATTEMPTS, + WINDOW, + ); + assert_eq!( + accounts + .create(EMAIL, PASSWORD, &user(), Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + Registration::Created(user()) + ); + accounts + } + + /// The same, for a case with nothing to say about time. + async fn seeded() -> InMemoryAccounts { + seeded_on(Arc::new(ManualClock::default())).await + } + + /// Present a wrong password `times` times. + async fn fail(accounts: &InMemoryAccounts, times: u32) { + for _ in 0..times { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + } + + #[tokio::test] + async fn registering_then_signing_in_works() { + // The whole reason this adapter exists rather than a fail-closed stub. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn no_plaintext_password_is_retained() { + // The property the port is built on: the credential never rises above the adapter, and + // it is not sitting in the adapter either. + let accounts = seeded().await; + let held = accounts.by_email(EMAIL).expect("it is held"); + assert!(held.stored.starts_with("$argon2id$"), "{}", held.stored); + assert!(!held.stored.contains(PASSWORD)); + } + + #[tokio::test] + async fn a_taken_address_is_reported_and_nothing_is_written() { + let accounts = seeded().await; + let other = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e70"); + assert_eq!( + accounts + .create( + EMAIL, + "a different password entirely", + &other, + Timestamp::UNIX_EPOCH + ) + .await + .expect("it answers"), + Registration::AlreadyExists + ); + // The first account's credential still works, so nothing was overwritten. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn an_unknown_address_and_a_wrong_password_are_one_answer() { + // The port collapses them into one value on purpose, so no caller *can* tell them apart. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("nobody@example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + assert_eq!( + accounts + .authenticate(EMAIL, "the wrong password") + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn enough_failures_lock_the_account_and_a_correct_password_is_told_so() { + // `Locked` is the one refusal a *correct* password also receives, which is why it is a + // separate value: a client showing "wrong password" here would send somebody round a + // loop that cannot succeed. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + assert_eq!( + accounts + .authenticate(EMAIL, "wrong") + .await + .expect("it answers"), + Authentication::Refused + ); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + } + + #[tokio::test] + async fn a_lockout_decays_because_nothing_else_can_clear_it() { + // There is no unlock operation on any surface, and `login`, `reauthenticate` and + // `password` all refuse on `Locked` before they verify anything — so without a decay a + // lockout is a permanently lost account rather than a throttle. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + // One second short of the window: still locked. The boundary is asserted because an + // off-by-one here is a lockout that never engages. + clock.advance(WINDOW - SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + + clock.advance(SignedDuration::from_secs(1)); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn attempts_during_a_lockout_do_not_extend_it() { + // Deliberate, and the direction is not obvious. Extending the window on every attempt + // would keep a live guessing run permanently locked out — and would hand anybody who can + // reach the endpoint a way to keep *somebody else's* account locked forever by hammering + // it, which is a denial of service on an account rather than a defence of it. So the + // window runs from the last **counted** failure, and an attempt made while locked is + // refused without being counted. + // + // What that costs is bounded and small: a run gets `MAX_FAILED_ATTEMPTS` guesses per + // window and no more, which is four a minute at the default. What bounds an attacker + // across *many* accounts is a rate limiter, and the counter port that would carry one + // has no trusted client address to key on (`registry`, the disclosure section). + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + fail(&accounts, MAX_FAILED_ATTEMPTS).await; + for _ in 0..3 { + clock.advance(SignedDuration::from_mins(1)); + fail(&accounts, 1).await; + } + // Still inside the window measured from the tenth *counted* failure: locked. + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Locked + ); + // Past it: open, and the hammering did not move the deadline. + clock.advance(WINDOW); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn failures_spread_wider_than_the_window_never_accumulate() { + // Ten mistypes over a year is not a guessing run, and counting them as one would lock an + // account on a tenth attempt made months after the ninth. + let clock = Arc::new(ManualClock::default()); + let accounts = seeded_on(clock.clone()).await; + for _ in 0..MAX_FAILED_ATTEMPTS * 2 { + fail(&accounts, 1).await; + clock.advance(WINDOW + SignedDuration::from_secs(1)); + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn a_success_before_the_ceiling_clears_the_count() { + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS - 1 { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + // Back to zero: the next wrong password does not tip an already-full counter over. + let _ = accounts.authenticate(EMAIL, "wrong").await; + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + } + + #[tokio::test] + async fn a_password_change_clears_a_lockout() { + // The port requires it: a change is a successful credential presentation. + let accounts = seeded().await; + for _ in 0..MAX_FAILED_ATTEMPTS { + let _ = accounts.authenticate(EMAIL, "wrong").await; + } + assert_eq!( + accounts + .set_password(&user(), "a brand new password", Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + PasswordChanged::Yes + ); + assert_eq!( + accounts + .authenticate(EMAIL, "a brand new password") + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + assert_eq!( + accounts + .authenticate(EMAIL, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn re_authentication_takes_the_account_from_the_credential_and_not_the_request() { + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate_user(&user(), PASSWORD) + .await + .expect("it answers"), + Authentication::Granted(user()) + ); + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e71"); + assert_eq!( + accounts + .authenticate_user(&stranger, PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } + + #[tokio::test] + async fn changing_a_password_for_an_absent_account_writes_nothing() { + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e72"); + assert_eq!( + accounts + .set_password(&stranger, "irrelevant", Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + PasswordChanged::NoSuchAccount + ); + } + + #[tokio::test] + async fn a_profile_reads_back_what_registration_wrote_and_takes_an_edit() { + let accounts = seeded().await; + let profile = accounts + .read(&user()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(profile.email, EMAIL); + assert_eq!(profile.display_name, None); + assert_eq!(profile.created_at, Timestamp::UNIX_EPOCH); + + let updated = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(Some("Ada Lovelace".to_owned())), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(updated.display_name.as_deref(), Some("Ada Lovelace")); + + // An absent field leaves the name alone; `Some(None)` clears it. + let untouched = accounts + .update(&user(), &ProfileUpdate::default()) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(untouched.display_name.as_deref(), Some("Ada Lovelace")); + + let cleared = accounts + .update( + &user(), + &ProfileUpdate { + display_name: Some(None), + }, + ) + .await + .expect("it answers") + .expect("the account exists"); + assert_eq!(cleared.display_name, None); + } + + #[tokio::test] + async fn a_profile_for_an_absent_account_is_absent_and_not_an_error() { + // Reachable with a perfectly valid credential: a session outlives the account row it + // names if the account is deleted while a token is live. + let accounts = seeded().await; + let stranger = UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e73"); + assert!( + accounts + .read(&stranger) + .await + .expect("it answers") + .is_none() + ); + assert!( + accounts + .update(&stranger, &ProfileUpdate::default()) + .await + .expect("it answers") + .is_none() + ); + } + + #[tokio::test] + async fn addresses_are_compared_verbatim() { + // Recorded as a test rather than left implicit: case folding is a normalization policy + // this port does not describe, and #402's adapter has to make the same choice. + let accounts = seeded().await; + assert_eq!( + accounts + .authenticate("Somebody@Example.test", PASSWORD) + .await + .expect("it answers"), + Authentication::Refused + ); + } +} diff --git a/capsule-server/src/auth/accounts_postgres.rs b/capsule-server/src/auth/accounts_postgres.rs new file mode 100644 index 00000000..6acba297 --- /dev/null +++ b/capsule-server/src/auth/accounts_postgres.rs @@ -0,0 +1,536 @@ +//! [`PostgresAccounts`] — the durable account store (`S-C53`, `S-C54`, #402). +//! +//! # Four ports, one table, one adapter +//! +//! [`AccountRegistry`], [`AccountDirectory`], [`AccountProfiles`] and [`PasswordChange`] are four +//! ports because they answer four questions with four disclosure contracts — registration has to +//! say whether an address is taken, authentication must not — and their module docs argue that at +//! length. None of that makes them four *stores*: one row holds every fact all four read, and +//! splitting it across four tables would put the lockout counter somewhere the password change +//! that must clear it cannot reach in the same statement. +//! +//! # Where this adapter is stricter than the in-memory one, and why +//! +//! [`accounts_memory`](super::accounts_memory) computes its hash outside its mutex and takes a +//! read-modify-write gap in the failed-attempt counter as a result, recording its own docs that +//! *"that is the right trade for a development adapter and it is not the trade a Postgres +//! adapter should make: there the increment is one statement"*. It is one statement here. The +//! decay, the reset-on-a-stale-run and the increment are a single `UPDATE … SET failures = CASE +//! …`, so two simultaneous wrong passwords are two counted failures rather than one. +//! +//! Argon2id still runs outside every statement. It costs tens of milliseconds, and a verification +//! held inside a transaction is a row lock held for the length of the slowest primitive in the +//! process. +//! +//! # The clock is injected, and never `now()` +//! +//! The lockout window is measured against [`Clock`], not against the database's `now()`. Two +//! reasons: the conformance suite has to be able to move time without sleeping for fifteen +//! minutes, and a server and its database disagreeing about the hour should not change who is +//! locked out. +//! +//! # Addresses are compared verbatim +//! +//! `email` carries a plain unique index and no `lower()`, no `citext`, no folded companion +//! column. Case folding is a normalization policy no port describes, and the conformance suite +//! asserts the consequence — `addresses_are_compared_verbatim` — precisely because a `citext` +//! column is the kind of thing that changes an identity decision without anybody making one. + +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value}; + +use super::credential::{CredentialError, Credentials}; +use super::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{Clock, StoreError, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "accounts", + record: "Account", +}; + +/// The durable account store. +#[derive(Clone)] +pub struct PostgresAccounts { + connection: DatabaseConnection, + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, +} + +impl std::fmt::Debug for PostgresAccounts { + /// Names the policy and never the state, for the reason [`Credentials`] does: the state is a + /// pool holding a URL with a password in it. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PostgresAccounts") + .field("lockout_attempts", &self.lockout_attempts) + .field("lockout_window", &self.lockout_window) + .finish_non_exhaustive() + } +} + +impl PostgresAccounts { + /// An account store over `connection`, locking an account out for `lockout_window` after + /// `lockout_attempts` consecutive failures. + /// + /// The verifier is passed in rather than constructed here because building one costs an + /// Argon2id hash (the timing-equalized miss's decoy), and a composition root that builds + /// several adapters should pay that once. + pub fn new( + connection: DatabaseConnection, + credentials: Credentials, + clock: Arc, + lockout_attempts: u32, + lockout_window: SignedDuration, + ) -> Self { + Self { + connection, + credentials, + clock, + lockout_attempts, + lockout_window, + } + } + + /// The account `column` = `key` names, if there is one. + /// + /// `column` is a `&'static str` chosen from two literals below and never from a caller, so + /// this is not a string-built query in the sense that matters — there is no input path to it. + /// The alternative, two nearly identical statements, is the same query twice with a + /// different `WHERE`, and the second copy is where a lockout column eventually gets left out. + async fn lookup(&self, column: &'static str, key: &str) -> Result, StoreError> { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT user_id, credential, failures, last_failure_at \ + FROM accounts WHERE {column} = $1" + ), + [Value::from(key.to_owned())], + )) + .await + .map_err(PORT.failing("looking an account up"))?; + let Some(found) = found else { return Ok(None) }; + + let failed = PORT.failing("reading an account row"); + let user_id: String = found.try_get("", "user_id").map_err(&failed)?; + let stored: String = found.try_get("", "credential").map_err(&failed)?; + let failures: i64 = found.try_get("", "failures").map_err(&failed)?; + let last_failure_at: Option = found.try_get("", "last_failure_at").map_err(&failed)?; + Ok(Some(Held { + user_id: UserId::new(user_id), + stored, + failures: u32::try_from(failures) + .map_err(|_| PORT.undecodable(format!("{failures} is not a failure count")))?, + last_failure_at: last_failure_at + .map(|micros| { + from_micros(micros).ok_or_else(|| { + PORT.undecodable(format!("{micros}µs is not a representable instant")) + }) + }) + .transpose()?, + })) + } + + /// Whether enough *recent* failures have accumulated to refuse a correct password. + /// + /// Recent is the operative word, and the decay is not a courtesy: nothing in this server can + /// clear a lockout otherwise. `login`, `reauthenticate` and `password` each ask the directory + /// first and refuse on `Locked` before verifying anything, there is no unlock operation on + /// any surface, and no operator command reaches this row — so a permanent lockout is a + /// permanently lost account. + fn locked(&self, held: &Held, now: Timestamp) -> bool { + held.failures >= self.lockout_attempts + && held + .last_failure_at + .is_some_and(|at| now.duration_since(at) < self.lockout_window) + } + + /// Record the outcome of a credential presentation, in one statement. + /// + /// The `CASE` is the decay: a failure whose predecessor is older than the window starts a + /// fresh run at 1 rather than tipping a stale count over, so ten mistypes spread over a year + /// never lock an account. Done in SQL rather than by reading, deciding and writing, because + /// the read-decide-write is exactly the gap the in-memory adapter documents as the price of + /// being a development double. + async fn record(&self, user: &UserId, granted: bool, now: Timestamp) -> Result<(), StoreError> { + let statement = if granted { + Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET failures = 0, last_failure_at = NULL WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + ) + } else { + Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET \ + failures = CASE \ + WHEN last_failure_at IS NOT NULL AND $2 - last_failure_at >= $3 THEN 1 \ + ELSE failures + 1 END, \ + last_failure_at = $2 \ + WHERE user_id = $1", + [ + Value::from(user.as_str().to_owned()), + Value::from(to_micros(now)), + Value::from(self.lockout_window.as_micros() as i64), + ], + ) + }; + self.connection + .execute(statement) + .await + .map_err(PORT.failing("recording a credential presentation"))?; + Ok(()) + } + + /// Decide `password` against a looked-up account, and record what happened. + /// + /// The shared body of the two [`AccountDirectory`] methods: they differ only in how they find + /// the account, and that is exactly the difference the port wants them to have. + async fn decide( + &self, + held: Option, + password: &str, + ) -> Result { + let now = self.clock.now(); + let Some(held) = held else { + // The timing-equalized miss. Refusing here without doing the work would leak the + // difference between an unknown address and a wrong password in the response time, + // whatever the body said. + self.credentials.absorb_miss(password); + return Ok(Authentication::Refused); + }; + if self.locked(&held, now) { + // Still absorbed: a locked account that returned instantly would tell an attacker + // which addresses they have already spent attempts on. Deliberately **not** + // recorded — an attempt made while locked must not extend the window, or anybody + // who can reach the endpoint can keep somebody else's account locked forever. + self.credentials.absorb_miss(password); + return Ok(Authentication::Locked); + } + let granted = self + .credentials + .verify(password, &held.stored) + .map_err(unavailable)?; + self.record(&held.user_id, granted, now) + .await + .map_err(store_unavailable)?; + if granted { + Ok(Authentication::Granted(held.user_id)) + } else { + Ok(Authentication::Refused) + } + } + + /// The profile of `user`, read through whatever connection the caller has. + async fn profile(&self, user: &UserId) -> Result, StoreError> { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT user_id, email, display_name, created_at FROM accounts \ + WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading a profile"))?; + found.as_ref().map(profile_from).transpose() + } +} + +/// The columns an authentication decision reads. +#[derive(Debug)] +struct Held { + user_id: UserId, + /// The Argon2id PHC string. Never a password, and never read above this adapter. + stored: String, + failures: u32, + last_failure_at: Option, +} + +/// Read one profile projection back. +fn profile_from(row: &sea_orm::QueryResult) -> Result { + let failed = PORT.failing("reading a profile row"); + let user_id: String = row.try_get("", "user_id").map_err(&failed)?; + let email: String = row.try_get("", "email").map_err(&failed)?; + let display_name: Option = row.try_get("", "display_name").map_err(&failed)?; + let created_at: i64 = row.try_get("", "created_at").map_err(&failed)?; + Ok(ProfileRecord { + user_id: UserId::new(user_id), + email, + display_name, + created_at: from_micros(created_at).ok_or_else(|| { + PORT.undecodable(format!("{created_at}µs is not a representable instant")) + })?, + }) +} + +/// A credential fault is a directory fault: no decision was reached. +fn unavailable(error: CredentialError) -> DirectoryError { + tracing::error!(%error, "a stored credential could not be processed"); + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +/// A store fault is a directory fault, for the same reason. +/// +/// [`DirectoryError`] has exactly one variant, and that is the port's decision rather than a +/// simplification here: *"whether the backend was down or merely angry changes nothing about the +/// response"*. The [`StoreError`] distinction is preserved in the log line the mapping already +/// emitted, which is where an operator reads it. +fn store_unavailable(error: StoreError) -> DirectoryError { + DirectoryError::Unavailable { + detail: error.to_string(), + } +} + +impl AccountDirectory for PostgresAccounts { + fn authenticate<'a>( + &'a self, + email: &'a str, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { + let held = self + .lookup("email", email) + .await + .map_err(store_unavailable)?; + self.decide(held, password).await + }) + } + + fn authenticate_user<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + ) -> DirectoryFuture<'a, Authentication> { + Box::pin(async move { + let held = self + .lookup("user_id", user.as_str()) + .await + .map_err(store_unavailable)?; + self.decide(held, password).await + }) + } +} + +impl AccountRegistry for PostgresAccounts { + fn create<'a>( + &'a self, + email: &'a str, + password: &'a str, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, Registration> { + Box::pin(async move { + // Hashed before the statement, so nothing holds a row while Argon2id runs. The cost + // of that ordering is a hash computed for an address that turns out to be taken, + // which is the cheap direction to be wrong in. + let stored = self.credentials.hash(password).map_err(unavailable)?; + // `ON CONFLICT DO NOTHING` on the unique index, so `AlreadyExists` is decided by the + // index and never by a read followed by a write: two registrations racing on one + // address must not both believe they own it. + let inserted = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO accounts \ + (user_id, email, display_name, credential, failures, created_at, updated_at) \ + VALUES ($1, $2, NULL, $3, 0, $4, $4) \ + ON CONFLICT (email) DO NOTHING", + [ + Value::from(user.as_str().to_owned()), + Value::from(email.to_owned()), + Value::from(stored), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("creating an account")) + .map_err(store_unavailable)?; + if inserted.rows_affected() == 0 { + return Ok(Registration::AlreadyExists); + } + tracing::info!(%user, "an account was created"); + Ok(Registration::Created(user.clone())) + }) + } +} + +impl AccountProfiles for PostgresAccounts { + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { self.profile(user).await.map_err(store_unavailable) }) + } + + fn update<'a>( + &'a self, + user: &'a UserId, + update: &'a ProfileUpdate, + ) -> DirectoryFuture<'a, Option> { + Box::pin(async move { + // An empty update is a no-op the route answers with the current profile, and the + // port says so — which is why it is a read here rather than an `UPDATE` that sets + // every column to itself. + let Some(display_name) = update.display_name.clone() else { + return self.profile(user).await.map_err(store_unavailable); + }; + // One statement, as the port requires: a caller that read, changed a field and wrote + // the whole record back would clobber a concurrent edit from another device. + let updated = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts SET display_name = $2, updated_at = $3 WHERE user_id = $1 \ + RETURNING user_id, email, display_name, created_at", + [ + Value::from(user.as_str().to_owned()), + Value::from(display_name), + Value::from(to_micros(self.clock.now())), + ], + )) + .await + .map_err(PORT.failing("updating a profile")) + .map_err(store_unavailable)?; + updated + .as_ref() + .map(profile_from) + .transpose() + .map_err(store_unavailable) + }) + } +} + +impl PasswordChange for PostgresAccounts { + fn set_password<'a>( + &'a self, + user: &'a UserId, + password: &'a str, + at: Timestamp, + ) -> DirectoryFuture<'a, PasswordChanged> { + Box::pin(async move { + let stored = self.credentials.hash(password).map_err(unavailable)?; + // The lockout is cleared in the same statement, because the port requires it: a + // change is a successful credential presentation, and leaving the failure count + // behind would bar somebody from an account they just proved they own. + let changed = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE accounts \ + SET credential = $2, failures = 0, last_failure_at = NULL, updated_at = $3 \ + WHERE user_id = $1", + [ + Value::from(user.as_str().to_owned()), + Value::from(stored), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("replacing a password")) + .map_err(store_unavailable)?; + if changed.rows_affected() == 0 { + return Ok(PasswordChanged::NoSuchAccount); + } + tracing::info!(%user, "an account's password was replaced"); + Ok(PasswordChanged::Yes) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use std::sync::Arc; + + use jiff::SignedDuration; + + use super::super::PostgresAccounts; + use crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS; + use crate::auth::conformance::{self, Harness}; + use crate::auth::credential::Credentials; + use crate::auth::directory::AccountDirectory; + use crate::auth::profile::{AccountProfiles, PasswordChange}; + use crate::auth::registry::AccountRegistry; + use crate::postgres::testing; + use crate::store::StoreFuture; + use crate::store::memory::ManualClock; + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// One adapter over one container, on a clock the suite drives. + /// + /// The clock is the whole reason the harness has an `advance`: the lockout cases assert a + /// fifteen-minute decay from both sides of its boundary, and a container-backed suite + /// that waited for it would take half an hour. + #[derive(Debug)] + struct PostgresHarness { + accounts: PostgresAccounts, + clock: Arc, + } + + impl Harness for PostgresHarness { + fn registry(&self) -> &dyn AccountRegistry { + &self.accounts + } + + fn directory(&self) -> &dyn AccountDirectory { + &self.accounts + } + + fn profiles(&self) -> &dyn AccountProfiles { + &self.accounts + } + + fn passwords(&self) -> &dyn PasswordChange { + &self.accounts + } + + fn lockout_attempts(&self) -> u32 { + MAX_FAILED_ATTEMPTS + } + + fn lockout_window(&self) -> SignedDuration { + WINDOW + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok(()) + }) + } + } + + #[tokio::test] + async fn the_postgres_account_store_conforms() { + let Some(database) = testing::start("the Postgres account store").await else { + return; + }; + let clock = Arc::new(ManualClock::default()); + let harness = PostgresHarness { + accounts: PostgresAccounts::new( + database.connection().clone(), + Credentials::new().expect("the platform hashes"), + clock.clone(), + MAX_FAILED_ATTEMPTS, + WINDOW, + ), + clock, + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/auth/conformance.rs b/capsule-server/src/auth/conformance.rs new file mode 100644 index 00000000..e579ff32 --- /dev/null +++ b/capsule-server/src/auth/conformance.rs @@ -0,0 +1,700 @@ +//! The one suite every account adapter must pass. +//! +//! # Why the four ports share one suite +//! +//! [`AccountRegistry`], [`AccountDirectory`], [`AccountProfiles`] and [`PasswordChange`] are +//! four ports because they answer four questions with four *disclosure* contracts — registration +//! must say whether an address is taken, authentication must not — and their own module docs +//! argue at length for keeping them apart. They are not four stores. Every adapter that exists +//! or is planned implements all four over one account row, and most of the properties worth +//! asserting cross them: a password change is only interesting because the *directory* then +//! grants the new password and refuses the old one, and a lockout is only interesting because a +//! password change clears it. +//! +//! So the harness hands out all four, and a case reaches for the ones its property spans. +//! +//! # What this suite cannot assert, stated rather than faked +//! +//! **The timing-equalized miss.** [`AccountDirectory`]'s contract is that no caller can tell an +//! unknown account from a wrong password, and half of that is a *response time*. A timing +//! assertion is flaky by nature and would be the `S-C35` mistake in a new place — a suite +//! claiming to prove a property under conditions it cannot create. What is asserted instead is +//! the consequence a suite *can* see: both answers are the same value, and neither path is +//! distinguishable through the port. The work itself is asserted where it is decidable, in +//! `credential`'s own tests (`absorbing_a_miss_costs_what_a_verification_costs`), and structurally +//! by the fact that the miss path calls the same verifier. +//! +//! **Concurrency.** `create` is required to be one operation against two racing registrations, +//! and a single-process suite cannot exhibit that race. What it asserts is the observable +//! consequence — the second registration is refused and the first account's credential still +//! works — and the structural guarantee stays in the adapter, as one lock here and as a unique +//! index in Postgres. +//! +//! # Reusing a harness +//! +//! Every case scopes its addresses and ids to itself, so cases may share one harness and +//! [`run_all`] does. A case may move the harness clock forward and never moves it back. + +use jiff::{SignedDuration, Timestamp}; + +use super::directory::{AccountDirectory, Authentication, DirectoryError}; +use super::profile::{ + AccountProfiles, PasswordChange, PasswordChanged, ProfileRecord, ProfileUpdate, +}; +use super::registry::{AccountRegistry, Registration}; +use crate::store::{StoreFuture, UserId}; + +/// The four account ports under test, plus the two things a suite cannot do through them. +/// +/// `lockout_attempts` is asked of the harness rather than read from a constant because the +/// threshold is a **deployment setting** (`LOCKOUT_MAX_ATTEMPTS`), so a harness built with a +/// tighter one must still pass — and a suite that hardcoded ten would silently stop testing the +/// ceiling the moment a deployment moved it. +pub trait Harness: Send + Sync { + /// Where accounts are created (`S-C53`). + fn registry(&self) -> &dyn AccountRegistry; + /// Who exists, and whether a presented password is theirs. + fn directory(&self) -> &dyn AccountDirectory; + /// The facts an account keeps about itself (`S-C54`). + fn profiles(&self) -> &dyn AccountProfiles; + /// Where a password is replaced (`S-C54`). + fn passwords(&self) -> &dyn PasswordChange; + + /// How many consecutive failures this harness locks an account out after. + fn lockout_attempts(&self) -> u32; + /// How long that lockout lasts. + fn lockout_window(&self) -> SignedDuration; + + /// Move the harness `by` forward in its own time. + /// + /// The seam that keeps the lockout cases backend-agnostic: the deterministic double advances + /// a manual clock, and a container-backed harness rebuilds its adapter over a clock it drives. + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; +} + +/// Unwrap a directory result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming adapter must succeed at {doing}: {error}"), + } +} + +/// Unwrap an expected-present value. +#[track_caller] +fn present(value: Option, what: &str) -> T { + match value { + Some(value) => value, + None => panic!("{what} must be present"), + } +} + +/// The password every case registers with unless it is about passwords. +const PASSWORD: &str = "correct horse battery staple"; + +/// `case`'s own address, so cases may share a harness. +fn email(case: &str) -> String { + format!("{case}@example.test") +} + +/// `case`'s own account id. +/// +/// A UUID-shaped string rather than a bare word, because that is what `new_user_id` mints and +/// an adapter storing it in a typed column would notice the difference. +fn user(case: &str, n: u8) -> UserId { + UserId::new(format!( + "018f3f1e-4b7a-7c9d-8e2f-{:012x}", + u64::from(n) + hash(case) + )) +} + +/// A small stable spread so two cases' ids do not collide. +fn hash(case: &str) -> u64 { + case.bytes().fold(1_u64, |held, byte| { + held.wrapping_mul(131).wrapping_add(u64::from(byte)) & 0x0000_ffff_ffff + }) * 256 +} + +/// Register `case`'s account and return its id. +async fn seed(h: &dyn Harness, case: &str) -> (String, UserId) { + let address = email(case); + let id = user(case, 0); + assert_eq!( + ok( + h.registry() + .create(&address, PASSWORD, &id, Timestamp::UNIX_EPOCH) + .await, + "create an account", + ), + Registration::Created(id.clone()), + ); + (address, id) +} + +/// Present a wrong password `times` times against `address`. +async fn fail(h: &dyn Harness, address: &str, times: u32) { + for _ in 0..times { + let _ = h.directory().authenticate(address, "wrong").await; + } +} + +// =========================================================================================== +// Registration and sign-in +// =========================================================================================== + +/// The whole point of an account adapter: register, then sign in to what was registered. +pub async fn registering_then_signing_in_works(h: &dyn Harness) { + let (address, id) = seed(h, "signin").await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +/// A taken address is reported, and the account behind it is untouched. +/// +/// The one disclosure this surface makes, and the alternative — answering success and creating +/// nothing — is worse in every way, because a client that believed it would then fail to sign in +/// with no explanation. +pub async fn a_taken_address_is_reported_and_nothing_is_written(h: &dyn Harness) { + let (address, first) = seed(h, "taken").await; + let second = user("taken", 1); + assert_eq!( + ok( + h.registry() + .create( + &address, + "a different password entirely", + &second, + Timestamp::UNIX_EPOCH, + ) + .await, + "create a second account for one address", + ), + Registration::AlreadyExists, + ); + // The first account's credential still works, so nothing was overwritten — which is what + // makes this a refusal rather than a silent takeover of somebody's address. + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(first), + ); +} + +/// An unknown address and a wrong password are one answer. +/// +/// The port collapses them into one value on purpose, so no caller *can* tell them apart and no +/// future caller can reintroduce an enumeration oracle by branching on something the port does +/// not offer. +pub async fn an_unknown_address_and_a_wrong_password_are_one_answer(h: &dyn Harness) { + let (address, _) = seed(h, "oracle").await; + assert_eq!( + ok( + h.directory() + .authenticate("nobody-oracle@example.test", PASSWORD) + .await, + "authenticate an unknown address", + ), + Authentication::Refused, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "the wrong password") + .await, + "authenticate with a wrong password", + ), + Authentication::Refused, + ); +} + +/// Addresses are compared verbatim. +/// +/// Case folding is a normalization policy no port describes, and a policy invented by one +/// adapter is a policy the others have to guess at — so `Foo@example.test` and +/// `foo@example.test` are two accounts until a slice says otherwise. Asserted rather than left +/// implicit because it is exactly the kind of thing a `citext` column or a `lower()` index +/// changes without anyone deciding to. +pub async fn addresses_are_compared_verbatim(h: &dyn Harness) { + let (address, _) = seed(h, "verbatim").await; + assert_eq!( + ok( + h.directory() + .authenticate(&address.to_uppercase(), PASSWORD) + .await, + "authenticate a differently-cased address", + ), + Authentication::Refused, + ); +} + +/// Re-authentication takes the account from the credential and never from the request. +/// +/// An operation that took an address it was handed would be usable to test another account's +/// password from inside any authenticated session. +pub async fn re_authentication_takes_the_account_from_the_credential(h: &dyn Harness) { + let (_, id) = seed(h, "reauth").await; + assert_eq!( + ok( + h.directory().authenticate_user(&id, PASSWORD).await, + "re-authenticate", + ), + Authentication::Granted(id.clone()), + ); + let stranger = user("reauth", 9); + assert_eq!( + ok( + h.directory().authenticate_user(&stranger, PASSWORD).await, + "re-authenticate as a stranger", + ), + Authentication::Refused, + ); +} + +// =========================================================================================== +// The lockout +// =========================================================================================== + +/// Enough failures lock the account, and a correct password is told so. +/// +/// `Locked` is the one refusal a *correct* password also receives, which is why it is a separate +/// value: a client showing "wrong password" here would send somebody round a loop that cannot +/// succeed. +pub async fn enough_failures_lock_the_account_and_a_correct_password_is_told_so(h: &dyn Harness) { + let (address, _) = seed(h, "lockout").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate a locked account", + ), + Authentication::Locked, + ); +} + +/// A success before the ceiling clears the count. +pub async fn a_success_before_the_ceiling_clears_the_count(h: &dyn Harness) { + let (address, id) = seed(h, "clears").await; + fail(h, &address, h.lockout_attempts() - 1).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id.clone()), + ); + // Back to zero: the next wrong password does not tip an already-full counter over. + fail(h, &address, 1).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +/// The lockout decays, because nothing else in this server can clear it. +/// +/// There is no unlock operation on any surface, and `login`, `reauthenticate` and `password` all +/// refuse on `Locked` before they verify anything — so without a decay a lockout is a +/// permanently lost account rather than a throttle. The boundary is asserted from both sides +/// because an off-by-one here is a lockout that never engages. +pub async fn a_lockout_decays_because_nothing_else_can_clear_it(h: &dyn Harness) { + let (address, id) = seed(h, "decay").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate a locked account", + ), + Authentication::Locked, + ); + + ok_store( + h.advance(h.lockout_window() - SignedDuration::from_secs(1)) + .await, + ); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate one second inside the window", + ), + Authentication::Locked, + ); + + ok_store(h.advance(SignedDuration::from_secs(1)).await); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate past the window", + ), + Authentication::Granted(id), + ); +} + +/// Attempts made during a lockout do not extend it. +/// +/// Deliberate, and the direction is not obvious. Extending the window on every attempt would +/// hand anybody who can reach the endpoint a way to keep somebody else's account locked forever +/// by hammering it — a denial of service on an account rather than a defence of it. So the +/// window runs from the last **counted** failure. +pub async fn attempts_during_a_lockout_do_not_extend_it(h: &dyn Harness) { + let (address, id) = seed(h, "hammer").await; + fail(h, &address, h.lockout_attempts()).await; + // Hammer it three times, each a little later, all inside the window. + let step = h.lockout_window() / 8; + for _ in 0..3 { + ok_store(h.advance(step).await); + fail(h, &address, 1).await; + } + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate inside the window", + ), + Authentication::Locked, + ); + ok_store(h.advance(h.lockout_window()).await); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate past the window", + ), + Authentication::Granted(id), + "hammering a locked account must not move the deadline", + ); +} + +/// Failures spread wider than the window never accumulate. +/// +/// Ten mistypes over a year is not a guessing run, and counting them as one would lock an +/// account on a tenth attempt made months after the ninth. +pub async fn failures_spread_wider_than_the_window_never_accumulate(h: &dyn Harness) { + let (address, id) = seed(h, "spread").await; + for _ in 0..h.lockout_attempts() * 2 { + fail(h, &address, 1).await; + ok_store( + h.advance(h.lockout_window() + SignedDuration::from_secs(1)) + .await, + ); + } + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate", + ), + Authentication::Granted(id), + ); +} + +// =========================================================================================== +// Password change +// =========================================================================================== + +/// A password change replaces the credential, in both directions. +pub async fn a_password_change_grants_the_new_password_and_refuses_the_old(h: &dyn Harness) { + let (address, id) = seed(h, "rotate").await; + assert_eq!( + ok( + h.passwords() + .set_password(&id, "a brand new password", Timestamp::UNIX_EPOCH) + .await, + "replace a password", + ), + PasswordChanged::Yes, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "a brand new password") + .await, + "authenticate with the new password", + ), + Authentication::Granted(id), + ); + assert_eq!( + ok( + h.directory().authenticate(&address, PASSWORD).await, + "authenticate with the old password", + ), + Authentication::Refused, + ); +} + +/// A password change clears a lockout. +/// +/// The port requires it: a change is a successful credential presentation, and leaving the +/// failure count behind would bar somebody from an account they just proved they own. +pub async fn a_password_change_clears_a_lockout(h: &dyn Harness) { + let (address, id) = seed(h, "unlock").await; + fail(h, &address, h.lockout_attempts()).await; + assert_eq!( + ok( + h.passwords() + .set_password(&id, "a brand new password", Timestamp::UNIX_EPOCH) + .await, + "replace a password", + ), + PasswordChanged::Yes, + ); + assert_eq!( + ok( + h.directory() + .authenticate(&address, "a brand new password") + .await, + "authenticate after the change", + ), + Authentication::Granted(id), + ); +} + +/// Changing the password of an account that does not exist writes nothing. +pub async fn changing_a_password_for_an_absent_account_writes_nothing(h: &dyn Harness) { + let stranger = user("absent", 7); + assert_eq!( + ok( + h.passwords() + .set_password(&stranger, "irrelevant", Timestamp::UNIX_EPOCH) + .await, + "replace a stranger's password", + ), + PasswordChanged::NoSuchAccount, + ); + assert!( + ok(h.profiles().read(&stranger).await, "read a profile").is_none(), + "a password change must not create the account it could not find" + ); +} + +// =========================================================================================== +// Profiles +// =========================================================================================== + +/// A profile reads back what registration wrote, and takes an edit. +pub async fn a_profile_reads_back_what_registration_wrote_and_takes_an_edit(h: &dyn Harness) { + let (address, id) = seed(h, "profile").await; + let profile: ProfileRecord = present( + ok(h.profiles().read(&id).await, "read a profile"), + "the account's profile", + ); + assert_eq!(profile.user_id, id); + assert_eq!(profile.email, address); + assert_eq!(profile.display_name, None); + assert_eq!(profile.created_at, Timestamp::UNIX_EPOCH); + + let updated = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(Some("Ada Lovelace".to_owned())), + }, + ) + .await, + "update a profile", + ), + "the updated profile", + ); + assert_eq!(updated.display_name.as_deref(), Some("Ada Lovelace")); +} + +/// An absent field leaves the name alone; `Some(None)` clears it. +/// +/// The whole reason the field is a nested option: a flat one cannot express "leave it alone", so +/// every partial update would clear the name nobody mentioned. +pub async fn an_absent_field_and_a_cleared_one_are_different_updates(h: &dyn Harness) { + let (_, id) = seed(h, "nested").await; + let named = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(Some("Grace Hopper".to_owned())), + }, + ) + .await, + "set a display name", + ), + "the updated profile", + ); + assert_eq!(named.display_name.as_deref(), Some("Grace Hopper")); + + let untouched = present( + ok( + h.profiles().update(&id, &ProfileUpdate::default()).await, + "apply an empty update", + ), + "the profile", + ); + assert_eq!(untouched.display_name.as_deref(), Some("Grace Hopper")); + + let cleared = present( + ok( + h.profiles() + .update( + &id, + &ProfileUpdate { + display_name: Some(None), + }, + ) + .await, + "clear a display name", + ), + "the profile", + ); + assert_eq!(cleared.display_name, None); +} + +/// A profile for an account that does not exist is absent, not an error. +/// +/// Reachable with a perfectly valid credential: a session outlives the account row it names if +/// the account is deleted while a token is live. The route answers `404` rather than `500`, +/// because the server is working correctly and the account is gone. +pub async fn a_profile_for_an_absent_account_is_absent_and_not_an_error(h: &dyn Harness) { + let stranger = user("ghost", 5); + assert!( + ok( + h.profiles().read(&stranger).await, + "read a stranger's profile" + ) + .is_none(), + ); + assert!( + ok( + h.profiles() + .update(&stranger, &ProfileUpdate::default()) + .await, + "update a stranger's profile", + ) + .is_none(), + ); +} + +/// Unwrap the harness's own seam, which speaks `StoreError` rather than `DirectoryError`. +#[track_caller] +fn ok_store(result: Result<(), crate::store::StoreError>) { + if let Err(error) = result { + panic!("a conforming harness must be able to move its own clock: {error}"); + } +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + registering_then_signing_in_works(h).await; + a_taken_address_is_reported_and_nothing_is_written(h).await; + an_unknown_address_and_a_wrong_password_are_one_answer(h).await; + addresses_are_compared_verbatim(h).await; + re_authentication_takes_the_account_from_the_credential(h).await; + + enough_failures_lock_the_account_and_a_correct_password_is_told_so(h).await; + a_success_before_the_ceiling_clears_the_count(h).await; + a_lockout_decays_because_nothing_else_can_clear_it(h).await; + attempts_during_a_lockout_do_not_extend_it(h).await; + failures_spread_wider_than_the_window_never_accumulate(h).await; + + a_password_change_grants_the_new_password_and_refuses_the_old(h).await; + a_password_change_clears_a_lockout(h).await; + changing_a_password_for_an_absent_account_writes_nothing(h).await; + + a_profile_reads_back_what_registration_wrote_and_takes_an_edit(h).await; + an_absent_field_and_a_cleared_one_are_different_updates(h).await; + a_profile_for_an_absent_account_is_absent_and_not_an_error(h).await; +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jiff::SignedDuration; + + use super::{Harness, run_all}; + use crate::auth::accounts_memory::{InMemoryAccounts, MAX_FAILED_ATTEMPTS}; + use crate::auth::credential::Credentials; + use crate::auth::directory::AccountDirectory; + use crate::auth::profile::{AccountProfiles, PasswordChange}; + use crate::auth::registry::AccountRegistry; + use crate::store::StoreFuture; + use crate::store::memory::ManualClock; + + /// A fifteen-minute lockout window, as a deployment gets by default. + const WINDOW: SignedDuration = SignedDuration::from_mins(15); + + /// The deterministic double, on a clock the suite drives. + #[derive(Debug)] + struct MemoryHarness { + accounts: InMemoryAccounts, + clock: Arc, + } + + impl Harness for MemoryHarness { + fn registry(&self) -> &dyn AccountRegistry { + &self.accounts + } + + fn directory(&self) -> &dyn AccountDirectory { + &self.accounts + } + + fn profiles(&self) -> &dyn AccountProfiles { + &self.accounts + } + + fn passwords(&self) -> &dyn PasswordChange { + &self.accounts + } + + fn lockout_attempts(&self) -> u32 { + MAX_FAILED_ATTEMPTS + } + + fn lockout_window(&self) -> SignedDuration { + WINDOW + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok(()) + }) + } + } + + fn harness() -> MemoryHarness { + let clock = Arc::new(ManualClock::default()); + MemoryHarness { + accounts: InMemoryAccounts::new( + Credentials::new().expect("the platform hashes"), + clock.clone(), + MAX_FAILED_ATTEMPTS, + WINDOW, + ), + clock, + } + } + + /// The development profile's adapter is only a legitimate stand-in for Postgres to the + /// extent it passes this. + /// + /// One test rather than one per case, unlike [`crate::store::conformance`]'s: every case here + /// registers an account, which costs an Argon2id hash at the server-side parameter set, and + /// sixteen fresh harnesses would pay for sixteen decoys on top. The suite shares one harness + /// and every case scopes its own addresses, which is what makes that safe. + #[tokio::test] + async fn the_in_memory_account_store_conforms() { + run_all(&harness()).await; + } +} diff --git a/capsule-server/src/auth/credential.rs b/capsule-server/src/auth/credential.rs new file mode 100644 index 00000000..ddf906e5 --- /dev/null +++ b/capsule-server/src/auth/credential.rs @@ -0,0 +1,257 @@ +//! [`Credentials`] — the one place this server hashes and checks a password. +//! +//! # Why a helper and not a method on each adapter +//! +//! Three ports oblige their adapter to own credential verification end to end: +//! [`AccountDirectory`](super::AccountDirectory) verifies, +//! [`AccountRegistry`](super::AccountRegistry) hashes, and +//! [`PasswordChange`](super::PasswordChange) re-hashes. Their docs say why — a password hash +//! that crossed the port boundary would be a secret in a type that does not know it is one, and +//! it would put Argon2id's parameters in the routing layer, where a second call site can get +//! them subtly wrong. +//! +//! What that leaves is an obligation each *adapter* has to discharge identically. The in-memory +//! adapter beside this file discharges it, and the Postgres adapter (#402) discharges the same +//! one; written twice they would be two answers to a question with one right answer, and the +//! first divergence would be a parameter set — the thing that is invisible until somebody's +//! password is cheap to crack. So the algorithm is here, once, and an adapter owns *where the +//! hash is kept* rather than *what a hash is*. +//! +//! # Argon2id, at the crate's own defaults +//! +//! `Argon2::default()` is Argon2id, version 0x13, m=19456 KiB, t=2, p=1 — the parameter set the +//! RustCrypto crate publishes as its recommendation. Not the tiered parameters +//! [`capsule_core::crypto::pwkdf`] uses: those describe a *key derivation* that has to run on +//! the weakest device that will ever unwrap the blob, and this is a server-side verification +//! whose cost is paid on the server. The two are deliberately unrelated numbers, and the +//! parameters ride inside every PHC string this writes, so raising them is not a flag day. +//! +//! # The timing-equalized miss +//! +//! [`AccountDirectory`](super::AccountDirectory)'s contract is that no caller can tell an +//! unknown account from a wrong password. Returning early for an unknown address would leak +//! that difference in the response *time* whatever the body said, so an adapter must still do +//! the work — [`Credentials::absorb_miss`] is that work, verifying against a decoy hash +//! computed once at construction and discarding the answer. + +use std::fmt; + +use argon2::Argon2; +use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString}; + +/// How many bytes of salt every hash carries. +/// +/// Sixteen, which is what the PHC specification recommends and what `Argon2::default()` would +/// have generated. Longer buys nothing: the salt is a uniqueness device, not a secret. +const SALT_LEN: usize = 16; + +/// The password the decoy hash is built over. +/// +/// A constant, and it does not matter what it is: [`Credentials::absorb_miss`] never compares +/// against it successfully, and the only property required of the decoy is that verifying +/// against it costs what verifying against a real hash costs. +const DECOY_PASSWORD: &[u8] = b"capsule/decoy/there-is-no-such-account"; + +/// Something went wrong hashing or reading a credential. +/// +/// Never carries the password, the hash, or any part of either: this error is logged, and a +/// library that put a PHC string in a log line would put every account's salt there with it. +#[derive(Debug, thiserror::Error)] +#[error("a stored credential could not be processed: {detail}")] +pub struct CredentialError { + /// The algorithm's own description of the failure. + pub detail: String, +} + +/// Hashing and checking passwords, at one parameter set. +/// +/// `Debug` is hand-written and names the algorithm rather than the state, because the state +/// includes a hash. +#[derive(Clone)] +pub struct Credentials { + argon: Argon2<'static>, + /// A real Argon2id hash of a password nobody has, so a lookup that found nothing can cost + /// what a lookup that found something costs. + decoy: String, +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field("algorithm", &"argon2id") + .finish_non_exhaustive() + } +} + +impl Credentials { + /// A verifier at the crate's default Argon2id parameters. + /// + /// Pays for one hash — the decoy — so that no request ever has to. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the platform cannot produce a hash at all, which is a + /// startup failure rather than a request failure: a server that cannot hash a password + /// cannot authenticate anybody and must refuse to start. + pub fn new() -> Result { + let argon = Argon2::default(); + let decoy = hash_with(&argon, DECOY_PASSWORD)?; + Ok(Self { argon, decoy }) + } + + /// The PHC string to store for `password`. + /// + /// A fresh random salt every time, so two accounts with the same password have different + /// stored hashes and a stolen table cannot be attacked once for both. + /// + /// # Errors + /// + /// Returns [`CredentialError`] if the hash cannot be computed. + pub fn hash(&self, password: &str) -> Result { + hash_with(&self.argon, password.as_bytes()) + } + + /// Whether `password` is the one `stored` was made from. + /// + /// A wrong password is `Ok(false)`, not an error: a refused credential is a normal answer to + /// a normal question, and modelling it as a failure is what leads to a `?` that turns a + /// sign-in rejection into a 500. + /// + /// # Errors + /// + /// Returns [`CredentialError`] only when `stored` is not a PHC string this server can read — + /// a corrupted row, not a wrong password. + pub fn verify(&self, password: &str, stored: &str) -> Result { + let parsed = PasswordHash::new(stored).map_err(|error| CredentialError { + detail: format!("the stored hash is not a readable PHC string ({error})"), + })?; + match self.argon.verify_password(password.as_bytes(), &parsed) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(error) => Err(CredentialError { + detail: error.to_string(), + }), + } + } + + /// Spend what a verification costs, having found no account to verify against. + /// + /// The timing-equalized miss; see the module docs. The result is deliberately discarded — + /// there is nothing to learn from it, and a caller that branched on it would be branching + /// on whether a decoy password happens to be somebody's. + pub fn absorb_miss(&self, password: &str) { + let _ = self.verify(password, &self.decoy); + } +} + +/// Hash `password` under `argon` with a fresh salt. +fn hash_with(argon: &Argon2<'static>, password: &[u8]) -> Result { + let mut bytes = [0u8; SALT_LEN]; + // `ring`'s CSPRNG rather than `password_hash`'s optional `rand` feature: it is already this + // crate's source of randomness and its key generator, so one binary has one CSPRNG. + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut bytes).map_err( + |error| CredentialError { + detail: format!("the platform could not produce a salt ({error})"), + }, + )?; + let salt = SaltString::encode_b64(&bytes).map_err(|error| CredentialError { + detail: format!("the salt could not be encoded ({error})"), + })?; + Ok(argon + .hash_password(password, &salt) + .map_err(|error| CredentialError { + detail: error.to_string(), + })? + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::Credentials; + + /// One instance for the whole module: `Credentials::new` pays for an Argon2id hash, and + /// paying for it once per test is the difference between a fast suite and a slow one. + fn credentials() -> Credentials { + Credentials::new().expect("the platform hashes") + } + + #[test] + fn the_password_it_hashed_is_the_password_it_accepts() { + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + credentials + .verify("correct horse battery staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn a_wrong_password_is_a_refusal_and_not_an_error() { + // The distinction the port is built on: a refused credential is an answer, so a route + // cannot accidentally `?` it into a 500. + let credentials = credentials(); + let stored = credentials + .hash("correct horse battery staple") + .expect("it hashes"); + assert!( + !credentials + .verify("Correct Horse Battery Staple", &stored) + .expect("it reads") + ); + } + + #[test] + fn the_stored_hash_is_a_phc_string_naming_argon2id_and_never_the_password() { + let credentials = credentials(); + let stored = credentials + .hash("a password worth protecting") + .expect("it hashes"); + assert!(stored.starts_with("$argon2id$"), "{stored}"); + assert!(!stored.contains("a password worth protecting"), "{stored}"); + } + + #[test] + fn two_accounts_with_one_password_do_not_share_a_hash() { + // A fresh salt per hash, which is what stops one offline attack from covering both. + let credentials = credentials(); + let first = credentials.hash("shared").expect("it hashes"); + let second = credentials.hash("shared").expect("it hashes"); + assert_ne!(first, second); + assert!(credentials.verify("shared", &first).expect("it reads")); + assert!(credentials.verify("shared", &second).expect("it reads")); + } + + #[test] + fn a_corrupted_stored_hash_is_a_fault_and_not_a_refusal() { + // It must not read as "your password is wrong", which would send somebody round a loop + // that cannot succeed. + let credentials = credentials(); + assert!(credentials.verify("anything", "not a PHC string").is_err()); + } + + #[test] + fn absorbing_a_miss_costs_what_a_verification_costs() { + // Not a timing assertion — those are flaky by nature. What is asserted is that the + // decoy is a real hash the verifier reads, so the work actually happens: a decoy that + // failed to parse would return in microseconds and leak the account oracle the port + // exists to close. + let credentials = credentials(); + credentials.absorb_miss("anything at all"); + assert!( + !credentials + .verify("anything at all", &credentials.decoy) + .expect("the decoy parses") + ); + } + + #[test] + fn debug_names_the_algorithm_and_prints_no_hash() { + let credentials = credentials(); + let rendered = format!("{credentials:?}"); + assert!(rendered.contains("argon2id"), "{rendered}"); + assert!(!rendered.contains('$'), "{rendered}"); + } +} diff --git a/capsule-server/src/auth/mod.rs b/capsule-server/src/auth/mod.rs index dad5adb2..b936bdee 100644 --- a/capsule-server/src/auth/mod.rs +++ b/capsule-server/src/auth/mod.rs @@ -28,15 +28,30 @@ //! constructor that will eventually be got wrong positionally, and two `Arc` swapped at a //! call site is a compile error only by luck. //! -//! # Adapters this slice does not write +//! # Adapters, and the one that is still owed //! -//! There is no [`AccountDirectory`] implementation in `src/`, and that is deliberate rather than -//! unfinished: the real one is Postgres, the test one is a double, and a double in `src/` is a -//! fake credential directory shipped inside the server binary. The suite's doubles live in -//! `tests/support/`. Same reasoning, one step further than `S-C29` took it for the session -//! store, and it is why [`SessionTokens`] is not a trait at all. +//! [`InMemoryAccounts`] implements all four account ports over a map, verifying with the +//! [`credential`] helper's Argon2id — so the development profile can register an account and +//! sign in to it — and [`InMemoryTotp`] implements the second factor's. Neither is durable, and +//! neither is reachable without `--memory` +//! ([`Backends::Memory`](crate::config::Backends)), which is an explicit operator act. +//! +//! What is deliberately **not** here is a permissive one. `tests/support/mod.rs` holds a +//! credential directory that "accepts whatever password it was told to accept", and its own docs +//! say why that "belongs in a test binary and nowhere a server could link it". The distinction +//! the port modules were drawing is between a double and an implementation, not between +//! Postgres and everything else. +//! +//! The Postgres adapters are owed (#402), and they are written against these ports and the +//! suites over them. [`SessionTokens`] is not a trait at all, for the reason `credential` +//! records: it is a pure function of a key and a clock. +pub mod accounts_memory; +pub mod accounts_postgres; +pub mod conformance; +pub mod credential; pub mod directory; +pub mod oidc; pub mod profile; pub mod registry; pub mod scheme; @@ -45,6 +60,9 @@ pub mod totp; use std::sync::Arc; +pub use self::accounts_memory::InMemoryAccounts; +pub use self::accounts_postgres::PostgresAccounts; +pub use self::credential::{CredentialError, Credentials}; pub use self::directory::{AccountDirectory, Authentication, DirectoryError, DirectoryFuture}; pub use self::profile::{ AccountProfiles, MAX_DISPLAY_NAME_CHARS, MalformedProfile, PasswordChange, PasswordChanged, @@ -57,8 +75,8 @@ pub use self::tokens::{ TokenError, TokenKind, VerifiedChallenge, VerifiedToken, }; pub use self::totp::{ - ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, TotpCodes, - TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, + ActivateOutcome, BeginOutcome, CHALLENGE_TTL, ConsumeOutcome, EnrollmentState, InMemoryTotp, + TotpCodes, TotpContext, TotpEnrollment, TotpSecret, TotpStore, UnusableSecret, }; use crate::store::{AuthStateStore, Clock}; diff --git a/capsule-server/src/auth/oidc/accounts.rs b/capsule-server/src/auth/oidc/accounts.rs new file mode 100644 index 00000000..6082aaa9 --- /dev/null +++ b/capsule-server/src/auth/oidc/accounts.rs @@ -0,0 +1,369 @@ +//! [`FederatedAccounts`] — which Capsule account a verified identity is. +//! +//! # One method, one atomic operation +//! +//! `resolve_or_create` is keyed on `(issuer, subject)`: the pair OpenID Connect Core §2 makes +//! stable for a person at a provider. It looks the pair up and, absent, creates the account — in +//! **one** operation, for the reason [`AccountRegistry::create`](crate::auth::AccountRegistry) +//! is one: a caller that read, saw nothing and then wrote has a window in which a second callback +//! for the same person lands, and both would believe they created the account. +//! +//! # Not a method on [`AccountRegistry`](crate::auth::AccountRegistry) +//! +//! That port's own docs forbid it — *"a port with a second method is a port that will have +//! six"* — and the two are different operations besides: `create` takes a password it hashes, +//! and an account created here must have **none**. The adapter contract states it as an +//! invariant rather than a convention: an account row whose credential is null makes +//! [`AccountDirectory::authenticate`](crate::auth::AccountDirectory) return `Refused`, never +//! `Granted`. No password an attacker could guess exists for an OIDC account, because no +//! password exists. +//! +//! # No linking by address +//! +//! An unknown `(issuer, subject)` whose asserted `email` already belongs to an account answers +//! [`FederatedLink::AddressTaken`], which the route renders as `409 error.auth.oidc_address_taken` +//! — never a link. The address is a claim the provider controls; honouring it as a link key would +//! hand the matching local account to anyone who can set an email at the provider. The disclosure +//! the `409` makes is the one `error.auth.user_already_exists` already makes at registration, so +//! it adds no new oracle. Deliberately linking an existing account to a provider identity is a +//! separate, authenticated ceremony, and is out of scope. +//! +//! # Only a verified address is reserved, or compared +//! +//! An address the provider asserts **without** `email_verified` is one anybody at that provider +//! could have typed. Reserving it would let a person register an unverified address at the +//! provider and thereby block the address's real owner from ever signing in here — a targeted +//! denial of service costing one sign-up — so an unverified address is carried on the identity +//! and otherwise ignored: it reserves nothing and collides with nothing. That makes +//! [`VerifiedIdentity::email_verified`] the one production reader of the claim. It is the +//! interim rule until #460 folds federated rows into the one account table, where the address +//! is the verified one a password account registered with. +//! +//! # The in-memory adapter holds its own rows +//! +//! [`InMemoryFederatedAccounts`] is the development profile's adapter and it does **not** share +//! rows with [`InMemoryAccounts`](crate::auth::InMemoryAccounts), the password directory. That is +//! a gap, recorded in issue #460 rather than papered over: under `serve --memory` an identity +//! whose address matches a password account is not refused, and an OIDC account has no profile +//! row. The Postgres adapter is written over one account table, where both properties hold. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use jiff::Timestamp; + +use super::claims::VerifiedIdentity; +use super::provider::Disabled; +use crate::auth::{DirectoryError, DirectoryFuture}; +use crate::store::UserId; + +/// What resolving an identity did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FederatedLink { + /// The pair was known, and this is the account it names. + Linked(UserId), + /// The pair was new; an account was created under the id the caller minted. + Created(UserId), + /// The pair was new and its asserted address already belongs to an account. Nothing written. + AddressTaken, +} + +/// Which account a verified provider identity is. +pub trait FederatedAccounts: fmt::Debug + Send + Sync { + /// The account for `identity`, created under `user` at `at` if it does not exist. + /// + /// The id is minted **above** this port for the reason `AccountRegistry::create` takes one: + /// it is a fact about the server's clock rather than about the backend. + fn resolve_or_create<'a>( + &'a self, + identity: &'a VerifiedIdentity, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, FederatedLink>; +} + +impl FederatedAccounts for Disabled { + fn resolve_or_create<'a>( + &'a self, + _identity: &'a VerifiedIdentity, + _user: &'a UserId, + _at: Timestamp, + ) -> DirectoryFuture<'a, FederatedLink> { + Box::pin(async { + Err(DirectoryError::Unavailable { + detail: "no identity provider is configured".to_owned(), + }) + }) + } +} + +/// One federated account, as this adapter holds it. +#[derive(Debug, Clone)] +struct Row { + user_id: UserId, + #[allow( + dead_code, + reason = "held for the profile row the Postgres adapter will expose" + )] + created_at: Timestamp, +} + +#[derive(Debug, Default)] +struct Held { + /// `(issuer, subject)` → the account. + links: BTreeMap<(String, String), Row>, + /// Address → the account that asserted it first. + addresses: BTreeMap, +} + +/// Federated accounts held in this process. +/// +/// See the module docs for what it does and does not share with the password directory. +#[derive(Debug, Default)] +pub struct InMemoryFederatedAccounts { + held: Mutex, +} + +impl InMemoryFederatedAccounts { + /// An empty directory. + pub fn new() -> Self { + Self::default() + } + + /// How many federated accounts are held. + pub fn len(&self) -> usize { + self.held().links.len() + } + + /// Whether none is held yet. + pub fn is_empty(&self) -> bool { + self.held().links.is_empty() + } + + fn held(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl FederatedAccounts for InMemoryFederatedAccounts { + fn resolve_or_create<'a>( + &'a self, + identity: &'a VerifiedIdentity, + user: &'a UserId, + at: Timestamp, + ) -> DirectoryFuture<'a, FederatedLink> { + Box::pin(async move { + // One critical section, as the port requires. + let mut held = self.held(); + let key = (identity.issuer.clone(), identity.subject.clone()); + if let Some(row) = held.links.get(&key) { + return Ok(FederatedLink::Linked(row.user_id.clone())); + } + // Verified addresses only, both ways: an unverified one neither blocks nor reserves. + let verified_address = identity + .email + .as_deref() + .filter(|_| identity.email_verified); + if let Some(email) = verified_address + && held.addresses.contains_key(email) + { + tracing::info!(issuer = %identity.issuer, "a federated sign-in asserted an address another account holds"); + return Ok(FederatedLink::AddressTaken); + } + if let Some(email) = verified_address { + held.addresses.insert(email.to_owned(), user.clone()); + } + held.links.insert( + key, + Row { + user_id: user.clone(), + created_at: at, + }, + ); + tracing::info!(%user, issuer = %identity.issuer, "created an account for a federated identity"); + Ok(FederatedLink::Created(user.clone())) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(subject: &str, email: Option<&str>) -> VerifiedIdentity { + VerifiedIdentity { + issuer: "https://idp.example.test".to_owned(), + subject: subject.to_owned(), + email: email.map(str::to_owned), + email_verified: true, + } + } + + fn user(tag: &str) -> UserId { + UserId::new(format!("user-{tag}")) + } + + #[tokio::test] + async fn the_first_sign_in_creates_and_the_second_links_the_same_account() { + let accounts = InMemoryFederatedAccounts::new(); + let first = accounts + .resolve_or_create( + &identity("sub-1", Some("a@example.test")), + &user("1"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"); + assert_eq!(first, FederatedLink::Created(user("1"))); + + // A different minted id: the existing link wins, and the new id is discarded. + let second = accounts + .resolve_or_create( + &identity("sub-1", Some("a@example.test")), + &user("2"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"); + assert_eq!(second, FederatedLink::Linked(user("1"))); + assert_eq!(accounts.len(), 1); + } + + #[tokio::test] + async fn the_same_subject_at_another_issuer_is_another_person() { + let accounts = InMemoryFederatedAccounts::new(); + accounts + .resolve_or_create(&identity("sub-1", None), &user("1"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"); + let mut elsewhere = identity("sub-1", None); + elsewhere.issuer = "https://other.example.test".to_owned(); + assert_eq!( + accounts + .resolve_or_create(&elsewhere, &user("2"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"), + FederatedLink::Created(user("2")) + ); + } + + #[tokio::test] + async fn an_asserted_address_another_account_holds_is_refused_and_nothing_is_written() { + let accounts = InMemoryFederatedAccounts::new(); + accounts + .resolve_or_create( + &identity("sub-1", Some("a@example.test")), + &user("1"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"); + assert_eq!( + accounts + .resolve_or_create( + &identity("sub-2", Some("a@example.test")), + &user("2"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"), + FederatedLink::AddressTaken + ); + assert_eq!(accounts.len(), 1, "the refused identity created nothing"); + // And it is still refused, rather than having been half-linked. + assert_eq!( + accounts + .resolve_or_create( + &identity("sub-2", Some("a@example.test")), + &user("3"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"), + FederatedLink::AddressTaken + ); + } + + #[tokio::test] + async fn an_unverified_address_neither_blocks_nor_reserves() { + // A person who registers somebody else's address at the provider, unverified, must not + // be able to lock that person out of signing in here. + let accounts = InMemoryFederatedAccounts::new(); + let mut squatter = identity("squatter", Some("owner@example.test")); + squatter.email_verified = false; + assert_eq!( + accounts + .resolve_or_create(&squatter, &user("1"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"), + FederatedLink::Created(user("1")), + "the squatter gets an account of their own" + ); + // The real owner, verified, is not blocked. + assert_eq!( + accounts + .resolve_or_create( + &identity("owner", Some("owner@example.test")), + &user("2"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"), + FederatedLink::Created(user("2")) + ); + // And the reserved, verified address now blocks a *different* unverified claimant? No: + // an unverified claim is never compared either, so it is created rather than refused. + let mut another = identity("another", Some("owner@example.test")); + another.email_verified = false; + assert_eq!( + accounts + .resolve_or_create(&another, &user("3"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"), + FederatedLink::Created(user("3")) + ); + // A verified claim on the reserved address is what the 409 exists for. + assert_eq!( + accounts + .resolve_or_create( + &identity("impersonator", Some("owner@example.test")), + &user("4"), + Timestamp::UNIX_EPOCH, + ) + .await + .expect("answers"), + FederatedLink::AddressTaken + ); + } + + #[tokio::test] + async fn an_identity_without_an_address_is_an_account_too() { + let accounts = InMemoryFederatedAccounts::new(); + assert_eq!( + accounts + .resolve_or_create(&identity("sub-1", None), &user("1"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"), + FederatedLink::Created(user("1")) + ); + assert_eq!( + accounts + .resolve_or_create(&identity("sub-2", None), &user("2"), Timestamp::UNIX_EPOCH) + .await + .expect("answers"), + FederatedLink::Created(user("2")), + "two addressless identities do not collide on the absent address" + ); + } + + #[tokio::test] + async fn the_disabled_directory_refuses() { + assert!( + Disabled + .resolve_or_create(&identity("sub-1", None), &user("1"), Timestamp::UNIX_EPOCH) + .await + .is_err() + ); + } +} diff --git a/capsule-server/src/auth/oidc/claims.rs b/capsule-server/src/auth/oidc/claims.rs new file mode 100644 index 00000000..52f50d0a --- /dev/null +++ b/capsule-server/src/auth/oidc/claims.rs @@ -0,0 +1,790 @@ +//! [`verify_id_token`] — every check an ID token has to pass, as one pure function. +//! +//! # No I/O, no clock, no configuration +//! +//! The function takes the token, the key set it must verify under, what the relying party +//! expects, and the instant to judge expiry against. Nothing is fetched and nothing is read +//! from the environment, which is what lets every negative case here be a unit test with no +//! socket: a foreign key, a wrong audience, an expired token and a replayed nonce are each a +//! few lines against a key generated in the test. +//! +//! # Why the checks are Capsule's and not `jsonwebtoken`'s +//! +//! `jsonwebtoken` can validate `exp`, `iss` and `aud` itself, and it is deliberately asked to +//! do **only the signature**. Its temporal checks read the system clock, which would put the one +//! part of this module that has to be deterministic in tests behind a clock a test cannot move — +//! and each of the claim checks below is a security decision this repository documents, so it +//! is written where a reader can see it rather than delegated to a struct of booleans. +//! +//! # One answer on the wire, many in the log +//! +//! [`ClaimRejection`] names the check that failed, for the operator. The route collapses every +//! variant to one `error.auth.oidc_token_invalid`, so the callback is not an oracle over which +//! checks the relying party runs; the distinction that *does* reach a caller is between a token +//! that failed and an identity provider that could not be reached, which are different remedies. + +use std::collections::HashSet; + +use jiff::Timestamp; +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use serde::Deserialize; + +/// The signature algorithms an ID token may carry. +/// +/// Asymmetric only. `none` is refused by `jsonwebtoken` before it reaches here, and the HMAC +/// family is refused here because a JWKS can carry an `oct` key — and an ID token "signed" with +/// a symmetric key the provider published is a token anyone could have minted. +pub const ALLOWED_ALGORITHMS: [Algorithm; 3] = + [Algorithm::RS256, Algorithm::ES256, Algorithm::EdDSA]; + +/// How far the relying party's clock may disagree with the provider's, in seconds. +/// +/// Sixty, applied symmetrically to `exp`, `nbf` and `iat`. Generous enough for an unsynchronized +/// virtual machine, tight enough that a token is not honoured minutes after its provider said to +/// stop. +pub const CLOCK_SKEW_SECONDS: i64 = 60; + +/// The longest `sub` the relying party will store. +/// +/// OpenID Connect Core §2 bounds `sub` at 255 ASCII characters; a longer one is a provider +/// that is not conforming, and an unbounded value is a column nothing sized. +pub const MAX_SUBJECT_LENGTH: usize = 255; + +/// The most of a provider-supplied string a log line or an error will carry. +/// +/// A token's `iss`, its `kid` and a token endpoint's `error_description` all come from the +/// other side of the wire and all end up in a `WARN`. Bounded here, at construction, so a +/// provider that answers with a megabyte cannot put a megabyte in the log. +pub const MAX_QUOTED_BYTES: usize = 255; + +/// `text` cut to [`MAX_QUOTED_BYTES`] on a character boundary, with a marker when it was cut. +#[must_use] +pub fn bounded(text: &str) -> String { + if text.len() <= MAX_QUOTED_BYTES { + return text.to_owned(); + } + let mut end = MAX_QUOTED_BYTES; + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &text[..end]) +} + +/// What the relying party expects the token to say about itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Expectations { + /// The configured issuer, compared for exact string equality with `iss`. + pub issuer: String, + /// This relying party's `client_id`; `aud` must contain it and `azp`, if present, must be it. + pub client_id: String, + /// The nonce the authorization request carried; the token must echo it exactly. + pub nonce: String, +} + +/// The facts a verified ID token establishes about a person. +/// +/// `sub` and `iss` together are the account's federated key. The address is carried for the +/// one decision the route makes with it — refusing to create a second account for an address +/// that already has one — and is never a link key; see `auth::oidc::accounts`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedIdentity { + /// The issuer the token verified under, exactly as configured. + pub issuer: String, + /// The provider's stable identifier for the person. Never an address. + pub subject: String, + /// The address the provider asserted, if it asserted one. + pub email: Option, + /// Whether the provider says it verified that address. + pub email_verified: bool, +} + +/// Why an ID token was refused. +/// +/// Logged at `WARN` with its detail; rendered on the wire as one code. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ClaimRejection { + /// The token is not a compact JWS, or its header does not parse. + #[error("the ID token is not a well-formed JWS: {detail}")] + Malformed { + /// The parser's own description. + detail: String, + }, + /// The header names an algorithm outside [`ALLOWED_ALGORITHMS`]. + #[error("the ID token is signed with {algorithm}, which this relying party refuses")] + AlgorithmRefused { + /// The header's `alg`, as written. + algorithm: String, + }, + /// The header carries a non-empty `crit` (RFC 7515 §4.1.11). + /// + /// `crit` names extensions the recipient **must** understand or reject the token. This + /// relying party understands none, so the only conforming answer is to refuse — and a + /// forger who could make the verifier skip a header it does not know would have exactly + /// the seam `crit` exists to close. + #[error("the ID token names critical header extensions this relying party does not implement")] + CriticalHeader, + /// The header names a key the set does not hold. + /// + /// The one rejection a caller acts on rather than logs: it is what triggers a JWKS refetch, + /// because a provider that rotated its keys announces the fact this way. + #[error("the ID token names key {kid:?}, which the key set does not hold")] + UnknownKey { + /// The header's `kid`, or `None` when the header carries none. + kid: Option, + }, + /// The key the header named cannot be used to verify — a symmetric key, or one whose + /// parameters do not parse. + #[error("the key the ID token names cannot verify an asymmetric signature: {detail}")] + UnusableKey { + /// What was wrong with it. + detail: String, + }, + /// The signature does not verify under the named key. + #[error("the ID token's signature does not verify")] + Signature, + /// The claims are not the shape OpenID Connect Core §2 requires. + #[error("the ID token's claims are not usable: {detail}")] + Claims { + /// What was missing or malformed. + detail: String, + }, + /// `iss` is not the configured issuer. + #[error("the ID token was issued by {found:?}, not by the configured issuer")] + Issuer { + /// The token's `iss`. + found: String, + }, + /// `aud` does not contain this relying party. + #[error("the ID token is not addressed to this relying party")] + Audience, + /// `azp` is present and is not this relying party — or `aud` names several audiences and + /// `azp` is absent, which OpenID Connect Core §3.1.3.7 rule 4 says it must not be. + #[error("the ID token's authorized party is not this relying party")] + AuthorizedParty, + /// `exp` is in the past, beyond the skew. + #[error("the ID token expired at {expired_at}")] + Expired { + /// The token's `exp`. + expired_at: Timestamp, + }, + /// `nbf` or `iat` is in the future, beyond the skew. + #[error("the ID token is not valid before {valid_from}")] + NotYetValid { + /// The later of `nbf` and `iat`. + valid_from: Timestamp, + }, + /// `nonce` is absent or is not the one the authorization request carried. + #[error("the ID token's nonce is not the one this ceremony issued")] + Nonce, + /// `sub` is empty or over [`MAX_SUBJECT_LENGTH`]. + #[error("the ID token's subject is not usable")] + Subject, +} + +/// The claims an ID token carries, as this relying party reads them. +/// +/// `aud` is deserialized from either a string or an array, which OpenID Connect Core §2 allows. +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + iss: String, + sub: String, + #[serde(default, deserialize_with = "one_or_many")] + aud: Vec, + exp: i64, + #[serde(default)] + iat: Option, + #[serde(default)] + nbf: Option, + #[serde(default)] + nonce: Option, + #[serde(default)] + azp: Option, + #[serde(default)] + email: Option, + #[serde(default)] + email_verified: Option, +} + +/// `aud` as a single string or as an array of them. +fn one_or_many<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + Ok(match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(one) => vec![one], + OneOrMany::Many(many) => many, + }) +} + +/// Verify `raw` under `keys` against `expect`, judging time against `now`. +/// +/// The checks run in the order listed in the module docs, and the first failure is the answer: +/// header algorithm, key lookup, signature, then `iss`, `aud`, `azp`, the temporal claims, +/// `nonce`, and `sub`. +/// +/// # Errors +/// +/// Returns the first [`ClaimRejection`] the token trips. +pub fn verify_id_token( + raw: &str, + keys: &JwkSet, + expect: &Expectations, + now: Timestamp, +) -> Result { + let header = jsonwebtoken::decode_header(raw).map_err(|error| ClaimRejection::Malformed { + detail: error.to_string(), + })?; + if !ALLOWED_ALGORITHMS.contains(&header.alg) { + return Err(ClaimRejection::AlgorithmRefused { + algorithm: format!("{:?}", header.alg), + }); + } + if header.crit.as_ref().is_some_and(|crit| !crit.is_empty()) { + return Err(ClaimRejection::CriticalHeader); + } + + // A header without `kid` resolves only when the set holds exactly one key: a provider that + // publishes several and names none has given the verifier nothing to choose on, and trying + // each in turn would let a forger pick the weakest. + let jwk = match header.kid.as_deref() { + Some(kid) => keys.find(kid), + None if keys.keys.len() == 1 => keys.keys.first(), + None => None, + } + .ok_or_else(|| ClaimRejection::UnknownKey { + kid: header.kid.as_deref().map(bounded), + })?; + if matches!(jwk.algorithm, AlgorithmParameters::OctetKey(_)) { + return Err(ClaimRejection::UnusableKey { + detail: "the key is symmetric".to_owned(), + }); + } + if let Some(declared) = jwk.common.key_algorithm + && format!("{declared:?}") != format!("{:?}", header.alg) + { + // A key published for one algorithm and used under another is the substitution attack + // RFC 8725 §3.1 warns about; the header does not get to choose. + return Err(ClaimRejection::UnusableKey { + detail: format!( + "the key is published for {declared:?} and the token claims {:?}", + header.alg + ), + }); + } + let key = DecodingKey::from_jwk(jwk).map_err(|error| ClaimRejection::UnusableKey { + detail: error.to_string(), + })?; + + // Signature only. Every claim below is checked here, against the caller's clock. + let mut validation = Validation::new(header.alg); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + validation.required_spec_claims = HashSet::new(); + let decoded = jsonwebtoken::decode::(raw, &key, &validation).map_err( + |error| match error.kind() { + jsonwebtoken::errors::ErrorKind::InvalidSignature => ClaimRejection::Signature, + jsonwebtoken::errors::ErrorKind::Json(_) + | jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(_) + | jsonwebtoken::errors::ErrorKind::InvalidClaimFormat(_) => ClaimRejection::Claims { + detail: error.to_string(), + }, + jsonwebtoken::errors::ErrorKind::InvalidToken + | jsonwebtoken::errors::ErrorKind::Base64(_) + | jsonwebtoken::errors::ErrorKind::Utf8(_) => ClaimRejection::Malformed { + detail: error.to_string(), + }, + _ => ClaimRejection::UnusableKey { + detail: error.to_string(), + }, + }, + )?; + let claims = decoded.claims; + + if claims.iss != expect.issuer { + return Err(ClaimRejection::Issuer { + found: bounded(&claims.iss), + }); + } + if !claims.aud.contains(&expect.client_id) { + return Err(ClaimRejection::Audience); + } + // Core §3.1.3.7: `azp`, when present, must be us; and when `aud` names several parties it + // must be present, or a token minted for a different client that merely lists us could be + // presented here. + match claims.azp.as_deref() { + Some(azp) if azp != expect.client_id => return Err(ClaimRejection::AuthorizedParty), + None if claims.aud.len() > 1 => return Err(ClaimRejection::AuthorizedParty), + _ => {} + } + + let skew = jiff::SignedDuration::from_secs(CLOCK_SKEW_SECONDS); + let expired_at = instant(claims.exp)?; + if expired_at.checked_add(skew).unwrap_or(Timestamp::MAX) <= now { + return Err(ClaimRejection::Expired { expired_at }); + } + let valid_from = [claims.nbf, claims.iat] + .into_iter() + .flatten() + .map(instant) + .collect::, _>>()? + .into_iter() + .max(); + if let Some(valid_from) = valid_from + && valid_from.checked_sub(skew).unwrap_or(Timestamp::MIN) > now + { + return Err(ClaimRejection::NotYetValid { valid_from }); + } + + // Compared as bytes rather than as strings with a short-circuiting `==` on purpose; the + // nonce is high-entropy and single-use so the timing channel is academic, but the compare + // costs nothing and the habit is worth keeping. + let nonce_matches = claims + .nonce + .as_deref() + .is_some_and(|nonce| constant_time_equal(nonce.as_bytes(), expect.nonce.as_bytes())); + if !nonce_matches { + return Err(ClaimRejection::Nonce); + } + + if claims.sub.is_empty() || claims.sub.len() > MAX_SUBJECT_LENGTH { + return Err(ClaimRejection::Subject); + } + + Ok(VerifiedIdentity { + issuer: claims.iss, + subject: claims.sub, + email: claims + .email + .map(|address| address.trim().to_owned()) + .filter(|address| !address.is_empty()), + email_verified: claims.email_verified.unwrap_or(false), + }) +} + +/// A NumericDate claim as an instant, refusing one outside representable time. +fn instant(seconds: i64) -> Result { + Timestamp::from_second(seconds).map_err(|error| ClaimRejection::Claims { + detail: format!("a NumericDate claim is out of range: {error}"), + }) +} + +/// Byte equality that does not stop at the first difference. +fn constant_time_equal(a: &[u8], b: &[u8]) -> bool { + use subtle::ConstantTimeEq as _; + a.len() == b.len() && bool::from(a.ct_eq(b)) +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use jsonwebtoken::jwk::{ + CommonParameters, EllipticCurve, Jwk, KeyAlgorithm, OctetKeyPairParameters, + OctetKeyPairType, + }; + use jsonwebtoken::{EncodingKey, Header}; + use ring::signature::KeyPair as _; + use serde_json::json; + + use super::*; + + const ISSUER: &str = "https://idp.example.test"; + const CLIENT: &str = "capsule"; + const NONCE: &str = "nonce-1"; + + /// A signing key and the JWK a provider would publish for it. + struct Signer { + kid: &'static str, + encoding: EncodingKey, + public: Vec, + } + + impl Signer { + fn generate(kid: &'static str) -> Self { + let der = + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("the platform generates keys"); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(der.as_ref()) + .expect("a key just generated parses"); + Self { + kid, + encoding: EncodingKey::from_ed_der(der.as_ref()), + public: pair.public_key().as_ref().to_vec(), + } + } + + fn jwk(&self) -> Jwk { + Jwk { + common: CommonParameters { + key_id: Some(self.kid.to_owned()), + key_algorithm: Some(KeyAlgorithm::EdDSA), + ..CommonParameters::default() + }, + algorithm: AlgorithmParameters::OctetKeyPair(OctetKeyPairParameters { + key_type: OctetKeyPairType::OctetKeyPair, + curve: EllipticCurve::Ed25519, + x: URL_SAFE_NO_PAD.encode(&self.public), + }), + } + } + + fn sign(&self, claims: &serde_json::Value) -> String { + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.kid.to_owned()); + jsonwebtoken::encode(&header, claims, &self.encoding).expect("the key signs") + } + } + + fn keys(signers: &[&Signer]) -> JwkSet { + JwkSet { + keys: signers.iter().map(|signer| signer.jwk()).collect(), + } + } + + fn expectations() -> Expectations { + Expectations { + issuer: ISSUER.to_owned(), + client_id: CLIENT.to_owned(), + nonce: NONCE.to_owned(), + } + } + + fn now() -> Timestamp { + Timestamp::from_second(1_700_000_000).expect("in range") + } + + /// Claims a conforming provider would mint for a sign-in that began a minute ago. + fn good_claims() -> serde_json::Value { + json!({ + "iss": ISSUER, + "sub": "subject-1", + "aud": CLIENT, + "exp": now().as_second() + 300, + "iat": now().as_second() - 60, + "nonce": NONCE, + "email": " somebody@example.test ", + "email_verified": true, + }) + } + + fn verify(token: &str, keys: &JwkSet) -> Result { + verify_id_token(token, keys, &expectations(), now()) + } + + #[test] + fn a_conforming_token_yields_the_identity() { + let signer = Signer::generate("k1"); + let identity = verify(&signer.sign(&good_claims()), &keys(&[&signer])).expect("verifies"); + assert_eq!( + identity, + VerifiedIdentity { + issuer: ISSUER.to_owned(), + subject: "subject-1".to_owned(), + email: Some("somebody@example.test".to_owned()), + email_verified: true, + } + ); + } + + #[test] + fn an_audience_array_containing_the_client_is_accepted() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["aud"] = json!(["somebody-else", CLIENT]); + claims["azp"] = json!(CLIENT); + assert!(verify(&signer.sign(&claims), &keys(&[&signer])).is_ok()); + } + + #[test] + fn a_token_signed_by_a_foreign_key_is_refused() { + let ours = Signer::generate("k1"); + let theirs = Signer::generate("k1"); + assert_eq!( + verify(&theirs.sign(&good_claims()), &keys(&[&ours])), + Err(ClaimRejection::Signature) + ); + } + + #[test] + fn an_unknown_kid_is_the_one_rejection_that_asks_for_a_refetch() { + let old = Signer::generate("k1"); + let rotated = Signer::generate("k2"); + assert_eq!( + verify(&rotated.sign(&good_claims()), &keys(&[&old])), + Err(ClaimRejection::UnknownKey { + kid: Some("k2".to_owned()) + }) + ); + // And the same token verifies once the set has caught up. + assert!(verify(&rotated.sign(&good_claims()), &keys(&[&old, &rotated])).is_ok()); + } + + #[test] + fn a_header_without_kid_resolves_only_against_a_single_key() { + let signer = Signer::generate("k1"); + let header = Header::new(Algorithm::EdDSA); + let token = jsonwebtoken::encode(&header, &good_claims(), &signer.encoding).expect("signs"); + assert!(verify(&token, &keys(&[&signer])).is_ok()); + + let other = Signer::generate("k2"); + assert_eq!( + verify(&token, &keys(&[&signer, &other])), + Err(ClaimRejection::UnknownKey { kid: None }), + "two keys and no kid is a choice the verifier must not make" + ); + } + + #[test] + fn a_symmetric_key_in_the_set_cannot_verify_anything() { + let signer = Signer::generate("k1"); + let mut set = keys(&[&signer]); + set.keys[0].algorithm = + AlgorithmParameters::OctetKey(jsonwebtoken::jwk::OctetKeyParameters { + key_type: jsonwebtoken::jwk::OctetKeyType::Octet, + value: URL_SAFE_NO_PAD.encode(b"shared secret"), + }); + assert!(matches!( + verify(&signer.sign(&good_claims()), &set), + Err(ClaimRejection::UnusableKey { .. }) + )); + } + + #[test] + fn an_hmac_token_is_refused_before_any_key_is_consulted() { + let signer = Signer::generate("k1"); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("k1".to_owned()); + let token = jsonwebtoken::encode( + &header, + &good_claims(), + &EncodingKey::from_secret(b"anything"), + ) + .expect("signs"); + assert_eq!( + verify(&token, &keys(&[&signer])), + Err(ClaimRejection::AlgorithmRefused { + algorithm: "HS256".to_owned() + }) + ); + } + + #[test] + fn a_key_published_for_another_algorithm_is_not_used_under_this_one() { + let signer = Signer::generate("k1"); + let mut set = keys(&[&signer]); + set.keys[0].common.key_algorithm = Some(KeyAlgorithm::RS256); + assert!(matches!( + verify(&signer.sign(&good_claims()), &set), + Err(ClaimRejection::UnusableKey { .. }) + )); + } + + #[test] + fn garbage_is_malformed() { + let signer = Signer::generate("k1"); + assert!(matches!( + verify("not.a.jws", &keys(&[&signer])), + Err(ClaimRejection::Malformed { .. }) + )); + } + + #[test] + fn the_issuer_must_match_exactly() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["iss"] = json!("https://idp.example.test/"); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Issuer { + found: "https://idp.example.test/".to_owned() + }), + "a trailing slash is a different issuer; the mix-up defence is exact equality" + ); + } + + #[test] + fn a_token_for_another_client_is_refused() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["aud"] = json!("another-client"); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Audience) + ); + } + + #[test] + fn several_audiences_without_an_authorized_party_are_refused() { + // Core §3.1.3.7 rule 4: a multi-audience token names who it was issued to, or it is + // a token for somebody else that merely lists us. + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["aud"] = json!([CLIENT, "another-client"]); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::AuthorizedParty) + ); + } + + #[test] + fn a_critical_header_extension_is_refused() { + let signer = Signer::generate("k1"); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("k1".to_owned()); + header.crit = Some(vec!["b64".to_owned()]); + let token = jsonwebtoken::encode(&header, &good_claims(), &signer.encoding).expect("signs"); + assert_eq!( + verify(&token, &keys(&[&signer])), + Err(ClaimRejection::CriticalHeader) + ); + // An empty `crit` is a malformed-but-harmless header and is not what the rule is about. + header.crit = Some(Vec::new()); + let token = jsonwebtoken::encode(&header, &good_claims(), &signer.encoding).expect("signs"); + assert!(verify(&token, &keys(&[&signer])).is_ok()); + } + + #[test] + fn provider_supplied_strings_are_bounded_before_they_reach_a_log() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + let long = format!("https://{}.test", "x".repeat(2_000)); + claims["iss"] = json!(long); + match verify(&signer.sign(&claims), &keys(&[&signer])) { + Err(ClaimRejection::Issuer { found }) => { + assert!( + found.len() <= MAX_QUOTED_BYTES + '…'.len_utf8(), + "{}", + found.len() + ); + assert!(found.ends_with('…')); + } + other => panic!("expected an issuer refusal, got {other:?}"), + } + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("k".repeat(2_000)); + let token = jsonwebtoken::encode(&header, &good_claims(), &signer.encoding).expect("signs"); + match verify(&token, &keys(&[&signer])) { + Err(ClaimRejection::UnknownKey { kid: Some(kid) }) => { + assert!(kid.len() <= MAX_QUOTED_BYTES + '…'.len_utf8()); + } + other => panic!("expected an unknown key, got {other:?}"), + } + assert_eq!(bounded("short"), "short"); + // Cut on a character boundary: a multi-byte character straddling the limit is dropped + // whole rather than split. + let cut = bounded(&"é".repeat(200)); + assert!(cut.len() <= MAX_QUOTED_BYTES + '…'.len_utf8()); + assert!(std::str::from_utf8(cut.as_bytes()).is_ok()); + } + + #[test] + fn an_authorized_party_that_is_not_us_is_refused() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["aud"] = json!([CLIENT, "another-client"]); + claims["azp"] = json!("another-client"); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::AuthorizedParty) + ); + } + + #[test] + fn expiry_is_judged_against_the_callers_clock_with_skew() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["exp"] = json!(now().as_second() - CLOCK_SKEW_SECONDS + 1); + assert!( + verify(&signer.sign(&claims), &keys(&[&signer])).is_ok(), + "inside the skew it is still honoured" + ); + claims["exp"] = json!(now().as_second() - CLOCK_SKEW_SECONDS); + assert!(matches!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Expired { .. }) + )); + } + + #[test] + fn a_token_from_the_future_is_refused() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["nbf"] = json!(now().as_second() + CLOCK_SKEW_SECONDS + 1); + assert!(matches!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::NotYetValid { .. }) + )); + let mut claims = good_claims(); + claims["iat"] = json!(now().as_second() + CLOCK_SKEW_SECONDS + 1); + assert!(matches!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::NotYetValid { .. }) + )); + } + + #[test] + fn a_missing_expiry_is_a_claims_failure() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims.as_object_mut().expect("object").remove("exp"); + assert!(matches!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Claims { .. }) + )); + } + + #[test] + fn the_nonce_must_be_the_one_this_ceremony_issued() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["nonce"] = json!("a-nonce-from-another-ceremony"); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Nonce) + ); + claims.as_object_mut().expect("object").remove("nonce"); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Nonce), + "an absent nonce is a replayable token" + ); + } + + #[test] + fn the_subject_is_bounded() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["sub"] = json!(""); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Subject) + ); + claims["sub"] = json!("x".repeat(MAX_SUBJECT_LENGTH + 1)); + assert_eq!( + verify(&signer.sign(&claims), &keys(&[&signer])), + Err(ClaimRejection::Subject) + ); + } + + #[test] + fn an_absent_or_blank_address_is_none_and_unverified_by_default() { + let signer = Signer::generate("k1"); + let mut claims = good_claims(); + claims["email"] = json!(" "); + claims + .as_object_mut() + .expect("object") + .remove("email_verified"); + let identity = verify(&signer.sign(&claims), &keys(&[&signer])).expect("verifies"); + assert_eq!(identity.email, None); + assert!(!identity.email_verified); + } +} diff --git a/capsule-server/src/auth/oidc/discovery.rs b/capsule-server/src/auth/oidc/discovery.rs new file mode 100644 index 00000000..f34c2cbe --- /dev/null +++ b/capsule-server/src/auth/oidc/discovery.rs @@ -0,0 +1,328 @@ +//! Provider metadata — the one document that tells the relying party where everything is. +//! +//! Fetched from `{issuer}/.well-known/openid-configuration` (OpenID Connect Discovery 1.0 §4), +//! **lazily and never at boot**: an identity provider that is down must not stop a server from +//! serving local auth. Cached for [`METADATA_TTL`] and refreshed on the next read after that. +//! +//! # Two refusals, both structural +//! +//! - **The document's `issuer` must equal the configured one.** Discovery 1.0 §4.3 requires it, +//! and it is the mix-up defence: a document fetched from one origin that names another is a +//! provider claiming to be somebody else, and honouring its endpoints would send the client +//! secret and the authorization code wherever it said. +//! - **Every endpoint must be `https`**, unless the issuer itself is a loopback address — the +//! development and test carve-out, stated here rather than left to a flag — and under that +//! carve-out every plain-HTTP endpoint must **itself** be loopback: a loopback issuer whose +//! document names an off-box `token_endpoint` would send the code and the verifier across the +//! network in the clear. A token endpoint reached over plain HTTP is a client secret and an ID +//! token on the wire in the clear. `localhost` is not loopback here, for the reason +//! [`RedirectPolicy`](super::provider::RedirectPolicy) refuses it: a resolver can be made to +//! send it elsewhere (RFC 8252 §8.3). + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use jiff::{SignedDuration, Timestamp}; +use serde::Deserialize; + +use crate::store::Clock; + +/// How long fetched metadata is trusted before it is read again. +/// +/// A day. Providers rotate endpoints rarely and announce it; what changes often — the signing +/// keys — has its own cache with its own trigger ([`super::jwks`]). +pub const METADATA_TTL: SignedDuration = SignedDuration::from_hours(24); + +/// The provider facts this relying party reads. Everything else in the document is ignored. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ProviderMetadata { + /// The issuer the document claims to describe. Must equal the configured one. + pub issuer: String, + /// Where the person is sent to authenticate. + pub authorization_endpoint: String, + /// Where the authorization code is exchanged. + pub token_endpoint: String, + /// Where the signing keys are published. + pub jwks_uri: String, +} + +/// Why metadata could not be used. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DiscoveryError { + /// The document could not be fetched. + #[error("the provider's discovery document could not be fetched: {detail}")] + Unreachable { + /// The transport's own description. + detail: String, + }, + /// The document is not the shape Discovery 1.0 describes. + #[error("the provider's discovery document is not usable: {detail}")] + Malformed { + /// What was wrong with it. + detail: String, + }, + /// The document names an issuer other than the configured one. + #[error("the discovery document names issuer {found:?}, not the configured issuer")] + IssuerMismatch { + /// The `issuer` the document carried. + found: String, + }, + /// An endpoint is not `https`, and the issuer is not a loopback address. + #[error("the provider's {endpoint} is not https: {url}")] + InsecureEndpoint { + /// Which endpoint. + endpoint: &'static str, + /// The URL as published. + url: String, + }, +} + +/// The discovery URL for `issuer`, per Discovery 1.0 §4.1. +/// +/// The issuer's own trailing slash is honoured rather than normalized — `issuer` is compared for +/// exact equality everywhere else, so this is the only place it is manipulated at all. +#[must_use] +pub fn discovery_url(issuer: &str) -> String { + format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ) +} + +/// Whether `issuer` is served from this machine — the one case plain HTTP is admitted. +/// +/// The loopback IP literals only, never `localhost` (RFC 8252 §8.3): a name is resolved, and +/// a resolver can be made to answer with something that is not this machine. +#[must_use] +pub fn is_loopback_issuer(issuer: &str) -> bool { + reqwest::Url::parse(issuer).is_ok_and(|url| { + url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "[::1]" | "::1")) + }) +} + +/// Check a fetched document against the configured `issuer`. +/// +/// Pure, so the two refusals are unit tests. +/// +/// # Errors +/// +/// [`DiscoveryError::IssuerMismatch`] if the document is somebody else's; +/// [`DiscoveryError::InsecureEndpoint`] for an endpoint that is neither `https` nor — under a +/// loopback issuer — itself loopback `http`. +pub fn admit(metadata: ProviderMetadata, issuer: &str) -> Result { + if metadata.issuer != issuer { + return Err(DiscoveryError::IssuerMismatch { + found: super::claims::bounded(&metadata.issuer), + }); + } + let loopback_issuer = is_loopback_issuer(issuer); + for (endpoint, url) in [ + ("authorization_endpoint", &metadata.authorization_endpoint), + ("token_endpoint", &metadata.token_endpoint), + ("jwks_uri", &metadata.jwks_uri), + ] { + let secure = url.starts_with("https://"); + let loopback = loopback_issuer && is_loopback_issuer(url); + if !secure && !loopback { + return Err(DiscoveryError::InsecureEndpoint { + endpoint, + url: super::claims::bounded(url), + }); + } + } + Ok(metadata) +} + +/// One fetched document and when it was fetched. +#[derive(Debug)] +struct Cached { + metadata: Arc, + fetched_at: Timestamp, +} + +/// The metadata cache for one configured issuer. +/// +/// A `std` mutex rather than an async one, held only to read or replace the `Arc`: the fetch +/// happens outside it, so two concurrent misses fetch twice and the second write wins, which is +/// harmless for an idempotent document and cheaper than serializing every read behind a +/// network call. +#[derive(Debug)] +pub struct MetadataCache { + issuer: String, + http: reqwest::Client, + clock: Arc, + cached: Mutex>, +} + +impl MetadataCache { + /// A cache for `issuer`, fetching with `http` and ageing by `clock`. + pub fn new(issuer: impl Into, http: reqwest::Client, clock: Arc) -> Self { + Self { + issuer: issuer.into(), + http, + clock, + cached: Mutex::new(None), + } + } + + /// The configured issuer. + pub fn issuer(&self) -> &str { + &self.issuer + } + + fn slot(&self) -> MutexGuard<'_, Option> { + self.cached.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The current metadata, fetching it if the cache is empty or older than [`METADATA_TTL`]. + /// + /// # Errors + /// + /// The fetch's [`DiscoveryError`], if one was needed and failed. A stale document is **not** + /// served on a failed refresh: endpoints are where secrets are sent, and a day-old answer + /// to "where is the token endpoint" is a day-old fact about where to send them. + pub async fn current(&self) -> Result, DiscoveryError> { + let now = self.clock.now(); + if let Some(cached) = self.slot().as_ref() + && now.duration_since(cached.fetched_at) < METADATA_TTL + { + return Ok(Arc::clone(&cached.metadata)); + } + + tracing::info!(issuer = %self.issuer, "fetching the provider's discovery document"); + let fetched = self.fetch().await?; + let metadata = Arc::new(admit(fetched, &self.issuer)?); + *self.slot() = Some(Cached { + metadata: Arc::clone(&metadata), + fetched_at: now, + }); + Ok(metadata) + } + + async fn fetch(&self) -> Result { + let response = self + .http + .get(discovery_url(&self.issuer)) + .send() + .await + .map_err(|error| DiscoveryError::Unreachable { + detail: error.to_string(), + })?; + let status = response.status(); + if !status.is_success() { + return Err(DiscoveryError::Unreachable { + detail: format!("the discovery endpoint answered {status}"), + }); + } + response + .json::() + .await + .map_err(|error| DiscoveryError::Malformed { + detail: error.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(issuer: &str, scheme: &str) -> ProviderMetadata { + ProviderMetadata { + issuer: issuer.to_owned(), + authorization_endpoint: format!("{scheme}://idp.example.test/auth"), + token_endpoint: format!("{scheme}://idp.example.test/token"), + jwks_uri: format!("{scheme}://idp.example.test/keys"), + } + } + + #[test] + fn the_discovery_url_hangs_off_the_issuer() { + assert_eq!( + discovery_url("https://idp.example.test"), + "https://idp.example.test/.well-known/openid-configuration" + ); + assert_eq!( + discovery_url("https://idp.example.test/realm/"), + "https://idp.example.test/realm/.well-known/openid-configuration" + ); + } + + #[test] + fn a_document_naming_another_issuer_is_refused() { + let error = admit( + metadata("https://somebody-else.test", "https"), + "https://idp.example.test", + ) + .expect_err("refused"); + assert_eq!( + error, + DiscoveryError::IssuerMismatch { + found: "https://somebody-else.test".to_owned() + } + ); + } + + #[test] + fn an_off_box_endpoint_under_a_loopback_issuer_is_refused() { + // The carve-out is for a provider on this machine, not for a provider on this machine + // that sends the code somewhere else in the clear. + let issuer = "http://127.0.0.1:5556/dex"; + let mut off_box = metadata(issuer, "http"); + off_box.authorization_endpoint = format!("{issuer}/auth"); + off_box.jwks_uri = format!("{issuer}/keys"); + off_box.token_endpoint = "http://idp.example.test/token".to_owned(); + assert!(matches!( + admit(off_box, issuer).expect_err("refused"), + DiscoveryError::InsecureEndpoint { + endpoint: "token_endpoint", + .. + } + )); + let mut named = metadata(issuer, "http"); + named.authorization_endpoint = format!("{issuer}/auth"); + named.jwks_uri = format!("{issuer}/keys"); + named.token_endpoint = "http://localhost:5556/dex/token".to_owned(); + assert!( + admit(named, issuer).is_err(), + "localhost is not loopback either" + ); + } + + #[test] + fn plain_http_endpoints_are_refused_unless_the_issuer_is_loopback() { + let error = admit( + metadata("https://idp.example.test", "http"), + "https://idp.example.test", + ) + .expect_err("refused"); + assert!(matches!( + error, + DiscoveryError::InsecureEndpoint { + endpoint: "authorization_endpoint", + .. + } + )); + + for issuer in ["http://127.0.0.1:5556/dex", "http://[::1]:5556"] { + assert!(is_loopback_issuer(issuer), "{issuer}"); + // Loopback endpoints under a loopback issuer are the carve-out; https anywhere is + // still admitted. + let loopback = ProviderMetadata { + issuer: issuer.to_owned(), + authorization_endpoint: format!("{issuer}/auth"), + token_endpoint: format!("{issuer}/token"), + jwks_uri: "https://keys.example.test/jwks".to_owned(), + }; + assert!(admit(loopback, issuer).is_ok(), "{issuer}"); + } + assert!( + !is_loopback_issuer("http://localhost:5556"), + "localhost is a name a resolver answers for; RFC 8252 §8.3" + ); + assert!( + !is_loopback_issuer("https://127.0.0.1"), + "https is not the carve-out" + ); + assert!(!is_loopback_issuer("http://idp.example.test")); + } +} diff --git a/capsule-server/src/auth/oidc/jwks.rs b/capsule-server/src/auth/oidc/jwks.rs new file mode 100644 index 00000000..1ef59063 --- /dev/null +++ b/capsule-server/src/auth/oidc/jwks.rs @@ -0,0 +1,171 @@ +//! The provider's signing keys, cached and refreshed on evidence. +//! +//! # Refreshed on an unknown `kid`, and floored +//! +//! A provider that rotates its keys announces it by signing with a `kid` the relying party has +//! not seen, so an unknown `kid` is the trigger for a refetch. It is also what a forger sends, +//! so the refetch is floored at one per [`REFRESH_FLOOR`]: a stream of tokens with invented +//! key ids cannot make this server hammer the provider's JWKS endpoint, and a burst of +//! suppressed refetches in the log is a probe worth reading about. +//! +//! # And a ceiling, so a revoked key stops being honoured +//! +//! Evidence only reaches this cache when a token names a key it does not hold. A key the +//! provider *revoked* never generates that evidence — every token it signed still names a `kid` +//! the cache knows — so the cache would keep honouring it until something else rotated. The +//! ceiling ([`MAX_AGE`]) closes that: a set older than an hour is refetched on its next read, +//! and a key that left the provider's set stops verifying within the hour. +//! +//! # Stale rather than empty, inside the ceiling +//! +//! A failed evidence-driven refresh keeps the previous set. A key set that was good a minute ago +//! is still the provider's — the failure mode to avoid is the one where a transient network +//! fault turns every sign-in into a `500`. Past the ceiling the failure is returned instead: a +//! set that could not be confirmed for an hour is not one to keep verifying against. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use jiff::{SignedDuration, Timestamp}; +use jsonwebtoken::jwk::JwkSet; + +use crate::store::Clock; + +/// The shortest interval between two refetches of the key set. +pub const REFRESH_FLOOR: SignedDuration = SignedDuration::from_secs(60); + +/// The longest a fetched key set is honoured before it is read again. +/// +/// An hour: the bound on how long a key the provider revoked keeps verifying here. +pub const MAX_AGE: SignedDuration = SignedDuration::from_hours(1); + +/// Why the key set could not be fetched. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum KeyError { + /// The JWKS endpoint could not be reached, or answered with an error. + #[error("the provider's key set could not be fetched: {detail}")] + Unreachable { + /// The transport's own description. + detail: String, + }, + /// The document is not a JWK Set. + #[error("the provider's key set is not usable: {detail}")] + Malformed { + /// What was wrong with it. + detail: String, + }, +} + +/// What a refetch did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refresh { + /// The set was fetched again, and this is it. + Fetched(Arc), + /// A fetch happened inside the floor; the cached set stands. + Suppressed, +} + +#[derive(Debug)] +struct Cached { + keys: Arc, + fetched_at: Timestamp, +} + +/// The key cache for one provider. +#[derive(Debug)] +pub struct KeyCache { + http: reqwest::Client, + clock: Arc, + cached: Mutex>, +} + +impl KeyCache { + /// An empty cache fetching with `http` and ageing by `clock`. + pub fn new(http: reqwest::Client, clock: Arc) -> Self { + Self { + http, + clock, + cached: Mutex::new(None), + } + } + + fn slot(&self) -> MutexGuard<'_, Option> { + self.cached.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The current key set, fetched from `jwks_uri` if none is cached or the cached one is + /// older than [`MAX_AGE`]. + /// + /// Rotation is otherwise detected on evidence ([`Self::refresh`]) rather than on a timer, + /// because the only observable fact about a rotation is a token the cached set cannot + /// verify; the ceiling exists for the revocation no token ever announces. + /// + /// # Errors + /// + /// The fetch's [`KeyError`] if a fetch was needed and failed. + pub async fn current(&self, jwks_uri: &str) -> Result, KeyError> { + let now = self.clock.now(); + if let Some(cached) = self.slot().as_ref() + && now.duration_since(cached.fetched_at) < MAX_AGE + { + return Ok(Arc::clone(&cached.keys)); + } + tracing::info!( + "fetching the provider's key set: none is cached, or it reached its ceiling" + ); + self.fetch_and_store(jwks_uri).await + } + + /// Refetch the key set because a token named a key the cached set does not hold. + /// + /// Returns [`Refresh::Suppressed`] — and logs a `WARN` — when the last fetch was less than + /// [`REFRESH_FLOOR`] ago. A failed fetch keeps the cached set and returns the error. + /// + /// # Errors + /// + /// The fetch's [`KeyError`]. + pub async fn refresh(&self, jwks_uri: &str) -> Result { + let now = self.clock.now(); + if let Some(cached) = self.slot().as_ref() + && now.duration_since(cached.fetched_at) < REFRESH_FLOOR + { + tracing::warn!( + "an ID token named an unknown key inside the refetch floor; a burst of these is \ + a forged-kid probe" + ); + return Ok(Refresh::Suppressed); + } + tracing::info!("refetching the provider's key set after an unknown kid"); + self.fetch_and_store(jwks_uri).await.map(Refresh::Fetched) + } + + async fn fetch_and_store(&self, jwks_uri: &str) -> Result, KeyError> { + let now = self.clock.now(); + let response = + self.http + .get(jwks_uri) + .send() + .await + .map_err(|error| KeyError::Unreachable { + detail: error.to_string(), + })?; + let status = response.status(); + if !status.is_success() { + return Err(KeyError::Unreachable { + detail: format!("the JWKS endpoint answered {status}"), + }); + } + let keys = response + .json::() + .await + .map_err(|error| KeyError::Malformed { + detail: error.to_string(), + })?; + let keys = Arc::new(keys); + *self.slot() = Some(Cached { + keys: Arc::clone(&keys), + fetched_at: now, + }); + tracing::debug!(keys = keys.keys.len(), "cached the provider's key set"); + Ok(keys) + } +} diff --git a/capsule-server/src/auth/oidc/mod.rs b/capsule-server/src/auth/oidc/mod.rs new file mode 100644 index 00000000..bb9f43af --- /dev/null +++ b/capsule-server/src/auth/oidc/mod.rs @@ -0,0 +1,129 @@ +//! The OpenID Connect relying party (slice `S-N1`). +//! +//! # What it is, and what it is not +//! +//! Capsule is a **relying party only** (design/authentication.md, "Choosing an Auth Path"): an +//! external identity provider authenticates the *session*, and the master key never derives +//! from, and is never visible to, whatever the provider verified. Account lifecycle policy lives +//! at the provider. What this module does is the authorization-code + PKCE handshake, the checks +//! on what comes back, and the mapping from a provider's stable subject to a Capsule account — +//! after which the session is opened by exactly the function the password path uses. +//! +//! # Shape +//! +//! | Concern | Lives | Why | +//! | --- | --- | --- | +//! | Every check an ID token must pass | [`claims`] | a pure function, so every negative case is a unit test with no socket and no clock | +//! | Provider metadata and its cache | [`discovery`] | one fetch a day, refused if it names a different issuer | +//! | The provider's signing keys | [`jwks`] | refetched on an unknown `kid`, at most once a minute | +//! | The port the routes drive, and its one HTTP adapter | [`provider`] | the only external boundary the feature has, so the only thing doubled | +//! | Which account a verified identity is | [`accounts`] | one atomic operation keyed on `(issuer, subject)` | +//! | The pending ceremony between the two legs | [`crate::store::OidcAuthorizationStore`] | a single-use, short-window credential, which is what the ceremony stores are for | +//! +//! # Hand-written over `jsonwebtoken`, not `openidconnect` +//! +//! `openidconnect` would have pulled in `chrono` and the `log` facade — both banned — and +//! `rsa 0.9` carrying RUSTSEC-2023-0071 with no fixed release, which `deny.toml` would not catch +//! because only the licence check is wired. The hard part, verifying a signature against a JWKS, +//! is already in the workspace's JWT crate; what is left is discovery, a form `POST` and the +//! claim checks, each of which is a security decision this repository wants written where a +//! reader can see it. See the OIDC row in design/dependencies.md. + +pub mod accounts; +pub mod claims; +pub mod discovery; +pub mod jwks; +pub mod provider; + +use std::sync::Arc; + +pub use self::accounts::{FederatedAccounts, FederatedLink, InMemoryFederatedAccounts}; +pub use self::claims::{ + ALLOWED_ALGORITHMS, CLOCK_SKEW_SECONDS, ClaimRejection, Expectations, MAX_QUOTED_BYTES, + MAX_SUBJECT_LENGTH, VerifiedIdentity, bounded, verify_id_token, +}; +pub use self::provider::{ + AuthorizationRequest, ClientSecret, Disabled, HttpIdentityProvider, IdentityProvider, + OidcSettings, ProviderError, ProviderFuture, Redemption, RedirectPolicy, SCOPES, + code_challenge, fresh_nonce, fresh_state, fresh_verifier, +}; +use crate::store::{Clock, OidcAuthorizationStore}; + +/// The collaborators an [`OidcContext`] is assembled from. Named rather than ordered, as +/// [`AuthCollaborators`](crate::auth::AuthCollaborators) is. +#[derive(Debug)] +pub struct OidcCollaborators { + /// The identity provider — [`Disabled`] when `OIDC_ISSUER` is unset. + pub provider: Arc, + /// The pending ceremonies between the two legs. + pub authorizations: Arc, + /// Which account a verified identity is. + pub accounts: Arc, + /// The clock every ceremony and every deadline is stamped from. + pub clock: Arc, +} + +/// Everything the OIDC operations reach for, as one injectable value. +#[derive(Debug, Clone)] +pub struct OidcContext { + provider: Arc, + authorizations: Arc, + accounts: Arc, + clock: Arc, +} + +impl OidcContext { + /// Assembles the module. + pub fn new(collaborators: OidcCollaborators) -> Self { + let OidcCollaborators { + provider, + authorizations, + accounts, + clock, + } = collaborators; + Self { + provider, + authorizations, + accounts, + clock, + } + } + + /// The module for a deployment with no identity provider. + /// + /// Every operation answers `error.auth.oidc_not_configured` (the authorize) or finds no + /// pending ceremony (the callback). The store is real and empty so the shape is the same as + /// a configured deployment's; nothing ever writes to it. + pub fn disabled(clock: Arc) -> Self { + Self::new(OidcCollaborators { + provider: Arc::new(Disabled), + authorizations: Arc::new( + crate::store::memory::InMemoryOidcAuthorizations::with_default_ttl(Arc::clone( + &clock, + )), + ), + accounts: Arc::new(Disabled), + clock, + }) + } + + /// The identity provider. + pub fn provider(&self) -> &dyn IdentityProvider { + self.provider.as_ref() + } + + /// The pending ceremonies. + pub fn authorizations(&self) -> &dyn OidcAuthorizationStore { + self.authorizations.as_ref() + } + + /// Which account a verified identity is. + pub fn accounts(&self) -> &dyn FederatedAccounts { + self.accounts.as_ref() + } + + /// The clock every ceremony is stamped from. + pub fn clock(&self) -> &dyn Clock { + self.clock.as_ref() + } +} diff --git a/capsule-server/src/auth/oidc/provider.rs b/capsule-server/src/auth/oidc/provider.rs new file mode 100644 index 00000000..5a2db412 --- /dev/null +++ b/capsule-server/src/auth/oidc/provider.rs @@ -0,0 +1,614 @@ +//! [`IdentityProvider`] — the port the OIDC routes drive, and its one HTTP adapter. +//! +//! # Two methods, not three +//! +//! Discovery is not a caller-visible operation; it is how both of these are answered. The port +//! is what the routes need — a URL to send the person to, and an identity for the code they +//! come back with — and nothing about how the adapter gets there. +//! +//! # The only thing doubled +//! +//! Everything else in the OIDC module is pure or is a store with an in-memory adapter. The +//! identity provider is the feature's one external boundary, so it is the one place the +//! mocking rule applies: the routes are tested against a double of this trait, and +//! [`HttpIdentityProvider`] is tested against an in-process mock provider that speaks the real +//! wire — discovery JSON, a JWK Set, a form-encoded token `POST`, a signed compact JWS. +//! +//! # The redirect URI is client-supplied and allow-listed +//! +//! A native client's loopback port is ephemeral (RFC 8252 §7.3), and the value sent to the token +//! endpoint must byte-match the one sent to the authorization endpoint (RFC 6749 §4.1.3). So the +//! client names its redirect, [`RedirectPolicy`] admits it or refuses it, and the admitted value +//! is stored with the ceremony and replayed verbatim. This is the one field that makes the CLI +//! and iOS flows possible without a second server surface. +//! +//! # PKCE, and no client secret by default +//! +//! Every ceremony carries an S256 code challenge. A client secret is optional: RFC 8252 §8.5 +//! says a native application cannot keep one, and PKCE is what makes a public client sound. +//! When a deployment configures one it is sent as HTTP Basic credentials (RFC 6749 §2.3.1, +//! the default `client_secret_basic` method every provider supports). + +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::Deserialize; + +use super::claims::{ClaimRejection, Expectations, VerifiedIdentity, bounded, verify_id_token}; +use super::discovery::{DiscoveryError, MetadataCache}; +use super::jwks::{KeyCache, KeyError, Refresh}; +use crate::store::{AuthorizationCode, Clock, OidcNonce, OidcState, PkceVerifier}; + +/// The scopes every authorization request asks for. +/// +/// `openid` is what makes it an OIDC request at all. `email` is the one claim the relying party +/// reads, for the one decision it makes with it. **Not `profile`**: design/authentication.md +/// makes the display name something the person sets, and asking the provider for it would have +/// the server store a fact it declined to collect at registration. +pub const SCOPES: &str = "openid email"; + +/// How long an outbound request to the provider may take. +/// +/// Ten seconds, end to end. A provider that takes longer to answer a discovery or token request +/// is one the person should be told is down, rather than one whose slowness holds a request +/// worker open indefinitely. +pub const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The future every port operation returns. +pub type ProviderFuture<'a, T> = + Pin> + Send + 'a>>; + +/// What the relying party sends the person to the provider with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationRequest<'a> { + /// Where the provider should send the person back. Already admitted by the policy. + pub redirect_uri: &'a str, + /// The ceremony's state. + pub state: &'a OidcState, + /// The nonce the ID token must echo. + pub nonce: &'a OidcNonce, + /// The S256 challenge of the ceremony's verifier. + pub code_challenge: &'a str, +} + +/// What the relying party redeems at the provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Redemption<'a> { + /// The code the provider handed back through the redirect. + pub code: &'a AuthorizationCode, + /// The verifier whose challenge the authorization request carried. + pub verifier: &'a PkceVerifier, + /// The redirect URI the authorization request named, byte for byte. + pub redirect_uri: &'a str, + /// The nonce the authorization request carried. + pub nonce: &'a OidcNonce, +} + +/// Why the provider could not complete an operation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ProviderError { + /// This deployment has no identity provider. + /// + /// Answered by [`Disabled`], the provider an unconfigured deployment runs with, so the routes + /// have one shape whether or not `OIDC_ISSUER` is set. + #[error("no identity provider is configured")] + NotConfigured, + /// The redirect URI is neither the configured one nor an admitted loopback address. + #[error("the redirect URI {redirect_uri:?} is not admitted")] + RedirectRefused { + /// The URI the client asked for. + redirect_uri: String, + }, + /// The provider could not be reached, or its published facts could not be used. + #[error("the identity provider is unavailable: {detail}")] + Unavailable { + /// What went wrong, for the log line. + detail: String, + }, + /// The provider refused to exchange the code. + #[error("the identity provider refused the exchange: {detail}")] + ExchangeRefused { + /// The provider's own `error` and `error_description`, when it gave them. + detail: String, + }, + /// The provider answered with an ID token this relying party refuses. + #[error("the ID token was refused: {0}")] + TokenRejected(#[from] ClaimRejection), +} + +impl From for ProviderError { + fn from(error: DiscoveryError) -> Self { + Self::Unavailable { + detail: error.to_string(), + } + } +} + +impl From for ProviderError { + fn from(error: KeyError) -> Self { + Self::Unavailable { + detail: error.to_string(), + } + } +} + +/// An external identity provider, as the routes see it. +pub trait IdentityProvider: fmt::Debug + Send + Sync { + /// Whether the deployment's [`RedirectPolicy`] admits `redirect_uri`. + /// + /// Synchronous, pure and free: it is a string comparison against configuration, with no + /// clock, no socket and no state. It exists as a port method rather than as a copy of the + /// policy held beside the routes because there must be exactly **one** answer to "is this + /// redirect admitted" — a second copy is two call sites that eventually disagree, and the + /// disagreement would be invisible until one of them admitted something the other refused. + /// + /// The route calls it **before** it charges the rate limiter, so the counter key it charges + /// is one the policy already admitted. That ordering is the whole point: the redirect URI is + /// caller-supplied and unbounded, and keying a counter on an unvalidated one hands an + /// unauthenticated caller a lever on the counter store's key cardinality. See + /// [`CounterKey::OidcAuthorizeRefused`](crate::counter::CounterKey::OidcAuthorizeRefused). + /// + /// [`authorization_url`](Self::authorization_url) applies the same policy again and is the + /// authority; this is the cheap look-ahead, never the enforcement. + fn admits_redirect(&self, redirect_uri: &str) -> bool; + + /// The URL to send the person to. + /// + /// Refuses a redirect the policy does not admit before anything is fetched, so a refused + /// request costs no round trip to the provider. + fn authorization_url<'a>( + &'a self, + request: &'a AuthorizationRequest<'a>, + ) -> ProviderFuture<'a, String>; + + /// Exchange the code for an ID token, verify it, and say who it is. + fn redeem<'a>(&'a self, redemption: &'a Redemption<'a>) + -> ProviderFuture<'a, VerifiedIdentity>; +} + +/// Which redirect URIs a deployment admits. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RedirectPolicy { + configured: Option, + allow_loopback: bool, +} + +impl RedirectPolicy { + /// Admit `configured` exactly, plus loopback URIs when `allow_loopback`. + pub fn new(configured: Option, allow_loopback: bool) -> Self { + Self { + configured, + allow_loopback, + } + } + + /// Whether `redirect_uri` may be used. + /// + /// Exact string equality with the configured URI, or — when loopback is allowed — an + /// `http` URI whose host is `127.0.0.1` or `[::1]` on **any** port, with no fragment (RFC + /// 6749 §3.1.2 forbids one). `localhost` is deliberately not a loopback address here: RFC + /// 8252 §8.3 recommends the IP literals, because a resolver can be made to send `localhost` + /// elsewhere. + #[must_use] + pub fn admits(&self, redirect_uri: &str) -> bool { + if self.configured.as_deref() == Some(redirect_uri) { + return true; + } + if !self.allow_loopback { + return false; + } + reqwest::Url::parse(redirect_uri).is_ok_and(|url| { + url.scheme() == "http" + && url.fragment().is_none() + && matches!(url.host_str(), Some("127.0.0.1" | "[::1]")) + }) + } +} + +/// A client secret, redacted in `Debug`. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientSecret(String); + +impl ClientSecret { + /// Hold `secret`. + pub fn new(secret: impl Into) -> Self { + Self(secret.into()) + } + + fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ClientSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ClientSecret()") + } +} + +/// Everything an operator decides about the relying party. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcSettings { + /// The issuer, exactly as the ID token must carry it. + pub issuer: String, + /// This relying party's `client_id` at the provider. + pub client_id: String, + /// The client secret, if the deployment is a confidential client. Absent means PKCE-only. + pub client_secret: Option, + /// Which redirect URIs are admitted. + pub redirects: RedirectPolicy, +} + +// =========================================================================================== +// Ceremony material +// =========================================================================================== + +/// Fresh random bytes, base64url without padding. +fn random_token(bytes: usize) -> String { + use ring::rand::SecureRandom as _; + let mut buf = vec![0u8; bytes]; + ring::rand::SystemRandom::new() + .fill(&mut buf) + .expect("the platform's random source works"); + URL_SAFE_NO_PAD.encode(buf) +} + +/// A fresh `state`: 128 bits, base64url. +#[must_use] +pub fn fresh_state() -> OidcState { + OidcState::new(random_token(16)) +} + +/// A fresh `nonce`: 128 bits, base64url. +#[must_use] +pub fn fresh_nonce() -> OidcNonce { + OidcNonce::new(random_token(16)) +} + +/// A fresh PKCE verifier: 256 bits, base64url — 43 characters, inside RFC 7636 §4.1's 43–128. +#[must_use] +pub fn fresh_verifier() -> PkceVerifier { + PkceVerifier::new(random_token(32)) +} + +/// The S256 challenge of `verifier` (RFC 7636 §4.2). +#[must_use] +pub fn code_challenge(verifier: &PkceVerifier) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, verifier.as_str().as_bytes()); + URL_SAFE_NO_PAD.encode(digest.as_ref()) +} + +// =========================================================================================== +// The HTTP adapter +// =========================================================================================== + +/// The token endpoint's success body. Everything but the ID token is ignored: the relying party +/// calls no other provider API, so an access token to the provider is a credential with no use. +#[derive(Deserialize)] +struct TokenResponse { + id_token: String, +} + +/// The token endpoint's refusal body (RFC 6749 §5.2). +#[derive(Deserialize, Default)] +struct TokenRefusal { + #[serde(default)] + error: String, + #[serde(default)] + error_description: Option, +} + +/// The relying party over a real provider. +#[derive(Debug)] +pub struct HttpIdentityProvider { + settings: OidcSettings, + http: reqwest::Client, + metadata: MetadataCache, + keys: KeyCache, + clock: Arc, +} + +impl HttpIdentityProvider { + /// The egress client every relying-party request is sent with. + /// + /// Timeouts on, redirects off: a token endpoint that redirects is sending the client secret + /// somewhere the discovery document did not name. `roots` are the operator's additional + /// trust anchors (`OIDC_CA_BUNDLE`) — a provider behind a private CA is the ordinary + /// enterprise case — added to, never replacing, the public roots. + /// + /// # Errors + /// + /// Whatever `reqwest` refuses to build with — in practice nothing. + pub fn http_client(roots: &[reqwest::Certificate]) -> reqwest::Result { + let mut builder = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("capsule-server/", env!("CARGO_PKG_VERSION"))); + for root in roots { + builder = builder.add_root_certificate(root.clone()); + } + builder.build() + } + + /// A relying party for `settings`, fetching with `http` and judging time by `clock`. + pub fn new(settings: OidcSettings, http: reqwest::Client, clock: Arc) -> Self { + Self { + metadata: MetadataCache::new(settings.issuer.clone(), http.clone(), Arc::clone(&clock)), + keys: KeyCache::new(http.clone(), Arc::clone(&clock)), + settings, + http, + clock, + } + } + + /// The settings this relying party runs with. + pub fn settings(&self) -> &OidcSettings { + &self.settings + } + + async fn exchange(&self, redemption: &Redemption<'_>) -> Result { + let metadata = self.metadata.current().await?; + let mut form = vec![ + ("grant_type", "authorization_code"), + ("code", redemption.code.as_str()), + ("redirect_uri", redemption.redirect_uri), + ("client_id", self.settings.client_id.as_str()), + ("code_verifier", redemption.verifier.as_str()), + ]; + // A public client identifies itself in the body; a confidential one authenticates. + let mut request = self.http.post(&metadata.token_endpoint); + if let Some(secret) = &self.settings.client_secret { + request = request.basic_auth(&self.settings.client_id, Some(secret.expose())); + form.retain(|(name, _)| *name != "client_id"); + } + let response = + request + .form(&form) + .send() + .await + .map_err(|error| ProviderError::Unavailable { + detail: format!("the token endpoint could not be reached: {error}"), + })?; + + let status = response.status(); + if status.is_success() { + let body: TokenResponse = + response + .json() + .await + .map_err(|error| ProviderError::Unavailable { + detail: format!("the token endpoint's answer was not usable: {error}"), + })?; + return Ok(body.id_token); + } + if status.is_client_error() { + // RFC 6749 §5.2: a refused grant is a `400` with an `error` member. Anything else in + // the 4xx range is still the provider saying no to *this* request. + let refusal: TokenRefusal = response.json().await.unwrap_or_default(); + // Bounded before it reaches the log: both strings are the provider's to fill. + let detail = match refusal.error_description { + Some(description) => { + format!("{} ({})", bounded(&refusal.error), bounded(&description)) + } + None if refusal.error.is_empty() => format!("the token endpoint answered {status}"), + None => bounded(&refusal.error), + }; + return Err(ProviderError::ExchangeRefused { detail }); + } + Err(ProviderError::Unavailable { + detail: format!("the token endpoint answered {status}"), + }) + } + + async fn verify( + &self, + raw: &str, + nonce: &OidcNonce, + ) -> Result { + let metadata = self.metadata.current().await?; + let expect = Expectations { + issuer: self.settings.issuer.clone(), + client_id: self.settings.client_id.clone(), + nonce: nonce.as_str().to_owned(), + }; + let now = self.clock.now(); + let keys = self.keys.current(&metadata.jwks_uri).await?; + match verify_id_token(raw, &keys, &expect, now) { + Err(ClaimRejection::UnknownKey { kid }) => { + // The one rejection that is evidence rather than a verdict: the provider may + // have rotated. Refetch — once, floored — and judge again against the new set. + match self.keys.refresh(&metadata.jwks_uri).await? { + Refresh::Fetched(keys) => Ok(verify_id_token(raw, &keys, &expect, now)?), + Refresh::Suppressed => Err(ClaimRejection::UnknownKey { kid }.into()), + } + } + verdict => Ok(verdict?), + } + } +} + +impl IdentityProvider for HttpIdentityProvider { + fn admits_redirect(&self, redirect_uri: &str) -> bool { + self.settings.redirects.admits(redirect_uri) + } + + fn authorization_url<'a>( + &'a self, + request: &'a AuthorizationRequest<'a>, + ) -> ProviderFuture<'a, String> { + Box::pin(async move { + if !self.settings.redirects.admits(request.redirect_uri) { + return Err(ProviderError::RedirectRefused { + redirect_uri: request.redirect_uri.to_owned(), + }); + } + let metadata = self.metadata.current().await?; + let mut url = + reqwest::Url::parse(&metadata.authorization_endpoint).map_err(|error| { + ProviderError::Unavailable { + detail: format!("the authorization endpoint is not a URL: {error}"), + } + })?; + url.query_pairs_mut() + .append_pair("response_type", "code") + .append_pair("client_id", &self.settings.client_id) + .append_pair("redirect_uri", request.redirect_uri) + .append_pair("scope", SCOPES) + .append_pair("state", request.state.as_str()) + .append_pair("nonce", request.nonce.as_str()) + .append_pair("code_challenge", request.code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url.into()) + }) + } + + fn redeem<'a>( + &'a self, + redemption: &'a Redemption<'a>, + ) -> ProviderFuture<'a, VerifiedIdentity> { + Box::pin(async move { + let raw = self.exchange(redemption).await?; + self.verify(&raw, redemption.nonce).await + }) + } +} + +/// The provider an unconfigured deployment runs with. +/// +/// A null object rather than an `Option` on the context, so the routes have one shape and the +/// "not configured" answer is produced where every other provider answer is. It also implements +/// [`FederatedAccounts`](super::accounts::FederatedAccounts), refusing, so an unconfigured +/// context needs no second null object; that path is unreachable — a callback on an unconfigured +/// deployment finds no pending authorization first. +#[derive(Debug, Clone, Copy, Default)] +pub struct Disabled; + +impl IdentityProvider for Disabled { + /// Nothing is admitted, because nothing is configured. The authorize still answers + /// `404 error.auth.oidc_not_configured` rather than `400`: this only decides which counter + /// bucket the refusal is charged to, and an unconfigured deployment's every request is a + /// refusal, so the fixed bucket is the correct one. + fn admits_redirect(&self, _redirect_uri: &str) -> bool { + false + } + + fn authorization_url<'a>( + &'a self, + _request: &'a AuthorizationRequest<'a>, + ) -> ProviderFuture<'a, String> { + Box::pin(async { Err(ProviderError::NotConfigured) }) + } + + fn redeem<'a>( + &'a self, + _redemption: &'a Redemption<'a>, + ) -> ProviderFuture<'a, VerifiedIdentity> { + Box::pin(async { Err(ProviderError::NotConfigured) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_configured_redirect_is_admitted_exactly() { + let policy = RedirectPolicy::new(Some("https://app.example.test/cb".to_owned()), false); + assert!(policy.admits("https://app.example.test/cb")); + assert!(!policy.admits("https://app.example.test/cb/")); + assert!(!policy.admits("https://app.example.test/cb?x=1")); + assert!( + !policy.admits("http://127.0.0.1:4242/cb"), + "loopback is off" + ); + } + + #[test] + fn loopback_is_admitted_on_any_port_and_only_as_an_ip_literal() { + let policy = RedirectPolicy::new(None, true); + assert!(policy.admits("http://127.0.0.1:4242/cb")); + assert!(policy.admits("http://127.0.0.1/cb")); + assert!(policy.admits("http://[::1]:9/")); + assert!(!policy.admits("http://localhost:4242/cb"), "RFC 8252 §8.3"); + assert!( + !policy.admits("https://127.0.0.1:4242/cb"), + "loopback is plain http" + ); + assert!( + !policy.admits("http://127.0.0.1:4242/cb#frag"), + "RFC 6749 §3.1.2" + ); + assert!(!policy.admits("http://10.0.0.1:4242/cb")); + assert!(!policy.admits("not a url")); + } + + #[test] + fn nothing_is_admitted_by_an_empty_policy() { + let policy = RedirectPolicy::new(None, false); + assert!(!policy.admits("http://127.0.0.1:4242/cb")); + assert!(!policy.admits("")); + } + + #[test] + fn ceremony_material_is_fresh_and_the_challenge_is_s256() { + assert_ne!(fresh_state(), fresh_state()); + assert_ne!(fresh_nonce(), fresh_nonce()); + let verifier = fresh_verifier(); + assert_eq!( + verifier.as_str().len(), + 43, + "RFC 7636 §4.1: 43 to 128 characters" + ); + // RFC 7636 appendix B's worked example. + let example = PkceVerifier::new("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + assert_eq!( + code_challenge(&example), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn a_client_secret_never_prints_itself() { + let settings = OidcSettings { + issuer: "https://idp.example.test".to_owned(), + client_id: "capsule".to_owned(), + client_secret: Some(ClientSecret::new("hunter2")), + redirects: RedirectPolicy::new(None, true), + }; + let printed = format!("{settings:?}"); + assert!(!printed.contains("hunter2"), "{printed}"); + assert!(printed.contains(""), "{printed}"); + } + + #[tokio::test] + async fn the_disabled_provider_answers_not_configured() { + let state = fresh_state(); + let nonce = fresh_nonce(); + let request = AuthorizationRequest { + redirect_uri: "http://127.0.0.1:1/cb", + state: &state, + nonce: &nonce, + code_challenge: "x", + }; + assert_eq!( + Disabled.authorization_url(&request).await, + Err(ProviderError::NotConfigured) + ); + let code = AuthorizationCode::new("code"); + let verifier = fresh_verifier(); + let redemption = Redemption { + code: &code, + verifier: &verifier, + redirect_uri: "http://127.0.0.1:1/cb", + nonce: &nonce, + }; + assert_eq!( + Disabled.redeem(&redemption).await, + Err(ProviderError::NotConfigured) + ); + } +} diff --git a/capsule-server/src/auth/totp.rs b/capsule-server/src/auth/totp.rs index 26a9f703..0b891f40 100644 --- a/capsule-server/src/auth/totp.rs +++ b/capsule-server/src/auth/totp.rs @@ -38,14 +38,23 @@ //! would otherwise turn off the control that exists to make a stolen access token insufficient. //! The retired surface got this right and it is preserved deliberately. //! -//! # No adapter here +//! # The one adapter that belongs in `src/` //! -//! Same reason [`AccountDirectory`](super::AccountDirectory) has none: the real one is Postgres, -//! and a shared-secret store in `src/` is a fake credential store shipped inside the server -//! binary. The suite's lives in `tests/support/`. +//! [`InMemoryTotp`] is here, and the account ports' "a double in `src/` is a fake credential +//! store shipped inside the server binary" reasoning does not reach it. A TOTP secret is +//! **server-generated**: nothing a caller presents is ever stored, so there is no credential to +//! be permissive about, and the three properties the port promises — the check-and-write in +//! [`TotpStore::begin`], the pending-only [`TotpStore::activate`], and the compare-and-set in +//! [`TotpStore::consume`] — are all expressible over a map without fudging any of them. What it +//! is missing is durability, which is what makes it the development profile +//! ([`Backends::Memory`](crate::config::Backends)) rather than a deployment. +//! +//! The Postgres adapter is still owed (#402), and it is written against this contract and the +//! suite over it rather than against this type. +use std::collections::BTreeMap; use std::fmt; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use jiff::{SignedDuration, Timestamp}; use subtle::ConstantTimeEq as _; @@ -357,6 +366,98 @@ impl TotpContext { } } +/// The second-factor enrollments this process holds. +/// +/// A real implementation of the port's contract rather than a stub; see the module docs for why +/// this one belongs in `src/` when the account ports' adapters do not. +#[derive(Debug, Default)] +pub struct InMemoryTotp { + held: Mutex>, +} + +impl InMemoryTotp { + /// An empty store. + pub fn new() -> Self { + Self::default() + } + + /// Take the lock, recovering rather than propagating a poisoned one. + /// + /// The same choice [`crate::store::memory`] makes: a panic in one request must not turn + /// every later second-factor check into a second panic. + fn enrollments(&self) -> MutexGuard<'_, BTreeMap> { + self.held.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl TotpStore for InMemoryTotp { + fn begin(&self, record: TotpEnrollment) -> DirectoryFuture<'_, BeginOutcome> { + Box::pin(async move { + // One critical section, as the port requires: a caller that read, saw no active + // enrollment and then wrote has a window in which a confirmation lands, and the + // confirmed factor is then silently replaced. + let mut held = self.enrollments(); + if held + .get(&record.user_id) + .is_some_and(|existing| existing.state == EnrollmentState::Active) + { + return Ok(BeginOutcome::AlreadyActive); + } + // A *pending* enrollment is replaced without ceremony: somebody who abandoned a QR + // code and started again is the ordinary case, and nothing is protecting an + // unconfirmed secret. + held.insert(record.user_id.clone(), record); + Ok(BeginOutcome::Started) + }) + } + + fn read<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, Option> { + Box::pin(async move { Ok(self.enrollments().get(user).cloned()) }) + } + + fn activate<'a>( + &'a self, + user: &'a UserId, + step: u64, + at: Timestamp, + ) -> DirectoryFuture<'a, ActivateOutcome> { + Box::pin(async move { + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ActivateOutcome::NotPending); + }; + if record.state != EnrollmentState::Pending { + return Ok(ActivateOutcome::NotPending); + } + record.state = EnrollmentState::Active; + record.activated_at = Some(at); + // The confirming code is spent, so it cannot also complete a sign-in. + record.last_step = Some(step); + Ok(ActivateOutcome::Activated) + }) + } + + fn consume<'a>(&'a self, user: &'a UserId, step: u64) -> DirectoryFuture<'a, ConsumeOutcome> { + Box::pin(async move { + // Compare-and-set inside one critical section, not a read followed by a write: two + // sign-ins racing on the same six digits is exactly the case a read-then-write + // loses, and losing it accepts a replay. + let mut held = self.enrollments(); + let Some(record) = held.get_mut(user) else { + return Ok(ConsumeOutcome::NotEnrolled); + }; + if record.last_step.is_some_and(|last| step <= last) { + return Ok(ConsumeOutcome::Replayed); + } + record.last_step = Some(step); + Ok(ConsumeOutcome::Fresh) + }) + } + + fn disable<'a>(&'a self, user: &'a UserId) -> DirectoryFuture<'a, bool> { + Box::pin(async move { Ok(self.enrollments().remove(user).is_some()) }) + } +} #[cfg(test)] mod tests { use jiff::Timestamp; @@ -501,3 +602,142 @@ mod tests { assert!(!format!("{secret:?}").contains("JBSWY")); } } +#[cfg(test)] +mod memory_tests { + use jiff::Timestamp; + + use super::{ + ActivateOutcome, BeginOutcome, ConsumeOutcome, EnrollmentState, InMemoryTotp, TotpCodes, + TotpEnrollment, TotpStore, + }; + use crate::store::UserId; + + fn user() -> UserId { + UserId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f") + } + + fn pending() -> TotpEnrollment { + TotpEnrollment { + user_id: user(), + secret: TotpCodes::generate_secret(), + state: EnrollmentState::Pending, + last_step: None, + enrolled_at: Timestamp::UNIX_EPOCH, + activated_at: None, + } + } + + #[tokio::test] + async fn an_abandoned_pending_enrollment_is_replaced_without_ceremony() { + let store = InMemoryTotp::new(); + assert_eq!( + store.begin(pending()).await.expect("it writes"), + BeginOutcome::Started + ); + let second = pending(); + let expected = second.secret.clone(); + assert_eq!( + store.begin(second).await.expect("it writes"), + BeginOutcome::Started + ); + let held = store + .read(&user()) + .await + .expect("it answers") + .expect("it is held"); + assert_eq!(held.secret, expected); + } + + #[tokio::test] + async fn an_active_enrollment_is_never_silently_replaced() { + // The one refusal `begin` makes, and the reason it exists: an enroll that overwrote an + // active secret would let a stolen session swap the factor for one the attacker holds + // without ever presenting a code. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"); + assert_eq!( + store.begin(pending()).await.expect("it answers"), + BeginOutcome::AlreadyActive + ); + } + + #[tokio::test] + async fn only_a_pending_enrollment_activates_and_the_confirming_code_is_spent() { + let store = InMemoryTotp::new(); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "there is nothing to confirm" + ); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store + .activate(&user(), 7, Timestamp::UNIX_EPOCH) + .await + .expect("it writes"), + ActivateOutcome::Activated + ); + assert_eq!( + store + .activate(&user(), 8, Timestamp::UNIX_EPOCH) + .await + .expect("it answers"), + ActivateOutcome::NotPending, + "an active enrollment is not pending" + ); + // The code that confirmed the enrollment must not also complete a sign-in. + assert_eq!( + store.consume(&user(), 7).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + } + + #[tokio::test] + async fn a_step_at_or_below_the_last_used_one_is_a_replay() { + // RFC 6238 §5.2: a code is accepted at most once, and a code is valid for three steps + // with drift — so re-typing it twelve seconds later is a real attack. + let store = InMemoryTotp::new(); + store.begin(pending()).await.expect("it writes"); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + assert_eq!( + store.consume(&user(), 10).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 9).await.expect("it answers"), + ConsumeOutcome::Replayed + ); + assert_eq!( + store.consume(&user(), 11).await.expect("it answers"), + ConsumeOutcome::Fresh + ); + } + + #[tokio::test] + async fn consuming_against_nothing_says_so_rather_than_accepting() { + let store = InMemoryTotp::new(); + assert_eq!( + store.consume(&user(), 1).await.expect("it answers"), + ConsumeOutcome::NotEnrolled + ); + } + + #[tokio::test] + async fn disabling_reports_whether_there_was_anything_to_disable() { + let store = InMemoryTotp::new(); + assert!(!store.disable(&user()).await.expect("it answers")); + store.begin(pending()).await.expect("it writes"); + assert!(store.disable(&user()).await.expect("it answers")); + assert!(store.read(&user()).await.expect("it answers").is_none()); + } +} diff --git a/capsule-server/src/bin/gen_openapi.rs b/capsule-server/src/bin/gen_openapi.rs deleted file mode 100644 index 11a40402..00000000 --- a/capsule-server/src/bin/gen_openapi.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Deterministic OpenAPI **3.2** document dump for the Kynos server (slice `S-C34`). -//! -//! Serializes [`capsule_server::openapi()`] to `capsule-server/openapi.json` and, with -//! `--check`, fails when the committed copy is stale. It is the drift guard for the rebuild's -//! central claim: that the description is derived from the types and cannot disagree with them. -//! -//! That claim is already enforced *inside* the crate — `assert_conformance` catches a response -//! the document did not predict, and `assert_declared_responses_covered` catches a promise no -//! test produced. Neither helps a **client**. A surface can be ported, the emitted document can -//! change shape, and nothing outside the crate notices until someone regenerates by hand. This -//! binary is what makes such a change fail. -//! -//! It needs no database, no Valkey, no key material, no disk and no network: `openapi()` builds -//! the router purely to describe it. That is what lets `--check` run in the Rust check gate, -//! exactly as `i18n-check` and the Salvo `openapi-check` do. -//! -//! **This is not yet the SDK's contract.** `capsule-sdk` still generates from -//! `capsule-sdk/openapi.json`, the Salvo document, and the two are deliberately gated -//! separately while the port proceeds — committing both as *the* contract at once would leave -//! no way to say which one a client should believe. The changeover is its own step: it also -//! drops the four `spargen::OmitRule` narrowings, which exist only because the Salvo document is -//! structurally invalid in ways Kynos cannot express. -//! -//! Usage: -//! - `gen_openapi [FILE]` writes the document (default `capsule-server/openapi.json`). -//! - `gen_openapi --check [FILE]` fails if the committed document is stale, writing nothing. - -use std::path::PathBuf; - -use clap::Parser; -use color_eyre::eyre::{Context, Result, bail}; - -#[derive(Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - /// Output path for the OpenAPI 3.2 document (relative to the repo root). - #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] - output: PathBuf, - - /// Verify the committed document is up to date instead of writing it (CI drift gate). - #[arg(long)] - check: bool, -} - -fn main() -> Result<()> { - color_eyre::install()?; - let cli = Cli::parse(); - - let document = capsule_server::openapi() - .map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; - // `to_json` is already pretty-printed; the trailing newline matches the Salvo dump so both - // committed documents are ordinary text files rather than one-line blobs in a diff. - let mut json = document - .to_json() - .wrap_err("serializing the OpenAPI document to JSON")?; - json.push('\n'); - - if cli.check { - let committed = std::fs::read_to_string(&cli.output).wrap_err_with(|| { - format!("cannot read committed document at {}", cli.output.display()) - })?; - if committed != json { - bail!( - "OpenAPI document at {} is out of sync with the server; run \ - `mise run openapi-kynos` and commit the result", - cli.output.display() - ); - } - println!("OpenAPI document is up to date: {}", cli.output.display()); - } else { - if let Some(parent) = cli.output.parent() { - std::fs::create_dir_all(parent) - .wrap_err_with(|| format!("creating {}", parent.display()))?; - } - std::fs::write(&cli.output, &json) - .wrap_err_with(|| format!("writing {}", cli.output.display()))?; - println!("Wrote {}", cli.output.display()); - } - - Ok(()) -} diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs new file mode 100644 index 00000000..33c3b9ae --- /dev/null +++ b/capsule-server/src/boot.rs @@ -0,0 +1,1472 @@ +//! [`assemble`] — the one composition root, and the only place adapters are chosen. +//! +//! # Why this is a library module and not `main` +//! +//! Until this slice the only composition root in the tree was `tests/support/mod.rs`, which +//! assembles seventeen module contexts out of test doubles. Nothing assembled the server. A +//! composition root that lives in `main` is a composition root nothing tests: "the router +//! builds", "every port has an adapter" and "the published signing key is the one the tokens +//! verify under" are all properties of *this function*, and they are asserted below rather than +//! discovered on a deployment. +//! +//! # The seam, and what #446 still changes +//! +//! Selection is a two-arm `match` on [`Backends`] and not a trait. The `Arc` fields in +//! [`Modules`] already **are** the abstraction; a second one over the top would abstract the +//! composition root from itself. The Postgres (#402) and Valkey (#403) adapters fill both halves +//! of the [`Backends::Durable`] arm, and nothing else here moves. +//! +//! Both halves of that arm are real. [`open_durable_database`] demands `DATABASE_URL`, opens the +//! pool, and refuses to continue against a database whose schema is not the one this binary was +//! built for — naming the command that fixes it, never the URL. [`valkey`] then connects to +//! `VALKEY_URL` and proves it answers `PING`; a server that cannot be reached is +//! [`BootError::Valkey`] — the refusal `store/mod.rs` has said since `S-C29` a required service +//! earns: *"Valkey is required; the server refuses to boot without `VALKEY_URL`"*. +//! +//! And then the arm **still refuses**, because five durable ports have an adapter on neither +//! side: they are #446's. Refusing there rather than filling them with in-memory adapters is the +//! whole point. design/filesystem/server.md is explicit — *"Required means required"* — and a +//! server that came up holding state it will lose on the next restart is worse than one that does +//! not start. So the arm proves everything it honestly can, says exactly what is missing, and +//! #446 turns the last `Err` into an `Ok`. +//! +//! No `VALKEY_URL` and no `--memory` is a configuration fault naming the variable +//! ([`Config::load`]). Nothing here ever silently becomes an in-memory server. +//! +//! # What the memory profile is, precisely +//! +//! Every deterministic in-crate adapter, over a **real** [`FilesystemBlobStore`] and a real +//! [`SystemClock`]. Two consequences worth stating because an operator will meet both: +//! +//! - **The blobs survive a restart and the index does not.** That is not a bug to route around, +//! it is the shape of a profile whose durable half is exactly the one adapter that has been +//! written. It also makes the profile useful to `scrub`, which compares those two halves and +//! will honestly report every blob as an orphan. +//! - **The collector's marks do not survive either.** [`crate::gc::collect`] marks a blob on one +//! pass and sweeps it on a later pass once the grace window has passed, so a fresh process can +//! only ever mark. Sweeping needs a durable `CollectionStore`, which is #446's — #402 landed +//! the durable index the collector *reads*, not the marks it writes. + +use std::sync::Arc; + +use jiff::Timestamp; + +use crate::album::authority::ProvisionedAuthority; +use crate::album::{AlbumContext, InMemoryAlbums}; +use crate::app::{App, Modules}; +use crate::attestation::{AttestationContext, InMemoryReceipts, LocalAttestationKey}; +use crate::auth::oidc::{ + HttpIdentityProvider, IdentityProvider, InMemoryFederatedAccounts, OidcCollaborators, + OidcContext, OidcSettings, +}; +use crate::auth::{ + AuthCollaborators, AuthContext, Credentials, InMemoryAccounts, InMemoryTotp, SessionTokens, + TotpCodes, TotpContext, +}; +use crate::blob::FilesystemBlobStore; +use crate::config::{Backends, Config}; +use crate::counter::{CounterContext, InMemoryCounters}; +use crate::directory::{DeviceDirectoryContext, InMemoryDeviceDirectory}; +use crate::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo}; +use crate::drop::{DropContext, InMemoryDrops}; +use crate::enrollment::EnrollmentContext; +use crate::escrow::{EscrowContext, InMemoryEscrow}; +use crate::federation::{ + CapabilityCodec, FederationCollaborators, FederationContext, InMemoryCapabilities, + InMemoryPeers, +}; +use crate::gc::CollectionContext; +use crate::gc::memory::InMemoryCollection; +use crate::index::memory::InMemoryAssetIndex; +use crate::membership::{InMemoryMembership, MembershipContext}; +use crate::moderation::{InMemoryModeration, ModerationContext}; +use crate::quota::{InMemoryQuota, QuotaContext, QuotaLimits}; +use crate::scrub::ScrubContext; +use crate::serve::ServeContext; +use crate::share::{InMemoryShares, ShareContext}; +use crate::store::SystemClock; +use crate::store::memory::{ + InMemoryAuthState, InMemoryChallenges, InMemoryChannels, InMemoryCohorts, InMemoryEnrollments, + InMemoryOidcAuthorizations, InMemoryUploadSessions, +}; +use crate::store::valkey::ValkeyStores; +use crate::sync::{CursorCodec, SyncContext}; +use crate::upload::{UploadContext, UploadPolicy}; +use crate::verify::VerifyContext; + +/// Why a process could not be assembled. +/// +/// Every variant is a **startup** failure. There is deliberately no variant for a degraded boot: +/// a server that came up with one port missing would answer some requests and 500 on others, +/// which is harder to diagnose than a process that refused to start and said why. +#[derive(Debug, thiserror::Error)] +pub enum BootError { + /// The configuration itself is not usable. + #[error(transparent)] + Configuration(#[from] crate::config::ConfigError), + /// A setting [`Config::load`] treats as optional is required by this path. + /// + /// The backstop behind [`crate::config::Demands`], which is the aggregating front door an + /// operator reads. This variant fires only when the two disagree — a subcommand asking for + /// more than it declared — which is a programming error rather than a deployment one, and + /// failing loudly beats an `expect`. + #[error("{key} is required to assemble this server and is not set")] + Missing { + /// The setting. + key: &'static str, + }, + /// The blob root could not be opened. + /// + /// Refused rather than deferred: a store that cannot be created now is a store every upload + /// will fail against at write time, and a server that accepts bytes it cannot keep is worse + /// than one that does not start. + #[error("the blob store at {root} could not be opened: {detail}")] + BlobRoot { + /// The path that was tried. + root: String, + /// The filesystem's own description. + detail: String, + }, + /// The token-signing key could not be read. + #[error("the server's token-signing key could not be loaded: {detail}")] + SigningKey { + /// What was wrong with it. Never the key. + detail: String, + }, + /// The credential verifier could not be built. + #[error("the credential verifier could not be built: {detail}")] + Credentials { + /// The algorithm's own description. + detail: String, + }, + /// The durable backend could not be opened, or is not the schema this binary expects. + /// + /// Never carries the connection URL: a `DATABASE_URL` holds a password, and a startup error + /// is the most-copied line in any incident channel. + #[error("the durable backend could not be opened: {detail}")] + Database { + /// The driver's own description, or which migration is missing. Never the URL. + detail: String, + }, + /// The relying party's outbound HTTP client could not be built — including a CA bundle + /// (`OIDC_CA_BUNDLE`) that could not be read or holds no certificate. + #[error("the OIDC relying party's HTTP client could not be built: {detail}")] + OidcClient { + /// What went wrong; names the bundle's path, never its contents. + detail: String, + }, + /// A durable backend was selected and its adapter is not written yet. + /// + /// Named with the issue that will honour it, because "not implemented" without a pointer is + /// a dead end for whoever reads it. + #[error("{key} selects a durable adapter that is not implemented yet (see {issue})")] + AdapterUnavailable { + /// The setting that selected it. + key: &'static str, + /// Where the work is tracked. + issue: &'static str, + }, + /// The Valkey at `VALKEY_URL` could not be reached, or did not answer `PING`. + /// + /// Required means required: a server that came up without its session store would answer + /// nothing an authenticated client sent, and saying so at boot beats discovering it per + /// request. `detail` never carries the URL, which carries the password. + #[error("the Valkey at VALKEY_URL could not be reached: {detail}")] + Valkey { + /// The adapter's own description of the failure. + detail: String, + }, + /// An operator command was run without `--memory` and one of the stores it reads has no + /// durable adapter. + /// + /// Deliberately not [`Self::AdapterUnavailable`]: that one names `DATABASE_URL`, which an + /// operator running `capsule-server scrub` has typically never set, and pointing them at a + /// variable that would not have helped is worse than saying nothing. + /// + /// The durable **index** exists as of #402, so this no longer says otherwise. What is still + /// missing is on the worker's own side: the collector marks a blob on one pass and sweeps it + /// on a later one, so a volatile `CollectionStore` means a process can only ever mark; and + /// the scrub reads the upload-session store to tell a live transfer apart from an orphan. + #[error( + "this command needs `--memory`: it reads stores that have no durable adapter yet — the \ + collector's marks and the upload sessions the scrub reconciles against (see {issue})" + )] + MaintenanceNeedsMemory { + /// Where the work is tracked. + issue: &'static str, + }, + /// The router's own types do not describe a buildable server. + /// + /// Unreachable in practice — the conformance suite builds the same router on every test run + /// — and kept because the alternative is an `expect` in the composition root. + #[error("the router could not be built: {detail}")] + Router { + /// Kynos's own description. + detail: String, + }, +} + +/// The two operator workers' collaborators. +/// +/// Assembled **without any key material**, which is what makes `config`'s claim that +/// `gc`/`purge`/`scrub` need none structural rather than a promise: there is no signing key in +/// scope here to accidentally require. A maintenance host that had to hold the production +/// token-signing key to sweep a directory would be a reason to put the key on a maintenance +/// host. +/// +/// Neither worker has a wire surface, so neither is reachable through the router — which is why +/// they are a separate assembly rather than fields on [`App`]. +#[derive(Debug)] +pub struct Maintenance { + /// The collector's collaborators (`gc`, `purge`). + pub collection: CollectionContext, + /// The integrity scrub's collaborators (`scrub`). + pub scrub: ScrubContext, +} + +/// A server, ready to serve. +/// +/// Carries [`Maintenance`] as well, over the **same** stores: one index, one blob store and one +/// mark store behind all three, which is what makes "upload it, then let the collector see it" a +/// property of the server rather than of three disconnected assemblies. +#[derive(Debug)] +pub struct Assembled { + /// The application context every operation resolves its dependencies from. + pub app: App, + /// The operator workers, over the same stores. + pub maintenance: Maintenance, +} + +impl Assembled { + /// Build the service the listener drives. + /// + /// # Errors + /// + /// Returns [`BootError::Router`] if the router's types do not describe a buildable server. + pub fn service(&self) -> Result, BootError> { + crate::service(self.app.clone()).map_err(|error| BootError::Router { + detail: error.to_string(), + }) + } +} + +/// Assemble a server from `config`. +/// +/// # Errors +/// +/// Returns [`BootError`] for any of the startup failures above. Nothing is left half-built: the +/// blob root is the only side effect, and it is idempotent. +pub async fn assemble(config: &Config) -> Result { + let stores = stores(config).await?; + match config.backends { + Backends::Memory => memory(config, stores), + Backends::Durable => { + // Checked before anything else the durable arm does, so it survives the adapters + // filling that arm: the OIDC ceremony store and the federated-account directory have + // in-memory adapters only (#460), and a durable profile must never run one of those + // beside a real Valkey — a pending authorization that lives in one replica's memory + // is a sign-in that fails whenever the callback lands on another. + if config.oidc.is_some() { + return Err(BootError::AdapterUnavailable { + key: "OIDC_ISSUER", + issue: "#460 (the OIDC ceremony and federated-account adapters)", + }); + } + // Both probes run for real, in the order the ports are declared: `DATABASE_URL` is + // demanded and the pool opened first — a schema that is not the one this binary was + // built for refuses here — and then Valkey must answer `PING`. The arm still refuses + // afterwards, because the ports it cannot fill are the ones a server loses state + // without. See the module docs. + // + // Neither handle is passed on. Constructing the adapters and throwing them away + // would be theatre in production code; that they *do* compose out of exactly what + // this function has is asserted in `tests::postgres_conformance` instead, which is + // where an assertion belongs. + let _connection = open_durable_database(config).await?; + let _valkey = valkey(config).await?; + Err(durable_ports_owed()) + } + } +} + +/// Open the durable pool and refuse a schema this binary was not built for. +/// +/// The check is **not** a migration. `capsule-server` cannot link the migrator — see +/// `capsule-server/migration`'s manifest for the `chrono` gate that forces it — and a server that +/// migrated on start would run the migration once per replica during a rolling deploy anyway. +/// What it does instead is read `seaql_migrations` and refuse, naming the command an operator has +/// to run. +/// +/// `DATABASE_URL` is demanded here rather than by [`crate::config::Demands`] because it is this +/// *path* that needs it: `gc`, `purge` and `scrub` never reach here, and `serve --memory` does +/// not either. +async fn open_durable_database(config: &Config) -> Result { + let database_url = config.database_url.as_ref().ok_or(BootError::Missing { + key: "DATABASE_URL", + })?; + let connection = crate::postgres::connect(database_url) + .await + .map_err(|error| BootError::Database { + detail: error.to_string(), + })?; + crate::postgres::assert_schema_current(&connection) + .await + .map_err(|error| BootError::Database { + detail: error.to_string(), + })?; + tracing::info!("the durable database is open and its schema is current"); + Ok(connection) +} + +/// Assemble only what `gc`, `purge` and `scrub` read. +/// +/// # Errors +/// +/// Returns [`BootError`] for the blob root or an unimplemented durable backend. It cannot fail +/// on key material, because it asks for none. +pub async fn assemble_maintenance(config: &Config) -> Result { + let stores = stores(config).await?; + match config.backends { + Backends::Memory => { + let maintenance = stores.maintenance(config.grace_window); + tracing::info!( + blob_root = %stores.root.display(), + grace_window = %config.grace_window, + "assembled the operator workers on the in-memory adapters" + ); + Ok(maintenance) + } + // **Not** `durable_ports_owed()`. A maintenance command reaching here has almost always + // set no backend variable at all — `gc`/`purge`/`scrub` never demand `VALKEY_URL`, so + // naming it would send an operator to configure a variable that would not have helped. + // + // The durable index exists as of #402, and it is not what is missing. The collector + // marks a blob on one pass and sweeps it on a later one, so a `CollectionStore` that + // forgets is a collector that can only ever mark — and it has no durable adapter at all, + // which is #446's. The upload-session store the scrub reconciles against (how it tells a + // live transfer apart from an orphan) does have one as of #403, but it is on Valkey and + // these commands deliberately never demand `VALKEY_URL`; opening a connection they were + // built not to need is its own decision, and #446 is where it belongs. + Backends::Durable => Err(BootError::MaintenanceNeedsMemory { + issue: "#446 (the collector's marks, and a durable read path for these commands)", + }), + } +} + +/// Connect to `VALKEY_URL` and prove it answers, before any module is assembled. +/// +/// Straight after the durable pool, and before a socket is bound: a Valkey that cannot be +/// reached is the fault an operator most needs named early. Every Valkey store — sessions, +/// upload sessions and the three ceremonies — comes back on one connection and the real clock; +/// the counter adapter shares that connection. +async fn valkey(config: &Config) -> Result { + let url = config + .valkey_url + .as_deref() + .ok_or(BootError::Missing { key: "VALKEY_URL" })?; + let stores = ValkeyStores::connect(url, Arc::new(SystemClock)) + .await + .map_err(|error| BootError::Valkey { + detail: error.to_string(), + })?; + tracing::info!("VALKEY_URL answers; the volatile state ports are on Valkey"); + Ok(stores) +} + +/// The trust anchors `OIDC_CA_BUNDLE` names, read once at boot. +/// +/// Refused rather than deferred, like the blob root: a bundle that cannot be read now is a +/// provider every sign-in will fail against at handshake time, with a less legible error. An +/// empty bundle is refused too — an operator who named a file meant it to hold something. +fn oidc_roots(path: &std::path::Path) -> Result, BootError> { + let shown = path.display(); + let pem = std::fs::read(path).map_err(|error| BootError::OidcClient { + detail: format!("OIDC_CA_BUNDLE `{shown}` could not be read: {error}"), + })?; + let roots = + reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| BootError::OidcClient { + detail: format!("OIDC_CA_BUNDLE `{shown}` is not a PEM certificate bundle: {error}"), + })?; + if roots.is_empty() { + return Err(BootError::OidcClient { + detail: format!("OIDC_CA_BUNDLE `{shown}` holds no certificate"), + }); + } + tracing::info!(bundle = %shown, roots = roots.len(), "trusting additional roots for the identity provider"); + Ok(roots) +} + +/// The refusal `store/mod.rs` documents, now reached only for the ports neither #402 nor #403 +/// landed. +/// +/// `Config::load` already turned "no `VALKEY_URL` and no `--memory`" into a configuration fault +/// naming the variable, so reaching here means the operator *did* set it — and both probes have +/// run by this point: the pool is open, the schema has been checked, and Valkey has answered +/// `PING`. A durable deployment therefore fails on the ports that are genuinely absent rather +/// than on the first one anybody happened to write. +fn durable_ports_owed() -> BootError { + BootError::AdapterUnavailable { + key: "DATABASE_URL", + issue: "#446 (the remaining durable ports)", + } +} + +/// The stores every subcommand shares, and the only one of them that is durable. +/// +/// A struct rather than six locals because [`assemble`] and [`assemble_maintenance`] must build +/// the *same* stores: two functions each opening their own index is two servers that disagree +/// about what is in it. +#[derive(Debug)] +struct Stores { + root: std::path::PathBuf, + clock: Arc, + blobs: Arc, + index: Arc, + uploads: Arc, + marks: Arc, + quotas: Arc, +} + +impl Stores { + /// The operator workers over these stores. + fn maintenance(&self, grace_window: jiff::SignedDuration) -> Maintenance { + Maintenance { + collection: CollectionContext::new( + self.index.clone(), + self.blobs.clone(), + self.marks.clone(), + self.quotas.clone(), + self.clock.clone(), + grace_window, + ), + scrub: ScrubContext::new(self.index.clone(), self.blobs.clone(), self.uploads.clone()), + } + } +} + +/// Open the blob root and build the stores over it. +/// +/// The blob root is the one thing on this path that touches the filesystem, and it is refused +/// rather than deferred: a store that cannot be created now is a store every upload will fail +/// against at write time, and a server that accepts bytes it cannot keep is worse than one that +/// does not start. +async fn stores(config: &Config) -> Result { + let root = config + .blob_root + .clone() + .ok_or(BootError::Missing { key: "BLOB_ROOT" })?; + let clock = Arc::new(SystemClock); + let blobs = + Arc::new( + FilesystemBlobStore::open(&root) + .await + .map_err(|error| BootError::BlobRoot { + root: root.display().to_string(), + detail: error.to_string(), + })?, + ); + Ok(Stores { + root, + index: Arc::new(InMemoryAssetIndex::new()), + uploads: Arc::new(InMemoryUploadSessions::with_default_ttl(clock.clone())), + marks: Arc::new(InMemoryCollection::new()), + quotas: Arc::new(InMemoryQuota::new()), + blobs, + clock, + }) +} + +/// The development profile: every in-crate adapter, over a real blob store and a real clock. +#[allow( + clippy::too_many_lines, + reason = "seventeen module contexts, named once each; splitting it would hide the shape" +)] +fn memory(config: &Config, stores: Stores) -> Result { + let der = config.signing_key_der.as_ref().ok_or(BootError::Missing { + key: "JWT_ED25519_DER", + })?; + let cursor_key = config.sync_cursor_mac_key.ok_or(BootError::Missing { + key: "SYNC_CURSOR_MAC_KEY", + })?; + let seed = config.attestation_key_seed.ok_or(BootError::Missing { + key: "ATTESTATION_KEY_SEED", + })?; + + // Cloned rather than moved: `stores` is handed to `Stores::maintenance` at the end, so the + // application and the two operator workers are built over the *same* stores. Every clone + // here is an `Arc` refcount bump. + let root = stores.root.clone(); + let clock = stores.clock.clone(); + let blobs = stores.blobs.clone(); + let index = stores.index.clone(); + let uploads = stores.uploads.clone(); + let marks = stores.marks.clone(); + let quotas = stores.quotas.clone(); + + // The signer is built from the private key alone and derives its own public half, which is + // what lets `ServerInfo` below publish the key tokens actually verify under rather than one + // an operator pasted beside it. + let tokens = Arc::new( + SessionTokens::from_pkcs8(der.expose(), clock.clone()).map_err(|error| { + BootError::SigningKey { + detail: error.detail, + } + })?, + ); + + // One verifier, shared: building it costs an Argon2id hash (the timing-equalized miss's + // decoy), and that is a startup cost rather than a per-request one. + let credentials = Credentials::new().map_err(|error| BootError::Credentials { + detail: error.detail, + })?; + let accounts = Arc::new(InMemoryAccounts::new( + credentials, + clock.clone(), + config.lockout_attempts, + config.lockout_window, + )); + + let albums = Arc::new(InMemoryAlbums::new()); + let members = Arc::new(InMemoryMembership::new()); + let directories = Arc::new(InMemoryDeviceDirectory::new()); + // The production write authority (`S-C19`/`S-C20`), not a permissive double: it reads the + // album's own pin and the account's published device directory, so invariants 6 and 7 mean + // what they say even in the development profile. + let authority = Arc::new(ProvisionedAuthority::new( + albums.clone(), + directories.clone(), + members.clone(), + clock.clone(), + )); + let receipts = Arc::new(InMemoryReceipts::new()); + // Distinct from the token signer, as the design requires: a receipt that verified under the + // operational key would let anything holding that key manufacture custody evidence. + // + // Which is why a durable deployment has to **supply** `ATTESTATION_KEY_SEED` rather than + // have it derived (`config`, the key-material section). A different HKDF `info` over the + // same input is not a separation at all — anyone holding `JWT_ED25519_DER` recomputes it — + // and it read as one, which is worse than no comment. The derivation survives only under + // `Backends::Memory`, where the server is a development act whose state is discarded. + let attestation_key = Arc::new(LocalAttestationKey::new( + config.server_domain.clone(), + capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), + )); + + // The capability codec signs with the **same** key: a peer verifies a capability against + // the key `server-info` publishes, and that key is read out of the session signer. Built + // from the same bytes rather than handed the signer, so the two stay one key by + // construction; `tests::the_capability_codec_signs_under_the_published_key` asserts it. + let capabilities = Arc::new( + CapabilityCodec::from_pkcs8(der.expose(), config.server_domain.clone(), clock.clone()) + .map_err(|error| BootError::SigningKey { + detail: error.detail, + })?, + ); + // The capability store **is** the revocation list `revoked-jti` serves: one object, handed + // to discovery as the list and to federation as the store (design/federation.md). + let issued = Arc::new(InMemoryCapabilities::new(clock.clone())); + let peers = Arc::new(InMemoryPeers::new()); + + let mut server_info = ServerInfo::new( + config.server_domain.clone(), + config.api_base_url.clone(), + ProtocolWindow { + min: config.protocol_min.clone(), + max: config.protocol_max.clone(), + }, + tokens.public_key().to_vec(), + ); + if config.oidc.is_some() { + // Endpoints only; the issuer and client id stay the server's. + server_info = server_info.with_oidc(); + } + if let Some(url) = &config.federation_url { + server_info = server_info.with_federation(url.clone()); + } + let server_info = Arc::new(server_info); + + // The relying party, or the null object. Discovery is lazy: nothing here reaches the + // provider, so an identity provider that is down does not stop a server from serving local + // auth. + let oidc = match &config.oidc { + Some(oidc) => { + let roots = match &oidc.ca_bundle { + Some(path) => oidc_roots(path)?, + None => Vec::new(), + }; + let http = HttpIdentityProvider::http_client(&roots).map_err(|error| { + BootError::OidcClient { + detail: error.to_string(), + } + })?; + let provider: Arc = Arc::new(HttpIdentityProvider::new( + OidcSettings { + issuer: oidc.issuer.clone(), + client_id: oidc.client_id.clone(), + client_secret: oidc.client_secret.clone(), + redirects: oidc.redirects.clone(), + }, + http, + clock.clone(), + )); + tracing::info!(issuer = %oidc.issuer, "the OIDC relying party is configured"); + OidcContext::new(OidcCollaborators { + provider, + authorizations: Arc::new(InMemoryOidcAuthorizations::with_default_ttl( + clock.clone(), + )), + accounts: Arc::new(InMemoryFederatedAccounts::new()), + clock: clock.clone(), + }) + } + None => OidcContext::disabled(clock.clone()), + }; + + let app = App::new(Modules { + auth: AuthContext::new(AuthCollaborators { + sessions: Arc::new(InMemoryAuthState::with_default_ttl(clock.clone())), + accounts: accounts.clone(), + registry: accounts.clone(), + profiles: accounts.clone(), + passwords: accounts.clone(), + challenges: Arc::new(InMemoryChallenges::with_default_ttl(clock.clone())), + cohorts: Arc::new(InMemoryCohorts::new()), + tokens: tokens.clone(), + clock: clock.clone(), + }), + totp: TotpContext::new( + Arc::new(InMemoryTotp::new()), + // The issuer is what an authenticator app shows beside the code, so it is this + // deployment's own name rather than a constant every deployment shares. + Arc::new(TotpCodes::new(config.server_domain.clone())), + ), + oidc, + upload: UploadContext::new( + uploads.clone(), + blobs.clone(), + index.clone(), + authority.clone(), + clock.clone(), + // The window the operator configured, not the crate default: this policy is what + // the handshake enforces and what every response advertises (`negotiation`), and the + // discovery record above publishes the same two values. One window, three readers. + UploadPolicy::default() + .with_protocol_window(config.protocol_min.clone(), config.protocol_max.clone()) + .with_min_client_build(config.min_client_build.clone()), + ), + sync: SyncContext::new( + index.clone(), + blobs.clone(), + Arc::new(CursorCodec::new(&cursor_key)), + albums.clone(), + members.clone(), + ), + serve: ServeContext::new( + index.clone(), + blobs.clone(), + marks.clone(), + uploads.clone(), + crate::serve::membership_reads(members.clone()), + ), + verify: VerifyContext::new(index.clone(), blobs.clone(), marks.clone(), clock.clone()), + directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), + albums: AlbumContext::new(albums.clone(), clock.clone()), + membership: MembershipContext::new(members, clock.clone()), + // Unlimited, which is what a self-hosted deployment runs. A configurable ceiling is a + // quota policy this slice does not own; `QuotaLimits` already takes one. + quota: QuotaContext::new(quotas.clone(), clock.clone(), QuotaLimits::unlimited()), + attestation: AttestationContext::new( + receipts.clone(), + attestation_key, + // The published key has been active since the epoch, because the seed is derived + // deterministically and has therefore never *not* been this deployment's key. + // Publishing a rotation history is `ATTESTATION_KEY_HISTORY`'s job and nobody's yet. + Timestamp::UNIX_EPOCH, + ), + discovery: DiscoveryContext::new(server_info, issued.clone()), + escrow: EscrowContext::new(Arc::new(InMemoryEscrow::new()), clock.clone()), + federation: FederationContext::new(FederationCollaborators { + codec: capabilities, + capabilities: issued, + peers, + clock: clock.clone(), + federation_url: config.federation_url.clone(), + }), + enrollment: EnrollmentContext::new( + Arc::new(InMemoryEnrollments::with_default_ttl(clock.clone())), + Arc::new(InMemoryChannels::with_default_ttl(clock.clone())), + clock.clone(), + ), + moderation: ModerationContext::new(Arc::new(InMemoryModeration::new())), + share: ShareContext::new( + Arc::new(InMemoryShares::new()), + blobs.clone(), + clock.clone(), + ), + drops: DropContext::new( + Arc::new(InMemoryDrops::new()), + uploads.clone(), + blobs.clone(), + clock.clone(), + ), + counters: CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone()), + }); + + tracing::info!( + blob_root = %root.display(), + server_id = %config.server_domain, + protocol_min = %config.protocol_min, + protocol_max = %config.protocol_max, + "assembled a server on the in-memory adapters" + ); + + Ok(Assembled { + app, + // The same stores, so "upload it, then let the collector see it" is a property of the + // server rather than of two disconnected assemblies. + maintenance: stores.maintenance(config.grace_window), + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{App, BootError, assemble}; + use crate::config::{Config, Demands, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64. Signs nothing; see `config`'s own tests. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn memory_config(root: &std::path::Path) -> Config { + memory_config_with(root, &[]) + } + + /// The same, with `extra` on top of the environment. + fn memory_config_with(root: &std::path::Path, extra: &[(&str, &str)]) -> Config { + let mut environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ] + .into_iter() + .collect(); + for (key, value) in extra { + environment.insert((*key).to_owned(), (*value).to_owned()); + } + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + Config::load(&environment, &overrides, Demands::Serve).expect("the configuration loads") + } + + /// Register the account the auth cases sign in with, through the surface. + async fn register(client: &kynos::test::TestClient, password: &str) { + client + .post("/v1/auth/register") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + } + + /// Attempt a sign-in and return the status the route answered with. + async fn login( + client: &kynos::test::TestClient, + password: &str, + ) -> kynos::http::StatusCode { + client + .post("/v1/auth/login") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ "email": "somebody@example.test", "password": password })) + .send() + .await + .status() + } + + #[tokio::test] + async fn the_memory_profile_assembles_a_server_whose_router_builds() { + // The property nothing in this crate asserted before: seventeen module contexts, every + // port filled, and a router Kynos will build out of them. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + assembled.service().expect("the router builds"); + } + + #[tokio::test] + async fn the_blob_root_is_created_rather_than_required_to_exist() { + let parent = tempfile::tempdir().expect("a scratch directory"); + let root = parent.path().join("does/not/exist/yet"); + let assembled = assemble(&memory_config(&root)).await.expect("it assembles"); + assert!(root.join("blobs").is_dir(), "the store's tree is created"); + drop(assembled); + } + + #[tokio::test] + async fn a_signing_key_that_is_not_ed25519_refuses_the_boot() { + // `SigningKeyError` has always been documented as a startup failure. This is the startup + // it fails. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ( + "JWT_ED25519_DER".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"not a PKCS#8 document", + ), + ), + ] + .into_iter() + .collect(); + let overrides = Overrides { + memory: true, + ..Overrides::default() + }; + let config = + Config::load(&environment, &overrides, Demands::Serve).expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::SigningKey { .. }), "{error:?}"); + } + + #[tokio::test] + async fn a_durable_backend_without_a_database_url_refuses_by_name() { + // `Config::load` demands `VALKEY_URL` for the durable path and deliberately does not + // demand `DATABASE_URL` — `gc`, `purge` and `scrub` load the same configuration and need + // neither. So the demand belongs to this *path*, and this is the assertion that it is + // made rather than discovered as a `None` somewhere further in. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = Config::load( + &durable_environment(root.path()), + &Overrides::default(), + Demands::Serve, + ) + .expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::Missing { + key: "DATABASE_URL" + } + ), + "{error:?}" + ); + } + + /// The environment a durable `serve` needs, minus the backend URLs a case adds itself. + fn durable_environment(root: &std::path::Path) -> BTreeMap { + [ + ("BLOB_ROOT".to_owned(), root.display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ("VALKEY_URL".to_owned(), "redis://127.0.0.1:1".to_owned()), + // A durable deployment supplies its own attestation identity rather than having one + // derived from the token signer; `config` refuses without it. + ( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [9_u8; 64].as_slice(), + ), + ), + ] + .into_iter() + .collect() + } + + /// What a durable boot does once it can actually reach a database. + mod postgres_conformance { + use std::sync::Arc; + + use super::{ + BTreeMap, BootError, Config, Demands, Overrides, assemble, durable_environment, + }; + use crate::auth::{Credentials, PostgresAccounts}; + use crate::federation::postgres::{PostgresCapabilities, PostgresPeers}; + use crate::index::postgres::PostgresAssetIndex; + use crate::membership::PostgresMembership; + use crate::postgres::testing; + use crate::quota::PostgresQuota; + use crate::store::{PostgresCohorts, SystemClock}; + + /// A config pointing at `url`, with everything else a durable `serve` needs. + fn config_for(root: &std::path::Path, url: &str) -> Config { + let mut environment: BTreeMap = durable_environment(root); + environment.insert("DATABASE_URL".to_owned(), url.to_owned()); + Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect("it is well-formed") + } + + /// A durable boot clears Postgres and refuses on Valkey rather than falling back. + /// + /// The property that matters is *which* refusal: falling back to the in-memory adapters + /// is the one thing that must never happen, and refusing on the first port anybody + /// happened to write would hide what is actually missing. Getting as far as the Valkey + /// probe proves the pool opened and the schema check passed. + /// + /// This was two cases before #402 and #403 met. #403 asserted the Valkey refusal as a + /// unit test, because nothing had yet demanded `DATABASE_URL` ahead of it; #402 asserted + /// that the arm reached `AdapterUnavailable { key: "VALKEY_URL" }`, which was the + /// refusal of a port *no adapter read*. Both halves have landed, so the durable arm now + /// needs a real database before it dials Valkey at all, and the honest error at + /// `redis://127.0.0.1:1` is [`BootError::Valkey`] — the adapter tried and nothing + /// answered. One case, both lanes' assertions, and it needs a container to reach. + #[tokio::test] + async fn the_durable_arm_clears_postgres_and_refuses_on_an_unreachable_valkey() { + let Some(database) = testing::start("the durable boot arm").await else { + return; + }; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::Valkey { .. }), "{error:?}"); + let message = format!("{error}"); + assert!(message.contains("VALKEY_URL"), "{message}"); + assert!(!message.contains("127.0.0.1:1"), "never the URL: {message}"); + } + + /// A database that has not been migrated refuses, and names the command that fixes it. + /// + /// The whole reason `serve` reads `seaql_migrations` rather than running + /// `Migrator::up`: the alternative is a rolling deploy in which every replica races to + /// apply the same schema change. + #[tokio::test] + async fn an_unmigrated_database_refuses_and_names_the_migration_command() { + let Some(database) = testing::start("an unmigrated durable boot").await else { + return; + }; + database.roll_back().await; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::Database { .. }), "{error:?}"); + let rendered = format!("{error}"); + assert!( + rendered.contains("capsule-server-migration up"), + "the refusal must name the command that fixes it, got {rendered}" + ); + assert!( + !rendered.contains(database.url()), + "a startup error must never carry the connection URL: {rendered}" + ); + } + + /// The seven Postgres adapters compose out of exactly what the boot path has. + /// + /// Asserted here rather than by constructing them in `assemble` and throwing them away: + /// production code that builds something it cannot use is theatre, and what #446 needs + /// to know is that its one hunk will type-check. Every constructor takes the shared + /// connection plus values `Config` already carries. + #[tokio::test] + async fn every_postgres_adapter_composes_from_the_boot_configuration() { + let Some(database) = testing::start("the durable adapter set").await else { + return; + }; + let root = tempfile::tempdir().expect("a scratch directory"); + let config = config_for(root.path(), database.url()); + let connection = crate::postgres::connect(&config.database_url.clone().expect("set")) + .await + .expect("the pool opens"); + let credentials = Credentials::new().expect("the platform hashes"); + let clock = Arc::new(SystemClock); + + let index: Arc = + Arc::new(PostgresAssetIndex::new(connection.clone())); + let accounts = Arc::new(PostgresAccounts::new( + connection.clone(), + credentials, + clock, + config.lockout_attempts, + config.lockout_window, + )); + let cohorts: Arc = + Arc::new(PostgresCohorts::new(connection.clone())); + let quotas: Arc = + Arc::new(PostgresQuota::new(connection.clone())); + let members: Arc = + Arc::new(PostgresMembership::new(connection.clone())); + let capabilities: Arc = Arc::new( + PostgresCapabilities::new(connection.clone(), Arc::new(SystemClock)), + ); + let peers: Arc = + Arc::new(PostgresPeers::new(connection)); + + // Each one answers through its port, which is what makes this a boot check rather + // than a compile check: the schema the migration applied is the schema the adapters + // query. + let owner = crate::store::OwnerId::new("boot-probe-owner"); + assert_eq!(index.head_seq(&owner).await.expect("the index answers"), 0); + let user = crate::store::UserId::new("boot-probe-user"); + assert!( + crate::auth::AccountProfiles::read(accounts.as_ref(), &user) + .await + .expect("the account store answers") + .is_none() + ); + assert!( + cohorts + .cohorts_for_user(&user) + .await + .expect("the cohort map answers") + .is_empty() + ); + assert_eq!( + quotas.usage(&user).await.expect("the ledger answers").used, + 0 + ); + let album = crate::store::AlbumId::new("boot-probe-album"); + assert_eq!( + members + .membership(&album, &user) + .await + .expect("the membership store answers"), + crate::membership::Membership::Never + ); + assert!( + capabilities + .find("boot-probe-jti") + .await + .expect("the capability store answers") + .is_none() + ); + assert!( + peers + .read(&crate::federation::PeerId::new("boot-probe.test")) + .await + .expect("the peer store answers") + .is_none() + ); + } + } + + #[tokio::test] + async fn a_durable_backend_with_oidc_configured_names_the_adapter_issue() { + // The in-memory ceremony and federated-account adapters are the only ones written; a + // durable profile must never run one of them beside a real Valkey. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = [ + ("BLOB_ROOT".to_owned(), root.path().display().to_string()), + ("JWT_ED25519_DER".to_owned(), EXAMPLE_DER.to_owned()), + ("VALKEY_URL".to_owned(), "redis://127.0.0.1:6379".to_owned()), + ( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [9_u8; 64].as_slice(), + ), + ), + ( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ), + ("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()), + ] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect("it is well-formed"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!( + matches!( + error, + BootError::AdapterUnavailable { + key: "OIDC_ISSUER", + .. + } + ), + "{error:?}" + ); + assert!(format!("{error}").contains("#460"), "{error}"); + } + + #[tokio::test] + async fn a_ca_bundle_is_read_at_boot_and_refused_by_name_when_unusable() { + let root = tempfile::tempdir().expect("a scratch directory"); + let bundle = root.path().join("idp-ca.pem"); + + // Missing: refused, naming the path. + let config = memory_config_with( + root.path(), + &[ + ("OIDC_ISSUER", "https://idp.example.test"), + ("OIDC_CLIENT_ID", "capsule"), + ("OIDC_CA_BUNDLE", &bundle.display().to_string()), + ], + ); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::OidcClient { .. }), "{error:?}"); + assert!(format!("{error}").contains("idp-ca.pem"), "{error}"); + + // Present and not a certificate: refused, and the contents are not echoed. + std::fs::write( + &bundle, + "this is not a certificate, it is a secret-looking string", + ) + .expect("writes"); + let error = assemble(&config).await.expect_err("it refuses"); + assert!(matches!(error, BootError::OidcClient { .. }), "{error:?}"); + assert!(!format!("{error}").contains("secret-looking"), "{error}"); + + // A real CA certificate: the server boots, trusting it. + let key = rcgen::KeyPair::generate().expect("a key"); + let mut params = rcgen::CertificateParams::new(Vec::::new()).expect("params"); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca = params.self_signed(&key).expect("a CA"); + let body = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ca.der()); + let pem = format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----\n"); + std::fs::write(&bundle, pem).expect("writes"); + let assembled = assemble(&config).await.expect("it assembles"); + assembled.service().expect("the router builds"); + } + + #[tokio::test] + async fn the_memory_profile_assembles_with_a_relying_party_without_reaching_it() { + // Discovery is lazy: an issuer nothing answers at is still a server that boots. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[ + ("OIDC_ISSUER", "http://127.0.0.1:9/nothing-listens-here"), + ("OIDC_CLIENT_ID", "capsule"), + ], + ); + let assembled = assemble(&config).await.expect("it assembles"); + assembled.service().expect("the router builds"); + } + + #[tokio::test] + async fn the_published_signing_key_is_the_one_the_tokens_verify_under() { + // Not a coincidence to be re-checked at every deployment: `ServerInfo` is built from + // `tokens.public_key()`, so there is no second copy for an operator to paste wrongly. + use crate::auth::SessionTokens; + use crate::store::SystemClock; + + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let expected = SessionTokens::from_pkcs8( + config + .signing_key_der + .as_ref() + .expect("the key is configured") + .expose(), + std::sync::Arc::new(SystemClock), + ) + .expect("the key parses") + .public_key() + .to_vec(); + + // Read back the way a client would, through the surface rather than through a field. + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + let published = body["signing_key"].as_str().expect("it is published"); + assert_eq!( + published, + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected) + ); + } + + #[tokio::test] + async fn the_capability_codec_signs_under_the_published_key() { + // A peer verifies a capability against `server-info`'s `signing_key`. The codec is + // built from the same DER as the session signer, so the two are one key — asserted + // through the surface and through the codec, rather than assumed from the wiring. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + let published = body["signing_key"].as_str().expect("it is published"); + let codec = crate::federation::CapabilityCodec::from_pkcs8( + config + .signing_key_der + .as_ref() + .expect("the key is configured") + .expose(), + config.server_domain.clone(), + std::sync::Arc::new(crate::store::SystemClock), + ) + .expect("the key parses"); + assert_eq!( + published, + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + codec.public_key() + ) + ); + assert_eq!(codec.server_id(), config.server_domain); + assert!( + body.get("federation_url").is_none(), + "a deployment without FEDERATION_URL publishes no federation endpoint" + ); + } + + #[tokio::test] + async fn federation_url_is_published_when_configured() { + // Opt-in by one variable, and the record is the only way a peer learns it. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[("FEDERATION_URL", "https://capsule.example/v1")], + ); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert_eq!(body["federation_url"], "https://capsule.example/v1"); + } + + #[tokio::test] + async fn the_published_protocol_window_is_the_configured_one() { + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert_eq!(body["protocol_version"]["min"], config.protocol_min); + assert_eq!(body["protocol_version"]["max"], config.protocol_max); + assert_eq!(body["server_id"], config.server_domain); + assert_eq!(body["api_base_url"], config.api_base_url); + } + + /// The window the handshake enforces and advertises is the configured one (issue #404). + /// + /// Before this the upload policy was `UploadPolicy::default()` regardless of `PROTOCOL_MIN` + /// and `PROTOCOL_MAX`, so a deployment that narrowed its window published one range on + /// `/.well-known/capsule/server-info` and enforced another on `POST /v1/upload`. + #[tokio::test] + async fn the_enforced_and_advertised_window_is_the_configured_one() { + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + // Every response advertises the window, an exempt read included. + let response = client + .get("/v1/version") + .header("accept", "application/json") + .send() + .await; + response.assert_status(kynos::http::StatusCode::OK); + assert_eq!( + response.header("x-capsule-protocol-min"), + Some(config.protocol_min.as_str()) + ); + assert_eq!( + response.header("x-capsule-protocol-max"), + Some(config.protocol_max.as_str()) + ); + assert_eq!( + response.header("x-capsule-min-client-build"), + Some(config.min_client_build.as_str()) + ); + + // And the gate holds a write to the same window: a version one day below the + // configured minimum is refused before authentication is even looked at. (A read would + // be admitted at any date — threat-model/validation.md — which is why this is a `DELETE`.) + let below = format!( + "{}", + config + .protocol_min + .parse::() + .expect("the configured minimum is a date") + .yesterday() + .expect("there is a day before it") + ); + let refused = client + .delete("/v1/upload/anything") + .header("x-capsule-protocol", &below) + .send() + .await; + refused.assert_status(kynos::http::StatusCode::UPGRADE_REQUIRED); + assert_eq!( + refused.header("x-capsule-protocol-min"), + Some(config.protocol_min.as_str()) + ); + } + + #[tokio::test] + async fn an_account_can_be_registered_and_signed_in_to() { + // The whole point of the amended deliverable boundary: `mise run serve-memory` is a + // server a client developer can point at, not a surface they can only read. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + let registered: serde_json::Value = client + .post("/v1/auth/register") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(registered["access_token"].is_string(), "{registered}"); + + let signed_in: serde_json::Value = client + .post("/v1/auth/login") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert!(signed_in["access_token"].is_string(), "{signed_in}"); + } + + #[tokio::test] + async fn a_locked_account_recovers_through_the_login_route_once_the_window_passes() { + // Asserted through the **route** rather than against the adapter, because that is where + // the property actually has to hold: `login` asks the directory first and answers `423` + // before it verifies anything, so a lockout that did not decay would be an account no + // request could ever open again — there is no unlock operation on any surface. + // + // A one-attempt threshold, a one-second window and a real wait. Both numbers are + // settings rather than constants precisely so this is expressible: driving the default + // ten-failure ceiling through the route would cost ten Argon2id verifications, and on a + // loaded machine the gaps between them can themselves exceed a short window — a test + // whose setup races its own subject. The alternative was a clock seam through the whole + // composition root for one case, and the composition root is the thing under test. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[ + ("LOCKOUT_MAX_ATTEMPTS", "1"), + ("LOCKOUT_WINDOW_SECONDS", "1"), + ], + ); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + + register(&client, "correct horse battery staple").await; + assert_eq!( + login(&client, "wrong").await, + kynos::http::StatusCode::UNAUTHORIZED + ); + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::LOCKED, + "the ceiling engages, and a correct password is told so rather than refused" + ); + + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + assert_eq!( + login(&client, "correct horse battery staple").await, + kynos::http::StatusCode::OK, + "the window passed, so the account is the owner's again" + ); + } + + #[tokio::test] + async fn a_maintenance_command_is_told_it_needs_memory_and_not_valkey() { + // An operator running `capsule-server scrub` has typically set no backend variable at + // all. Naming `VALKEY_URL` would send them to configure something that would not have + // helped; what is missing is the durable index these workers read. + let root = tempfile::tempdir().expect("a scratch directory"); + let environment: BTreeMap = + [("BLOB_ROOT".to_owned(), root.path().display().to_string())] + .into_iter() + .collect(); + let config = Config::load(&environment, &Overrides::default(), Demands::Maintenance) + .expect("maintenance demands nothing else"); + let error = super::assemble_maintenance(&config) + .await + .expect_err("it refuses"); + assert!( + matches!(error, BootError::MaintenanceNeedsMemory { .. }), + "{error:?}" + ); + let message = format!("{error}"); + assert!(message.contains("--memory"), "{message}"); + assert!(!message.contains("VALKEY_URL"), "{message}"); + } + + #[tokio::test] + async fn a_wrong_password_is_refused_rather_than_granted() { + // The property that makes the adapter real rather than permissive: the credential + // double `tests/support/mod.rs` warns about would accept this. + let root = tempfile::tempdir().expect("a scratch directory"); + let assembled = assemble(&memory_config(root.path())) + .await + .expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + client + .post("/v1/auth/register") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "correct horse battery staple", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::OK); + client + .post("/v1/auth/login") + .header( + "x-capsule-protocol", + capsule_core::crypto::primitives::PROTOCOL_VERSION, + ) + .header("accept", "application/json") + .json(&serde_json::json!({ + "email": "somebody@example.test", + "password": "the wrong password entirely", + })) + .send() + .await + .assert_status(kynos::http::StatusCode::UNAUTHORIZED); + } +} diff --git a/capsule-server/src/cli.rs b/capsule-server/src/cli.rs new file mode 100644 index 00000000..136af3c5 --- /dev/null +++ b/capsule-server/src/cli.rs @@ -0,0 +1,860 @@ +//! The `capsule-server` command line: one binary, several subcommands. +//! +//! # One binary and not four +//! +//! The Salvo tree shipped `capsule-gc`, `capsule-scrub`, `capsule-keygen` and `gen_openapi` as +//! separate `[[bin]]`s. Four executables would each need their own copy of the configuration +//! loader and the adapter seam — which is the duplication [`crate::boot`] exists to prevent — +//! and `design/filesystem/maintenance.md` calls the scrub "an operator-invoked command, +//! schedulable as a job" rather than a distinct executable. `capsule-cli` already sets the +//! one-binary-many-subcommands precedent. +//! +//! # Why the bodies live here and not in `main` +//! +//! `main.rs` installs error reporting and dispatches; everything a subcommand actually does is +//! in this module, in the library. That is what lets `tests/binary.rs` assert against the same +//! code the binary runs, and it is the shape `capsule-cli/src/main.rs` already has. +//! +//! # stdout is a data channel +//! +//! Every log line goes to **stderr**. `gen-openapi` writes a path to stdout and the operator +//! commands write a report there, and a subscriber sharing that stream is how a pipeline ends up +//! parsing a log line. `capsule-cli/tests/cull_round_trip.rs` has to set `RUST_LOG=off` to keep +//! stdout parseable, which is the failure mode being avoided here. + +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use color_eyre::eyre::{Context as _, Result, bail, eyre}; +use kynos::server::Server; +use kynos::server::shutdown::Shutdown; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, fmt as log_fmt}; + +use crate::boot::{self, Assembled, Maintenance}; +use crate::config::{Config, Demands, Environment, LogFormat, Overrides, ProcessEnvironment}; +use crate::gc::{CollectionReport, Mode, PurgeReport}; +use crate::scrub::{Depth, ScrubReport}; + +/// The exit code a configuration refusal produces. +/// +/// Two, and distinct from [`EXIT_FINDINGS`] on purpose: a wrapper script has to be able to tell +/// "you configured this wrongly" from "the store is not clean", and both being `1` would make a +/// misconfigured cron job look like a corrupted store. It is also the code clap itself uses for +/// a usage error, so the two kinds of "the invocation was wrong" agree. +pub const EXIT_MISCONFIGURED: u8 = 2; + +/// The exit code a read-only check produces when it found something. +/// +/// One. `design/filesystem/maintenance.md` requires that the scrub "exits non-zero, and mutates +/// nothing", which is what makes it usable as a monitoring probe. +pub const EXIT_FINDINGS: u8 = 1; + +/// How many tombstoned assets one `purge` pass considers. +/// +/// A bound rather than a policy: the pass walks the index and a retention sweep on a large +/// deployment should be a job that finishes, not one that holds a read for an hour. An operator +/// who wants more runs it again. +const DEFAULT_PURGE_LIMIT: usize = 1_000; + +/// How many bytes one `scrub --deep` pass will read when no budget is given. +/// +/// One gibibyte. `Depth::Deep` carries a budget precisely because re-hashing every blob is +/// heavy I/O by definition, and "a scrub that saturates the disk is a scrub an operator turns +/// off". A truncated pass says so in its report, so the default cannot silently pass a store it +/// did not finish looking at. +const DEFAULT_SCRUB_BUDGET: u64 = 1024 * 1024 * 1024; + +/// The Capsule server. +#[derive(Debug, Parser)] +#[command(name = "capsule-server", author, version, about, long_about = None)] +pub struct Cli { + /// Read settings from a configuration file. + /// + /// Reserved and **not implemented**: every setting is read from the environment. The flag + /// exists so the refusal is a sentence rather than clap's "unexpected argument". + #[arg(long, value_name = "PATH", global = true)] + pub config: Option, + + /// What to do. + #[command(subcommand)] + pub command: Command, +} + +/// Where a subcommand's state lives. +/// +/// Flattened into every subcommand that touches a store rather than declared once on [`Cli`], +/// because `gen-openapi` touches none and a global `--blob-root` would advertise otherwise. +#[derive(Debug, Args)] +pub struct BackendArgs { + /// The filesystem tree ciphertext blobs are written to (`BLOB_ROOT`). + #[arg(long, value_name = "PATH")] + pub blob_root: Option, + + /// Run on the in-memory adapters instead of Postgres and Valkey. + /// + /// A development profile, and an explicit act: a deployment that merely forgot `VALKEY_URL` + /// must fail closed rather than come up holding state it loses on the next restart. + #[arg(long)] + pub memory: bool, +} + +/// The things this binary does. +#[derive(Debug, Subcommand)] +pub enum Command { + /// Accept requests until a termination signal, then drain. + Serve { + /// The address to bind (`SERVER_HOST`/`SERVER_PORT`, default `0.0.0.0:3000`). + /// + /// Port `0` asks the operating system to choose one, and the chosen address is written + /// to stdout — see [`serve`]. + #[arg(long, value_name = "HOST:PORT")] + listen: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Sweep blobs nothing references any more. + /// + /// Two passes, by design: a blob that reaches zero references is *marked*, and a later pass + /// sweeps it once the grace window has passed and the count is still zero. That is what + /// gives an in-flight finalization retry time to re-reference it. + Gc { + /// Carry it out. Without this nothing is marked, unmarked or swept. + /// + /// Dry run is the default for the two subcommands that write, because the first thing an + /// operator does with a collector is find out what it thinks. + #[arg(long)] + apply: bool, + + /// How long a blob must sit at zero references before it may be swept + /// (`GC_GRACE_WINDOW_HOURS`, default 24). + #[arg(long, value_name = "HOURS")] + grace_window_hours: Option, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Drop the blob references of tombstoned assets whose retention window has passed. + /// + /// The tombstone itself stays: a client that has not synced since the delete still has to + /// learn about it, and removing the row would make the deletion invisible rather than final. + Purge { + /// Carry it out. Without this nothing is dropped. + #[arg(long)] + apply: bool, + + /// How many tombstoned assets to consider in this pass. + #[arg(long, value_name = "N", default_value_t = DEFAULT_PURGE_LIMIT)] + limit: usize, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Compare the index against the store and report every disagreement. + /// + /// Mutates nothing, by construction, and exits non-zero on a non-empty report — which is + /// what makes it usable as a monitoring probe. + Scrub { + /// Also re-hash every blob's bytes: the bit-rot check. + #[arg(long)] + deep: bool, + + /// The most bytes a deep pass will read. Blobs past it are left for the next run. + #[arg(long, value_name = "BYTES", default_value_t = DEFAULT_SCRUB_BUDGET)] + budget: u64, + + /// Where state lives. + #[command(flatten)] + backend: BackendArgs, + }, + + /// Emit the OpenAPI 3.2 document the SDK's client is generated from. + /// + /// Needs no database, no Valkey, no key material, no disk and no network: the router is + /// built purely to describe it, which is what lets `--check` run in the Rust check gate. + GenOpenapi { + /// Output path for the document, relative to the repo root. + #[arg(value_name = "FILE", default_value = "capsule-server/openapi.json")] + output: PathBuf, + + /// Verify the committed document is up to date instead of writing it (CI drift gate). + #[arg(long)] + check: bool, + }, +} + +impl Command { + /// What this subcommand needs from the configuration. + fn demands(&self) -> Demands { + match self { + Self::Serve { .. } => Demands::Serve, + // No key material. A maintenance host that had to hold the production + // token-signing key to sweep a directory would be a reason to put the key there. + Self::Gc { .. } | Self::Purge { .. } | Self::Scrub { .. } => Demands::Maintenance, + Self::GenOpenapi { .. } => Demands::Nothing, + } + } + + /// What this subcommand overrides on the command line. + fn overrides(&self, config_file: Option) -> Overrides { + let mut overrides = Overrides { + config_file, + ..Overrides::default() + }; + match self { + Self::Serve { listen, backend } => { + overrides.listen = *listen; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::Gc { + grace_window_hours, + backend, + .. + } => { + overrides.grace_window_hours = *grace_window_hours; + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::Purge { backend, .. } | Self::Scrub { backend, .. } => { + overrides.blob_root.clone_from(&backend.blob_root); + overrides.memory = backend.memory; + } + Self::GenOpenapi { .. } => {} + } + overrides + } +} + +/// Parse the command line, install the log stream, and do what was asked. +/// +/// # Errors +/// +/// Returns whatever the subcommand could not finish. A configuration refusal is **not** an +/// error here — it is [`EXIT_MISCONFIGURED`] with its own report on stderr, because +/// `ConfigError` already renders the full list of faults and an error chain around it would bury +/// them. +pub async fn run() -> Result { + let cli = Cli::parse(); + let environment = ProcessEnvironment; + let overrides = cli.command.overrides(cli.config.clone()); + + install_tracing(&environment, &overrides); + + let config = match Config::load(&environment, &overrides, cli.command.demands()) { + Ok(config) => config, + Err(error) => { + // Straight to stderr rather than through `tracing`: a startup refusal has to be + // visible whatever `RUST_LOG` says, and this is the one message an operator who + // mis-typed a variable needs to read. + eprintln!("capsule-server: {error}"); + return Ok(ExitCode::from(EXIT_MISCONFIGURED)); + } + }; + + match cli.command { + Command::Serve { .. } => serve(&config).await, + Command::Gc { apply, .. } => collect(&config, mode(apply)).await, + Command::Purge { apply, limit, .. } => purge(&config, mode(apply), limit).await, + Command::Scrub { deep, budget, .. } => { + let depth = if deep { + Depth::Deep { budget } + } else { + Depth::Structural + }; + scrub(&config, depth).await + } + // The document is a property of the router's types, so the configuration is loaded only + // to refuse `--config` and is deliberately not logged: `mise run openapi-check-kynos` is + // a check gate, and a settings dump on its stderr is noise in every CI log that runs it. + Command::GenOpenapi { output, check } => { + drop(config); + gen_openapi(&output, check) + } + } +} + +/// Assemble the server from `config`, logging what it came up on. +/// +/// Shared by every subcommand that needs a store, so the settings dump an operator reads after +/// an incident is written once and says the same thing whichever command produced it. +async fn assemble(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble(config).await?) +} + +/// Assemble only what the operator workers read. +/// +/// A separate entry point rather than reaching into [`Assembled`], because `gc`, `purge` and +/// `scrub` need **no key material** and the way to make that true is for the assembly they use +/// to have none in scope — not for it to build a token signer and then not use it. +async fn maintenance(config: &Config) -> Result { + tracing::debug!(?config, "loaded the configuration"); + Ok(boot::assemble_maintenance(config).await?) +} + +/// Accept requests until a termination signal, then drain. +/// +/// # The bound address goes to stdout +/// +/// It is logged at `INFO` **and** written to stdout as one `listening on ` line. That is +/// not a duplicate: `--listen 127.0.0.1:0` is a request for the operating system to choose a +/// port, and a caller that asked for that has no other way to learn which one it got. Making +/// them parse a log format — which `LOG_FORMAT` can change under them — would be a contract +/// nobody wrote down. Rust's stdout is line-buffered, so the line is readable the moment it is +/// written. +/// +/// # No TLS +/// +/// `design/cryptography/failure-modes.md` is explicit that "application servers do not terminate +/// TLS", and scopes in-code TLS to the SDK client, LAN peering and server-to-server egress — +/// none of which is this listener. Kynos's `tls` feature stays off, so a certificate cannot be +/// configured by accident. +async fn serve(config: &Config) -> Result { + let assembled = assemble(config).await?; + let bound = Server::new(assembled.service()?) + .bind(config.listen) + // SIGINT and SIGTERM, with a second one forcing. Kynos keeps its listeners alive + // through the drain, so an impatient operator's second Ctrl-C is honoured rather than + // ignored. + .graceful_shutdown(Shutdown::signals()) + .shutdown_timeout(config.shutdown_timeout) + .max_connections(config.max_connections) + .prepare() + .await + .map_err(|error| eyre!("binding {}: {error}", config.listen))?; + + for address in bound.local_addrs() { + tracing::info!(%address, "listening"); + println!("listening on http://{address}"); + } + + bound + .serve() + .await + .map_err(|error| eyre!("serving: {error}"))?; + tracing::info!("drained and stopped"); + Ok(ExitCode::SUCCESS) +} + +/// Install the log stream, on stderr. +/// +/// The format is read best-effort — [`Demands::Nothing`] never fails on a missing setting, and a +/// malformed one falls back to the build profile's default — because the configuration error +/// this cannot read has to be *reported*, and reporting it needs a subscriber. +fn install_tracing(environment: &dyn Environment, overrides: &Overrides) { + let format = Config::load(environment, overrides, Demands::Nothing).map_or_else( + |_| { + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + }, + |config| config.log_format, + ); + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| { + if cfg!(debug_assertions) { + EnvFilter::try_new("debug") + } else { + EnvFilter::try_new("info") + } + }) + .expect("built-in log filter directives are valid"); + + let registry = tracing_subscriber::registry().with(filter); + match format { + LogFormat::Json => registry + .with( + log_fmt::layer() + .json() + .flatten_event(true) + .with_writer(std::io::stderr), + ) + .init(), + LogFormat::Pretty => registry + .with( + log_fmt::layer() + .pretty() + .with_file(true) + .with_line_number(true) + .with_writer(std::io::stderr), + ) + .init(), + } +} + +/// Whether `--apply` was passed. +/// +/// A free function rather than a `From` on [`Mode`]: the boolean is a command-line flag, +/// and a blanket conversion would let any `bool` in the crate become a write mode. +fn mode(apply: bool) -> Mode { + if apply { Mode::Apply } else { Mode::DryRun } +} + +/// Sweep blobs nothing references any more. +/// +/// # What an interrupted pass leaves, and what the operator is told +/// +/// `gc::collect` returns `Result`, so a pass that fails part-way +/// returns **no report at all** — the work it did before the failure is not recoverable from +/// here, and stdout stays empty. What the operator sees is the store error on stderr and a +/// non-zero exit; what they have to do to find out how far it got is read the `INFO` lines the +/// collector logs as it marks and sweeps. +/// +/// That is a real gap and it is the library's to close: the report would have to come back +/// alongside the error (`Result<(CollectionReport, Option), _>` or equivalent), and +/// `gc/mod.rs` is not this change's to edit. The state left behind is safe either way, by the +/// module's own argument — a mark is reversible, and a sweep only ever removed a blob confirmed +/// unreferenced twice — so re-running the pass is the correct response to one that stopped. +async fn collect(config: &Config, mode: Mode) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::collect(&maintenance.collection, mode) + .await + .map_err(|error| eyre!("the collection pass could not finish: {error}"))?; + print!("{}", render_collection(&report, mode)); + Ok(ExitCode::SUCCESS) +} + +/// Drop the blob references of tombstoned assets past their retention window. +/// +/// An interrupted pass reports nothing, for the reason [`collect`] records. +async fn purge(config: &Config, mode: Mode, limit: usize) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::gc::purge_expired(&maintenance.collection, mode, limit) + .await + .map_err(|error| eyre!("the retention purge could not finish: {error}"))?; + print!("{}", render_purge(&report, mode)); + Ok(ExitCode::SUCCESS) +} + +/// Compare the index against the store. +/// +/// Exits [`EXIT_FINDINGS`] on a non-empty report, which `design/filesystem/maintenance.md` +/// requires of it — and a **truncated** deep pass is not clean even with no findings, because it +/// did not finish looking. `ScrubReport::is_clean` already draws that distinction; this only has +/// to honour it. +async fn scrub(config: &Config, depth: Depth) -> Result { + let maintenance = maintenance(config).await?; + let report = crate::scrub::scrub(&maintenance.scrub, depth) + .await + .map_err(|error| eyre!("the integrity scrub could not finish: {error}"))?; + print!("{}", render_scrub(&report)); + Ok(if report.is_clean() { + ExitCode::SUCCESS + } else { + ExitCode::from(EXIT_FINDINGS) + }) +} + +/// One pass's report, rendered for a person. +/// +/// A [`std::fmt::Display`] wrapper rather than a `-> String` helper, so every line is one `writeln!` +/// into the caller's formatter: building the whole report in a `String` first meant an +/// allocation per line and a clippy lint saying so. +struct Rendered<'a, T>(&'a T, Mode); + +/// What a dry run prints above its report, so nobody reads one as an action. +fn posture(mode: Mode) -> &'static str { + match mode { + Mode::Apply => "applied", + Mode::DryRun => "dry run — nothing was changed", + } +} + +/// Render a collection pass. +/// +/// Every class names its blobs rather than counting them. [`CollectionReport`]'s own docs say +/// why: "a count tells an operator that something happened without telling them what to look +/// at." Empty classes are omitted, so a quiet pass is one short line rather than six zeroes. +fn render_collection(report: &CollectionReport, mode: Mode) -> Rendered<'_, CollectionReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, CollectionReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "garbage collection ({})", posture(mode))?; + let mut quiet = true; + for (class, addresses) in [ + ("marked", &report.marked), + ("unmarked", &report.unmarked), + ("swept", &report.swept), + ("reprieved", &report.reprieved), + ("dangling", &report.dangling), + ] { + if addresses.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", addresses.len())?; + for address in addresses { + writeln!(f, " {address}")?; + } + } + if !report.credited.is_empty() { + quiet = false; + let total: u64 = report.credited.iter().map(|(_, bytes)| *bytes).sum(); + writeln!( + f, + " credited ({} accounts, {total} bytes)", + report.credited.len() + )?; + for (user, bytes) in &report.credited { + writeln!(f, " {user} {bytes}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render a retention purge. +/// +/// `retained` is reported as well as `purged`, because "why has this not gone yet" is exactly +/// the question a dry run is run to answer. +fn render_purge(report: &PurgeReport, mode: Mode) -> Rendered<'_, PurgeReport> { + Rendered(report, mode) +} + +impl std::fmt::Display for Rendered<'_, PurgeReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self(report, mode) = *self; + writeln!(f, "retention purge ({})", posture(mode))?; + let mut quiet = true; + for (class, assets) in [("purged", &report.purged), ("retained", &report.retained)] { + if assets.is_empty() { + continue; + } + quiet = false; + writeln!(f, " {class} ({})", assets.len())?; + for asset in assets { + writeln!(f, " {asset}")?; + } + } + if quiet { + writeln!(f, " nothing to do")?; + } + Ok(()) + } +} + +/// Render an integrity scrub. +/// +/// A scrub never writes, so it has no posture; the mode is carried and ignored. +fn render_scrub(report: &ScrubReport) -> Rendered<'_, ScrubReport> { + Rendered(report, Mode::DryRun) +} + +/// Grouped by the class an operator alerts on, and each finding printed through its **own** +/// `Debug`. Not a hand-written line per variant: every `Finding` variant already carries both +/// sides' evidence, a second rendering would be a second place for the two to disagree, and a +/// variant added later would otherwise render as nothing at all — which is the one failure mode +/// a report must not have. +impl std::fmt::Display for Rendered<'_, ScrubReport> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let report = self.0; + writeln!( + f, + "integrity scrub ({} finding{}, {} bytes hashed{})", + report.findings.len(), + if report.findings.len() == 1 { "" } else { "s" }, + report.bytes_hashed, + if report.budget_exhausted { + ", budget exhausted — the pass did not finish looking" + } else { + "" + } + )?; + for (class, count) in report.counts() { + writeln!(f, " {class} ({count})")?; + for finding in report + .findings + .iter() + .filter(|finding| finding.class() == class) + { + writeln!(f, " {finding:?}")?; + } + } + if report.is_clean() { + writeln!(f, " the index and the store agree")?; + } + Ok(()) + } +} + +/// Write, or verify, the committed OpenAPI 3.2 document (slice `S-C34`). +/// +/// The drift guard for the rebuild's central claim: that the description is derived from the +/// types and cannot disagree with them. That claim is already enforced *inside* the crate — +/// `assert_conformance` catches a response the document did not predict, and +/// `assert_declared_responses_covered` catches a promise no test produced. Neither helps a +/// **client**: a surface can be ported, the emitted document can change shape, and nothing +/// outside the crate notices until somebody regenerates by hand. This is what makes such a +/// change fail. +fn gen_openapi(output: &PathBuf, check: bool) -> Result { + let document = + crate::openapi().map_err(|e| color_eyre::eyre::eyre!("describing the router: {e}"))?; + // `to_json` is already pretty-printed; the trailing newline keeps the committed document an + // ordinary text file rather than a one-line blob in a diff. + let mut json = document + .to_json() + .wrap_err("serializing the OpenAPI document to JSON")?; + json.push('\n'); + + if check { + let committed = std::fs::read_to_string(output) + .wrap_err_with(|| format!("cannot read committed document at {}", output.display()))?; + if committed != json { + bail!( + "OpenAPI document at {} is out of sync with the server; run \ + `mise run openapi-kynos` and commit the result", + output.display() + ); + } + println!("OpenAPI document is up to date: {}", output.display()); + } else { + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .wrap_err_with(|| format!("creating {}", parent.display()))?; + } + std::fs::write(output, &json).wrap_err_with(|| format!("writing {}", output.display()))?; + println!("Wrote {}", output.display()); + } + + Ok(ExitCode::SUCCESS) +} + +#[cfg(test)] +mod tests { + use clap::{CommandFactory as _, Parser as _}; + + use super::{ + Cli, CollectionReport, Command, Mode, PurgeReport, ScrubReport, mode, render_collection, + render_purge, render_scrub, + }; + + #[test] + fn the_command_line_is_well_formed() { + // clap's own consistency check: a duplicate long flag, a subcommand with two positional + // arguments in the wrong order, or an argument whose value name collides is a panic + // here rather than a report from the first operator to run `--help`. + Cli::command().debug_assert(); + } + + #[test] + fn gen_openapi_defaults_to_the_committed_document() { + let cli = Cli::parse_from(["capsule-server", "gen-openapi"]); + let Command::GenOpenapi { output, check } = cli.command else { + panic!("that is the subcommand that was parsed") + }; + assert_eq!(output, std::path::Path::new("capsule-server/openapi.json")); + assert!(!check, "writing is the default; checking is opt-in"); + } + + #[test] + fn serve_carries_its_flags_into_the_overrides_the_loader_reads() { + // The command line's half of the precedence table. A flag that parsed but never reached + // `Config::load` would be a flag that silently does nothing. + let cli = Cli::parse_from([ + "capsule-server", + "serve", + "--memory", + "--listen", + "127.0.0.1:6000", + "--blob-root", + "/var/lib/capsule/blobs", + ]); + let overrides = cli.command.overrides(None); + assert!(overrides.memory); + assert_eq!( + overrides.listen, + Some("127.0.0.1:6000".parse().expect("a literal address parses")) + ); + assert_eq!( + overrides.blob_root.as_deref(), + Some(std::path::Path::new("/var/lib/capsule/blobs")) + ); + } + + #[test] + fn serving_demands_a_key_and_describing_the_router_demands_nothing() { + assert_eq!( + Cli::parse_from(["capsule-server", "serve"]) + .command + .demands(), + crate::config::Demands::Serve + ); + assert_eq!( + Cli::parse_from(["capsule-server", "gen-openapi"]) + .command + .demands(), + crate::config::Demands::Nothing + ); + } + + #[test] + fn a_config_path_is_accepted_by_the_parser_so_it_can_be_refused_by_the_loader() { + let cli = Cli::parse_from([ + "capsule-server", + "--config", + "/etc/capsule.toml", + "gen-openapi", + ]); + assert_eq!( + cli.config.as_deref(), + Some(std::path::Path::new("/etc/capsule.toml")) + ); + } + + /// A well-formed content address, distinguished by `seed`. + fn address(seed: u8) -> crate::blob::ContentAddress { + let hex: String = std::iter::repeat_n(format!("{seed:02x}"), 32).collect(); + crate::blob::ContentAddress::parse(&hex).expect("an address") + } + + #[test] + fn a_quiet_collection_pass_is_one_short_line() { + // Six zeroes would be six lines an operator learns to skip, and the whole point of the + // report is that they read it. + let rendered = render_collection(&CollectionReport::default(), Mode::DryRun).to_string(); + assert!(rendered.contains("dry run"), "{rendered}"); + assert!(rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_collection_pass_names_the_blobs_rather_than_counting_them() { + // `CollectionReport`'s own docs: "a count tells an operator that something happened + // without telling them what to look at." + let report = CollectionReport { + marked: vec![address(0xAA), address(0xBB)], + swept: vec![address(0xCC)], + credited: vec![(crate::store::UserId::new("a-user"), 4096)], + ..CollectionReport::default() + }; + let rendered = render_collection(&report, Mode::Apply).to_string(); + assert!(rendered.contains("applied"), "{rendered}"); + assert!(rendered.contains("marked (2)"), "{rendered}"); + assert!(rendered.contains(&address(0xAA).to_string()), "{rendered}"); + assert!(rendered.contains(&address(0xBB).to_string()), "{rendered}"); + assert!(rendered.contains("swept (1)"), "{rendered}"); + assert!( + rendered.contains("credited (1 accounts, 4096 bytes)"), + "{rendered}" + ); + // Classes with nothing in them are omitted rather than printed as zero. + assert!(!rendered.contains("unmarked"), "{rendered}"); + assert!(!rendered.contains("nothing to do"), "{rendered}"); + } + + #[test] + fn a_purge_reports_what_is_still_waiting() { + // "Why has this not gone yet" is exactly the question a dry run is run to answer. + let report = PurgeReport { + purged: vec![crate::store::AssetId::new("gone")], + retained: vec![crate::store::AssetId::new("waiting")], + }; + let rendered = render_purge(&report, Mode::DryRun).to_string(); + assert!(rendered.contains("purged (1)"), "{rendered}"); + assert!(rendered.contains("gone"), "{rendered}"); + assert!(rendered.contains("retained (1)"), "{rendered}"); + assert!(rendered.contains("waiting"), "{rendered}"); + } + + #[test] + fn a_clean_scrub_says_the_two_sides_agree() { + let rendered = render_scrub(&ScrubReport::default()).to_string(); + assert!(rendered.contains("0 findings"), "{rendered}"); + assert!(rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_scrub_groups_findings_by_the_class_an_operator_alerts_on() { + let report = ScrubReport { + findings: vec![ + crate::scrub::Finding::Orphan { + address: address(0xAA), + }, + crate::scrub::Finding::Orphan { + address: address(0xBB), + }, + crate::scrub::Finding::Debris { + path: "blobs/aa/not-a-blob".to_owned(), + }, + ], + bytes_hashed: 0, + budget_exhausted: false, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("3 findings"), "{rendered}"); + assert!(rendered.contains("orphan (2)"), "{rendered}"); + assert!(rendered.contains("debris (1)"), "{rendered}"); + assert!(rendered.contains("not-a-blob"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn a_truncated_deep_pass_is_not_a_clean_one() { + // A clean report from a pass that stopped early is the one answer a scrub must never + // give, so the rendering says so out loud as well. + let report = ScrubReport { + findings: Vec::new(), + bytes_hashed: 1024, + budget_exhausted: true, + }; + let rendered = render_scrub(&report).to_string(); + assert!(rendered.contains("budget exhausted"), "{rendered}"); + assert!(!rendered.contains("agree"), "{rendered}"); + } + + #[test] + fn dry_run_is_the_default_for_the_two_subcommands_that_write() { + // The first thing an operator does with a collector is find out what it thinks. + let gc = Cli::parse_from(["capsule-server", "gc"]).command; + assert!(matches!(gc, Command::Gc { apply: false, .. })); + let purge = Cli::parse_from(["capsule-server", "purge"]).command; + assert!(matches!(purge, Command::Purge { apply: false, .. })); + assert_eq!(mode(false), Mode::DryRun); + assert_eq!(mode(true), Mode::Apply); + } + + #[test] + fn a_structural_scrub_is_the_default_and_deep_carries_a_budget() { + let shallow = Cli::parse_from(["capsule-server", "scrub"]).command; + assert!(matches!(shallow, Command::Scrub { deep: false, .. })); + let deep = Cli::parse_from(["capsule-server", "scrub", "--deep"]).command; + let Command::Scrub { deep, budget, .. } = deep else { + panic!("that is the subcommand that was parsed") + }; + assert!(deep); + assert_eq!(budget, super::DEFAULT_SCRUB_BUDGET); + } + + #[test] + fn the_operator_commands_demand_a_blob_root_and_no_key_material() { + for argv in [ + ["capsule-server", "gc"], + ["capsule-server", "purge"], + ["capsule-server", "scrub"], + ] { + assert_eq!( + Cli::parse_from(argv).command.demands(), + crate::config::Demands::Maintenance, + "{argv:?}" + ); + } + } +} diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs new file mode 100644 index 00000000..bdb4f8a9 --- /dev/null +++ b/capsule-server/src/config.rs @@ -0,0 +1,1524 @@ +//! [`Config`] — everything an operator gets to decide, read once at startup. +//! +//! # Environment only, and why there is no file +//! +//! `--config PATH` is accepted on the command line and **refused**: a configuration-file crate +//! would be a new dependency in a domain `design/dependencies.md` has no row for, and the +//! server this replaces was environment-only plus `dotenvy` +//! (`legacy-review/server-salvo/environment/`), so an operator loses nothing familiar. The flag +//! exists rather than being absent so the refusal is a sentence rather than clap's "unexpected +//! argument", and so the precedence table below already names the slot a file layer would sit +//! in. +//! +//! Precedence, highest first: **command line → process environment → built-in default.** +//! +//! # Every fault, once +//! +//! [`Config::load`] reports **all** of them ([`ConfigError`] holds a list) instead of failing on +//! the first. An operator bringing a deployment up otherwise restarts the process once per +//! variable, learning one missing key at a time from a server that already knew about four. +//! +//! # What is required depends on the subcommand +//! +//! `gc`, `purge` and `scrub` need a blob root and **no key material** — demanding a signing key +//! to sweep a directory would be a reason to keep a production key on a maintenance host. So +//! the requirement set is a parameter ([`Demands`]) rather than a property of the type. +//! +//! # Secrets +//! +//! [`SecretBytes`] redacts itself in `Debug`, the way +//! [`SessionTokens`](crate::auth::SessionTokens) does by hand: `Config` is logged at startup, +//! and a `Debug` that printed the token-signing key is how one reaches a log file. + +use std::collections::BTreeMap; +use std::fmt; +use std::net::{IpAddr, SocketAddr}; +use std::num::NonZeroUsize; +use std::path::PathBuf; + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD}; +use jiff::SignedDuration; + +use crate::auth::oidc::{ClientSecret, RedirectPolicy}; +use crate::sync::CURSOR_KEY_LEN; + +/// The bind address a deployment gets without saying anything. +const DEFAULT_LISTEN: &str = "0.0.0.0:3000"; + +/// The port half of [`DEFAULT_LISTEN`], for composing `SERVER_HOST` with no `SERVER_PORT`. +const DEFAULT_PORT: u16 = 3000; + +/// The domain a deployment gets without saying anything. +const DEFAULT_DOMAIN: &str = "localhost"; + +/// The drain deadline, matching Kynos's own default — under the usual 30-second orchestrator +/// termination window, which is the whole reason that number is what it is. +const DEFAULT_SHUTDOWN_TIMEOUT: u64 = 25; + +/// The accepted-connection ceiling, matching Kynos's own default. +const DEFAULT_MAX_CONNECTIONS: usize = 10_000; + +/// How long an account stays locked after enough failed credential presentations. +/// +/// Fifteen minutes. `design/authentication.md` names no figure — it says only that a locked +/// account is locked at password change too — so this is a decision recorded here rather than a +/// value read from somewhere: long enough that an online guessing run is throttled to +/// uselessness, short enough that a person who mistyped their password four times gets back into +/// their own account without an operator. +/// +/// It has to decay at all, because there is **no unlock endpoint and no operator command that +/// clears it**: every route that could reset the state (`login`, `reauthenticate`, +/// `password`) refuses on `Locked` before it verifies anything, so a permanent lockout is +/// a permanently lost account. Seconds rather than minutes as the unit so a test can pick a +/// window it can actually wait out. +const DEFAULT_LOCKOUT_WINDOW_SECONDS: u64 = 15 * 60; + +/// How many consecutive failures inside that window lock an account. +/// +/// The companion number to the window — a lockout is not one policy but two, and a deployment +/// that wants a tighter one needs to move both. Ten is +/// [`MAX_FAILED_ATTEMPTS`](crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS), which is where the +/// reasoning for the figure lives. +const DEFAULT_LOCKOUT_ATTEMPTS: u32 = crate::auth::accounts_memory::MAX_FAILED_ATTEMPTS; + +/// The seed [`HybridSigningKey`](capsule_core::crypto::keys::HybridSigningKey) is built from. +const ATTESTATION_SEED_LEN: usize = 64; + +/// HKDF `info` for the sync-cursor MAC key derived from the token-signing key. +const CURSOR_KEY_INFO: &[u8] = b"capsule/sync-cursor-mac/v1"; + +/// HKDF `info` for the attestation seed derived from the token-signing key. +const ATTESTATION_SEED_INFO: &[u8] = b"capsule/attestation-seed/v1"; + +/// Where a setting is read from. +/// +/// A trait rather than [`std::env::var`] directly so the precedence table is a unit test rather +/// than a claim: a test builds the environment it wants and asserts what came out, with no +/// process-global state two concurrent tests would fight over. +pub trait Environment: fmt::Debug { + /// The value of `key`, or `None` when it is unset **or set to the empty string**. + /// + /// Empty is absent on purpose. `FOO=` in a compose file or a `.env` is how an operator + /// writes "I did not set this", and a server that read it as a zero-length signing key + /// would fail somewhere much less obvious than here. + fn var(&self, key: &str) -> Option; +} + +/// The real process environment. +#[derive(Debug, Clone, Copy)] +pub struct ProcessEnvironment; + +impl Environment for ProcessEnvironment { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok().filter(|value| !value.is_empty()) + } +} + +impl Environment for BTreeMap { + fn var(&self, key: &str) -> Option { + self.get(key).filter(|value| !value.is_empty()).cloned() + } +} + +/// Whether `value` is a `YYYY-MM-DD` calendar date, spelled exactly that way. +/// +/// `jiff::civil::Date` parses the strict ISO form and refuses `2026-6-1` and February 30th +/// alike; the round trip back to text refuses a value the parser tolerated but the gate's +/// bytewise comparison would misorder. +fn is_protocol_date(value: &str) -> bool { + value + .parse::() + .is_ok_and(|date| date.to_string() == value) +} + +/// Whether `value` is `MAJOR.MINOR.PATCH` with three non-negative integers. +fn is_semver(value: &str) -> bool { + let parts: Vec<&str> = value.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) +} + +/// Bytes that must not be printed. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretBytes(Vec); + +impl SecretBytes { + /// Hold `bytes` as a secret. + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// The bytes, for the one caller that has to use them. + pub fn expose(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretBytes()") + } +} + +/// Which family of adapters a process runs on. +/// +/// Two arms, and the second one is not implemented yet — see +/// [`assemble`](crate::boot::assemble). It is an enum rather than a trait because the +/// `Arc` fields in [`Modules`](crate::app::Modules) already **are** the abstraction; +/// a second one over the top would abstract the composition root from itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backends { + /// Every deterministic in-crate adapter, over a real filesystem blob store. + /// + /// An explicit operator act (`--memory`, or `CAPSULE_PROFILE=memory`) and **never** a + /// fallback: a deployment that forgets `VALKEY_URL` must fail closed rather than come up + /// holding state it will lose on the next restart. + Memory, + /// Postgres and Valkey, selected by `DATABASE_URL` and `VALKEY_URL`. + Durable, +} + +/// How the log stream is rendered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + /// One JSON object per event — what a log shipper wants. + Json, + /// Multi-line, coloured, human-first — what a developer wants. + Pretty, +} + +/// What a subcommand needs before it can do anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Demands { + /// `serve`: a blob root, a token-signing key, and a chosen backend family. + Serve, + /// `gc` / `purge` / `scrub`: a blob root, and deliberately no key material. + Maintenance, + /// `gen-openapi`: nothing at all. The document is a property of the router's types. + Nothing, +} + +/// One thing wrong with the configuration. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigFault { + /// A required setting is absent. + #[error("{key} is required and is not set")] + Missing { + /// The environment variable or flag an operator has to set. + key: &'static str, + }, + /// A setting is present and cannot be used. + /// + /// `detail` never quotes the value: `JWT_ED25519_DER` is a private key, and a startup error + /// is the most-copied line in any incident channel. + #[error("{key} is not usable: {detail}")] + Invalid { + /// The setting. + key: &'static str, + /// What is wrong with it — never the value itself. + detail: String, + }, + /// A setting is understood, accepted at the boundary, and not implemented. + #[error("{key} is not supported yet: {detail}")] + Unsupported { + /// The setting. + key: &'static str, + /// What to do instead. + detail: String, + }, +} + +/// Everything wrong with the configuration, in one message. +/// +/// `Display` is hand-written rather than `thiserror`-generated because the message *is* a list; +/// a derived one-line format would put five faults on one line, which is the shape an operator +/// reads worst. The variants themselves are `thiserror` as the repository requires. +#[derive(Debug, PartialEq, Eq)] +pub struct ConfigError { + faults: Vec, +} + +impl ConfigError { + /// Every fault found, in the order the fields are read. + pub fn faults(&self) -> &[ConfigFault] { + &self.faults + } + + /// Whether `key` is among the faults, for a test that asserts one was reported. + pub fn names(&self, key: &str) -> bool { + self.faults.iter().any(|fault| match fault { + ConfigFault::Missing { key: named } + | ConfigFault::Invalid { key: named, .. } + | ConfigFault::Unsupported { key: named, .. } => *named == key, + }) + } +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "the server configuration is not usable ({} problem{})", + self.faults.len(), + if self.faults.len() == 1 { "" } else { "s" } + )?; + for fault in &self.faults { + write!(f, "\n - {fault}")?; + } + Ok(()) + } +} + +impl std::error::Error for ConfigError {} + +/// The command line's say, applied over the environment. +/// +/// Every field is an `Option` (or a `bool` that is only ever set) so "the operator did not pass +/// this flag" and "the operator passed this flag with the default value" are different states — +/// which is what makes the precedence table implementable at all. +#[derive(Debug, Clone, Default)] +pub struct Overrides { + /// `--config PATH`. Refused; see the module docs. + pub config_file: Option, + /// `--listen HOST:PORT`. + pub listen: Option, + /// `--blob-root PATH`. + pub blob_root: Option, + /// `--memory`. + pub memory: bool, + /// `--grace-window-hours N`. + pub grace_window_hours: Option, +} + +/// The OpenID Connect relying party, when a deployment has one (slice `S-N1`). +/// +/// Present only when `OIDC_ISSUER` is set. A half-configured relying party — an issuer with no +/// client id, or the reverse — is a configuration fault rather than a feature that is quietly +/// off, because an operator who set one of the two meant to set both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcConfig { + /// The issuer, exactly as the provider's ID tokens will carry it. + pub issuer: String, + /// This deployment's `client_id` at the provider. + pub client_id: String, + /// The client secret, when the deployment is a confidential client. Absent means PKCE-only. + pub client_secret: Option, + /// Which redirect URIs a client may name. + pub redirects: RedirectPolicy, + /// A PEM bundle of additional trust anchors for reaching the provider, if it sits behind a + /// private CA. Read at boot, not here: a path is configuration, its contents are not. + pub ca_bundle: Option, +} + +/// Everything an operator gets to decide. +#[derive(Debug, Clone)] +pub struct Config { + /// Where the process accepts connections. + pub listen: SocketAddr, + /// This deployment's canonical origin — the `server_id` every published record carries. + pub server_domain: String, + /// The absolute base URL clients reach the versioned API at. + pub api_base_url: String, + /// Where federated peers reach this server, when it federates at all (`FEDERATION_URL`). + /// + /// `None` is a deployment that does not federate: `server-info` publishes no + /// `federation_url`, and the capability lifecycle writes refuse with + /// `error.federation.not_configured`. + pub federation_url: Option, + /// The filesystem tree ciphertext blobs are written to. There is no object store. + pub blob_root: Option, + /// The Postgres URL, once an adapter reads it (#402). + pub database_url: Option, + /// The Valkey URL the state ports' adapters connect to (`store::valkey`, #403). + pub valkey_url: Option, + /// The PKCS#8 Ed25519 private key access and refresh tokens are signed with. + pub signing_key_der: Option, + /// The HMAC key sync cursors are authenticated under. + pub sync_cursor_mac_key: Option<[u8; CURSOR_KEY_LEN]>, + /// The seed the attestation signing key is built from. + pub attestation_key_seed: Option<[u8; ATTESTATION_SEED_LEN]>, + /// The oldest `protocol_version` accepted for writes (`YYYY-MM-DD`, validated). + pub protocol_min: String, + /// The newest `protocol_version` this server speaks (`YYYY-MM-DD`, validated). + pub protocol_max: String, + /// The advisory semver client-build cutoff advertised on every response. + pub min_client_build: String, + /// How long a blob sits at zero references before the collector may sweep it. + pub grace_window: SignedDuration, + /// How long an account stays locked after too many failed credential presentations. + pub lockout_window: SignedDuration, + /// How many consecutive failures inside that window lock it. + pub lockout_attempts: u32, + /// The OIDC relying party, if `OIDC_ISSUER` is set. + pub oidc: Option, + /// How long a shutdown may take to drain. + pub shutdown_timeout: std::time::Duration, + /// The accepted-connection ceiling. + pub max_connections: NonZeroUsize, + /// How the log stream is rendered. + pub log_format: LogFormat, + /// Which family of adapters to run on. + pub backends: Backends, +} + +impl Config { + /// Read the configuration for a subcommand that demands `demands`. + /// + /// # Errors + /// + /// Returns [`ConfigError`] carrying **every** fault found, never only the first. + #[allow( + clippy::too_many_lines, + reason = "one pass over the settings table, in the order the table is written" + )] + pub fn load( + env: &dyn Environment, + overrides: &Overrides, + demands: Demands, + ) -> Result { + let mut faults = Vec::new(); + + if let Some(path) = &overrides.config_file { + faults.push(ConfigFault::Unsupported { + key: "--config", + detail: format!( + "config files are not supported yet; every setting is read from the \ + environment, so drop `--config {}` and export the variables instead", + path.display() + ), + }); + } + + // ── Listener ──────────────────────────────────────────────────────────────────── + let port = parse_number::(env, "SERVER_PORT", &mut faults).unwrap_or(DEFAULT_PORT); + let listen = overrides.listen.or_else(|| { + let host = env.var("SERVER_HOST")?; + match host.parse::() { + Ok(address) => Some(SocketAddr::new(address, port)), + Err(error) => { + faults.push(ConfigFault::Invalid { + key: "SERVER_HOST", + // The value is an address, not a secret, and a typo is the whole point. + detail: format!("`{host}` is not an IP address to bind ({error})"), + }); + None + } + } + }); + let listen = listen.unwrap_or_else(|| { + let default: SocketAddr = DEFAULT_LISTEN + .parse() + .expect("the built-in default listener parses"); + SocketAddr::new(default.ip(), port) + }); + + // ── Identity ──────────────────────────────────────────────────────────────────── + let server_domain = env + .var("SERVER_DOMAIN") + .unwrap_or_else(|| DEFAULT_DOMAIN.to_owned()); + // `/v1` included: `ServerInfo` derives the published auth endpoints by appending to this, + // so a base URL without the version prefix publishes `http://host/auth/login`, which no + // route serves. The default is what a developer reaches on their own machine. + let api_base_url = env + .var("API_BASE_URL") + .unwrap_or_else(|| format!("http://{server_domain}:{}/v1", listen.port())); + // Opt-in, and the value is the URL peers pull from: the federation surface is the + // versioned API itself (design/federation.md, "no new data protocol"), so a deployment + // that federates publishes its API base here. + let federation_url = env.var("FEDERATION_URL"); + + // ── Storage ───────────────────────────────────────────────────────────────────── + // `UPLOAD_DIR` is the name the retired deployment used, accepted so an operator's + // existing environment keeps working, and warned about so it does not become the name. + let blob_root = overrides + .blob_root + .clone() + .or_else(|| env.var("BLOB_ROOT").map(PathBuf::from)) + .or_else(|| { + let legacy = env.var("UPLOAD_DIR").map(PathBuf::from)?; + tracing::warn!( + "UPLOAD_DIR is the retired name for BLOB_ROOT and is still honoured; \ + rename it" + ); + Some(legacy) + }); + let database_url = env.var("DATABASE_URL"); + let valkey_url = env.var("VALKEY_URL"); + + // ── Backend family ────────────────────────────────────────────────────────────── + let backends = if overrides.memory + || env + .var("CAPSULE_PROFILE") + .is_some_and(|profile| profile.eq_ignore_ascii_case("memory")) + { + Backends::Memory + } else { + Backends::Durable + }; + + // ── Key material ──────────────────────────────────────────────────────────────── + let signing_key_der = + decode_base64(env, "JWT_ED25519_DER", &mut faults).map(SecretBytes::new); + let sync_cursor_mac_key = + decode_fixed::(env, "SYNC_CURSOR_MAC_KEY", &mut faults); + let attestation_key_seed = decode_seed(env, &mut faults); + + // The sync-cursor MAC key is HKDF-derived from the token-signing key when unset. That is + // sound: a cursor MAC and a session token are the same trust domain — both are + // operational secrets this server holds to authenticate its own output — so deriving one + // from the other adds no capability to anybody who holds either. + // + // **The attestation seed is not, outside the development profile**, and that is a + // correction rather than a preference. `attestation/mod.rs` requires the attestation key + // to be distinct from the operational key precisely so that holding the operational key + // does not let anything manufacture custody evidence. Deriving the seed from + // `JWT_ED25519_DER` collapses exactly that distinction: anyone with the token-signing key + // recomputes the attestation key and signs receipts. So a real deployment must set + // `ATTESTATION_KEY_SEED` (see the `Demands::Serve` arm below), and only + // `Backends::Memory` — an explicit `--memory`, a development act, where the whole + // application state is discarded on exit — keeps the derivation, so `serve --memory` + // needs one variable rather than two. + let sync_cursor_mac_key = sync_cursor_mac_key.or_else(|| { + derive::(signing_key_der.as_ref()?.expose(), CURSOR_KEY_INFO) + }); + let attestation_key_seed = attestation_key_seed.or_else(|| match backends { + Backends::Memory => derive::( + signing_key_der.as_ref()?.expose(), + ATTESTATION_SEED_INFO, + ), + Backends::Durable => None, + }); + + // ── The OIDC relying party ────────────────────────────────────────────────────── + let oidc = read_oidc(env, &mut faults); + + // ── Protocol window ───────────────────────────────────────────────────────────── + // + // Both ends default to the policy's year window rather than to the single day + // `capsule-core` speaks: a default that collapsed the window to one date refused every + // client one build behind on its first write, which nobody chose. Both are parsed as + // dates, because every reader downstream — the gate's lexicographic comparison, the + // response header, the discovery record — assumes the `YYYY-MM-DD` grammar, and + // `2026-6-1` sorts before `2026-12-31` for the wrong reason. `min == max` is a + // legitimate explicit choice and is not refused. + let protocol_max = env + .var("PROTOCOL_MAX") + .unwrap_or_else(|| crate::upload::policy::DEFAULT_PROTOCOL_MAX.to_owned()); + let protocol_min = env + .var("PROTOCOL_MIN") + .unwrap_or_else(|| crate::upload::policy::DEFAULT_PROTOCOL_MIN.to_owned()); + for (key, value) in [ + ("PROTOCOL_MIN", &protocol_min), + ("PROTOCOL_MAX", &protocol_max), + ] { + if !is_protocol_date(value) { + faults.push(ConfigFault::Invalid { + key, + detail: "is not a YYYY-MM-DD date".to_owned(), + }); + } + } + if protocol_min > protocol_max { + faults.push(ConfigFault::Invalid { + key: "PROTOCOL_MIN", + detail: format!("`{protocol_min}` is newer than PROTOCOL_MAX `{protocol_max}`"), + }); + } + + // The advisory client-build cutoff, `X-Capsule-Min-Client-Build` on every response. + // Validated as three dot-separated integers because it is sent as a header value and + // compared as semver by clients; `0.0.0` — the default — is "no cutoff announced". + let min_client_build = env + .var("MIN_CLIENT_BUILD") + .unwrap_or_else(|| crate::upload::policy::DEFAULT_MIN_CLIENT_BUILD.to_owned()); + if !is_semver(&min_client_build) { + faults.push(ConfigFault::Invalid { + key: "MIN_CLIENT_BUILD", + detail: "is not a MAJOR.MINOR.PATCH semver build".to_owned(), + }); + } + + // ── Operational knobs ─────────────────────────────────────────────────────────── + let grace_window = overrides + .grace_window_hours + .or_else(|| parse_number::(env, "GC_GRACE_WINDOW_HOURS", &mut faults)) + .map_or(crate::gc::DEFAULT_GRACE_WINDOW, |hours| { + SignedDuration::from_hours(i64::try_from(hours).unwrap_or(i64::MAX)) + }); + let lockout_window = SignedDuration::from_secs( + parse_number::(env, "LOCKOUT_WINDOW_SECONDS", &mut faults).map_or_else( + || { + i64::try_from(DEFAULT_LOCKOUT_WINDOW_SECONDS) + .expect("the built-in lockout window fits") + }, + |seconds| seconds.max(0), + ), + ); + let lockout_attempts = parse_number::(env, "LOCKOUT_MAX_ATTEMPTS", &mut faults) + .and_then(|attempts| { + if attempts == 0 { + // Zero would lock every account on its first wrong keystroke and never + // unlock it before the window passed, which is a denial of service dressed + // as a policy. Refused rather than clamped to one: an operator who typed it + // meant something, and guessing what is worse than saying it is not allowed. + faults.push(ConfigFault::Invalid { + key: "LOCKOUT_MAX_ATTEMPTS", + detail: "zero would lock every account on its first failure".to_owned(), + }); + None + } else { + Some(attempts) + } + }) + .unwrap_or(DEFAULT_LOCKOUT_ATTEMPTS); + let shutdown_timeout = std::time::Duration::from_secs( + parse_number::(env, "SHUTDOWN_TIMEOUT_SECONDS", &mut faults) + .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT), + ); + let max_connections = parse_number::(env, "MAX_CONNECTIONS", &mut faults) + .and_then(|limit| { + NonZeroUsize::new(limit).or_else(|| { + faults.push(ConfigFault::Invalid { + key: "MAX_CONNECTIONS", + detail: "zero would accept nothing at all".to_owned(), + }); + None + }) + }) + .unwrap_or_else(|| { + NonZeroUsize::new(DEFAULT_MAX_CONNECTIONS) + .expect("the built-in connection ceiling is non-zero") + }); + let log_format = match env.var("LOG_FORMAT") { + None => { + // JSON in release because the reader is a log shipper; pretty in debug because + // the reader is a person with the source open. + if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + } + } + Some(format) if format.eq_ignore_ascii_case("json") => LogFormat::Json, + Some(format) if format.eq_ignore_ascii_case("pretty") => LogFormat::Pretty, + Some(format) => { + faults.push(ConfigFault::Invalid { + key: "LOG_FORMAT", + detail: format!("`{format}` is neither `json` nor `pretty`"), + }); + LogFormat::Json + } + }; + + // ── What the subcommand demands ───────────────────────────────────────────────── + // + // Last, and after every parse, so one message carries both "this is malformed" and + // "that is missing" rather than a restart between them. + match demands { + Demands::Nothing => {} + Demands::Maintenance => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + } + Demands::Serve => { + require(blob_root.is_some(), "BLOB_ROOT", &mut faults); + require(signing_key_der.is_some(), "JWT_ED25519_DER", &mut faults); + // The refusal `store/mod.rs` has always documented and nothing has ever + // enforced: Valkey is required, and the in-memory adapters are a development + // profile an operator opts into rather than something to fall back on. + if backends == Backends::Durable { + if valkey_url.is_none() { + faults.push(ConfigFault::Missing { key: "VALKEY_URL" }); + } + // Required rather than derived; see the key-material section above for what + // deriving it from the token-signing key would give away. Demanded only on + // the durable path because `--memory` derives it, so a development server + // still comes up on one variable. + if attestation_key_seed.is_none() { + faults.push(ConfigFault::Missing { + key: "ATTESTATION_KEY_SEED", + }); + } + } + } + } + + if faults.is_empty() { + Ok(Self { + listen, + server_domain, + api_base_url, + federation_url, + blob_root, + database_url, + valkey_url, + signing_key_der, + sync_cursor_mac_key, + attestation_key_seed, + protocol_min, + protocol_max, + min_client_build, + grace_window, + lockout_window, + lockout_attempts, + oidc, + shutdown_timeout, + max_connections, + log_format, + backends, + }) + } else { + Err(ConfigError { faults }) + } + } +} + +/// Read the `OIDC_*` settings, or `None` when none of them is set. +/// +/// `OIDC_ISSUER` must be an absolute `http(s)` URL with no query or fragment (OpenID Connect +/// Discovery 1.0 §3), and `https` unless it is a loopback address — the development carve-out +/// `auth::oidc::discovery` applies to the provider's endpoints too. `OIDC_REDIRECT_URL` is held +/// to the same scheme rule. `OIDC_ALLOW_LOOPBACK_REDIRECT` defaults to **off**: admitting a +/// redirect to any loopback port (RFC 8252 §7.3) is what a CLI's or desktop client's listener +/// needs, and it is the one knob that widens where the server will send a person back to, so a +/// deployment turns it on when it has such a client (#461's CLI flow will) rather than getting +/// it unasked. +fn read_oidc(env: &dyn Environment, faults: &mut Vec) -> Option { + let issuer = env.var("OIDC_ISSUER"); + let client_id = env.var("OIDC_CLIENT_ID"); + let client_secret = env.var("OIDC_CLIENT_SECRET"); + let redirect_url = env.var("OIDC_REDIRECT_URL"); + let allow_loopback = env.var("OIDC_ALLOW_LOOPBACK_REDIRECT"); + let ca_bundle = env.var("OIDC_CA_BUNDLE").map(PathBuf::from); + + if issuer.is_none() + && client_id.is_none() + && client_secret.is_none() + && redirect_url.is_none() + && allow_loopback.is_none() + && ca_bundle.is_none() + { + return None; + } + + // Half a relying party is refused, both ways round. + require(issuer.is_some(), "OIDC_ISSUER", faults); + require(client_id.is_some(), "OIDC_CLIENT_ID", faults); + + if let Some(issuer) = &issuer { + match reqwest::Url::parse(issuer) { + Ok(url) if url.query().is_some() || url.fragment().is_some() => { + faults.push(ConfigFault::Invalid { + key: "OIDC_ISSUER", + detail: "an issuer carries no query or fragment".to_owned(), + }); + } + Ok(url) if url.scheme() == "https" => {} + Ok(url) + if url.scheme() == "http" + && crate::auth::oidc::discovery::is_loopback_issuer(issuer) => {} + Ok(url) => { + faults.push(ConfigFault::Invalid { + key: "OIDC_ISSUER", + detail: format!( + "`{}://` is not accepted; an issuer is https, or http on a loopback \ + address for development", + url.scheme() + ), + }); + } + Err(error) => { + faults.push(ConfigFault::Invalid { + key: "OIDC_ISSUER", + // The issuer is a public URL, not a secret; quoting it is what a typo needs. + detail: format!("`{issuer}` is not an absolute URL ({error})"), + }); + } + } + } + + if let Some(redirect) = &redirect_url { + match reqwest::Url::parse(redirect) { + Ok(url) if url.scheme() == "https" => {} + Ok(url) + if url.scheme() == "http" + && crate::auth::oidc::discovery::is_loopback_issuer(redirect) => {} + Ok(url) => { + faults.push(ConfigFault::Invalid { + key: "OIDC_REDIRECT_URL", + detail: format!( + "`{}://` is not accepted; a redirect is https, or http on a loopback \ + address for development", + url.scheme() + ), + }); + } + Err(error) => { + faults.push(ConfigFault::Invalid { + key: "OIDC_REDIRECT_URL", + detail: format!("`{redirect}` is not an absolute URL ({error})"), + }); + } + } + } + + let allow_loopback = match allow_loopback.as_deref().map(str::trim) { + None => false, + Some(raw) + if ["true", "1", "yes", "on"] + .iter() + .any(|v| raw.eq_ignore_ascii_case(v)) => + { + true + } + Some(raw) + if ["false", "0", "no", "off"] + .iter() + .any(|v| raw.eq_ignore_ascii_case(v)) => + { + false + } + Some(raw) => { + faults.push(ConfigFault::Invalid { + key: "OIDC_ALLOW_LOOPBACK_REDIRECT", + detail: format!("`{raw}` is neither `true` nor `false`"), + }); + false + } + }; + + Some(OidcConfig { + issuer: issuer?, + client_id: client_id?, + client_secret: client_secret.map(ClientSecret::new), + redirects: RedirectPolicy::new(redirect_url, allow_loopback), + ca_bundle, + }) +} + +/// Record a missing required setting. +fn require(present: bool, key: &'static str, faults: &mut Vec) { + if !present { + faults.push(ConfigFault::Missing { key }); + } +} + +/// Parse `key` as `T`, recording a fault rather than failing the whole read. +fn parse_number( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option +where + T: std::str::FromStr, + T::Err: fmt::Display, +{ + let raw = env.var(key)?; + match raw.trim().parse::() { + Ok(value) => Some(value), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + detail: format!("`{raw}` is not a number this setting accepts ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64, accepting padded and unpadded input. +/// +/// Two alphabets rather than one because the documented way to produce `JWT_ED25519_DER` is +/// `openssl genpkey … | base64 -w 0`, and a shell pipeline that strips the padding is common +/// enough that refusing it would be a support question rather than a security property. +fn decode_base64( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option> { + let raw = env.var(key)?; + let trimmed = raw.trim(); + if let Ok(bytes) = BASE64.decode(trimmed) { + return Some(bytes); + } + match BASE64_NO_PAD.decode(trimmed) { + Ok(bytes) => Some(bytes), + Err(error) => { + faults.push(ConfigFault::Invalid { + key, + // The error names a position, never the bytes: this value is a private key. + detail: format!("it is not base64 ({error})"), + }); + None + } + } +} + +/// Decode `key` from base64 and require exactly `N` bytes. +fn decode_fixed( + env: &dyn Environment, + key: &'static str, + faults: &mut Vec, +) -> Option<[u8; N]> { + let bytes = decode_base64(env, key, faults)?; + let found = bytes.len(); + <[u8; N]>::try_from(bytes.as_slice()).ok().or_else(|| { + faults.push(ConfigFault::Invalid { + key, + detail: format!("it decodes to {found} bytes and must be exactly {N}"), + }); + None + }) +} + +/// Decode `ATTESTATION_KEY_SEED`, accepting 32 bytes and expanding them to 64. +/// +/// Thirty-two is what every general-purpose "generate a seed" instruction produces, and the +/// hybrid signing key needs sixty-four; expanding rather than refusing means an operator's +/// `openssl rand -base64 32` works, and the expansion is domain-separated so the two halves are +/// not the same 32 bytes twice. +fn decode_seed( + env: &dyn Environment, + faults: &mut Vec, +) -> Option<[u8; ATTESTATION_SEED_LEN]> { + let bytes = decode_base64(env, "ATTESTATION_KEY_SEED", faults)?; + match bytes.len() { + ATTESTATION_SEED_LEN => <[u8; ATTESTATION_SEED_LEN]>::try_from(bytes.as_slice()).ok(), + 32 => derive::(&bytes, ATTESTATION_SEED_INFO), + found => { + faults.push(ConfigFault::Invalid { + key: "ATTESTATION_KEY_SEED", + detail: format!( + "it decodes to {found} bytes and must be 32 or {ATTESTATION_SEED_LEN}" + ), + }); + None + } + } +} + +/// HKDF-SHA256 `secret` into `N` bytes under `info`. +/// +/// `ring` rather than a second HKDF implementation: it is already this crate's HMAC for the sync +/// cursor, so the derived key and the key it authenticates come from one primitive. +fn derive(secret: &[u8], info: &[u8]) -> Option<[u8; N]> { + /// The output length, as `ring`'s key-type trait wants it. + #[derive(Debug, Clone, Copy)] + struct Len(usize); + + impl ring::hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } + } + + // An empty salt is HKDF's documented default and the right one here: the derivation is + // domain-separated by `info`, and a salt would have to be configured — one more variable an + // operator can get wrong for no gain, because the input is already a private key. + let prk = ring::hkdf::Salt::new(ring::hkdf::HKDF_SHA256, &[]).extract(secret); + let mut out = [0u8; N]; + prk.expand(&[info], Len(N)).ok()?.fill(&mut out).ok()?; + Some(out) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use jiff::SignedDuration; + + use super::{Backends, Config, Demands, LogFormat, Overrides}; + + /// A PKCS#8 v1 Ed25519 key, base64, from the retired deployment's own `.env.example`. + /// + /// A committed *example* key rather than a generated one, because these tests assert + /// **derivation is deterministic**, and a fresh key per run would make that unassertable. + /// It signs nothing: no deployment ever used it, and any that did published the fact. + const EXAMPLE_DER: &str = "MC4CAQAwBQYDK2VwBCIEIN6eTvXEL7xMZWHY8rTk7VbQSGSuRkle5MVfiiYUStLF"; + + fn env(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + /// The environment a `serve --memory` needs and nothing more. + fn serveable() -> BTreeMap { + env(&[ + ("BLOB_ROOT", "/var/lib/capsule/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]) + } + + fn memory() -> Overrides { + Overrides { + memory: true, + ..Overrides::default() + } + } + + #[test] + fn the_defaults_are_a_complete_configuration() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "0.0.0.0:3000"); + assert_eq!(config.server_domain, "localhost"); + assert_eq!(config.api_base_url, "http://localhost:3000/v1"); + assert_eq!(config.backends, Backends::Memory); + // The policy's year window, not the single day core speaks: a build one day behind + // still writes, and the day core speaks sits strictly inside it. + assert_eq!( + config.protocol_min, + crate::upload::policy::DEFAULT_PROTOCOL_MIN + ); + assert_eq!( + config.protocol_max, + crate::upload::policy::DEFAULT_PROTOCOL_MAX + ); + let spoken = capsule_core::crypto::PROTOCOL_VERSION; + assert!(config.protocol_min.as_str() < spoken && spoken < config.protocol_max.as_str()); + assert_eq!( + config.min_client_build, + crate::upload::policy::DEFAULT_MIN_CLIENT_BUILD + ); + } + + #[test] + fn a_protocol_bound_that_is_not_a_strict_date_is_refused() { + for (key, value) in [ + ("PROTOCOL_MIN", "2026-6-1"), + ("PROTOCOL_MAX", "2026-02-30"), + ("PROTOCOL_MAX", "yesterday"), + ("PROTOCOL_MIN", "2026-05-31T00:00:00Z"), + ] { + let mut environment = serveable(); + environment.insert(key.to_owned(), value.to_owned()); + let error = + Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names(key), "{key}={value}: {error}"); + } + } + + #[test] + fn a_window_of_one_day_is_a_legitimate_operator_choice() { + let mut environment = serveable(); + environment.insert("PROTOCOL_MIN".to_owned(), "2026-05-31".to_owned()); + environment.insert("PROTOCOL_MAX".to_owned(), "2026-05-31".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.protocol_min, config.protocol_max); + } + + #[test] + fn the_client_build_cutoff_is_semver_or_refused() { + let mut environment = serveable(); + environment.insert("MIN_CLIENT_BUILD".to_owned(), "1.4.0".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.min_client_build, "1.4.0"); + + for bad in ["1.4", "v1.4.0", "1.4.0-beta", "one.two.three", ""] { + let mut environment = serveable(); + environment.insert("MIN_CLIENT_BUILD".to_owned(), bad.to_owned()); + match Config::load(&environment, &memory(), Demands::Serve) { + // Empty is unset, which is the default and loads. + Ok(config) if bad.is_empty() => assert_eq!(config.min_client_build, "0.0.0"), + Ok(_) => panic!("MIN_CLIENT_BUILD={bad} loaded"), + Err(error) => assert!(error.names("MIN_CLIENT_BUILD"), "{bad}: {error}"), + } + } + } + + #[test] + fn a_flag_beats_the_environment_which_beats_the_default() { + // The whole precedence table in one case: the environment moves the port off the + // built-in default, and the flag moves it off the environment. + let mut environment = serveable(); + environment.insert("SERVER_PORT".to_owned(), "5000".to_owned()); + + let from_env = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(from_env.listen.to_string(), "0.0.0.0:5000"); + + let overridden = Overrides { + listen: Some("127.0.0.1:6000".parse().expect("a literal address parses")), + ..memory() + }; + let from_flag = Config::load(&environment, &overridden, Demands::Serve).expect("it loads"); + assert_eq!(from_flag.listen.to_string(), "127.0.0.1:6000"); + } + + #[test] + fn server_host_and_server_port_compose_into_one_listener() { + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("SERVER_HOST", "127.0.0.1"), + ("SERVER_PORT", "8080"), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.listen.to_string(), "127.0.0.1:8080"); + } + + #[test] + fn every_fault_is_reported_in_one_pass() { + // The property the aggregate exists for: an operator bringing a deployment up learns + // about all four at once rather than restarting four times. + let environment = env(&[ + ("SERVER_PORT", "not-a-port"), + ("LOG_FORMAT", "yaml"), + ("MAX_CONNECTIONS", "0"), + ]); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SERVER_PORT"), "{error}"); + assert!(error.names("LOG_FORMAT"), "{error}"); + assert!(error.names("MAX_CONNECTIONS"), "{error}"); + assert!(error.names("BLOB_ROOT"), "{error}"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + // Not `ATTESTATION_KEY_SEED`: `--memory` derives it, so naming it here would send a + // developer looking for a variable the development profile does not want. + assert!(!error.names("ATTESTATION_KEY_SEED"), "{error}"); + + // The durable path names both of its own, alongside everything else. + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); + } + + #[test] + fn serving_without_valkey_and_without_the_memory_profile_is_refused_by_name() { + // `store/mod.rs` has documented this refusal since `S-C29` and nothing enforced it. + let error = Config::load(&serveable(), &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("VALKEY_URL"), "{error}"); + } + + #[test] + fn the_memory_profile_is_also_reachable_from_the_environment() { + let mut environment = serveable(); + environment.insert("CAPSULE_PROFILE".to_owned(), "Memory".to_owned()); + let config = + Config::load(&environment, &Overrides::default(), Demands::Serve).expect("it loads"); + assert_eq!(config.backends, Backends::Memory); + } + + #[test] + fn maintenance_needs_a_blob_root_and_no_key_material() { + // A maintenance host that had to hold the production token-signing key to sweep a + // directory would be a reason to put the key on a maintenance host. + let config = Config::load( + &env(&[("BLOB_ROOT", "/blobs")]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect("it loads"); + assert!(config.signing_key_der.is_none()); + + let error = Config::load( + &env(&[("JWT_ED25519_DER", EXAMPLE_DER)]), + &Overrides::default(), + Demands::Maintenance, + ) + .expect_err("it refuses"); + assert!(error.names("BLOB_ROOT"), "{error}"); + } + + #[test] + fn describing_the_router_needs_nothing() { + let config = Config::load(&BTreeMap::new(), &Overrides::default(), Demands::Nothing) + .expect("it loads"); + assert!(config.blob_root.is_none()); + } + + #[test] + fn a_durable_serve_must_be_given_an_attestation_seed() { + // The attestation key must be distinct from the operational key — `attestation/mod.rs` + // requires it so that holding the token signer does not let anything manufacture custody + // evidence. Deriving the seed from `JWT_ED25519_DER` collapses exactly that, so a real + // deployment is made to say what its attestation identity is. + let environment = env(&[ + ("BLOB_ROOT", "/blobs"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ("VALKEY_URL", "redis://127.0.0.1:6379"), + ]); + let error = Config::load(&environment, &Overrides::default(), Demands::Serve) + .expect_err("it refuses"); + assert!(error.names("ATTESTATION_KEY_SEED"), "{error}"); + + let mut with_seed = environment.clone(); + with_seed.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [9_u8; 64]), + ); + let config = Config::load(&with_seed, &Overrides::default(), Demands::Serve) + .expect("it loads with a seed of its own"); + assert_eq!(config.attestation_key_seed, Some([9; 64])); + } + + #[test] + fn the_cursor_key_and_the_attestation_seed_are_derived_from_the_signing_key() { + // The **development** profile only. A durable deployment is made to set the seed; see + // the case above. + let first = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let second = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + + let cursor = first.sync_cursor_mac_key.expect("it is derived"); + let seed = first.attestation_key_seed.expect("it is derived"); + assert_eq!( + Some(cursor), + second.sync_cursor_mac_key, + "derivation is stable" + ); + assert_eq!( + Some(seed), + second.attestation_key_seed, + "derivation is stable" + ); + // Domain separation: the two derivations of one key must not be the same bytes. + assert_ne!( + &seed[..32], + &cursor[..], + "the two infos separate the outputs" + ); + } + + #[test] + fn the_lockout_threshold_is_configurable_and_may_not_be_zero() { + // A lockout is two numbers, not one, and a deployment that wants a tighter policy has to + // be able to move both. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 10); + + let mut environment = serveable(); + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "3".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_attempts, 3); + + environment.insert("LOCKOUT_MAX_ATTEMPTS".to_owned(), "0".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("LOCKOUT_MAX_ATTEMPTS"), "{error}"); + } + + #[test] + fn the_lockout_window_defaults_to_fifteen_minutes_and_is_configurable() { + // It has to decay at all: no route resets a lockout — every one of them refuses on + // `Locked` before it verifies anything — so a permanent lockout is a lost account. + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_mins(15)); + + let mut environment = serveable(); + environment.insert("LOCKOUT_WINDOW_SECONDS".to_owned(), "1".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.lockout_window, SignedDuration::from_secs(1)); + } + + #[test] + fn an_explicit_cursor_key_wins_over_the_derived_one() { + let mut environment = serveable(); + let explicit = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [0x5C_u8; super::CURSOR_KEY_LEN], + ); + environment.insert("SYNC_CURSOR_MAC_KEY".to_owned(), explicit); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.sync_cursor_mac_key, + Some([0x5C; super::CURSOR_KEY_LEN]) + ); + } + + #[test] + fn a_cursor_key_of_the_wrong_length_is_refused_by_length_and_not_by_value() { + let mut environment = serveable(); + environment.insert( + "SYNC_CURSOR_MAC_KEY".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7_u8; 16]), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("SYNC_CURSOR_MAC_KEY"), "{error}"); + assert!(format!("{error}").contains("16 bytes"), "{error}"); + } + + #[test] + fn a_thirty_two_byte_attestation_seed_is_expanded_rather_than_refused() { + let mut environment = serveable(); + environment.insert( + "ATTESTATION_KEY_SEED".to_owned(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [3_u8; 32]), + ); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + let seed = config.attestation_key_seed.expect("it is expanded"); + // Expanded, not repeated: the two halves of the hybrid key must not share a seed. + assert_ne!(&seed[..32], &seed[32..]); + } + + #[test] + fn a_signing_key_that_is_not_base64_is_refused_without_quoting_it() { + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + "not base64 at all!!".to_owned(), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + let message = format!("{error}"); + assert!(error.names("JWT_ED25519_DER"), "{message}"); + assert!( + !message.contains("not base64 at all"), + "a startup error must not echo the key material: {message}" + ); + } + + #[test] + fn an_unpadded_signing_key_loads() { + // `openssl genpkey … | base64` piped through anything that strips `=` is common enough + // that refusing it would be a support question rather than a security property. + let mut environment = serveable(); + environment.insert( + "JWT_ED25519_DER".to_owned(), + EXAMPLE_DER.trim_end_matches('=').to_owned(), + ); + assert!(Config::load(&environment, &memory(), Demands::Serve).is_ok()); + } + + #[test] + fn an_empty_variable_is_an_absent_one() { + // `FOO=` in a compose file means "I did not set this", and reading it as a zero-length + // signing key would fail somewhere much less obvious than here. + let mut environment = serveable(); + environment.insert("JWT_ED25519_DER".to_owned(), String::new()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("JWT_ED25519_DER"), "{error}"); + } + + #[test] + fn the_retired_upload_dir_name_is_still_honoured() { + let environment = env(&[ + ("UPLOAD_DIR", "/legacy/uploads"), + ("JWT_ED25519_DER", EXAMPLE_DER), + ]); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.blob_root.as_deref(), + Some(std::path::Path::new("/legacy/uploads")) + ); + } + + #[test] + fn a_config_file_is_refused_with_what_to_do_instead() { + let overrides = Overrides { + config_file: Some("/etc/capsule/server.toml".into()), + ..memory() + }; + let error = Config::load(&serveable(), &overrides, Demands::Serve).expect_err("it refuses"); + assert!(error.names("--config"), "{error}"); + assert!(format!("{error}").contains("environment"), "{error}"); + } + + #[test] + fn an_inverted_protocol_window_is_refused() { + let mut environment = serveable(); + environment.insert("PROTOCOL_MIN".to_owned(), "2099-01-01".to_owned()); + environment.insert("PROTOCOL_MAX".to_owned(), "2025-01-01".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("PROTOCOL_MIN"), "{error}"); + } + + #[test] + fn the_log_format_follows_the_build_profile_when_unset() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let expected = if cfg!(debug_assertions) { + LogFormat::Pretty + } else { + LogFormat::Json + }; + assert_eq!(config.log_format, expected); + } + + #[test] + fn the_grace_window_is_hours_and_the_flag_beats_the_environment() { + let mut environment = serveable(); + environment.insert("GC_GRACE_WINDOW_HOURS".to_owned(), "72".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(72)); + + let overrides = Overrides { + grace_window_hours: Some(1), + ..memory() + }; + let config = Config::load(&environment, &overrides, Demands::Serve).expect("it loads"); + assert_eq!(config.grace_window, jiff::SignedDuration::from_hours(1)); + } + + #[test] + fn oidc_is_off_unless_an_issuer_is_named() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + assert!(config.oidc.is_none()); + } + + #[test] + fn an_oidc_relying_party_is_read_whole() { + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test/realm".to_owned(), + ); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + environment.insert("OIDC_CLIENT_SECRET".to_owned(), "hunter2".to_owned()); + environment.insert( + "OIDC_REDIRECT_URL".to_owned(), + "https://app.example.test/oidc/callback".to_owned(), + ); + environment.insert( + "OIDC_ALLOW_LOOPBACK_REDIRECT".to_owned(), + "false".to_owned(), + ); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + let oidc = config.oidc.clone().expect("configured"); + assert_eq!(oidc.issuer, "https://idp.example.test/realm"); + assert_eq!(oidc.client_id, "capsule"); + assert!(oidc.client_secret.is_some()); + assert!( + oidc.redirects + .admits("https://app.example.test/oidc/callback") + ); + assert!(!oidc.redirects.admits("http://127.0.0.1:4242/cb")); + assert!( + !format!("{config:?}").contains("hunter2"), + "the client secret is redacted" + ); + } + + #[test] + fn loopback_redirects_are_opt_in_and_a_secret_is_optional() { + // RFC 8252 §8.5: a native client cannot keep a secret; PKCE is what makes it sound. And + // the loopback arm is the one knob that widens where the server redirects to, so it is + // off until a deployment with such a client turns it on. + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + let oidc = config.oidc.expect("configured"); + assert!(oidc.client_secret.is_none()); + assert!(!oidc.redirects.admits("http://127.0.0.1:4242/cb")); + + environment.insert("OIDC_ALLOW_LOOPBACK_REDIRECT".to_owned(), "true".to_owned()); + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert!( + config + .oidc + .expect("configured") + .redirects + .admits("http://127.0.0.1:4242/cb") + ); + } + + #[test] + fn a_ca_bundle_is_a_path_read_later_not_here() { + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + environment.insert( + "OIDC_CA_BUNDLE".to_owned(), + "/etc/capsule/idp-ca.pem".to_owned(), + ); + // The file does not exist and loading does not care: reading it is boot's job. + let config = Config::load(&environment, &memory(), Demands::Serve).expect("it loads"); + assert_eq!( + config.oidc.expect("configured").ca_bundle.as_deref(), + Some(std::path::Path::new("/etc/capsule/idp-ca.pem")) + ); + } + + #[test] + fn a_redirect_url_is_https_or_loopback_http() { + for (redirect, ok) in [ + ("https://app.example.test/cb", true), + ("http://127.0.0.1:4242/cb", true), + ("http://app.example.test/cb", false), + ("http://localhost:4242/cb", false), + ] { + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + environment.insert("OIDC_REDIRECT_URL".to_owned(), redirect.to_owned()); + let result = Config::load(&environment, &memory(), Demands::Serve); + assert_eq!(result.is_ok(), ok, "{redirect}: {result:?}"); + if let Err(error) = result { + assert!(error.names("OIDC_REDIRECT_URL"), "{error}"); + } + } + } + + #[test] + fn half_a_relying_party_is_refused_both_ways_round() { + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("OIDC_CLIENT_ID"), "{error}"); + + let mut environment = serveable(); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("OIDC_ISSUER"), "{error}"); + } + + #[test] + fn an_issuer_is_https_or_loopback_http_with_no_query() { + for (issuer, ok) in [ + ("https://idp.example.test", true), + ("http://127.0.0.1:5556/dex", true), + ("http://idp.example.test", false), + ("https://idp.example.test/?x=1", false), + ("https://idp.example.test/#frag", false), + ("idp.example.test", false), + ("ftp://idp.example.test", false), + ] { + let mut environment = serveable(); + environment.insert("OIDC_ISSUER".to_owned(), issuer.to_owned()); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + let result = Config::load(&environment, &memory(), Demands::Serve); + assert_eq!(result.is_ok(), ok, "{issuer}: {result:?}"); + if let Err(error) = result { + assert!(error.names("OIDC_ISSUER"), "{error}"); + } + } + } + + #[test] + fn a_malformed_redirect_url_or_loopback_flag_is_refused_by_name() { + let mut environment = serveable(); + environment.insert( + "OIDC_ISSUER".to_owned(), + "https://idp.example.test".to_owned(), + ); + environment.insert("OIDC_CLIENT_ID".to_owned(), "capsule".to_owned()); + environment.insert("OIDC_REDIRECT_URL".to_owned(), "not a url".to_owned()); + environment.insert( + "OIDC_ALLOW_LOOPBACK_REDIRECT".to_owned(), + "maybe".to_owned(), + ); + let error = Config::load(&environment, &memory(), Demands::Serve).expect_err("it refuses"); + assert!(error.names("OIDC_REDIRECT_URL"), "{error}"); + assert!(error.names("OIDC_ALLOW_LOOPBACK_REDIRECT"), "{error}"); + } + + #[test] + fn a_secret_is_not_printed_by_debug() { + let config = Config::load(&serveable(), &memory(), Demands::Serve).expect("it loads"); + let rendered = format!("{config:?}"); + assert!(rendered.contains(""), "{rendered}"); + assert!(!rendered.contains("MC4CAQAwBQYDK2Vw"), "{rendered}"); + } +} diff --git a/capsule-server/src/counter/budgets.rs b/capsule-server/src/counter/budgets.rs index 2c53cd5f..826720cc 100644 --- a/capsule-server/src/counter/budgets.rs +++ b/capsule-server/src/counter/budgets.rs @@ -28,6 +28,17 @@ pub const LOGIN_ATTEMPTS: Budget = Budget::new(5, SignedDuration::from_mins(15)) /// offer at all. pub const ENROLLMENT_REDEMPTION: Budget = Budget::new(10, SignedDuration::from_mins(10)); +/// Redemption attempts presenting something that is not shaped like a code at all. +/// +/// Sixty a minute against +/// [`CounterKey::EnrollmentRedemptionMalformed`](crate::counter::CounterKey::EnrollmentRedemptionMalformed)'s +/// one bucket, the same shape the refused-redirect bucket takes. Deliberately far more generous +/// than [`ENROLLMENT_REDEMPTION`]: a malformed code is not a guess at a *particular* pending +/// enrollment — it cannot match one — so this number is not part of the entropy argument the +/// per-code budget carries. It exists so that a malformed attempt is not free, and so the +/// partition holding it can never be more than one key wide. +pub const ENROLLMENT_REDEMPTION_MALFORMED: Budget = Budget::new(60, SignedDuration::from_mins(1)); + /// Requests against one share link's opaque id. /// /// Sixty a minute: generous for a person opening a shared album, and a hard ceiling on how fast @@ -60,9 +71,62 @@ pub const DROP_SOURCE: Budget = Budget::new(60, SignedDuration::from_hours(1)); /// because a code that expires mid-typing costs an attempt through no fault of the user. pub const SECOND_FACTOR: Budget = Budget::new(5, SignedDuration::from_mins(5)); +/// Begun OIDC ceremonies per **admitted** redirect host (`S-N1`). +/// +/// Sixty a minute. A person signing in begins one; a browser that retries a few times begins a +/// handful; a script filling the pending-ceremony store begins thousands. The key space is +/// three hosts at most (the configured redirect and the two loopback literals) *because the +/// route validates the redirect before it charges this budget*, so this is close to a +/// deployment-wide ceiling: at the ten-minute ceremony TTL it caps the in-memory store at +/// well under two thousand live records against its ten-thousand ceiling. +pub const OIDC_AUTHORIZE: Budget = Budget::new(60, SignedDuration::from_mins(1)); + +/// OIDC authorizes whose redirect the policy refused, deployment-wide (`S-N1`). +/// +/// Sixty a minute, the same number as the admitted path, against +/// [`CounterKey::OidcAuthorizeRefused`](crate::counter::CounterKey::OidcAuthorizeRefused)'s one +/// bucket. A refused authorize does no work worth throttling for its own sake — the redirect +/// check is a string comparison and nothing is written — so this budget is not protecting the +/// server's CPU. It is here so that "refused" is not the one request on the surface that costs +/// an attacker nothing to repeat, and so the log line that reports the refusal is itself +/// bounded. +pub const OIDC_AUTHORIZE_REFUSED: Budget = Budget::new(60, SignedDuration::from_mins(1)); + /// Deep storage verifications per account. /// /// Four an hour. The contract calls the limiter *half of the feature*: a deep verify reads and /// re-hashes every declared blob, so an unbounded one is an I/O-amplification attack costing the /// attacker one small JSON body. pub const DEEP_VERIFY: Budget = Budget::new(4, SignedDuration::from_hours(1)); + +/// Requests from one federated peer, across the sync and blob reads (invariant 21). +/// +/// Ten thousand an hour — the retired server's established-tier default. A peer pulling a +/// shared album makes one sync page and a few blob fetches per asset; ten thousand is an +/// evening of photos from one household of peers, and a hostile peer enumerating addresses +/// gets fewer than three a second. A fixed window, so a peer that spends it waits for the +/// hour to turn rather than trickling back in. +pub const PEER_REQUESTS: Budget = Budget::new(10_000, SignedDuration::from_hours(1)); + +/// Federated moderation reports from one peer against one account (invariant 24). +/// +/// Twenty an hour per `(reporting_server, reported_user)`. A real report is one message; a +/// flood against one user is the false-flag vector the contract names, and backpressure at +/// twenty bounds it without silencing a peer that has two things to say. +pub const FEDERATED_REPORTS: Budget = Budget::new(20, SignedDuration::from_hours(1)); + +/// Federated moderation reports from one peer against **every** account (`S-C49`). +/// +/// Two hundred an hour. Ten times the per-account allowance, so a peer with a genuinely bad hour +/// — a spam wave it is reporting honestly — is not silenced, while a peer cycling `reported_user` +/// to mint itself a fresh per-account budget each time runs into a ceiling that does not care +/// which account it named. +pub const PEER_REPORTS: Budget = Budget::new(200, SignedDuration::from_hours(1)); + +/// Attempts at `POST /v1/federation/reports` from one **claimed** origin (`S-C49`). +/// +/// Three hundred an hour, charged before the peer is looked up. Deliberately above +/// [`PEER_REPORTS`], because it is not a policy on reporting — it is the bound on how much work +/// an anonymous caller can ask for on the server's one unauthenticated write, and a real peer +/// must never meet it before meeting the budget that *is* the policy. +pub const FEDERATED_INTAKE: Budget = Budget::new(300, SignedDuration::from_hours(1)); diff --git a/capsule-server/src/counter/conformance.rs b/capsule-server/src/counter/conformance.rs new file mode 100644 index 00000000..bfb2fb29 --- /dev/null +++ b/capsule-server/src/counter/conformance.rs @@ -0,0 +1,234 @@ +//! The one suite every counter adapter must pass. +//! +//! The property that matters is that charging and deciding are one operation. A single-threaded +//! sequence cannot exhibit the race that makes read-then-write wrong — the same limit `S-C21`'s +//! conformance note records — so most of what is asserted here is the *observable consequence*: +//! the n-th hit inside a window is refused, and no sequence of calls admits more than the budget. +//! [`concurrent_hits_admit_exactly_the_budget`] is the one case that does race, on a +//! multi-threaded runtime, and it is the case a Valkey adapter built on read-then-`INCR` fails. +//! +//! Like [`crate::store::conformance`], this lives in `src/` because it is part of the contract: +//! the in-memory double is a legitimate stand-in for Valkey only to the extent it passes the +//! same cases the container-backed adapter does. +//! +//! # Time +//! +//! [`CounterStore::hit`] takes `at`, so the suite moves time by passing later instants rather +//! than through a harness clock. That is a property of the port worth keeping: the window a +//! limit measures is a fact about the caller's clock, and an adapter that measured it with a +//! clock of its own would be a second clock for one fact. +//! +//! # Reusing a store +//! +//! Every case scopes its key to itself and resets it first, so cases may share one store — and +//! [`run_all`] does — and may be re-run against a server that still holds the previous run. + +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; + +use super::{Budget, CounterKey, CounterStore, Verdict}; +use crate::store::{StoreError, UserId, deadline}; + +/// Unwrap a store result, failing with the operation that was expected to work. +fn ok(result: Result, operation: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming adapter must succeed at {operation}: {error}"), + } +} + +/// A key scoped to one case. +fn key(case: &str) -> CounterKey { + CounterKey::LoginAttempts(UserId::new(format!("counter-conformance-{case}"))) +} + +/// Three hits per ten minutes. +fn budget() -> Budget { + Budget::new(3, SignedDuration::from_mins(10)) +} + +/// `mins` minutes after the epoch. +fn at(mins: i64) -> Timestamp { + deadline(Timestamp::UNIX_EPOCH, SignedDuration::from_mins(mins)) +} + +/// A fresh key for `case`: reset, so a store that remembers a previous run starts clean. +async fn fresh(counters: &dyn CounterStore, case: &str) -> CounterKey { + let key = key(case); + ok(counters.reset(&key).await, "reset"); + key +} + +/// A window admits exactly its budget, then refuses and says when to come back. +pub async fn a_window_admits_exactly_its_budget_and_then_refuses(counters: &dyn CounterStore) { + let key = fresh(counters, "budget").await; + + for expected_remaining in [2, 1, 0] { + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Admitted { + remaining: expected_remaining + } + ); + } + + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Limited { + retry_after: at(10) + }, + "the fourth hit is refused, and is told when to come back" + ); +} + +/// A refused hit does not extend the window. +/// +/// Otherwise an attacker who keeps hitting a limited key holds it limited forever, which turns a +/// rate limit into a denial of service against the legitimate user. +pub async fn a_refused_hit_does_not_extend_the_window(counters: &dyn CounterStore) { + let key = fresh(counters, "no-extend").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + for minute in 1..8 { + assert!( + !ok(counters.hit(&key, budget(), at(minute)).await, "hit").admits(), + "still limited at minute {minute}" + ); + } + + assert!( + ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "the window still ends ten minutes after it opened, not ten after the last attempt" + ); +} + +/// A window is measured from its first hit, and a fresh window is a fresh budget. +pub async fn a_window_measures_from_its_first_hit(counters: &dyn CounterStore) { + let key = fresh(counters, "first-hit").await; + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + ok(counters.hit(&key, budget(), at(9)).await, "hit"); + + assert!( + ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "a fresh window opens once the first one passes" + ); + for _ in 0..2 { + assert!(ok(counters.hit(&key, budget(), at(11)).await, "hit").admits()); + } + assert!( + !ok(counters.hit(&key, budget(), at(11)).await, "hit").admits(), + "and that fresh window is a fresh budget, not a continuation" + ); +} + +/// One account's failed sign-ins are not another's. +pub async fn counters_are_scoped_to_their_key(counters: &dyn CounterStore) { + let key = fresh(counters, "scoped-a").await; + let other = fresh(counters, "scoped-b").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + + assert!( + ok(counters.hit(&other, budget(), at(0)).await, "hit").admits(), + "one account's failed sign-ins are not another's" + ); + assert!( + !ok(counters.hit(&key, budget(), at(0)).await, "hit").admits(), + "and the first is still limited" + ); +} + +/// A reset ends the run: what a successful sign-in does to a failed-attempt counter. +pub async fn a_reset_ends_the_run(counters: &dyn CounterStore) { + let key = fresh(counters, "reset").await; + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + assert!(!ok(counters.hit(&key, budget(), at(0)).await, "hit").admits()); + + ok(counters.reset(&key).await, "reset"); + assert_eq!( + ok(counters.hit(&key, budget(), at(0)).await, "hit"), + Verdict::Admitted { remaining: 2 } + ); +} + +/// Peeking charges nothing and answers a verdict, not a number. +pub async fn peeking_charges_nothing_and_answers_a_verdict(counters: &dyn CounterStore) { + let key = fresh(counters, "peek").await; + for _ in 0..5 { + assert_eq!( + ok(counters.peek(&key, budget(), at(0)).await, "peek"), + Verdict::Admitted { remaining: 3 }, + "peeking does not spend the budget" + ); + } + + for _ in 0..3 { + ok(counters.hit(&key, budget(), at(0)).await, "hit"); + } + assert_eq!( + ok(counters.peek(&key, budget(), at(0)).await, "peek"), + Verdict::Limited { + retry_after: at(10) + } + ); + assert!( + ok(counters.peek(&key, budget(), at(10)).await, "peek").admits(), + "a peek past the window sees a fresh budget" + ); +} + +/// No sequence of calls admits more than the budget, however peeks are interleaved. +pub async fn no_sequence_of_calls_admits_more_than_the_budget(counters: &dyn CounterStore) { + let key = fresh(counters, "sequence").await; + let mut admitted = 0; + for _ in 0..50 { + ok(counters.peek(&key, budget(), at(0)).await, "peek"); + if ok(counters.hit(&key, budget(), at(0)).await, "hit").admits() { + admitted += 1; + } + } + assert_eq!(admitted, 3); +} + +/// Racing hits admit exactly the budget — the property read-then-write does not have. +/// +/// Every task charges the same key at the same instant. An adapter that read the count, compared +/// it and then incremented would let a burst read the same under-limit value and admit them all; +/// one that charges and decides in one critical section (a mutex, a Lua script) admits three. +/// Meaningful on a multi-threaded runtime; on a single thread it degrades to the sequence case. +pub async fn concurrent_hits_admit_exactly_the_budget(counters: Arc) { + let key = fresh(&*counters, "concurrent").await; + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..24 { + let counters = Arc::clone(&counters); + let key = key.clone(); + tasks.spawn(async move { ok(counters.hit(&key, budget(), at(0)).await, "hit") }); + } + let mut admitted = 0; + while let Some(verdict) = tasks.join_next().await { + if verdict.expect("a hit task completes").admits() { + admitted += 1; + } + } + assert_eq!(admitted, 3, "racing hits must admit exactly the budget"); +} + +/// Run every case above against one store, in order. +/// +/// The entry point for a backend where standing up a store is expensive. A unit-tier adapter +/// should prefer one test per case, so a failure names the property that broke. +pub async fn run_all(counters: Arc) { + a_window_admits_exactly_its_budget_and_then_refuses(&*counters).await; + a_refused_hit_does_not_extend_the_window(&*counters).await; + a_window_measures_from_its_first_hit(&*counters).await; + counters_are_scoped_to_their_key(&*counters).await; + a_reset_ends_the_run(&*counters).await; + peeking_charges_nothing_and_answers_a_verdict(&*counters).await; + no_sequence_of_calls_admits_more_than_the_budget(&*counters).await; + concurrent_hits_admit_exactly_the_budget(counters).await; +} diff --git a/capsule-server/src/counter/mod.rs b/capsule-server/src/counter/mod.rs index be6601dd..be22ed81 100644 --- a/capsule-server/src/counter/mod.rs +++ b/capsule-server/src/counter/mod.rs @@ -37,6 +37,78 @@ //! v1 abuse gate needs. Where the doubled burst would matter the budget is halved rather than //! the algorithm changed, and this paragraph is the record of that trade rather than a comment //! somebody later mistakes for a bug. +//! +//! # The map of windows is bounded, twice — and partitioned, so the bounds are not shared +//! +//! A counter's *key* is frequently derived from something a caller sent — a share-link id, an +//! enrollment code, a redirect host. A key space every caller can extend is a map that only +//! grows, and this one is process-wide and shared by every limiter on the surface, so growth +//! here is not one feature's problem. Two bounds, the same pair +//! [`InMemoryOidcAuthorizations`](crate::store::memory::InMemoryOidcAuthorizations) carries: +//! +//! - **Purged on every write.** A window whose budget has lapsed decides nothing — [`verdict`] +//! already treats it as absent — so it is dropped rather than kept as a row nobody reads. The +//! map holds live windows plus whatever lapsed since the last hit, never everything ever +//! counted. +//! - **A ceiling.** Past its ceiling a key that has no window yet is refused with +//! [`StoreError::Rejected`], while every key that already has one keeps counting. Callers +//! treat a counter error as a refusal, so a full partition fails *closed*: a limiter under +//! memory pressure denies rather than waves through, and an attacker cannot switch a limiter +//! off by loading the store. +//! +//! # The ceiling is per [`CounterKey`] variant, never one number for everything +//! +//! One shared ceiling makes every limiter share a fate. Three surfaces charge a caller-controlled +//! key *before* resolving what it names — the share path, the drop path and the enrollment +//! redemption — and the drop path's window is an hour long, so it is the cheapest of them to +//! hold saturated. Under a single ceiling, a flood against that one surface would refuse a +//! **first** key to every other: a first-time share view, a first enrollment redemption after a +//! reboot, the first OIDC sign-in of the day. Each of those maps a counter error to a fail-closed +//! `500`/`503`, so the weakest surface would decide the availability of all four. +//! +//! So the store holds one partition per variant, each with [`CounterKey::ceiling`] sized from +//! that variant's own window and its own plausible rate of *distinct* keys — see the constants +//! below for the arithmetic. Filling one partition refuses new keys in that partition only. The +//! totals are deliberately close to the single ceiling they replace, because the point is not to +//! hold more windows; it is that the windows one surface holds are not the windows another +//! surface is denied. +//! +//! # What a legitimate caller experiences when a ceiling bites +//! +//! The paragraphs above describe the mechanism. This is the consequence, which is the part worth +//! knowing at three in the morning. +//! +//! Partitioning bounds the blast radius; it does not make the flooded surface well. `DropLink` +//! is the cheapest partition to hold saturated — twenty thousand fabricated but well-formed ids +//! across an hour-long window, under six a second — and while it is saturated, every visitor +//! arriving at a drop link the store holds no window for is refused. That is a **first-time** +//! visitor: a link already being counted keeps being counted, so the flood cannot evict anyone +//! it has not already locked out. +//! +//! Those callers are told `429` with an `error.*_at_capacity` code and a `retry_after`, **not** +//! the `500 error.*_unavailable` a broken store renders. The refusal is fail-closed either way; +//! what changes is that a client can back off instead of reporting an outage, and an operator +//! paged on `5xx` can tell "saturated by design" from "the store is down" without reading a +//! server-side `WARN` and inferring it. The distinction is the whole reason the ceiling refusal +//! has a code of its own. +//! +//! What partitioning is *not* is a fix for the flood. A per-source key is what would bound it, +//! and all three source keys wait on a trusted client address this server does not have. +//! +//! # One lock, and what that does and does not cover +//! +//! Every partition lives behind the same [`Mutex`]. Admission is genuinely independent — one +//! partition's occupancy is invisible to another's ceiling — but *latency* is not: a sustained +//! flood against one key serialises `hit`, `peek` and `reset` for every other. The critical +//! section holds no `.await` and does `O(log n)` work over at most twenty thousand entries, so at +//! these sizes it is contention rather than denial. Stated because the claim above ("the windows +//! one surface holds are not the windows another is denied") is about admission and should not be +//! read as a latency guarantee. Per-partition locking is deferred to issue #477, not overlooked. +//! +//! This is defence in depth, not a licence. Every derived key should still be bounded where it +//! is built — the OIDC authorize validates the redirect before it charges +//! ([`CounterKey::OidcAuthorizeRefused`]), and the enrollment redemption shape-checks the code +//! before it charges ([`CounterKey::EnrollmentRedemptionMalformed`]). use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; @@ -56,7 +128,19 @@ pub enum CounterKey { LoginAttempts(UserId), /// Enrollment-code redemptions against one pending enrollment (`S-C7`, invariant 31's /// sibling in the enrollment contract). + /// + /// Built only from a code that passed the route's shape check, for the reason + /// [`Self::OidcAuthorize`] is built only from an admitted redirect host: the presented code + /// is caller-supplied, and a counter keyed on an unchecked one is a partition an + /// unauthenticated caller fills a row at a time. Anything malformed goes to + /// [`Self::EnrollmentRedemptionMalformed`]. EnrollmentRedemption(String), + /// Every enrollment redemption presenting a code that is not even shaped like one (`S-C7`). + /// + /// One bucket, as [`Self::OidcAuthorizeRefused`] is one bucket, and for the same reasons: + /// a malformed attempt must still be throttled, and it must not be throttled *per code*, + /// because the code is whatever the caller typed. + EnrollmentRedemptionMalformed, /// Requests against one share link's opaque id (`S-C4`). ShareLink(String), /// Requests from one source address, on the public share path. @@ -83,14 +167,78 @@ pub enum CounterKey { /// missed — recorded here rather than replaced by an email-keyed limiter, which would bound /// repeated probes against one address while doing nothing about a sweep across many. RegistrationSource(String), + /// Begun OIDC ceremonies naming one **admitted** redirect host (`S-N1`). + /// + /// Keyed on the redirect URI's host, and constructed only after + /// [`IdentityProvider::admits_redirect`](crate::auth::oidc::IdentityProvider::admits_redirect) + /// has said so. That ordering is load-bearing rather than tidy: the policy admits the + /// configured redirect and the two loopback literals, so **downstream of validation** the key + /// space is three buckets and the budget is, in effect, a deployment-wide ceiling on how fast + /// pending ceremonies can be begun — which is what bounds the ceremony store's growth. + /// Upstream of validation the host is an arbitrary caller-supplied string, and a counter + /// keyed on one is a map an unauthenticated caller grows a row at a time. Every refusal goes + /// to [`Self::OidcAuthorizeRefused`] instead. + /// + /// A per-source key is the better one and is waiting on the same missing fact as + /// [`Self::RegistrationSource`]. + OidcAuthorize(String), + /// Every OIDC authorize whose redirect the policy refused, in one bucket (`S-N1`). + /// + /// A refusal must still be throttled — otherwise the cheapest request on the surface is the + /// one nothing counts — but it must not be throttled *per host*, because the host of a + /// refused redirect is whatever the caller typed. So refusals share one deployment-wide + /// window. That is deliberately blunt: it means a flood of invalid redirects can spend the + /// refusal budget for everybody. It costs nothing real, because a client whose redirect the + /// deployment admits never charges this bucket at all — only misconfigured and abusive + /// callers do, and a misconfigured client's remedy is to be configured. + /// + /// A unit variant rather than `OidcAuthorize("")`: this enum exists because the + /// retired surface namespaced counters with hand-formatted strings, and a sentinel string is + /// that mistake with a nicer name. + OidcAuthorizeRefused, + /// Requests from one federated peer server, across the sync and blob reads (`S-E2`, + /// invariant 21). + /// + /// Keyed on the peer's origin, never on the capability: a peer holding ten capabilities is + /// one blast-radius boundary, and a budget per token would be a budget a peer widens by + /// asking for more tokens. Events per hour only; bytes and CPU per hour need a weighted + /// counter this port does not have and are post-v1. + PeerRequests(String), + /// Federated moderation reports from one peer against one account (`S-C49`, invariant + /// 24), keyed on `"{reporting_server}:{reported_user}"` as the contract bounds them. + FederatedReports(String), + /// Federated moderation reports from one peer against **every** account (`S-C49`). + /// + /// The ceiling the per-account budget cannot provide: `reported_user` is a string a peer + /// chooses, so a peer cycling accounts gets a fresh per-account allowance each time, and + /// only a key that ignores the account bounds the peer's total volume. + PeerReports(String), + /// Attempts at `POST /v1/federation/reports`, keyed on the **claimed** reporting origin. + /// + /// Charged before the peer is looked up, which is the only place a bound can sit on this + /// route: it is the server's one unauthenticated write, and everything after it — a store + /// read and an Ed25519 verification — is work an anonymous caller would otherwise get for + /// free. The key is attacker-chosen and that is stated rather than papered over: it bounds + /// one claimed origin looping, not a caller cycling origins, and this server has no trusted + /// client address to key on instead (see + /// [`CounterKey::RegistrationSource`], which waits on the same missing fact). + FederatedIntake(String), } +/// The scope segment a key with nothing to be scoped *to* carries. +/// +/// Only the unit variants use it: a counter that partitions by account or by address puts that +/// value here, and one that is a single global bucket has no such value to put. Named rather +/// than written inline at each arm so the two spellings cannot drift. +const GLOBAL_SCOPE: &str = "global"; + impl CounterKey { /// The name this key travels under, for a log field. pub fn as_str(&self) -> &'static str { match self { Self::LoginAttempts(_) => "login_attempts", Self::EnrollmentRedemption(_) => "enrollment_redemption", + Self::EnrollmentRedemptionMalformed => "enrollment_redemption_malformed", Self::ShareLink(_) => "share_link", Self::ShareSource(_) => "share_source", Self::DropLink(_) => "drop_link", @@ -98,6 +246,63 @@ impl CounterKey { Self::DeepVerify(_) => "deep_verify", Self::SecondFactor(_) => "second_factor", Self::RegistrationSource(_) => "registration_source", + Self::OidcAuthorize(_) => "oidc_authorize", + Self::OidcAuthorizeRefused => "oidc_authorize_refused", + Self::PeerRequests(_) => "peer_requests", + Self::FederatedReports(_) => "federated_reports", + Self::PeerReports(_) => "peer_reports", + Self::FederatedIntake(_) => "federated_intake", + } + } + + /// How many simultaneously live windows this variant's partition may hold. + /// + /// Per variant and never one number for all of them: see the module docs for why a shared + /// ceiling is a shared fate, and [`ceilings`] for each number's arithmetic. + pub fn ceiling(&self) -> usize { + match self { + Self::LoginAttempts(_) => ceilings::LOGIN_ATTEMPTS, + Self::EnrollmentRedemption(_) => ceilings::ENROLLMENT_REDEMPTION, + Self::EnrollmentRedemptionMalformed => ceilings::ENROLLMENT_REDEMPTION_MALFORMED, + Self::ShareLink(_) => ceilings::SHARE_LINK, + Self::DropLink(_) => ceilings::DROP_LINK, + Self::ShareSource(_) | Self::DropSource(_) | Self::RegistrationSource(_) => { + ceilings::SOURCE_ADDRESS + } + Self::DeepVerify(_) => ceilings::DEEP_VERIFY, + Self::SecondFactor(_) => ceilings::SECOND_FACTOR, + Self::OidcAuthorize(_) => ceilings::OIDC_AUTHORIZE, + Self::OidcAuthorizeRefused => ceilings::OIDC_AUTHORIZE_REFUSED, + Self::PeerRequests(_) | Self::PeerReports(_) => ceilings::PEER_ORIGIN, + Self::FederatedReports(_) => ceilings::FEDERATED_REPORTS, + Self::FederatedIntake(_) => ceilings::FEDERATED_INTAKE, + } + } + + /// What the key is scoped to — the account, the link, the address — for the backend key + /// that carries it. Paired with [`Self::as_str`], which names the kind. + pub fn scope(&self) -> &str { + match self { + Self::LoginAttempts(user) | Self::DeepVerify(user) => user.as_str(), + Self::EnrollmentRedemption(scope) + | Self::ShareLink(scope) + | Self::ShareSource(scope) + | Self::DropLink(scope) + | Self::DropSource(scope) + | Self::SecondFactor(scope) + | Self::OidcAuthorize(scope) + | Self::PeerRequests(scope) + | Self::FederatedReports(scope) + | Self::PeerReports(scope) + | Self::FederatedIntake(scope) + | Self::RegistrationSource(scope) => scope, + // The two unit variants are each **one** global bucket by construction — that is why + // they are unit variants rather than a scoped one carrying a sentinel string (see + // their own docs). They still need a scope segment, because the backend key is + // `capsule:counter:{kind}:{scope}` and a key ending in `:` invites a second, subtly + // different spelling later. `GLOBAL` is that segment, shared deliberately: `as_str` + // already distinguishes the two kinds, so one constant cannot collide them. + Self::EnrollmentRedemptionMalformed | Self::OidcAuthorizeRefused => GLOBAL_SCOPE, } } } @@ -150,8 +355,15 @@ pub trait CounterStore: std::fmt::Debug + Send + Sync { /// /// **The two together, never separately.** Read-then-increment lets every request in a burst /// read the same under-limit value, which is the burst the limiter exists to stop. Every - /// adapter owes this atomically; the in-memory one gets it from a mutex and Valkey from - /// `INCR` plus a first-hit `EXPIRE`. + /// adapter owes this atomically; the in-memory one gets it from a mutex and Valkey from one + /// Lua script that opens, charges or refuses the window in a single server-side step + /// ([`valkey::ValkeyCounters`]). + /// + /// An adapter may answer [`StoreError::Rejected`](crate::store::StoreError::Rejected) when + /// it cannot hold another key's window. That is an error and not a [`Verdict`], deliberately: + /// the caller's rule for *any* counter failure is already "refuse", so a full store denies + /// through the path that is documented to fail closed rather than through a new one somebody + /// could handle as an admission. fn hit<'a>( &'a self, key: &'a CounterKey, @@ -178,10 +390,129 @@ pub trait CounterStore: std::fmt::Debug + Send + Sync { fn reset<'a>(&'a self, key: &'a CounterKey) -> StoreFuture<'a, ()>; } +/// How many simultaneously live windows each [`CounterKey`] variant may hold. +/// +/// Each is that variant's window length multiplied by a stated rate of *distinct* keys, rounded +/// up for headroom. The budget bounds hits **per key**; it says nothing about how many keys +/// exist, so the key rate is the assumption each number is built on and each is written down. +pub mod ceilings { + /// [`CounterKey::LoginAttempts`](super::CounterKey::LoginAttempts) — 15-minute window, keyed + /// on an account. + /// + /// 900 s × ~1 account entering a failure window per second = 900. Rounded to ten thousand: + /// the key is an account id, so the true bound is the size of the directory, and a + /// deployment large enough to exceed this has other numbers to raise first. + pub const LOGIN_ATTEMPTS: usize = 10_000; + + /// [`CounterKey::EnrollmentRedemption`](super::CounterKey::EnrollmentRedemption) — + /// 10-minute window, keyed on a shape-checked code. + /// + /// 600 s × ~1 code presented per second = 600. Rounded to five thousand. Device enrollment + /// is a rare, deliberate act: a deployment redeeming five thousand distinct codes inside ten + /// minutes is not one this number is failing. + pub const ENROLLMENT_REDEMPTION: usize = 5_000; + + /// [`CounterKey::EnrollmentRedemptionMalformed`](super::CounterKey::EnrollmentRedemptionMalformed) + /// — one key exists, so one window. + pub const ENROLLMENT_REDEMPTION_MALFORMED: usize = 1; + + /// [`CounterKey::ShareLink`](super::CounterKey::ShareLink) — 1-minute window, keyed on a + /// caller-supplied opaque id. + /// + /// 60 s × ~100 distinct links opened per second = 6 000. Rounded to twenty thousand for + /// three-fold headroom, because this is the surface a public link is *meant* to be hit on. + pub const SHARE_LINK: usize = 20_000; + + /// [`CounterKey::DropLink`](super::CounterKey::DropLink) — 1-hour window, keyed on a + /// caller-supplied opaque id. + /// + /// 3 600 s × ~1 distinct link receiving a session per second = 3 600. Rounded to twenty + /// thousand. The hour-long window makes this the cheapest partition to hold saturated — at + /// twenty thousand ids an hour, under six a second — which is exactly why it is a partition: + /// saturating it costs the drop path its first-time keys and costs no other surface + /// anything. + pub const DROP_LINK: usize = 20_000; + + /// [`CounterKey::SecondFactor`](super::CounterKey::SecondFactor) — 5-minute window, keyed on + /// a server-minted challenge id. + /// + /// 300 s × ~10 sign-ins reaching a second factor per second = 3 000. Rounded to ten + /// thousand. Not caller-controlled: a challenge id comes off a token this server signed. + pub const SECOND_FACTOR: usize = 10_000; + + /// [`CounterKey::DeepVerify`](super::CounterKey::DeepVerify) — 1-hour window, keyed on an + /// authenticated account. + /// + /// Bounded by the directory, as `LOGIN_ATTEMPTS` is, and reached only by accounts that asked + /// for a deep scan in the last hour. + pub const DEEP_VERIFY: usize = 10_000; + + /// The three source-address keys — 1-minute and 1-hour windows, keyed on a client address. + /// + /// Charged nowhere yet: all three wait on a trusted client address this server does not have + /// behind an unconfigured proxy chain. Sized for the day one arrives — distinct addresses in + /// the window, which for a self-hosted deployment is thousands, not millions. + pub const SOURCE_ADDRESS: usize = 10_000; + + /// [`CounterKey::OidcAuthorize`](super::CounterKey::OidcAuthorize) — 1-minute window, keyed + /// on an **admitted** redirect host. + /// + /// Three keys can exist: the configured redirect's host and the two loopback literals. Set + /// to sixteen rather than three so that changing `OIDC_REDIRECT_URL` while a window is open, + /// or a provider spelling `[::1]` differently, meets headroom instead of a cliff — and small + /// enough that it is visibly a *bounded* key rather than a hopeful one. + pub const OIDC_AUTHORIZE: usize = 16; + + /// [`CounterKey::OidcAuthorizeRefused`](super::CounterKey::OidcAuthorizeRefused) — one key + /// exists, so one window. + pub const OIDC_AUTHORIZE_REFUSED: usize = 1; + + /// [`CounterKey::PeerRequests`](super::CounterKey::PeerRequests) and + /// [`CounterKey::PeerReports`](super::CounterKey::PeerReports) — both keyed on a peer's + /// origin, so both are bounded by the same thing and share one number. + /// + /// Not caller-controlled: a peer origin reaches either key only off a capability this + /// server minted, so the true bound is the size of the peer list an operator pinned. Ten + /// thousand is the same rounding as [`LOGIN_ATTEMPTS`], and for the same reason — a + /// deployment federating with more peers than that has other numbers to raise first. + pub const PEER_ORIGIN: usize = 10_000; + + /// [`CounterKey::FederatedReports`](super::CounterKey::FederatedReports) — 1-hour window, + /// keyed on `"{reporting_server}:{reported_user}"`. + /// + /// The account half is a string a peer chooses, so this partition *is* growable by a + /// misbehaving peer — which is exactly why + /// [`CounterKey::PeerReports`](super::CounterKey::PeerReports) exists. That is also what + /// bounds this: a report charges both keys, so a peer cannot create more distinct pairs per + /// hour than [`budgets::PEER_REPORTS`](super::budgets::PEER_REPORTS) admits — 200. Across a + /// pinned peer list of the order [`PEER_ORIGIN`] anticipates, twenty thousand is roughly a + /// hundred peers each spending their whole hourly allowance on distinct accounts. + pub const FEDERATED_REPORTS: usize = 20_000; + + /// [`CounterKey::FederatedIntake`](super::CounterKey::FederatedIntake) — 1-hour window, + /// keyed on the **claimed** reporting origin. + /// + /// The one federation key charged before anything is authenticated, so the key space is + /// attacker-chosen outright and this ceiling is the only bound on it. Sized like + /// [`SHARE_LINK`] and [`DROP_LINK`], the other partitions a caller supplies the key for: + /// twenty thousand distinct claimed origins in an hour is under six a second, and spending + /// it costs the intake path its first-time keys and costs no other surface anything. That + /// containment is the whole reason it is a partition rather than a share of one. + pub const FEDERATED_INTAKE: usize = 20_000; +} + /// A deterministic in-memory adapter. +/// +/// Windows are purged as they lapse, and each [`CounterKey`] variant is bounded in a partition of +/// its own; see the module docs. #[derive(Debug, Default)] pub struct InMemoryCounters { - windows: Mutex>, + /// Set by [`InMemoryCounters::with_ceiling`], and then the ceiling of **every** partition. + /// `None` in production, where each variant carries its own. + ceiling_override: Option, + /// Partitioned by [`CounterKey::as_str`], so one variant's occupancy is invisible to + /// another's ceiling. An emptied partition is dropped by the purge rather than left behind. + windows: Mutex>>, } /// One key's open window. @@ -189,13 +520,70 @@ pub struct InMemoryCounters { struct Window { hits: u32, opened_at: Timestamp, + /// When this window may be dropped, from the budget in force when it opened. + /// + /// A **purge hint only.** Admission is always recomputed by [`verdict`] from `opened_at` + /// against the budget the caller supplies, so re-tuning a budget takes effect on the next + /// hit exactly as it did before this field existed; all this decides is when a row nobody + /// will read again is collected. + purge_after: Timestamp, } +/// The partitioned window map: one inner map per [`CounterKey`] variant. +type Partitions = BTreeMap<&'static str, BTreeMap>; + impl InMemoryCounters { - /// An empty set of counters. + /// An empty set of counters, each variant bounded by its own [`CounterKey::ceiling`]. pub fn new() -> Self { Self::default() } + + /// The same counters with **every** partition bounded at `ceiling` instead. + /// + /// A tuning and testing affordance: it makes the partition boundary observable without + /// writing twenty thousand keys. Production leaves it unset, so each variant carries the + /// number its own window and key rate justify. + #[must_use] + pub fn with_ceiling(mut self, ceiling: usize) -> Self { + self.ceiling_override = Some(ceiling); + self + } + + /// How many distinct keys hold a window right now, across every partition. + /// + /// For tests that assert the *cardinality* of the key space rather than any one verdict — + /// the property a caller-controlled key silently destroys. + pub fn len(&self) -> usize { + lock(&self.windows).values().map(BTreeMap::len).sum() + } + + /// How many distinct keys hold a window in `key`'s partition. + /// + /// The number the ceiling is actually compared against, so a test can assert that filling + /// one surface left another's occupancy alone. + pub fn len_of(&self, key: &CounterKey) -> usize { + lock(&self.windows) + .get(key.as_str()) + .map_or(0, BTreeMap::len) + } + + /// Whether no key holds a window. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// This key's partition ceiling, or the override every partition shares when one is set. + fn ceiling_for(&self, key: &CounterKey) -> usize { + self.ceiling_override.unwrap_or_else(|| key.ceiling()) + } + + /// Drop every window whose budget has lapsed, and every partition thereby emptied. + fn purge(windows: &mut Partitions, now: Timestamp) { + windows.retain(|_, partition| { + partition.retain(|_, window| now < window.purge_after); + !partition.is_empty() + }); + } } /// Take the lock, recovering from a poisoned mutex. @@ -241,7 +629,33 @@ impl CounterStore for InMemoryCounters { ) -> StoreFuture<'a, Verdict> { Box::pin(async move { let mut windows = lock(&self.windows); - let (decision, live) = verdict(windows.get(key).copied(), budget, at); + Self::purge(&mut windows, at); + + let held = windows + .get(key.as_str()) + .and_then(|partition| partition.get(key)) + .copied(); + let ceiling = self.ceiling_for(key); + let occupancy = windows.get(key.as_str()).map_or(0, BTreeMap::len); + // Only a key with no window yet can be refused, and only by its own partition's + // occupancy. A key already being counted keeps being counted, and another variant's + // flood is not visible here at all. + if held.is_none() && occupancy >= ceiling { + tracing::warn!( + counter = key.as_str(), + windows = occupancy, + ceiling, + "a counter partition is full; a hit was refused rather than counted" + ); + return Err(crate::store::StoreError::Rejected { + store: COUNTER_STORE, + detail: format!( + "{ceiling} open windows is the ceiling for `{}`", + key.as_str() + ), + }); + } + let (decision, live) = verdict(held, budget, at); match decision { Verdict::Limited { retry_after } => { @@ -259,13 +673,18 @@ impl CounterStore for InMemoryCounters { Some(open) => Window { hits: open.hits.saturating_add(1), opened_at: open.opened_at, + purge_after: crate::store::deadline(open.opened_at, budget.window), }, None => Window { hits: 1, opened_at: at, + purge_after: crate::store::deadline(at, budget.window), }, }; - windows.insert(key.clone(), updated); + windows + .entry(key.as_str()) + .or_default() + .insert(key.clone(), updated); Ok(Verdict::Admitted { remaining: budget.limit.saturating_sub(updated.hits), }) @@ -282,13 +701,23 @@ impl CounterStore for InMemoryCounters { ) -> StoreFuture<'a, Verdict> { Box::pin(async move { let windows = lock(&self.windows); - Ok(verdict(windows.get(key).copied(), budget, at).0) + let held = windows + .get(key.as_str()) + .and_then(|partition| partition.get(key)) + .copied(); + Ok(verdict(held, budget, at).0) }) } fn reset<'a>(&'a self, key: &'a CounterKey) -> StoreFuture<'a, ()> { Box::pin(async move { - if lock(&self.windows).remove(key).is_some() { + let mut windows = lock(&self.windows); + if let Some(partition) = windows.get_mut(key.as_str()) + && partition.remove(key).is_some() + { + if partition.is_empty() { + windows.remove(key.as_str()); + } tracing::debug!(counter = key.as_str(), "a counter window was cleared"); } Ok(()) @@ -345,9 +774,64 @@ impl CounterContext { pub async fn reset(&self, key: &CounterKey) -> Result<(), crate::store::StoreError> { self.counters.reset(key).await } + + /// What a caller should be told about a failed [`Self::hit`]. + /// + /// `Some(retry_after)` when the partition was full — the limiter working as designed, which + /// a route renders `429 error.*_at_capacity` — and `None` when the store could not answer at + /// all, which stays a `500`. One method rather than a predicate plus a clock read at three + /// call sites, because the half that is easy to forget is the deadline. + /// + /// The refusal is fail-closed either way; this decides only the answer. + pub fn capacity_refusal( + &self, + error: &crate::store::StoreError, + budget: Budget, + ) -> Option { + is_at_capacity(error).then(|| capacity_retry_after(self.clock.now(), budget)) + } +} + +/// Whether a [`CounterStore`] failure was the partition ceiling rather than a broken store. +/// +/// The two failures arrive as one `Result::Err` and mean opposite things to a caller: a full +/// partition is the limiter working as designed and clears on its own within the window, while +/// anything else is a store that could not answer. A route that renders both as `500` tells a +/// client to report an outage and tells an operator to go looking for one, so every route that +/// charges a caller-influenced key asks this and answers `429 error.*_at_capacity` when it is +/// true. +/// +/// The refusal itself is fail-closed either way. This decides only what the caller is told. +pub fn is_at_capacity(error: &crate::store::StoreError) -> bool { + matches!( + error, + crate::store::StoreError::Rejected { store, .. } if *store == COUNTER_STORE + ) +} + +/// The `store` name [`InMemoryCounters`] refuses under, and [`is_at_capacity`] matches on. +pub const COUNTER_STORE: &str = "counters"; + +/// When a caller refused by a full partition may expect room, as an **upper** bound. +/// +/// One window from now. A full partition is full of *live* windows, and the earliest of them +/// lapses no later than one window after it opened, so a caller that waits this long finds room +/// unless the flood is still running — in which case it finds the same honest `429` again. +pub fn capacity_retry_after(now: Timestamp, budget: Budget) -> Timestamp { + crate::store::deadline(now, budget.window) +} + +/// `at` as Unix seconds for a `retry_after` extension member. +/// +/// Saturating at zero, as every other deadline on this surface does: a clock before the epoch is +/// a misconfiguration, and "retry now" is the safe reading of one. +pub fn unix_seconds(at: Timestamp) -> u64 { + u64::try_from(at.as_second()).unwrap_or(0) } pub mod budgets; +pub mod conformance; +pub mod valkey; #[cfg(test)] mod tests; diff --git a/capsule-server/src/counter/tests.rs b/capsule-server/src/counter/tests.rs index 0fdf9a78..7c331bc3 100644 --- a/capsule-server/src/counter/tests.rs +++ b/capsule-server/src/counter/tests.rs @@ -1,18 +1,24 @@ -//! The counter port's own suite. +//! The in-memory adapter against the counter port's own suite. //! -//! The property that matters is that charging and deciding are one operation. A single-threaded -//! suite cannot exhibit the race that makes read-then-write wrong — the same limit `S-C21`'s -//! conformance note records — so what is asserted here is the *observable consequence*: the -//! n-th hit inside a window is refused, and no sequence of calls admits more than the budget. +//! One test per [`conformance`] case, on a fresh store each, so a failure names the property +//! that broke; and the whole suite in one pass, because that is the entry point the +//! container-backed adapter uses and it must be exercised where a failure is easiest to read. -use super::{budgets, *}; +use std::sync::Arc; -fn key() -> CounterKey { - CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-000000000001")) +use super::{CounterStore, InMemoryCounters, budgets, ceilings, conformance, *}; + +fn counters() -> Arc { + Arc::new(InMemoryCounters::new()) } -fn other() -> CounterKey { - CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-0000000000ff")) +// The cases below the conformance block drive the adapter directly rather than through a +// conformance case, so they need a budget and a clock of their own. `conformance` has its own +// private pair; these are deliberately not shared, because a conformance helper that a +// non-conformance test also depends on is a helper nobody can change for the suite's sake. + +fn key() -> CounterKey { + CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-000000000001")) } fn budget() -> Budget { @@ -23,187 +29,372 @@ fn at(mins: i64) -> Timestamp { crate::store::deadline(Timestamp::UNIX_EPOCH, SignedDuration::from_mins(mins)) } +/// Declares one `#[tokio::test]` per conformance case taking a borrowed store. +macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + conformance::$case(&*counters()).await; + } + )+ + }; +} + +conformance_cases! { + a_window_admits_exactly_its_budget_and_then_refuses, + a_refused_hit_does_not_extend_the_window, + a_window_measures_from_its_first_hit, + counters_are_scoped_to_their_key, + a_reset_ends_the_run, + peeking_charges_nothing_and_answers_a_verdict, + no_sequence_of_calls_admits_more_than_the_budget, +} + +/// The one case that races, on the runtime that lets it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_hits_admit_exactly_the_budget() { + conformance::concurrent_hits_admit_exactly_the_budget(counters()).await; +} + +/// The whole suite, in one pass on one store. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_whole_suite_passes_in_one_pass() { + conformance::run_all(counters()).await; +} + #[tokio::test] -async fn a_window_admits_exactly_its_budget_and_then_refuses() { +async fn a_lapsed_window_is_dropped_rather_than_kept_as_a_row_nobody_reads() { + // The map is process-wide and shared by every limiter, and several keys are derived from + // something a caller sent. A window that decides nothing must not still occupy a row. let counters = InMemoryCounters::new(); - - for expected_remaining in [2, 1, 0] { - assert_eq!( + for index in 0..50 { + let key = CounterKey::ShareLink(format!("link-{index}")); + assert!( counters - .hit(&key(), budget(), at(0)) + .hit(&key, budget(), at(0)) .await - .expect("the store answers"), - Verdict::Admitted { - remaining: expected_remaining - } + .expect("answers") + .admits() ); } + assert_eq!(counters.len(), 50); - assert_eq!( + // One hit after every window has lapsed, and the fifty are collected with it. + let key = CounterKey::ShareLink("link-fresh".to_owned()); + assert!( counters - .hit(&key(), budget(), at(0)) + .hit(&key, budget(), at(11)) .await - .expect("the store answers"), - Verdict::Limited { - retry_after: at(10) - }, - "the fourth hit is refused, and is told when to come back" + .expect("answers") + .admits() ); + assert_eq!(counters.len(), 1, "only the live window survives"); } #[tokio::test] -async fn a_refused_hit_does_not_extend_the_window() { - // Otherwise an attacker who keeps hitting a limited key holds it limited forever, which - // turns a rate limit into a denial of service against the legitimate user. - let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } - for minute in 1..8 { +async fn a_full_store_refuses_a_new_key_and_keeps_counting_the_ones_it_holds() { + let counters = InMemoryCounters::new().with_ceiling(2); + let first = CounterKey::ShareLink("a".to_owned()); + let second = CounterKey::ShareLink("b".to_owned()); + + for key in [&first, &second] { assert!( - !counters - .hit(&key(), budget(), at(minute)) + counters + .hit(key, budget(), at(0)) .await .expect("answers") .admits() ); } + // A third key finds no room. An error and not a verdict: every caller treats a counter + // failure as a refusal, so this fails closed. + let refusal = counters + .hit(&CounterKey::ShareLink("c".to_owned()), budget(), at(0)) + .await + .expect_err("the ceiling refuses"); + assert!( + matches!(refusal, crate::store::StoreError::Rejected { store, .. } if store == "counters"), + "{refusal:?}" + ); + assert_eq!(counters.len(), 2, "the refused key wrote nothing"); + + // A key the store already holds keeps counting: a flood of new keys must not switch off a + // limiter that is already tracking somebody. assert!( counters - .hit(&key(), budget(), at(11)) + .hit(&first, budget(), at(0)) .await .expect("answers") - .admits(), - "the window still ends ten minutes after it opened, not ten after the last attempt" + .admits() ); -} - -#[tokio::test] -async fn a_window_measures_from_its_first_hit() { - let counters = InMemoryCounters::new(); - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - counters.hit(&key(), budget(), at(9)).await.expect("hits"); - + // Right up to its own budget, which is still the thing that limits it. assert!( counters - .hit(&key(), budget(), at(11)) + .hit(&first, budget(), at(0)) .await .expect("answers") - .admits(), - "a fresh window opens once the first one passes" + .admits() ); - // And that fresh window is a fresh budget, not a continuation. - for _ in 0..2 { - assert!( - counters - .hit(&key(), budget(), at(11)) - .await - .expect("answers") - .admits() - ); - } assert!( !counters - .hit(&key(), budget(), at(11)) + .hit(&first, budget(), at(0)) .await .expect("answers") - .admits() + .admits(), + "the budget still ends the run" ); -} - -#[tokio::test] -async fn counters_are_scoped_to_their_key() { - let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } + // And the ceiling is not a one-way door: once the windows lapse, a new key fits again. assert!( counters - .hit(&other(), budget(), at(0)) + .hit(&CounterKey::ShareLink("c".to_owned()), budget(), at(11)) .await .expect("answers") - .admits(), - "one account's failed sign-ins are not another's" + .admits() ); } #[tokio::test] -async fn a_reset_ends_the_run() { - // What a *successful* sign-in does to a failed-attempt counter: the policy counts - // consecutive failures, so a success is not one more event, it ends the run. +async fn purging_does_not_change_a_verdict() { + // The purge hint is recorded from the budget in force when a window opened; admission is + // still recomputed from `opened_at` against the budget the caller supplies. A budget that + // was re-tuned between two hits must decide by the new one. let counters = InMemoryCounters::new(); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); - } + let wide = Budget::new(3, SignedDuration::from_mins(60)); assert!( - !counters - .hit(&key(), budget(), at(0)) + counters + .hit(&key(), wide, at(0)) .await .expect("answers") .admits() ); - - counters.reset(&key()).await.expect("resets"); + // Re-tuned to ten minutes: at minute eleven the window has lapsed under the new budget, so + // the hit opens a fresh one with the full allowance, exactly as before this field existed. + let narrow = Budget::new(3, SignedDuration::from_mins(10)); assert_eq!( - counters - .hit(&key(), budget(), at(0)) - .await - .expect("answers"), + counters.hit(&key(), narrow, at(11)).await.expect("answers"), Verdict::Admitted { remaining: 2 } ); } +/// The finding decision 22 answers: one shared ceiling makes four unauthenticated surfaces share +/// a fate. +/// +/// The drop path keys on a caller-supplied id over an hour-long window, which makes it the +/// cheapest partition to hold saturated. Under one global ceiling, saturating it would refuse a +/// *first* key to every other surface — a first-time share view, a first enrollment redemption, +/// the first OIDC sign-in after a reboot — and every one of those maps a counter error to a +/// fail-closed 500/503. Partitioned, the flood costs the flooded surface its new keys and costs +/// the others nothing. #[tokio::test] -async fn peeking_charges_nothing_and_answers_a_verdict() { - // It returns a `Verdict` rather than a count on purpose: handing back a number is handing - // back the read half of a read-then-write, and somebody would eventually build a limiter - // out of it. - let counters = InMemoryCounters::new(); - for _ in 0..5 { +async fn flooding_one_surface_does_not_deny_a_fresh_key_to_another() { + // The override bounds every partition equally, so the partition *boundary* is what this + // test observes rather than the size of any one of them. The real numbers are asserted in + // `every_ceiling_is_sized_from_its_own_window`. + let counters = InMemoryCounters::new().with_ceiling(50); + + // Fill the drop path to its ceiling with fabricated but well-formed ids. + for index in 0..50 { + let key = CounterKey::DropLink(format!("{index:032x}")); assert!( counters - .peek(&key(), budget(), at(0)) + .hit(&key, budgets::DROP_LINK, at(0)) .await .expect("answers") - .admits(), - "peeking does not spend the budget" + .admits() ); } + assert_eq!(counters.len_of(&CounterKey::DropLink(String::new())), 50); - for _ in 0..3 { - counters.hit(&key(), budget(), at(0)).await.expect("hits"); + // Saturated: a fifty-first drop id is refused, which is the bound doing its job. + counters + .hit( + &CounterKey::DropLink("ffffffffffffffffffffffffffffffff".to_owned()), + budgets::DROP_LINK, + at(0), + ) + .await + .expect_err("the drop partition is full"); + + // And every other surface is untouched. A never-seen key on each of the three that a shared + // ceiling would have denied: + for (key, budget) in [ + ( + CounterKey::ShareLink("never-seen-share".to_owned()), + budgets::SHARE_LINK, + ), + ( + CounterKey::OidcAuthorize("app.example.test".to_owned()), + budgets::OIDC_AUTHORIZE, + ), + ( + CounterKey::EnrollmentRedemption("00000000".to_owned()), + budgets::ENROLLMENT_REDEMPTION, + ), + ( + CounterKey::SecondFactor("challenge-1".to_owned()), + budgets::SECOND_FACTOR, + ), + ] { + assert!( + counters + .hit(&key, budget, at(0)) + .await + .expect("a full drop partition is not another surface's problem") + .admits(), + "{} was denied by a flood against drop_link", + key.as_str() + ); } + + // The flooded surface's own existing keys keep counting, up to their own budget. + let held = CounterKey::DropLink(format!("{0:032x}", 0)); assert!( - !counters - .peek(&key(), budget(), at(0)) + counters + .hit(&held, budgets::DROP_LINK, at(0)) .await .expect("answers") - .admits() + .admits(), + "a key already being counted keeps being counted" + ); + assert_eq!( + counters.len_of(&CounterKey::DropLink(String::new())), + 50, + "and counting it minted nothing" ); } +/// The same property against the **real** `DropLink` ceiling rather than a test override. +/// +/// `flooding_one_surface_does_not_deny_a_fresh_key_to_another` bounds every partition equally so +/// the boundary is legible; this one spends the twenty thousand the shipped constant actually +/// allows, so that a future edit which re-shares the ceilings — or sizes `DropLink` off some +/// other variant's number — is caught by the number a deployment really runs with. #[tokio::test] -async fn no_sequence_of_calls_admits_more_than_the_budget() { - // The observable consequence of charging and deciding together. A single-threaded suite - // cannot exhibit the race that makes read-then-write wrong, so what is asserted is that the - // count admitted never exceeds the limit however the calls are interleaved with peeks. +async fn the_real_drop_ceiling_is_the_drop_partition_and_nobody_else_s() { let counters = InMemoryCounters::new(); - let mut admitted = 0; - for step in 0..50 { - counters.peek(&key(), budget(), at(0)).await.expect("peeks"); - if counters - .hit(&key(), budget(), at(0)) + + for index in 0..ceilings::DROP_LINK { + counters + .hit( + &CounterKey::DropLink(format!("{index:032x}")), + budgets::DROP_LINK, + at(0), + ) + .await + .expect("answers"); + } + assert_eq!( + counters.len_of(&CounterKey::DropLink(String::new())), + ceilings::DROP_LINK + ); + counters + .hit( + &CounterKey::DropLink("ffffffffffffffffffffffffffffffff".to_owned()), + budgets::DROP_LINK, + at(0), + ) + .await + .expect_err("the drop partition is at its shipped ceiling"); + + // The two the finding named: a first-time share view and the first OIDC sign-in. + assert!( + counters + .hit( + &CounterKey::ShareLink("never-seen-share".to_owned()), + budgets::SHARE_LINK, + at(0), + ) + .await + .expect("a saturated drop partition denies nobody else") + .admits() + ); + assert!( + counters + .hit( + &CounterKey::OidcAuthorize("app.example.test".to_owned()), + budgets::OIDC_AUTHORIZE, + at(0), + ) + .await + .expect("a saturated drop partition denies nobody else") + .admits() + ); + + // And the flooded surface keeps counting the keys it already holds. + assert!( + counters + .hit( + &CounterKey::DropLink(format!("{0:032x}", 0)), + budgets::DROP_LINK, + at(0), + ) .await .expect("answers") .admits() - { - admitted += 1; - } - let _ = step; + ); +} + +#[tokio::test] +async fn a_partition_is_dropped_when_its_last_window_lapses() { + let counters = InMemoryCounters::new(); + let key = CounterKey::ShareLink("a".to_owned()); + counters.hit(&key, budget(), at(0)).await.expect("answers"); + assert_eq!(counters.len_of(&key), 1); + + // A hit on a *different* partition purges the lapsed one rather than leaving it behind. + counters + .hit(&CounterKey::DropLink("b".to_owned()), budget(), at(11)) + .await + .expect("answers"); + assert_eq!(counters.len_of(&key), 0, "the emptied partition is gone"); + assert_eq!(counters.len(), 1); +} + +#[test] +fn every_ceiling_is_sized_from_its_own_window() { + // The two keys only one value of which can exist hold exactly one window. + assert_eq!(CounterKey::OidcAuthorizeRefused.ceiling(), 1); + assert_eq!(CounterKey::EnrollmentRedemptionMalformed.ceiling(), 1); + + // The admitted OIDC host is structurally three values; the ceiling is headroom over that + // and nothing like the caller-controlled partitions. + assert_eq!(CounterKey::OidcAuthorize(String::new()).ceiling(), 16); + assert!( + CounterKey::OidcAuthorize(String::new()).ceiling() + < CounterKey::ShareLink(String::new()).ceiling() / 100, + "a bounded key must not be sized like an unbounded one" + ); + + // The three surfaces that key on a caller-supplied string are the ones that need room. + for key in [ + CounterKey::ShareLink(String::new()), + CounterKey::DropLink(String::new()), + CounterKey::EnrollmentRedemption(String::new()), + ] { + assert!(key.ceiling() >= ceilings::ENROLLMENT_REDEMPTION, "{key:?}"); } - assert_eq!(admitted, 3); + + // No two variants share a partition name, or one flood would reach two ceilings. + let names = [ + CounterKey::LoginAttempts(UserId::new("u")), + CounterKey::EnrollmentRedemption(String::new()), + CounterKey::EnrollmentRedemptionMalformed, + CounterKey::ShareLink(String::new()), + CounterKey::ShareSource(String::new()), + CounterKey::DropLink(String::new()), + CounterKey::DropSource(String::new()), + CounterKey::DeepVerify(UserId::new("u")), + CounterKey::SecondFactor(String::new()), + CounterKey::RegistrationSource(String::new()), + CounterKey::OidcAuthorize(String::new()), + CounterKey::OidcAuthorizeRefused, + ] + .map(|key| key.as_str()); + let unique: std::collections::BTreeSet<&str> = names.iter().copied().collect(); + assert_eq!(unique.len(), names.len(), "{names:?}"); } #[test] @@ -217,3 +408,90 @@ fn every_budget_is_declared_in_one_place() { assert!(budgets::DROP_SOURCE.limit > budgets::DROP_LINK.limit); assert_eq!(budgets::DEEP_VERIFY.window.as_hours(), 1); } + +/// The classifier must say "capacity" for the ceiling and **only** for the ceiling. +/// +/// [`is_at_capacity`] decides capacity-versus-outage by matching +/// `StoreError::Rejected { store: COUNTER_STORE, .. }`. Today that is safe by construction: +/// `InMemoryCounters::hit`'s only error path *is* the ceiling, so no route can misclassify. It +/// stops being safe by construction the moment a second `CounterStore` exists — `COUNTER_STORE` +/// is `pub`, and a Valkey adapter (#460) that refuses under that same store name for an +/// unrelated reason would have a genuine outage rendered `429 error.*_at_capacity`. That tells a +/// caller to retry a store that is down and tells an operator nothing is wrong, which is a worse +/// failure than the `500`-for-everything this round replaced. +/// +/// So the negative cases are pinned now, against the adapter that does not exist yet. +#[test] +fn only_the_counter_store_s_own_ceiling_reads_as_capacity() { + use crate::store::StoreError; + + let ceiling = StoreError::Rejected { + store: COUNTER_STORE, + detail: "2 open windows is the ceiling for `share_link`".to_owned(), + }; + assert!(is_at_capacity(&ceiling), "the ceiling is the capacity case"); + + for outage in [ + // A store that could not answer at all — the `500` this must stay. + StoreError::Unavailable { + store: COUNTER_STORE, + detail: "the connection was refused".to_owned(), + }, + // A refusal from some *other* store that happens to travel the same channel. + StoreError::Rejected { + store: "something-else", + detail: "a refusal that is not this port's ceiling".to_owned(), + }, + StoreError::Unavailable { + store: "something-else", + detail: "an outage that is not this port's at all".to_owned(), + }, + ] { + assert!( + !is_at_capacity(&outage), + "only the counter store's own ceiling is a capacity refusal: {outage:?}" + ); + } +} + +/// And the method the routes actually call agrees with the predicate, including the deadline. +#[tokio::test] +async fn capacity_refusal_offers_a_deadline_for_the_ceiling_and_none_for_an_outage() { + use std::sync::Arc; + + use crate::store::StoreError; + use crate::store::memory::ManualClock; + + let clock = Arc::new(ManualClock::new(at(0))); + let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone()); + + // The ceiling: a deadline one window out, on the same clock the windows themselves use. + let ceiling = StoreError::Rejected { + store: COUNTER_STORE, + detail: "full".to_owned(), + }; + assert_eq!( + counters.capacity_refusal(&ceiling, budget()), + Some(at(10)), + "one window from now, which is the bound on when a live window lapses" + ); + + // Everything else: no deadline, so the route renders its `500` rather than a `429` telling + // a caller to retry a store that is down. + for outage in [ + StoreError::Unavailable { + store: COUNTER_STORE, + detail: "the connection was refused".to_owned(), + }, + StoreError::Rejected { + store: "something-else", + detail: "not this port's ceiling".to_owned(), + }, + ] { + assert_eq!( + counters.capacity_refusal(&outage, budget()), + None, + "{outage:?}" + ); + } +} diff --git a/capsule-server/src/counter/valkey.rs b/capsule-server/src/counter/valkey.rs new file mode 100644 index 00000000..27048add --- /dev/null +++ b/capsule-server/src/counter/valkey.rs @@ -0,0 +1,200 @@ +//! The Valkey [`CounterStore`] (#403). +//! +//! One hash per key — `capsule:counter:{kind}:{scope}` holding `hits` and `opened_at` — and one +//! Lua script that opens the window, or charges it, or refuses, in a single server-side step. +//! That is the atomicity the port demands: a burst of requests cannot read the same under-limit +//! count, because there is no read a caller performs separately from the charge. +//! +//! # Why a hash and a script rather than `INCR` + first-hit `EXPIRE` +//! +//! The port's window is measured from the caller's `at`, which is what lets the in-memory double +//! be deterministic and the [`conformance`](super::conformance) suite pass absolute instants. A +//! window that `EXPIRE` measured on the server's clock instead would be a second clock for one +//! fact, and the same suite could not drive both adapters. So `opened_at` is stored and compared +//! against `at` inside the script; `PEXPIRE` is set to the window as well, but only so a key +//! nobody hits again is collected — it never decides anything. +//! +//! An over-budget hit is refused **without** being counted, exactly as the double does it: the +//! window's end is fixed by its first hit, so a refused hit cannot extend it. + +use jiff::Timestamp; + +use super::{Budget, CounterKey, CounterStore, Verdict}; +use crate::store::valkey::{Lua, Valkey, from_micros, micros}; +use crate::store::{StoreError, StoreFuture}; + +/// The port name, for the log line and the error. +const COUNTERS: &str = "counters"; + +// KEYS: counter. ARGV: at (µs), window (µs), limit, window (ms, for the collector). +// Returns {hits after this call, or -1 when refused; the instant the window ends (µs)}. +static HIT: Lua = Lua::new( + "local at = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local opened = redis.call('HGET', KEYS[1], 'opened_at') +if not opened or tonumber(opened) + window <= at then + redis.call('HSET', KEYS[1], 'hits', 1, 'opened_at', ARGV[1]) + redis.call('PEXPIRE', KEYS[1], ARGV[4]) + return {1, at + window} +end +local ends = tonumber(opened) + window +local hits = tonumber(redis.call('HGET', KEYS[1], 'hits') or 0) +if hits >= limit then return {-1, ends} end +hits = redis.call('HINCRBY', KEYS[1], 'hits', 1) +return {hits, ends}", +); + +// KEYS: counter. ARGV: at (µs), window (µs). Returns {hits in the open window, or 0; its end}. +static PEEK: Lua = Lua::new( + "local at = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local opened = redis.call('HGET', KEYS[1], 'opened_at') +if not opened or tonumber(opened) + window <= at then return {0, 0} end +return {tonumber(redis.call('HGET', KEYS[1], 'hits') or 0), tonumber(opened) + window}", +); + +/// Valkey [`CounterStore`]. +#[derive(Debug)] +pub struct ValkeyCounters { + valkey: Valkey, +} + +impl ValkeyCounters { + /// Counters on `valkey`. + pub fn new(valkey: Valkey) -> Self { + Self { valkey } + } + + async fn run( + &self, + script: &Lua, + key: &CounterKey, + args: &[String], + ) -> Result<(i64, i64), StoreError> { + let mut invocation = script.script().prepare_invoke(); + invocation.key(counter_key(key)); + for arg in args { + invocation.arg(arg.as_str()); + } + self.valkey.invoke(COUNTERS, &invocation).await + } +} + +/// `capsule:counter:{kind}:{scope}`. +fn counter_key(key: &CounterKey) -> String { + format!("capsule:counter:{}:{}", key.as_str(), key.scope()) +} + +/// A window as the microsecond string the scripts compare, and the millisecond string +/// `PEXPIRE` takes. +fn window_args(budget: Budget) -> (String, String) { + let micros = i64::try_from(budget.window.as_micros()).unwrap_or(i64::MAX); + let millis = budget.window.as_millis().max(1); + (micros.to_string(), millis.to_string()) +} + +fn admitted(limit: u32, hits: i64) -> Verdict { + Verdict::Admitted { + remaining: limit.saturating_sub(u32::try_from(hits).unwrap_or(u32::MAX)), + } +} + +impl CounterStore for ValkeyCounters { + fn hit<'a>( + &'a self, + key: &'a CounterKey, + budget: Budget, + at: Timestamp, + ) -> StoreFuture<'a, Verdict> { + Box::pin(async move { + let (window_micros, window_millis) = window_args(budget); + let (hits, ends) = self + .run( + &HIT, + key, + &[ + micros(at).to_string(), + window_micros, + budget.limit.to_string(), + window_millis, + ], + ) + .await?; + if hits < 0 { + let retry_after = from_micros(COUNTERS, "Window", ends)?; + tracing::info!(counter = key.as_str(), %retry_after, "a rate limit engaged"); + return Ok(Verdict::Limited { retry_after }); + } + tracing::trace!(counter = key.as_str(), hits, "charged a counter"); + Ok(admitted(budget.limit, hits)) + }) + } + + fn peek<'a>( + &'a self, + key: &'a CounterKey, + budget: Budget, + at: Timestamp, + ) -> StoreFuture<'a, Verdict> { + Box::pin(async move { + let (window_micros, _) = window_args(budget); + let (hits, ends) = self + .run(&PEEK, key, &[micros(at).to_string(), window_micros]) + .await?; + // No open window is a fresh budget, whatever the limit — including zero, which the + // double answers `Admitted { remaining: 0 }` rather than `Limited`. + if ends == 0 { + return Ok(admitted(budget.limit, 0)); + } + if hits >= i64::from(budget.limit) { + return Ok(Verdict::Limited { + retry_after: from_micros(COUNTERS, "Window", ends)?, + }); + } + Ok(admitted(budget.limit, hits)) + }) + } + + fn reset<'a>(&'a self, key: &'a CounterKey) -> StoreFuture<'a, ()> { + Box::pin(async move { + let mut cmd = redis::cmd("DEL"); + cmd.arg(counter_key(key)); + let removed: u64 = self.valkey.command(COUNTERS, cmd).await?; + if removed > 0 { + tracing::debug!(counter = key.as_str(), "a counter window was cleared"); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::UserId; + + #[test] + fn a_counter_key_names_its_kind_and_its_scope() { + let key = CounterKey::ShareSource("203.0.113.7".to_owned()); + assert_eq!( + counter_key(&key), + "capsule:counter:share_source:203.0.113.7" + ); + let key = CounterKey::LoginAttempts(UserId::new("u1")); + assert_eq!(counter_key(&key), "capsule:counter:login_attempts:u1"); + } + + #[test] + fn a_window_is_passed_in_both_units() { + let (micros, millis) = window_args(Budget::new(3, jiff::SignedDuration::from_secs(2))); + assert_eq!(micros, "2000000"); + assert_eq!(millis, "2000"); + } + + #[test] + fn remaining_never_underflows() { + assert_eq!(admitted(3, 5), Verdict::Admitted { remaining: 0 }); + assert_eq!(admitted(3, 1), Verdict::Admitted { remaining: 2 }); + } +} diff --git a/capsule-server/src/directory/tests.rs b/capsule-server/src/directory/tests.rs index 2cfd8cea..a5d322bc 100644 --- a/capsule-server/src/directory/tests.rs +++ b/capsule-server/src/directory/tests.rs @@ -4,8 +4,7 @@ //! document — check it verifies under the account's identity anchor, compare one projected //! field, and hand the bytes back unchanged. -use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; -use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore}; +use capsule_core::crypto::keys::{DeviceDirectory, DirectoryCore, HybridSigningKey}; use uuid::Uuid; use super::*; diff --git a/capsule-server/src/discovery/mod.rs b/capsule-server/src/discovery/mod.rs index 2cd23c72..2c97dfb1 100644 --- a/capsule-server/src/discovery/mod.rs +++ b/capsule-server/src/discovery/mod.rs @@ -80,16 +80,44 @@ pub struct AuthEndpoints { pub refresh: String, /// Where a session is ended. pub logout: String, + /// Where a sign-in through an external identity provider begins and ends (slice `S-N1`), + /// or `None` when this deployment has no provider. + /// + /// Endpoints only — never the issuer, never the client id, never anything user-scoped. The + /// presence of the record is how a login chooser decides whether to offer the path at all. + pub oidc: Option, } impl AuthEndpoints { - /// The ceremony's three URLs, under `api_base_url`. + /// The ceremony's three URLs, under `api_base_url`; no OIDC until + /// [`ServerInfo::with_oidc`] says so. fn under(api_base_url: &str) -> Self { let base = api_base_url.trim_end_matches('/'); Self { login: format!("{base}/auth/login"), refresh: format!("{base}/auth/refresh"), logout: format!("{base}/auth/logout"), + oidc: None, + } + } +} + +/// Where the OIDC ceremony's two legs are performed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcEndpoints { + /// Where a client asks for an authorization URL. + pub authorize: String, + /// Where a client presents the `state` and `code` the provider's redirect carried. + pub callback: String, +} + +impl OidcEndpoints { + /// The two URLs, under `api_base_url`. + fn under(api_base_url: &str) -> Self { + let base = api_base_url.trim_end_matches('/'); + Self { + authorize: format!("{base}/auth/oidc/authorize"), + callback: format!("{base}/auth/oidc/callback"), } } } @@ -200,6 +228,19 @@ impl ServerInfo { self } + /// Declare that this deployment signs people in through an identity provider, and publish + /// where (slice `S-N1`). + /// + /// Absent by default, for the reason [`Self::with_federation`] is: publishing endpoints a + /// deployment does not serve sends clients to a `404`, and "there is no identity provider" is + /// the ordinary self-hosted case. Only the endpoints are published; which provider, and + /// under which client id, is the server's business alone. + #[must_use] + pub fn with_oidc(mut self) -> Self { + self.auth.oidc = Some(OidcEndpoints::under(&self.api_base_url)); + self + } + /// Override the announcement window this server holds itself to. #[must_use] pub fn with_announcement_window(mut self, window: SignedDuration) -> Self { diff --git a/capsule-server/src/discovery/revocation.rs b/capsule-server/src/discovery/revocation.rs index 02ccc6f0..108b7bbf 100644 --- a/capsule-server/src/discovery/revocation.rs +++ b/capsule-server/src/discovery/revocation.rs @@ -25,15 +25,22 @@ //! somebody has to enforce. [`RevocationList::revoke`] refuses an entry whose expiry is beyond //! the ceiling, which is what keeps that reasoning true: one accepted long-lived entry and the //! list grows without bound while the peer-side staleness math silently stops applying. +//! +//! # Where the list lives now +//! +//! The port is implemented by the federation capability store +//! ([`crate::federation::CapabilityStore`]), because once this server *issues* capabilities the +//! record of one and the fact of its revocation are one row, and a standalone list would be a +//! second answer to "is this `jti` revoked". The deterministic adapter is +//! [`crate::federation::InMemoryCapabilities`]; the conformance suite that pins the pruning, +//! ordering and ceiling rules is `federation::conformance`. -use std::collections::BTreeMap; use std::fmt; use std::pin::Pin; -use std::sync::{Arc, Mutex}; use jiff::{SignedDuration, Timestamp}; -use crate::store::{Clock, StoreError, StoreFuture}; +use crate::store::{StoreError, StoreFuture}; /// The ceiling design/federation.md puts on a capability token's lifetime. pub const MAX_TOKEN_TTL: SignedDuration = SignedDuration::from_hours(24); @@ -122,82 +129,6 @@ pub trait RevocationList: fmt::Debug + Send + Sync { fn published(&self) -> StoreFuture<'_, PublishedRevocations>; } -/// The deterministic in-memory adapter. -#[derive(Debug)] -pub struct InMemoryRevocations { - entries: Mutex>, - clock: Arc, -} - -impl InMemoryRevocations { - /// An empty list reading `clock` for pruning and for `generated_at`. - pub fn new(clock: Arc) -> Self { - Self { - entries: Mutex::new(BTreeMap::new()), - clock, - } - } -} - -impl RevocationList for InMemoryRevocations { - fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> { - Box::pin(async move { - let now = self.clock.now(); - let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL); - if token.expires_at > ceiling { - tracing::warn!( - jti = %token.jti, - expires_at = %token.expires_at, - "a revocation was refused: its expiry is beyond the capability TTL ceiling" - ); - return Err(RevocationError::BeyondTtlCeiling { - expires_at: token.expires_at, - ceiling: MAX_TOKEN_TTL, - } - .into()); - } - - let mut entries = self - .entries - .lock() - .expect("the revocation list is not poisoned"); - entries.insert(token.jti.clone(), token.expires_at); - tracing::info!( - jti = %token.jti, - expires_at = %token.expires_at, - published = entries.len(), - "a federation capability token was revoked" - ); - Ok(()) - }) - } - - fn published(&self) -> StoreFuture<'_, PublishedRevocations> { - Box::pin(async move { - let now = self.clock.now(); - let mut entries = self - .entries - .lock() - .expect("the revocation list is not poisoned"); - // Pruned on read *and* retained pruned, so a list nobody fetches does not grow - // forever holding entries that already mean nothing. - entries.retain(|_, expires_at| *expires_at > now); - let mut revoked: Vec = entries - .iter() - .map(|(jti, expires_at)| RevokedToken { - jti: jti.clone(), - expires_at: *expires_at, - }) - .collect(); - revoked.sort_by_key(|token| (token.expires_at, token.jti.clone())); - Ok(PublishedRevocations { - generated_at: now, - revoked, - }) - }) - } -} - /// What a verifier concluded about one `jti`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RevocationVerdict { diff --git a/capsule-server/src/discovery/tests.rs b/capsule-server/src/discovery/tests.rs index 540f84f4..e3b7c3b9 100644 --- a/capsule-server/src/discovery/tests.rs +++ b/capsule-server/src/discovery/tests.rs @@ -1,19 +1,15 @@ //! The registry's remaining records, and the rule a peer reads them by. -use std::sync::Arc; - use jiff::{SignedDuration, Timestamp}; use super::revocation::{ - InMemoryRevocations, MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationList, - RevocationVerdict, RevokeError, RevokedToken, check_revocation, + MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationVerdict, RevokedToken, + check_revocation, }; use super::{ AnnouncementError, DEFAULT_ANNOUNCEMENT_WINDOW, DeprecationAnnouncement, ProtocolWindow, ServerInfo, }; -use crate::store::Clock; -use crate::store::memory::ManualClock; fn window() -> ProtocolWindow { ProtocolWindow { @@ -127,90 +123,6 @@ fn a_cutoff_in_the_past_is_refused_as_such() { assert!(matches!(error, AnnouncementError::AlreadyPassed { .. })); } -#[tokio::test] -async fn a_revocation_beyond_the_ttl_ceiling_is_refused() { - // The published list is bounded *because* a capability token cannot outlive 24 hours. One - // accepted long-lived entry and the list grows without bound while the peer-side staleness - // math silently stops applying — so the ceiling is the port's invariant, not a convention. - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - - let error = list - .revoke(RevokedToken { - jti: "beyond".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(25)), - }) - .await - .expect_err("an entry past the ceiling is refused"); - - assert!(matches!(error, RevokeError::Refused(_))); - let published = list.published().await.expect("the list reads back"); - assert!(published.revoked.is_empty()); -} - -#[tokio::test] -async fn revoking_the_same_token_twice_is_one_entry() { - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - let entry = RevokedToken { - jti: "repeated".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)), - }; - - list.revoke(entry.clone()).await.expect("first revocation"); - list.revoke(entry).await.expect("a retry is not a new fact"); - - let published = list.published().await.expect("the list reads back"); - assert_eq!(published.revoked.len(), 1); -} - -#[tokio::test] -async fn an_entry_is_pruned_once_the_token_it_names_has_expired() { - // An expired token is rejected whether or not it appears here, so the entry carries no - // information — and dropping it is what keeps the list bounded by 24 hours of revocations. - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - list.revoke(RevokedToken { - jti: "short".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)), - }) - .await - .expect("revocation recorded"); - - assert_eq!( - list.published().await.expect("reads back").revoked.len(), - 1, - "live while the token it names could still be presented" - ); - - clock.advance(SignedDuration::from_hours(2)); - let published = list.published().await.expect("reads back"); - assert!(published.revoked.is_empty()); - assert_eq!(published.generated_at, clock.now()); -} - -#[tokio::test] -async fn the_published_list_orders_by_expiry() { - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] { - list.revoke(RevokedToken { - jti: jti.to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(hours)), - }) - .await - .expect("revocation recorded"); - } - - let published = list.published().await.expect("reads back"); - let order: Vec<&str> = published - .revoked - .iter() - .map(|token| token.jti.as_str()) - .collect(); - assert_eq!(order, ["sooner", "middle", "later"]); -} - #[test] fn a_listed_token_is_refused() { let now = at(0); diff --git a/capsule-server/src/federation/capability.rs b/capsule-server/src/federation/capability.rs new file mode 100644 index 00000000..71e6dde7 --- /dev/null +++ b/capsule-server/src/federation/capability.rs @@ -0,0 +1,811 @@ +//! The federation capability token, and the codec that mints and reads it. +//! +//! # The format is the contract +//! +//! design/federation.md makes the claim set normative — it is what every federated peer parses +//! and what this server signs — so the shape here is that table verbatim and nothing more: +//! +//! ```text +//! { "iss": , "sub": , "aud": "urn:capsule:album:", +//! "scope": "read" | "read-derivative-only", +//! "iat": , "exp": , "nbf": , +//! "jti": , "min_protocol_version": } +//! ``` +//! +//! Three deviations from RFC 7519 defaults, each the design's and each enforced here rather +//! than left to a peer's discretion: +//! +//! - **`aud` names the album, never the recipient.** The recipient is `sub`. A verifier that +//! matched `aud` against itself would accept every capability for every album, so +//! `jsonwebtoken`'s audience check is off and [`CapabilityCodec::verify`] hands the album back +//! for the *route* to match against the album being pulled. +//! - **The three instants are RFC 3339 strings**, not numeric dates, so the library's own +//! `exp`/`nbf` checks — against the system clock, with sixty seconds of leeway — are off and +//! every temporal decision is made here against the injected [`Clock`]. The same rule +//! [`crate::auth::tokens`] applies to session tokens, for the same reason: a deadline a test +//! cannot walk over is a deadline nobody tests. +//! - **`exp` is never more than 24 hours after `iat`.** Minting clamps; verification refuses a +//! wider window even under a valid signature, because the published revocation list is +//! bounded *by* that ceiling and one long-lived token would quietly break the bound. +//! +//! # One key, two token types +//! +//! The codec signs with the **same** Ed25519 key `SessionTokens` does — the operational key +//! `server-info` publishes — and the two token types cannot be confused with each other: a +//! session token carries `iss = "capsule-api"` and a required `kind`, a capability carries +//! `iss = ` and no `kind`, so each verifier finds the other's tokens unreadable by +//! construction. +//! +//! # Whole seconds, deliberately +//! +//! Every instant a capability carries is truncated to the second at mint. That is what lets a +//! grant be **re-signed byte-for-byte** from its stored record ([`CapabilityCodec::sign`]): +//! the refresh operation is idempotent on `(peer, jti)` and must answer a replay with the same +//! successor token, and a store that keeps microseconds cannot reproduce a nanosecond string. +//! Ed25519 signatures are deterministic, so the same claims sign to the same bytes. + +use std::fmt; +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use super::PeerId; +use crate::auth::tokens::SigningKeyError; +use crate::discovery::revocation::MAX_TOKEN_TTL; +use crate::store::{AlbumId, BlobRole, Clock}; + +/// The URN prefix an album-scoped `aud` claim carries. +pub const ALBUM_URN_PREFIX: &str = "urn:capsule:album:"; + +/// The `aud` claim for `album`. +#[must_use] +pub fn album_urn(album: &AlbumId) -> String { + format!("{ALBUM_URN_PREFIX}{}", album.as_str()) +} + +/// The album an `aud` claim names, or `None` for a claim that is not an album URN. +/// +/// The suffix must be a UUID, because an album id is one: a URN over any other text is not a +/// claim this server ever minted. +#[must_use] +pub fn album_from_urn(aud: &str) -> Option { + let id = aud.strip_prefix(ALBUM_URN_PREFIX)?; + uuid::Uuid::parse_str(id).ok().map(|_| AlbumId::new(id)) +} + +/// What a capability grants over an album's blobs. +/// +/// Enforced structurally against each blob's server-visible **role**, which is on its index row +/// and named by its signed envelope: a derivative-only capability is refused an `original` at +/// `GET /v1/blob/{hash}` whatever the peer claims to be fetching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Scope { + /// Everything a member reads: originals, derivatives, metadata, provenance. + Read, + /// Thumbnails and previews only — never originals. + ReadDerivativeOnly, +} + +impl Scope { + /// The stable token this scope travels under, on the wire and in a column. + pub fn as_str(self) -> &'static str { + match self { + Self::Read => "read", + Self::ReadDerivativeOnly => "read-derivative-only", + } + } + + /// The scope a stored token names, or `None` for a token no version of this server wrote. + pub fn from_token(token: &str) -> Option { + match token { + "read" => Some(Self::Read), + "read-derivative-only" => Some(Self::ReadDerivativeOnly), + _ => None, + } + } + + /// Whether a blob of `role` may be fetched under this scope. + /// + /// A backup is refused under both: a peer pulls an album's assets, and a backup copy is the + /// owner's own durability artefact rather than part of what was shared. + pub fn permits(self, role: BlobRole) -> bool { + match role { + BlobRole::Backup => false, + BlobRole::Original => matches!(self, Self::Read), + BlobRole::Derivative | BlobRole::Metadata | BlobRole::Provenance => true, + } + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The claims a capability carries. Serialized as the JWT payload, verbatim from the design. +/// +/// `deny_unknown_fields` because the set is closed: a claim the contract does not name is a +/// token this server did not mint, whatever its signature says. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Claims { + iss: String, + sub: String, + aud: String, + scope: Scope, + iat: String, + exp: String, + nbf: String, + jti: String, + min_protocol_version: String, +} + +/// What a capability turned out to grant, once it verified. +/// +/// Carries no raw token: everything downstream needs is here, and handing on the credential +/// itself is how one ends up in a log. `aud` has been parsed into the album it names, so a +/// route matches an [`AlbumId`] against an [`AlbumId`] rather than re-parsing a URN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityGrant { + /// The peer server the grant was issued to (`sub`). + pub peer: PeerId, + /// The album it scopes to (`aud`). + pub album: AlbumId, + /// What it permits. + pub scope: Scope, + /// The revocation key (`jti`). + pub jti: String, + /// When it was issued; also its `nbf`. + pub issued_at: Timestamp, + /// When it stops being honoured. + pub expires_at: Timestamp, + /// The album's pinned protocol date, which the peer selects its parser from. + pub min_protocol_version: String, +} + +/// Why a presented capability was not honoured. +/// +/// Deliberately carries no fragment of the token. The variants exist so the unit suite can +/// assert *which* mutation was refused; on the wire every one of them but [`Self::Expired`] +/// collapses into the framework's uncoded `401`, as the session scheme's do. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CapabilityError { + /// The token did not verify: a bad signature, a malformed payload, or a missing claim. + #[error("the capability could not be read")] + Unreadable, + /// The token verified and was issued by some other server. + #[error("the capability was issued by another server")] + WrongIssuer, + /// A claim is present and is not the shape the contract fixes. + #[error("the capability's {claim} claim is malformed")] + Malformed { + /// The claim that did not parse. + claim: &'static str, + }, + /// `exp` is more than the ceiling after `iat`. + #[error("the capability's lifetime exceeds the {MAX_TOKEN_TTL} ceiling")] + BeyondTtlCeiling, + /// `nbf` is in the future on this server's clock. + #[error("the capability is not valid yet")] + NotYetValid, + /// `exp` has passed. + #[error("the capability has expired")] + Expired, +} + +/// The claims could not be signed. The server's fault, never the caller's. +#[derive(Debug, thiserror::Error)] +#[error("the capability could not be signed: {detail}")] +pub struct MintError { + /// The signer's own description of the failure. + pub detail: String, +} + +/// What a mint asks for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MintRequest { + /// The peer server the grant is for. + pub peer: PeerId, + /// The album it scopes to. + pub album: AlbumId, + /// What it permits. + pub scope: Scope, + /// The album's pinned protocol date. + pub min_protocol_version: String, + /// The requested lifetime. Clamped into `1s ..= MAX_TOKEN_TTL`, never refused: a + /// capability that expired before it was issued would be one the codec signs and cannot + /// read. + pub ttl: SignedDuration, +} + +/// A freshly minted capability. +/// +/// `Debug` is hand-written: the token is a bearer credential and a derived impl would publish +/// it to any `tracing` field that formatted the struct. +#[derive(Clone, PartialEq, Eq)] +pub struct Minted { + /// The signed token, to hand to the peer. + pub token: String, + /// What it grants, for the issuer's own record. + pub grant: CapabilityGrant, +} + +impl fmt::Debug for Minted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Minted") + .field("token", &"") + .field("grant", &self.grant) + .finish() + } +} + +/// Mints and reads capabilities under this server's operational key. +/// +/// `Debug` is hand-written and prints no key material. +pub struct CapabilityCodec { + signing: EncodingKey, + verifying: DecodingKey, + public_key: Vec, + validation: Validation, + server_id: String, + clock: Arc, +} + +impl fmt::Debug for CapabilityCodec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CapabilityCodec") + .field("server_id", &self.server_id) + .field("keys", &"") + .finish_non_exhaustive() + } +} + +impl CapabilityCodec { + /// A codec over the operator's PKCS#8 Ed25519 key, issuing as `server_id`. + /// + /// The **same** bytes `SessionTokens::from_pkcs8` takes, so the public half this derives is + /// the one `server-info` publishes and the one a peer verifies against — `boot` asserts the + /// two agree. `from_pkcs8_maybe_unchecked` for the reason the session signer uses it: a v1 + /// PKCS#8 document, which is what `openssl genpkey` writes, lacks the public half that is + /// being derived here anyway. + /// + /// # Errors + /// + /// Returns [`SigningKeyError`] if `pkcs8_der` is not a readable Ed25519 private key. + pub fn from_pkcs8( + pkcs8_der: &[u8], + server_id: impl Into, + clock: Arc, + ) -> Result { + use ring::signature::KeyPair as _; + + let pair = ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8_der).map_err( + |error| SigningKeyError { + detail: error.to_string(), + }, + )?; + let public_key = pair.public_key().as_ref().to_vec(); + + // Everything temporal is decided here against `clock`, and `aud` is matched by the + // route against the album: the library checks the signature and the algorithm and that + // the three identity claims are present, and nothing else. + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_required_spec_claims(&["iss", "sub", "aud"]); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + + Ok(Self { + signing: EncodingKey::from_ed_der(pkcs8_der), + verifying: DecodingKey::from_ed_der(&public_key), + public_key, + validation, + server_id: server_id.into(), + clock, + }) + } + + /// The issuer every capability from this codec carries. + pub fn server_id(&self) -> &str { + &self.server_id + } + + /// The raw Ed25519 public key capabilities verify under. Thirty-two bytes, no encoding. + pub fn public_key(&self) -> &[u8] { + &self.public_key + } + + /// Mint a capability for `request`, at the clock's now. + /// + /// `iat = nbf = now`, `exp = now + min(ttl, ceiling)`, a fresh UUIDv7 `jti`, all instants + /// at whole seconds (see the module docs). + /// + /// # Errors + /// + /// Returns [`MintError`] if the claims cannot be signed. + pub fn mint(&self, request: &MintRequest) -> Result { + let now = whole_seconds(self.clock.now()); + let ttl = request + .ttl + .clamp(SignedDuration::from_secs(1), MAX_TOKEN_TTL); + let grant = CapabilityGrant { + peer: request.peer.clone(), + album: request.album.clone(), + scope: request.scope, + jti: uuid::Uuid::now_v7().to_string(), + issued_at: now, + expires_at: whole_seconds(crate::store::deadline(now, ttl)), + min_protocol_version: request.min_protocol_version.clone(), + }; + let token = self.sign(&grant)?; + tracing::info!( + peer = %grant.peer, + album = %grant.album, + scope = %grant.scope, + jti = %grant.jti, + expires_at = %grant.expires_at, + "minted a federation capability" + ); + Ok(Minted { token, grant }) + } + + /// Sign `grant` exactly as it was first minted. + /// + /// What answers a replayed refresh with the same successor: the grant is rebuilt from its + /// stored record and re-signed, and because every instant is at whole seconds and Ed25519 + /// is deterministic, the bytes are the bytes the peer already holds. + /// + /// # Errors + /// + /// Returns [`MintError`] if the claims cannot be signed. + pub fn sign(&self, grant: &CapabilityGrant) -> Result { + let claims = Claims { + iss: self.server_id.clone(), + sub: grant.peer.as_str().to_owned(), + aud: album_urn(&grant.album), + scope: grant.scope, + iat: grant.issued_at.to_string(), + exp: grant.expires_at.to_string(), + nbf: grant.issued_at.to_string(), + jti: grant.jti.clone(), + min_protocol_version: grant.min_protocol_version.clone(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &self.signing).map_err( + |error| MintError { + detail: error.to_string(), + }, + ) + } + + /// Read a presented capability. + /// + /// Signature and issuer first, then the shape of every claim, then the window against the + /// ceiling, then the clock. The order reports the most specific true reason without ever + /// computing with a claim that has not yet been checked. + /// + /// # Errors + /// + /// Returns [`CapabilityError`] for every way a token can fail; none carries any of it. + pub fn verify(&self, presented: &str) -> Result { + let claims = jsonwebtoken::decode::(presented, &self.verifying, &self.validation) + .map_err(|error| { + // The *kind* names which check failed and never any part of the credential. + tracing::debug!(reason = ?error.kind(), "a presented capability did not verify"); + CapabilityError::Unreadable + })? + .claims; + + if claims.iss != self.server_id { + tracing::debug!("a presented capability names another issuer"); + return Err(CapabilityError::WrongIssuer); + } + if claims.sub.is_empty() { + return Err(CapabilityError::Malformed { claim: "sub" }); + } + // A UUIDv7, as the table says and as this server mints: any other `jti` is a token + // this server did not issue, whatever key it verifies under. + if !uuid::Uuid::parse_str(&claims.jti).is_ok_and(|id| id.get_version_num() == 7) { + return Err(CapabilityError::Malformed { claim: "jti" }); + } + if claims + .min_protocol_version + .parse::() + .is_err() + { + return Err(CapabilityError::Malformed { + claim: "min_protocol_version", + }); + } + let album = + album_from_urn(&claims.aud).ok_or(CapabilityError::Malformed { claim: "aud" })?; + let issued_at = instant(&claims.iat, "iat")?; + let expires_at = instant(&claims.exp, "exp")?; + let not_before = instant(&claims.nbf, "nbf")?; + if expires_at <= issued_at { + return Err(CapabilityError::Malformed { claim: "exp" }); + } + if expires_at.duration_since(issued_at) > MAX_TOKEN_TTL { + tracing::debug!(jti = %claims.jti, "a presented capability outlives the ceiling"); + return Err(CapabilityError::BeyondTtlCeiling); + } + + let now = self.clock.now(); + if now < not_before { + tracing::debug!(jti = %claims.jti, "a presented capability is not valid yet"); + return Err(CapabilityError::NotYetValid); + } + if expires_at <= now { + tracing::debug!(jti = %claims.jti, "a presented capability has expired"); + return Err(CapabilityError::Expired); + } + + Ok(CapabilityGrant { + peer: PeerId::new(claims.sub), + album, + scope: claims.scope, + jti: claims.jti, + issued_at, + expires_at, + min_protocol_version: claims.min_protocol_version, + }) + } +} + +/// `at` with its sub-second part dropped. +fn whole_seconds(at: Timestamp) -> Timestamp { + Timestamp::from_second(at.as_second()).unwrap_or(at) +} + +/// An RFC 3339 claim as an instant. +fn instant(text: &str, claim: &'static str) -> Result { + text.parse::() + .map_err(|_| CapabilityError::Malformed { claim }) +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use serde_json::{Value, json}; + + use super::*; + use crate::store::memory::ManualClock; + + const SERVER: &str = "home.test"; + + fn der() -> Vec { + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("the platform generates a key") + .as_ref() + .to_vec() + } + + fn codec(clock: Arc) -> CapabilityCodec { + CapabilityCodec::from_pkcs8(&der(), SERVER, clock).expect("a fresh key parses") + } + + fn request() -> MintRequest { + MintRequest { + peer: PeerId::new("other.test"), + album: AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0"), + scope: Scope::ReadDerivativeOnly, + min_protocol_version: "2026-06-01".to_owned(), + ttl: SignedDuration::from_hours(6), + } + } + + /// The payload of `token`, as JSON. + fn payload(token: &str) -> Value { + let segment = token.split('.').nth(1).expect("a JWT has three segments"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("base64url")) + .expect("the payload is JSON") + } + + /// `token` with its payload replaced by `edit(payload)`, re-signed under `key`. + fn resigned(token: &str, key: &[u8], edit: impl FnOnce(&mut Value)) -> String { + let mut claims = payload(token); + edit(&mut claims); + jsonwebtoken::encode( + &Header::new(Algorithm::EdDSA), + &claims, + &EncodingKey::from_ed_der(key), + ) + .expect("the edited claims sign") + } + + #[test] + fn a_minted_capability_verifies_and_carries_exactly_the_contracts_claims() { + let clock = Arc::new(ManualClock::default()); + let codec = codec(clock); + let minted = codec.mint(&request()).expect("it mints"); + + let claims = payload(&minted.token); + let keys: Vec<&str> = claims + .as_object() + .expect("an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + [ + "aud", + "exp", + "iat", + "iss", + "jti", + "min_protocol_version", + "nbf", + "scope", + "sub" + ], + "the claim set is the design's table and nothing else" + ); + assert_eq!(claims["iss"], SERVER); + assert_eq!(claims["sub"], "other.test"); + assert_eq!( + claims["aud"], + "urn:capsule:album:01937b7c-0000-7000-8000-00000000a1b0" + ); + assert_eq!(claims["scope"], "read-derivative-only"); + assert_eq!(claims["iat"], "1970-01-01T00:00:00Z"); + assert_eq!(claims["nbf"], "1970-01-01T00:00:00Z"); + assert_eq!(claims["exp"], "1970-01-01T06:00:00Z"); + assert_eq!( + uuid::Uuid::parse_str(claims["jti"].as_str().expect("a string")) + .expect("a uuid") + .get_version_num(), + 7 + ); + + let grant = codec.verify(&minted.token).expect("it verifies"); + assert_eq!(grant, minted.grant); + } + + #[test] + fn a_grant_re_signs_to_the_same_bytes() { + // What refresh idempotency rests on: the record can reproduce the token. + let codec = codec(Arc::new(ManualClock::default())); + let minted = codec.mint(&request()).expect("it mints"); + assert_eq!(codec.sign(&minted.grant).expect("it signs"), minted.token); + } + + #[test] + fn the_lifetime_is_clamped_to_the_ceiling_and_the_instants_are_whole_seconds() { + let clock = Arc::new(ManualClock::new( + Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"), + )); + let codec = codec(clock); + let minted = codec + .mint(&MintRequest { + ttl: SignedDuration::from_hours(48), + ..request() + }) + .expect("it mints"); + assert_eq!( + minted.grant.issued_at, + Timestamp::from_second(1_700_000_000).expect("an instant") + ); + assert_eq!( + minted + .grant + .expires_at + .duration_since(minted.grant.issued_at), + MAX_TOKEN_TTL + ); + assert!(codec.verify(&minted.token).is_ok()); + } + + #[test] + fn every_mutation_of_a_claim_is_refused_with_its_reason() { + // The federation doc's own unit bullet: mutate each claim and assert the reason. + let clock = Arc::new(ManualClock::default()); + let key = der(); + let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses"); + let minted = codec.mint(&request()).expect("it mints"); + let token = &minted.token; + + // A lifetime that is not positive is clamped up rather than signed unreadable. + let instant = codec + .mint(&MintRequest { + ttl: SignedDuration::from_secs(-5), + ..request() + }) + .expect("it mints"); + assert_eq!( + instant + .grant + .expires_at + .duration_since(instant.grant.issued_at), + SignedDuration::from_secs(1) + ); + assert!(codec.verify(&instant.token).is_ok()); + + // The signature: any other key, or a flipped payload byte under the right key. + let forged = resigned(token, &der(), |_| {}); + assert_eq!(codec.verify(&forged), Err(CapabilityError::Unreadable)); + let mut tampered = token.clone(); + let payload_start = tampered.find('.').expect("a dot") + 1; + let byte = tampered.as_bytes()[payload_start]; + tampered.replace_range( + payload_start..=payload_start, + if byte == b'A' { "B" } else { "A" }, + ); + assert_eq!(codec.verify(&tampered), Err(CapabilityError::Unreadable)); + + // Each claim, under the real key. + type Edit = Box; + let cases: [(&str, Edit, CapabilityError); 16] = [ + ( + "iss", + Box::new(|c| c["iss"] = json!("elsewhere.test")), + CapabilityError::WrongIssuer, + ), + ( + "sub", + Box::new(|c| c["sub"] = json!("")), + CapabilityError::Malformed { claim: "sub" }, + ), + ( + "aud", + Box::new(|c| c["aud"] = json!("urn:capsule:user:someone")), + CapabilityError::Malformed { claim: "aud" }, + ), + ( + "scope", + Box::new(|c| c["scope"] = json!("write")), + CapabilityError::Unreadable, + ), + ( + "iat", + Box::new(|c| c["iat"] = json!(0)), + CapabilityError::Unreadable, + ), + ( + "exp", + Box::new(|c| c["exp"] = json!("tomorrow")), + CapabilityError::Malformed { claim: "exp" }, + ), + ( + "exp before iat", + Box::new(|c| c["exp"] = json!("1969-12-31T23:00:00Z")), + CapabilityError::Malformed { claim: "exp" }, + ), + ( + "exp beyond the ceiling", + Box::new(|c| c["exp"] = json!("1970-01-02T00:00:01Z")), + CapabilityError::BeyondTtlCeiling, + ), + ( + "nbf", + Box::new(|c| c["nbf"] = json!("1970-01-01T01:00:00Z")), + CapabilityError::NotYetValid, + ), + ( + "jti", + Box::new(|c| c["jti"] = json!("")), + CapabilityError::Malformed { claim: "jti" }, + ), + ( + "jti that is not a UUIDv7", + Box::new(|c| c["jti"] = json!("2b6ed3a6-4c7e-4f3a-9d3c-1f1f1f1f1f1f")), + CapabilityError::Malformed { claim: "jti" }, + ), + ( + "aud whose suffix is not an album id", + Box::new(|c| c["aud"] = json!("urn:capsule:album:not-an-id")), + CapabilityError::Malformed { claim: "aud" }, + ), + ( + "min_protocol_version", + Box::new(|c| c["min_protocol_version"] = json!("soon")), + CapabilityError::Malformed { + claim: "min_protocol_version", + }, + ), + ( + "an extra claim", + Box::new(|c| c["kind"] = json!("access")), + CapabilityError::Unreadable, + ), + ( + "a missing claim", + Box::new(|c| { + c.as_object_mut().expect("an object").remove("jti"); + }), + CapabilityError::Unreadable, + ), + ( + "a session token's shape", + Box::new(|c| { + c["iss"] = json!("capsule-api"); + c["kind"] = json!("access"); + }), + CapabilityError::Unreadable, + ), + ]; + for (claim, edit, expected) in cases { + let mutated = resigned(token, &key, edit); + assert_eq!( + codec.verify(&mutated), + Err(expected), + "mutating {claim} was not refused as expected" + ); + } + + // And expiry, on the clock rather than by editing a claim. + clock.advance(SignedDuration::from_hours(6)); + assert_eq!(codec.verify(token), Err(CapabilityError::Expired)); + } + + #[test] + fn a_session_token_is_unreadable_to_the_capability_codec_and_vice_versa() { + // The same key signs both, and the two verifiers still cannot be confused: a session + // token carries `iss = capsule-api` and a `kind`, a capability neither. + let clock = Arc::new(ManualClock::default()); + let key = der(); + let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses"); + let sessions = crate::auth::SessionTokens::from_pkcs8(&key, clock).expect("parses"); + assert_eq!(codec.public_key(), sessions.public_key()); + + let issued = sessions + .issue( + &crate::store::UserId::new("user"), + &crate::store::SessionId::new("session"), + SignedDuration::from_hours(1), + ) + .expect("it issues"); + assert_eq!( + codec.verify(&issued.access_token), + Err(CapabilityError::Unreadable), + "a session token has no aud, which the capability codec requires" + ); + + let minted = codec.mint(&request()).expect("it mints"); + assert!(matches!( + sessions.verify(&minted.token, crate::auth::TokenKind::Access), + Err(crate::auth::TokenError::Unreadable) + )); + } + + #[test] + fn scope_is_decided_by_the_blobs_role() { + for role in [ + BlobRole::Derivative, + BlobRole::Metadata, + BlobRole::Provenance, + ] { + assert!(Scope::Read.permits(role)); + assert!(Scope::ReadDerivativeOnly.permits(role)); + } + assert!(Scope::Read.permits(BlobRole::Original)); + assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Original)); + assert!(!Scope::Read.permits(BlobRole::Backup)); + assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Backup)); + for scope in [Scope::Read, Scope::ReadDerivativeOnly] { + assert_eq!(Scope::from_token(scope.as_str()), Some(scope)); + } + assert_eq!(Scope::from_token("write"), None); + } + + #[test] + fn the_album_urn_round_trips_and_nothing_else_parses() { + let album = AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0"); + assert_eq!(album_from_urn(&album_urn(&album)), Some(album)); + assert_eq!(album_from_urn("urn:capsule:album:"), None); + assert_eq!(album_from_urn("home.test"), None); + } + + #[test] + fn nothing_prints_a_token_or_a_key() { + let codec = codec(Arc::new(ManualClock::default())); + let minted = codec.mint(&request()).expect("it mints"); + let rendered = format!("{minted:?} {codec:?}"); + assert!(!rendered.contains(&minted.token), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + } +} diff --git a/capsule-server/src/federation/conformance.rs b/capsule-server/src/federation/conformance.rs new file mode 100644 index 00000000..fa085d7a --- /dev/null +++ b/capsule-server/src/federation/conformance.rs @@ -0,0 +1,800 @@ +//! The one suite every [`CapabilityStore`] and [`PeerStore`] adapter must pass. +//! +//! # The rules the suite exists to protect +//! +//! - **The store is the revocation list.** Revoking an issued capability publishes its `jti`; +//! revoking a `jti` nothing backs still publishes it; and what is published is pruned past +//! the token's own expiry and bounded by the TTL ceiling — the rules the standalone list +//! carried before this store replaced it. +//! - **Refresh is one operation and is idempotent.** The successor is recorded, the +//! predecessor is linked and revoked, and a replay answers with the same successor. +//! - **A refusal changes nothing.** `AlreadyRevoked`, `AlreadyRefreshed`, `AlreadyBlocked` +//! leave every row as it was. +//! +//! # Reusing a harness +//! +//! Every case scopes its own identifiers, so cases may share one store and [`run_all`] does. +//! The clock is the harness's own [`ManualClock`], because pruning is decided on the adapter's +//! clock rather than on an argument. + +use jiff::SignedDuration; + +use super::PeerId; +use super::capability::Scope; +use super::peers::{BlockOutcome, PeerStore, UnblockOutcome}; +use super::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, MAX_GRANT_LIFETIME, RefreshOutcome, + RevokeOutcome, +}; +use crate::discovery::revocation::{RevokeError, RevokedToken}; +use crate::store::memory::ManualClock; +use crate::store::{AlbumId, Clock as _, StoreError, UserId}; + +/// The stores under test. +pub trait Harness: Send + Sync { + /// The capability store under test. + fn capabilities(&self) -> &dyn CapabilityStore; + /// The peer store under test. + fn peers(&self) -> &dyn PeerStore; + /// The clock both adapters read. + fn clock(&self) -> &ManualClock; +} + +/// Unwrap a store result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming federation store must succeed at {doing}: {error}"), + } +} + +/// A capability for `case`, minted at the clock's now and good for `hours`. +/// +/// Not renewable: `not_after` equals `expires_at`, which is the default an owner gets when they +/// do not ask for renewal. The cases that are about the deadline set it themselves. +fn record(h: &dyn Harness, case: &str, jti: &str, hours: i64) -> CapabilityRecord { + renewable_record(h, case, jti, hours, hours) +} + +/// A capability for `case`, good for `hours` and renewable until `grant_hours` from now. +fn renewable_record( + h: &dyn Harness, + case: &str, + jti: &str, + hours: i64, + grant_hours: i64, +) -> CapabilityRecord { + let now = h.clock().now(); + CapabilityRecord { + jti: format!("{case}-{jti}"), + album_id: AlbumId::new(format!("{case}-album")), + peer_id: PeerId::new(format!("{case}.peer.test")), + member: UserId::new(format!("{case}-member")), + scope: Scope::Read, + granted_epoch: 3, + min_protocol_version: "2026-06-01".to_owned(), + issued_at: now, + expires_at: crate::store::deadline(now, SignedDuration::from_hours(hours)), + not_after: crate::store::deadline(now, SignedDuration::from_hours(grant_hours)), + revoked_at: None, + refreshed_to: None, + } +} + +async fn issue(h: &dyn Harness, record: CapabilityRecord) { + ok(h.capabilities().issue(record).await, "record a capability"); +} + +async fn find(h: &dyn Harness, jti: &str) -> Option { + ok(h.capabilities().find(jti).await, "find a capability") +} + +async fn published(h: &dyn Harness) -> Vec { + ok(h.capabilities().published().await, "read the list") + .revoked + .into_iter() + .map(|token| token.jti) + .collect() +} + +// =========================================================================================== +// Capabilities +// =========================================================================================== + +/// An issued capability reads back whole, and an unknown `jti` is `None`. +pub async fn an_issued_capability_reads_back_and_an_unknown_jti_is_none(h: &dyn Harness) { + let case = "readback"; + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + assert_eq!(find(h, &record.jti).await, Some(record.clone())); + assert!(record.is_live(h.clock().now())); + assert_eq!(find(h, "readback-never").await, None); +} + +/// A second record under one `jti` is refused as a rejection, and the first stands. +pub async fn a_duplicate_jti_is_rejected_and_the_first_record_stands(h: &dyn Harness) { + let case = "duplicate"; + let first = record(h, case, "one", 6); + issue(h, first.clone()).await; + let error = h + .capabilities() + .issue(CapabilityRecord { + scope: Scope::ReadDerivativeOnly, + ..first.clone() + }) + .await + .expect_err("a jti is minted once"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!(find(h, &first.jti).await, Some(first)); +} + +/// `live` answers by album and by peer, and leaves out the revoked and the expired. +pub async fn live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h: &dyn Harness) { + let case = "live"; + let now = h.clock().now(); + let a = record(h, case, "a", 6); + let b = CapabilityRecord { + peer_id: PeerId::new("other-live.peer.test"), + ..record(h, case, "b", 6) + }; + let revoked = record(h, case, "revoked", 6); + let expiring = record(h, case, "expiring", 1); + for record in [&a, &b, &revoked, &expiring] { + issue(h, record.clone()).await; + } + assert_eq!( + h.capabilities() + .revoke_issued(&revoked.jti, now) + .await + .expect("revokes"), + RevokeOutcome::Revoked + ); + + let later = crate::store::deadline(now, SignedDuration::from_hours(2)); + let mut by_album: Vec = ok( + h.capabilities() + .live(&CapabilityFilter::Album(a.album_id.clone()), later) + .await, + "list by album", + ) + .into_iter() + .map(|record| record.jti) + .collect(); + by_album.sort(); + assert_eq!(by_album, vec![a.jti.clone(), b.jti.clone()]); + + let by_peer: Vec = ok( + h.capabilities() + .live(&CapabilityFilter::Peer(b.peer_id.clone()), later) + .await, + "list by peer", + ) + .into_iter() + .map(|record| record.jti) + .collect(); + assert_eq!(by_peer, vec![b.jti]); +} + +/// Revoking an issued capability sets `revoked_at`, publishes its `jti`, and is idempotent. +pub async fn revoking_an_issued_capability_publishes_it_once(h: &dyn Harness) { + let case = "revoke"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + + assert_eq!( + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("revokes"), + RevokeOutcome::Revoked + ); + let stored = find(h, &record.jti).await.expect("still recorded"); + assert_eq!(stored.revoked_at, Some(now)); + assert!(!stored.is_live(now)); + assert!(published(h).await.contains(&record.jti)); + + assert_eq!( + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("answers"), + RevokeOutcome::AlreadyRevoked + ); + assert_eq!( + find(h, &record.jti) + .await + .expect("still recorded") + .revoked_at, + Some(now), + "a retry does not move the instant" + ); + assert_eq!( + h.capabilities() + .revoke_issued("revoke-never", now) + .await + .expect("answers"), + RevokeOutcome::Unknown + ); + assert_eq!( + published(h) + .await + .iter() + .filter(|jti| *jti == &record.jti) + .count(), + 1, + "one entry however many times it is revoked" + ); +} + +/// A `jti` nothing backs is still published, and one past the ceiling is refused. +pub async fn a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h: &dyn Harness) { + let now = h.clock().now(); + h.capabilities() + .revoke(RevokedToken { + jti: "foreign-one".to_owned(), + expires_at: crate::store::deadline(now, SignedDuration::from_hours(2)), + }) + .await + .expect("a foreign jti is a fact the list carries"); + assert!(published(h).await.contains(&"foreign-one".to_owned())); + assert_eq!( + find(h, "foreign-one").await, + None, + "no record is invented for it" + ); + + let error = h + .capabilities() + .revoke(RevokedToken { + jti: "foreign-beyond".to_owned(), + expires_at: crate::store::deadline(now, SignedDuration::from_hours(25)), + }) + .await + .expect_err("an entry past the ceiling is refused"); + assert!(matches!(error, RevokeError::Refused(_)), "{error:?}"); + assert!(!published(h).await.contains(&"foreign-beyond".to_owned())); +} + +/// Revoking a `jti` through the list also revokes the record behind it, and once only. +pub async fn the_list_and_the_record_are_one_fact(h: &dyn Harness) { + let case = "onefact"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + let entry = RevokedToken { + jti: record.jti.clone(), + expires_at: record.expires_at, + }; + h.capabilities() + .revoke(entry.clone()) + .await + .expect("first revocation"); + h.capabilities() + .revoke(entry) + .await + .expect("a retry is not a new fact"); + assert_eq!( + find(h, &record.jti).await.expect("recorded").revoked_at, + Some(now) + ); + assert_eq!( + published(h) + .await + .iter() + .filter(|jti| *jti == &record.jti) + .count(), + 1 + ); +} + +/// A list-side revocation of an issued `jti` is published under the record's own expiry. +/// +/// A shorter expiry from the caller would prune the entry while the token still verifies — +/// a peer's cached list would drop it and honour a revoked token until its real `exp`. +pub async fn a_list_side_revocation_keeps_the_records_expiry(h: &dyn Harness) { + let case = "keepexp"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + h.capabilities() + .revoke(RevokedToken { + jti: record.jti.clone(), + expires_at: crate::store::deadline(now, SignedDuration::from_mins(1)), + }) + .await + .expect("revokes"); + let entry = ok(h.capabilities().published().await, "read the list") + .revoked + .into_iter() + .find(|token| token.jti == record.jti) + .expect("published"); + assert_eq!(entry.expires_at, record.expires_at); + assert_eq!( + find(h, &record.jti).await.expect("recorded").revoked_at, + Some(now) + ); +} + +/// A record that would outlive the ceiling is refused by the store, at issue and at refresh. +pub async fn a_record_past_the_ceiling_is_refused(h: &dyn Harness) { + let case = "ceiling"; + let now = h.clock().now(); + let long = record(h, case, "long", 25); + let error = h + .capabilities() + .issue(long.clone()) + .await + .expect_err("the list is bounded by the ceiling, so the store holds it too"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!(find(h, &long.jti).await, None); + + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + let error = h + .capabilities() + .refresh(&old.jti, record(h, case, "long-successor", 25), now) + .await + .expect_err("a successor is held to the same ceiling"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + let old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(old.refreshed_to, None, "a refusal changes nothing"); + assert_eq!(old.revoked_at, None); +} + +/// A successor for another peer, album or member is refused; the link cannot widen a grant. +pub async fn a_successor_must_carry_the_predecessors_peer_album_and_member(h: &dyn Harness) { + let case = "widen"; + let now = h.clock().now(); + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + for (name, successor) in [ + ( + "peer", + CapabilityRecord { + peer_id: PeerId::new("widen-other.peer.test"), + ..record(h, case, "peer", 6) + }, + ), + ( + "album", + CapabilityRecord { + album_id: AlbumId::new("widen-other-album"), + ..record(h, case, "album", 6) + }, + ), + ( + "member", + CapabilityRecord { + member: UserId::new("widen-other-member"), + ..record(h, case, "member", 6) + }, + ), + ] { + let error = h + .capabilities() + .refresh(&old.jti, successor.clone(), now) + .await + .expect_err("a successor naming another peer, album or member is a rejection"); + assert!( + matches!(error, StoreError::Rejected { .. }), + "{name}: {error:?}" + ); + assert_eq!( + find(h, &successor.jti).await, + None, + "{name}: a refusal records nothing" + ); + } + let old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(old.refreshed_to, None); + assert_eq!(old.revoked_at, None); +} + +/// A successor may not move the grant's absolute deadline, in either direction. +/// +/// The rule that makes "a refresh cannot extend a grant" a property of the *store* rather than +/// of the one route that computes a successor's TTL. Without it a peer holding a deliberately +/// short grant refreshes into an indefinite one, and every adapter would have to be trusted to +/// have re-derived the same check. +pub async fn a_successor_may_not_move_the_grants_deadline(h: &dyn Harness) { + let case = "deadline"; + let now = h.clock().now(); + // Renewable for a week; each token lives six hours. + let old = renewable_record(h, case, "old", 6, 24 * 7); + issue(h, old.clone()).await; + + for (name, not_after) in [ + ( + "longer", + crate::store::deadline(now, SignedDuration::from_hours(24 * 30)), + ), + ( + "shorter", + crate::store::deadline(now, SignedDuration::from_hours(12)), + ), + ] { + let successor = CapabilityRecord { + not_after, + ..renewable_record(h, case, name, 6, 24 * 7) + }; + let error = h + .capabilities() + .refresh(&old.jti, successor.clone(), now) + .await + .expect_err("a successor moving the deadline is a rejection"); + assert!( + matches!(error, StoreError::Rejected { .. }), + "{name}: {error:?}" + ); + assert_eq!( + find(h, &successor.jti).await, + None, + "{name}: a refusal records nothing" + ); + } + + // And a token that would run past the deadline is refused at issue, before any refresh. + let overhanging = CapabilityRecord { + expires_at: crate::store::deadline(now, SignedDuration::from_hours(12)), + not_after: crate::store::deadline(now, SignedDuration::from_hours(6)), + ..record(h, case, "overhanging", 6) + }; + let error = h + .capabilities() + .issue(overhanging.clone()) + .await + .expect_err("a token outliving its grant is a rejection"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!(find(h, &overhanging.jti).await, None); + + // The one successor that *is* admissible carries the deadline unchanged. + let successor = renewable_record(h, case, "ok", 6, 24 * 7); + match ok( + h.capabilities() + .refresh(&old.jti, successor.clone(), now) + .await, + "refresh a renewable grant", + ) { + RefreshOutcome::Issued(issued) => assert_eq!(issued.not_after, old.not_after), + other => panic!("a live renewable predecessor must refresh, got {other:?}"), + } +} + +/// A grant whose deadline runs past the ninety-day ceiling is refused by the **store**, not only +/// by the route that parses `renewable_until`. +/// +/// Issued here **directly through the port**, bypassing `mint_capability` entirely, because that +/// is the whole property: the route is the only caller of [`CapabilityStore::issue`] today and +/// #476's operator tooling is exactly the second one. A ceiling enforced by whichever caller +/// remembers it is a ceiling the next caller does not have. +/// +/// Distinct from the two cases beside it: [`a_record_past_the_ceiling_is_refused`] bounds one +/// *token*'s life against `MAX_TOKEN_TTL`, and +/// [`a_successor_may_not_move_the_grants_deadline`] bounds a successor against its predecessor. +/// Neither says anything about how far out the original deadline may be. +pub async fn a_grant_past_the_lifetime_ceiling_is_refused_by_the_store(h: &dyn Harness) { + let case = "lifetime"; + let now = h.clock().now(); + + // Ninety days and an hour: a mistyped year is the input this exists for, and one hour past + // is the boundary that proves the comparison is the ceiling and not a rounding of it. + let mut past = record(h, case, "past", 6); + past.not_after = + crate::store::deadline(now, MAX_GRANT_LIFETIME + SignedDuration::from_hours(1)); + let error = h + .capabilities() + .issue(past.clone()) + .await + .expect_err("a grant past the lifetime ceiling is a rejection"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!( + find(h, &past.jti).await, + None, + "a refused grant records nothing" + ); + + // Exactly at the ceiling is admissible: the bound is inclusive, and a deployment sharing for + // precisely ninety days is not the mistake being guarded against. + let mut edge = record(h, case, "edge", 6); + edge.not_after = crate::store::deadline(now, MAX_GRANT_LIFETIME); + issue(h, edge.clone()).await; + assert_eq!( + find(h, &edge.jti).await.expect("recorded").not_after, + edge.not_after + ); + + // And a refresh cannot be used to walk past it either: the successor carries the deadline + // unchanged, so the ceiling is fixed at the original mint rather than re-measured per token. + let successor = CapabilityRecord { + not_after: edge.not_after, + ..record(h, case, "successor", 6) + }; + match ok( + h.capabilities().refresh(&edge.jti, successor, now).await, + "refresh a grant sitting at the ceiling", + ) { + RefreshOutcome::Issued(issued) => assert_eq!(issued.not_after, edge.not_after), + other => panic!("a live grant at the ceiling must refresh, got {other:?}"), + } +} + +/// Every adapter accepts the widest `granted_epoch` the port's type allows, and refuses the +/// first one it does not — identically. +/// +/// The suite fixes `granted_epoch` at 3 everywhere else, which is exactly the shape of divergence +/// this file exists to catch and cannot: the port says `u64` and the Postgres column is a +/// `BIGINT`, so an epoch above `i64::MAX` is representable to a caller and not to one adapter. +/// The rule is that it is **refused**, not silently narrowed — a grant recorded under a different +/// epoch than the one asked for is a grant that admits the wrong membership — and that both +/// adapters draw the line in the same place. +pub async fn the_widest_epoch_every_adapter_accepts_is_the_same_one(h: &dyn Harness) { + let case = "epoch"; + let widest = u64::try_from(i64::MAX).expect("i64::MAX is a u64"); + + // The widest value that round-trips, stored and read back unchanged. + let mut held = record(h, case, "widest", 6); + held.granted_epoch = widest; + issue(h, held.clone()).await; + assert_eq!( + find(h, &held.jti).await.expect("recorded").granted_epoch, + widest, + "the widest epoch must survive the round trip unchanged" + ); + + // Zero is the other end, and is a legitimate epoch rather than a missing one. + let mut zero = record(h, case, "zero", 6); + zero.granted_epoch = 0; + issue(h, zero.clone()).await; + assert_eq!(find(h, &zero.jti).await.expect("recorded").granted_epoch, 0); + + // One past it is refused, and refused *before* anything is written. + let mut past = record(h, case, "past", 6); + past.granted_epoch = widest + 1; + let error = h + .capabilities() + .issue(past.clone()) + .await + .expect_err("an epoch past the port's width is a rejection, never a truncation"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!( + find(h, &past.jti).await, + None, + "a refused epoch records nothing" + ); + + // And `live` still finds the widest one, so the refusal did not disturb the rows beside it. + let filter = CapabilityFilter::Album(held.album_id.clone()); + let found = ok( + h.capabilities().live(&filter, h.clock().now()).await, + "list live capabilities", + ); + assert!(found.iter().any(|row| row.jti == held.jti)); +} + +/// An entry leaves the list once the token it names has expired, and the list orders by expiry. +pub async fn the_published_list_prunes_expired_entries_and_orders_by_expiry(h: &dyn Harness) { + let case = "prune"; + let now = h.clock().now(); + for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] { + let record = record(h, case, jti, hours); + issue(h, record.clone()).await; + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("revokes"); + } + let listed: Vec = published(h) + .await + .into_iter() + .filter(|jti| jti.starts_with("prune-")) + .collect(); + assert_eq!(listed, ["prune-sooner", "prune-middle", "prune-later"]); + + h.clock().advance(SignedDuration::from_hours(3)); + let list = ok(h.capabilities().published().await, "read the list"); + assert_eq!(list.generated_at, h.clock().now()); + let listed: Vec = list + .revoked + .into_iter() + .map(|token| token.jti) + .filter(|jti| jti.starts_with("prune-")) + .collect(); + assert_eq!( + listed, + ["prune-middle", "prune-later"], + "an expired token is refused whether or not it is listed, so its entry carries nothing" + ); +} + +/// A refresh issues the successor, links and revokes the predecessor, and replays. +pub async fn a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h: &dyn Harness) { + let case = "refresh"; + let now = h.clock().now(); + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + let new = record(h, case, "new", 6); + + let outcome = h + .capabilities() + .refresh(&old.jti, new.clone(), now) + .await + .expect("refreshes"); + assert_eq!(outcome, RefreshOutcome::Issued(new.clone())); + let stored_old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(stored_old.refreshed_to, Some(new.jti.clone())); + assert_eq!(stored_old.revoked_at, Some(now)); + assert!(published(h).await.contains(&old.jti)); + assert_eq!(find(h, &new.jti).await, Some(new.clone())); + + // The replay: a different successor is offered and the first one is answered. + let another = record(h, case, "another", 6); + let replay = h + .capabilities() + .refresh(&old.jti, another.clone(), now) + .await + .expect("answers"); + assert_eq!(replay, RefreshOutcome::AlreadyRefreshed(new)); + assert_eq!( + find(h, &another.jti).await, + None, + "nothing was recorded for the replay" + ); +} + +/// A revoked predecessor cannot be refreshed, and an unknown one is unknown. +pub async fn a_revoked_or_unknown_predecessor_is_not_refreshed(h: &dyn Harness) { + let case = "norefresh"; + let now = h.clock().now(); + let revoked = record(h, case, "revoked", 6); + issue(h, revoked.clone()).await; + h.capabilities() + .revoke_issued(&revoked.jti, now) + .await + .expect("revokes"); + let successor = record(h, case, "successor", 6); + assert_eq!( + h.capabilities() + .refresh(&revoked.jti, successor.clone(), now) + .await + .expect("answers"), + RefreshOutcome::Revoked + ); + assert_eq!( + h.capabilities() + .refresh("norefresh-never", successor.clone(), now) + .await + .expect("answers"), + RefreshOutcome::Unknown + ); + assert_eq!( + find(h, &successor.jti).await, + None, + "a refusal records nothing" + ); +} + +// =========================================================================================== +// Peers +// =========================================================================================== + +/// An unknown peer is `None`; a pinned one reads back with its key and is not blocked. +pub async fn a_pinned_peer_reads_back_and_an_unknown_one_is_none(h: &dyn Harness) { + let peer = PeerId::new("pin.peer.test"); + let now = h.clock().now(); + assert_eq!(ok(h.peers().read(&peer).await, "read a peer"), None); + ok(h.peers().pin(&peer, [7; 32], now).await, "pin a peer"); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned"); + assert_eq!(record.server_id, peer); + assert_eq!(record.signing_key, Some([7; 32])); + assert_eq!(record.first_seen_at, now); + assert!(!record.is_blocked()); + assert_eq!(record.note, None); + + // A re-pin rotates the key and keeps the first-seen instant. + h.clock().advance(SignedDuration::from_hours(1)); + ok( + h.peers().pin(&peer, [8; 32], h.clock().now()).await, + "re-pin a peer", + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned"); + assert_eq!(record.signing_key, Some([8; 32])); + assert_eq!(record.first_seen_at, now); +} + +/// A block on a never-pinned peer creates a keyless row; a second block changes nothing. +pub async fn a_block_needs_no_key_and_is_idempotent(h: &dyn Harness) { + let peer = PeerId::new("block.peer.test"); + let now = h.clock().now(); + assert_eq!( + ok( + h.peers().block(&peer, now, Some("spam".to_owned())).await, + "block a peer" + ), + BlockOutcome::Blocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!(record.is_blocked()); + assert_eq!(record.blocked_at, Some(now)); + assert_eq!(record.signing_key, None); + assert_eq!(record.note.as_deref(), Some("spam")); + + let later = crate::store::deadline(now, SignedDuration::from_hours(1)); + assert_eq!( + ok( + h.peers() + .block(&peer, later, Some("again".to_owned())) + .await, + "block a peer again" + ), + BlockOutcome::AlreadyBlocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert_eq!( + record.blocked_at, + Some(now), + "a retry does not move the instant" + ); + assert_eq!(record.note.as_deref(), Some("spam")); +} + +/// Pinning keeps a block, and unblocking keeps the key. +pub async fn a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h: &dyn Harness) { + let peer = PeerId::new("keep.peer.test"); + let now = h.clock().now(); + ok(h.peers().block(&peer, now, None).await, "block a peer"); + ok( + h.peers().pin(&peer, [9; 32], now).await, + "pin a blocked peer", + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!( + record.is_blocked(), + "pinning a key is not an opinion about talking to its owner" + ); + assert_eq!(record.signing_key, Some([9; 32])); + + assert_eq!( + ok(h.peers().unblock(&peer).await, "unblock a peer"), + UnblockOutcome::Unblocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!(!record.is_blocked()); + assert_eq!(record.signing_key, Some([9; 32])); + assert_eq!( + ok(h.peers().unblock(&peer).await, "unblock a peer again"), + UnblockOutcome::NotBlocked + ); + assert_eq!( + ok( + h.peers() + .unblock(&PeerId::new("keep-never.peer.test")) + .await, + "unblock an unknown peer" + ), + UnblockOutcome::NotBlocked + ); +} + +/// Every case, against one harness. +pub async fn run_all(h: &dyn Harness) { + an_issued_capability_reads_back_and_an_unknown_jti_is_none(h).await; + a_duplicate_jti_is_rejected_and_the_first_record_stands(h).await; + live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h).await; + revoking_an_issued_capability_publishes_it_once(h).await; + a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h).await; + the_list_and_the_record_are_one_fact(h).await; + a_list_side_revocation_keeps_the_records_expiry(h).await; + a_record_past_the_ceiling_is_refused(h).await; + a_successor_must_carry_the_predecessors_peer_album_and_member(h).await; + a_successor_may_not_move_the_grants_deadline(h).await; + the_widest_epoch_every_adapter_accepts_is_the_same_one(h).await; + a_grant_past_the_lifetime_ceiling_is_refused_by_the_store(h).await; + the_published_list_prunes_expired_entries_and_orders_by_expiry(h).await; + a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h).await; + a_revoked_or_unknown_predecessor_is_not_refreshed(h).await; + a_pinned_peer_reads_back_and_an_unknown_one_is_none(h).await; + a_block_needs_no_key_and_is_idempotent(h).await; + a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h).await; +} diff --git a/capsule-server/src/federation/memory.rs b/capsule-server/src/federation/memory.rs new file mode 100644 index 00000000..3d10d37d --- /dev/null +++ b/capsule-server/src/federation/memory.rs @@ -0,0 +1,383 @@ +//! The deterministic doubles: [`InMemoryCapabilities`] and [`InMemoryPeers`]. +//! +//! One mutex each, which is what makes every multi-step operation — revoke-and-publish, +//! refresh — one critical section, exactly as the Postgres adapter's transaction is. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use jiff::Timestamp; + +use super::PeerId; +use super::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome}; +use super::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome, +}; +use crate::discovery::revocation::{ + MAX_TOKEN_TTL, PublishedRevocations, RevocationError, RevocationList, RevokeFuture, + RevokedToken, +}; +use crate::store::{Clock, StoreError, StoreFuture}; + +/// Take the lock, recovering from a poisoned mutex. +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The deterministic capability store, and the revocation list it publishes. +#[derive(Debug)] +pub struct InMemoryCapabilities { + inner: Mutex, + clock: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + /// Every capability issued here, by `jti`. + records: BTreeMap, + /// Every published revocation, by `jti`, with the token's own expiry for pruning. + published: BTreeMap, +} + +impl Inner { + /// Revoke `jti` at `at` if a record backs it, and publish it either way. + /// + /// The one place both halves happen, so no path can do one without the other. The expiry + /// the entry is published under is the **record's** when there is one — a caller's shorter + /// `expires_at` would prune the entry while the token still verifies, which is a peer + /// honouring a revoked token — and an entry already published is never shortened. + fn revoke(&mut self, jti: &str, expires_at: Timestamp, at: Timestamp) { + let mut expires_at = expires_at; + if let Some(record) = self.records.get_mut(jti) { + if record.revoked_at.is_none() { + record.revoked_at = Some(at); + } + expires_at = record.expires_at; + } + let entry = self.published.entry(jti.to_owned()).or_insert(expires_at); + *entry = (*entry).max(expires_at); + } + + /// Refuse a record whose lifetime the published list could not stay bounded under. + fn admissible(record: &CapabilityRecord) -> Result<(), StoreError> { + super::store::admissible(record) + } +} + +impl InMemoryCapabilities { + /// An empty store reading `clock` for pruning and for `generated_at`. + pub fn new(clock: Arc) -> Self { + Self { + inner: Mutex::new(Inner::default()), + clock, + } + } +} + +impl RevocationList for InMemoryCapabilities { + fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> { + Box::pin(async move { + let now = self.clock.now(); + let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL); + if token.expires_at > ceiling { + tracing::warn!( + jti = %token.jti, + expires_at = %token.expires_at, + "a revocation was refused: its expiry is beyond the capability TTL ceiling" + ); + return Err(RevocationError::BeyondTtlCeiling { + expires_at: token.expires_at, + ceiling: MAX_TOKEN_TTL, + } + .into()); + } + let mut inner = lock(&self.inner); + inner.revoke(&token.jti, token.expires_at, now); + tracing::info!( + jti = %token.jti, + expires_at = %token.expires_at, + published = inner.published.len(), + "a federation capability token was revoked" + ); + Ok(()) + }) + } + + fn published(&self) -> StoreFuture<'_, PublishedRevocations> { + Box::pin(async move { + let now = self.clock.now(); + let mut inner = lock(&self.inner); + // Pruned on read *and* retained pruned, so a list nobody fetches does not grow + // forever holding entries that already mean nothing. + inner.published.retain(|_, expires_at| *expires_at > now); + let mut revoked: Vec = inner + .published + .iter() + .map(|(jti, expires_at)| RevokedToken { + jti: jti.clone(), + expires_at: *expires_at, + }) + .collect(); + revoked.sort_by_key(|token| (token.expires_at, token.jti.clone())); + Ok(PublishedRevocations { + generated_at: now, + revoked, + }) + }) + } +} + +impl CapabilityStore for InMemoryCapabilities { + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> { + Box::pin(async move { + Inner::admissible(&record)?; + let mut inner = lock(&self.inner); + if inner.records.contains_key(&record.jti) { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!("a capability with jti {} is already recorded", record.jti), + }); + } + tracing::info!( + jti = %record.jti, + peer = %record.peer_id, + album = %record.album_id, + member = %record.member, + scope = %record.scope, + granted_epoch = record.granted_epoch, + expires_at = %record.expires_at, + "a federation capability was recorded" + ); + inner.records.insert(record.jti.clone(), record); + Ok(()) + }) + } + + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.inner).records.get(jti).cloned()) }) + } + + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec> { + Box::pin(async move { + Ok(lock(&self.inner) + .records + .values() + .filter(|record| record.is_live(now)) + .filter(|record| match filter { + CapabilityFilter::Album(album) => &record.album_id == album, + CapabilityFilter::Peer(peer) => &record.peer_id == peer, + }) + .cloned() + .collect()) + }) + } + + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> { + Box::pin(async move { + let mut inner = lock(&self.inner); + let Some(record) = inner.records.get(jti) else { + return Ok(RevokeOutcome::Unknown); + }; + if record.revoked_at.is_some() { + return Ok(RevokeOutcome::AlreadyRevoked); + } + let expires_at = record.expires_at; + inner.revoke(jti, expires_at, at); + tracing::info!(%jti, published = inner.published.len(), "an issued capability was revoked"); + Ok(RevokeOutcome::Revoked) + }) + } + + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome> { + Box::pin(async move { + Inner::admissible(&successor)?; + let mut inner = lock(&self.inner); + let Some(old) = inner.records.get(predecessor) else { + return Ok(RefreshOutcome::Unknown); + }; + super::store::continues(predecessor, old, &successor)?; + if let Some(next) = &old.refreshed_to { + let existing = + inner + .records + .get(next) + .cloned() + .ok_or_else(|| StoreError::Corrupt { + store: "capabilities", + record: "CapabilityRecord", + detail: format!( + "{predecessor} was refreshed to {next}, which is not recorded" + ), + })?; + return Ok(RefreshOutcome::AlreadyRefreshed(existing)); + } + if old.revoked_at.is_some() { + return Ok(RefreshOutcome::Revoked); + } + if inner.records.contains_key(&successor.jti) { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "a capability with jti {} is already recorded", + successor.jti + ), + }); + } + let old_expires_at = old.expires_at; + inner.revoke(predecessor, old_expires_at, at); + if let Some(old) = inner.records.get_mut(predecessor) { + old.refreshed_to = Some(successor.jti.clone()); + } + tracing::info!( + predecessor = %predecessor, + successor = %successor.jti, + peer = %successor.peer_id, + "a federation capability was refreshed" + ); + inner + .records + .insert(successor.jti.clone(), successor.clone()); + Ok(RefreshOutcome::Issued(successor)) + }) + } +} + +/// The deterministic peer store. +#[derive(Debug, Default)] +pub struct InMemoryPeers { + peers: Mutex>, +} + +impl InMemoryPeers { + /// An empty store: no peer pinned, no peer blocked. + pub fn new() -> Self { + Self::default() + } +} + +impl PeerStore for InMemoryPeers { + fn pin<'a>( + &'a self, + peer: &'a PeerId, + signing_key: [u8; 32], + at: Timestamp, + ) -> StoreFuture<'a, ()> { + Box::pin(async move { + let mut peers = lock(&self.peers); + match peers.get_mut(peer) { + Some(record) => record.signing_key = Some(signing_key), + None => { + peers.insert( + peer.clone(), + PeerRecord { + server_id: peer.clone(), + signing_key: Some(signing_key), + first_seen_at: at, + blocked_at: None, + note: None, + }, + ); + } + } + tracing::info!(%peer, "a peer's signing key was pinned"); + Ok(()) + }) + } + + fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.peers).get(peer).cloned()) }) + } + + fn block<'a>( + &'a self, + peer: &'a PeerId, + at: Timestamp, + note: Option, + ) -> StoreFuture<'a, BlockOutcome> { + Box::pin(async move { + let mut peers = lock(&self.peers); + let record = peers.entry(peer.clone()).or_insert_with(|| PeerRecord { + server_id: peer.clone(), + signing_key: None, + first_seen_at: at, + blocked_at: None, + note: None, + }); + if record.blocked_at.is_some() { + return Ok(BlockOutcome::AlreadyBlocked); + } + record.blocked_at = Some(at); + record.note = note; + tracing::warn!(%peer, "a peer server was blocked"); + Ok(BlockOutcome::Blocked) + }) + } + + fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> { + Box::pin(async move { + let mut peers = lock(&self.peers); + match peers.get_mut(peer) { + Some(record) if record.blocked_at.is_some() => { + record.blocked_at = None; + record.note = None; + tracing::info!(%peer, "a peer server was unblocked"); + Ok(UnblockOutcome::Unblocked) + } + _ => Ok(UnblockOutcome::NotBlocked), + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::super::conformance::{self, Harness}; + use super::{InMemoryCapabilities, InMemoryPeers}; + use crate::federation::{CapabilityStore, PeerStore}; + use crate::store::memory::ManualClock; + + #[derive(Debug)] + struct MemoryHarness { + clock: Arc, + capabilities: InMemoryCapabilities, + peers: InMemoryPeers, + } + + impl Harness for MemoryHarness { + fn capabilities(&self) -> &dyn CapabilityStore { + &self.capabilities + } + + fn peers(&self) -> &dyn PeerStore { + &self.peers + } + + fn clock(&self) -> &ManualClock { + &self.clock + } + } + + #[tokio::test] + async fn the_in_memory_stores_conform() { + let clock = Arc::new(ManualClock::default()); + let harness = MemoryHarness { + capabilities: InMemoryCapabilities::new(clock.clone()), + peers: InMemoryPeers::new(), + clock, + }; + conformance::run_all(&harness).await; + } +} diff --git a/capsule-server/src/federation/mod.rs b/capsule-server/src/federation/mod.rs new file mode 100644 index 00000000..93516a14 --- /dev/null +++ b/capsule-server/src/federation/mod.rs @@ -0,0 +1,587 @@ +//! Server-to-server federation (`S-E2`, `S-E5`, `S-C49`): the capability that gates which peer +//! may pull which album, the store it is issued from and revoked into, and the peers this +//! server knows. +//! +//! # No new data protocol +//! +//! design/federation.md is explicit: a peer fetches *exactly* the primitives a client fetches — +//! `GET /v1/sync?album_id=…` and `GET /v1/blob/{hash}` — and what federation adds is the +//! **capability token** those two reads accept in the `Authorization: Bearer` slot, plus the +//! per-peer budget behind it. So there is no `/v1/federation/pull` here and never will be: the +//! pull path is the read path, and this module is the credential, the lifecycle around it +//! (mint, refresh, revoke) and the moderation halves that hang on it (signed report intake, the +//! server-level blocklist). +//! +//! # What lives where +//! +//! - [`capability`] — the EdDSA-JWT and the codec that mints and reads it, over the **same** +//! Ed25519 key the session tokens are signed with, which is the key `server-info` publishes. +//! - [`store`] — [`CapabilityStore`], the record of every capability this server issued. It +//! **is** the revocation list: the adapters implement +//! [`RevocationList`](crate::discovery::revocation::RevocationList) and +//! `/.well-known/capsule/revoked-jti` reads them, so "is this `jti` revoked" has one answer. +//! - [`peers`] — [`PeerStore`], the peers whose signing keys an operator has pinned and the +//! blocklist, which is a column on the same row. +//! - [`memory`] — the deterministic doubles; [`conformance`] — the suite every adapter passes. +//! +//! # A peer is not an account +//! +//! A [`PeerId`] is a server's canonical origin (`other.tld`), never a user id, and the types +//! keep them apart everywhere the two could be confused: the sync cursor's scope byte, the +//! blob authority's principal, the counter key. Nothing here holds a user list, and nothing +//! published here names a user — the registry's no-enumeration rule holds at this layer too. + +use std::fmt; +use std::sync::Arc; + +pub mod capability; +pub mod conformance; +pub mod memory; +pub mod peers; +pub mod postgres; +pub mod report; +pub mod scheme; +pub mod store; + +pub use self::capability::{ + ALBUM_URN_PREFIX, CapabilityCodec, CapabilityError, CapabilityGrant, MintError, MintRequest, + Minted, Scope, album_from_urn, album_urn, +}; +pub use self::memory::{InMemoryCapabilities, InMemoryPeers}; +pub use self::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome}; +pub use self::report::{ReportClaim, ReportError, verify_signed_report}; +pub use self::scheme::{Principal, ReadBearer, VerifiedCapability}; +pub use self::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, MAX_GRANT_LIFETIME, RefreshOutcome, + RevokeOutcome, +}; +use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::store::Clock; + +/// A peer server's identity: its canonical origin, as its own `server-info` publishes it. +/// +/// Its own type rather than a `UserId` or a bare string so a peer can never be handed to a port +/// that expects an account, and so the log field that names one reads as what it is. +/// +/// Canonical: a host name is case-insensitive and a trailing dot names the same host, so both +/// are folded at construction. A block on `other.test` therefore covers a capability minted for +/// `Other.Test.`, and two records can never name one peer twice. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PeerId(String); + +impl PeerId { + /// Wraps an origin, folded to its canonical form. + pub fn new(value: impl Into) -> Self { + let value: String = value.into(); + Self(value.trim().trim_end_matches('.').to_ascii_lowercase()) + } + + /// The origin as text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Debug for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PeerId({:?})", self.0) + } +} + +/// What the federation module is assembled from. +/// +/// Named rather than positional, for the reason [`crate::app::Modules`] is: a constructor that +/// lengthens with every collaborator is one that is eventually got wrong positionally. +#[derive(Debug)] +pub struct FederationCollaborators { + /// Mints and reads capability tokens. + pub codec: Arc, + /// Every capability this server issued, and the revocation list it publishes. + pub capabilities: Arc, + /// The peers this server has pinned or blocked. + pub peers: Arc, + /// The clock every record and every deadline is stamped from. + pub clock: Arc, + /// Where peers reach this server, when it federates at all. + /// + /// `None` is a deployment that does not federate: the lifecycle writes refuse with + /// `error.federation.not_configured`, while a capability minted earlier still verifies — + /// a token is not un-minted by a configuration change. + pub federation_url: Option, +} + +/// The federation module's collaborators. +#[derive(Debug, Clone)] +pub struct FederationContext { + codec: Arc, + capabilities: Arc, + peers: Arc, + clock: Arc, + federation_url: Option, +} + +impl FederationContext { + /// Assembles the module. + pub fn new(collaborators: FederationCollaborators) -> Self { + let FederationCollaborators { + codec, + capabilities, + peers, + clock, + federation_url, + } = collaborators; + Self { + codec, + capabilities, + peers, + clock, + federation_url, + } + } + + /// The codec capabilities are minted with and read by. + pub fn codec(&self) -> &CapabilityCodec { + &self.codec + } + + /// Every capability this server issued. + pub fn capabilities(&self) -> &dyn CapabilityStore { + self.capabilities.as_ref() + } + + /// The peers this server knows. + pub fn peers(&self) -> &dyn PeerStore { + self.peers.as_ref() + } + + /// The clock. + pub fn clock(&self) -> &dyn Clock { + self.clock.as_ref() + } + + /// Where peers reach this server, if it federates. + pub fn federation_url(&self) -> Option<&str> { + self.federation_url.as_deref() + } + + /// Whether this deployment federates at all. + /// + /// The gate on every lifecycle write. Reads are not gated on it: a capability that was + /// minted while federation was on still verifies, and refusing it would cut a peer off + /// without a revocation anybody can see. + pub fn is_configured(&self) -> bool { + self.federation_url.is_some() + } +} + +/// Why an admitted capability is refused by a route. +/// +/// Every variant is a *coded* answer the route renders — the authenticator has no seam for one +/// (see [`scheme`]). The order [`admit`] decides them in is the order a client should learn them: +/// a revoked grant is refused before anything is charged to the peer's budget, and a blocked +/// peer is refused before it is either. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// The capability's `jti` is revoked. `403 error.federation.capability_revoked`. + Revoked, + /// The peer is on this server's blocklist. `403 error.moderation.server_blocked`. + PeerBlocked, + /// The peer's events-per-hour budget is spent. `429 error.federation.rate_budget_exceeded`. + RateLimited { + /// When the window resets. + retry_after: jiff::Timestamp, + }, + /// A collaborator could not answer, so nothing was decided. `500 error.federation.unavailable`. + /// + /// Never an admission: a limiter that fails open is a limiter an attacker turns off by + /// loading the counter store. + Unavailable, +} + +/// What a capability is being presented for. +/// +/// Only the liveness rule differs, and it differs for one reason: a predecessor that was revoked +/// **because it was refreshed** is exactly what a replayed refresh looks like, and refusing it +/// would make the idempotency the contract promises unreachable. Every other revocation refuses +/// both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Presentation { + /// A read: a sync page or a blob fetch. The grant must be live. + Read, + /// A refresh, presenting the predecessor. A predecessor already linked to a successor is + /// admitted so the replay can be answered with that successor — whose own liveness the + /// route then asks about, because a block cascades over it too. + Refresh, +} + +/// Decide whether `capability` may be presented at all, and charge the peer's budget if so. +/// +/// The three questions every federated request asks before it looks at what is being asked for: +/// is the grant still live, is the peer still welcome, and is the peer within budget. Asked +/// here once so the sync, blob and refresh routes cannot ask them in different orders. +/// +/// # Errors +/// +/// Returns the [`Refusal`] the route renders. +pub async fn admit( + federation: &FederationContext, + counters: &CounterContext, + capability: &VerifiedCapability, + presentation: Presentation, +) -> Result<(), Refusal> { + let peer = &capability.record.peer_id; + let live = match presentation { + Presentation::Read => capability.record.is_live(federation.clock().now()), + Presentation::Refresh => { + capability.record.refreshed_to.is_some() + || capability.record.is_live(federation.clock().now()) + } + }; + if !live { + tracing::info!( + %peer, + jti = %capability.record.jti, + "a revoked capability was presented" + ); + return Err(Refusal::Revoked); + } + + let blocked = federation + .peers() + .read(peer) + .await + .map_err(|error| { + tracing::error!(%error, %peer, "the peer store could not answer an admission"); + Refusal::Unavailable + })? + .is_some_and(|record| record.is_blocked()); + if blocked { + tracing::info!(%peer, "a blocked peer presented a capability"); + return Err(Refusal::PeerBlocked); + } + + let key = CounterKey::PeerRequests(peer.as_str().to_owned()); + match counters + .hit(&key, budgets::PEER_REQUESTS) + .await + .map_err(|error| { + tracing::error!(%error, %peer, "the per-peer counter could not be reached"); + Refusal::Unavailable + })? { + crate::counter::Verdict::Admitted { .. } => Ok(()), + crate::counter::Verdict::Limited { retry_after } => { + tracing::info!(%peer, %retry_after, "a peer's events budget is spent"); + Err(Refusal::RateLimited { retry_after }) + } + } +} + +/// Revoke every live capability over `album` whose member is not on the roster `listed` names. +/// +/// The one automatic revocation write (`S-E5`). A roster is the album owner's statement of who +/// may read it; a capability minted for a member the owner has just removed is a grant the +/// owner has withdrawn, and the peer holding it learns so from +/// `/.well-known/capsule/revoked-jti` rather than from a refusal it cannot explain. +/// +/// **An epoch bump alone revokes nothing.** A member still on the roster still holds their keys +/// and the server has nothing to cut; the grant's own epoch binding, re-checked at every +/// presentation, is what handles a member who *left and came back*. +/// +/// **A takedown revokes nothing either.** A moderation hold is a per-asset serving constraint +/// answering `410`, not a statement about who may read the album (design/moderation.md). +/// +/// # Errors +/// +/// Returns the store error. The caller — the roster route — logs it and still answers the +/// roster's own success: the roster is the fact, and a capability whose member has gone is +/// refused at its next presentation anyway, because membership is re-checked there. The gap is +/// bounded by the token's TTL and closes at the next revocation write. +pub async fn on_roster_applied( + federation: &FederationContext, + album: &crate::store::AlbumId, + listed: &[crate::store::UserId], +) -> Result { + let now = federation.clock().now(); + let live = federation + .capabilities() + .live(&CapabilityFilter::Album(album.clone()), now) + .await?; + let mut revoked = 0; + for record in live { + if listed.contains(&record.member) { + continue; + } + federation + .capabilities() + .revoke_issued(&record.jti, now) + .await?; + revoked += 1; + tracing::info!( + %album, + peer = %record.peer_id, + member = %record.member, + jti = %record.jti, + "a roster change revoked a federation capability" + ); + } + if revoked > 0 { + tracing::info!(%album, revoked, "a roster change cut federated grants"); + } + Ok(revoked) +} + +/// Revoke every live capability held by `peer`, because it has just been blocked. +/// +/// A block is already consulted at every federation boundary, so this cuts nothing the +/// blocklist would have let through. What it adds is **publication**: the peer's `jti`s go onto +/// `/.well-known/capsule/revoked-jti`, so a blocked peer learns its grants are gone from the +/// record every peer polls rather than only from a refusal, and an operator who later unblocks +/// the peer does not silently restore access the block was meant to end. +/// +/// # It is owed a caller +/// +/// **Nothing in production calls this.** A block is written by an operator command, and there is +/// none: `boot::assemble` refuses the durable backend until #403 lands its adapters, so a +/// command that blocked a peer could only run against `serve --memory` and would forget the +/// moment it exited. The command is owed with #476, and this function is what it will call. +/// +/// Said here the way [`crate::boot`] names what #403 owes, so a reader does not take the cascade +/// for something that happens automatically. What *is* automatic is the refusal: `blocked_at` is +/// consulted at mint, at every presentation, at refresh and at report intake, so a block +/// enforces itself the moment it is written. This adds only **publication** — the peer's `jti`s +/// reach the list every peer polls — which is why it is a separate step at all. +/// +/// # Errors +/// +/// Returns the store error. The caller — an operator command — reports it; the block itself has +/// already been written, and a block whose cascade failed still refuses every request. +pub async fn on_peer_blocked( + federation: &FederationContext, + peer: &PeerId, +) -> Result { + let now = federation.clock().now(); + let live = federation + .capabilities() + .live(&CapabilityFilter::Peer(peer.clone()), now) + .await?; + let mut revoked = 0; + for record in live { + federation + .capabilities() + .revoke_issued(&record.jti, now) + .await?; + revoked += 1; + } + tracing::info!(%peer, revoked, "a peer was blocked and its grants were cut"); + Ok(revoked) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jiff::{SignedDuration, Timestamp}; + + use super::*; + use crate::counter::{Budget, CounterStore, InMemoryCounters, Verdict}; + use crate::store::memory::ManualClock; + use crate::store::{AlbumId, StoreError, StoreFuture, UserId}; + + #[test] + fn a_peer_id_is_canonical() { + assert_eq!(PeerId::new("Other.Test."), PeerId::new("other.test")); + assert_eq!(PeerId::new(" other.test ").as_str(), "other.test"); + assert_ne!(PeerId::new("other.test"), PeerId::new("another.test")); + } + + /// A counter that cannot be reached. + #[derive(Debug)] + struct DownCounters; + + fn down() -> StoreFuture<'static, T> { + Box::pin(async { + Err(StoreError::Unavailable { + store: "counters", + detail: "down".to_owned(), + }) + }) + } + + impl CounterStore for DownCounters { + fn hit<'a>( + &'a self, + _: &'a CounterKey, + _: Budget, + _: Timestamp, + ) -> StoreFuture<'a, Verdict> { + down() + } + + fn peek<'a>( + &'a self, + _: &'a CounterKey, + _: Budget, + _: Timestamp, + ) -> StoreFuture<'a, Verdict> { + down() + } + + fn reset<'a>(&'a self, _: &'a CounterKey) -> StoreFuture<'a, ()> { + down() + } + } + + /// A peer store that cannot be reached. + #[derive(Debug)] + struct DownPeers; + + impl PeerStore for DownPeers { + fn pin<'a>(&'a self, _: &'a PeerId, _: [u8; 32], _: Timestamp) -> StoreFuture<'a, ()> { + down() + } + + fn read<'a>(&'a self, _: &'a PeerId) -> StoreFuture<'a, Option> { + down() + } + + fn block<'a>( + &'a self, + _: &'a PeerId, + _: Timestamp, + _: Option, + ) -> StoreFuture<'a, BlockOutcome> { + down() + } + + fn unblock<'a>(&'a self, _: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> { + down() + } + } + + fn context(peers: Arc, clock: Arc) -> FederationContext { + let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("a key generates"); + FederationContext::new(FederationCollaborators { + codec: Arc::new( + CapabilityCodec::from_pkcs8(der.as_ref(), "home.test", clock.clone()) + .expect("parses"), + ), + capabilities: Arc::new(InMemoryCapabilities::new(clock.clone())), + peers, + clock, + federation_url: None, + }) + } + + fn verified(clock: &ManualClock) -> VerifiedCapability { + let now = clock.now(); + let record = CapabilityRecord { + jti: "01937b7c-0000-7000-8000-0000000000aa".to_owned(), + album_id: AlbumId::new("album"), + peer_id: PeerId::new("other.test"), + member: UserId::new("bob"), + scope: Scope::Read, + granted_epoch: 1, + min_protocol_version: "2026-06-01".to_owned(), + issued_at: now, + expires_at: crate::store::deadline(now, SignedDuration::from_hours(1)), + not_after: crate::store::deadline(now, SignedDuration::from_hours(1)), + revoked_at: None, + refreshed_to: None, + }; + VerifiedCapability { + grant: record.grant(), + record, + } + } + + #[tokio::test] + async fn a_predecessor_revoked_by_its_own_refresh_is_admitted_only_to_be_refreshed() { + // What makes a replayed refresh answerable: the predecessor is revoked the moment its + // successor is issued, and a rule that refused every revoked grant would make the + // idempotency the contract promises unreachable. A read is still refused. + let clock = Arc::new(ManualClock::default()); + let mut capability = verified(&clock); + capability.record.revoked_at = Some(clock.now()); + capability.record.refreshed_to = Some("01937b7c-0000-7000-8000-0000000000bb".to_owned()); + let federation = context(Arc::new(InMemoryPeers::new()), clock.clone()); + let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Refresh).await, + Ok(()) + ); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Read).await, + Err(Refusal::Revoked) + ); + + // A grant revoked without a successor is refused on both. + capability.record.refreshed_to = None; + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Refresh).await, + Err(Refusal::Revoked) + ); + } + + #[tokio::test] + async fn a_store_that_cannot_answer_an_admission_is_an_outage_never_an_admission() { + // The fail-closed rule at the seam every federated read passes through: a peer store + // or a counter that cannot be reached decides nothing, and "nothing" is a refusal. + let clock = Arc::new(ManualClock::default()); + let capability = verified(&clock); + + let federation = context(Arc::new(DownPeers), clock.clone()); + let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone()); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Read).await, + Err(Refusal::Unavailable) + ); + + let federation = context(Arc::new(InMemoryPeers::new()), clock.clone()); + let counters = CounterContext::new(Arc::new(DownCounters), clock.clone()); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Read).await, + Err(Refusal::Unavailable) + ); + + let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Read).await, + Ok(()) + ); + } + + #[tokio::test] + async fn a_revoked_capability_is_refused_before_the_peer_is_charged() { + let clock = Arc::new(ManualClock::default()); + let mut capability = verified(&clock); + capability.record.revoked_at = Some(clock.now()); + let federation = context(Arc::new(InMemoryPeers::new()), clock.clone()); + let store = Arc::new(InMemoryCounters::new()); + let counters = CounterContext::new(store.clone(), clock.clone()); + assert_eq!( + admit(&federation, &counters, &capability, Presentation::Read).await, + Err(Refusal::Revoked) + ); + assert_eq!( + store + .peek( + &CounterKey::PeerRequests("other.test".to_owned()), + budgets::PEER_REQUESTS, + clock.now(), + ) + .await + .expect("answers"), + Verdict::Admitted { + remaining: budgets::PEER_REQUESTS.limit + }, + "a revoked grant costs the peer nothing" + ); + } +} diff --git a/capsule-server/src/federation/peers.rs b/capsule-server/src/federation/peers.rs new file mode 100644 index 00000000..32b0fef4 --- /dev/null +++ b/capsule-server/src/federation/peers.rs @@ -0,0 +1,99 @@ +//! [`PeerStore`] — the peer servers this one knows: their pinned signing keys, and the +//! server-level blocklist. +//! +//! # Operator-pinned, not fetched +//! +//! design/federation.md describes peers caching each other's keys TOFU-style with a perspective +//! check on rotation. This server has no outbound HTTP client at all — nothing in +//! `capsule-server` reaches out to another server — so in v1 a peer's key arrives the way a +//! deployment's own key does: an operator puts it there. That is stated rather than worked +//! around because the alternative, fetching `server-info` at report intake, would make the +//! first federated report from a new peer the thing that decides whether it is trusted. +//! +//! **Minting needs no peer key.** A capability is signed with this server's own key, and the +//! peer verifies it against `server-info`. The pinned key serves exactly one thing: verifying +//! the signature on a federated moderation report. +//! +//! # The blocklist is a column +//! +//! design/moderation.md's server-level blocklist "operates at the federation capability layer", +//! and here it is a row's `blocked_at`. Blocking a peer nobody has pinned is legitimate — an +//! operator blocks a server they never wanted to hear from — so a block creates the row without +//! a key. Every federation boundary consults it: mint, presentation, refresh, report intake. + +use std::fmt; + +use jiff::Timestamp; + +use super::PeerId; +use crate::store::StoreFuture; + +/// What this server knows about one peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerRecord { + /// The peer's canonical origin. + pub server_id: PeerId, + /// Its operational Ed25519 public key, if an operator has pinned one. + pub signing_key: Option<[u8; 32]>, + /// When this server first recorded the peer, by a pin or by a block. + pub first_seen_at: Timestamp, + /// When it was blocked, while it is. + pub blocked_at: Option, + /// The operator's note on the block, if they left one. + pub note: Option, +} + +impl PeerRecord { + /// Whether federated requests from this peer are refused. + pub fn is_blocked(&self) -> bool { + self.blocked_at.is_some() + } +} + +/// What blocking did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockOutcome { + /// The peer is now blocked. + Blocked, + /// It already was. A retry is not a new fact. + AlreadyBlocked, +} + +/// What unblocking did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnblockOutcome { + /// The peer is no longer blocked. + Unblocked, + /// It was not blocked, or was never recorded. + NotBlocked, +} + +/// Where peers are kept. +pub trait PeerStore: fmt::Debug + Send + Sync { + /// Pin `signing_key` as `peer`'s operational key, at `at`. + /// + /// Replaces a key already pinned: rotation is an operator act here. A block already on + /// the row is kept — pinning a key is not an opinion about whether to talk to its owner. + fn pin<'a>( + &'a self, + peer: &'a PeerId, + signing_key: [u8; 32], + at: Timestamp, + ) -> StoreFuture<'a, ()>; + + /// What is known about `peer`, if anything. + fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option>; + + /// Refuse federated requests from `peer` from `at`, with `note` for the operator's record. + /// + /// Creates the row if the peer was never pinned. Idempotent. + fn block<'a>( + &'a self, + peer: &'a PeerId, + at: Timestamp, + note: Option, + ) -> StoreFuture<'a, BlockOutcome>; + + /// Lift a block on `peer`. The pinned key, if any, is kept. + fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome>; +} diff --git a/capsule-server/src/federation/postgres.rs b/capsule-server/src/federation/postgres.rs new file mode 100644 index 00000000..71f76dd8 --- /dev/null +++ b/capsule-server/src/federation/postgres.rs @@ -0,0 +1,657 @@ +//! [`PostgresCapabilities`] and [`PostgresPeers`] — the durable federation stores (`S-E2`). +//! +//! # Two tables behind one port, written in one transaction +//! +//! `federation_capabilities` holds every capability this server minted; `federation_revoked_jti` +//! is the list `/.well-known/capsule/revoked-jti` publishes. They are one port because "is this +//! `jti` revoked" must have one answer: revoking an issued capability sets its `revoked_at` +//! **and** publishes its `jti`, and doing one without the other is what a transaction is for. +//! +//! They are two *tables* because a revocation is also accepted for a `jti` no record backs — an +//! operator cutting a token named in a peer's report, or one that predates this store — so the +//! list cannot be a view over the records. +//! +//! # Why the lock is an advisory lock +//! +//! `revoke_issued` and `refresh` each read a record, decide, and write two tables. The row they +//! would `SELECT … FOR UPDATE` exists for `revoke_issued` but not for the successor `refresh` +//! inserts, and two concurrent refreshes of one predecessor must not both issue. A +//! transaction-scoped advisory lock keyed on the predecessor's `jti` serialises both, is +//! released by the commit or the rollback, and needs no row to exist — the same choice +//! [`PostgresMembership`](crate::membership::postgres::PostgresMembership) makes for the same +//! reason. +//! +//! # Pruning on read +//! +//! `published` deletes every entry past its expiry before it reads, exactly as the in-memory +//! adapter retains, so a list nobody fetches does not grow forever holding entries that already +//! mean nothing. The delete is the read's own statement rather than a background job: the record +//! is fetched often and pruned rarely, and a sweeper would be a second thing to run. + +use jiff::Timestamp; +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; + +use super::PeerId; +use super::capability::Scope; +use super::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome}; +use super::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome, +}; +use crate::discovery::revocation::{ + MAX_TOKEN_TTL, PublishedRevocations, RevocationError, RevocationList, RevokeFuture, + RevokedToken, +}; +use crate::federation::store::admissible; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{AlbumId, Clock, StoreError, StoreFuture, UserId}; + +/// Which port is speaking, for every error the capability adapter raises. +const CAPABILITIES: Port = Port { + store: "capabilities", + record: "CapabilityRecord", +}; + +/// Which port is speaking, for every error the peer adapter raises. +const PEERS: Port = Port { + store: "peers", + record: "PeerRecord", +}; + +/// The columns every capability read selects, in the order [`record_from`] decodes them. +const CAPABILITY_COLUMNS: &str = "jti, album_id, peer_id, member_id, scope, granted_epoch, \ + min_protocol_version, issued_at, expires_at, not_after, \ + revoked_at, refreshed_to"; + +/// An epoch as the column holds it. +fn epoch_to_column(value: u64) -> Result { + i64::try_from(value).map_err(|_| StoreError::Rejected { + store: CAPABILITIES.store, + detail: format!("{value} is past what a BIGINT column holds"), + }) +} + +/// An instant as the port speaks it. +fn instant(port: Port, micros: i64) -> Result { + from_micros(micros) + .ok_or_else(|| port.undecodable(format!("{micros}µs is not a representable instant"))) +} + +/// Decode one row of [`CAPABILITY_COLUMNS`]. +fn record_from(row: &sea_orm::QueryResult) -> Result { + let failed = CAPABILITIES.failing("reading a capability"); + let jti: String = row.try_get("", "jti").map_err(&failed)?; + let album_id: String = row.try_get("", "album_id").map_err(&failed)?; + let peer_id: String = row.try_get("", "peer_id").map_err(&failed)?; + let member_id: String = row.try_get("", "member_id").map_err(&failed)?; + let scope: String = row.try_get("", "scope").map_err(&failed)?; + let granted_epoch: i64 = row.try_get("", "granted_epoch").map_err(&failed)?; + let min_protocol_version: String = row.try_get("", "min_protocol_version").map_err(&failed)?; + let issued_at: i64 = row.try_get("", "issued_at").map_err(&failed)?; + let expires_at: i64 = row.try_get("", "expires_at").map_err(&failed)?; + let not_after: i64 = row.try_get("", "not_after").map_err(&failed)?; + let revoked_at: Option = row.try_get("", "revoked_at").map_err(&failed)?; + let refreshed_to: Option = row.try_get("", "refreshed_to").map_err(&failed)?; + Ok(CapabilityRecord { + jti, + album_id: AlbumId::new(album_id), + peer_id: PeerId::new(peer_id), + member: UserId::new(member_id), + scope: Scope::from_token(&scope) + .ok_or_else(|| CAPABILITIES.undecodable(format!("`{scope}` is not a scope")))?, + granted_epoch: u64::try_from(granted_epoch) + .map_err(|_| CAPABILITIES.undecodable(format!("{granted_epoch} is not an epoch")))?, + min_protocol_version, + issued_at: instant(CAPABILITIES, issued_at)?, + expires_at: instant(CAPABILITIES, expires_at)?, + not_after: instant(CAPABILITIES, not_after)?, + revoked_at: revoked_at + .map(|micros| instant(CAPABILITIES, micros)) + .transpose()?, + refreshed_to, + }) +} + +/// Begin a transaction, or say why not. +async fn begin( + connection: &DatabaseConnection, + port: Port, +) -> Result { + connection + .begin() + .await + .map_err(port.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction, port: Port) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(port.failing("committing a transaction")) +} + +/// Take the transaction-scoped lock that serialises everything keyed on `key`. +async fn lock(transaction: &DatabaseTransaction, key: &str, port: Port) -> Result<(), StoreError> { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_advisory_xact_lock(hashtext($1))", + [Value::from(key.to_owned())], + )) + .await + .map(|_| ()) + .map_err(port.failing("taking the federation lock")) +} + +/// Read one capability under `connection`. +async fn record_of( + connection: &C, + jti: &str, +) -> Result, StoreError> { + let row = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!("SELECT {CAPABILITY_COLUMNS} FROM federation_capabilities WHERE jti = $1"), + [Value::from(jti.to_owned())], + )) + .await + .map_err(CAPABILITIES.failing("reading a capability"))?; + row.as_ref().map(record_from).transpose() +} + +/// Insert `record`, refusing a `jti` already recorded. +async fn insert( + connection: &C, + record: &CapabilityRecord, +) -> Result<(), StoreError> { + let inserted = connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO federation_capabilities \ + (jti, album_id, peer_id, member_id, scope, granted_epoch, min_protocol_version, \ + issued_at, expires_at, not_after, revoked_at, refreshed_to) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL, NULL) \ + ON CONFLICT (jti) DO NOTHING", + [ + Value::from(record.jti.clone()), + Value::from(record.album_id.as_str().to_owned()), + Value::from(record.peer_id.as_str().to_owned()), + Value::from(record.member.as_str().to_owned()), + Value::from(record.scope.as_str().to_owned()), + Value::from(epoch_to_column(record.granted_epoch)?), + Value::from(record.min_protocol_version.clone()), + Value::from(to_micros(record.issued_at)), + Value::from(to_micros(record.expires_at)), + Value::from(to_micros(record.not_after)), + ], + )) + .await + .map_err(CAPABILITIES.failing("recording a capability"))?; + if inserted.rows_affected() == 0 { + return Err(StoreError::Rejected { + store: CAPABILITIES.store, + detail: format!("a capability with jti {} is already recorded", record.jti), + }); + } + Ok(()) +} + +/// Publish `jti` under `expires_at`, never shortening an entry already published. +async fn publish( + connection: &C, + jti: &str, + expires_at: Timestamp, + at: Timestamp, +) -> Result<(), StoreError> { + connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO federation_revoked_jti (jti, expires_at, revoked_at) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (jti) DO UPDATE SET \ + expires_at = GREATEST(federation_revoked_jti.expires_at, EXCLUDED.expires_at)", + [ + Value::from(jti.to_owned()), + Value::from(to_micros(expires_at)), + Value::from(to_micros(at)), + ], + )) + .await + .map(|_| ()) + .map_err(CAPABILITIES.failing("publishing a revocation"))?; + Ok(()) +} + +/// Mark `jti` revoked at `at` if it is not already, and publish it under the record's own expiry. +/// +/// The two halves in one call, so no path can do one without the other. +async fn revoke_and_publish( + connection: &C, + jti: &str, + expires_at: Timestamp, + at: Timestamp, +) -> Result<(), StoreError> { + connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE federation_capabilities SET revoked_at = $2 \ + WHERE jti = $1 AND revoked_at IS NULL", + [Value::from(jti.to_owned()), Value::from(to_micros(at))], + )) + .await + .map_err(CAPABILITIES.failing("revoking a capability"))?; + publish(connection, jti, expires_at, at).await +} + +/// The durable capability store, and the revocation list it publishes. +#[derive(Debug, Clone)] +pub struct PostgresCapabilities { + connection: DatabaseConnection, + clock: std::sync::Arc, +} + +impl PostgresCapabilities { + /// A store over `connection`, reading `clock` for pruning and for `generated_at`. + pub fn new(connection: DatabaseConnection, clock: std::sync::Arc) -> Self { + Self { connection, clock } + } +} + +impl RevocationList for PostgresCapabilities { + fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> { + Box::pin(async move { + let now = self.clock.now(); + let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL); + if token.expires_at > ceiling { + tracing::warn!( + jti = %token.jti, + expires_at = %token.expires_at, + "a revocation was refused: its expiry is beyond the capability TTL ceiling" + ); + return Err(RevocationError::BeyondTtlCeiling { + expires_at: token.expires_at, + ceiling: MAX_TOKEN_TTL, + } + .into()); + } + let transaction = begin(&self.connection, CAPABILITIES).await?; + lock(&transaction, &token.jti, CAPABILITIES).await?; + // The expiry an entry is published under is the **record's** when one backs it: a + // caller's shorter `expires_at` would prune the entry while the token still + // verifies, which is a peer honouring a revoked token. + let expires_at = record_of(&transaction, &token.jti) + .await? + .map_or(token.expires_at, |record| record.expires_at); + revoke_and_publish(&transaction, &token.jti, expires_at, now).await?; + commit(transaction, CAPABILITIES).await?; + tracing::info!( + jti = %token.jti, + expires_at = %token.expires_at, + "a federation capability token was revoked" + ); + Ok(()) + }) + } + + fn published(&self) -> StoreFuture<'_, PublishedRevocations> { + Box::pin(async move { + let now = self.clock.now(); + let transaction = begin(&self.connection, CAPABILITIES).await?; + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM federation_revoked_jti WHERE expires_at <= $1", + [Value::from(to_micros(now))], + )) + .await + .map_err(CAPABILITIES.failing("pruning the revocation list"))?; + let rows = transaction + .query_all(Statement::from_string( + DbBackend::Postgres, + "SELECT jti, expires_at FROM federation_revoked_jti \ + ORDER BY expires_at ASC, jti ASC", + )) + .await + .map_err(CAPABILITIES.failing("reading the revocation list"))?; + commit(transaction, CAPABILITIES).await?; + + let failed = CAPABILITIES.failing("reading the revocation list"); + let mut revoked = Vec::with_capacity(rows.len()); + for row in &rows { + let jti: String = row.try_get("", "jti").map_err(&failed)?; + let expires_at: i64 = row.try_get("", "expires_at").map_err(&failed)?; + revoked.push(RevokedToken { + jti, + expires_at: instant(CAPABILITIES, expires_at)?, + }); + } + Ok(PublishedRevocations { + generated_at: now, + revoked, + }) + }) + } +} + +impl CapabilityStore for PostgresCapabilities { + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> { + Box::pin(async move { + admissible(&record)?; + insert(&self.connection, &record).await?; + tracing::info!( + jti = %record.jti, + peer = %record.peer_id, + album = %record.album_id, + member = %record.member, + scope = %record.scope, + granted_epoch = record.granted_epoch, + expires_at = %record.expires_at, + "a federation capability was recorded" + ); + Ok(()) + }) + } + + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> { + Box::pin(async move { record_of(&self.connection, jti).await }) + } + + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec> { + Box::pin(async move { + let (column, key) = match filter { + CapabilityFilter::Album(album) => ("album_id", album.as_str().to_owned()), + CapabilityFilter::Peer(peer) => ("peer_id", peer.as_str().to_owned()), + }; + let rows = self + .connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {CAPABILITY_COLUMNS} FROM federation_capabilities \ + WHERE {column} = $1 AND revoked_at IS NULL AND expires_at > $2 \ + ORDER BY jti ASC" + ), + [Value::from(key), Value::from(to_micros(now))], + )) + .await + .map_err(CAPABILITIES.failing("listing live capabilities"))?; + rows.iter().map(record_from).collect() + }) + } + + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection, CAPABILITIES).await?; + lock(&transaction, jti, CAPABILITIES).await?; + let Some(record) = record_of(&transaction, jti).await? else { + return Ok(RevokeOutcome::Unknown); + }; + if record.revoked_at.is_some() { + return Ok(RevokeOutcome::AlreadyRevoked); + } + revoke_and_publish(&transaction, jti, record.expires_at, at).await?; + commit(transaction, CAPABILITIES).await?; + tracing::info!(%jti, "an issued capability was revoked"); + Ok(RevokeOutcome::Revoked) + }) + } + + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome> { + Box::pin(async move { + admissible(&successor)?; + let transaction = begin(&self.connection, CAPABILITIES).await?; + lock(&transaction, predecessor, CAPABILITIES).await?; + let Some(old) = record_of(&transaction, predecessor).await? else { + return Ok(RefreshOutcome::Unknown); + }; + super::store::continues(predecessor, &old, &successor)?; + if let Some(next) = &old.refreshed_to { + let existing = + record_of(&transaction, next) + .await? + .ok_or_else(|| StoreError::Corrupt { + store: CAPABILITIES.store, + record: CAPABILITIES.record, + detail: format!( + "{predecessor} was refreshed to {next}, which is not recorded" + ), + })?; + return Ok(RefreshOutcome::AlreadyRefreshed(existing)); + } + if old.revoked_at.is_some() { + return Ok(RefreshOutcome::Revoked); + } + insert(&transaction, &successor).await?; + revoke_and_publish(&transaction, predecessor, old.expires_at, at).await?; + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE federation_capabilities SET refreshed_to = $2 WHERE jti = $1", + [ + Value::from(predecessor.to_owned()), + Value::from(successor.jti.clone()), + ], + )) + .await + .map_err(CAPABILITIES.failing("linking a refreshed capability"))?; + commit(transaction, CAPABILITIES).await?; + tracing::info!( + predecessor = %predecessor, + successor = %successor.jti, + peer = %successor.peer_id, + "a federation capability was refreshed" + ); + Ok(RefreshOutcome::Issued(successor)) + }) + } +} + +/// The durable peer store. +#[derive(Debug, Clone)] +pub struct PostgresPeers { + connection: DatabaseConnection, +} + +impl PostgresPeers { + /// A store over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } + + /// Read one peer under `connection`. + async fn read_under( + connection: &C, + peer: &PeerId, + ) -> Result, StoreError> { + let Some(row) = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT server_id, signing_key, first_seen_at, blocked_at, note \ + FROM federation_peers WHERE server_id = $1", + [Value::from(peer.as_str().to_owned())], + )) + .await + .map_err(PEERS.failing("reading a peer"))? + else { + return Ok(None); + }; + let failed = PEERS.failing("reading a peer"); + let server_id: String = row.try_get("", "server_id").map_err(&failed)?; + let signing_key: Option> = row.try_get("", "signing_key").map_err(&failed)?; + let first_seen_at: i64 = row.try_get("", "first_seen_at").map_err(&failed)?; + let blocked_at: Option = row.try_get("", "blocked_at").map_err(&failed)?; + let note: Option = row.try_get("", "note").map_err(&failed)?; + let signing_key = signing_key + .map(|bytes| { + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { + PEERS.undecodable(format!( + "{server_id}'s signing key is {} bytes, not thirty-two", + bytes.len() + )) + }) + }) + .transpose()?; + Ok(Some(PeerRecord { + server_id: PeerId::new(server_id), + signing_key, + first_seen_at: instant(PEERS, first_seen_at)?, + blocked_at: blocked_at + .map(|micros| instant(PEERS, micros)) + .transpose()?, + note, + })) + } +} + +impl PeerStore for PostgresPeers { + fn pin<'a>( + &'a self, + peer: &'a PeerId, + signing_key: [u8; 32], + at: Timestamp, + ) -> StoreFuture<'a, ()> { + Box::pin(async move { + // A block already on the row is kept: pinning a key is not an opinion about whether + // to talk to its owner. + self.connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO federation_peers (server_id, signing_key, first_seen_at) \ + VALUES ($1, $2, $3) \ + ON CONFLICT (server_id) DO UPDATE SET signing_key = EXCLUDED.signing_key", + [ + Value::from(peer.as_str().to_owned()), + Value::from(signing_key.to_vec()), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PEERS.failing("pinning a peer's key"))?; + tracing::info!(%peer, "a peer's signing key was pinned"); + Ok(()) + }) + } + + fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option> { + Box::pin(async move { Self::read_under(&self.connection, peer).await }) + } + + fn block<'a>( + &'a self, + peer: &'a PeerId, + at: Timestamp, + note: Option, + ) -> StoreFuture<'a, BlockOutcome> { + Box::pin(async move { + // Creates the row if the peer was never pinned: blocking a server nobody wanted to + // hear from is legitimate, and it must not need a key first. + let updated = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO federation_peers (server_id, first_seen_at, blocked_at, note) \ + VALUES ($1, $2, $2, $3) \ + ON CONFLICT (server_id) DO UPDATE SET blocked_at = $2, note = $3 \ + WHERE federation_peers.blocked_at IS NULL", + [ + Value::from(peer.as_str().to_owned()), + Value::from(to_micros(at)), + Value::from(note), + ], + )) + .await + .map_err(PEERS.failing("blocking a peer"))?; + if updated.rows_affected() == 0 { + return Ok(BlockOutcome::AlreadyBlocked); + } + tracing::warn!(%peer, "a peer server was blocked"); + Ok(BlockOutcome::Blocked) + }) + } + + fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> { + Box::pin(async move { + let updated = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE federation_peers SET blocked_at = NULL, note = NULL \ + WHERE server_id = $1 AND blocked_at IS NOT NULL", + [Value::from(peer.as_str().to_owned())], + )) + .await + .map_err(PEERS.failing("unblocking a peer"))?; + if updated.rows_affected() == 0 { + return Ok(UnblockOutcome::NotBlocked); + } + tracing::info!(%peer, "a peer server was unblocked"); + Ok(UnblockOutcome::Unblocked) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use std::sync::Arc; + + use super::super::{PostgresCapabilities, PostgresPeers}; + use crate::federation::conformance::{self, Harness}; + use crate::federation::{CapabilityStore, PeerStore}; + use crate::postgres::testing; + use crate::store::memory::ManualClock; + + /// Both stores over one container. + #[derive(Debug)] + struct PostgresHarness { + clock: Arc, + capabilities: PostgresCapabilities, + peers: PostgresPeers, + } + + impl Harness for PostgresHarness { + fn capabilities(&self) -> &dyn CapabilityStore { + &self.capabilities + } + + fn peers(&self) -> &dyn PeerStore { + &self.peers + } + + fn clock(&self) -> &ManualClock { + &self.clock + } + } + + #[tokio::test] + async fn the_postgres_federation_stores_conform() { + let Some(database) = testing::start("the Postgres federation stores").await else { + return; + }; + let clock = Arc::new(ManualClock::default()); + let harness = PostgresHarness { + capabilities: PostgresCapabilities::new( + database.connection().clone(), + clock.clone(), + ), + peers: PostgresPeers::new(database.connection().clone()), + clock, + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/federation/report.rs b/capsule-server/src/federation/report.rs new file mode 100644 index 00000000..6675929a --- /dev/null +++ b/capsule-server/src/federation/report.rs @@ -0,0 +1,205 @@ +//! The signed federated moderation report, and what verifies one (`S-C49`). +//! +//! # Why a report is signed rather than authenticated +//! +//! A peer filing a report holds no capability on this server — it is reporting *this* server's +//! content, not pulling it — so there is no bearer to present and nothing to check a bearer +//! against. What there is, is the peer's operational Ed25519 key, which an operator has pinned +//! ([`PeerStore::pin`](super::PeerStore::pin)). So the report carries its own signature and the +//! route verifies it against that pinned key: intake is unauthenticated in the HTTP sense and +//! attributed in every sense that matters. +//! +//! That is also why intake is not TOFU. Fetching a peer's key at the moment it first files would +//! make the first report from a new server the thing that decides whether to trust that server. +//! +//! # What is signed +//! +//! The canonical CBOR of [`ReportClaim`] — every field of the report except the signature — so +//! the bytes a peer signs are reproducible from the body this server received and nothing about +//! JSON key order or number formatting can change them. Canonical CBOR is the same encoding +//! every other signed document in Capsule uses, and `capsule-core` owns it. +//! +//! Replay is bounded by the `(reporting_server, reported_user)` budget rather than by a nonce: +//! a replayed report is a duplicate row in an operator's queue, not an action, and a nonce table +//! would be a second store for a threat whose worst outcome is a duplicate. + +use serde::{Deserialize, Serialize}; + +/// The report's signed payload: every field except the signature. +/// +/// Canonical CBOR sorts a map's keys, so the encoding depends on the field *names* and their +/// values and not on the order they are declared in here — which is what lets a peer implement +/// the format from the design doc rather than from this file. Renaming a field, adding one, or +/// changing one's type is still a breaking change to every peer, and +/// `tests::the_signing_bytes_are_stable` pins the encoding so it fails here rather than in the +/// field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReportClaim { + /// The peer filing the report, as its own `server-info` names it. + pub reporting_server: String, + /// The account on the receiving server the report is about. + pub reported_user: String, + /// The content address of the asset complained about. + pub asset_hash: String, + /// The album it was pulled from. + pub album_id: String, + /// A short reason, where the peer gives one. + pub reason: Option, + /// When the peer says it was reported, RFC 3339. + pub reported_at: String, +} + +/// Why a report was not accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ReportError { + /// The claim could not be encoded, which can only be this server's own fault. + #[error("the report's signing bytes could not be produced")] + Unencodable, + /// The signature does not verify under the peer's pinned key. + #[error("the report's signature does not verify")] + NotAuthentic, +} + +impl ReportClaim { + /// The exact bytes a peer signs. + /// + /// # Errors + /// + /// Returns [`ReportError::Unencodable`] if the claim does not serialize, which nothing a + /// peer can send causes: every field is a string. + pub fn signing_bytes(&self) -> Result, ReportError> { + capsule_core::cbor::to_canonical_vec(self).map_err(|error| { + tracing::error!(%error, "a federated report's claim did not serialize"); + ReportError::Unencodable + }) + } + + /// Whether `signature` is this claim's, under `key`. + /// + /// # Errors + /// + /// Returns [`ReportError::NotAuthentic`] when it is not. No part of the signature or the key + /// is logged: which check failed is the whole of what is safe to say. + pub fn verify(&self, signature: &[u8], key: &[u8; 32]) -> Result<(), ReportError> { + let bytes = self.signing_bytes()?; + verify_signed_report(&bytes, signature, key).inspect_err(|_| { + tracing::info!( + from = %self.reporting_server, + "a federated report's signature did not verify under the pinned key" + ); + }) + } +} + +/// Whether `signature` covers `signed` under `key`. +/// +/// The re-verification an operator does against a filed report, months after intake: it takes +/// the two byte strings the row carries and the peer's pinned key, and nothing that was derived +/// or normalized. Deliberately *not* a method on [`ReportClaim`] — reconstructing a claim from a +/// stored row is exactly the mistake this exists to make unnecessary. +/// +/// # Errors +/// +/// Returns [`ReportError::NotAuthentic`] when it does not. +pub fn verify_signed_report( + signed: &[u8], + signature: &[u8], + key: &[u8; 32], +) -> Result<(), ReportError> { + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key.as_slice()) + .verify(signed, signature) + .map_err(|_| ReportError::NotAuthentic) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claim() -> ReportClaim { + ReportClaim { + reporting_server: "other.test".to_owned(), + reported_user: "01937b7c-0000-7000-8000-0000000000b0".to_owned(), + asset_hash: "a".repeat(64), + album_id: "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60".to_owned(), + reason: Some("csam".to_owned()), + reported_at: "2026-09-02T00:00:00Z".to_owned(), + } + } + + /// A key pair, and the raw thirty-two public bytes an operator pins. + fn keypair() -> (ring::signature::Ed25519KeyPair, [u8; 32]) { + use ring::signature::KeyPair as _; + let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("a key generates"); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(der.as_ref()).expect("it parses"); + let public: [u8; 32] = pair.public_key().as_ref().try_into().expect("32 bytes"); + (pair, public) + } + + #[test] + fn a_report_verifies_under_the_key_that_signed_it_and_no_other() { + let (pair, public) = keypair(); + let claim = claim(); + let signature = pair.sign(&claim.signing_bytes().expect("it encodes")); + assert_eq!(claim.verify(signature.as_ref(), &public), Ok(())); + + let (_, other) = keypair(); + assert_eq!( + claim.verify(signature.as_ref(), &other), + Err(ReportError::NotAuthentic) + ); + } + + #[test] + fn every_field_is_covered_by_the_signature() { + // The whole point of signing the claim rather than a digest of part of it: a peer + // cannot have its signature over one report re-used to file a different one. + let (pair, public) = keypair(); + let original = claim(); + let signature = pair.sign(&original.signing_bytes().expect("it encodes")); + + // Plain function pointers rather than boxed closures: none of them captures, and the + // list is the point — one entry per field of the claim, so a field added without a + // mutation here is a field this case silently stops covering. + let mutations: [fn(&mut ReportClaim); 7] = [ + |claim| claim.reporting_server = "third.test".to_owned(), + |claim| claim.reported_user = "01937b7c-0000-7000-8000-0000000000cc".to_owned(), + |claim| claim.asset_hash = "b".repeat(64), + |claim| claim.album_id = "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff".to_owned(), + |claim| claim.reason = Some("spam".to_owned()), + |claim| claim.reason = None, + |claim| claim.reported_at = "2026-09-03T00:00:00Z".to_owned(), + ]; + for (index, mutate) in mutations.iter().enumerate() { + let mut mutated = original.clone(); + mutate(&mut mutated); + assert_eq!( + mutated.verify(signature.as_ref(), &public), + Err(ReportError::NotAuthentic), + "mutation {index} was not covered by the signature" + ); + } + } + + #[test] + fn the_signing_bytes_are_stable() { + // Pinned as a literal: this encoding is the contract every peer signs against, and a + // field reordering or an encoder change would break every peer at once. It must fail + // here rather than in the field. + let bytes = claim().signing_bytes().expect("it encodes"); + assert_eq!( + hex_of(&bytes), + "a666726561736f6e646373616d68616c62756d5f6964782430313866336631652d346237612d376339642d386532662d3161326233633464356536306a61737365745f686173687840616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616b7265706f727465645f617474323032362d30392d30325430303a30303a30305a6d7265706f727465645f75736572782430313933376237632d303030302d373030302d383030302d303030303030303030306230707265706f7274696e675f7365727665726a6f746865722e74657374", + "if this changed, every peer's signature changed with it" + ); + } + + fn hex_of(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + bytes.iter().fold(String::new(), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }) + } +} diff --git a/capsule-server/src/federation/scheme.rs b/capsule-server/src/federation/scheme.rs new file mode 100644 index 00000000..1cde3e96 --- /dev/null +++ b/capsule-server/src/federation/scheme.rs @@ -0,0 +1,182 @@ +//! [`ReadBearer`] — the one bearer carriage the two read primitives accept two principals on. +//! +//! # One component key, two principals +//! +//! design/api-surfaces.md: "Session access tokens and federation capabilities are different token +//! types verified by their owning modules, even though both use the standard HTTP carriage." +//! Kynos registers a scheme under its component name, and `GET /v1/sync` and +//! `GET /v1/blob/{hash}` must keep declaring the same `bearer` requirement every other operation +//! does — the generated SDK client attaches its credential by that key, and a second key would +//! split one carriage into two in the document for a difference the wire does not have. So this +//! scheme registers under **the same name, with a byte-identical description**, as +//! [`AccessToken`]; Kynos accepts a duplicate registration exactly when the two descriptions are +//! equal, and `tests::the_two_schemes_describe_one_component` pins that they are. +//! +//! # Session first, capability second +//! +//! The authenticator asks the session module first, exactly as `Auth` would — the +//! ledger check of `S-C48` included — and only on *unauthenticated* tries the capability codec. +//! A session token that is live and the wrong kind stays `403`: a refresh token is an +//! insufficient credential on this operation whichever module reads it. A capability that +//! verifies must also be one this server **recorded**: an unknown `jti` under a valid signature +//! is a token this server did not issue, and the record is what carries the member and epoch the +//! route checks membership against. +//! +//! # What this authenticator refuses, and what it deliberately does not +//! +//! Only the structural refusals — does not verify, expired, unknown — are decided here, and they +//! render as the framework's uncoded `401`, the recorded limitation of +//! [`crate::auth::scheme`]. Everything a client can act on with a code — revoked, wrong album, +//! insufficient scope, over budget, blocked peer — is decided by the **route** from the admitted +//! [`VerifiedCapability`] through [`admit`](super::admit), the same way `Membership::Revoked` +//! becomes a route's `403`. The credential never carries the raw token. + +use kynos::error::rejection::AuthRejection; +use kynos::prelude::*; +use kynos::security::Authenticator; +use kynos::security::carrier::BearerToken; + +use super::FederationContext; +use super::capability::CapabilityGrant; +use super::store::CapabilityRecord; +use crate::app::App; +use crate::auth::{AccessToken, AuthContext, AuthenticatedSession}; + +/// A capability that verified and that this server recorded. +/// +/// The grant is what the token said; the record is what the server knows about it — the member +/// it was minted for, the epoch their membership was granted at, whether it has been revoked. +/// A route decides from both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedCapability { + /// The token's claims, verified. + pub grant: CapabilityGrant, + /// The issued record the `jti` names. + pub record: CapabilityRecord, +} + +/// Who a read is being served to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Principal { + /// An account, through a session access token. + Session(AuthenticatedSession), + /// A peer server, through a federation capability. + /// + /// Boxed: the grant and its record are several hundred bytes against a session's tens, and + /// every session-authenticated read would otherwise carry the difference. + Peer(Box), +} + +/// The bearer carriage on the two read primitives a peer may pull through. +/// +/// Registered under the same component as [`AccessToken`], with the same description: one +/// `bearer` in the document, one credential key in the SDK. The handler receives a +/// [`Principal`] and never the token. +#[derive(SecurityScheme)] +#[security(bearer(format = "JWT"))] +#[security( + name = "bearer", + credential = Principal, + description = "A short-lived Capsule access token, issued by `POST /v1/auth/login` and \ + rotated by `POST /v1/auth/refresh`." +)] +pub struct ReadBearer; + +impl Authenticator for FederationContext { + async fn authenticate( + &self, + presented: BearerToken, + context: &App, + ) -> Result { + // The session module first, and its answer is final unless it is "not a session": a + // live refresh token is `403` here as everywhere, and a session it admits is admitted. + match >::authenticate( + context.auth(), + presented.clone(), + context, + ) + .await + { + Ok(session) => return Ok(Principal::Session(session)), + // `AuthRejection` is non-exhaustive; anything that is not "insufficient" is "not a + // session", which is the one answer that opens the capability path. + Err(AuthRejection::Forbidden) => return Err(AuthRejection::Forbidden), + Err(_) => {} + } + + let grant = self.codec().verify(presented.as_str()).map_err(|reason| { + // Which check failed is safe to log — it names no part of the credential — and it + // is the only thing that makes "my capability is refused" actionable. + tracing::debug!(%reason, "a request presented a credential that is neither a session nor a capability"); + AuthRejection::unauthenticated() + })?; + + let record = match self.capabilities().find(&grant.jti).await { + Ok(Some(record)) => record, + Ok(None) => { + tracing::info!( + peer = %grant.peer, + jti = %grant.jti, + "a capability verified under this server's key but was never issued here" + ); + return Err(AuthRejection::unauthenticated()); + } + // Fail closed, as the session ledger does. `401` is the only refusal this trait can + // render; the route-level `admit` renders the honest `500` for the same outage. + Err(error) => { + tracing::error!( + %error, + jti = %grant.jti, + "the capability store could not be read, so the request was refused closed" + ); + return Err(AuthRejection::unauthenticated()); + } + }; + if record.grant() != grant { + // The token and the record disagree about what was granted. Nothing this server + // wrote can produce that, so it is refused rather than reconciled. + tracing::error!(jti = %grant.jti, "a capability's claims do not match its record"); + return Err(AuthRejection::unauthenticated()); + } + + tracing::trace!( + peer = %grant.peer, + album = %grant.album, + jti = %grant.jti, + "a request presented a recorded federation capability" + ); + Ok(Principal::Peer(Box::new(VerifiedCapability { + grant, + record, + }))) + } + + async fn authorize( + &self, + _credential: &Principal, + _scopes: &'static [&'static str], + _context: &App, + ) -> Result<(), AuthRejection> { + // Neither token type carries OAuth-style scopes; a capability's `scope` is decided + // against a blob's role by the route. Exists because the trait requires it. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use kynos::security::SecurityScheme as _; + + use super::ReadBearer; + use crate::auth::AccessToken; + + #[test] + fn the_two_schemes_describe_one_component() { + // What keeps `components.securitySchemes` at one `bearer` entry and every operation's + // `security` unchanged: Kynos accepts a second registration under a name exactly when + // its description is byte-identical to the first. + assert_eq!(ReadBearer::NAME, AccessToken::NAME); + assert_eq!(ReadBearer::describe(), AccessToken::describe()); + assert_eq!(ReadBearer::challenge(), AccessToken::challenge()); + } +} diff --git a/capsule-server/src/federation/store.rs b/capsule-server/src/federation/store.rs new file mode 100644 index 00000000..5318ccff --- /dev/null +++ b/capsule-server/src/federation/store.rs @@ -0,0 +1,315 @@ +//! [`CapabilityStore`] — every capability this server issued, and the revocation list it +//! publishes. +//! +//! # The store is the revocation list +//! +//! `/.well-known/capsule/revoked-jti` was served from a standalone list before federation had a +//! minting side (`S-C18`). Once a capability is a stored record, "is this `jti` revoked" has a +//! second possible answer — the record's `revoked_at` — and two answers to that question is the +//! one shape revocation cannot afford. So every adapter here **is** a +//! [`RevocationList`]: revoking an issued capability sets its `revoked_at` and publishes its +//! `jti` in one critical section, and the standalone in-memory list is gone. +//! +//! [`RevocationList::revoke`] still accepts a `jti` this server never issued, and still +//! publishes it: an operator revoking a token by hand from a peer's report, or a record that +//! predates the store, is a fact the list must carry whether or not a row backs it. +//! +//! # What the record binds that the token does not +//! +//! The token names the peer, the album and the scope. The record adds the **member** the +//! capability was minted for and the **epoch** their membership was granted at +//! ([`CapabilityRecord::granted_epoch`]), so presentation can ask whether that member is still +//! on the roster at that epoch: a member removed and re-admitted later gets a fresh grant, and +//! the old capability — minted for a membership that ended — is refused without anyone having +//! revoked it. The token format is normative and parsed by every peer, which is why the epoch is +//! a stored fact rather than a claim. +//! +//! # Refresh is one operation +//! +//! [`CapabilityStore::refresh`] issues the successor, marks the predecessor as refreshed *to* +//! it, and revokes the predecessor, in one critical section. Idempotency keyed by +//! `(peer, jti)` — threat-model/validation.md — falls out of the `refreshed_to` link: a replay +//! finds the predecessor already refreshed and answers with the same successor, and two +//! concurrent refreshes of one token cannot both issue. The `peer` half of the key is the +//! credential's: only the holder of the predecessor can present it, and the store refuses a +//! successor that names another peer, album or member than the predecessor did, so the link +//! can never widen what was granted. A successor answered to a replay may itself have been +//! revoked since (a block cascades over every live capability of a peer); the route re-checks +//! [`CapabilityRecord::is_live`] before re-signing it. + +use std::fmt; + +use jiff::{SignedDuration, Timestamp}; + +use super::PeerId; +use super::capability::{CapabilityGrant, Scope}; +use crate::discovery::revocation::{MAX_TOKEN_TTL, RevocationList}; +use crate::store::{AlbumId, StoreError, StoreFuture, UserId}; + +/// One capability this server issued. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityRecord { + /// The token's `jti`, and the revocation key. + pub jti: String, + /// The album it scopes to. + pub album_id: AlbumId, + /// The peer server it was issued to. + pub peer_id: PeerId, + /// The roster member whose access it carries, as the owner listed them. + pub member: UserId, + /// What it permits. + pub scope: Scope, + /// The epoch the member's membership was granted at when this was minted. + pub granted_epoch: u64, + /// The album's pinned protocol date, carried so the grant can be re-signed. + pub min_protocol_version: String, + /// When it was minted; also its `nbf`. + pub issued_at: Timestamp, + /// When it stops being honoured. + pub expires_at: Timestamp, + /// The absolute deadline the **whole grant** dies at, chosen at the original mint. + /// + /// The token's own `expires_at` is at most 24 h out and a refresh replaces it; this is the + /// thing a refresh cannot move. Equal to `expires_at` for a grant the owner did not make + /// renewable, which is the default — see [`CapabilityRecord::may_refresh_at`]. + pub not_after: Timestamp, + /// When it was revoked, if it has been. + pub revoked_at: Option, + /// The `jti` of the successor a refresh issued, if one has. + pub refreshed_to: Option, +} + +impl CapabilityRecord { + /// Whether the capability may still be presented at `now`: unrevoked and unexpired. + pub fn is_live(&self, now: Timestamp) -> bool { + self.revoked_at.is_none() && self.expires_at > now + } + + /// Whether a successor may still be issued from this grant at `now`. + /// + /// The absolute deadline, and the whole of what stops a refresh chain. Without it a peer + /// holding a deliberate sixty-second grant refreshes inside the minute to the default TTL + /// and again forever, and the owner's chosen lifetime is advisory for exactly one hop. + /// + /// Two conditions, and the first is the default: the owner must have made the grant + /// renewable at all, and the deadline must not have passed. The last token of a renewable + /// grant is minted for exactly what is left, so it satisfies neither and answers the same + /// "this grant is over" as one that was never renewable — which is the honest answer in + /// both cases, because in both there is no successor left to have. + pub fn may_refresh_at(&self, now: Timestamp) -> bool { + self.is_renewable() && self.not_after > now + } + + /// Whether the owner made this grant renewable at all. + /// + /// `false` is the default: renewability is asked for at mint, never assumed. + pub fn is_renewable(&self) -> bool { + self.not_after > self.expires_at + } + + /// The grant this record describes, which the codec re-signs byte-for-byte. + pub fn grant(&self) -> CapabilityGrant { + CapabilityGrant { + peer: self.peer_id.clone(), + album: self.album_id.clone(), + scope: self.scope, + jti: self.jti.clone(), + issued_at: self.issued_at, + expires_at: self.expires_at, + min_protocol_version: self.min_protocol_version.clone(), + } + } +} + +/// The furthest out a grant's absolute deadline may sit from the mint that fixed it. +/// +/// Ninety days. Not a security boundary — the owner chose the date and can revoke it — but a +/// mistyped year is the one input on this surface whose blast radius is measured in years, and a +/// cap turns that into a refusal the client sees rather than a grant nobody remembers making. +/// +/// Here rather than on the route that parses `renewable_until`, and that is the whole point: the +/// route is the only caller of [`CapabilityStore::issue`] **today**, and #476's operator tooling +/// is exactly the second one. A ceiling enforced by whichever caller happens to remember it is a +/// ceiling the next caller does not have. +pub const MAX_GRANT_LIFETIME: SignedDuration = SignedDuration::from_hours(24 * 90); + +/// Refuse a record whose lifetime the published list could not stay bounded under. +/// +/// Shared by every adapter rather than re-derived in each: the 24 h ceiling and the absolute +/// deadline are properties of the *record*, and an adapter that checked them differently would +/// be an adapter that accepted a grant another one refuses. +/// +/// # Errors +/// +/// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) when the token's own +/// window is past [`MAX_TOKEN_TTL`], when it runs past the grant's absolute deadline, when that +/// deadline is further out than [`MAX_GRANT_LIFETIME`], or when its `granted_epoch` is wider +/// than every adapter can hold. +pub fn admissible(record: &CapabilityRecord) -> Result<(), StoreError> { + // The port says `u64` and the durable column is a `BIGINT`, so an epoch above `i64::MAX` is + // representable to a caller and not to one adapter. Refused here, for every adapter at once, + // rather than narrowed: a grant recorded under a different epoch than the one asked for is a + // grant that admits the wrong membership, and an in-memory store that accepted what Postgres + // refuses is the divergence class the conformance suite exists to catch. + if i64::try_from(record.granted_epoch).is_err() { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "capability {}'s granted epoch {} is wider than a stored epoch", + record.jti, record.granted_epoch + ), + }); + } + if record.expires_at.duration_since(record.issued_at) > MAX_TOKEN_TTL { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "capability {} would live past the {MAX_TOKEN_TTL} ceiling", + record.jti + ), + }); + } + if record.expires_at > record.not_after { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "capability {} expires at {}, past its grant's deadline of {}", + record.jti, record.expires_at, record.not_after + ), + }); + } + // The ceiling on the deadline itself. The route that parses `renewable_until` refuses one + // further out than this, and that is not enough: a grant is only as bounded as its *least* + // careful caller, and the whole reason these checks live in the store is that an adapter — + // or a second caller — cannot re-derive them differently. + if record.not_after.duration_since(record.issued_at) > MAX_GRANT_LIFETIME { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "capability {}'s grant would run to {}, past the {MAX_GRANT_LIFETIME} ceiling \ + from its mint at {}", + record.jti, record.not_after, record.issued_at + ), + }); + } + Ok(()) +} + +/// Refuse a successor that does not continue exactly what its predecessor granted. +/// +/// The four things a refresh may never move: the peer, the album, the member, and the absolute +/// deadline. The first three keep a refresh from *widening* a grant; the fourth keeps it from +/// *outliving* one, which is the same defect one dimension along. +/// +/// # Errors +/// +/// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) naming which of them +/// moved. Every one is a bug in the caller, never a peer's request. +pub fn continues( + predecessor: &str, + old: &CapabilityRecord, + successor: &CapabilityRecord, +) -> Result<(), StoreError> { + if successor.peer_id != old.peer_id + || successor.album_id != old.album_id + || successor.member != old.member + { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!("a successor of {predecessor} must carry its peer, album and member"), + }); + } + if successor.not_after != old.not_after { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "a successor of {predecessor} must carry its deadline of {}, not {}", + old.not_after, successor.not_after + ), + }); + } + Ok(()) +} + +/// Which live capabilities a caller wants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilityFilter { + /// Every live capability over one album — what a roster change consults. + Album(AlbumId), + /// Every live capability held by one peer — what a block cascades over. + Peer(PeerId), +} + +/// What revoking an issued capability did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevokeOutcome { + /// It was live and is now revoked and published. + Revoked, + /// It was already revoked. A retry is not a new fact. + AlreadyRevoked, + /// No capability with that `jti` was ever issued here. + Unknown, +} + +/// What a refresh did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshOutcome { + /// The successor was issued and the predecessor revoked and linked to it. + Issued(CapabilityRecord), + /// The predecessor had already been refreshed; this is the successor it links to. + AlreadyRefreshed(CapabilityRecord), + /// The predecessor was revoked without a successor, so there is nothing to continue. + Revoked, + /// No capability with the predecessor's `jti` was ever issued here. + Unknown, +} + +/// Where issued capabilities live, and the revocation list they feed. +pub trait CapabilityStore: RevocationList + fmt::Debug + Send + Sync { + /// Record a freshly minted capability. + /// + /// # Errors + /// + /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if a capability with + /// the same `jti` is already recorded — a `jti` is a fresh UUIDv7 per mint, so a collision is + /// a bug rather than a retry — or if the record would live past the TTL ceiling, which the + /// published list is bounded by. The codec clamps at mint, so the second is a bug too. + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()>; + + /// The capability `jti` names, revoked or not. + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option>; + + /// Every capability matching `filter` that is live at `now`. + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec>; + + /// Revoke the capability `jti` names at `at`, and publish its `jti`, in one operation. + /// + /// Idempotent: a second call answers [`RevokeOutcome::AlreadyRevoked`] and changes nothing. + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome>; + + /// Issue `successor` in place of the capability `predecessor` names, at `at`. + /// + /// One critical section: the successor is recorded, the predecessor's `refreshed_to` is set + /// to it, and the predecessor is revoked and published. A predecessor that has already been + /// refreshed answers [`RefreshOutcome::AlreadyRefreshed`] with the successor it links to and + /// records nothing — which is the idempotency the contract promises. + /// + /// # Errors + /// + /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if `successor` names + /// a different peer, album or member than the predecessor, **carries a different + /// `not_after` or one its own `expires_at` runs past**, or would live past the ceiling, or + /// reuses a recorded `jti`. Every one is a bug in the caller, never a peer's request — and + /// the `not_after` rule is what makes "a refresh cannot extend a grant" structural rather + /// than a property of the one route that happens to compute the successor's TTL. + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome>; +} diff --git a/capsule-server/src/index/conformance.rs b/capsule-server/src/index/conformance.rs index 3e482b77..ff87b514 100644 --- a/capsule-server/src/index/conformance.rs +++ b/capsule-server/src/index/conformance.rs @@ -916,6 +916,74 @@ pub async fn re_applying_a_manifest_is_a_replay(index: &dyn AssetIndex) { ); } +/// Two identical submissions racing produce one application and one replay. +/// +/// The window this closes is not hypothetical: an adapter that checks its idempotency store, +/// then takes the asset's lock, has read *before* the lock and decided *after* it. Two clients +/// retrying the same manifest — or one client whose first attempt is still in flight when its +/// retry timer fires — both find nothing, and the loser then serializes behind a winner that has +/// meanwhile applied the very manifest it looked for. +/// +/// Answering that loser from what it read before the lock is wrong twice over. It would fail +/// invariant 17 against a chain head the winner has just advanced, and report `StaleChain` to a +/// client whose manifest *was* applied — moments ago, by the winner — which is precisely the +/// answer `re_applying_a_manifest_is_a_replay` exists to forbid in the sequential case. +/// +/// A single-process suite cannot force a particular interleaving, so this asserts the property +/// that holds under *every* interleaving: one `Applied`, one `Replayed`, the same sequence +/// number in both, and exactly one number minted. +pub async fn racing_identical_submissions_apply_once_and_replay_once(index: &dyn AssetIndex) { + let (asset, _) = publish(index, "op-race", 1).await; + let created = head_of(index, &asset).await; + // A seed of its own. `applied_manifests` is keyed on the hash **globally**, not per asset — + // which is the point of it — so a case that reused another case's manifest would be told + // `Replayed` for a submission it had never made, and would take that for its own answer. + let hash = manifest(41); + let owner = OwnerId::new("op-race-owner"); + let before = ok(index.head_seq(&owner).await, "head"); + + let (first, second) = tokio::join!( + index.apply_op(op("op-race", 1, OpAction::Delete, created, hash)), + index.apply_op(op("op-race", 1, OpAction::Delete, created, hash)), + ); + let outcomes = [ok(first, "apply"), ok(second, "apply")]; + + let applied: Vec = outcomes + .iter() + .filter_map(|outcome| match outcome { + OpOutcome::Applied { sync_seq, .. } => Some(*sync_seq), + _ => None, + }) + .collect(); + let replayed: Vec = outcomes + .iter() + .filter_map(|outcome| match outcome { + OpOutcome::Replayed { sync_seq } => Some(*sync_seq), + _ => None, + }) + .collect(); + assert_eq!( + applied.len(), + 1, + "exactly one of two identical submissions applies, got {outcomes:?}" + ); + assert_eq!( + replayed.len(), + 1, + "the other is a replay and never a stale chain, got {outcomes:?}" + ); + assert_eq!( + applied[0], replayed[0], + "a replay reports the number the application minted" + ); + + assert_eq!( + ok(index.head_seq(&owner).await, "head"), + before.saturating_add(1), + "two identical submissions cost one sequence number, not two" + ); +} + /// A delete tombstones, a restore un-tombstones, and both reach the feed. pub async fn delete_and_restore_are_both_publishable_changes(index: &dyn AssetIndex) { let (asset, _) = publish(index, "op-cycle", 1).await; @@ -1474,6 +1542,254 @@ pub async fn holding_an_unknown_asset_is_not_found(index: &dyn AssetIndex) { ); } +// --------------------------------------------------------------------------------------- +// The scrub's walk +// --------------------------------------------------------------------------------------- + +/// Every row is walked, whatever its state, and the walk resumes where it stopped. +/// +/// The scrub's input, and it was uncovered until the Postgres adapter arrived — which is +/// exactly the shape of gap a shared suite exists to close. Two properties, and both are the +/// port's own words: +/// +/// - *"Every row, whatever its state. A scrub that skipped pending or tombstoned rows would +/// skip exactly the rows a half-finished write leaves behind."* +/// - *"an interrupted pass resumes where it stopped rather than starting over — which for a +/// store worth scrubbing is the difference between a check that finishes and one that never +/// does."* +pub async fn the_row_walk_covers_every_state_and_resumes(index: &dyn AssetIndex) { + // One row in each state, so a filter on state would drop one of them. + let pending_only = pending("walk", 1); + let unseen = pending_only.asset_id.clone(); + ok(index.reserve(pending_only).await, "reserve a pending row"); + let (visible, _) = publish(index, "walk", 2).await; + let (deleted, _) = publish(index, "walk", 3).await; + ok( + index.tombstone(&deleted, Timestamp::UNIX_EPOCH).await, + "tombstone a row", + ); + + for page_size in [1_usize, 2, 50] { + let mut cursor: Option = None; + let mut seen: Vec = Vec::new(); + loop { + let page = ok( + index.rows(cursor.as_ref(), page_size).await, + "walk the asset rows", + ); + if page.is_empty() { + break; + } + assert!( + page.len() <= page_size, + "a page of {} exceeded the requested {page_size}", + page.len() + ); + for row in &page { + if let Some(previous) = seen.last() { + assert!( + &row.asset_id > previous, + "the walk went backwards: {} came after {previous}", + row.asset_id + ); + } + seen.push(row.asset_id.clone()); + } + cursor = page.last().map(|row| row.asset_id.clone()); + } + + for expected in [&unseen, &visible, &deleted] { + assert!( + seen.contains(expected), + "the walk at page size {page_size} missed {expected}; a scrub that skips a \ + pending or tombstoned row skips exactly the rows a half-finished write leaves \ + behind" + ); + } + let mut unique = seen.clone(); + unique.dedup(); + assert_eq!( + unique.len(), + seen.len(), + "the walk at page size {page_size} returned a row twice" + ); + } +} + +/// The walk's order is the identifier's own byte order, not a locale's. +/// +/// Asset ids are the manifest's client-chosen `file_id`, so they are full of punctuation and are +/// not a shape the suite gets to pick. A backend ordering them under a locale collation — which +/// is what `en_US.utf8` does, ignoring `-` at the primary level — walks them in a different +/// order from the deterministic double, and a cursor handed between the two skips rows. The +/// three ids below are the smallest set where byte order and a punctuation-ignoring collation +/// disagree. +pub async fn the_row_walk_orders_by_the_identifiers_own_bytes(index: &dyn AssetIndex) { + let ids = ["walkord-a-b", "walkord-ab", "walkord-a-c"]; + for id in ids { + let mut row = pending("walkord", 0); + row.asset_id = AssetId::new(id); + ok(index.reserve(row).await, "reserve a row"); + } + + let walked: Vec = ok(index.rows(None, 100).await, "walk the asset rows") + .into_iter() + .filter(|row| row.asset_id.as_str().starts_with("walkord-")) + .map(|row| row.asset_id.as_str().to_owned()) + .collect(); + let mut expected: Vec = ids.iter().map(|id| (*id).to_owned()).collect(); + expected.sort(); + assert_eq!( + walked, expected, + "the walk must order asset ids by their bytes, so a cursor means the same thing to \ + every adapter" + ); +} + +/// An album page is the owner's sequence filtered to one album, with its own head (`S-C51`). +/// +/// Positions are the owner's numbers, so a member's per-album anti-rewind mark is the same value +/// the owner's feed carries; gaps are the other albums' entries. The head is the album's last +/// entry, not the owner's allocator: a member who has seen it is caught up whatever the owner +/// minted elsewhere since. +pub async fn an_album_page_is_the_owners_sequence_filtered_to_one_album(index: &dyn AssetIndex) { + let owner = OwnerId::new("albumpage-owner"); + let shared = AlbumId::new("albumpage-shared"); + let private = AlbumId::new("albumpage-private"); + let mut in_shared = Vec::new(); + for (n, album) in [(1_u32, &shared), (2, &private), (3, &shared), (4, &private)] { + let row = PendingAsset { + asset_id: AssetId::new(format!("albumpage-asset-{n}")), + owner_id: owner.clone(), + album_id: album.clone(), + protocol_version: "2026-01-01".to_owned(), + crypto_suite_id: 1, + created_at: Timestamp::UNIX_EPOCH, + }; + let asset = row.asset_id.clone(); + ok(index.reserve(row).await, "reserve a row"); + record( + index, + &asset, + blob(BlobRole::Provenance, &format!("albumpage-p{n}")), + ) + .await; + let seq = record( + index, + &asset, + blob(BlobRole::Metadata, &format!("albumpage-m{n}")), + ) + .await + .expect("landing the index tier publishes"); + if album == &shared { + in_shared.push(seq); + } + } + + let page = ok( + index.album_feed_page(&owner, &shared, 0, 10).await, + "page an album", + ); + assert_eq!( + page.iter().map(|entry| entry.sync_seq).collect::>(), + in_shared, + "the album page is the owner's sequence, filtered, in order" + ); + assert!(page.iter().all(|entry| entry.album_id == shared)); + assert_eq!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ), + in_shared[1], + "the head is the album's last entry, not the owner's allocator" + ); + assert!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ) < ok(index.head_seq(&owner).await, "read the owner's head"), + "the owner minted more in another album since" + ); + + // Resuming past the first shared entry yields exactly the second. + let resumed = ok( + index + .album_feed_page(&owner, &shared, in_shared[0], 10) + .await, + "resume an album page", + ); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].sync_seq, in_shared[1]); + // And a bounded page is bounded. + assert_eq!( + ok( + index.album_feed_page(&owner, &shared, 0, 1).await, + "page one" + ) + .len(), + 1 + ); + + // A row another account filed under the *same* album id is not this album's: the page is + // bound to the owner the album record names, which is what the index is keyed on. + let squatter = OwnerId::new("albumpage-squatter"); + let row = PendingAsset { + asset_id: AssetId::new("albumpage-asset-squat"), + owner_id: squatter.clone(), + album_id: shared.clone(), + protocol_version: "2026-01-01".to_owned(), + crypto_suite_id: 1, + created_at: Timestamp::UNIX_EPOCH, + }; + ok(index.reserve(row).await, "reserve a row"); + record( + index, + &AssetId::new("albumpage-asset-squat"), + blob(BlobRole::Provenance, "albumpage-ps"), + ) + .await; + record( + index, + &AssetId::new("albumpage-asset-squat"), + blob(BlobRole::Metadata, "albumpage-ms"), + ) + .await; + assert_eq!( + ok( + index.album_feed_page(&owner, &shared, 0, 10).await, + "page an album" + ) + .len(), + 2, + "another owner's row under the same album id is not on this owner's album page" + ); + assert_eq!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ), + in_shared[1] + ); + + // An album nothing was filed into: empty, head zero — not an error. + let unknown = AlbumId::new("albumpage-unknown"); + assert!( + ok( + index.album_feed_page(&owner, &unknown, 0, 10).await, + "page an unknown album" + ) + .is_empty() + ); + assert_eq!( + ok( + index.album_head_seq(&owner, &unknown).await, + "head of an unknown album" + ), + 0 + ); +} + pub async fn run_all(index: &dyn AssetIndex) { reserving_twice_joins_the_same_row(index).await; a_disagreeing_reservation_is_refused_without_disclosure(index).await; @@ -1486,6 +1802,7 @@ pub async fn run_all(index: &dyn AssetIndex) { the_change_kind_is_relative_to_the_reader(index).await; paging_is_ordered_bounded_and_resumable(index).await; every_minted_number_is_reachable(index).await; + an_album_page_is_the_owners_sequence_filtered_to_one_album(index).await; an_albums_numbers_are_monotonic_with_gaps(index).await; a_tombstone_reaches_every_reader(index).await; tombstoning_a_pending_row_publishes_nothing(index).await; @@ -1496,6 +1813,7 @@ pub async fn run_all(index: &dyn AssetIndex) { a_live_holder_outranks_a_deleted_one(index).await; a_lifecycle_write_extends_the_chain(index).await; re_applying_a_manifest_is_a_replay(index).await; + racing_identical_submissions_apply_once_and_replay_once(index).await; delete_and_restore_are_both_publishable_changes(index).await; an_epoch_that_regresses_the_album_is_refused(index).await; an_op_on_an_asset_that_is_not_the_callers_is_not_found(index).await; @@ -1507,6 +1825,8 @@ pub async fn run_all(index: &dyn AssetIndex) { a_restore_clears_the_retention_floor(index).await; a_serving_hold_is_placed_reported_and_lifted(index).await; holding_an_unknown_asset_is_not_found(index).await; + the_row_walk_covers_every_state_and_resumes(index).await; + the_row_walk_orders_by_the_identifiers_own_bytes(index).await; } #[cfg(test)] diff --git a/capsule-server/src/index/memory.rs b/capsule-server/src/index/memory.rs index facc950e..924cf9d4 100644 --- a/capsule-server/src/index/memory.rs +++ b/capsule-server/src/index/memory.rs @@ -19,7 +19,7 @@ use jiff::Timestamp; use super::{ AssetIndex, AssetRow, AssetState, BlobOutcome, BlobRecord, BlobRef, FeedEntry, HoldOutcome, IndexFuture, LifecycleOp, OpAction, OpOutcome, PendingAsset, Reservation, ServingHold, - entry_for, + entry_for, is_singular, set_singular, }; use crate::blob::ContentAddress; use crate::store::{AlbumId, AssetId, BlobRole, OwnerId}; @@ -30,47 +30,6 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } -/// The roles an asset may hold exactly one of. -/// -/// The manifest, the metadata blob and the original are each named by the signed manifest, so -/// a second address under one of these roles is a contradiction rather than an addition. -/// Derivatives and backups are plural by nature — an asset has a thumbnail *and* a preview. -fn is_singular(role: BlobRole) -> bool { - matches!( - role, - BlobRole::Original | BlobRole::Metadata | BlobRole::Provenance - ) -} - -/// Point `role` at `address`, replacing whatever it held. -/// -/// The one place a singular role legitimately moves. [`AssetIndex::record_blob`] refuses to -/// re-point one because an upload doing so would swap bytes under a signature that still -/// verifies against the old ones; a lifecycle op is the *authorized* form of the same change, -/// and it arrives with a manifest chaining onto the one it supersedes. -fn set_singular(row: &mut AssetRow, role: BlobRole, address: &ContentAddress) { - // `S-C52`: a superseded *manifest* is kept referenced rather than dropped. Only the - // provenance role — the other singular roles are ciphertext, and the old bytes of a replaced - // original are exactly what the collector is for. - if role == BlobRole::Provenance - && let Some(previous) = row.address_for(BlobRole::Provenance).cloned() - && &previous != address - && !row.superseded.contains(&previous) - { - row.superseded.push(previous); - } - row.blobs.retain(|blob| blob.role != role); - row.blobs.push(BlobRef { - role, - address: address.clone(), - // Size is not a fact this path learns: the bytes were stored by whoever put them in the - // blob store, and re-`stat`ing here would make the index depend on the store. - size: 0, - }); - row.blobs - .sort_by(|a, b| (a.role, a.address.as_str()).cmp(&(b.role, b.address.as_str()))); -} - /// Everything the double holds, behind one lock. /// /// One lock rather than two maps with two locks, because the sequence counter and the row it @@ -309,6 +268,7 @@ impl AssetIndex for InMemoryAssetIndex { let holds = |row: &&AssetRow| row.blobs.iter().any(|blob| &blob.address == address); let reference = |row: &AssetRow| super::BlobReference { asset_id: row.asset_id.clone(), + album_id: row.album_id.clone(), owner_id: row.owner_id.clone(), role: row .blobs @@ -582,4 +542,42 @@ impl AssetIndex for InMemoryAssetIndex { fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { Box::pin(async move { Ok(lock(&self.inner).minted.get(owner).copied().unwrap_or(0)) }) } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + let inner = lock(&self.inner); + let mut page: Vec = inner + .rows + .values() + .filter(|row| &row.owner_id == owner && &row.album_id == album) + .filter(|row| row.sync_seq.is_some_and(|seq| seq > after)) + .filter_map(|row| entry_for(row, after)) + .collect(); + page.sort_by_key(|entry| entry.sync_seq); + page.truncate(limit); + Ok(page) + }) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + Box::pin(async move { + Ok(lock(&self.inner) + .rows + .values() + .filter(|row| &row.owner_id == owner && &row.album_id == album) + .filter_map(|row| row.sync_seq) + .max() + .unwrap_or(0)) + }) + } } diff --git a/capsule-server/src/index/mod.rs b/capsule-server/src/index/mod.rs index 5e5f5df1..5ed9c596 100644 --- a/capsule-server/src/index/mod.rs +++ b/capsule-server/src/index/mod.rs @@ -59,6 +59,7 @@ pub mod conformance; pub mod memory; +pub mod postgres; use capsule_core::crypto::hash::Hash32; use jiff::Timestamp; @@ -388,6 +389,11 @@ impl ChangeKind { pub struct BlobReference { /// The asset the reference belongs to. pub asset_id: AssetId, + /// The album that asset belongs to (`S-C51`). + /// + /// What the read authority asks the membership store about, from the same read that found + /// the reference, for the reason [`Self::owner_id`] rides here. + pub album_id: AlbumId, /// The account that asset is filed under (`S-C39`). /// /// The fact the read authority decides on. Carried on the reference for the same reason @@ -711,6 +717,80 @@ pub trait AssetIndex: std::fmt::Debug + Send + Sync { /// What lets a page report whether the client is caught up without asking for another page /// that would come back empty. fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64>; + + /// Up to `limit` feed entries in `album` after sequence number `after`, in sequence order + /// (`S-C51`). + /// + /// The **owner's** sequence, filtered to one album: positions are the same numbers the + /// owner's own feed carries, so they are per-album monotonic exactly as the client's + /// anti-rewind mark requires, and gaps are the other albums' entries. A member of the album + /// reads this page; the route decides who is one, and hands over the album's owner from + /// the album record so the query is bound to the rows that owner filed — the `(owner, + /// album)` pair is what the index is keyed on, and a row another account filed under the + /// same album id is not this album's. + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec>; + + /// The highest sequence number any entry in `album` carries, or `0` for none (`S-C51`). + /// + /// The album page's caught-up mark. Not the owner's allocator: a member who has seen the + /// album's last entry is caught up whatever the owner has minted in other albums since. + fn album_head_seq<'a>(&'a self, owner: &'a OwnerId, album: &'a AlbumId) + -> IndexFuture<'a, u64>; +} + +/// The roles an asset may hold exactly one of. +/// +/// The manifest, the metadata blob and the original are each named by the signed manifest, so a +/// second address under one of these roles is a contradiction rather than an addition. +/// Derivatives and backups are plural by nature — an asset has a thumbnail *and* a preview. +/// +/// Free function beside [`entry_for`] rather than a method on an adapter, and for the same +/// reason: two adapters that disagreed about which roles are singular would disagree about when +/// [`BlobOutcome::Conflict`] is the answer, which is a security property (`record_blob` refuses +/// to re-point a role a signature names) and not a formatting detail. +pub(crate) fn is_singular(role: BlobRole) -> bool { + matches!( + role, + BlobRole::Original | BlobRole::Metadata | BlobRole::Provenance + ) +} + +/// Point `role` at `address`, replacing whatever it held. +/// +/// The one place a singular role legitimately moves. [`AssetIndex::record_blob`] refuses to +/// re-point one because an upload doing so would swap bytes under a signature that still +/// verifies against the old ones; a lifecycle op is the *authorized* form of the same change, +/// and it arrives with a manifest chaining onto the one it supersedes. +/// +/// Shared by every adapter for the reason [`entry_for`] is: the `S-C52` retention rule below — +/// a superseded *manifest* is kept referenced rather than dropped — is exactly the kind of thing +/// two implementations drift on, and the drift reclaims the server's own rebuttal evidence. +pub(crate) fn set_singular(row: &mut AssetRow, role: BlobRole, address: &ContentAddress) { + // `S-C52`: only the provenance role. The other singular roles are ciphertext, and the old + // bytes of a replaced original are exactly what the collector is for. + if role == BlobRole::Provenance + && let Some(previous) = row.address_for(BlobRole::Provenance).cloned() + && &previous != address + && !row.superseded.contains(&previous) + { + row.superseded.push(previous); + } + row.blobs.retain(|blob| blob.role != role); + row.blobs.push(BlobRef { + role, + address: address.clone(), + // Size is not a fact this path learns: the bytes were stored by whoever put them in the + // blob store, and re-`stat`ing here would make the index depend on the store. + size: 0, + }); + row.blobs + .sort_by(|a, b| (a.role, a.address.as_str()).cmp(&(b.role, b.address.as_str()))); } /// Build the feed entry a row presents to a reader sitting at `after`. diff --git a/capsule-server/src/index/postgres.rs b/capsule-server/src/index/postgres.rs new file mode 100644 index 00000000..03305311 --- /dev/null +++ b/capsule-server/src/index/postgres.rs @@ -0,0 +1,1291 @@ +//! [`PostgresAssetIndex`] — the durable asset index (`S-C37`, #402). +//! +//! # The one thing this adapter exists to get right +//! +//! A sequence number is allocated **inside the same critical section that makes its row +//! readable**. The in-memory double gets that from holding one mutex across both writes; this +//! gets it from a row lock held to commit, which is the structure `index/mod.rs` designed the +//! port around and the one a single-process conformance suite cannot prove. Every mutating +//! operation here is one `BEGIN … COMMIT` that takes `SELECT … FOR UPDATE` on the asset row +//! first, mints from `owner_sequences` inside it, and commits both together. +//! +//! `owner_sequences` is a **counter row**, never a Postgres `SEQUENCE` or a `bigserial`. +//! `nextval` is deliberately non-transactional: it hands 5 and 6 to two concurrent +//! finalizations and does not roll back, so a reader who sees 6 commit first can page past 5 +//! forever. `index/mod.rs` names that as the whole of `S-C21`, and a counter row updated by the +//! allocating transaction makes allocation order equal commit order. +//! +//! # Why the mutations are written in Rust rather than in SQL +//! +//! Each write hydrates the whole [`AssetRow`] under the lock, applies the same free functions +//! the in-memory adapter applies (`is_singular`, `set_singular`, +//! [`AssetRow::is_publishable`]), and writes the row back before committing. The alternative — +//! expressing the state machine as a chain of `UPDATE … WHERE` statements — would be a *second* +//! statement of rules the port already fixes, in a language where the conformance suite cannot +//! see it. The lock is held for the length of one small in-memory computation, and the rules +//! that decide what a row becomes stay in one place for both adapters. +//! +//! # What is a query and never a stored number +//! +//! [`AssetIndex::reference_count`] is a `COUNT(*)`. design/filesystem/server.md fixes that: a +//! blob's reference count is derived from the rows that name it, because a counter is a second +//! copy of a derivable fact and a counter that drifts low deletes a live blob. Superseded +//! manifests count (`S-C52`), or the collector reclaims the server's own rebuttal evidence. + +use capsule_core::crypto::hash::Hash32; +use jiff::Timestamp; +use sea_orm::{ + AccessMode, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, + IsolationLevel, Statement, TransactionTrait, Value, +}; + +use super::{ + AssetIndex, AssetRow, AssetState, BlobOutcome, BlobRecord, BlobRef, BlobReference, FeedEntry, + HoldOutcome, IndexFuture, LifecycleOp, OpAction, OpOutcome, PendingAsset, Reservation, + ServingHold, entry_for, is_singular, set_singular, +}; +use crate::blob::ContentAddress; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, stored, to_micros}; +use crate::store::{AlbumId, AssetId, BlobRole, OwnerId, StoreError}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "asset-index", + record: "AssetRow", +}; + +/// The durable asset index. +#[derive(Debug, Clone)] +pub struct PostgresAssetIndex { + connection: DatabaseConnection, +} + +impl PostgresAssetIndex { + /// An index over `connection`. + /// + /// The schema is **not** applied here: `capsule-server` cannot link the migrator, and + /// `postgres::assert_schema_current` is what refuses to boot against a database that has not + /// been migrated. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +// ------------------------------------------------------------------------------------------- +// Column encodings +// +// Every enum crosses the boundary as its own stable wire token rather than as an ordinal, for +// the reason the tokens exist at all: an ordinal is a number whose meaning lives in the order of +// a Rust `enum`, and inserting a variant would silently re-label every stored row. +// ------------------------------------------------------------------------------------------- + +/// The token an [`AssetState`] is stored as. +fn state_token(state: AssetState) -> &'static str { + match state { + AssetState::Pending => "pending", + AssetState::Visible => "visible", + AssetState::Tombstoned => "tombstoned", + } +} + +/// Read a stored state back, or say the row is undecodable. +fn state_from(token: &str) -> Result { + match token { + "pending" => Ok(AssetState::Pending), + "visible" => Ok(AssetState::Visible), + "tombstoned" => Ok(AssetState::Tombstoned), + other => Err(PORT.undecodable(format!("`{other}` is not an asset state"))), + } +} + +/// Read a stored hold back. +fn hold_from(token: Option) -> Result, StoreError> { + match token.as_deref() { + None => Ok(None), + Some("takedown") => Ok(Some(ServingHold::Takedown)), + Some("legal_hold") => Ok(Some(ServingHold::LegalHold)), + Some(other) => Err(PORT.undecodable(format!("`{other}` is not a serving hold"))), + } +} + +/// Read a stored blob role back. +fn role_from(token: &str) -> Result { + match token { + "original" => Ok(BlobRole::Original), + "derivative" => Ok(BlobRole::Derivative), + "metadata" => Ok(BlobRole::Metadata), + "provenance" => Ok(BlobRole::Provenance), + "backup" => Ok(BlobRole::Backup), + other => Err(PORT.undecodable(format!("`{other}` is not a blob role"))), + } +} + +/// Read a stored content address back. +fn address_from(text: &str) -> Result { + ContentAddress::parse(text) + .map_err(|error| PORT.undecodable(format!("a stored address is malformed ({error})"))) +} + +/// Read a stored instant back. +fn instant_from(micros: i64) -> Result { + from_micros(micros) + .ok_or_else(|| PORT.undecodable(format!("{micros}µs is not a representable instant"))) +} + +/// Read a stored chain head back. +fn hash_from(bytes: Option>) -> Result, StoreError> { + let Some(bytes) = bytes else { return Ok(None) }; + let sized: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + PORT.undecodable(format!( + "a stored chain head is {} bytes rather than 32", + bytes.len() + )) + })?; + Ok(Some(Hash32::from_bytes(sized))) +} + +/// A sequence number as the port speaks it. +/// +/// Sequence numbers are `u64` above this boundary and `BIGINT` below it. The conversion is +/// fallible in exactly one direction, and a negative one in the column is a corrupt row rather +/// than a number to clamp. +fn sequence_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a sequence number"))) +} + +/// A byte count as the column holds it. +fn size_to_column(size: u64) -> Result { + i64::try_from(size).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{size} bytes is past what a BIGINT column holds"), + }) +} + +/// A byte count as the port speaks it. +fn size_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a byte count"))) +} + +// ------------------------------------------------------------------------------------------- +// Reading a row back +// ------------------------------------------------------------------------------------------- + +/// Every column of `assets`, in the order every `SELECT` below lists them. +const ASSET_COLUMNS: &str = "asset_id, owner_id, album_id, protocol_version, crypto_suite_id, \ + state, hold, sync_seq, first_seq, chain_head, amk_version, \ + retention_until, created_at, updated_at"; + +/// Turn one `assets` row into an [`AssetRow`] with empty collections. +/// +/// The blobs and the superseded chain are loaded separately, so a page of rows costs three +/// queries rather than three per row. +fn asset_without_collections(row: &sea_orm::QueryResult) -> Result { + let column = PORT.failing("reading an asset row"); + let asset_id: String = row.try_get("", "asset_id").map_err(&column)?; + let owner_id: String = row.try_get("", "owner_id").map_err(&column)?; + let album_id: String = row.try_get("", "album_id").map_err(&column)?; + let protocol_version: String = row.try_get("", "protocol_version").map_err(&column)?; + let crypto_suite_id: i32 = row.try_get("", "crypto_suite_id").map_err(&column)?; + let state: String = row.try_get("", "state").map_err(&column)?; + let hold: Option = row.try_get("", "hold").map_err(&column)?; + let sync_seq: Option = row.try_get("", "sync_seq").map_err(&column)?; + let first_seq: Option = row.try_get("", "first_seq").map_err(&column)?; + let chain_head: Option> = row.try_get("", "chain_head").map_err(&column)?; + let amk_version: i64 = row.try_get("", "amk_version").map_err(&column)?; + let retention_until: Option = row.try_get("", "retention_until").map_err(&column)?; + let created_at: i64 = row.try_get("", "created_at").map_err(&column)?; + let updated_at: i64 = row.try_get("", "updated_at").map_err(&column)?; + + Ok(AssetRow { + asset_id: AssetId::new(asset_id), + owner_id: OwnerId::new(owner_id), + album_id: AlbumId::new(album_id), + protocol_version, + crypto_suite_id: u16::try_from(crypto_suite_id) + .map_err(|_| PORT.undecodable(format!("{crypto_suite_id} is not a crypto suite id")))?, + state: state_from(&state)?, + blobs: Vec::new(), + first_seq: first_seq.map(sequence_from).transpose()?, + sync_seq: sync_seq.map(sequence_from).transpose()?, + chain_head: hash_from(chain_head)?, + amk_version: sequence_from(amk_version)?, + superseded: Vec::new(), + hold: hold_from(hold)?, + retention_until: retention_until.map(instant_from).transpose()?, + created_at: instant_from(created_at)?, + updated_at: instant_from(updated_at)?, + }) +} + +/// Fill in `row`'s blobs and superseded chain. +async fn load_collections( + connection: &C, + row: &mut AssetRow, +) -> Result<(), StoreError> { + let key = Value::from(row.asset_id.as_str().to_owned()); + + let blobs = connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT role, address, size FROM asset_blobs WHERE asset_id = $1", + [key.clone()], + )) + .await + .map_err(PORT.failing("reading an asset's blobs"))?; + for blob in &blobs { + let role: String = blob + .try_get("", "role") + .map_err(PORT.failing("reading a blob role"))?; + let address: String = blob + .try_get("", "address") + .map_err(PORT.failing("reading a blob address"))?; + let size: i64 = blob + .try_get("", "size") + .map_err(PORT.failing("reading a blob size"))?; + row.blobs.push(BlobRef { + role: role_from(&role)?, + address: address_from(&address)?, + size: size_from(size)?, + }); + } + // The port contracts role-then-address order and a `Vec` will not sort itself, so two + // adapters that accepted the same blobs hold the same row. + row.blobs.sort(); + + let superseded = connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT address FROM asset_superseded WHERE asset_id = $1 ORDER BY position", + [key], + )) + .await + .map_err(PORT.failing("reading an asset's superseded manifests"))?; + for held in &superseded { + let address: String = held + .try_get("", "address") + .map_err(PORT.failing("reading a superseded address"))?; + row.superseded.push(address_from(&address)?); + } + + Ok(()) +} + +/// The whole row for `asset`, or `None`. +async fn hydrate( + connection: &C, + asset: &AssetId, +) -> Result, StoreError> { + let found = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!("SELECT {ASSET_COLUMNS} FROM assets WHERE asset_id = $1"), + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an asset row"))?; + let Some(found) = found else { return Ok(None) }; + let mut row = asset_without_collections(&found)?; + load_collections(connection, &mut row).await?; + Ok(Some(row)) +} + +/// The whole row for `asset`, with its row locked until the transaction commits. +/// +/// `FOR UPDATE` is what makes the sequence mint below it commit-ordered: two finalizations for +/// one asset serialize here, and the number the second one gets is allocated after the first has +/// committed rather than beside it. +async fn hydrate_locked( + transaction: &DatabaseTransaction, + asset: &AssetId, +) -> Result, StoreError> { + let found = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!("SELECT {ASSET_COLUMNS} FROM assets WHERE asset_id = $1 FOR UPDATE"), + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("locking an asset row"))?; + let Some(found) = found else { return Ok(None) }; + let mut row = asset_without_collections(&found)?; + load_collections(transaction, &mut row).await?; + Ok(Some(row)) +} + +/// Hydrate each of `rows`' collections, in order. +async fn load_all_collections( + connection: &C, + rows: &mut [AssetRow], +) -> Result<(), StoreError> { + for row in rows { + load_collections(connection, row).await?; + } + Ok(()) +} + +// ------------------------------------------------------------------------------------------- +// Writing a row back +// ------------------------------------------------------------------------------------------- + +/// Allocate `owner`'s next sequence number, inside the caller's transaction. +/// +/// The upsert is the allocation: the row is created at 1 on an owner's first publication and +/// incremented under the transaction's lock afterwards, so allocation order is commit order and +/// the skip window a `SEQUENCE` produces is not expressible. Numbers start at 1 so that a fresh +/// client's cursor and "I have seen nothing" are the same value. +async fn mint(transaction: &DatabaseTransaction, owner: &OwnerId) -> Result { + let minted = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO owner_sequences (owner_id, next_seq) VALUES ($1, 1) \ + ON CONFLICT (owner_id) DO UPDATE SET next_seq = owner_sequences.next_seq + 1 \ + RETURNING next_seq", + [Value::from(owner.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("allocating a sequence number"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the sequence allocation returned no row".to_owned(), + })?; + let next: i64 = minted + .try_get("", "next_seq") + .map_err(PORT.failing("reading an allocated sequence number"))?; + sequence_from(next) +} + +/// Write `row`'s mutable columns, blobs and superseded chain back. +/// +/// Whole-collection replacement rather than a diff, and deliberately: the row is locked, the +/// collections are a handful of entries, and a diff would be a second description of what the +/// mutation did — one that can disagree with the row the caller is about to return. +async fn persist(transaction: &DatabaseTransaction, row: &AssetRow) -> Result<(), StoreError> { + let asset = Value::from(row.asset_id.as_str().to_owned()); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE assets SET state = $2, hold = $3, sync_seq = $4, first_seq = $5, \ + chain_head = $6, amk_version = $7, retention_until = $8, updated_at = $9 \ + WHERE asset_id = $1", + [ + asset.clone(), + Value::from(state_token(row.state).to_owned()), + Value::from(row.hold.map(|hold| hold.as_str().to_owned())), + Value::from(row.sync_seq.map(|seq| seq as i64)), + Value::from(row.first_seq.map(|seq| seq as i64)), + Value::from(row.chain_head.map(|head| head.as_bytes().to_vec())), + Value::from(row.amk_version as i64), + Value::from(row.retention_until.map(to_micros)), + Value::from(to_micros(row.updated_at)), + ], + )) + .await + .map_err(PORT.failing("updating an asset row"))?; + + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM asset_blobs WHERE asset_id = $1", + [asset.clone()], + )) + .await + .map_err(PORT.failing("clearing an asset's blobs"))?; + for blob in &row.blobs { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO asset_blobs (asset_id, role, address, size) VALUES ($1, $2, $3, $4)", + [ + asset.clone(), + Value::from(blob.role.as_str().to_owned()), + Value::from(blob.address.as_str().to_owned()), + Value::from(size_to_column(blob.size)?), + ], + )) + .await + .map_err(PORT.failing("recording an asset's blob"))?; + } + + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM asset_superseded WHERE asset_id = $1", + [asset.clone()], + )) + .await + .map_err(PORT.failing("clearing an asset's superseded manifests"))?; + for (position, address) in row.superseded.iter().enumerate() { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO asset_superseded (asset_id, position, address) VALUES ($1, $2, $3)", + [ + asset.clone(), + Value::from(position as i64), + Value::from(address.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("recording a superseded manifest"))?; + } + + Ok(()) +} + +/// The sequence number `op`'s manifest was applied at, if it already has been. +/// +/// The whole idempotency store: a replay needs the number the first application minted, and +/// everything else in the response is derivable from the manifest itself. Called **twice** by +/// [`AssetIndex::apply_op`] — once before the row lock and once after — and the second call is +/// the one that is load-bearing; see the comment there. +async fn applied_sequence( + transaction: &DatabaseTransaction, + op: &LifecycleOp, +) -> Result, StoreError> { + let found = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT sync_seq FROM applied_manifests WHERE manifest_hash = $1", + [Value::from(op.manifest_hash.as_bytes().to_vec())], + )) + .await + .map_err(PORT.failing("checking whether a manifest was already applied"))?; + let Some(found) = found else { return Ok(None) }; + let sync_seq: i64 = found + .try_get("", "sync_seq") + .map_err(PORT.failing("reading a replayed sequence number"))?; + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + "a lifecycle write was replayed; nothing was written" + ); + Ok(Some(sequence_from(sync_seq)?)) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Begin a read-only transaction whose statements all see **one** snapshot. +/// +/// Every read here answers from more than one statement: an asset row, then its blobs, then the +/// manifests its chain has moved past. Under PostgreSQL's default `READ COMMITTED` each of those +/// takes a *fresh* snapshot, so a concurrent finalization landing between them yields a row +/// nobody ever held — the clearest case being +/// [`AssetIndex::find_reference`](super::AssetIndex::find_reference), where `state` and `hold` +/// decide whether bytes are served and `role` and `original_held` come from the blob rows. A +/// takedown applied between the two statements would produce a reference that says "no hold" +/// about an asset that has one. +/// +/// `REPEATABLE READ` is what actually fixes that; wrapping the statements in a default +/// transaction would look like a fix and change nothing. `ReadOnly` is not decoration either — +/// it makes "this path does not write" a property the database enforces rather than one the +/// reader has to confirm by reading every statement. +async fn begin_read_snapshot( + connection: &DatabaseConnection, +) -> Result { + connection + .begin_with_config( + Some(IsolationLevel::RepeatableRead), + Some(AccessMode::ReadOnly), + ) + .await + .map_err(PORT.failing("opening a read snapshot")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +impl AssetIndex for PostgresAssetIndex { + fn reserve(&self, asset: PendingAsset) -> IndexFuture<'_, Reservation> { + Box::pin(async move { + // `ON CONFLICT DO NOTHING` rather than a read followed by a write: two sessions of + // one bundle reserve unconditionally at creation, so this is the *normal* path and + // a read-then-write would let both believe they created the row. + // + // **`RETURNING`, and one transaction around the whole thing.** The row a + // `Reservation::Created` carries is the row the `INSERT` wrote, read out of the + // statement that wrote it — so it cannot be a *later* state of that asset that a + // concurrent finalization has already moved on. Reading it back separately would + // make `Created` mean "created, and here is whatever it looks like now", which is a + // different and weaker claim; the conformance suite asserts + // `created.state == Pending` and `created.sync_seq.is_none()`, and both of those are + // properties of the moment of creation rather than of the present. + let transaction = begin(&self.connection).await?; + let created = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "INSERT INTO assets (asset_id, owner_id, album_id, protocol_version, \ + crypto_suite_id, state, amk_version, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6, $6) \ + ON CONFLICT (asset_id) DO NOTHING \ + RETURNING {ASSET_COLUMNS}" + ), + [ + Value::from(asset.asset_id.as_str().to_owned()), + Value::from(asset.owner_id.as_str().to_owned()), + Value::from(asset.album_id.as_str().to_owned()), + Value::from(asset.protocol_version.clone()), + Value::from(i32::from(asset.crypto_suite_id)), + Value::from(to_micros(asset.created_at)), + ], + )) + .await + .map_err(PORT.failing("reserving an asset row"))?; + + if let Some(created) = created { + // A row that has just been inserted holds no blobs and no superseded manifests, + // by construction — so there is nothing to load, and querying for it would be a + // round trip to be told what the `INSERT` already fixed. + let row = asset_without_collections(&created)?; + commit(transaction).await?; + tracing::debug!(asset = %asset.asset_id, "reserved a pending asset row"); + return Ok(Reservation::Created(Box::new(row))); + } + + // The insert conflicted, so a row exists. Read it inside the same transaction, so + // the row this compares against is the row it returns. + let existing = hydrate(&transaction, &asset.asset_id).await?; + commit(transaction).await?; + let Some(existing) = existing else { + // Kept as a defensive refusal rather than deleted, and it is worth saying which: + // nothing in this crate removes an `assets` row — `purge` clears a row's blob + // references and keeps the tombstone deliberately — so an insert that conflicted + // against a row a `SELECT` in the same transaction cannot then see is a database + // this server does not understand. `Rejected` says whether state changed is + // unknown, which is exactly right, and it beats an `expect` in a path a client + // can reach. + return Err(StoreError::Rejected { + store: PORT.store, + detail: "an asset id conflicted with a row that could not then be read" + .to_owned(), + }); + }; + + let agrees = existing.owner_id == asset.owner_id + && existing.album_id == asset.album_id + && existing.protocol_version == asset.protocol_version + && existing.crypto_suite_id == asset.crypto_suite_id; + Ok(if agrees { + Reservation::Joined(Box::new(existing)) + } else { + // Carries nothing: the caller is by definition not the party the row belongs + // to, and the asset id is client-chosen. + Reservation::Conflict + }) + }) + } + + fn read<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + Box::pin(async move { + // One snapshot: the row, its blobs and its superseded chain are one fact, and three + // statements at `READ COMMITTED` can return three different moments of it. + let snapshot = begin_read_snapshot(&self.connection).await?; + let row = hydrate(&snapshot, asset).await?; + commit(snapshot).await?; + Ok(row) + }) + } + + fn record_blob<'a>( + &'a self, + asset: &'a AssetId, + blob: BlobRecord, + ) -> IndexFuture<'a, BlobOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(row) = hydrate_locked(&transaction, asset).await? else { + return Ok(BlobOutcome::NotFound); + }; + + if row + .blobs + .iter() + .any(|held| held.role == blob.role && held.address == blob.address) + { + // A retried finalization. Idempotent by address rather than by role, so a + // genuine retry is free. + return Ok(BlobOutcome::AlreadyHeld(Box::new(row))); + } + if is_singular(blob.role) && row.blobs.iter().any(|held| held.role == blob.role) { + // Refused rather than overwritten: an upload that re-pointed a singular role + // would swap bytes under a signature that still verifies against the old ones. + return Ok(BlobOutcome::Conflict); + } + + let mut row = row; + row.blobs.push(BlobRef { + role: blob.role, + address: blob.address, + size: blob.size, + }); + row.blobs.sort(); + // Through `stored`, because this row is returned to the caller without being read + // back: a `BIGINT` of microseconds cannot carry the nanoseconds `Timestamp` does, so + // the untruncated value would differ from what the next `read` produces. + row.updated_at = stored(blob.finalized_at); + + // The create's provenance blob is the asset's first accepted manifest, so it is the + // chain the first lifecycle op must name (invariant 17). Set from the record's own + // `manifest_sha256` and never from the content address (`S-C31`). + if blob.role == BlobRole::Provenance + && row.chain_head.is_none() + && let Some(manifest_sha256) = blob.manifest_sha256 + { + row.chain_head = Some(manifest_sha256); + } + + let minted = if row.state == AssetState::Tombstoned { + // A late blob for a deleted asset is stored — the bytes exist and GC must see + // the reference — but publishes nothing. + None + } else if row.is_publishable() { + let seq = mint(&transaction, &row.owner_id).await?; + row.state = AssetState::Visible; + row.sync_seq = Some(seq); + row.first_seq = Some(row.first_seq.unwrap_or(seq)); + Some(seq) + } else { + None + }; + + persist(&transaction, &row).await?; + commit(transaction).await?; + if let Some(seq) = minted { + tracing::info!(%asset, sync_seq = seq, "an asset became visible on its owner's feed"); + } + Ok(BlobOutcome::Recorded { + row: Box::new(row), + minted, + }) + }) + } + + fn tombstone<'a>( + &'a self, + asset: &'a AssetId, + at: Timestamp, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(mut row) = hydrate_locked(&transaction, asset).await? else { + return Ok(None); + }; + if row.state == AssetState::Tombstoned { + return Ok(Some(row)); + } + + // A row nobody could see needs no retraction, so it takes no sequence number. It + // still becomes terminal, so its id cannot be reserved back into life. + let was_published = row.sync_seq.is_some(); + row.state = AssetState::Tombstoned; + row.updated_at = stored(at); + if was_published { + row.sync_seq = Some(mint(&transaction, &row.owner_id).await?); + } + persist(&transaction, &row).await?; + commit(transaction).await?; + tracing::info!(%asset, published = was_published, "an asset was tombstoned"); + Ok(Some(row)) + }) + } + + fn find_by_address<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + // Both scopes are load-bearing and for different reasons — owner is the disclosure + // boundary, album is the merge contract. Ordered by asset id so the answer does not + // depend on physical row order. + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT a.asset_id FROM assets a \ + JOIN asset_blobs b ON b.asset_id = a.asset_id \ + WHERE a.owner_id = $1 AND a.album_id = $2 AND b.address = $3 \ + AND a.state <> 'tombstoned' \ + ORDER BY a.asset_id COLLATE \"C\" LIMIT 1", + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + Value::from(address.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("looking an address up in an album"))?; + let Some(found) = found else { return Ok(None) }; + let asset_id: String = found + .try_get("", "asset_id") + .map_err(PORT.failing("reading an asset id"))?; + Ok(Some(AssetId::new(asset_id))) + }) + } + + fn find_reference<'a>( + &'a self, + address: &'a ContentAddress, + ) -> IndexFuture<'a, Option> { + Box::pin(async move { + // Two statements rather than one ordered query: a **visible** reference outranks a + // tombstoned one, and a pending row is not a reference at all. Content addressing + // means two assets share a thumbnail, so deleting one must not take the other's + // bytes with it. + // + // All of it inside one snapshot. This is the read the serving path decides a + // takedown from, and `BlobReference`'s own docs say the decision has to come from + // the *same read* that found the reference — "one round trip, and no window in which + // a hold applied between the two reads is missed". At `READ COMMITTED` the row query + // and the blob query are two reads with exactly that window between them. + let snapshot = begin_read_snapshot(&self.connection).await?; + for state in ["visible", "tombstoned"] { + let found = snapshot + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets a \ + WHERE a.state = $2 AND EXISTS ( \ + SELECT 1 FROM asset_blobs b \ + WHERE b.asset_id = a.asset_id AND b.address = $1) \ + ORDER BY a.asset_id COLLATE \"C\" LIMIT 1" + ), + [ + Value::from(address.as_str().to_owned()), + Value::from(state.to_owned()), + ], + )) + .await + .map_err(PORT.failing("looking an address up for the serving path"))?; + let Some(found) = found else { continue }; + let mut row = asset_without_collections(&found)?; + load_collections(&snapshot, &mut row).await?; + let reference = BlobReference { + asset_id: row.asset_id.clone(), + album_id: row.album_id.clone(), + owner_id: row.owner_id.clone(), + role: row + .blobs + .iter() + .find(|blob| &blob.address == address) + .map_or(BlobRole::Original, |blob| blob.role), + state: row.state, + original_held: row.original_held(), + hold: row.hold, + }; + commit(snapshot).await?; + return Ok(Some(reference)); + } + commit(snapshot).await?; + Ok(None) + }) + } + + fn apply_op(&self, op: LifecycleOp) -> IndexFuture<'_, OpOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + + // Idempotency first, before any invariant: a byte-identical resubmission of an + // already-applied op is not a stale chain, it is the same op arriving twice. + // Checking 17 first would answer `409` to a client whose only fault was losing an + // acknowledgement. + if let Some(sync_seq) = applied_sequence(&transaction, &op).await? { + return Ok(OpOutcome::Replayed { sync_seq }); + } + + let Some(row) = hydrate_locked(&transaction, &op.asset_id).await? else { + return Ok(OpOutcome::NotFound); + }; + + // **And again, now that the asset row is locked.** The first look is an optimisation + // — it answers a leisurely retry without taking a lock — and on its own it is a + // read-then-decide with a window in it: two identical submissions racing both find + // nothing, and the loser then serializes on `FOR UPDATE` behind a winner that has + // meanwhile inserted the very row it looked for. Answering that loser from the state + // it read before the lock produces the wrong outcome twice over — it would fail + // invariant 17 against a chain head the winner has just advanced, and report + // `StaleChain` to a client whose manifest *was* applied, by the winner, moments ago. + // + // The retry inside the critical section is what makes `Replayed` the answer either + // way, which is what the port promises: a byte-identical resubmission has the same + // hash, so it is the same op however it arrived. The alternative — catching the + // unique-violation on the `applied_manifests` insert further down — means reaching + // into `sqlx::Error` to tell a constraint violation from any other execution + // failure, which `postgres/error.rs` refuses to do for reasons recorded there. + if let Some(sync_seq) = applied_sequence(&transaction, &op).await? { + return Ok(OpOutcome::Replayed { sync_seq }); + } + // Not this caller's asset, or not in the album the op was addressed to. Both are + // the same answer, and neither is distinguishable from an asset that never existed. + if row.owner_id != op.owner_id || row.album_id != op.album_id { + tracing::info!( + asset = %op.asset_id, + "a lifecycle write was refused: the asset is not this caller's" + ); + return Ok(OpOutcome::NotFound); + } + // A row nothing can see yet has no chain to extend. + if row.state == AssetState::Pending { + return Ok(OpOutcome::NotFound); + } + + // Invariant 17, decided under the row lock rather than by the caller: a + // read-compare-write above this port has a window in which two ops chain onto the + // same head, which is the double-apply the invariant exists to catch. + if op.prior_provenance_hash != row.chain_head { + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + "a lifecycle write was refused: it does not chain onto the stored head" + ); + return Ok(OpOutcome::StaleChain { + head: row.chain_head, + }); + } + + // Invariant 18, over the **album's** high-water mark rather than this row's: an + // epoch is an album-wide fact, so an op on a stale asset must not re-admit an epoch + // the album has already moved past. + let epoch = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COALESCE(MAX(amk_version), 0) AS stored FROM assets WHERE album_id = $1", + [Value::from(op.album_id.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an album's epoch"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the album epoch query returned no row".to_owned(), + })?; + let column: i64 = epoch + .try_get("", "stored") + .map_err(PORT.failing("reading an album's epoch"))?; + let album_epoch = sequence_from(column)?; + if op.amk_version < album_epoch { + tracing::info!( + asset = %op.asset_id, + stored = album_epoch, + submitted = op.amk_version, + "a lifecycle write was refused: the album epoch regresses" + ); + return Ok(OpOutcome::AmkRegressed { + stored: album_epoch, + }); + } + + let mut row = row; + row.state = match op.action { + OpAction::Delete => AssetState::Tombstoned, + // A restore returns the asset to the live set. Every other action leaves the + // state alone — re-uploading the bytes of something you deleted does not + // undelete it, because undeleting is what a `trash-restore` is for. + OpAction::TrashRestore => AssetState::Visible, + OpAction::MetadataUpdate | OpAction::Derivative | OpAction::Replace => row.state, + }; + // The provenance blob is re-pointed on every op: the chain *is* a succession of + // manifests, so the newest one is what the feed must serve. + set_singular(&mut row, BlobRole::Provenance, &op.provenance); + if let Some(metadata) = &op.metadata { + set_singular(&mut row, BlobRole::Metadata, metadata); + } + // A replace's whole point (`S-C43`): the authorized form of the change + // `record_blob` refuses, arriving with a manifest that chains onto the one it + // supersedes. + if let Some(original) = &op.original { + set_singular(&mut row, BlobRole::Original, original); + } + row.chain_head = Some(op.manifest_hash); + row.amk_version = op.amk_version; + row.retention_until = match op.action { + OpAction::Delete => op.retention_until.map(stored), + // Back in the live set: there is no window left to run out. + OpAction::TrashRestore => None, + OpAction::MetadataUpdate | OpAction::Derivative | OpAction::Replace => { + row.retention_until + } + }; + row.updated_at = stored(op.at); + + let sync_seq = mint(&transaction, &row.owner_id).await?; + row.sync_seq = Some(sync_seq); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO applied_manifests (manifest_hash, sync_seq) VALUES ($1, $2)", + [ + Value::from(op.manifest_hash.as_bytes().to_vec()), + Value::from(sync_seq as i64), + ], + )) + .await + .map_err(PORT.failing("recording an applied manifest"))?; + persist(&transaction, &row).await?; + commit(transaction).await?; + + tracing::info!( + asset = %op.asset_id, + action = op.action.as_str(), + sync_seq, + "a lifecycle write was applied" + ); + Ok(OpOutcome::Applied { + row: Box::new(row), + sync_seq, + }) + }) + } + + fn set_hold<'a>( + &'a self, + asset: &'a AssetId, + hold: Option, + ) -> IndexFuture<'a, HoldOutcome> { + Box::pin(async move { + // `IS DISTINCT FROM` rather than `<>` so a null-to-null no-op is `Unchanged` rather + // than an update nobody can see: re-applying a takedown must not append a second + // provenance record claiming the asset was taken down twice. + let applied = self + .connection + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE assets SET hold = $2 WHERE asset_id = $1 AND hold IS DISTINCT FROM $2", + [ + Value::from(asset.as_str().to_owned()), + Value::from(hold.map(|hold| hold.as_str().to_owned())), + ], + )) + .await + .map_err(PORT.failing("placing a serving hold"))?; + if applied.rows_affected() == 1 { + if let Some(hold) = hold { + tracing::info!( + %asset, + hold = hold.as_str(), + "an asset was placed under a serving hold; its bytes are untouched" + ); + } else { + tracing::info!(%asset, "an asset's serving hold was lifted"); + } + return Ok(HoldOutcome::Applied); + } + + let exists = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT 1 AS present FROM assets WHERE asset_id = $1", + [Value::from(asset.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an asset row"))?; + Ok(if exists.is_some() { + HoldOutcome::Unchanged + } else { + HoldOutcome::NotFound + }) + }) + } + + fn reference_count<'a>(&'a self, address: &'a ContentAddress) -> IndexFuture<'a, u64> { + Box::pin(async move { + // A **query**, never a stored counter. A manifest the chain has moved past is still + // referenced (`S-C52`); without that the collector reclaims the server's own + // rebuttal evidence. + let counted = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COUNT(*) AS held FROM assets a WHERE \ + EXISTS (SELECT 1 FROM asset_blobs b \ + WHERE b.asset_id = a.asset_id AND b.address = $1) \ + OR EXISTS (SELECT 1 FROM asset_superseded s \ + WHERE s.asset_id = a.asset_id AND s.address = $1)", + [Value::from(address.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("counting the rows that name an address"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the reference count returned no row".to_owned(), + })?; + let held: i64 = counted + .try_get("", "held") + .map_err(PORT.failing("reading a reference count"))?; + sequence_from(held) + }) + } + + fn rows<'a>( + &'a self, + after: Option<&'a AssetId>, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + // Every row, whatever its state: a scrub that skipped pending or tombstoned rows + // would skip exactly the rows a half-finished write leaves behind. + // + // `COLLATE "C"` is byte order, which is the in-memory adapter's `BTreeMap` order — + // so "asset-id order" means one thing for both adapters rather than whatever the + // database was initialised with. A locale collation would also be *self*-consistent + // here (the cursor comparison and the ordering share it), so this is about adapter + // parity rather than about a resumable walk: en_US.utf8 ignores punctuation at the + // primary level, and asset ids are client-chosen and full of hyphens. + let statement = match after { + Some(after) => Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE asset_id COLLATE \"C\" > $1 \ + ORDER BY asset_id COLLATE \"C\" LIMIT $2" + ), + [ + Value::from(after.as_str().to_owned()), + Value::from(limit as i64), + ], + ), + None => Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets ORDER BY asset_id COLLATE \"C\" \ + LIMIT $1" + ), + [Value::from(limit as i64)], + ), + }; + // One snapshot across the page and every row's collections: a scrub comparing the + // index against the blob store must not be handed a row whose blob list came from a + // later moment than its state. + let snapshot = begin_read_snapshot(&self.connection).await?; + let found = snapshot + .query_all(statement) + .await + .map_err(PORT.failing("walking the asset rows"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&snapshot, &mut rows).await?; + commit(snapshot).await?; + Ok(rows) + }) + } + + fn tombstoned(&self, limit: usize) -> IndexFuture<'_, Vec> { + Box::pin(async move { + // Oldest change first, so a bounded pass makes progress on the oldest deletions + // rather than revisiting the same page. The asset id breaks ties so the order is + // total and a resumed pass is deterministic. + let snapshot = begin_read_snapshot(&self.connection).await?; + let found = snapshot + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets WHERE state = 'tombstoned' \ + ORDER BY updated_at, asset_id COLLATE \"C\" LIMIT $1" + ), + [Value::from(limit as i64)], + )) + .await + .map_err(PORT.failing("listing tombstoned rows"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&snapshot, &mut rows).await?; + commit(snapshot).await?; + Ok(rows) + }) + } + + fn purge<'a>(&'a self, asset: &'a AssetId) -> IndexFuture<'a, Option> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + let Some(mut row) = hydrate_locked(&transaction, asset).await? else { + return Ok(None); + }; + // The row **stays**. A client that has not synced since the delete still has to + // learn about it, so removing the row would make the deletion invisible rather than + // final. The chain goes with the bytes it describes: a purge is the end of the + // retention window the user's own signed delete fixed (`S-C52`). + row.blobs.clear(); + row.superseded.clear(); + persist(&transaction, &row).await?; + commit(transaction).await?; + tracing::info!(%asset, "purged a tombstoned asset's blob references"); + Ok(Some(row)) + }) + } + + fn feed_page<'a>( + &'a self, + owner: &'a OwnerId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + // One snapshot: a page whose entries carried blob lists from different moments + // would hand a client a manifest address and a metadata address that never coexisted. + let snapshot = begin_read_snapshot(&self.connection).await?; + let found = snapshot + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE owner_id = $1 AND sync_seq > $2 \ + ORDER BY sync_seq LIMIT $3" + ), + [ + Value::from(owner.as_str().to_owned()), + Value::from(after as i64), + Value::from(limit as i64), + ], + )) + .await + .map_err(PORT.failing("reading a feed page"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&snapshot, &mut rows).await?; + commit(snapshot).await?; + // `entry_for` is shared with the in-memory adapter so both render an entry + // identically — the `ChangeKind` rule in particular is the kind of thing two + // adapters drift on. + Ok(rows + .iter() + .filter_map(|row| entry_for(row, after)) + .collect()) + }) + } + + fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { + Box::pin(async move { + // The allocator's own row: the highest number this owner has minted, which is what + // lets a page report whether the client is caught up without asking for another + // page that would come back empty. + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT next_seq FROM owner_sequences WHERE owner_id = $1", + [Value::from(owner.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an owner's head sequence number"))?; + let Some(found) = found else { return Ok(0) }; + let next: i64 = found + .try_get("", "next_seq") + .map_err(PORT.failing("reading an owner's head sequence number"))?; + sequence_from(next) + }) + } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + // One snapshot, as the owner's page: see `feed_page`. Bound to the owner as well as + // the album so the `(owner_id, album_id)` index serves it. + let snapshot = begin_read_snapshot(&self.connection).await?; + let found = snapshot + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE owner_id = $1 AND album_id = $2 AND sync_seq > $3 \ + ORDER BY sync_seq LIMIT $4" + ), + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + Value::from(after as i64), + Value::from(limit as i64), + ], + )) + .await + .map_err(PORT.failing("reading an album's feed page"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&snapshot, &mut rows).await?; + commit(snapshot).await?; + Ok(rows + .iter() + .filter_map(|row| entry_for(row, after)) + .collect()) + }) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + Box::pin(async move { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COALESCE(MAX(sync_seq), 0)::bigint AS head FROM assets \ + WHERE owner_id = $1 AND album_id = $2", + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("reading an album's head sequence number"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the album head query returned no row".to_owned(), + })?; + let head: i64 = found + .try_get("", "head") + .map_err(PORT.failing("reading an album's head sequence number"))?; + sequence_from(head) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + /// + /// One case, running the whole list in one pass, because nextest runs a process per test and + /// a container per case would be thirty containers rather than one. `index/conformance.rs` + /// said as much before this adapter existed: *"a Postgres-backed smoke test is one `run_all` + /// call"*. + mod postgres_conformance { + use super::super::PostgresAssetIndex; + use crate::index::conformance; + use crate::postgres::testing; + + #[tokio::test] + async fn the_postgres_index_conforms() { + let Some(database) = testing::start("the Postgres asset index").await else { + return; + }; + let index = PostgresAssetIndex::new(database.connection().clone()); + conformance::run_all(&index).await; + } + } +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index b021f1f8..d25ca7ed 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -4,8 +4,9 @@ //! //! The previous server was Salvo, and its wire-contract types were themselves salvo-typed, so //! replacing it was never a transport swap (`SLICES.md`, the salvo→kynos row). `S-C27` moved the -//! response taxonomy into the framework-free [`capsule_wire`]; this crate is where the surfaces -//! that taxonomy describes get rebuilt. +//! response taxonomy into a framework-free crate so the contract could outlive the transport; +//! that crate retired with the Salvo tree it adapted (ADR-0004), and this crate is where the +//! surfaces the taxonomy described get rebuilt. `problem`, `limits` and `body` own it now. //! //! # What the framework buys, and why it was chosen //! @@ -21,25 +22,46 @@ //! //! One application composed from cohesive internal modules — not separate public transports or //! microservices (design/module-map.md, "Planned Server Modules"). Authentication state and -//! upload-session state stay behind separate Capsule-owned ports with Postgres, Valkey and -//! deterministic in-memory adapters; no generic CAS, transfer or TTL abstraction is planned. +//! upload-session state stay behind separate Capsule-owned ports whose adapters are Valkey and a +//! deterministic in-memory double — **not** Postgres, which design/filesystem/server.md rejects +//! for a session table — while the durable records go to Postgres. No generic CAS, transfer or +//! TTL abstraction is planned. //! //! Each module owns one port and, where it has one, the surface over it. [`routes`] is the only //! module that knows about HTTP: everything under it — [`album`], [`directory`], [`discovery`], -//! [`enrollment`], [`escrow`], [`gc`], [`index`], [`moderation`], [`quota`], [`scrub`], [`serve`], +//! [`enrollment`], [`escrow`], [`federation`], [`gc`], [`index`], [`membership`], +//! [`moderation`], +//! [`negotiation`], [`quota`], +//! [`scrub`], [`serve`], //! [`share`], [`store`], //! [`sync`], [`upload`], //! [`verify`] — is framework-free and testable without a router, which is why the operator //! workers ([`gc`], [`scrub`]) have no wire surface at all and cost nothing to exercise. //! -//! # Every adapter is in-memory +//! # How a process is assembled +//! +//! [`config`] reads what an operator decided; [`boot`] turns it into the one [`App`] the router +//! is built with. Both are library modules rather than binary code, because a composition root +//! that lives in `main` is a composition root nothing tests: `boot::assemble` is driven by unit +//! tests here and by the binary identically, so "the server can be built at all" is an +//! assertion rather than something discovered on a deployment. +//! +//! # Every port has a double, and four of them have Postgres besides //! //! Every port in this crate has a deterministic in-memory adapter and a conformance suite, and -//! **no Postgres, Valkey or filesystem adapter is written** except the blob store's. That is a -//! deliberate ordering rather than an omission: the contract and its suite are what a real -//! adapter is written *against*, and a port with two implementations before it has one suite is -//! a port whose two implementations will disagree. It is also why this crate's whole test suite -//! runs without a container. +//! that ordering was deliberate rather than an omission: the contract and its suite are what a +//! real adapter is written *against*, and a port with two implementations before it has one +//! suite is a port whose two implementations will disagree. +//! +//! Four ports now have the second implementation (#402) — [`index`]'s asset index, [`auth`]'s +//! account cluster, [`store`]'s device-cohort map and [`quota`]'s ledger — each passing the same +//! case list as its double. The remaining durable ports are #446's and the volatile ones are +//! Valkey's (#403). +//! +//! **The suite still runs without a container.** Every Postgres case is gated on +//! `CAPSULE_TEST_POSTGRES=1` and prints one line naming itself when it is skipped, so +//! `cargo nextest run` on a machine with no container runtime is green and says what it did not +//! prove — which is the acceptance gap design/module-map.md sets for the framework. pub mod album; pub mod app; @@ -47,17 +69,24 @@ pub mod attestation; pub mod auth; pub mod blob; pub mod body; +pub mod boot; +pub mod cli; +pub mod config; pub mod counter; pub mod directory; pub mod discovery; pub mod drop; pub mod enrollment; pub mod escrow; +pub mod federation; pub mod gc; pub mod index; pub mod limits; +pub mod membership; pub mod moderation; +pub mod negotiation; mod openapi; +pub mod postgres; pub mod problem; pub mod quota; pub mod routes; @@ -73,6 +102,7 @@ use kynos::middleware::catch_panic::Propagate; use kynos::middleware::limits::BodySize; use kynos::middleware::stack::Cons; use kynos::prelude::*; +use kynos::router::group::Group; use kynos::router::service::Service; pub use self::app::App; @@ -93,95 +123,151 @@ pub fn router() -> ServerRouter { // and the bearer scheme's `401`/`403` — and fills in the `error.*` code none of those // framework-owned types has a seam to carry. See [`problem`], and `S-C36`. .intercept(problem::CodedProblems::new()) + // Inside the coder and outside everything that can refuse: the protocol window rides + // **every** response — the body-size `413` below, an extractor's `400`, the bearer + // scheme's `401`, the gate's own `426` — because each of those is produced beneath this + // and passes back up through it. See [`negotiation`]. + .intercept(negotiation::Negotiation::new()) // Mounted on the whole router, not on the operations that happen to take a body today: // an oversized body is refused wherever it is sent, and the `413` that refusal produces // is declared on every operation it covers because Kynos derives the declaration from // the interceptor's own type. See [`limits`]. .intercept(limits::body_size()) - // Seven `mount` calls, not one. Kynos's `EndpointSet` is implemented for tuples up to - // sixteen and the seventeenth operation is a compile error, so a split is forced — but - // grouping by surface rather than cutting at the arbitrary boundary is what makes the - // next addition obvious rather than a puzzle. Each group is well under the cap, so a - // new operation joins the surface it belongs to instead of wherever there is room. - // The account: who you are, what devices you have, and how you get your key back. + // The protocol gate is two `Group`s, not a router interceptor, for two reasons the type + // system makes concrete. First, the design exempts ten operations, and a group is how + // Kynos spells "these and not those": an operation mounted inside declares the handshake + // parameters and the gate's statuses, one mounted on the router below does not and still + // carries the response headers. Second, the design holds a **write** to the window (a + // grammatical date outside it is `426`) and a **read** to the grammar only ("reads of any + // past version succeed", threat-model/validation.md) — and since an interceptor's + // declaration is its type, a read operation must sit behind a gate whose `Short` has no + // `426` in it, or the document would promise a status the read never renders. So the + // non-safe operations sit behind `ProtocolGate` and the `GET`/`HEAD` ones behind + // `ProtocolReadGate`. `tests/conformance.rs` pins both sets and the exempt ten against + // the emitted document and walks every operation on the wire, so a route cannot change + // gate by accident. + // + // Several `mount` calls per group, not one. Kynos's `EndpointSet` is implemented for + // tuples up to sixteen and the seventeenth operation is a compile error, so a split is + // forced — but grouping by surface rather than cutting at the arbitrary boundary is what + // makes the next addition obvious rather than a puzzle. + .group( + Group::::new("/") + .intercept(negotiation::ProtocolGate::new()) + // The account: opening, refreshing and closing sessions, revoking them all. + .mount(kynos::routes![ + routes::auth::register_user, + routes::auth::login_user, + routes::auth::refresh_token, + routes::auth::logout, + routes::auth::revoke_all_challenge, + routes::auth::revoke_all, + routes::auth::reauthenticate, + routes::devices::revoke_session, + routes::directory::publish_device_directory, + routes::escrow::store_escrow, + ]) + // What an account changes about itself, its second factor, and the other way + // a session is opened: through an external identity provider (`S-N1`). + .mount(kynos::routes![ + routes::profile::update_profile, + routes::profile::change_password, + routes::totp::totp_enroll, + routes::totp::totp_verify_enrollment, + routes::totp::totp_disable, + routes::totp::totp_verify_login, + routes::oidc::begin_oidc_login, + routes::oidc::complete_oidc_login, + ]) + // The cross-device add: one code, one channel, and the writes into it. + .mount(kynos::routes![ + routes::enroll::issue_enrollment_code, + routes::enroll::redeem_enrollment_code, + routes::enroll::relay_enrollment_payload, + routes::enroll::close_enrollment_channel, + ]) + // The library's own writes: albums, upgrades, uploads, operations, verification. + .mount(kynos::routes![ + routes::albums::provision_album, + routes::upgrade::begin_album_upgrade, + routes::upgrade::abort_album_upgrade, + routes::roster::publish_album_roster, + routes::upload::create_upload, + routes::upload::append_chunk, + routes::upload::cancel_upload, + routes::ops::apply_op, + routes::storage::verify_storage, + ]) + // Share links and guest drops: the owner's writes on both. + .mount(kynos::routes![ + routes::share::issue_share, + routes::share::revoke_share, + routes::drop::provision_link, + routes::drop::revoke_link, + routes::drop::adopt_drop, + routes::drop::discard_drop, + ]), + ) + // Federation's lifecycle: minting, revoking and refreshing the capability a peer pulls + // with, and the signed report intake. The pull itself is `sync_feed` and `get_blob` in + // the read group below — federation adds no new data protocol (design/federation.md). + // + // Its own group rather than a fifth `mount` on the one above, because these four are one + // surface and the sixteen-operation tuple ceiling is close. A **tighter body cap** was + // tried here and cannot be expressed: see [`crate::limits::MAX_FEDERATION_BODY_BYTES`]. + .group( + Group::::new("/") + .intercept(negotiation::ProtocolGate::new()) + .mount(kynos::routes![ + routes::federation::issue_capability, + routes::federation::revoke_capability, + routes::federation::refresh_capability, + routes::federation::submit_federated_report, + ]), + ) + // The reads: every gated `GET` and `HEAD`. Held to the handshake's grammar, admitted at + // any protocol date, and declaring the `400` alone. + .group( + Group::::new("/") + .intercept(negotiation::ProtocolReadGate::new()) + .mount(kynos::routes![ + routes::devices::list_devices, + routes::directory::fetch_device_directory, + routes::escrow::fetch_escrow, + routes::profile::get_profile, + routes::enroll::drain_enrollment_channel, + routes::upgrade::album_upgrade_phase, + routes::quota::get_quota, + routes::moderation::moderation_record, + routes::upload::head_upload, + routes::sessions::list_upload_sessions, + routes::receipts::get_upload_receipt, + routes::sync::sync_feed, + routes::blob::get_blob, + routes::assets::get_asset_receipts, + routes::drop::list_inbox, + ]), + ) + // **The ten operations the design exempts from the gate**, mounted on the router so + // the group above does not cover them (`api-surfaces.md`, "Negotiation Across + // Transports"). `GET /v1/version` is the reachability probe a client hits before it + // knows the window. The four `/.well-known/capsule/*` records are public discovery, + // read before any handshake. The three `/s/{opaque_id}*` share reads must answer an + // indistinguishable `404` (share-links.md), which a `426` would turn into a probing + // oracle. The two `/d/{opaque_id}*` guest deposits have their protocol pinned at link + // issuance (web-upload.md), so a browser guest has nothing to assert. All ten still + // carry the response headers, because `Negotiation` is the router's. .mount(kynos::routes![ routes::version::get_version, - routes::auth::register_user, - routes::auth::login_user, - routes::auth::refresh_token, - routes::auth::logout, - routes::auth::revoke_all_challenge, - routes::auth::revoke_all, - routes::devices::list_devices, - routes::devices::revoke_session, - routes::directory::publish_device_directory, - routes::directory::fetch_device_directory, - routes::escrow::store_escrow, - routes::escrow::fetch_escrow, - routes::auth::reauthenticate, - ]) - // What an account knows about itself, and the credentials it opens sessions with. - .mount(kynos::routes![ - routes::profile::get_profile, - routes::profile::update_profile, - routes::profile::change_password, - routes::totp::totp_enroll, - routes::totp::totp_verify_enrollment, - routes::totp::totp_disable, - routes::totp::totp_verify_login, - ]) - // The cross-device add: one code, one channel, and the two devices' mailboxes. - .mount(kynos::routes![ - routes::enroll::issue_enrollment_code, - routes::enroll::redeem_enrollment_code, - routes::enroll::relay_enrollment_payload, - routes::enroll::drain_enrollment_channel, - routes::enroll::close_enrollment_channel, - ]) - // The library's own surfaces, and the public record anybody may read. - .mount(kynos::routes![ - routes::albums::provision_album, - routes::upgrade::begin_album_upgrade, - routes::upgrade::album_upgrade_phase, - routes::upgrade::abort_album_upgrade, - routes::quota::get_quota, - routes::moderation::moderation_record, routes::well_known::attestation_keys, routes::well_known::server_info, routes::well_known::deprecation_announcements, routes::well_known::revoked_jti, - ]) - // The asset surfaces: getting bytes in, changing what they mean, and reading them back. - .mount(kynos::routes![ - routes::upload::create_upload, - routes::upload::append_chunk, - routes::sessions::list_upload_sessions, - routes::upload::head_upload, - routes::upload::cancel_upload, - routes::receipts::get_upload_receipt, - routes::ops::apply_op, - routes::sync::sync_feed, - routes::blob::get_blob, - routes::storage::verify_storage, - routes::assets::get_asset_receipts, - ]) - // Share links: two owner operations, and the one path served without an account. - .mount(kynos::routes![ - routes::share::issue_share, - routes::share::revoke_share, routes::share::share_metadata, routes::share::share_wrapped_secret, routes::share::share_blob, - ]) - // Guest drops: the owner's link, the guest's deposit, and the inbox between them. - .mount(kynos::routes![ - routes::drop::provision_link, - routes::drop::revoke_link, routes::drop::create_drop, routes::drop::append_drop_chunk, - routes::drop::list_inbox, - routes::drop::adopt_drop, - routes::drop::discard_drop, ]) } @@ -191,7 +277,11 @@ pub fn router() -> ServerRouter { /// two interceptors answering with one status a compile error rather than a runtime surprise — /// so mounting one changes this signature. That is a feature: the alias is the one place the /// server's middleware stack is written down. -pub type ServerRouter = Router>>; +pub type ServerRouter = Router< + App, + Propagate, + Cons>>, +>; /// Builds the service the server and the in-process tests both drive. /// @@ -213,7 +303,7 @@ pub fn service(context: App) -> kynos::Result> { /// /// **3.2, pinned deliberately.** `Router::openapi()` would emit the *lowest* version that /// expresses the API without loss — a good default, and not what a committed contract wants. -/// `capsule-sdk/openapi.json` is checked in and generated from by spargen, so its version must +/// `capsule-server/openapi.json` is checked in and generated from by spargen, so its version must /// be a decision rather than a consequence: left to follow the API it would flip 3.1 → 3.2 the /// day the first streamed response lands, churning the schema gate and regenerating the client /// for a change nobody asked for. Kynos names this exact case — "reach for this when a @@ -243,5 +333,10 @@ pub fn openapi() -> kynos::Result { // a generator. Filled in with the binary marker so the SDK's client can be generated from // the whole document instead of most of it. openapi::describe_raw_byte_payloads(&mut document); + // Issue #404: Kynos describes an interceptor's response headers on success responses only, + // while [`negotiation::Negotiation`] attaches them to every response it forwards — errors + // included. The walk files the same three declarations under every other response, so the + // document promises exactly what the wire carries. + openapi::describe_negotiation_headers(&mut document); Ok(document) } diff --git a/capsule-server/src/limits.rs b/capsule-server/src/limits.rs index 30482d07..c2fb920c 100644 --- a/capsule-server/src/limits.rs +++ b/capsule-server/src/limits.rs @@ -65,6 +65,38 @@ pub fn body_size() -> BodySize { BodySize::new(MAX_REQUEST_BODY_BYTES) } +/// The cap a federation write's body *would* have: **16 KiB** (`S-E2`, `S-C49`). +/// +/// Declared and **not enforced**, which is the opposite of this module's usual rule, so it says +/// why rather than sitting here looking like a control. +/// +/// The transport backstop above is sized for a 16 MiB upload chunk. A federation write is +/// nothing like one — the largest legitimate body on that surface is a signed moderation report, +/// six short strings and a base64 signature, under a kilobyte in practice — and it matters +/// because `POST /v1/federation/reports` is this server's only **unauthenticated** write: an +/// anonymous caller can hand it a body two thousand times larger than any real report and have +/// it parsed before anything looks at who is speaking. +/// +/// Mounting a second [`BodySize`] on the federation group is the obvious fix and Kynos refuses +/// it at compile time, correctly: +/// +/// ```text +/// evaluation panicked: two interceptors covering this route answer with the same status; +/// a consumer could not tell which one replied +/// ``` +/// +/// Both answer `413`, and an operation cannot declare two of them. The alternative — moving +/// `BodySize` off the router and onto every group — would take `413` off the ten operations +/// mounted outside every group, which `tests/conformance.rs` pins as declared on *every* +/// operation, and that contract is `S-C33`'s rather than this lane's to change. Filed as #478. +/// +/// What bounds the route meanwhile is not nothing, and is not this: every field is length-capped +/// before any store is read ([`crate::routes::federation`]), and +/// [`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) is charged +/// before the peer lookup and the signature check. What remains unbounded is bytes *parsed* per +/// request, which needs either a per-operation limit Kynos does not express or `S-C33` revisited. +pub const MAX_FEDERATION_BODY_BYTES: u64 = 16 * 1024; + #[cfg(test)] mod tests { use super::*; diff --git a/capsule-server/src/main.rs b/capsule-server/src/main.rs new file mode 100644 index 00000000..4b8e4513 --- /dev/null +++ b/capsule-server/src/main.rs @@ -0,0 +1,19 @@ +//! The `capsule-server` binary: a thin shim over the [`capsule_server`] library, which owns the +//! subcommand implementations. +//! +//! This binary installs error reporting and dispatches, in the shape `capsule-cli/src/main.rs` +//! already has. Everything else — parsing, the log stream, the configuration, the composition +//! root and each subcommand's body — is in the library, so `tests/binary.rs` asserts against the +//! same code this runs and a unit test can drive `boot::assemble` without a subprocess. +//! +//! Replaces the `gen_openapi` `[[bin]]`, which was this crate's only executable: the document +//! dump is now `capsule-server gen-openapi`, one subcommand of the one binary. See +//! [`capsule_server::cli`] for why four executables were rejected. + +use color_eyre::eyre::Result; + +#[tokio::main] +async fn main() -> Result { + color_eyre::install()?; + capsule_server::cli::run().await +} diff --git a/capsule-server/src/membership/conformance.rs b/capsule-server/src/membership/conformance.rs new file mode 100644 index 00000000..a789ac72 --- /dev/null +++ b/capsule-server/src/membership/conformance.rs @@ -0,0 +1,653 @@ +//! The one suite every [`MembershipStore`] adapter must pass. +//! +//! # The rules the suite exists to protect +//! +//! - **One critical section.** A roster at the held version with different bytes is `Stale`, +//! not applied; a single-process suite cannot exhibit the race itself, so it asserts the +//! consequence — the loser of a version tie changes nothing — and the structural guarantee +//! stays in the adapter (one mutex here, one transaction lock in Postgres). +//! - **Removal is a stored fact.** A member omitted from a later roster answers +//! [`Membership::Revoked`] with the version and epoch at which they vanished, never +//! [`Membership::Never`]. That is what the blob route's `403` is rendered from. +//! - **A refusal changes nothing.** `Stale`, `VersionLeap` and `EpochRegressed` leave both the +//! roster and every member row exactly as they were. +//! - **No publish can wedge the album.** A version far above the held one is refused, and the +//! next legitimate roster still applies — the counter is bounded above as well as below. +//! +//! # Reusing a harness +//! +//! Every case scopes its own album ids, so cases may share one store and [`run_all`] does. + +use jiff::{SignedDuration, Timestamp}; +use uuid::Uuid; + +use super::{MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord}; +use crate::store::{AlbumId, StoreError, UserId}; + +/// The store under test. +pub trait Harness: Send + Sync { + /// The membership store under test. + fn members(&self) -> &dyn MembershipStore; +} + +/// Unwrap a store result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming membership store must succeed at {doing}: {error}"), + } +} + +/// `case`'s own album. +fn album(case: &str) -> AlbumId { + AlbumId::new(format!("{case}-album")) +} + +/// `case`'s account `name`. +fn user(case: &str, name: &str) -> UserId { + UserId::new(format!("{case}-{name}")) +} + +/// A roster record for `case` at `version` and `epoch`, whose bytes differ per version and per +/// `variant` so a "same version, different bytes" case can be written. +fn roster(case: &str, version: u64, epoch: u64, variant: &str) -> RosterRecord { + RosterRecord { + album_id: album(case), + roster_version: version, + amk_epoch: epoch, + attested_by_device: Uuid::from_u128(0xD1), + received_at: Timestamp::UNIX_EPOCH + SignedDuration::from_secs(1_700_000_000), + document: format!("{case}/v{version}/e{epoch}/{variant}").into_bytes(), + } +} + +/// Apply `roster` naming `members`, expecting it to be accepted. +async fn apply( + h: &dyn Harness, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, +) -> RosterOutcome { + ok( + h.members().apply_roster(roster, members).await, + "apply a roster", + ) +} + +/// What the store says `user` is to `case`'s album. +async fn membership(h: &dyn Harness, case: &str, user: &UserId) -> Membership { + ok( + h.members().membership(&album(case), user).await, + "read a membership", + ) +} + +// =========================================================================================== +// Applying rosters +// =========================================================================================== + +/// The first roster is applied and its members are members, with the roster's epoch. +pub async fn the_first_roster_is_applied_and_its_members_are_members(h: &dyn Harness) { + let case = "first"; + let bob = user(case, "bob"); + let outcome = apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + let RosterOutcome::Applied(record) = outcome else { + panic!("the first roster must be applied, got {outcome:?}"); + }; + assert_eq!(record.roster_version, 1); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + let held = ok( + h.members().current_roster(&album(case)).await, + "read the current roster", + ) + .expect("a roster is held"); + assert_eq!( + held, record, + "current_roster returns what apply_roster returned" + ); +} + +/// The held record is the stored record, instant included. +/// +/// An adapter that keeps sub-microsecond precision in Rust and drops it in the column would hand +/// `apply_roster`'s caller a record the next `current_roster` does not produce. +pub async fn the_returned_record_is_the_record_the_next_read_produces(h: &dyn Harness) { + let case = "precise"; + let mut precise = roster(case, 1, 1, ""); + precise.received_at = + Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"); + let RosterOutcome::Applied(returned) = apply(h, precise, vec![]).await else { + panic!("applied"); + }; + let held = ok( + h.members().current_roster(&album(case)).await, + "read the current roster", + ) + .expect("a roster is held"); + assert_eq!(held.received_at, returned.received_at); + assert_eq!(held.document, returned.document); +} + +/// The same bytes again are a replay: the held record comes back and nothing changes. +pub async fn the_same_bytes_again_are_a_replay(h: &dyn Harness) { + let case = "replay"; + let bob = user(case, "bob"); + let first = roster(case, 1, 1, ""); + let RosterOutcome::Applied(record) = + apply(h, first.clone(), vec![(bob.clone(), MemberRole::Writer)]).await + else { + panic!("applied"); + }; + // The member list is deliberately *different* on the replay: a replay is decided on the + // document's bytes, which the route derived the list from, not on the list itself. + assert_eq!( + apply(h, first, vec![]).await, + RosterOutcome::Replayed(record), + "identical bytes at the held version are a replay" + ); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + }, + "a replay changes nothing" + ); +} + +/// A version at or below the held one with different bytes is stale, and changes nothing. +pub async fn a_stale_version_is_refused_and_changes_nothing(h: &dyn Harness) { + let case = "stale"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + + // Same version, different bytes: the loser of a concurrent publish. + assert_eq!( + apply( + h, + roster(case, 1, 1, "other"), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::Stale { current_version: 1 } + ); + // A lower version: a client that is behind. + assert_eq!( + apply( + h, + roster(case, 0, 1, ""), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::Stale { current_version: 1 } + ); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + // And the album is not left locked by the refusals: the next real version applies. An + // adapter whose early return leaked its critical section would hang here, visibly. + assert!(matches!( + apply(h, roster(case, 2, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + )); +} + +/// A version far above the held one is refused, and the album still takes its next roster. +/// +/// The wedge this exists to deny: accept one publish at the top of the counter and no later +/// roster can ever be strictly greater, so the album's membership is frozen for good. The case +/// therefore asserts both halves — the absurd version changes nothing, *and* the legitimate +/// successor still applies. +pub async fn a_version_leap_is_refused_and_the_album_still_takes_its_next_roster(h: &dyn Harness) { + let case = "leap"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + + assert_eq!( + apply( + h, + roster(case, u64::MAX, 1, ""), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::VersionLeap { + current_version: 1, + max_version: 1 + super::MAX_ROSTER_VERSION_STEP, + } + ); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + + // The whole point: the next legitimate roster is still accepted. + assert!(matches!( + apply( + h, + roster(case, 2, 1, ""), + vec![ + (bob, MemberRole::Writer), + (carol.clone(), MemberRole::Reader) + ] + ) + .await, + RosterOutcome::Applied(_) + )); + assert_eq!( + membership(h, case, &carol).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +/// A first roster is bounded too: an empty album is a held version of zero. +pub async fn the_version_window_binds_an_albums_first_roster(h: &dyn Harness) { + let case = "leap-first"; + let bob = user(case, "bob"); + assert_eq!( + apply( + h, + roster(case, u64::MAX, 1, ""), + vec![(bob.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::VersionLeap { + current_version: 0, + max_version: super::MAX_ROSTER_VERSION_STEP, + } + ); + assert!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .is_none(), + "a refused first roster leaves the album with none" + ); + assert!(matches!( + apply(h, roster(case, 1, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + )); +} + +/// A newer version carrying a lower epoch is a regression, and changes nothing. +pub async fn an_epoch_regression_is_refused_and_changes_nothing(h: &dyn Harness) { + let case = "regress"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + assert_eq!( + apply(h, roster(case, 2, 0, ""), vec![]).await, + RosterOutcome::EpochRegressed { + current_version: 1, + stored: 1 + } + ); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + }, + "the member was not revoked by a refused roster" + ); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + assert!( + matches!( + apply(h, roster(case, 2, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + ), + "the refusal released the album for the next version" + ); +} + +/// One application revokes, continues and admits at once. +/// +/// The case that runs the adapters' set arithmetic with more than one name on each side: the +/// omitted member is revoked, the continuing one keeps their grant, the new one gets a fresh +/// grant — in one operation, from one list. +pub async fn one_application_revokes_continues_and_admits(h: &dyn Harness) { + let case = "mixed"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + let dave = user(case, "dave"); + apply( + h, + roster(case, 1, 1, ""), + vec![ + (bob.clone(), MemberRole::Writer), + (carol.clone(), MemberRole::Reader), + ], + ) + .await; + apply( + h, + roster(case, 2, 2, ""), + vec![ + (carol.clone(), MemberRole::Writer), + (dave.clone(), MemberRole::Reader), + ], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + assert_eq!( + membership(h, case, &carol).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + assert_eq!( + membership(h, case, &dave).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 2, + } + ); +} + +/// An account listed twice is taken once, the last entry winning. +/// +/// The route refuses such a document, so this is the port's promise rather than a wire case — +/// and it is asserted because an adapter that folded the list into one multi-row statement would +/// fail it loudly rather than diverge quietly. +pub async fn an_account_listed_twice_is_taken_once_last_entry_winning(h: &dyn Harness) { + let case = "twice"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![ + (bob.clone(), MemberRole::Writer), + (bob.clone(), MemberRole::Reader), + ], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +// =========================================================================================== +// Membership over time +// =========================================================================================== + +/// A member omitted from a later roster is revoked at that roster's version and epoch. +/// +/// The `403` case: the row is retained and marked, never deleted, or a former member would be +/// indistinguishable from a stranger. +pub async fn an_omitted_member_is_revoked_at_the_rosters_version_and_epoch(h: &dyn Harness) { + let case = "revoke"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply(h, roster(case, 2, 2, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + // And a *further* roster that still omits them does not move the revocation. + apply(h, roster(case, 3, 3, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }), + "the revocation records the first omission, not the latest roster" + ); +} + +/// A re-admitted member is a member again, with a fresh grant at the re-admitting epoch. +pub async fn a_re_admitted_member_gets_a_fresh_grant(h: &dyn Harness) { + let case = "readmit"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply(h, roster(case, 2, 2, ""), vec![]).await; + apply( + h, + roster(case, 3, 3, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 3, + } + ); +} + +/// A continuing member's role follows the roster and their grant does not move. +pub async fn a_continuing_members_role_changes_and_their_grant_does_not(h: &dyn Harness) { + let case = "continue"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply( + h, + roster(case, 2, 2, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + }, + "the grant is the epoch this continuous membership began at" + ); +} + +/// An account never listed is `Never`, and so is any account on an album with no roster. +pub async fn an_unlisted_account_is_never_a_member(h: &dyn Harness) { + let case = "never"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ), + None + ); + apply(h, roster(case, 1, 1, ""), vec![(bob, MemberRole::Reader)]).await; + assert_eq!(membership(h, case, &carol).await, Membership::Never); +} + +/// Membership is per album: the same account on two albums has two independent answers. +pub async fn membership_does_not_leak_between_albums(h: &dyn Harness) { + let case = "isolated"; + let other = "isolated-other"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply( + h, + roster(other, 1, 1, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + // Revoking on one album leaves the other untouched. + apply(h, roster(case, 2, 2, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + assert_eq!( + membership(h, other, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + the_first_roster_is_applied_and_its_members_are_members(h).await; + the_returned_record_is_the_record_the_next_read_produces(h).await; + the_same_bytes_again_are_a_replay(h).await; + a_stale_version_is_refused_and_changes_nothing(h).await; + a_version_leap_is_refused_and_the_album_still_takes_its_next_roster(h).await; + the_version_window_binds_an_albums_first_roster(h).await; + an_epoch_regression_is_refused_and_changes_nothing(h).await; + one_application_revokes_continues_and_admits(h).await; + an_account_listed_twice_is_taken_once_last_entry_winning(h).await; + + an_omitted_member_is_revoked_at_the_rosters_version_and_epoch(h).await; + a_re_admitted_member_gets_a_fresh_grant(h).await; + a_continuing_members_role_changes_and_their_grant_does_not(h).await; + an_unlisted_account_is_never_a_member(h).await; + membership_does_not_leak_between_albums(h).await; +} + +#[cfg(test)] +mod tests { + use super::{Harness, run_all}; + use crate::membership::{InMemoryMembership, MembershipStore}; + + /// The deterministic store. + #[derive(Debug, Default)] + struct MemoryHarness { + members: InMemoryMembership, + } + + impl Harness for MemoryHarness { + fn members(&self) -> &dyn MembershipStore { + &self.members + } + } + + /// Declares one `#[tokio::test]` per conformance case, on a fresh store each. + macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + super::$case(&MemoryHarness::default()).await; + } + )+ + }; + } + + conformance_cases! { + the_first_roster_is_applied_and_its_members_are_members, + the_returned_record_is_the_record_the_next_read_produces, + the_same_bytes_again_are_a_replay, + a_stale_version_is_refused_and_changes_nothing, + a_version_leap_is_refused_and_the_album_still_takes_its_next_roster, + the_version_window_binds_an_albums_first_roster, + an_epoch_regression_is_refused_and_changes_nothing, + one_application_revokes_continues_and_admits, + an_account_listed_twice_is_taken_once_last_entry_winning, + an_omitted_member_is_revoked_at_the_rosters_version_and_epoch, + a_re_admitted_member_gets_a_fresh_grant, + a_continuing_members_role_changes_and_their_grant_does_not, + an_unlisted_account_is_never_a_member, + membership_does_not_leak_between_albums, + } + + /// The whole suite, in one pass on one store — the entry point the container case uses. + #[tokio::test] + async fn the_in_memory_store_conforms() { + run_all(&MemoryHarness::default()).await; + } +} diff --git a/capsule-server/src/membership/memory.rs b/capsule-server/src/membership/memory.rs new file mode 100644 index 00000000..91b7fefb --- /dev/null +++ b/capsule-server/src/membership/memory.rs @@ -0,0 +1,123 @@ +//! [`InMemoryMembership`] — the deterministic double. +//! +//! One mutex over both maps, which is what makes [`MembershipStore::apply_roster`] one critical +//! section: the version comparison and the replacement happen under the same lock. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use super::{ + MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord, precheck, +}; +use crate::store::{AlbumId, StoreFuture, UserId}; + +/// The deterministic membership store. +#[derive(Debug, Default)] +pub struct InMemoryMembership { + inner: Mutex, +} + +/// One account's row for one album. +#[derive(Debug, Clone)] +struct MemberRow { + role: MemberRole, + granted_epoch: u64, + revoked: Option, +} + +#[derive(Debug, Default)] +struct Inner { + rosters: BTreeMap, + members: BTreeMap<(AlbumId, UserId), MemberRow>, +} + +impl InMemoryMembership { + /// An empty store. + pub fn new() -> Self { + Self::default() + } +} + +/// Take the lock, recovering from a poisoned mutex. +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +impl MembershipStore for InMemoryMembership { + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome> { + Box::pin(async move { + let mut inner = lock(&self.inner); + if let Some(outcome) = precheck(inner.rosters.get(&roster.album_id), &roster) { + return Ok(outcome); + } + let album = roster.album_id.clone(); + let listed: BTreeMap = members.into_iter().collect(); + + // Everyone live who is not on the new list vanished at this version and epoch. + for ((row_album, user), row) in &mut inner.members { + if row_album == &album && row.revoked.is_none() && !listed.contains_key(user) { + row.revoked = Some(Revocation { + at_version: roster.roster_version, + at_epoch: roster.amk_epoch, + }); + } + } + for (user, role) in listed { + let key = (album.clone(), user); + match inner.members.get_mut(&key) { + // Continuing: the role may change, the grant does not. + Some(row) if row.revoked.is_none() => row.role = role, + // New, or re-admitted: a fresh grant at this roster's epoch. + _ => { + inner.members.insert( + key, + MemberRow { + role, + granted_epoch: roster.amk_epoch, + revoked: None, + }, + ); + } + } + } + tracing::info!( + %album, + roster_version = roster.roster_version, + amk_epoch = roster.amk_epoch, + "an album roster was applied" + ); + inner.rosters.insert(album, roster.clone()); + Ok(RosterOutcome::Applied(roster)) + }) + } + + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership> { + Box::pin(async move { + let inner = lock(&self.inner); + Ok(match inner.members.get(&(album.clone(), user.clone())) { + None => Membership::Never, + Some(row) => match row.revoked { + Some(revocation) => Membership::Revoked(revocation), + None => Membership::Member { + role: row.role, + granted_epoch: row.granted_epoch, + }, + }, + }) + }) + } + + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.inner).rosters.get(album).cloned()) }) + } +} diff --git a/capsule-server/src/membership/mod.rs b/capsule-server/src/membership/mod.rs new file mode 100644 index 00000000..2942f984 --- /dev/null +++ b/capsule-server/src/membership/mod.rs @@ -0,0 +1,517 @@ +//! Album membership (`S-C51`): the one fact the key-free server holds about who may read and +//! write a shared album, and the port it is held behind. +//! +//! # What the server knows, and where it learned it +//! +//! The server cannot read the MLS roster — every membership change is AEAD-protected under a +//! group key it never holds — so what it knows is what the album owner **told** it: a +//! [`SignedAlbumRoster`](capsule_core::crypto::membership::SignedAlbumRoster), verified against +//! the owner's published device directory before it reaches this port (the roster route). The +//! port stores the *consequence* of that document — who is a member, with what role, since +//! which version and epoch — and never re-verifies it: the same rule `album/mod.rs` records for +//! a quiescence, that verification happens once at the write and a stored fact is read as a +//! fact. +//! +//! # Removal is a stored fact, not a deleted row +//! +//! A member who vanishes from a later roster is not deleted; the row is marked with the version +//! and epoch at which they vanished. That is what makes `403 error.blob.access_revoked` +//! renderable at all: `serve/authority.rs` reserves the `403` for a caller the server can see +//! once **had** access, and everyone else gets the unknown-address `404`. Delete the row and +//! the former member is indistinguishable from a stranger, and the authorization-change signal +//! design/import/download-sync.md requires is gone. +//! +//! # Removing a member reclaims nothing, and that is observable +//! +//! A writer member's upload is filed under the album **owner**'s namespace and charged to the +//! **uploader** (`routes/upload.rs`: `owner_id` is the namespace, `upload_user_id` is billed). +//! Removing that member from a later roster changes neither fact. The asset stays in the +//! owner's album, and its bytes stay against the removed member's quota — they are still stored, +//! so the ledger is not wrong, but the account they are charged to can no longer reach them: +//! a removed member may not write ops to that album, so they cannot delete their way back under +//! quota. The only thing that ever releases the attribution is the refcount collector +//! (`gc/mod.rs`, `QuotaStore::release_attribution`, `S-C44`), which runs when the last reference +//! to the bytes goes — i.e. only if the *owner* deletes the asset. +//! +//! This is recorded rather than repaired: reclaiming on removal is a protocol question (does the +//! owner inherit the bytes, does the member keep paying for what the owner still holds, is +//! removal a deletion at all?) that no design document in this tree answers, and inventing an +//! answer inside a storage port is how a quota becomes a way to delete somebody else's photos. +//! Tracked as issue #473. +//! +//! # One critical section +//! +//! [`MembershipStore::apply_roster`] compares versions and replaces the roster in **one** +//! operation, the way the device directory's `publish` does: two concurrent publishes cannot +//! both read "version 1 is current" and both write version 2. The in-memory adapter holds one +//! mutex; the Postgres adapter takes a per-album transaction lock. +//! +//! # The version is bounded above as well as below +//! +//! Monotonicity alone makes `roster_version` a one-way ratchet with no stop: a single publish at +//! the top of the counter can never be superseded, and the album's membership is frozen for +//! good. [`MAX_ROSTER_VERSION_STEP`] closes that — a roster is applied only inside a window +//! above the held version — and the refusal ([`RosterOutcome::VersionLeap`]) names the held +//! version, so the client re-signs at `held+1` and loses nothing: the roster is a full document, +//! so the version is only ever an ordering, never a count of anything. +//! +//! The window is clamped by [`MAX_ROSTER_VERSION`] as well as by the step, which is what keeps +//! the counter inside what a `BIGINT` holds and what the generated clients can decode, and what +//! makes the degenerate case at the top of the type unreachable instead of merely improbable. + +use std::fmt; + +pub use capsule_core::crypto::membership::MemberRole; +use jiff::Timestamp; +use uuid::Uuid; + +use crate::store::{AlbumId, StoreFuture, UserId}; + +pub mod conformance; +pub mod memory; +pub mod postgres; + +pub use self::memory::InMemoryMembership; +pub use self::postgres::PostgresMembership; + +/// The stable column token for a role, and its inverse. +/// +/// Here rather than on the core type because the token is a **storage** contract of this crate: +/// a row written as `writer` has to read back as `Writer` across every deploy, whatever the wire +/// spelling does. +pub fn role_token(role: MemberRole) -> &'static str { + match role { + MemberRole::Reader => "reader", + MemberRole::Writer => "writer", + } +} + +/// The role a stored token names, or `None` for a token no version of this server wrote. +pub fn role_from_token(token: &str) -> Option { + match token { + "reader" => Some(MemberRole::Reader), + "writer" => Some(MemberRole::Writer), + _ => None, + } +} + +/// How far above the held version a roster may declare itself, and still be applied. +/// +/// `roster_version` is the client's counter, and without a ceiling it is also a **latch**: one +/// publish at `u64::MAX` can never be superseded, because nothing can be strictly greater than +/// it, and the album's membership is frozen for good. That is a wedge no recovery path in this +/// design undoes — the store's comparison is the only ordering there is. +/// +/// So a version is accepted only in the window `held+1 ..= held+16` (`held` reads as `0` for an +/// album with no roster yet). Sixteen because the gap a *legitimate* client opens is the number +/// of membership changes it made while it could not reach the server — a roster is a full +/// document, so it publishes only its latest — and sixteen offline changes to one album's +/// membership is already far past what the design describes. A client that does exceed it is +/// not stuck: the refusal names the held version, and the roster it re-signs at `held+1` says +/// exactly the same thing, because absence at a higher version *is* removal. +pub const MAX_ROSTER_VERSION_STEP: u64 = 16; + +/// The widest `roster_version` any adapter will accept. +/// +/// Two independent reasons, and they agree on the same number. +/// +/// The durable adapter stores the counter in a `BIGINT`, so anything above `i64::MAX` is a +/// version Postgres cannot hold — `counter_to_column` refuses it. Deciding that at the port +/// instead means both adapters answer the same typed refusal rather than one answering a +/// storage failure, which is the divergence the container suite already caught once. +/// +/// And every integer this server puts in a problem body is lowered by spargen as `i64` +/// (it emits no `u64` anywhere, `format: uint64` notwithstanding), so a counter above +/// `i64::MAX` would be a number the generated client cannot decode — and a decode failure is +/// not a typed API error, so the `code` and the recovery hint would be lost. Bounding the +/// counter here makes "every version the server can hold or name is decodable" true by +/// construction rather than by argument. +/// +/// Reaching it legitimately would take ~9.2 × 10^18 publishes for one album. +pub const MAX_ROSTER_VERSION: u64 = i64::MAX as u64; + +/// The roster the server currently holds for an album. +#[derive(Clone, PartialEq, Eq)] +pub struct RosterRecord { + /// The album. + pub album_id: AlbumId, + /// Strictly monotonic per album; the idempotency key with `album_id`. + pub roster_version: u64, + /// The AMK epoch the roster reflects. Non-decreasing across versions. + pub amk_epoch: u64, + /// The owner-account device that signed it. + pub attested_by_device: Uuid, + /// When the server accepted it, on the server's clock. + pub received_at: Timestamp, + /// The signed document, verbatim canonical CBOR. Kept so a replay is decided on bytes and so + /// an operator can re-verify what was accepted. + pub document: Vec, +} + +impl fmt::Debug for RosterRecord { + /// The document is a few kilobytes of CBOR; a log line wants its length, not its bytes. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RosterRecord") + .field("album_id", &self.album_id) + .field("roster_version", &self.roster_version) + .field("amk_epoch", &self.amk_epoch) + .field("attested_by_device", &self.attested_by_device) + .field("received_at", &self.received_at) + .field("document_len", &self.document.len()) + .finish() + } +} + +/// The version and epoch at which a member vanished from the roster. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Revocation { + /// The first roster version that omitted them. + pub at_version: u64, + /// The AMK epoch that roster carried — the epoch the owner bumped to on removal. + pub at_epoch: u64, +} + +/// What the server knows about one account's relationship to one album. +/// +/// The owner is never a member here: the owner's access is the album record's own fact, and a +/// caller that needs both asks both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Membership { + /// Listed on the current roster. + Member { + /// What they may do. + role: MemberRole, + /// The epoch at which this continuous membership began. A re-admitted member gets the + /// epoch of the roster that re-admitted them, not their original one. + granted_epoch: u64, + }, + /// Once listed, since omitted. The `403` case. + Revoked(Revocation), + /// Never listed. Indistinguishable from a stranger, by design. + Never, +} + +/// What applying a roster did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RosterOutcome { + /// A newer roster replaced the held one (or there was none). + Applied(RosterRecord), + /// The same version with the same bytes: nothing changed, and the held record is returned. + Replayed(RosterRecord), + /// A version at or below the held one with different bytes. The client is behind. + Stale { + /// The version the server holds. + current_version: u64, + }, + /// A version more than [`MAX_ROSTER_VERSION_STEP`] above the held one. Not a roster that is + /// behind — one so far ahead that accepting it would put the counter out of reach of every + /// later publish. + VersionLeap { + /// The version the server holds (`0` when it holds no roster). + current_version: u64, + /// The highest version it would have accepted. + max_version: u64, + }, + /// A newer version that carried a lower AMK epoch than the held one. An epoch never goes + /// backwards, so this is a client that lost state, not a legitimate roster. + EpochRegressed { + /// The version the server holds — the same re-sync hint `Stale` carries, so a route + /// need not read the roster a second time to name it. + current_version: u64, + /// The epoch the server holds. + stored: u64, + }, +} + +/// Where membership is kept. +pub trait MembershipStore: fmt::Debug + Send + Sync { + /// Replace the album's roster with `roster` naming `members`, in one critical section. + /// + /// The version comparison and the replacement are one operation. On `Applied`: every live + /// member absent from `members` is marked revoked at the roster's version and epoch; every + /// listed member is upserted, keeping their `granted_epoch` if they were already live and + /// taking the roster's epoch if they are new or re-admitted. A user listed twice is taken + /// once, last entry winning; the route refuses such a document before it reaches here. + /// + /// `Stale`, `Replayed` and `EpochRegressed` change nothing. + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome>; + + /// What `user` is to `album`. + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership>; + + /// The roster the server holds for `album`, if any. + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option>; +} + +/// The membership module's collaborators. +#[derive(Debug, Clone)] +pub struct MembershipContext { + members: std::sync::Arc, + clock: std::sync::Arc, +} + +impl MembershipContext { + /// Assembles the module from its collaborators. + pub fn new( + members: std::sync::Arc, + clock: std::sync::Arc, + ) -> Self { + Self { members, clock } + } + + /// The store. + pub fn members(&self) -> &dyn MembershipStore { + self.members.as_ref() + } + + /// The clock a roster's `received_at` is stamped from. + pub fn clock(&self) -> &dyn crate::store::Clock { + self.clock.as_ref() + } +} + +/// Decide what `incoming` does to `held`, before any row is touched. +/// +/// Pure, so both adapters make the same decision and the rule is testable without a store. `None` +/// is "apply it"; `Some` is the outcome that ends the operation without a write. +pub(crate) fn precheck( + held: Option<&RosterRecord>, + incoming: &RosterRecord, +) -> Option { + // The ceiling first, and against a held version of `0` when there is no roster yet: a first + // publish at `u64::MAX` would wedge the album exactly as a later one would, and an album + // whose membership no publish can ever change is the one outcome this port must not be able + // to reach. `saturating_add` so the window itself cannot overflow into wrapping around. + let current_version = held.map_or(0, |held| held.roster_version); + // Clamped to the ceiling as well as to the step: `saturating_add` alone would let + // `max_version` sit above what an adapter can store and a client can decode, and — at the + // very top of the type — collapse to `max_version == current_version`, where every later + // publish is stale and the album is wedged after all. The clamp makes that unreachable + // rather than merely improbable: a version above the ceiling is never accepted, so a held + // version above it never exists. + let max_version = current_version + .saturating_add(MAX_ROSTER_VERSION_STEP) + .min(MAX_ROSTER_VERSION); + if incoming.roster_version > max_version { + return Some(RosterOutcome::VersionLeap { + current_version, + max_version, + }); + } + let held = held?; + if incoming.roster_version == held.roster_version { + return Some(if incoming.document == held.document { + RosterOutcome::Replayed(held.clone()) + } else { + RosterOutcome::Stale { + current_version: held.roster_version, + } + }); + } + if incoming.roster_version < held.roster_version { + return Some(RosterOutcome::Stale { + current_version: held.roster_version, + }); + } + if incoming.amk_epoch < held.amk_epoch { + return Some(RosterOutcome::EpochRegressed { + current_version: held.roster_version, + stored: held.amk_epoch, + }); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(version: u64, epoch: u64, document: &[u8]) -> RosterRecord { + RosterRecord { + album_id: AlbumId::new("album"), + roster_version: version, + amk_epoch: epoch, + attested_by_device: Uuid::from_u128(1), + received_at: Timestamp::UNIX_EPOCH, + document: document.to_vec(), + } + } + + #[test] + fn the_first_roster_is_always_applied() { + assert_eq!(precheck(None, &record(1, 1, b"a")), None); + // Even a version 0 or an epoch 0: monotonicity is against the *held* roster only. + assert_eq!(precheck(None, &record(0, 0, b"a")), None); + } + + #[test] + fn the_same_version_is_a_replay_on_identical_bytes_and_stale_otherwise() { + let held = record(1, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(1, 1, b"a")), + Some(RosterOutcome::Replayed(held.clone())) + ); + assert_eq!( + precheck(Some(&held), &record(1, 1, b"b")), + Some(RosterOutcome::Stale { current_version: 1 }) + ); + } + + #[test] + fn a_lower_version_is_stale_whatever_its_bytes_or_epoch() { + let held = record(2, 2, b"a"); + assert_eq!( + precheck(Some(&held), &record(1, 9, b"a")), + Some(RosterOutcome::Stale { current_version: 2 }) + ); + } + + #[test] + fn a_newer_version_with_a_lower_epoch_is_a_regression() { + let held = record(1, 3, b"a"); + assert_eq!( + precheck(Some(&held), &record(2, 2, b"b")), + Some(RosterOutcome::EpochRegressed { + current_version: 1, + stored: 3 + }) + ); + // Equal is fine: a roster may change without a key rotation. + assert_eq!(precheck(Some(&held), &record(2, 3, b"b")), None); + assert_eq!(precheck(Some(&held), &record(2, 4, b"b")), None); + } + + #[test] + fn a_version_past_the_window_is_a_leap_whatever_it_holds() { + // The wedge: one publish at the ceiling of the type, which nothing could ever supersede. + let held = record(2, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(u64::MAX, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: 2, + max_version: 2 + MAX_ROSTER_VERSION_STEP, + }) + ); + // The edge of the window is inside it; one past it is not. + assert_eq!( + precheck(Some(&held), &record(2 + MAX_ROSTER_VERSION_STEP, 1, b"b")), + None + ); + assert_eq!( + precheck(Some(&held), &record(3 + MAX_ROSTER_VERSION_STEP, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: 2, + max_version: 2 + MAX_ROSTER_VERSION_STEP, + }) + ); + } + + #[test] + fn the_window_binds_the_first_roster_too_against_a_held_version_of_zero() { + assert_eq!( + precheck(None, &record(MAX_ROSTER_VERSION_STEP, 0, b"a")), + None + ); + assert_eq!( + precheck(None, &record(u64::MAX, 0, b"a")), + Some(RosterOutcome::VersionLeap { + current_version: 0, + max_version: MAX_ROSTER_VERSION_STEP, + }) + ); + } + + #[test] + fn nothing_above_the_storable_ceiling_is_ever_accepted() { + // The ceiling is what a BIGINT holds and what a generated client can decode. It binds + // the first roster and every later one, and it is the reason a held version above it + // cannot exist. + assert_eq!( + precheck(None, &record(MAX_ROSTER_VERSION + 1, 0, b"a")), + Some(RosterOutcome::VersionLeap { + current_version: 0, + max_version: MAX_ROSTER_VERSION_STEP, + }) + ); + let held = record(MAX_ROSTER_VERSION - 1, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(MAX_ROSTER_VERSION, 1, b"b")), + None + ); + assert_eq!( + precheck(Some(&held), &record(MAX_ROSTER_VERSION + 1, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: MAX_ROSTER_VERSION - 1, + // Clamped: `held + 16` would be past what an adapter can store. + max_version: MAX_ROSTER_VERSION, + }) + ); + } + + #[test] + fn the_window_never_names_a_ceiling_a_client_could_not_decode() { + // Every `max_version` the route can render is inside the range the generated clients + // lower integers into (`i64`), whatever the held version is — including the values that + // cannot occur, so the property does not depend on the ceiling being enforced elsewhere. + for held_version in [0, 1, MAX_ROSTER_VERSION - 1, MAX_ROSTER_VERSION, u64::MAX] { + let held = record(held_version, 1, b"a"); + let Some(RosterOutcome::VersionLeap { + current_version, + max_version, + }) = precheck(Some(&held), &record(u64::MAX, 1, b"b")) + else { + continue; + }; + assert!(max_version <= MAX_ROSTER_VERSION, "{max_version}"); + assert!(i64::try_from(max_version).is_ok(), "{max_version}"); + assert_eq!(current_version, held_version); + } + } + + #[test] + fn a_held_version_at_the_top_of_the_type_is_unreachable_and_refuses_everything() { + // The degenerate case the clamp exists to make unreachable, pinned at the exact + // boundary rather than near it. A store that somehow held `u64::MAX` would refuse every + // publish — including `u64::MAX` itself, which is *not* treated as a replay, because the + // ceiling is decided before the version comparison. That state cannot arise: no roster + // above `MAX_ROSTER_VERSION` is ever applied (the case above), so no held record can + // carry one. The behaviour is stated here so it is a decision rather than an accident. + let held = record(u64::MAX, 1, b"a"); + let refusal = Some(RosterOutcome::VersionLeap { + current_version: u64::MAX, + max_version: MAX_ROSTER_VERSION, + }); + assert_eq!(precheck(Some(&held), &record(u64::MAX, 1, b"a")), refusal); + assert_eq!(precheck(Some(&held), &record(u64::MAX, 1, b"b")), refusal); + // And a version inside the storable range is stale against it, not a leap. + assert_eq!( + precheck(Some(&held), &record(5, 1, b"b")), + Some(RosterOutcome::Stale { + current_version: u64::MAX + }) + ); + } + + #[test] + fn the_role_tokens_round_trip_and_nothing_else_parses() { + for role in [MemberRole::Reader, MemberRole::Writer] { + assert_eq!(role_from_token(role_token(role)), Some(role)); + } + assert_eq!(role_from_token("admin"), None); + } + + #[test] + fn a_roster_records_debug_shows_the_documents_length_not_its_bytes() { + let rendered = format!("{:?}", record(1, 1, b"secret-bytes")); + assert!(rendered.contains("document_len: 12"), "{rendered}"); + assert!(!rendered.contains("secret"), "{rendered}"); + } +} diff --git a/capsule-server/src/membership/postgres.rs b/capsule-server/src/membership/postgres.rs new file mode 100644 index 00000000..73225ec4 --- /dev/null +++ b/capsule-server/src/membership/postgres.rs @@ -0,0 +1,359 @@ +//! [`PostgresMembership`] — the durable membership store (`S-C51`). +//! +//! # Two tables, one lock +//! +//! `album_rosters` holds the roster the server currently accepts for an album — one row per +//! album, the signed document verbatim — and `album_members` holds one row per account that +//! has ever been on one of that album's rosters. A revoked member keeps their row with +//! `revoked_at_version` and `revoked_epoch` set, because a deleted row would make the blob +//! route's `403` unrenderable (see the module docs). +//! +//! # Why the lock is an advisory lock and not `SELECT … FOR UPDATE` +//! +//! `apply_roster` has to be one critical section against a concurrent publish, and the row it +//! would lock does not exist yet for the album's **first** roster — two first publishes would +//! both read "no roster" and both upsert, and the loser would silently overwrite the winner's +//! row rather than answer `Stale`. A transaction-scoped advisory lock keyed on the album id +//! serialises both cases with one statement, is released by the commit or rollback, and needs +//! no row to exist. Every statement after it runs under the lock, so the read, the comparison +//! and the writes are one operation. +//! +//! # The advisory keyspace is shared, and that costs only serialization +//! +//! `pg_advisory_xact_lock(hashtext($1))` takes a **single-argument** advisory lock, whose key +//! space is the whole database's: `hashtext` is 32 bits, and any other advisory-lock user in +//! the same database — another Capsule adapter, an operator's migration script, an unrelated +//! application sharing the instance — can land on the same key for an entirely different +//! reason. What that costs is *serialization*, never correctness: a collision makes two +//! unrelated operations take turns. It cannot admit two concurrent publishes for one album, +//! because equal album ids always hash equal, which is the only direction this lock is relied +//! on for. If a deployment ever measures contention here, the repair is a two-argument +//! `pg_advisory_xact_lock(classid, objid)` with a class id reserved for this port — not a +//! wider lock and not a different concurrency story. + +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; +use uuid::Uuid; + +use super::{ + MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord, precheck, + role_from_token, role_token, +}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, stored, to_micros}; +use crate::store::{AlbumId, StoreError, StoreFuture, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "membership", + record: "RosterRecord", +}; + +/// The durable membership store. +#[derive(Debug, Clone)] +pub struct PostgresMembership { + connection: DatabaseConnection, +} + +impl PostgresMembership { + /// A store over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// A version or epoch as the column holds it. +fn counter_to_column(value: u64) -> Result { + i64::try_from(value).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{value} is past what a BIGINT column holds"), + }) +} + +/// A version or epoch as the port speaks it. +fn counter_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a counter"))) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +/// The roster row for `album`, read through `connection`. +async fn roster_of( + connection: &C, + album: &AlbumId, +) -> Result, StoreError> { + let Some(row) = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT roster_version, amk_epoch, attested_by_device, received_at, document \ + FROM album_rosters WHERE album_id = $1", + [Value::from(album.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an album's roster"))? + else { + return Ok(None); + }; + let failed = PORT.failing("reading an album's roster"); + let roster_version: i64 = row.try_get("", "roster_version").map_err(&failed)?; + let amk_epoch: i64 = row.try_get("", "amk_epoch").map_err(&failed)?; + let attested_by_device: String = row.try_get("", "attested_by_device").map_err(&failed)?; + let received_at: i64 = row.try_get("", "received_at").map_err(&failed)?; + let document: Vec = row.try_get("", "document").map_err(&failed)?; + Ok(Some(RosterRecord { + album_id: album.clone(), + roster_version: counter_from(roster_version)?, + amk_epoch: counter_from(amk_epoch)?, + attested_by_device: Uuid::parse_str(&attested_by_device) + .map_err(|_| PORT.undecodable(format!("`{attested_by_device}` is not a device id")))?, + received_at: from_micros(received_at).ok_or_else(|| { + PORT.undecodable(format!("{received_at}µs is not a representable instant")) + })?, + document, + })) +} + +impl MembershipStore for PostgresMembership { + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + + // The critical section starts here: everything below runs under the album's lock, + // and the lock is released with the transaction. + // + // `hashtext` is 32 bits in a key space shared with every other advisory-lock user in + // this database, so an unrelated caller can collide with an album. The cost of a + // collision is serialization and nothing else — two unrelated operations take turns + // — because equal album ids always hash equal, which is the only guarantee this lock + // is asked for. See the module docs for the two-argument repair if it ever matters. + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_advisory_xact_lock(hashtext($1))", + [Value::from(roster.album_id.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("locking an album's roster"))?; + + let held = roster_of(&transaction, &roster.album_id).await?; + if let Some(outcome) = precheck(held.as_ref(), &roster) { + // Nothing to write; the rollback releases the lock. + return Ok(outcome); + } + + // Column widths are decided **after** the port's own rule, never before it. A version + // past what a `BIGINT` holds is exactly the wedge `precheck`'s window refuses, and + // converting first would answer it as this adapter's storage failure — a `500` where + // the in-memory store answers a typed refusal, which is the divergence the shared + // conformance suite exists to catch. Anything that reaches here is inside the window + // above a version this column already held, so these conversions are a guard on an + // earlier check's promise rather than a decision. + let roster_version = counter_to_column(roster.roster_version)?; + let amk_epoch = counter_to_column(roster.amk_epoch)?; + + let roster = RosterRecord { + received_at: stored(roster.received_at), + ..roster + }; + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO album_rosters \ + (album_id, roster_version, amk_epoch, attested_by_device, received_at, \ + document) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (album_id) DO UPDATE SET \ + roster_version = EXCLUDED.roster_version, \ + amk_epoch = EXCLUDED.amk_epoch, \ + attested_by_device = EXCLUDED.attested_by_device, \ + received_at = EXCLUDED.received_at, \ + document = EXCLUDED.document", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + Value::from(roster.attested_by_device.to_string()), + Value::from(to_micros(roster.received_at)), + Value::from(roster.document.clone()), + ], + )) + .await + .map_err(PORT.failing("replacing an album's roster"))?; + + // Everyone live who is not on the new list vanished at this version and epoch. The + // list is bound as a text array so one statement covers any roster size. + let listed: Vec = members + .iter() + .map(|(user, _)| user.as_str().to_owned()) + .collect(); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE album_members \ + SET revoked_at_version = $2, revoked_epoch = $3 \ + WHERE album_id = $1 AND revoked_at_version IS NULL \ + AND NOT (user_id = ANY($4))", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + Value::from(listed), + ], + )) + .await + .map_err(PORT.failing("revoking the members a roster omits"))?; + + // One statement per listed member, so a user listed twice is taken once, last entry + // winning, exactly as the in-memory store's map does. A continuing member keeps + // their grant and takes the new role; a new or re-admitted one gets a fresh grant. + for (user, role) in members { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO album_members \ + (album_id, user_id, role, since_version, granted_epoch, \ + revoked_at_version, revoked_epoch) \ + VALUES ($1, $2, $3, $4, $5, NULL, NULL) \ + ON CONFLICT (album_id, user_id) DO UPDATE SET \ + role = EXCLUDED.role, \ + since_version = CASE \ + WHEN album_members.revoked_at_version IS NULL \ + THEN album_members.since_version ELSE EXCLUDED.since_version END, \ + granted_epoch = CASE \ + WHEN album_members.revoked_at_version IS NULL \ + THEN album_members.granted_epoch ELSE EXCLUDED.granted_epoch END, \ + revoked_at_version = NULL, \ + revoked_epoch = NULL", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + Value::from(role_token(role).to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + ], + )) + .await + .map_err(PORT.failing("recording a roster member"))?; + } + + commit(transaction).await?; + tracing::info!( + album = %roster.album_id, + roster_version = roster.roster_version, + amk_epoch = roster.amk_epoch, + "an album roster was applied" + ); + Ok(RosterOutcome::Applied(roster)) + }) + } + + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership> { + Box::pin(async move { + let Some(row) = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT role, granted_epoch, revoked_at_version, revoked_epoch \ + FROM album_members WHERE album_id = $1 AND user_id = $2", + [ + Value::from(album.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("reading a membership"))? + else { + return Ok(Membership::Never); + }; + let failed = PORT.failing("reading a membership"); + let role: String = row.try_get("", "role").map_err(&failed)?; + // Validated on every row, revoked ones included: a stored fact is read as a fact, + // and a token no version of this server wrote is corruption whichever row holds it. + let role = role_from_token(&role) + .ok_or_else(|| PORT.undecodable(format!("`{role}` is not a member role")))?; + let granted_epoch: i64 = row.try_get("", "granted_epoch").map_err(&failed)?; + let revoked_at_version: Option = + row.try_get("", "revoked_at_version").map_err(&failed)?; + let revoked_epoch: Option = row.try_get("", "revoked_epoch").map_err(&failed)?; + Ok(match (revoked_at_version, revoked_epoch) { + (Some(at_version), Some(at_epoch)) => Membership::Revoked(Revocation { + at_version: counter_from(at_version)?, + at_epoch: counter_from(at_epoch)?, + }), + (None, None) => Membership::Member { + role, + granted_epoch: counter_from(granted_epoch)?, + }, + // The two revocation columns are written together; one without the other is a + // row this server did not write. + _ => { + return Err( + PORT.undecodable("a member row carries half a revocation".to_owned()) + ); + } + }) + }) + } + + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option> { + Box::pin(async move { roster_of(&self.connection, album).await }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use super::super::PostgresMembership; + use crate::membership::MembershipStore; + use crate::membership::conformance::{self, Harness}; + use crate::postgres::testing; + + /// A store over one container. + #[derive(Debug)] + struct PostgresHarness { + members: PostgresMembership, + } + + impl Harness for PostgresHarness { + fn members(&self) -> &dyn MembershipStore { + &self.members + } + } + + #[tokio::test] + async fn the_postgres_membership_store_conforms() { + let Some(database) = testing::start("the Postgres membership store").await else { + return; + }; + let harness = PostgresHarness { + members: PostgresMembership::new(database.connection().clone()), + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/moderation/mod.rs b/capsule-server/src/moderation/mod.rs index 8f1d54ba..5ec577aa 100644 --- a/capsule-server/src/moderation/mod.rs +++ b/capsule-server/src/moderation/mod.rs @@ -23,22 +23,32 @@ //! forget: a takedown that failed to record itself is exactly the silent operation the rule //! forbids, and it would fail silently in the direction that hides it. //! -//! # What is not here, and why +//! # The federated half (`S-C49`) //! -//! - **Federated report intake** needs a peer's signing key to verify against, and federation -//! has no surface on this port. Its rate limit needs `S-C32`'s counter besides. -//! - **The server-level blocklist** operates at the federation-capability layer, which likewise -//! does not exist here. +//! Both halves design/moderation.md names are now here. **Federated report intake** is +//! [`ModerationStore::file_report`], written by `POST /v1/federation/reports` once the report's +//! Ed25519 signature verifies against the reporting peer's operator-pinned key and the +//! `(reporting_server, reported_user)` budget admits it; [`ModerationStore::pending_reports`] is +//! how an operator reads the queue. The content is a hash and an album pointer and nothing else, +//! because a report must not become a channel for a peer to say things about a user. //! -//! Both are `S-C8` deliverables and both are recorded as owed rather than stubbed, because a -//! blocklist nothing consults is worse than an absent one: it reads as protection. +//! **The server-level blocklist** is not here and is not meant to be: it operates at the +//! federation-capability layer, so it is a column on [`crate::federation::PeerRecord`] and is +//! consulted at mint, at every presentation, at refresh and at intake. Per-user blocks are +//! MLS-side and never propagate. +//! +//! # What is still not here, and why +//! +//! **An admin surface.** design/moderation.md names an admin queue and an admin who acts on it, +//! and specifies no way for that admin to authenticate. [`ModerationStore::pending_reports`] is +//! the queue; reading it over HTTP is what waits for an admin authentication model. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use jiff::Timestamp; -use crate::store::{AssetId, StoreFuture, UserId}; +use crate::store::{AlbumId, AssetId, StoreFuture, UserId}; /// Whether an account may act. #[derive(Debug, Clone, PartialEq, Eq)] @@ -117,6 +127,57 @@ pub struct ModerationEvent { pub reason: Option, } +/// A moderation report one peer server filed against an account on this one (`S-C49`). +/// +/// # The content is a pointer, not a complaint +/// +/// design/moderation.md fixes what a federated report may carry: the reported user, the asset's +/// **content hash** and the album it is in, and a short reason. No text about the person, no +/// evidence blob, no copy of anything. A report is a request that this server's operator look at +/// something it already holds — everything else would make the intake a channel for a peer to +/// publish claims about a user into this server's storage. +/// +/// # Re-verifiable, which means the *signed bytes* are what is kept +/// +/// The signature is kept so an operator can re-verify it long after the fact, and so a key +/// rotation cannot silently turn an accepted report into an unattributable one. That is only +/// true if what is stored is what was signed — and the fields below are not: `reporting_server` +/// is the canonical [`PeerId`](crate::federation::PeerId) form (case-folded, trailing dot +/// stripped) rather than the string the peer sent, and `reported_at` is a parsed instant rather +/// than the RFC 3339 text. Re-encoding those back into a claim would produce different bytes and +/// a signature that no longer verifies. +/// +/// So [`FederatedReport::signed`] holds the exact canonical-CBOR bytes the signature covers, and +/// every field below is **derived from them** at intake rather than assembled beside them. An +/// operator re-verifies with `signed` and the peer's pinned key and needs nothing else; +/// `moderation::tests` pins the round trip. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FederatedReport { + /// This server's identifier for the report, a UUIDv7. + pub report_id: String, + /// The peer that filed it, as its own `server-info` names it. + pub reporting_server: String, + /// The account on **this** server the report is about. + pub reported_user: UserId, + /// The content address of the asset complained about. + pub asset_hash: String, + /// The album it was pulled from. + pub album_id: AlbumId, + /// The peer's short reason, where it gave one. + pub reason: Option, + /// When the peer says it was reported. + pub reported_at: Timestamp, + /// When this server accepted it. The only timestamp this server vouches for. + pub received_at: Timestamp, + /// The peer's Ed25519 signature over [`Self::signed`]. + pub signature: Vec, + /// The exact canonical-CBOR bytes the signature covers. + /// + /// Stored verbatim, never rebuilt: every other field on this record is derived from these + /// bytes, and re-encoding a normalized field would produce a report nobody can attribute. + pub signed: Vec, +} + /// The account-standing and moderation-record port. pub trait ModerationStore: std::fmt::Debug + Send + Sync { /// Apply `event` and move `standing` to match, as one operation. @@ -134,8 +195,41 @@ pub trait ModerationStore: std::fmt::Debug + Send + Sync { /// The order is part of the contract: this is a user-visible surface, and a reader following /// what happened to their account needs it in the order it happened. fn events_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec>; + + /// Record a federated report (`S-C49`). + /// + /// Writes nothing about the reported account's standing: a peer's report is an *input* to a + /// decision, never a decision. Filing one has no effect a user can observe, which is why it + /// is not a [`ModerationEvent`] — the no-silent-operations rule is about actions taken + /// against a user, and nothing has been taken. + /// + /// # Errors + /// + /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if a report with the + /// same `report_id` is already recorded. The id is a fresh UUIDv7 per accepted report, so a + /// collision is a bug rather than a retry. + fn file_report(&self, report: FederatedReport) -> StoreFuture<'_, ()>; + + /// Every federated report on file, oldest first. + /// + /// "Pending" is the whole set until an admin surface exists to work through it — there is no + /// authentication model for the admin who would resolve one (see the module docs), so a + /// resolved state would be a column nothing could ever set. + fn pending_reports(&self) -> StoreFuture<'_, Vec>; } +/// The most federated reports the in-memory adapter keeps. +/// +/// Reports arrive on an unauthenticated route from parties an operator pinned, and an in-memory +/// map with no eviction is process memory that never returns — a `--memory` deployment left +/// running would grow until it did not. Ten thousand is far above any real queue an operator +/// works by hand and far below anything that matters to a process. +/// +/// **Eviction is oldest-first and loud.** A dropped report is a moderation input nobody will +/// ever see, so it is a `warn`, not a silent trim; the durable adapter (#476) is where a queue +/// that must not lose anything belongs. +pub const MAX_IN_MEMORY_REPORTS: usize = 10_000; + /// A deterministic in-memory adapter. #[derive(Debug, Default)] pub struct InMemoryModeration { @@ -146,6 +240,8 @@ pub struct InMemoryModeration { struct Inner { standing: BTreeMap, events: BTreeMap>, + /// Keyed by `report_id`, which is a UUIDv7 — so iteration order is arrival order. + reports: BTreeMap, } impl InMemoryModeration { @@ -191,6 +287,44 @@ impl ModerationStore for InMemoryModeration { }) } + fn file_report(&self, report: FederatedReport) -> StoreFuture<'_, ()> { + Box::pin(async move { + let mut inner = lock(&self.inner); + if inner.reports.contains_key(&report.report_id) { + return Err(crate::store::StoreError::Rejected { + store: "moderation", + detail: format!("report {} is already on file", report.report_id), + }); + } + tracing::info!( + report = %report.report_id, + from = %report.reporting_server, + about = %report.reported_user, + album = %report.album_id, + "a federated moderation report was filed" + ); + inner.reports.insert(report.report_id.clone(), report); + // The `report_id` is a UUIDv7, so the map's own order is arrival order and the first + // key is the oldest report. + while inner.reports.len() > MAX_IN_MEMORY_REPORTS { + let Some(oldest) = inner.reports.keys().next().cloned() else { + break; + }; + tracing::warn!( + report = %oldest, + kept = MAX_IN_MEMORY_REPORTS, + "the in-memory report queue is full; the oldest report was dropped" + ); + inner.reports.remove(&oldest); + } + Ok(()) + }) + } + + fn pending_reports(&self) -> StoreFuture<'_, Vec> { + Box::pin(async move { Ok(lock(&self.inner).reports.values().cloned().collect()) }) + } + fn events_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec> { Box::pin(async move { Ok(lock(&self.inner) diff --git a/capsule-server/src/negotiation.rs b/capsule-server/src/negotiation.rs new file mode 100644 index 00000000..e74e7a97 --- /dev/null +++ b/capsule-server/src/negotiation.rs @@ -0,0 +1,889 @@ +//! The protocol handshake, as one declaration for the whole router (issue #404). +//! +//! # The contract +//! +//! [Threat Model — Protocol and Capability +//! Negotiation](../../capsule-docs/src/content/docs/design/threat-model/validation.md) puts six +//! headers on the wire and says they are applied "by shared Kynos middleware to every public +//! route": three the client sends — `X-Capsule-Protocol`, `X-Capsule-Crypto-Suite`, +//! `X-Capsule-Sidecar-Schema` — and three the server answers with on **every** response of +//! every operation — +//! `X-Capsule-Protocol-Min`, `X-Capsule-Protocol-Max`, `X-Capsule-Min-Client-Build`. A +//! protocol outside the window is `426` with `error.protocol.version_unsupported`; a suite the +//! inventory does not name or a sidecar schema newer than this build knows is `400`. +//! +//! # Where it was broken, and why +//! +//! Before this module the handshake lived in `routes/upload.rs` as a per-route helper: four +//! operations read the request header, and the response window rode as problem *extension +//! members* because a Kynos `ApiError` "has no seam for a response header". Meanwhile +//! `capsule-sdk/src/upload.rs` reads the window **from headers** — and got `None` every time. +//! The `426` recovery path the design promises was dead on both ends, and no route outside the +//! upload surface advertised anything at all. +//! +//! Kynos *does* have the seam; it is just not on the error type. An [`Interceptor`]'s three +//! associated types are its declaration — `Reads` contributes request parameters, `Short` +//! contributes responses, `Adds` contributes response headers — and an interceptor sees every +//! response the chain beneath it produces, a short-circuit included. So the window belongs on an +//! interceptor mounted outside everything that can refuse, not on each refusal. +//! +//! # Three interceptors, deliberately +//! +//! - [`Negotiation`] **advertises**. `Reads = ()`, `Short = Infallible`, `Adds` the three +//! response headers. Mounted on the router, outside the body-size limit, so a `413`, a +//! `401`, a `426` and a `200` all leave with the window on them. It cannot refuse anything. +//! What it cannot reach is a response the router produced *before* choosing an operation — +//! an unrouted `404` or `405` — because Kynos runs interceptors per operation, after routing. +//! - [`ProtocolGate`] **refuses a write**. `Reads` the three request headers, `Adds = ()`, +//! `Short` is [`NegotiationRejection`]: `426` outside the window, `400` malformed. +//! - [`ProtocolReadGate`] **checks a read**. The same `Reads`, `Short` is +//! [`MalformedHandshake`]: `400` malformed, and a grammatical date outside the window is +//! *admitted* — "reads of any past version succeed" (threat-model/validation.md, Fail-Closed +//! Rules), and the `426` there is scoped to a write. +//! +//! The two gates are two `Group`s in `lib.rs::router`, one holding the non-safe operations and +//! one the `GET`/`HEAD` ones, which is how a per-method rule is spelled in a declaration that +//! is an interceptor's *type*: a read operation then declares the `400` and not the `426` it +//! can never render. A gate that read the method at run time would declare both on everything. +//! One interceptor doing all three jobs would also make the exemption impossible to express — +//! the response headers are wanted everywhere and the gates are not — and Kynos's conflict +//! check would refuse a second copy of either at a narrower scope. +//! +//! # What the gates read, and how strictly +//! +//! `X-Capsule-Protocol` is required on every gated operation: absent is a `400`, not a date is +//! a `400`. A date outside the window is a `426` on a write and admitted on a read; a *future* +//! date on a read is admitted too, because the design is silent on it and a read invariant +//! that is stable across past versions has nothing to refuse in a version it does not know. +//! The other two are validated **when present** — a suite the inventory does not implement and +//! a sidecar schema above [`MAX_KNOWN_SIDECAR_SCHEMA`] are each a `400` — and their absence is +//! not refused. The design scopes `X-Capsule-Crypto-Suite` to writes and +//! `X-Capsule-Sidecar-Schema` to metadata updates, every write already carries its suite in a +//! body the envelope gate checks, and a gate that demanded a header on a read that has no use +//! for it would refuse every client for a value nobody reads. +//! +//! They are nonetheless *declared* on every gated operation, reads included, as optional +//! parameters: an interceptor's `Reads` type is its declaration, and one type is mounted on +//! both groups. Declaring them on the write operations alone would need a second request type +//! that reads two headers instead of three and a third gate to carry it, for a document that +//! said "optional" either way. +//! +//! All three are read as strings and parsed here rather than typed by the framework, so a +//! malformed value is *this* module's coded `400` and not the framework's uncoded one. +//! +//! # The single home of the six names +//! +//! The header names are the constants at the top of this module and nowhere else in the +//! server. `capsule-wire` once carried a `headers` module for them; it is retired by #430, and +//! this crate adds no new use of it — once #430 lands, this module is the sole home. +//! +//! # `X-Capsule-Min-Client-Build` is advisory +//! +//! The design says "advisory unless the path is hard-deprecated", and no path is. The header is +//! sent, the value is the policy's, and nothing refuses on it. A deployment that has announced +//! no cutoff publishes `0.0.0`, which every build satisfies — the honest spelling of "no cutoff", +//! rather than an absent header a client could not tell from a server that never speaks it. +//! +//! # The document +//! +//! `Reads` and `Short` describe themselves through the interceptor's types. `Adds` describes +//! itself only on success responses (Kynos attaches an interceptor's response headers at +//! `StatusPattern::Success`, `kynos/src/middleware/erased.rs`), so +//! [`crate::openapi`] walks the emitted document once and files the same three headers under +//! every other response. The names and schemas both come from [`response_header_declarations`], +//! so the document and the wire cannot disagree about what a header is called. + +use std::convert::Infallible; + +use capsule_core::validation::protocol::{ + HandshakeReject, check_sidecar_schema, check_suite, protocol_gate, +}; +use capsule_i18n::error_codes; +use kynos::di::Provides; +use kynos::error::rejection::HeaderRejection; +use kynos::extract::params::header::{DecodeHeaders, EncodeHeaders, HeaderParams}; +use kynos::http::{HeaderMap, HeaderName, HeaderValue, Request}; +use kynos::middleware::{Continued, Interceptor, Next}; +use kynos::openapi::{Header, Parameter, Schema}; +use kynos::prelude::*; +use kynos::schema::registry::Registry; + +use crate::upload::{UploadContext, UploadPolicy}; + +/// The request header carrying the `YYYY-MM-DD` protocol version the request is written against. +pub const PROTOCOL: &str = "X-Capsule-Protocol"; +/// The request header carrying the `u16` crypto suite id, on writes. +pub const CRYPTO_SUITE: &str = "X-Capsule-Crypto-Suite"; +/// The request header carrying the `u16` sidecar schema version, on metadata updates. +pub const SIDECAR_SCHEMA: &str = "X-Capsule-Sidecar-Schema"; +/// The response header carrying the oldest protocol version this server accepts. +pub const PROTOCOL_MIN: &str = "X-Capsule-Protocol-Min"; +/// The response header carrying the newest protocol version this server accepts. +pub const PROTOCOL_MAX: &str = "X-Capsule-Protocol-Max"; +/// The response header carrying the advisory semver deprecation cutoff. +pub const MIN_CLIENT_BUILD: &str = "X-Capsule-Min-Client-Build"; + +/// The newest sidecar schema this build indexes. +/// +/// `capsule-core` keeps `SIDECAR_SCHEMA_V1` crate-private behind its frozen barrel (`#399`), so +/// the server states the number it will acknowledge here. The server never parses a sidecar — +/// this is the Postel cross-version closure the threat model asks for: a write whose schema +/// number this build cannot index is refused rather than acknowledged and lost. +pub const MAX_KNOWN_SIDECAR_SCHEMA: u16 = 1; + +// =========================================================================================== +// The request half +// =========================================================================================== + +/// The three request headers the handshake reads. +/// +/// Every field is optional at the *type* level so that a missing or unreadable one is this +/// module's coded rejection rather than the framework's uncoded `HeaderRejection`; whether a +/// header is required is decided by [`negotiate`] and declared by [`HeaderParams::parameters`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProtocolRequestHeaders { + /// `X-Capsule-Protocol`, verbatim. + pub protocol: Option, + /// `X-Capsule-Crypto-Suite`, verbatim. + pub crypto_suite: Option, + /// `X-Capsule-Sidecar-Schema`, verbatim. + pub sidecar_schema: Option, +} + +impl HeaderParams for ProtocolRequestHeaders { + // Lower-case, because these are what the conflict check compares and what a decoder looks + // up; the document spells them in their canonical case below. + const NAMES: &'static [&'static str] = &[ + "x-capsule-protocol", + "x-capsule-crypto-suite", + "x-capsule-sidecar-schema", + ]; + + fn parameters(registry: &mut Registry) -> Vec { + let _ = registry; + vec![ + Parameter::header(PROTOCOL, date_schema()) + .required(true) + .with_description( + "The `YYYY-MM-DD` protocol version this request is written against. \ + Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` \ + window the request is refused with `426`.", + ), + Parameter::header(CRYPTO_SUITE, u16_schema()) + .required(false) + .with_description( + "The crypto suite id from the primitives inventory. Sent on writes; a suite \ + this server does not implement is refused with `400`.", + ), + Parameter::header(SIDECAR_SCHEMA, u16_schema()) + .required(false) + .with_description( + "The sidecar schema version declared at `sidecar_schema` field 0. Sent on \ + metadata updates; a schema newer than this server indexes is refused with \ + `400`.", + ), + ] + } +} + +impl DecodeHeaders for ProtocolRequestHeaders { + fn decode(headers: &HeaderMap) -> Result { + Ok(Self { + protocol: read(headers, PROTOCOL)?, + crypto_suite: read(headers, CRYPTO_SUITE)?, + sidecar_schema: read(headers, SIDECAR_SCHEMA)?, + }) + } +} + +/// One header as text, or `None` when absent. +/// +/// The only failure is a value that is not visible ASCII, which is the one thing that cannot be +/// turned into a coded rejection here because it cannot be turned into a `String` at all. +fn read(headers: &HeaderMap, name: &str) -> Result, HeaderRejection> { + headers + .get(name) + .map(|value| { + value + .to_str() + .map(str::to_owned) + .map_err(|_| HeaderRejection::Invalid { + name: name.to_owned(), + detail: "the value is not printable ASCII".to_owned(), + }) + }) + .transpose() +} + +/// A malformed handshake, which every gated operation refuses the same way. +/// +/// Its own type rather than a variant shared with the `426`, because a Kynos rejection type +/// declares its statuses on every operation that returns it: [`ProtocolReadGate`] answers with +/// this alone, so a read declares the `400` and not a `426` it never renders. +#[derive(Debug, PartialEq, Eq, thiserror::Error, ApiError)] +pub enum MalformedHandshake { + /// A handshake header is missing, unreadable, or names something this server does not + /// implement. The `detail` says which. + #[error("{detail}")] + #[problem(status = 400, title = "Malformed handshake")] + Malformed { + /// What was wrong, in English. Reaches the client as the problem's `detail`. + detail: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl MalformedHandshake { + fn new(detail: impl Into) -> Self { + Self::Malformed { + detail: detail.into(), + code: error_codes::REQUEST_MALFORMED, + } + } +} + +/// Why the write gate refused a request. +/// +/// The `426` carries the window in its `detail` for a human and **on the response headers** +/// for a client — [`Negotiation`] sits outside this gate, so the refusal leaves with +/// `X-Capsule-Protocol-Min`/`-Max` on it like every other response. No extension member +/// restates them: two spellings of one fact is the drift the census exists to prevent. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum NegotiationRejection { + /// `X-Capsule-Protocol` is a date outside `[min, max]`. + #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] + #[problem(status = 426, title = "Protocol version unsupported")] + ProtocolUnsupported { + /// The lowest version this server accepts. + protocol_min: String, + /// The highest version this server accepts. + protocol_max: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A handshake header is missing, unreadable, or names something this server does not + /// implement. The `detail` says which. + #[error("{detail}")] + #[problem(status = 400, title = "Malformed handshake")] + Malformed { + /// What was wrong, in English. Reaches the client as the problem's `detail`. + detail: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl From for NegotiationRejection { + fn from(rejection: MalformedHandshake) -> Self { + match rejection { + MalformedHandshake::Malformed { detail, code } => Self::Malformed { detail, code }, + } + } +} + +/// What a well-formed handshake said about the protocol version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict { + /// Inside `[min, max]`. + InWindow, + /// A grammatical date outside `[min, max]` — a `426` on a write, admitted on a read. + OutOfWindow, +} + +/// The one-shot handshake: a client either speaks a version this server accepts, or it is +/// refused before any state is read or written. There is no negotiation and no degrade. +/// +/// Pure, so every outcome is unit-tested without a router. Returns the window verdict rather +/// than deciding what it means, because that depends on the method: [`ProtocolGate`] turns +/// [`Verdict::OutOfWindow`] into a `426` and [`ProtocolReadGate`] admits it. +/// +/// # Errors +/// +/// `400` for a missing or non-date protocol, a suite the inventory does not name, or a sidecar +/// schema above [`MAX_KNOWN_SIDECAR_SCHEMA`]. +pub fn negotiate( + policy: &UploadPolicy, + headers: &ProtocolRequestHeaders, +) -> Result { + let Some(protocol) = headers.protocol.as_deref() else { + return Err(MalformedHandshake::new(format!( + "{PROTOCOL} is required on this operation" + ))); + }; + let verdict = match protocol_gate(protocol, policy.protocol_min(), policy.protocol_max()) { + Ok(()) => Verdict::InWindow, + Err(HandshakeReject::ProtocolOutOfRange) => Verdict::OutOfWindow, + Err(_) => { + tracing::debug!( + presented = protocol, + "a request was refused: protocol is not a date" + ); + return Err(MalformedHandshake::new(format!( + "{PROTOCOL} is not a YYYY-MM-DD date" + ))); + } + }; + + if let Some(suite) = headers.crypto_suite.as_deref() { + let id = suite.trim().parse::().map_err(|_| { + MalformedHandshake::new(format!("{CRYPTO_SUITE} is not a u16 suite id")) + })?; + if check_suite(id).is_err() { + tracing::debug!( + suite = id, + "a request was refused: crypto suite not implemented" + ); + return Err(MalformedHandshake::new(format!( + "{CRYPTO_SUITE} {id} is not in this server's inventory" + ))); + } + } + + if let Some(schema) = headers.sidecar_schema.as_deref() { + let version = schema.trim().parse::().map_err(|_| { + MalformedHandshake::new(format!("{SIDECAR_SCHEMA} is not a u16 schema version")) + })?; + if check_sidecar_schema(version, MAX_KNOWN_SIDECAR_SCHEMA).is_err() { + tracing::debug!( + schema = version, + max_known = MAX_KNOWN_SIDECAR_SCHEMA, + "a request was refused: sidecar schema newer than this server indexes" + ); + return Err(MalformedHandshake::new(format!( + "{SIDECAR_SCHEMA} {version} is newer than this server indexes \ + ({MAX_KNOWN_SIDECAR_SCHEMA})" + ))); + } + } + + Ok(verdict) +} + +/// The write rule: a grammatical date outside the window is a `426`. +/// +/// # Errors +/// +/// Everything [`negotiate`] refuses, plus `426` for [`Verdict::OutOfWindow`]. +pub fn negotiate_write( + policy: &UploadPolicy, + headers: &ProtocolRequestHeaders, +) -> Result<(), NegotiationRejection> { + match negotiate(policy, headers)? { + Verdict::InWindow => Ok(()), + Verdict::OutOfWindow => { + tracing::info!( + presented = headers.protocol.as_deref().unwrap_or_default(), + min = policy.protocol_min(), + max = policy.protocol_max(), + "a write was refused: protocol version outside the accepted window" + ); + Err(NegotiationRejection::ProtocolUnsupported { + protocol_min: policy.protocol_min().to_owned(), + protocol_max: policy.protocol_max().to_owned(), + code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, + }) + } + } +} + +/// The read rule: a grammatical date outside the window is admitted. +/// +/// # Errors +/// +/// Everything [`negotiate`] refuses. +pub fn negotiate_read( + policy: &UploadPolicy, + headers: &ProtocolRequestHeaders, +) -> Result<(), MalformedHandshake> { + if negotiate(policy, headers)? == Verdict::OutOfWindow { + tracing::debug!( + presented = headers.protocol.as_deref().unwrap_or_default(), + min = policy.protocol_min(), + max = policy.protocol_max(), + "a read outside the protocol window was admitted" + ); + } + Ok(()) +} + +// =========================================================================================== +// The response half +// =========================================================================================== + +/// The three response headers every response carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiationResponseHeaders { + /// `X-Capsule-Protocol-Min`. + pub protocol_min: String, + /// `X-Capsule-Protocol-Max`. + pub protocol_max: String, + /// `X-Capsule-Min-Client-Build`. + pub min_client_build: String, +} + +impl NegotiationResponseHeaders { + /// The window a policy advertises — the same values it enforces, read from one place. + #[must_use] + pub fn advertise(policy: &UploadPolicy) -> Self { + Self { + protocol_min: policy.protocol_min().to_owned(), + protocol_max: policy.protocol_max().to_owned(), + min_client_build: policy.min_client_build().to_owned(), + } + } +} + +/// The response headers, as `(name, schema, description)`, in wire order. +/// +/// The one source for the document's two spellings of them — the header parameters Kynos +/// describes on success responses through [`HeaderParams::parameters`], and the response headers +/// the post-emit walk in [`crate::openapi`] files under every other response — so the two cannot +/// disagree about what a header is called or what it carries. +fn declarations() -> [(&'static str, Schema, &'static str); 3] { + [ + ( + PROTOCOL_MIN, + date_schema(), + "The oldest protocol version this server accepts.", + ), + ( + PROTOCOL_MAX, + date_schema(), + "The newest protocol version this server accepts.", + ), + ( + MIN_CLIENT_BUILD, + semver_schema(), + "The semver client build below which this server will stop answering. Advisory: \ + `0.0.0` when no cutoff has been announced.", + ), + ] +} + +/// The response headers as a response's `headers` map declares them: `(name, header)`. +#[must_use] +pub fn response_header_declarations() -> Vec<(&'static str, Header)> { + declarations() + .into_iter() + .map(|(name, schema, description)| { + ( + name, + Header::new(schema) + .required(true) + .with_description(description), + ) + }) + .collect() +} + +impl HeaderParams for NegotiationResponseHeaders { + const NAMES: &'static [&'static str] = &[ + "x-capsule-protocol-min", + "x-capsule-protocol-max", + "x-capsule-min-client-build", + ]; + + fn parameters(registry: &mut Registry) -> Vec { + let _ = registry; + declarations() + .into_iter() + .map(|(name, schema, description)| { + Parameter::header(name, schema) + .required(true) + .with_description(description) + }) + .collect() + } +} + +impl EncodeHeaders for NegotiationResponseHeaders { + fn encode(&self) -> Vec<(HeaderName, HeaderValue)> { + [ + ("x-capsule-protocol-min", self.protocol_min.as_str()), + ("x-capsule-protocol-max", self.protocol_max.as_str()), + ("x-capsule-min-client-build", self.min_client_build.as_str()), + ] + .into_iter() + .map(|(name, value)| { + // Total by construction: `config.rs` parses both window ends as `jiff::civil::Date` + // and the client-build cutoff as three dot-separated integers before a policy is + // built from them, and the crate defaults are literals of the same shapes. A value + // that reaches here and is not a header value is a policy built past the + // configuration boundary, which is a programming error and is reported as one. + let value = HeaderValue::from_str(value).unwrap_or_else(|error| { + panic!( + "{name} carries `{value}`, which config validation should have refused: \ + {error}" + ) + }); + (HeaderName::from_static(name), value) + }) + .collect() + } +} + +// =========================================================================================== +// The interceptors +// =========================================================================================== + +/// Advertises the protocol window on every response. +/// +/// Mounted on the whole router and outside the body-size limit, so nothing that refuses a +/// request — the framework's `413`, the bearer scheme's `401`, [`ProtocolGate`]'s `426` — can +/// answer without it. Cannot refuse: `Short` is [`Infallible`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct Negotiation; + +impl Negotiation { + /// The interceptor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Interceptor for Negotiation +where + C: Provides + Sync + 'static, +{ + type Reads = (); + type Adds = NegotiationResponseHeaders; + /// Advertising never refuses. + type Short = Infallible; + + async fn intercept( + &self, + request: Request, + (): (), + context: &C, + next: Next<'_, C>, + ) -> Result, Infallible> { + let upload: UploadContext = context.provide(); + let window = NegotiationResponseHeaders::advertise(upload.policy()); + Ok(next.run(request).await.with_headers(window)) + } +} + +/// Refuses a **write** whose handshake this server cannot honour, before the handler runs. +/// +/// Mounted on the `Group` holding the non-safe operations, rather than the router, because the +/// exemptions the design names — the reachability probe, public discovery, share reads, guest +/// deposits — are expressed by mounting those operations outside it, and because a read is held +/// to a different rule by [`ProtocolReadGate`]. See `lib.rs::router` for the lists and the +/// reasons. +#[derive(Debug, Clone, Copy, Default)] +pub struct ProtocolGate; + +impl ProtocolGate { + /// The interceptor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Interceptor for ProtocolGate +where + C: Provides + Sync + 'static, +{ + type Reads = ProtocolRequestHeaders; + type Adds = (); + type Short = NegotiationRejection; + + async fn intercept( + &self, + request: Request, + reads: ProtocolRequestHeaders, + context: &C, + next: Next<'_, C>, + ) -> Result, NegotiationRejection> { + let upload: UploadContext = context.provide(); + negotiate_write(upload.policy(), &reads)?; + Ok(next.run(request).await) + } +} + +/// Checks a **read**'s handshake for shape, and admits any grammatical protocol date. +/// +/// Mounted on the `Group` holding the `GET` and `HEAD` operations. "Reads of any past version +/// succeed" (threat-model/validation.md): a client pinned to a version this server no longer +/// accepts for writes can still read what it wrote, and learns the window from the response +/// headers rather than from a refusal. +#[derive(Debug, Clone, Copy, Default)] +pub struct ProtocolReadGate; + +impl ProtocolReadGate { + /// The interceptor. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl Interceptor for ProtocolReadGate +where + C: Provides + Sync + 'static, +{ + type Reads = ProtocolRequestHeaders; + type Adds = (); + type Short = MalformedHandshake; + + async fn intercept( + &self, + request: Request, + reads: ProtocolRequestHeaders, + context: &C, + next: Next<'_, C>, + ) -> Result, MalformedHandshake> { + let upload: UploadContext = context.provide(); + negotiate_read(upload.policy(), &reads)?; + Ok(next.run(request).await) + } +} + +// =========================================================================================== +// Schemas +// =========================================================================================== + +/// A `YYYY-MM-DD` protocol date. +fn date_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$", + })) + .expect("a literal date schema is a schema") +} + +/// A `u16`, as a header carries it. +fn u16_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "integer", + "minimum": 0, + "maximum": 65535, + })) + .expect("a literal integer schema is a schema") +} + +/// A semver build. +fn semver_schema() -> Schema { + serde_json::from_value(serde_json::json!({ + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + })) + .expect("a literal semver schema is a schema") +} + +#[cfg(test)] +mod tests { + use kynos::response::ShortCircuit as _; + + use super::*; + + fn headers( + protocol: Option<&str>, + suite: Option<&str>, + schema: Option<&str>, + ) -> ProtocolRequestHeaders { + ProtocolRequestHeaders { + protocol: protocol.map(str::to_owned), + crypto_suite: suite.map(str::to_owned), + sidecar_schema: schema.map(str::to_owned), + } + } + + fn policy() -> UploadPolicy { + UploadPolicy::default().with_protocol_window("2026-01-01", "2026-12-31") + } + + fn code(rejection: &NegotiationRejection) -> &'static str { + match rejection { + NegotiationRejection::ProtocolUnsupported { code, .. } + | NegotiationRejection::Malformed { code, .. } => code, + } + } + + #[test] + fn a_protocol_inside_the_window_passes_both_gates() { + // Both ends are inclusive. + for presented in ["2026-05-31", "2026-01-01", "2026-12-31"] { + let read = headers(Some(presented), None, None); + assert_eq!(negotiate(&policy(), &read), Ok(Verdict::InWindow)); + assert!(negotiate_write(&policy(), &read).is_ok()); + assert!(negotiate_read(&policy(), &read).is_ok()); + } + } + + #[test] + fn a_protocol_outside_the_window_is_426_on_a_write_and_admitted_on_a_read() { + // Past and future alike: the write rule is the window, the read rule is the grammar. + for presented in ["2025-12-31", "2027-01-01", "1999-01-01", "2099-12-31"] { + let read = headers(Some(presented), None, None); + assert_eq!(negotiate(&policy(), &read), Ok(Verdict::OutOfWindow)); + let refused = negotiate_write(&policy(), &read).expect_err("a write is refused"); + assert!( + matches!( + &refused, + NegotiationRejection::ProtocolUnsupported { protocol_min, protocol_max, .. } + if protocol_min == "2026-01-01" && protocol_max == "2026-12-31" + ), + "{presented}: {refused:?}" + ); + assert_eq!(code(&refused), error_codes::PROTOCOL_VERSION_UNSUPPORTED); + assert!( + negotiate_read(&policy(), &read).is_ok(), + "{presented}: reads of any version succeed" + ); + } + } + + #[test] + fn a_missing_or_non_date_protocol_is_400_on_every_gate() { + for presented in [None, Some("yesterday"), Some("2026/05/31"), Some("")] { + let read = headers(presented, None, None); + let MalformedHandshake::Malformed { code, .. } = + negotiate_read(&policy(), &read).expect_err("malformed"); + assert_eq!(code, error_codes::REQUEST_MALFORMED, "{presented:?}"); + let refused = negotiate_write(&policy(), &read).expect_err("malformed"); + assert!( + matches!(refused, NegotiationRejection::Malformed { .. }), + "{presented:?}: {refused:?}" + ); + assert_eq!(self::code(&refused), error_codes::REQUEST_MALFORMED); + } + } + + #[test] + fn the_suite_and_the_sidecar_schema_are_checked_when_present() { + let ok = Some("2026-05-31"); + let suite = capsule_core::crypto::primitives::CRYPTO_SUITE_ID.to_string(); + assert!(negotiate(&policy(), &headers(ok, Some(&suite), Some("1"))).is_ok()); + assert!(negotiate(&policy(), &headers(ok, Some(&suite), Some("0"))).is_ok()); + + for (suite, schema) in [ + (Some("9999"), None), + (Some("not a number"), None), + (None, Some("2")), + (None, Some("v1")), + ] { + let MalformedHandshake::Malformed { code, .. } = + negotiate(&policy(), &headers(ok, suite, schema)).expect_err("refused"); + assert_eq!( + code, + error_codes::REQUEST_MALFORMED, + "suite {suite:?}, schema {schema:?}" + ); + } + } + + #[test] + fn each_gate_declares_exactly_the_statuses_it_renders() { + let mut statuses = NegotiationRejection::STATUSES.to_vec(); + statuses.sort_unstable(); + assert_eq!(statuses, [400, 426], "a write gate refuses two ways"); + assert_eq!( + MalformedHandshake::STATUSES, + [400], + "a read gate refuses one way" + ); + } + + #[test] + fn the_response_group_encodes_what_it_declares() { + let window = NegotiationResponseHeaders { + protocol_min: "2026-01-01".to_owned(), + protocol_max: "2026-12-31".to_owned(), + min_client_build: "0.0.0".to_owned(), + }; + let encoded: Vec<(String, String)> = window + .encode() + .into_iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().expect("ascii").to_owned(), + ) + }) + .collect(); + assert_eq!( + encoded, + [ + ("x-capsule-protocol-min".to_owned(), "2026-01-01".to_owned()), + ("x-capsule-protocol-max".to_owned(), "2026-12-31".to_owned()), + ("x-capsule-min-client-build".to_owned(), "0.0.0".to_owned()), + ] + ); + + // The declared names, the encoded names and the documented names are one list. + let declared: Vec = response_header_declarations() + .into_iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect(); + assert_eq!(declared, NegotiationResponseHeaders::NAMES); + let documented: Vec = + NegotiationResponseHeaders::parameters(&mut Registry::default()) + .into_iter() + .map(|parameter| parameter.name.to_ascii_lowercase()) + .collect(); + assert_eq!(documented, NegotiationResponseHeaders::NAMES); + } + + /// A policy built past the configuration boundary with a non-header value is a programming + /// error, and is reported as one rather than silently sending a shorter response. + #[test] + #[should_panic(expected = "config validation should have refused")] + fn a_window_value_that_is_not_a_header_value_is_a_programming_error() { + let window = NegotiationResponseHeaders { + protocol_min: "2026-01-01".to_owned(), + protocol_max: "bad\nvalue".to_owned(), + min_client_build: "0.0.0".to_owned(), + }; + let _ = window.encode(); + } + + #[test] + fn the_request_group_documents_the_protocol_as_required_and_the_rest_as_optional() { + let parameters = ProtocolRequestHeaders::parameters(&mut Registry::default()); + let required: Vec<(&str, Option)> = parameters + .iter() + .map(|parameter| (parameter.name.as_str(), parameter.required)) + .collect(); + assert_eq!( + required, + [ + (PROTOCOL, Some(true)), + (CRYPTO_SUITE, Some(false)), + (SIDECAR_SCHEMA, Some(false)), + ] + ); + let declared: Vec = parameters + .iter() + .map(|parameter| parameter.name.to_ascii_lowercase()) + .collect(); + assert_eq!(declared, ProtocolRequestHeaders::NAMES); + } + + #[test] + fn decoding_reads_each_header_verbatim_and_tolerates_absence() { + let mut map = HeaderMap::new(); + assert_eq!( + ProtocolRequestHeaders::decode(&map).expect("absent is fine"), + ProtocolRequestHeaders::default() + ); + map.insert("x-capsule-protocol", HeaderValue::from_static("2026-05-31")); + map.insert("x-capsule-crypto-suite", HeaderValue::from_static("1")); + assert_eq!( + ProtocolRequestHeaders::decode(&map).expect("decodes"), + headers(Some("2026-05-31"), Some("1"), None) + ); + map.insert( + "x-capsule-sidecar-schema", + HeaderValue::from_bytes(b"\xff").expect("opaque bytes are a header value"), + ); + assert!(ProtocolRequestHeaders::decode(&map).is_err()); + } +} diff --git a/capsule-server/src/openapi/mod.rs b/capsule-server/src/openapi/mod.rs index f3415629..91dccf89 100644 --- a/capsule-server/src/openapi/mod.rs +++ b/capsule-server/src/openapi/mod.rs @@ -46,8 +46,8 @@ //! new `#[problem(extension)]` field that is not `code` will not appear in the document until //! somebody adds a row, and nothing here fails when they forget. //! -//! Three things bound that. The table is small — nine rows against sixteen non-`code` fields -//! across six enums — and `every_row_names_a_response_that_exists` fails on a row that has gone +//! Three things bound that. The table is small — six rows against the non-`code` fields +//! across the rejection enums — and `every_row_names_a_response_that_exists` fails on a row that has gone //! stale, so it cannot rot in the other direction. The `code` member, which is the one the i18n //! contract turns on and 104 of the 120 extension fields on this surface, needs no table at all. //! And the real fix is upstream: `#[problem(extension)]` should carry a schema, which is the @@ -88,6 +88,22 @@ struct Member { name: &'static str, /// Its JSON Schema type. json_type: &'static str, + /// Its JSON Schema `format`, when the type alone would lose the Rust one. + /// + /// Kynos gives the *body* schemas their `format` from the Rust type; this table is + /// hand-written, so a member that is a `u64` on the wire has to say so here, or the + /// document describes it as a bare integer and the contract is less true than the code. + /// + /// **It buys truthfulness, not safety.** spargen lowers every integer in this document as + /// `i64` and emits no `u64` at all — `RosterResponse.roster_version` carries + /// `format: uint64` *and* `minimum: 0` and is still generated as `i64`. So a member whose + /// value can exceed `i64::MAX` breaks the generated client's *decode*, which is not a typed + /// API error: the `code` and every recovery hint are discarded and the caller cannot tell + /// the refusal from a network fault. The defence is therefore not this field but the + /// bound on the value: every integer member in this table is one the server cannot emit + /// above `i64::MAX` (see `MAX_ROSTER_VERSION` for the roster counters), and a number that + /// cannot be bounded belongs in the English `detail`, not in an extension. + format: Option<&'static str>, /// What it means, for the client that has to act on it. description: &'static str, /// Whether the member may be `null` — the shape a `Option` extension renders as. @@ -110,30 +126,6 @@ struct Extra { /// /// See the module docs for why this is a table and what bounds the risk of one. const EXTRAS: &[Extra] = &[ - Extra { - component: "ProtocolRangeProblem", - operation: "create_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "append_chunk", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "head_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "cancel_upload", - status: 426, - members: PROTOCOL_RANGE, - }, Extra { component: "ProtocolRangeProblem", operation: "album_lifecycle_op", @@ -147,6 +139,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "existing_asset", json_type: "string", + format: None, description: "The asset already holding these exact bytes in the same album. \ Structured so a client merges rather than re-parsing a sentence \ (slice `S-C22`).", @@ -160,6 +153,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "offset", json_type: "integer", + format: None, description: "The offset the server is actually at, so a client resumes from it \ instead of asking again.", nullable: false, @@ -173,17 +167,54 @@ const EXTRAS: &[Extra] = &[ Member { name: "submitted", json_type: "integer", + format: None, description: "The directory version the request carried.", nullable: false, }, Member { name: "stored", json_type: "integer", + format: None, description: "The version the server holds. A client re-signs above this one.", nullable: false, }, ], }, + Extra { + component: "RosterVersionLeapProblem", + operation: "publish_album_roster", + status: 400, + members: &[ + Member { + name: "current_version", + json_type: "integer", + format: Some("uint64"), + description: "The roster version the server holds; `0` when it holds none.", + nullable: false, + }, + Member { + name: "max_version", + json_type: "integer", + format: Some("uint64"), + description: "The highest version this album would have accepted. A client \ + re-signs the same roster at `current_version + 1`; a version \ + nothing could supersede would freeze the album's membership.", + nullable: false, + }, + ], + }, + Extra { + component: "RosterStaleProblem", + operation: "publish_album_roster", + status: 409, + members: &[Member { + name: "current_version", + json_type: "integer", + format: Some("uint64"), + description: "The roster version the server holds. A client re-syncs and republishes above it.", + nullable: false, + }], + }, Extra { component: "StaleRevivalProblem", operation: "album_lifecycle_op", @@ -191,6 +222,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "chain_head", json_type: "string", + format: None, description: "The manifest hash the asset's chain is actually at. Absent when the \ conflict is not a chain conflict, which is why it is nullable.", nullable: true, @@ -203,23 +235,81 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "limit", json_type: "integer", + format: None, description: "The largest file this drop link accepts, in bytes.", nullable: false, }], }, + Extra { + component: "DropRateLimitedProblem", + operation: "create_drop", + status: 429, + members: RETRY_AFTER, + }, + Extra { + component: "EnrollmentRateLimitedProblem", + operation: "redeem_enrollment_code", + status: 429, + members: RETRY_AFTER, + }, + Extra { + component: "ShareMetadataRateLimitedProblem", + operation: "share_metadata", + status: 429, + members: RETRY_AFTER, + }, + Extra { + component: "ShareSecretRateLimitedProblem", + operation: "share_wrapped_secret", + status: 429, + members: RETRY_AFTER, + }, + Extra { + component: "ShareBlobRateLimitedProblem", + operation: "share_blob", + status: 429, + members: RETRY_AFTER, + }, ]; -/// The protocol window a `426` publishes, shared by every operation that pins one. +/// The retry hint every throttled response carries (`S-C32`). +/// +/// One `429` reaches a caller from two causes — the key's own budget spent, or the limiter's +/// partition full — and the `code` tells them apart. `retry_after` is what a client acts on +/// either way, which is why it is not nullable: both causes set it. +const RETRY_AFTER: &[Member] = &[Member { + name: "retry_after", + json_type: "integer", + // `counter::unix_seconds` returns a `u64`, so the document says so. It is bounded well under + // `i64::MAX` by construction — it is a wall-clock second derived from a live budget window — + // which is the property that matters, per this field's own documentation. + format: Some("uint64"), + description: "When the caller may retry, as Unix seconds. From the limiter's own window \ + when a budget is spent, and an upper bound of one window when the limiter \ + is at capacity (slice `S-C32`).", + nullable: false, +}]; + +/// The protocol window a **body-level** `426` publishes as extension members. +/// +/// One row is left: `album_lifecycle_op` refuses a manifest envelope pinned outside the window +/// and still renders the range in the body. The four upload operations no longer do — since +/// issue #404 the window rides `X-Capsule-Protocol-Min`/`-Max` on every response, header-gated +/// and body-gated `426`s alike, which is where the SDK reads it; a second spelling in the body +/// is the drift the census exists to prevent. The remaining row goes when `routes/ops.rs` +/// drops its members. const PROTOCOL_RANGE: &[Member] = &[ Member { name: "protocol_min", json_type: "string", + format: None, description: "The oldest protocol date this server still speaks (`YYYY-MM-DD`).", nullable: false, }, Member { name: "protocol_max", json_type: "string", + format: None, description: "The newest protocol date this server speaks (`YYYY-MM-DD`).", nullable: false, }, @@ -308,6 +398,60 @@ fn fill_binary(schema: &mut Option) { ); } +/// Files the protocol window's three response headers under every response (issue #404). +/// +/// [`crate::negotiation::Negotiation`] attaches `X-Capsule-Protocol-Min`, `-Max` and +/// `X-Capsule-Min-Client-Build` to **every** response it forwards, and it forwards everything — +/// a short-circuit from an inner interceptor, an extractor's rejection, a handler's answer. +/// Kynos describes an interceptor's `Adds` at `StatusPattern::Success` only +/// (`kynos/src/middleware/erased.rs`), so without this the document would promise the headers +/// on a `200` and stay silent on the `426` where a client most needs them. +/// +/// The declarations come from [`crate::negotiation::response_header_declarations`] — the same +/// source the interceptor's own description uses — and a response that already declares a +/// header under one of these names is left exactly as the router emitted it, so this can never +/// overwrite what Kynos said. +pub(crate) fn describe_negotiation_headers(document: &mut Document) { + let declarations = crate::negotiation::response_header_declarations(); + for item in document.paths.items.values_mut() { + let slots: Vec<&mut Option>> = vec![ + &mut item.get, + &mut item.put, + &mut item.post, + &mut item.delete, + &mut item.options, + &mut item.head, + &mut item.patch, + &mut item.trace, + &mut item.query, + ]; + for operation in slots.into_iter().filter_map(|slot| slot.as_deref_mut()) { + let responses = operation + .responses + .responses + .values_mut() + .chain(operation.responses.default_response.iter_mut()); + for response in responses { + let kynos::openapi::RefOr::Item(response) = response else { + continue; + }; + for (name, header) in &declarations { + let declared = response + .headers + .keys() + .any(|existing| existing.eq_ignore_ascii_case(name)); + if !declared { + response.headers.insert( + (*name).to_owned(), + kynos::openapi::RefOr::Item(header.clone()), + ); + } + } + } + } + } +} + pub(crate) fn describe_problem_extensions(document: &mut Document) { let Some(base) = document.components.schemas.get(BASE).cloned() else { return; @@ -391,6 +535,7 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { crate::problem::CODE_MEMBER.to_owned(), member_schema( "string", + None, false, "The stable `error.*` catalog code. The client localizes this; `detail` stays \ English. Present on every problem this server renders.", @@ -407,7 +552,12 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { for member in members { object.properties.insert( member.name.to_owned(), - member_schema(member.json_type, member.nullable, member.description), + member_schema( + member.json_type, + member.format, + member.nullable, + member.description, + ), ); } @@ -415,17 +565,25 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { } /// One member's schema. -fn member_schema(json_type: &str, nullable: bool, description: &str) -> Schema { +fn member_schema( + json_type: &str, + format: Option<&str>, + nullable: bool, + description: &str, +) -> Schema { let types = if nullable { serde_json::json!([json_type, "null"]) } else { serde_json::json!(json_type) }; - serde_json::from_value(serde_json::json!({ + let mut schema = serde_json::json!({ "type": types, "description": description, - })) - .expect("a literal member schema is a schema") + }); + if let Some(format) = format { + schema["format"] = serde_json::json!(format); + } + serde_json::from_value(schema).expect("a literal member schema is a schema") } #[cfg(test)] diff --git a/capsule-server/src/postgres/error.rs b/capsule-server/src/postgres/error.rs new file mode 100644 index 00000000..04b8585a --- /dev/null +++ b/capsule-server/src/postgres/error.rs @@ -0,0 +1,159 @@ +//! One `DbErr` → [`StoreError`] mapping, for every Postgres adapter (#402). +//! +//! # Why one function and not one per adapter +//! +//! [`StoreError`]'s three variants are a *decision* a caller acts on — the operation certainly +//! did not happen, the operation may or may not have happened, the stored value cannot be read +//! back — and four adapters classifying the same `DbErr` four times is four chances to get that +//! decision subtly different. The route above them maps the variant onto an `error.*` code, so a +//! divergence here shows up as one port answering `503` where another answers `500` for the same +//! dropped socket. +//! +//! # How the three variants are decided +//! +//! Deliberately on the `DbErr` variant alone, without reaching into `sqlx::Error`. Two reasons, +//! and the second is the load-bearing one: +//! +//! - Naming `sqlx::Error` means depending on `sqlx` directly, which is a second pin of a crate +//! sea-orm already owns. +//! - **A statement that failed mid-flight has not "certainly not happened".** +//! [`StoreError::Unavailable`] promises exactly that, so the tempting mapping — any I/O error +//! under `DbErr::Exec` is `Unavailable` — would be a lie in the one case it matters: a +//! connection dropped after the server committed. That case is +//! [`StoreError::Rejected`], whose contract is "whether state changed is unknown". So +//! `Unavailable` is reserved for the failures that happen *before* a statement is sent — +//! acquiring a connection, opening one — and everything else that is not a decoding failure is +//! `Rejected`. +//! +//! `DbErr::RecordNotFound` never reaches here as an error: every port in this crate models +//! absence as an `Option` or an outcome variant, and the adapters answer it from a row count +//! rather than from a raised error. + +use sea_orm::DbErr; + +use crate::store::StoreError; + +/// Which port is speaking, and what record it holds. +/// +/// A value rather than two parameters at every call site: an adapter declares it once as a +/// constant and every `map_err` reads as the operation it was doing. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Port { + /// The port's name, for the log line and the `StoreError` field. + pub(crate) store: &'static str, + /// The record type its rows decode into. + pub(crate) record: &'static str, +} + +impl Port { + /// A closure that turns a `DbErr` raised while `doing` into a [`StoreError`]. + /// + /// Returned as a closure so a call site reads + /// `.map_err(PORT.failing("reserving an asset row"))?`, with the operation named once beside + /// the statement that performs it rather than repeated in a `match`. + pub(crate) fn failing(self, doing: &'static str) -> impl Fn(DbErr) -> StoreError { + move |error| self.classify(doing, &error) + } + + /// A stored value that could not be read back as the record type that owns its key. + /// + /// Raised by the adapters themselves — an unknown enum discriminant, a content address that + /// no longer parses, an `i64` that is not a valid instant — rather than by the driver. It is + /// the variant `store/mod.rs` keeps for a rolling deploy that left an older encoding behind. + pub(crate) fn undecodable(self, detail: impl Into) -> StoreError { + let detail = detail.into(); + tracing::error!( + store = self.store, + record = self.record, + %detail, + "a stored row could not be read back as the record that owns it" + ); + StoreError::Corrupt { + store: self.store, + record: self.record, + detail, + } + } + + /// The classification itself, split out so it is testable without a database. + fn classify(self, doing: &str, error: &DbErr) -> StoreError { + let detail = format!("{doing}: {error}"); + match error { + // Before any statement was sent. The operation certainly did not happen. + DbErr::Conn(_) | DbErr::ConnectionAcquire(_) => { + tracing::error!(store = self.store, %detail, "the Postgres backend is unreachable"); + StoreError::Unavailable { + store: self.store, + detail, + } + } + // The driver reached a value it could not turn into the Rust type the column is + // read as — a schema the running binary was not built against. + DbErr::Type(_) + | DbErr::Json(_) + | DbErr::TryIntoErr { .. } + | DbErr::ConvertFromU64(_) => self.undecodable(detail), + // Everything else: the statement was sent and did not succeed. Whether state + // changed is unknown, which is exactly what `Rejected` means. + _ => { + tracing::warn!(store = self.store, %detail, "Postgres refused an operation"); + StoreError::Rejected { + store: self.store, + detail, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use sea_orm::{ConnAcquireErr, DbErr, RuntimeErr}; + + use super::Port; + use crate::store::StoreError; + + const PORT: Port = Port { + store: "test", + record: "TestRecord", + }; + + #[test] + fn a_pool_timeout_is_unavailable_because_nothing_was_sent() { + let error = PORT.classify( + "acquiring a connection", + &DbErr::ConnectionAcquire(ConnAcquireErr::Timeout), + ); + assert!(matches!(error, StoreError::Unavailable { .. }), "{error:?}"); + } + + #[test] + fn a_failed_statement_is_rejected_and_never_unavailable() { + // The distinction the module exists for: a connection dropped *after* the server + // committed is indistinguishable here from one dropped before, and `Unavailable` + // promises the operation did not happen. Promising that wrongly is how a caller + // retries a write that already landed. + let error = PORT.classify( + "recording a blob", + &DbErr::Exec(RuntimeErr::Internal("connection reset".to_owned())), + ); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + } + + #[test] + fn a_value_that_will_not_decode_is_corrupt_and_names_the_record() { + let error = PORT.classify("reading a row", &DbErr::Type("not an i64".to_owned())); + match error { + StoreError::Corrupt { record, .. } => assert_eq!(record, "TestRecord"), + other => panic!("a decoding failure must be Corrupt, got {other:?}"), + } + } + + #[test] + fn an_adapter_raised_decoding_failure_is_the_same_variant() { + // The adapters raise this themselves for an unknown enum discriminant or an address + // that no longer parses — the driver cannot, because those columns are plain text. + let error = PORT.undecodable("`elsewhere` is not an asset state"); + assert!(matches!(error, StoreError::Corrupt { .. }), "{error:?}"); + } +} diff --git a/capsule-server/src/postgres/mod.rs b/capsule-server/src/postgres/mod.rs new file mode 100644 index 00000000..53abac52 --- /dev/null +++ b/capsule-server/src/postgres/mod.rs @@ -0,0 +1,254 @@ +//! The Postgres adapters' shared half: the pool, the error mapping, the instant conversion, and +//! the container harness the conformance suites run against (#402). +//! +//! # Why the adapters are not in here +//! +//! Only the cross-cutting machinery lives in this module. Each adapter lives beside the port it +//! implements — `index/postgres.rs`, `auth/accounts_postgres.rs`, `store/cohorts_postgres.rs`, +//! `quota/postgres.rs`, `membership/postgres.rs` — because design/module-map.md assigns +//! contract ownership per *behaviour module* rather than per backend, and the tree already does it that way for the blob store +//! (`blob/fs.rs`, `blob/memory.rs`). A `postgres/` tree holding every adapter would make one +//! directory the co-owner of every durable contract in the crate. +//! +//! Nothing forces the alternative either: no invariant in these ports needs a transaction +//! spanning two of them. The sequence mint is inside `record_blob`, the attribution check is +//! inside `QuotaStore::charge`, and the account row is one table. +//! +//! # Migrations are applied by a separate binary, and `serve` refuses to run without them +//! +//! `capsule-server` takes `capsule-server-migration` as a **dev-dependency only**, so +//! `Migrator::up` is not reachable from the server binary at all — see that crate's manifest for +//! why (the `chrono` gate). What the server does instead is [`assert_schema_current`]: it reads +//! `seaql_migrations` and refuses to boot unless every ordinal it was built against has been +//! applied, naming the command an operator has to run. +//! +//! That is also the safer rollout. A server that migrates on start runs the migration once per +//! replica during a rolling deploy — a schema change racing itself — and gives every replica +//! the privileges a DDL statement needs. + +pub mod error; +pub mod time; + +#[cfg(test)] +pub(crate) mod testing; + +use std::time::Duration; + +use sea_orm::{ConnectOptions, Database, DatabaseConnection, DbBackend, Statement}; + +/// The migrations this binary was built against, oldest first. +/// +/// Compiled in rather than read from the migration crate, which `capsule-server` cannot link. +/// `postgres::tests::the_expected_migrations_are_the_migrations_that_exist` compares the two +/// under `cfg(test)`, where the migration crate *is* available — so the list cannot drift from +/// the crate that defines it without a red test. +pub const EXPECTED_MIGRATIONS: &[&str] = &[ + "m20260902_000001_asset_index", + "m20260902_000002_accounts", + "m20260902_000003_cohorts", + "m20260902_000004_quota", + "m20260902_000005_album_membership", + "m20260902_000006_federation", +]; + +/// The command an operator runs to apply them. +const MIGRATION_COMMAND: &str = "capsule-server-migration up"; + +/// Why a Postgres-backed process could not start. +/// +/// A **startup** failure in both variants. Neither ever carries the connection URL: a +/// `DATABASE_URL` holds a password, and a startup error is the most-copied line in any incident +/// channel. +#[derive(Debug, thiserror::Error)] +pub enum PostgresError { + /// The pool could not be opened. + #[error("the Postgres connection could not be opened: {detail}")] + Connect { + /// The driver's own description. Never the URL. + detail: String, + }, + /// The schema is not the one this binary was built against. + #[error("the database schema is not current: {detail}; run `{MIGRATION_COMMAND}`")] + Schema { + /// Which ordinals are missing, or why the ledger could not be read. + detail: String, + }, +} + +/// Open the pool `serve` and the operator workers share. +/// +/// The numbers are a deployment default rather than a tuning surface, and each one is a +/// consequence of what this server does: +/// +/// - `max_connections(32)` — the durable ports are on the request path but not the *byte* path; +/// a chunk append touches the blob store and Valkey, never Postgres. Thirty-two is comfortably +/// above the concurrency a finalization-bound workload reaches and well under a default +/// `max_connections = 100` shared with the migration binary and an operator's `psql`. +/// - `connect_timeout(5s)` / `acquire_timeout(10s)` — a request that waits longer than this has +/// already lost; failing it as `Unavailable` is what lets the route answer rather than hang. +/// - `idle_timeout(10m)` / `max_lifetime(30m)` — a connection that outlives a rolling restart of +/// the database is a connection that discovers it is dead inside somebody's transaction. +/// - `sqlx_logging(false)` — `tracing` owns the log line. sqlx's own would double every query +/// *and* print bound parameters, which here means an email address and an Argon2id PHC string. +/// +/// # Errors +/// +/// Returns [`PostgresError::Connect`] if the pool cannot be opened. +pub async fn connect(database_url: &str) -> Result { + let mut options = ConnectOptions::new(database_url.to_owned()); + options + .max_connections(32) + .min_connections(2) + .connect_timeout(Duration::from_secs(5)) + .acquire_timeout(Duration::from_secs(10)) + .idle_timeout(Duration::from_mins(10)) + .max_lifetime(Duration::from_mins(30)) + .sqlx_logging(false); + + let connection = Database::connect(options) + .await + .map_err(|error| PostgresError::Connect { + detail: error.to_string(), + })?; + tracing::info!("opened the Postgres connection pool"); + Ok(connection) +} + +/// Refuse to continue unless every migration in [`EXPECTED_MIGRATIONS`] has been applied. +/// +/// Reads `seaql_migrations` — the ledger `sea-orm-migration` maintains — and compares its +/// applied names against the compiled-in list. A **missing** ordinal is fatal: the binary would +/// query a table or a column that does not exist, and the first symptom would be a 500 on +/// whichever request reached it first. +/// +/// An ordinal the database holds and this binary does not know about is **not** fatal, and that +/// asymmetry is the rolling-deploy contract: during a deploy the migration runs first and the +/// old replicas keep serving, so a newer schema underneath an older binary is the normal state +/// for the length of the rollout. A migration that removes something an older binary reads is a +/// migration that has to be split in two, which is a property of the migration rather than +/// something this check can enforce. +/// +/// # Errors +/// +/// Returns [`PostgresError::Schema`] if the ledger cannot be read or an expected ordinal is +/// absent. +pub async fn assert_schema_current(connection: &DatabaseConnection) -> Result<(), PostgresError> { + use sea_orm::ConnectionTrait as _; + + let statement = Statement::from_string( + DbBackend::Postgres, + "SELECT version FROM seaql_migrations".to_owned(), + ); + let rows = connection + .query_all(statement) + .await + .map_err(|error| PostgresError::Schema { + detail: format!("`seaql_migrations` could not be read ({error})"), + })?; + + let mut applied = Vec::with_capacity(rows.len()); + for row in rows { + let version: String = + row.try_get("", "version") + .map_err(|error| PostgresError::Schema { + detail: format!("`seaql_migrations.version` could not be read ({error})"), + })?; + applied.push(version); + } + + let missing: Vec<&str> = EXPECTED_MIGRATIONS + .iter() + .copied() + .filter(|expected| !applied.iter().any(|held| held == expected)) + .collect(); + if !missing.is_empty() { + return Err(PostgresError::Schema { + detail: format!("{} has not been applied", missing.join(", ")), + }); + } + + tracing::info!( + applied = applied.len(), + expected = EXPECTED_MIGRATIONS.len(), + "the database schema is current" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use server_migration::MigratorTrait as _; + + use super::{EXPECTED_MIGRATIONS, assert_schema_current, testing}; + + /// The compiled-in list is the migration crate's list. + /// + /// `capsule-server` cannot link the migrator in a normal build, so the list it refuses to + /// boot on is a copy. This is the assertion that keeps the copy honest — and it needs no + /// container, so it runs on every ordinary test pass. + #[test] + fn the_expected_migrations_are_the_migrations_that_exist() { + let defined: Vec = server_migration::Migrator::migrations() + .iter() + .map(|migration| migration.name().to_owned()) + .collect(); + assert_eq!( + defined, EXPECTED_MIGRATIONS, + "`postgres::EXPECTED_MIGRATIONS` is what `serve` refuses to boot without; it has \ + drifted from `capsule-server-migration`" + ); + } + + mod postgres_conformance { + use sea_orm::ConnectionTrait as _; + use server_migration::MigratorTrait as _; + + use super::{EXPECTED_MIGRATIONS, assert_schema_current, testing}; + + /// Up, down, up: the schema a rollback leaves behind is one the next deploy can build on. + #[tokio::test] + async fn the_migrations_apply_and_roll_back() { + let Some(database) = testing::start("the migration round trip").await else { + return; + }; + let connection = database.connection(); + + assert_schema_current(connection) + .await + .expect("a freshly migrated database is current"); + + server_migration::Migrator::down(connection, None) + .await + .expect("every migration rolls back"); + let error = assert_schema_current(connection) + .await + .expect_err("a rolled-back schema is not current"); + assert!( + format!("{error}").contains("capsule-server-migration up"), + "the refusal must name the command that fixes it, got {error}" + ); + + server_migration::Migrator::up(connection, None) + .await + .expect("every migration re-applies"); + assert_schema_current(connection) + .await + .expect("the schema is current again"); + + // And the ledger holds exactly the ordinals the server was built against — not an + // extra one a partially-applied `down` left behind. + let rows = connection + .query_all(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "SELECT version FROM seaql_migrations ORDER BY version".to_owned(), + )) + .await + .expect("the ledger is readable"); + let applied: Vec = rows + .iter() + .map(|row| row.try_get("", "version").expect("a version column")) + .collect(); + assert_eq!(applied, EXPECTED_MIGRATIONS); + } + } +} diff --git a/capsule-server/src/postgres/testing.rs b/capsule-server/src/postgres/testing.rs new file mode 100644 index 00000000..3cc50b66 --- /dev/null +++ b/capsule-server/src/postgres/testing.rs @@ -0,0 +1,173 @@ +//! The container harness the Postgres conformance suites run against. +//! +//! # The gate is load-bearing, not decorative +//! +//! `cargo nextest run` must be green on a machine with no container runtime — that is the +//! acceptance gap design/module-map.md sets for the framework, and it is what lets the rest of +//! the rebuild be tested without live infrastructure. So every Postgres case asks [`start`] +//! first, and [`start`] returns `None` — after printing why — unless `CAPSULE_TEST_POSTGRES=1` +//! is set. +//! +//! A **skip that says nothing** is the failure mode this is written against: a suite that +//! silently runs zero cases reads exactly like a suite that passes. Every skip prints one line +//! naming the case and how to run it. +//! +//! # One container per test, and why that is the right trade here +//! +//! nextest runs a process per test, so a `OnceCell` shared between cases would buy nothing — +//! each process would fill its own. Rather than pretend otherwise, each port contributes +//! exactly **one** container-backed test that runs its whole suite in one [`run_all`]-style +//! pass, which is what `index/conformance.rs` said a Postgres smoke test should be. Five +//! containers per run, serialized by the `containers` nextest group (`.config/nextest.toml`), +//! is a few seconds; thirty would not be. +//! +//! A fresh container per test also removes the isolation problem entirely: no schema +//! namespacing, no truncation between cases, and no case that passes because a previous one +//! left the database in a convenient state. + +use sea_orm::DatabaseConnection; +use server_migration::MigratorTrait as _; +use testcontainers::ContainerAsync; +use testcontainers::runners::AsyncRunner as _; +use testcontainers_modules::postgres::Postgres; + +/// The environment variable that admits the container-backed cases. +pub(crate) const GATE: &str = "CAPSULE_TEST_POSTGRES"; + +/// The image tag every run pins. +/// +/// **`18`, and glibc rather than alpine**, which is a decision with two halves. +/// +/// The major is the one a deployment runs: `capsule-server/compose.yaml` and `.env.example` ship +/// PostgreSQL 18, and design/filesystem/server.md records it. A harness that tested a different +/// major would be proving the adapters work against something nobody deploys. +/// +/// The libc is load-bearing for one case. musl collates `en_US.utf8` byte-for-byte, so on an +/// alpine image `index/conformance.rs`'s `the_row_walk_orders_by_the_identifiers_own_bytes` +/// cannot tell a query that pins `COLLATE "C"` from one that inherits the database's collation — +/// it passes either way, which is worse than failing. glibc orders +/// `walkord-a-b, walkord-ab, walkord-a-c` where bytes order +/// `walkord-a-b, walkord-a-c, walkord-ab`, so on this image the case is an assertion. Bump the +/// tag deliberately, and keep it glibc. +const POSTGRES_TAG: &str = "18"; + +/// Where the container keeps its cluster. +/// +/// Off the image's declared `VOLUME` deliberately. The data of a container that lives for one +/// test is throwaway by definition, so an anonymous volume buys nothing and costs two things: a +/// volume to create and reap per test, and — on a rootless runtime, where the volume is created +/// with an ownership the container's own user cannot `chmod` — an `initdb` that fails before +/// Postgres ever listens. +const PGDATA: &str = "/tmp/pgdata"; + +/// The user-namespace mode to run the container under, when the host needs one. +/// +/// Unset on an ordinary Docker host and on CI, which is why this is an environment variable +/// rather than a constant: `keep-id` is podman's spelling and Docker rejects it. +/// +/// It exists because of a real and non-obvious host shape. Under **rootless podman**, a +/// container process that is not the container's root maps to a host *subuid*, and that subuid +/// has to traverse the image store to reach anything — so on a machine whose home directory is +/// not world-traversable (`drwxrws---`), the official Postgres image dies at +/// `gosu postgres /usr/local/bin/docker-entrypoint.sh` with a bare "permission denied" that +/// names the entrypoint and says nothing about why. `--userns=keep-id` maps every container uid +/// onto the invoking user, which both fixes the traversal and makes the entrypoint skip its +/// drop-privileges branch entirely. +const USERNS_MODE: &str = "CAPSULE_TEST_CONTAINER_USERNS"; + +/// A running Postgres with the server's schema applied. +/// +/// Holds the container: dropping this stops and removes it, so a case cannot leak one. +#[derive(Debug)] +pub(crate) struct TestDatabase { + _container: ContainerAsync, + connection: DatabaseConnection, + url: String, +} + +impl TestDatabase { + /// The pool the adapter under test is built over. + pub(crate) fn connection(&self) -> &DatabaseConnection { + &self.connection + } + + /// The connection string, for a case that has to drive a boot path rather than an adapter. + pub(crate) fn url(&self) -> &str { + &self.url + } + + /// Undo every migration, leaving a reachable database with no schema. + /// + /// What `boot`'s unmigrated case needs: a database that answers and has nothing in it is the + /// state a deployment is in between `docker compose up` and the migration command, and it is + /// exactly the state `assert_schema_current` exists to refuse. + /// + /// # Panics + /// + /// Panics if the rollback fails; a harness that cannot reach the state the case is about has + /// nothing useful to report. + pub(crate) async fn roll_back(&self) { + server_migration::Migrator::down(&self.connection, None) + .await + .unwrap_or_else(|error| panic!("the schema could not be rolled back: {error}")); + } +} + +/// Whether the container-backed cases are admitted. +fn enabled() -> bool { + matches!(std::env::var(GATE).as_deref(), Ok("1")) +} + +/// Start a Postgres for `case`, or explain why it was skipped. +/// +/// `None` is a **skip**, and the caller returns rather than failing: the whole point of the gate +/// is that a run with no container runtime is green. +/// +/// # Panics +/// +/// Panics if the gate is set and the container cannot be started or migrated. An operator who +/// asked for the Postgres tier and did not get it must be told, not quietly skipped — that would +/// make the gate a way to hide a broken adapter. +pub(crate) async fn start(case: &str) -> Option { + if !enabled() { + eprintln!( + "skipping {case}: the Postgres conformance tier is unavailable. Set {GATE}=1 with a \ + reachable container runtime — for rootless podman, `systemctl --user start \ + podman.socket`, `export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock` \ + and `export {USERNS_MODE}=keep-id`." + ); + return None; + } + + let mut request = testcontainers::ImageExt::with_tag(Postgres::default(), POSTGRES_TAG); + request = testcontainers::ImageExt::with_env_var(request, "PGDATA", PGDATA); + if let Ok(userns) = std::env::var(USERNS_MODE) { + request = testcontainers::ImageExt::with_userns_mode(request, &userns); + } + let container = request.start().await.unwrap_or_else(|error| { + panic!("{GATE}=1 was set but a Postgres container could not be started: {error}") + }); + let host = container + .get_host() + .await + .unwrap_or_else(|error| panic!("the container has no reachable host: {error}")); + let port = container + .get_host_port_ipv4(5432) + .await + .unwrap_or_else(|error| panic!("the container published no port: {error}")); + // The module's own defaults; there is no secret here to keep out of a source file. + let url = format!("postgres://postgres:postgres@{host}:{port}/postgres"); + + let connection = super::connect(&url) + .await + .unwrap_or_else(|error| panic!("the test container refused a connection: {error}")); + server_migration::Migrator::up(&connection, None) + .await + .unwrap_or_else(|error| panic!("the server's schema could not be applied: {error}")); + + Some(TestDatabase { + _container: container, + connection, + url, + }) +} diff --git a/capsule-server/src/postgres/time.rs b/capsule-server/src/postgres/time.rs new file mode 100644 index 00000000..e08ac9af --- /dev/null +++ b/capsule-server/src/postgres/time.rs @@ -0,0 +1,176 @@ +//! `jiff::Timestamp` ⇄ `BIGINT` microseconds since the Unix epoch. +//! +//! # Why an integer and not `TIMESTAMPTZ` +//! +//! Binding a `TIMESTAMPTZ` needs one of sea-orm's datetime features, and both are refused. With +//! `with-chrono` the server joins the chrono path that design/dependencies.md makes a +//! review-blocking gate; with `with-time` the workspace gains a third datetime crate with no row +//! in that table. Without either there is no Rust type for the column at all, so the choice is +//! an integer column or a banned dependency. +//! +//! This is the server's exact analogue of the CLI's "chrono only at the entity boundary" rule: +//! the conversion happens here, at the adapter's edge, and every layer above speaks +//! [`jiff::Timestamp`]. design/dependencies.md already blesses integer epochs as a serialized +//! form. +//! +//! # What it costs +//! +//! No SQL date arithmetic. Every expiry and retention comparison in the durable ports is an +//! ordering on one column — `updated_at`, `first_seen`, `retention_until` — and integers order +//! identically, so nothing in scope wants it. A future port that needs `now() - interval` gets +//! the arithmetic in Rust, above the boundary, where the clock is already injected. +//! +//! # Microseconds, not nanoseconds +//! +//! `i64` nanoseconds run out in 2262 and `jiff` represents instants well past that, so the +//! conversion would be lossy at the wrong end. Microseconds cover `Timestamp`'s whole range in +//! an `i64` and are PostgreSQL's own `timestamp` resolution, so a column later migrated to +//! `TIMESTAMPTZ` loses nothing. +//! +//! # Two lossy edges, and what is done about each +//! +//! **Sub-microsecond precision is dropped.** `Timestamp` carries nanoseconds and a `BIGINT` of +//! microseconds cannot, so an instant that goes into a column comes back truncated toward zero. +//! That matters for one thing only: an adapter that builds a record in Rust and returns it +//! *without* reading it back would hand a caller a value the next read does not produce. +//! [`stored`] is what such an adapter puts the instant through, so the record it returns is the +//! record the database holds. +//! +//! **`jiff` will not read back its own maximum.** `Timestamp::MAX` is +//! `9999-12-30T22:00:00.999999999Z`, and `Timestamp::from_microsecond` accepts nothing past the +//! whole second below it — so the obvious round trip is *not* total, and a naive +//! `to_micros`-then-`from_micros` on `MAX` is a column no adapter can decode. [`to_micros`] +//! therefore clamps, exactly as [`crate::store::deadline`] clamps for the same reason: an +//! instant that far out is indistinguishable from never, and clamping is what gives that. The +//! bounds are derived from `jiff`'s own constants rather than written down, so a repin cannot +//! silently move them out from under this. + +use jiff::Timestamp; + +/// The largest microsecond value [`from_micros`] will read back. +/// +/// Derived rather than hardcoded: `Timestamp::MAX` carries a fractional second that +/// `Timestamp::from_microsecond` refuses, and the whole second below it is the boundary. +fn storable_max() -> i64 { + Timestamp::MAX.as_second().saturating_mul(1_000_000) +} + +/// The smallest microsecond value [`from_micros`] will read back. +/// +/// `Timestamp::MIN` lands on a whole second, so it needs no adjustment — and deriving it anyway +/// is what keeps this pair honest if a repin changes either end. +fn storable_min() -> i64 { + Timestamp::MIN.as_microsecond() +} + +/// The instant `at`, as microseconds since the Unix epoch, clamped to what can be read back. +/// +/// The clamp is reachable only in the last second of representable time, and it is the same +/// choice [`crate::store::deadline`] makes there. Writing the unclamped value would put a number +/// in the column that [`from_micros`] rejects — a row this server wrote and cannot decode. +pub(crate) fn to_micros(at: Timestamp) -> i64 { + at.as_microsecond().clamp(storable_min(), storable_max()) +} + +/// The instant `micros` microseconds after the Unix epoch, or `None` if it is not one. +/// +/// `None` is a **corrupt row**, not a missing value: the column is `NOT NULL` wherever it is +/// required, and [`to_micros`] cannot produce a value outside the range, so an unreadable one +/// came from something that did not write it through this module. The adapters map it onto +/// [`StoreError::Corrupt`](crate::store::StoreError::Corrupt). +pub(crate) fn from_micros(micros: i64) -> Option { + Timestamp::from_microsecond(micros).ok() +} + +/// `at` as the schema will hold it. +/// +/// What an adapter puts an incoming instant through before keeping it in a record it returns +/// without re-reading. Idempotent, and total: [`to_micros`] clamps, so the value always reads +/// back. +pub(crate) fn stored(at: Timestamp) -> Timestamp { + // The `unwrap_or` is unreachable while `to_micros` clamps, and is a clamp rather than a + // panic because a composition root has no business dying over an instant. + from_micros(to_micros(at)).unwrap_or(at) +} + +#[cfg(test)] +mod tests { + use jiff::{SignedDuration, Timestamp}; + + use super::{from_micros, stored, to_micros}; + + #[test] + fn an_instant_survives_the_round_trip() { + let at = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(1_700_000_000); + assert_eq!(from_micros(to_micros(at)), Some(at)); + } + + #[test] + fn the_epoch_is_zero_so_a_column_is_readable_by_a_human_with_a_calculator() { + assert_eq!(to_micros(Timestamp::UNIX_EPOCH), 0); + assert_eq!(from_micros(0), Some(Timestamp::UNIX_EPOCH)); + } + + #[test] + fn instants_order_the_way_their_integers_do() { + // The whole justification for giving up SQL date arithmetic: every comparison these + // tables make is an ordering, and an ordering is preserved. + let earlier = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(10); + let later = Timestamp::UNIX_EPOCH + SignedDuration::from_secs(20); + assert!(earlier < later); + assert!(to_micros(earlier) < to_micros(later)); + } + + #[test] + fn every_instant_this_module_writes_can_be_read_back() { + // The property the adapters actually depend on, and it is *not* "MAX round-trips". + // `Timestamp::MAX` is `9999-12-30T22:00:00.999999999Z` and + // `Timestamp::from_microsecond` refuses anything past the whole second below it, so an + // unclamped conversion would put a number in a `NOT NULL` column that this server + // cannot decode — a corrupt row of its own making. `to_micros` clamps instead. + assert!(from_micros(to_micros(Timestamp::MAX)).is_some()); + assert!(from_micros(to_micros(Timestamp::MIN)).is_some()); + // Microseconds rather than nanoseconds precisely so the *bottom* end survives: an `i64` + // of nanoseconds overflows in 2262, well inside `Timestamp`'s range. + assert_eq!(from_micros(to_micros(Timestamp::MIN)), Some(Timestamp::MIN)); + // And an integer no adapter wrote is refused rather than turned into some other instant. + assert_eq!(from_micros(i64::MAX), None); + assert_eq!(from_micros(i64::MIN), None); + } + + #[test] + fn sub_microsecond_precision_is_dropped_and_stored_says_so() { + // A `BIGINT` of microseconds cannot carry nanoseconds. What matters is that an adapter + // knows: a record it builds in Rust and returns without re-reading has to go through + // `stored`, or the value it hands a caller differs from the value the next read + // produces. + let precise = Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"); + let truncated = stored(precise); + assert_ne!( + truncated, precise, + "the nanoseconds are gone, and that is the point" + ); + assert_eq!( + truncated, + Timestamp::from_microsecond(1_700_000_000_123_456).expect("an instant"), + ); + assert_eq!(stored(truncated), truncated, "and `stored` is idempotent"); + } + + #[test] + fn the_clamp_is_a_clamp_and_not_a_wrap() { + // The one thing worse than losing the last second of year 9999 would be storing an + // instant that reads back as some *other* instant. + let clamped = stored(Timestamp::MAX); + assert!(clamped <= Timestamp::MAX); + assert_eq!(stored(clamped), clamped); + assert_eq!(from_micros(to_micros(clamped)), Some(clamped)); + } + + #[test] + fn a_negative_instant_is_before_the_epoch_rather_than_a_failure() { + let before = Timestamp::UNIX_EPOCH - SignedDuration::from_secs(1); + assert_eq!(to_micros(before), -1_000_000); + assert_eq!(from_micros(-1_000_000), Some(before)); + } +} diff --git a/capsule-server/src/quota/conformance.rs b/capsule-server/src/quota/conformance.rs new file mode 100644 index 00000000..2f3ea2af --- /dev/null +++ b/capsule-server/src/quota/conformance.rs @@ -0,0 +1,413 @@ +//! The one suite every [`QuotaStore`] adapter must pass. +//! +//! # What is here and what stayed in `tests.rs` +//! +//! Most of the quota module is a **pure** state machine — [`state_of`](super::state_of) and +//! [`admits`](super::admits) — and those cases read as a table with nothing to mock. They stay +//! in `quota/tests.rs`, because a suite generic over a store cannot say anything about a +//! function that takes no store. +//! +//! What is here is everything the *ledger* owes: the dedup rule, the two releases, and the +//! over-limit clock. Every one of those was written against `InMemoryQuota` only, which made +//! the double an unproven stand-in for exactly the adapter that has to get concurrency right. +//! +//! # The rule the suite exists to protect +//! +//! Attribution is keyed on the **content address, globally**, and a blob shared between two +//! uploaders counts against only the first. That is not a courtesy: without it a malicious user +//! could exhaust another account's quota by re-uploading blobs whose addresses they already +//! know. [`charging_the_same_address_twice_debits_once`] is that rule, and it is the case an +//! adapter that reached for "check, then debit" would fail. +//! +//! # Reusing a harness +//! +//! Every case scopes its own users and addresses, so cases may share one ledger and [`run_all`] +//! does. + +use jiff::{SignedDuration, Timestamp}; + +use super::{ChargeOutcome, QuotaLimits, QuotaStore}; +use crate::blob::ContentAddress; +use crate::store::{StoreError, UserId}; + +/// The ledger under test. +/// +/// One accessor and no time seam: nothing in this port expires, and the one instant it stores — +/// the moment an account crossed its hard limit — is an **argument** to +/// [`QuotaStore::charge`] rather than something read from a clock. That is deliberate in the +/// port and it is why this harness is one method: an adapter cannot get the crossing's timestamp +/// wrong by reading the wrong clock, because it never reads one. +pub trait Harness: Send + Sync { + /// The ledger under test. + fn quotas(&self) -> &dyn QuotaStore; +} + +/// Unwrap a ledger result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming ledger must succeed at {doing}: {error}"), + } +} + +/// A deployment with real limits: 100 bytes soft, 200 hard, a 14-day grace. +fn limits() -> QuotaLimits { + QuotaLimits::new(100, 200, super::DEFAULT_GRACE_WINDOW) +} + +/// An instant `days` after the epoch. +fn day(days: i64) -> Timestamp { + Timestamp::UNIX_EPOCH + SignedDuration::from_hours(days * 24) +} + +/// `case`'s own account `n`. +fn user(case: &str, n: u8) -> UserId { + UserId::new(format!("{case}-user-{n}")) +} + +/// A deterministic content address for `case`'s blob `n`. +/// +/// Hashed rather than formatted because a content address is 64 lowercase-hex characters and +/// `ContentAddress::parse` is the only gate that says so. +fn address(case: &str, n: u8) -> ContentAddress { + let mut seed = case.as_bytes().to_vec(); + seed.push(n); + ContentAddress::parse(&capsule_core::crypto::hash::hash_bytes(&seed).to_hex()) + .expect("a digest is a content address") +} + +// =========================================================================================== +// Attribution +// =========================================================================================== + +/// A blob shared between two uploaders is charged to the first only. +/// +/// The rule that stops a malicious user exhausting another account's quota by re-uploading blobs +/// whose addresses they already know. The second uploader is a merge, not a second copy. +pub async fn charging_the_same_address_twice_debits_once(h: &dyn Harness) { + let first = user("shared", 1); + let second = user("shared", 2); + let shared = address("shared", 1); + + assert_eq!( + ok( + h.quotas() + .charge(&first, &shared, 64, day(0), limits()) + .await, + "charge an address", + ), + ChargeOutcome::Charged { used: 64 }, + ); + assert_eq!( + ok( + h.quotas() + .charge(&second, &shared, 64, day(0), limits()) + .await, + "charge an address a second account already holds", + ), + ChargeOutcome::AlreadyAttributed, + ); + assert_eq!( + ok(h.quotas().usage(&second).await, "read usage").used, + 0, + "the second uploader is a merge, not a second copy" + ); + assert_eq!( + ok(h.quotas().usage(&first).await, "read usage").used, + 64, + "and the first is still charged exactly once" + ); +} + +/// `AlreadyAttributed` is one value whoever holds the address. +/// +/// Telling a caller "somebody else already holds these bytes" would answer, from a quota +/// endpoint, the cross-tenant question `AssetIndex::find_by_address` is owner-scoped to avoid. +/// Structural — the variant has no payload — and asserted here so a richer one cannot be added +/// without a case going red. +pub async fn the_already_attributed_answer_says_nothing_about_who_holds_it(h: &dyn Harness) { + let mine = user("silent", 1); + let stranger = user("silent", 2); + let held = address("silent", 1); + ok( + h.quotas().charge(&mine, &held, 32, day(0), limits()).await, + "charge an address", + ); + + let outcome = ok( + h.quotas() + .charge(&stranger, &held, 32, day(0), limits()) + .await, + "charge an address another account holds", + ); + let rendered = format!("{outcome:?}"); + assert!( + !rendered.contains(mine.as_str()), + "the refusal named the account that holds the address: {rendered}" + ); +} + +/// Usage is per account, and an account that has been charged nothing owes nothing. +pub async fn an_uncharged_account_owes_nothing(h: &dyn Harness) { + let stranger = user("fresh", 1); + let usage = ok(h.quotas().usage(&stranger).await, "read usage"); + assert_eq!(usage.used, 0); + assert_eq!(usage.over_since, None); +} + +// =========================================================================================== +// Releases +// =========================================================================================== + +/// Releasing a reservation credits the bytes back, once, and frees the address. +pub async fn releasing_a_reservation_credits_the_bytes_back(h: &dyn Harness) { + let owner = user("release", 1); + let held = address("release", 1); + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge an address", + ); + + assert!(ok(h.quotas().release(&owner, &held).await, "release")); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 0); + assert!( + !ok(h.quotas().release(&owner, &held).await, "release again"), + "a second release must not credit the bytes twice" + ); + + // And the address is free again, so a later uploader is charged for it. + assert_eq!( + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge a released address", + ), + ChargeOutcome::Charged { used: 64 }, + ); +} + +/// Another account's reservation is not releasable. +/// +/// Releasing somebody else's attribution would let one account free bytes off another's ledger — +/// and, worse, tell them the address was attributed. +pub async fn another_accounts_reservation_is_not_releasable(h: &dyn Harness) { + let owner = user("steal", 1); + let other = user("steal", 2); + let held = address("steal", 1); + ok( + h.quotas().charge(&owner, &held, 64, day(0), limits()).await, + "charge an address", + ); + + assert!(!ok( + h.quotas().release(&other, &held).await, + "release another account's attribution", + )); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 64); +} + +/// The collector releases by address, and the ledger names the account (`S-C44`). +/// +/// A sweep knows an address and nothing else — attribution is global by content address, so the +/// blob it is deleting may be charged to an account with no remaining connection to the asset +/// whose purge exposed it. The collector cannot supply the user and must not guess one. +pub async fn the_collector_releases_by_address_and_the_ledger_names_the_account(h: &dyn Harness) { + let owner = user("sweep", 1); + let swept = address("sweep", 1); + ok( + h.quotas() + .charge( + &owner, + &swept, + 1_024, + Timestamp::UNIX_EPOCH, + QuotaLimits::unlimited(), + ) + .await, + "charge an address", + ); + + let released = ok( + h.quotas().release_attribution(&swept).await, + "release by address", + ); + assert_eq!(released, Some((owner.clone(), 1_024))); + assert_eq!(ok(h.quotas().usage(&owner).await, "read usage").used, 0); +} + +/// Releasing an address the ledger never saw is `None` rather than an error. +/// +/// The ordinary case for a blob the ledger never saw. A sweep that treated it as a failure would +/// stall on the first one. +pub async fn releasing_an_unattributed_address_is_none_rather_than_an_error(h: &dyn Harness) { + assert_eq!( + ok( + h.quotas().release_attribution(&address("unseen", 1)).await, + "release an unattributed address", + ), + None, + ); +} + +// =========================================================================================== +// The over-limit clock +// =========================================================================================== + +/// The crossing is stamped once and cleared by going under. +/// +/// `over_since` is kept by the store rather than derived, because "how long have you been over" +/// cannot be computed from a current total. A later charge while still over must not restamp it, +/// or the grace window would never expire for an account that keeps trying to upload. +pub async fn the_crossing_is_stamped_once_and_cleared_by_going_under(h: &dyn Harness) { + let owner = user("clock", 1); + ok( + h.quotas() + .charge(&owner, &address("clock", 1), 150, day(0), limits()) + .await, + "charge an address", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + "150 is over the soft limit and under the hard one" + ); + + ok( + h.quotas() + .charge(&owner, &address("clock", 2), 100, day(1), limits()) + .await, + "charge an address", + ); + let over = ok(h.quotas().usage(&owner).await, "read usage"); + assert_eq!(over.used, 250); + assert_eq!(over.over_since, Some(day(1))); + + ok( + h.quotas() + .charge(&owner, &address("clock", 3), 10, day(9), limits()) + .await, + "charge an address while over", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + Some(day(1)), + "a later charge while still over must not restamp the clock" + ); + + // Going back under stops the clock, so a later crossing gets a fresh window rather than + // inheriting an expired one. + ok( + h.quotas().release(&owner, &address("clock", 2)).await, + "release", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + ); +} + +/// A collector release clears the over-limit clock exactly like a user release. +/// +/// Both releases credit the same way, which is why the in-memory adapter shares one helper: a +/// second copy would eventually forget `over_since`, leaving an account back under its limit +/// still carrying the clock that decides when a soft limit becomes a hard one. +pub async fn a_collector_release_clears_the_over_limit_clock(h: &dyn Harness) { + let owner = user("sweepclock", 1); + let held = address("sweepclock", 1); + let tight = QuotaLimits::new(1_000, 1_500, SignedDuration::from_hours(24)); + ok( + h.quotas() + .charge(&owner, &held, 2_000, Timestamp::UNIX_EPOCH, tight) + .await, + "charge an address", + ); + assert!( + ok(h.quotas().usage(&owner).await, "read usage") + .over_since + .is_some() + ); + + ok( + h.quotas().release_attribution(&held).await, + "release by address", + ); + assert_eq!( + ok(h.quotas().usage(&owner).await, "read usage").over_since, + None, + "back under the limit, so a later crossing gets a fresh window" + ); +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + charging_the_same_address_twice_debits_once(h).await; + the_already_attributed_answer_says_nothing_about_who_holds_it(h).await; + an_uncharged_account_owes_nothing(h).await; + + releasing_a_reservation_credits_the_bytes_back(h).await; + another_accounts_reservation_is_not_releasable(h).await; + the_collector_releases_by_address_and_the_ledger_names_the_account(h).await; + releasing_an_unattributed_address_is_none_rather_than_an_error(h).await; + + the_crossing_is_stamped_once_and_cleared_by_going_under(h).await; + a_collector_release_clears_the_over_limit_clock(h).await; +} + +#[cfg(test)] +mod tests { + use super::{Harness, run_all}; + use crate::quota::{InMemoryQuota, QuotaStore}; + + /// The deterministic ledger. + #[derive(Debug, Default)] + struct MemoryHarness { + quotas: InMemoryQuota, + } + + impl Harness for MemoryHarness { + fn quotas(&self) -> &dyn QuotaStore { + &self.quotas + } + } + + /// Declares one `#[tokio::test]` per conformance case. + /// + /// One test each, on a fresh ledger each: a failure names the property that broke, and no + /// case can pass because a previous one left the ledger in a convenient state. + macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + super::$case(&MemoryHarness::default()).await; + } + )+ + }; + } + + conformance_cases! { + charging_the_same_address_twice_debits_once, + the_already_attributed_answer_says_nothing_about_who_holds_it, + an_uncharged_account_owes_nothing, + releasing_a_reservation_credits_the_bytes_back, + another_accounts_reservation_is_not_releasable, + the_collector_releases_by_address_and_the_ledger_names_the_account, + releasing_an_unattributed_address_is_none_rather_than_an_error, + the_crossing_is_stamped_once_and_cleared_by_going_under, + a_collector_release_clears_the_over_limit_clock, + } + + /// The whole suite, in one pass on one ledger. + /// + /// The entry point a container-backed adapter uses, so it is exercised here too — otherwise + /// the first time anyone ran it would be against Postgres, where a failure is hardest to + /// read. It also proves the cases really are independent. + #[tokio::test] + async fn the_in_memory_ledger_conforms() { + run_all(&MemoryHarness::default()).await; + } +} diff --git a/capsule-server/src/quota/mod.rs b/capsule-server/src/quota/mod.rs index 1b2110fd..fff21a2a 100644 --- a/capsule-server/src/quota/mod.rs +++ b/capsule-server/src/quota/mod.rs @@ -491,5 +491,10 @@ pub async fn current_state( )) } +pub mod conformance; +pub mod postgres; + +pub use self::postgres::PostgresQuota; + #[cfg(test)] mod tests; diff --git a/capsule-server/src/quota/postgres.rs b/capsule-server/src/quota/postgres.rs new file mode 100644 index 00000000..6603cf42 --- /dev/null +++ b/capsule-server/src/quota/postgres.rs @@ -0,0 +1,329 @@ +//! [`PostgresQuota`] — the durable quota ledger (`S-C6`, #402). +//! +//! # The total is derived, and the clock is not +//! +//! `quota_attributions` holds one row per charged content address. A user's `used` is +//! `SUM(size)` over their rows and is **never** a stored column, for the reason +//! [`crate::index::AssetIndex::reference_count`] is a query: a stored total is a second copy of +//! a derivable fact, and one that drifts low hands somebody free storage. +//! +//! What cannot be derived is *when* an account crossed the hard limit and has not been under it +//! since — a current total says nothing about how long it has been that total. That single +//! instant is the whole of `quota_usage`. +//! +//! # Why `charge` is a transaction and not one statement +//! +//! The port's requirement is that the check and the debit are atomic against two concurrent +//! sessions for one address, and the `ON CONFLICT (address) DO NOTHING` insert is exactly that on +//! its own: the primary key decides, and the row count is the answer. What needs the transaction +//! is the *second* half — after a successful debit the adapter has to re-total the account and +//! stamp `over_since` if that debit crossed the limit, and a crash between the two would leave an +//! account over its limit with no crossing recorded. The port models that state +//! (`state_of` treats "over with no recorded crossing" as newly over rather than expired, +//! deliberately, so the missing timestamp cannot lock somebody out of the writes that free +//! space) — but leaving it reachable when one statement away is a choice, not a fallback. + +use jiff::Timestamp; +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; + +use super::{ChargeOutcome, QuotaLimits, QuotaStore, StoredUsage}; +use crate::blob::ContentAddress; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; +use crate::store::{StoreError, StoreFuture, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "quota", + record: "StoredUsage", +}; + +/// The durable quota ledger. +#[derive(Debug, Clone)] +pub struct PostgresQuota { + connection: DatabaseConnection, +} + +impl PostgresQuota { + /// A ledger over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// A byte count as the column holds it. +fn size_to_column(size: u64) -> Result { + i64::try_from(size).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{size} bytes is past what a BIGINT column holds"), + }) +} + +/// A byte count as the port speaks it. +fn size_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a byte count"))) +} + +/// The account's total and crossing, read through `connection`. +/// +/// One statement with two scalar subqueries rather than two round trips, so the total and the +/// clock come from one snapshot: read separately, a concurrent release between them could report +/// a total that is already under the limit beside a crossing that has already been cleared. +async fn usage_of( + connection: &C, + user: &UserId, +) -> Result { + let read = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT \ + (SELECT COALESCE(SUM(size), 0)::bigint FROM quota_attributions WHERE user_id = $1) \ + AS used, \ + (SELECT over_since FROM quota_usage WHERE user_id = $1) AS over_since", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an account's usage"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the usage query returned no row".to_owned(), + })?; + let failed = PORT.failing("reading an account's usage"); + let used: i64 = read.try_get("", "used").map_err(&failed)?; + let over_since: Option = read.try_get("", "over_since").map_err(&failed)?; + Ok(StoredUsage { + used: size_from(used)?, + over_since: over_since + .map(|micros| { + from_micros(micros).ok_or_else(|| { + PORT.undecodable(format!("{micros}µs is not a representable instant")) + }) + }) + .transpose()?, + }) +} + +/// Give an account's bytes back, and stop its over-limit clock. +/// +/// The clock is cleared **unconditionally**, exactly as the in-memory ledger's `credit` does, and +/// that is the contract rather than an approximation: an account that is still over after a +/// release gets a *fresh* window rather than inheriting a running one. Two copies of this rule +/// would eventually disagree, which is why the in-memory adapter has one helper and this has one +/// statement. +async fn stop_the_clock( + transaction: &DatabaseTransaction, + user: &UserId, +) -> Result<(), StoreError> { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE quota_usage SET over_since = NULL WHERE user_id = $1", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("clearing an account's over-limit clock"))?; + Ok(()) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +impl QuotaStore for PostgresQuota { + fn usage<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, StoredUsage> { + Box::pin(async move { usage_of(&self.connection, user).await }) + } + + fn charge<'a>( + &'a self, + user: &'a UserId, + address: &'a ContentAddress, + size: u64, + at: Timestamp, + limits: QuotaLimits, + ) -> StoreFuture<'a, ChargeOutcome> { + Box::pin(async move { + let size = size_to_column(size)?; + let transaction = begin(&self.connection).await?; + + // The primary key decides, and the row count is the answer. Two concurrent sessions + // for one address cannot both read "unattributed" and both debit, because neither + // reads: they both insert, and one of them affects no row. + let debited = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO quota_attributions (address, user_id, size, charged_at) \ + VALUES ($1, $2, $3, $4) ON CONFLICT (address) DO NOTHING", + [ + Value::from(address.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + Value::from(size), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("charging an address"))?; + if debited.rows_affected() == 0 { + // Already attributed — to this account or to another, and the port answers one + // value for both so a quota endpoint cannot become a cross-tenant oracle. + return Ok(ChargeOutcome::AlreadyAttributed); + } + + // The account's row in `quota_usage` exists from its first charge onwards, and holds + // nothing but the clock. + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO quota_usage (user_id, over_since) VALUES ($1, NULL) \ + ON CONFLICT (user_id) DO NOTHING", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("opening an account's usage row"))?; + + let used = usage_of(&transaction, user).await?.used; + if used >= limits.hard_limit { + // `WHERE over_since IS NULL` is what stamps the crossing **once**: a later charge + // while still over must not restamp it, or the grace window would never expire + // for an account that keeps trying to upload. + let stamped = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE quota_usage SET over_since = $2 \ + WHERE user_id = $1 AND over_since IS NULL", + [ + Value::from(user.as_str().to_owned()), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("stamping an account's hard-limit crossing"))?; + if stamped.rows_affected() == 1 { + tracing::info!(%user, used, "an account crossed its hard quota limit"); + } + } + + commit(transaction).await?; + Ok(ChargeOutcome::Charged { used }) + }) + } + + fn release<'a>( + &'a self, + user: &'a UserId, + address: &'a ContentAddress, + ) -> StoreFuture<'a, bool> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + // Scoped to the account in the `DELETE` itself. Releasing somebody else's + // attribution would let one account free bytes off another's ledger — and a + // read-then-check would answer whether the address was attributed at all, which is + // the disclosure the port refuses. + let released = transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM quota_attributions WHERE address = $1 AND user_id = $2", + [ + Value::from(address.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("releasing an attribution"))?; + if released.rows_affected() == 0 { + return Ok(false); + } + stop_the_clock(&transaction, user).await?; + commit(transaction).await?; + Ok(true) + }) + } + + fn release_attribution<'a>( + &'a self, + address: &'a ContentAddress, + ) -> StoreFuture<'a, Option<(UserId, u64)>> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + // The collector's release (`S-C44`): a sweep knows an address and nothing else, so + // the ledger names the account rather than the caller guessing one. `RETURNING` is + // what makes the delete and the answer one operation. + let released = transaction + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "DELETE FROM quota_attributions WHERE address = $1 RETURNING user_id, size", + [Value::from(address.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("releasing an attribution by address"))?; + let Some(released) = released else { + // The ordinary case for a blob the ledger never saw, and not an error: a sweep + // that treated it as one would stall on the first. + return Ok(None); + }; + let failed = PORT.failing("reading a released attribution"); + let owner: String = released.try_get("", "user_id").map_err(&failed)?; + let size: i64 = released.try_get("", "size").map_err(&failed)?; + let owner = UserId::new(owner); + stop_the_clock(&transaction, &owner).await?; + commit(transaction).await?; + let size = size_from(size)?; + tracing::info!( + user = %owner, + %address, + size, + "a swept blob's bytes were credited back to the account they were charged to" + ); + Ok(Some((owner, size))) + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use super::super::PostgresQuota; + use crate::postgres::testing; + use crate::quota::QuotaStore; + use crate::quota::conformance::{self, Harness}; + + /// A ledger over one container. + #[derive(Debug)] + struct PostgresHarness { + quotas: PostgresQuota, + } + + impl Harness for PostgresHarness { + fn quotas(&self) -> &dyn QuotaStore { + &self.quotas + } + } + + #[tokio::test] + async fn the_postgres_ledger_conforms() { + let Some(database) = testing::start("the Postgres quota ledger").await else { + return; + }; + let harness = PostgresHarness { + quotas: PostgresQuota::new(database.connection().clone()), + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/quota/tests.rs b/capsule-server/src/quota/tests.rs index 8e6a04a5..35d918c0 100644 --- a/capsule-server/src/quota/tests.rs +++ b/capsule-server/src/quota/tests.rs @@ -1,7 +1,15 @@ -//! The quota port's own suite. +//! The quota module's own suite: the **pure** half. //! -//! Most of it is the state machine, which is pure — so the cases that matter read as a table of -//! "this much used, this long over, this kind of write" and there is nothing to mock. +//! [`state_of`] and [`admits`] take no store, so the cases that matter read as a table of "this +//! much used, this long over, this kind of write" and there is nothing to mock. A suite generic +//! over an adapter cannot say anything about a function that takes none, which is why these +//! stayed here when the ledger's cases moved. +//! +//! Everything the *ledger* owes — the dedup rule, the two releases and the over-limit clock — +//! is in [`super::conformance`] (#402), so it runs against `InMemoryQuota` and against the +//! Postgres adapter from one list. Those cases used to live here against the double only, which +//! made the double an unproven stand-in for exactly the adapter that has to get concurrency +//! right. use super::*; @@ -15,15 +23,6 @@ fn day(days: i64) -> Timestamp { Timestamp::UNIX_EPOCH + SignedDuration::from_hours(days * 24) } -fn user() -> UserId { - UserId::new("01937b7c-0000-7000-8000-000000000001") -} - -fn address(seed: u8) -> ContentAddress { - ContentAddress::parse(&capsule_core::crypto::hash::hash_bytes(&[seed; 8]).to_hex()) - .expect("a content address") -} - #[test] fn the_states_follow_the_thresholds() { let now = day(0); @@ -112,188 +111,3 @@ fn an_unlimited_deployment_never_leaves_ok() { limits )); } - -#[tokio::test] -async fn a_shared_blob_is_charged_to_its_first_uploader_only() { - let quotas = InMemoryQuota::new(); - let first = user(); - let second = UserId::new("01937b7c-0000-7000-8000-000000000002"); - let shared = address(1); - - assert_eq!( - quotas - .charge(&first, &shared, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::Charged { used: 64 }, - ); - assert_eq!( - quotas - .charge(&second, &shared, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::AlreadyAttributed, - "without this a malicious user could exhaust another account's quota by re-uploading \ - blobs whose addresses they already know", - ); - assert_eq!( - quotas.usage(&second).await.expect("usage").used, - 0, - "the second uploader is a merge, not a second copy" - ); -} - -#[tokio::test] -async fn releasing_a_reservation_credits_the_bytes_back() { - let quotas = InMemoryQuota::new(); - let user = user(); - let held = address(2); - quotas - .charge(&user, &held, 64, day(0), limits()) - .await - .expect("charge"); - - assert!(quotas.release(&user, &held).await.expect("release")); - assert_eq!(quotas.usage(&user).await.expect("usage").used, 0); - assert!( - !quotas.release(&user, &held).await.expect("release"), - "a second release must not credit the bytes twice" - ); - - // And the address is free again, so a later uploader is charged for it. - assert_eq!( - quotas - .charge(&user, &held, 64, day(0), limits()) - .await - .expect("charge"), - ChargeOutcome::Charged { used: 64 }, - ); -} - -#[tokio::test] -async fn another_users_reservation_is_not_releasable() { - let quotas = InMemoryQuota::new(); - let owner = user(); - let other = UserId::new("01937b7c-0000-7000-8000-000000000002"); - let held = address(3); - quotas - .charge(&owner, &held, 64, day(0), limits()) - .await - .expect("charge"); - - assert!( - !quotas.release(&other, &held).await.expect("release"), - "releasing somebody else's attribution would let one account free bytes off another's \ - ledger — and, worse, tell them the address was attributed" - ); - assert_eq!(quotas.usage(&owner).await.expect("usage").used, 64); -} - -#[tokio::test] -async fn the_crossing_is_stamped_once_and_cleared_by_going_under() { - let quotas = InMemoryQuota::new(); - let user = user(); - quotas - .charge(&user, &address(4), 150, day(0), limits()) - .await - .expect("charge"); - assert_eq!(quotas.usage(&user).await.expect("usage").over_since, None); - - quotas - .charge(&user, &address(5), 100, day(1), limits()) - .await - .expect("charge"); - let over = quotas.usage(&user).await.expect("usage"); - assert_eq!(over.used, 250); - assert_eq!(over.over_since, Some(day(1))); - - // A later charge while still over must not restamp the clock, or the grace window would - // never expire for an account that keeps trying to upload. - quotas - .charge(&user, &address(6), 10, day(9), limits()) - .await - .expect("charge"); - assert_eq!( - quotas.usage(&user).await.expect("usage").over_since, - Some(day(1)), - ); - - // Going back under stops the clock, so a later crossing gets a fresh window rather than - // inheriting an expired one. - quotas.release(&user, &address(5)).await.expect("release"); - assert_eq!(quotas.usage(&user).await.expect("usage").over_since, None); -} - -#[tokio::test] -async fn the_collector_releases_by_address_and_the_ledger_names_the_account() { - // `S-C44`. A sweep knows an address and nothing else — attribution is global by content - // address, so the blob it is deleting may be charged to an account with no remaining - // connection to the asset whose purge exposed it. The collector must not guess a user. - let ledger = InMemoryQuota::new(); - let address = address(41); - ledger - .charge( - &user(), - &address, - 1_024, - Timestamp::UNIX_EPOCH, - QuotaLimits::unlimited(), - ) - .await - .expect("the ledger charges"); - - let released = ledger - .release_attribution(&address) - .await - .expect("the ledger answers") - .expect("the address was attributed"); - assert_eq!(released, (user(), 1_024)); - assert_eq!(ledger.usage(&user()).await.expect("usage").used, 0); -} - -#[tokio::test] -async fn releasing_an_unattributed_address_is_none_rather_than_an_error() { - // The ordinary case for a blob the ledger never saw. A sweep that treated it as a failure - // would stall on the first one. - let ledger = InMemoryQuota::new(); - assert_eq!( - ledger - .release_attribution(&address(42)) - .await - .expect("the ledger answers"), - None - ); -} - -#[tokio::test] -async fn a_collector_release_clears_the_over_limit_clock_like_a_user_release() { - // Both releases credit the same way, which is why they share one helper: a second copy - // would eventually forget `over_since`, leaving an account back under its limit still - // carrying the clock that decides when a soft limit becomes a hard one. - let ledger = InMemoryQuota::new(); - let limits = QuotaLimits::new(1_000, 1_500, SignedDuration::from_hours(24)); - ledger - .charge(&user(), &address(43), 2_000, Timestamp::UNIX_EPOCH, limits) - .await - .expect("the ledger charges"); - assert!( - ledger - .usage(&user()) - .await - .expect("usage") - .over_since - .is_some() - ); - - ledger - .release_attribution(&address(43)) - .await - .expect("the ledger answers") - .expect("attributed"); - - assert_eq!( - ledger.usage(&user()).await.expect("usage").over_since, - None, - "back under the limit, so a later crossing gets a fresh window" - ); -} diff --git a/capsule-server/src/routes/auth.rs b/capsule-server/src/routes/auth.rs index 277ec2c9..a2d8d25c 100644 --- a/capsule-server/src/routes/auth.rs +++ b/capsule-server/src/routes/auth.rs @@ -6,8 +6,9 @@ //! //! # The status audit (`S-C28`) //! -//! `S-C28` found thirteen response variants across the Salvo surface that render a status -//! `capsule-sdk/openapi.json` never declares — `LoginResponses::undocumented()` returns +//! `S-C28` found thirteen response variants across the Salvo surface that render a status the +//! Salvo document never declared (it was committed as `capsule-sdk/openapi.json`, deleted with +//! the tree it described in `S-C59`) — `LoginResponses::undocumented()` returns //! `[423, 429]`. Kynos makes that class of defect unrepresentable, because the status *is* the //! return type and there is only one declaration. So each status was audited as its operation //! was ported, and the verdict lives in the type: @@ -642,9 +643,17 @@ pub async fn login_user( /// Open a session for `user` and mint its pair. /// -/// Shared by the password-only sign-in above and by the second factor's completing request -/// (`S-C55`), which is the point: a session opened down one path and not the other is how a TOTP -/// sign-in ends up in the devices view as an unknown, ungrouped device. +/// Shared by the password-only sign-in above, by the second factor's completing request +/// (`S-C55`) and by the OIDC callback (`S-N1`), which is the point: a session opened down one +/// path and not the other is how a TOTP sign-in ends up in the devices view as an unknown, +/// ungrouped device. +/// +/// **The OIDC door does not consult the password lockout**, and that is a decision rather than +/// an omission. The lockout counts failed *credential presentations* against the local +/// directory, and a federated sign-in presents none: the identity provider already +/// authenticated the person. Refusing here on a locked local account would let anyone who can +/// guess passwords at `/v1/auth/login` lock a person out of single sign-on too — turning a +/// throttle on one door into a denial of service on the other. /// /// # Errors /// diff --git a/capsule-server/src/routes/blob.rs b/capsule-server/src/routes/blob.rs index b525b05b..d6151d7a 100644 --- a/capsule-server/src/routes/blob.rs +++ b/capsule-server/src/routes/blob.rs @@ -31,6 +31,18 @@ //! | `401` | kept, and now the framework's, with the `WWW-Authenticate` challenge | //! | `500` | kept, with `error.blob.unavailable` | //! +//! # A peer fetches here too (`S-E5`) +//! +//! `Authorization: Bearer` carries a session token **or** a federation capability, on the same +//! component and through the same scheme the feed uses. A peer is admitted first +//! ([`crate::federation::admit`]) — revoked grant, blocked peer, spent events budget — and then +//! resolves through exactly the path an account does, with two differences the principal owns: +//! there is no transient `409` for a peer (it reports the caller's *own* device), and the +//! authority may answer `403 error.federation.scope_insufficient` when the grant's scope does +//! not cover the blob's role. The `403` a peer gets for a revoked grant carries +//! `error.federation.capability_revoked`, not the account's `error.blob.access_revoked`: the +//! two say different things about what to do next. +//! //! [Encryption — ranged reads]: ../../../capsule-docs/src/content/docs/design/cryptography/encryption.md use capsule_i18n::error_codes; @@ -39,8 +51,11 @@ use kynos::http::etag::ETag; use kynos::prelude::*; use kynos::response::range::served::{Conditions, Delivery, Served}; -use crate::auth::AccessToken; -use crate::serve::{BlobSource, ServeContext, ServeResolution}; +use crate::counter::CounterContext; +use crate::federation::{ + self, FederationContext, Principal, ReadBearer, Refusal, VerifiedCapability, +}; +use crate::serve::{BlobSource, ReadPrincipal, ServeContext, ServeResolution}; /// The media surface: fetching the opaque ciphertext a sync entry named. #[derive(Tag)] @@ -50,6 +65,14 @@ use crate::serve::{BlobSource, ServeContext, ServeResolution}; )] pub struct MediaTag; +/// Who is fetching, owning the credential the borrowed [`ReadPrincipal`] points into. +enum Reader { + /// An account, through a session access token. + Account(crate::store::OwnerId), + /// A peer server, through an admitted federation capability (`S-E5`). + Peer(Box), +} + /// The content address in the path. #[derive(PathParams, Schema)] pub struct BlobPath { @@ -93,6 +116,20 @@ pub enum BlobRejection { code: &'static str, }, + /// The caller was a member of the album that holds these bytes and has been removed from + /// its roster (`S-C51`). An authorization change, not a durability loss: the client re-syncs + /// its album membership before retrying, and only then degrades. + /// + /// Only a former member ever sees this. Everyone else gets [`Self::NotFound`], byte-identical + /// to an unknown address, because a `403` confirms the address is referenced by somebody. + #[error("you no longer have access to this album")] + #[problem(status = 403, title = "Access revoked")] + Forbidden { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// Referenced, but not retrievable per policy. Permanent. #[error("this blob is no longer available")] #[problem(status = 410, title = "Gone")] @@ -102,6 +139,51 @@ pub enum BlobRejection { code: &'static str, }, + /// A peer's capability has been revoked (`S-E5`). + /// + /// The federated counterpart of [`Self::Forbidden`], and a different code because the + /// action is different: an account re-syncs its album membership, while a peer asks the + /// home server for a fresh grant or stops pulling. + #[error("this capability has been revoked")] + #[problem(status = 403, title = "Capability revoked")] + CapabilityRevoked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer is on this server's blocklist (`S-C49`). + #[error("this server is blocked")] + #[problem(status = 403, title = "Server blocked")] + PeerBlocked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer is entitled to the album but its grant does not cover this blob's role + /// (`S-E5`). + /// + /// A `read-derivative-only` capability asking for an `original`, or any capability asking + /// for a `backup`. `403` rather than `404` because the peer already knows the asset is + /// there — the feed told it — and a `404` would send it hunting an address that exists. + #[error("this capability does not cover this blob")] + #[problem(status = 403, title = "Scope insufficient")] + ScopeInsufficient { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer's events-per-hour budget is spent (invariant 21). + #[error("this peer has reached its request budget")] + #[problem(status = 429, title = "Rate budget exceeded")] + RateLimited { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// A collaborator could not answer, so nothing was decided. #[error("the blob could not be served")] #[problem(status = 500, title = "Internal server error")] @@ -112,7 +194,33 @@ pub enum BlobRejection { }, } +impl From for BlobRejection { + fn from(refusal: Refusal) -> Self { + match refusal { + Refusal::Revoked => Self::CapabilityRevoked { + code: error_codes::FEDERATION_CAPABILITY_REVOKED, + }, + Refusal::PeerBlocked => Self::PeerBlocked { + code: error_codes::MODERATION_SERVER_BLOCKED, + }, + Refusal::RateLimited { .. } => Self::RateLimited { + code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED, + }, + Refusal::Unavailable => Self::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + }, + } + } +} + impl BlobRejection { + /// A former member of the album that holds the bytes (`S-C51`). + fn forbidden() -> Self { + Self::Forbidden { + code: error_codes::BLOB_ACCESS_REVOKED, + } + } + /// No live reference, or a malformed address. fn not_found() -> Self { Self::NotFound { @@ -140,33 +248,62 @@ impl BlobRejection { code: error_codes::BLOB_UNAVAILABLE, } } + + /// A peer's grant does not cover this blob's role (`S-E5`). + fn scope_insufficient() -> Self { + Self::ScopeInsufficient { + code: error_codes::FEDERATION_SCOPE_INSUFFICIENT, + } + } } /// Fetch a ciphertext blob by its content address, ranged. /// -/// Opaque octets: the server holds no key and this route never learns what it is serving. Any -/// authenticated account may fetch any live address — see [`crate::serve`] for why that is a -/// capability model rather than a hole, and for the `403` the contract describes and nothing -/// implements. +/// Opaque octets: the server holds no key and this route never learns what it is serving. An +/// account fetches the blobs of its own assets and of the albums it is currently a member of; a +/// former member is told `403`, and everyone else is told what an unknown address is told — +/// see [`crate::serve`] for the boundary and its reasons. /// /// The one answer that *is* account-scoped is the transient `409`: it reports the caller's own /// in-flight upload and nobody else's (`S-C40`). +/// +/// A federated peer fetches here with a capability instead of a session token (`S-E5`), through +/// the same resolution and the same authority. #[kynos::get("/v1/blob/{hash}", operation_id = "get_blob", tag = MediaTag)] pub async fn get_blob( Inject(serve): Inject, - Auth(credential): Auth, + Inject(federation): Inject, + Inject(counters): Inject, + Auth(principal): Auth, Path(path): Path, conditions: Conditions, ) -> Result, BlobRejection> { - // The caller files under itself. Nothing about *reading* is scoped by it today — any - // authenticated account may fetch any live address, see [`crate::serve`] — but the - // transient `409` is, and `S-C39` is where the read authority that would scope the rest - // arrives. - let owner = crate::store::OwnerId::new(credential.user.as_str()); - let resolution = crate::serve::resolve(&serve, &owner, &path.hash) + // Who is asking, in the shape the read authority decides from. A peer is *admitted* before + // anything is resolved — a revoked grant, a blocked peer or a spent budget is refused + // without the index being touched — and only then does it become a principal. + let reader: Reader = match principal { + Principal::Session(credential) => { + Reader::Account(crate::store::OwnerId::new(credential.user.as_str())) + } + Principal::Peer(capability) => { + federation::admit( + &federation, + &counters, + &capability, + federation::Presentation::Read, + ) + .await?; + Reader::Peer(capability) + } + }; + let principal = match &reader { + Reader::Account(owner) => ReadPrincipal::Account(owner), + Reader::Peer(capability) => ReadPrincipal::Peer(capability), + }; + let resolution = crate::serve::resolve(&serve, principal, &path.hash) .await .map_err(|error| { - tracing::error!(%error, user = %credential.user, "a blob fetch could not be resolved"); + tracing::error!(%error, reader = %principal, "a blob fetch could not be resolved"); BlobRejection::unavailable() })?; @@ -174,6 +311,16 @@ pub async fn get_blob( ServeResolution::Serve { address, size } => (address, size), ServeResolution::AwaitingUpload { .. } => return Err(BlobRejection::pending()), ServeResolution::NotFound => return Err(BlobRejection::not_found()), + // The `403` says the same thing to both readers and says it differently, because what + // the reader does next differs: an account re-syncs its membership, a peer asks its + // home server for a fresh grant. + ServeResolution::Forbidden => { + return Err(match &reader { + Reader::Account(_) => BlobRejection::forbidden(), + Reader::Peer(_) => BlobRejection::from(Refusal::Revoked), + }); + } + ServeResolution::ScopeInsufficient => return Err(BlobRejection::scope_insufficient()), ServeResolution::Gone => return Err(BlobRejection::gone()), }; diff --git a/capsule-server/src/routes/drop.rs b/capsule-server/src/routes/drop.rs index c82381b5..3d52e831 100644 --- a/capsule-server/src/routes/drop.rs +++ b/capsule-server/src/routes/drop.rs @@ -50,7 +50,7 @@ use uuid::Uuid; use crate::auth::AccessToken; use crate::blob::ContentAddress; -use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::counter::{CounterContext, CounterKey, Verdict, budgets, unix_seconds}; use crate::drop::{Admission, DropContext, InboxEntry, LinkCaps, UploadLinkRecord, is_opaque_id}; use crate::store::{BlobRole, OwnerId, UploadId, UploadSessionRecord, UploadSessionStatus, UserId}; use crate::upload::body::ChunkBody; @@ -421,13 +421,24 @@ pub enum DropRejection { code: &'static str, }, - /// Too many drop-session creations against this link (invariant 31). + /// Too many drop-session creations against this link (invariant 31), **or** the limiter is + /// holding as many distinct links as it will hold. + /// + /// Two causes, one status, told apart by `code`: `error.drop.rate_limited` is this link's own + /// budget spent, `error.drop.at_capacity` is the limiter's per-link partition full. The + /// second used to render `500 error.drop.unavailable`, which told a client to report an + /// outage and an operator to go looking for one, when the limiter was working exactly as + /// designed and would clear itself inside the window. #[error("too many uploads through this link")] #[problem(status = 429, title = "Too many requests")] RateLimited { /// The stable catalog code. #[problem(extension)] code: &'static str, + /// When the caller may retry, as Unix seconds. An **upper** bound: one limiter window, + /// by which time a live window has lapsed and freed room. + #[problem(extension)] + retry_after: u64, }, /// A store could not answer. @@ -636,12 +647,20 @@ pub async fn create_drop( ) .await .map_err(|error| { - tracing::error!(%error, "the drop limiter could not be reached"); - DropRejection::unavailable() + // A full partition is the limiter working, not a broken store, and a caller told + // `500` cannot tell the difference. Fail-closed either way; only the answer differs. + if let Some(retry_after) = counters.capacity_refusal(&error, budgets::DROP_LINK) { + tracing::warn!(%error, "the drop limiter is at capacity"); + DropRejection::at_capacity(retry_after) + } else { + tracing::error!(%error, "the drop limiter could not be reached"); + DropRejection::unavailable() + } })?; - if !verdict.admits() { + if let Verdict::Limited { retry_after } = verdict { return Err(DropRejection::RateLimited { code: error_codes::DROP_RATE_LIMITED, + retry_after: unix_seconds(retry_after), }); } @@ -1080,10 +1099,17 @@ async fn adopt_claimed( let album = crate::store::AlbumId::new(&request.album_id); // Invariant 6, unchanged: adoption is a write into an album and needs the same capability - // any other write does. - let crate::upload::AlbumWriteAccess::Writable { protocol_pin, .. } = upload + // any other write does. And it stays a write into the link owner's **own** album: a drop is + // deposited with one account, and promoting it into an album that account merely writes to + // would file a guest's bytes under a third party. + let crate::upload::AlbumWriteAccess::Writable { + owner_id: filed_under, + role, + protocol_pin, + .. + } = upload .authority() - .album_write_access(&owner_id, &album) + .album_write_access(owner, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an adoption"); @@ -1094,6 +1120,12 @@ async fn adopt_claimed( "no write capability for that album", )); }; + if role != crate::upload::WriteRole::Owner || filed_under != owner_id { + tracing::info!(%owner, %album, "an adoption was refused: the album is not the link owner's own"); + return Err(AdoptRejection::refused( + "no write capability for that album", + )); + } // Invariant 7, unchanged. let device = crate::upload::envelope::created_by_device(&request.manifest_envelope) @@ -1347,6 +1379,18 @@ impl DropRejection { } } + /// The limiter is holding as many distinct links as it will hold. + /// + /// A `429` and not the `500` this used to be: the limiter is working as designed and clears + /// itself inside the window, so the caller is told to wait rather than told the server is + /// broken. + fn at_capacity(retry_after: jiff::Timestamp) -> Self { + Self::RateLimited { + code: error_codes::DROP_AT_CAPACITY, + retry_after: unix_seconds(retry_after), + } + } + /// A store could not answer. fn unavailable() -> Self { Self::Unavailable { diff --git a/capsule-server/src/routes/enroll.rs b/capsule-server/src/routes/enroll.rs index 5a462053..5f9440fd 100644 --- a/capsule-server/src/routes/enroll.rs +++ b/capsule-server/src/routes/enroll.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::auth::AccessToken; -use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::counter::{CounterContext, CounterKey, Verdict, budgets, unix_seconds}; use crate::enrollment::{EnrollmentContext, MAX_RELAY_BYTES}; use crate::store::{ ChannelId, Direction, DrainOutcome, EnrollmentCode, PendingEnrollment, RelayChannel, @@ -151,12 +151,22 @@ pub enum RedeemRejection { /// The limiter design/device-enrollment.md names as the reason the **shorter transcribable /// fallback** is safe to offer: it trades entropy for transcribability, and what keeps that /// trade honest is that the code cannot be ground through inside its ten-minute life. + /// + /// Two causes, one status, told apart by `code`: `error.enrollment.rate_limited` is this + /// code's own budget spent, `error.enrollment.at_capacity` is the limiter's partition full. + /// The second used to render `500 error.auth.unavailable`, which told a client to report an + /// outage and an operator to go looking for one, when the limiter was working exactly as + /// designed and would clear itself inside the window. #[error("too many attempts against this code")] #[problem(status = 429, title = "Too many attempts")] RateLimited { /// The stable catalog code. #[problem(extension)] code: &'static str, + /// When the caller may retry, as Unix seconds. An **upper** bound: one limiter window, + /// by which time a live window has lapsed and freed room. + #[problem(extension)] + retry_after: u64, }, /// A store could not answer. @@ -275,7 +285,8 @@ pub async fn redeem_enrollment_code( Inject(counters): Inject, Json(request): Json, ) -> Result, RedeemRejection> { - let presented = EnrollmentCode::new(request.code.trim()); + let offered = request.code.trim(); + let presented = EnrollmentCode::new(offered); // Charged **before** the redemption is attempted, and charged on every attempt whatever the // outcome (`S-C32`). A limiter that only counted failures would let a caller who guesses @@ -285,21 +296,47 @@ pub async fn redeem_enrollment_code( // Keyed on the presented code rather than on a source address, because the contract's // budget is per *pending enrollment* — the thing being guessed — and a caller behind many // addresses is exactly the caller a per-address key would miss. - let key = CounterKey::EnrollmentRedemption(request.code.trim().to_owned()); - let verdict = counters - .hit(&key, budgets::ENROLLMENT_REDEMPTION) - .await - .map_err(|error| { - // Fail closed. A limiter an attacker turns off by loading the counter store is not - // a limiter. + // + // But keyed on it **only when it is shaped like a code**. The presented value is an + // arbitrary caller-supplied string, and a counter keyed on one is a partition an + // unauthenticated caller fills a row at a time; anything else goes to one fixed bucket, as + // the OIDC authorize charges a refused redirect to one. This is a *shape* check and never an + // existence check: it cannot say whether a code is pending, so it tells a prober nothing and + // leaves untouched the charge-before-resolve ordering above, which is what stops this route + // being a free existence oracle. A malformed code still walks the same path to the same + // `error.enrollment.code_refused` it always did. + let (key, budget) = if is_enrollment_code(offered) { + ( + CounterKey::EnrollmentRedemption(offered.to_owned()), + budgets::ENROLLMENT_REDEMPTION, + ) + } else { + ( + CounterKey::EnrollmentRedemptionMalformed, + budgets::ENROLLMENT_REDEMPTION_MALFORMED, + ) + }; + let verdict = counters.hit(&key, budget).await.map_err(|error| { + // Fail closed. A limiter an attacker turns off by loading the counter store is not a + // limiter — but a *full* partition is the limiter working, not a broken store, and a + // caller told `500` cannot tell the difference. + if let Some(retry_after) = counters.capacity_refusal(&error, budget) { + tracing::warn!(%error, "the redemption limiter is at capacity"); + RedeemRejection::RateLimited { + code: error_codes::ENROLLMENT_AT_CAPACITY, + retry_after: unix_seconds(retry_after), + } + } else { tracing::error!(%error, "the redemption counter could not be reached"); RedeemRejection::Unavailable { code: error_codes::AUTH_UNAVAILABLE, } - })?; - if !verdict.admits() { + } + })?; + if let Verdict::Limited { retry_after } = verdict { return Err(RedeemRejection::RateLimited { code: error_codes::ENROLLMENT_RATE_LIMITED, + retry_after: unix_seconds(retry_after), }); } @@ -473,6 +510,30 @@ pub async fn close_enrollment_channel( Ok(NoContent) } +/// How many digits the transcribable fallback carries. See [`mint`], which is what produces it. +const TEXT_FALLBACK_DIGITS: usize = 8; + +/// Whether `raw` is shaped like one of the two spellings [`mint`] issues. +/// +/// The full-entropy form is a canonical hyphenated UUID; the transcribable fallback is exactly +/// [`TEXT_FALLBACK_DIGITS`] ASCII digits. Nothing else can name a pending enrollment, so nothing +/// else needs a counter key of its own — that is the whole of what this decides. +/// +/// **Not an existence check, and it must not become one.** It reads only the presented string's +/// shape, never the store, so it distinguishes "cannot possibly be a code" from "is a code", +/// never "is a code that exists" from "is a code that does not". Case is accepted either way for +/// the UUID form: a client that upper-cases what it scanned is presenting a real code, and +/// pushing it into the malformed bucket would throttle an honest caller on a technicality. The +/// store remains the only thing that decides whether a code redeems. +fn is_enrollment_code(raw: &str) -> bool { + if raw.len() == TEXT_FALLBACK_DIGITS && raw.bytes().all(|b| b.is_ascii_digit()) { + return true; + } + // `try_parse` also accepts the simple, braced and URN spellings; the length pins the + // hyphenated one `mint` actually issues. + raw.len() == 36 && Uuid::try_parse(raw).is_ok() +} + /// Mint a code pair, refusing one that is already taken. async fn mint( enrollment: &EnrollmentContext, @@ -480,8 +541,11 @@ async fn mint( // UUIDv4 rather than v7: an enrollment code's creation time must not leak, and a v7 code // read off a screen would carry a timestamp. That is the Identifiers rule's exact carve-out. let code = EnrollmentCode::new(Uuid::new_v4().to_string()); - let text_fallback = - EnrollmentCode::new(format!("{:08}", Uuid::new_v4().as_u128() % 100_000_000)); + let text_fallback = EnrollmentCode::new(format!( + "{:0width$}", + Uuid::new_v4().as_u128() % 100_000_000, + width = TEXT_FALLBACK_DIGITS + )); for candidate in [&code, &text_fallback] { let taken = enrollment @@ -540,3 +604,64 @@ impl RelayRejection { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_spellings_mint_issues_are_shaped_like_codes() { + // The predicate has to admit exactly what `mint` produces, or a legitimate redemption + // would be counted in the malformed bucket and throttled deployment-wide. + for _ in 0..64 { + let uuid = Uuid::new_v4().to_string(); + assert!(is_enrollment_code(&uuid), "{uuid}"); + let fallback = format!( + "{:0width$}", + Uuid::new_v4().as_u128() % 100_000_000, + width = TEXT_FALLBACK_DIGITS + ); + assert!(is_enrollment_code(&fallback), "{fallback}"); + } + // Including the leading-zero fallback, which is where an "is it a number" check breaks. + assert!(is_enrollment_code("00000042")); + // And an upper-cased UUID, which is a real code a client re-spelled. + assert!(is_enrollment_code( + &Uuid::new_v4().to_string().to_ascii_uppercase() + )); + } + + #[test] + fn nothing_else_gets_a_counter_key_of_its_own() { + for candidate in [ + "", + " ", + "1234567", // one digit short + "123456789", // one digit long + "0000000a", // right length, not digits + "not-a-uuid-at-all-not-even-close-xx", // right length, not a uuid + "0193d2f4a1b74c3e8f5a6b7c8d9e0f10", // simple uuid: not the spelling minted + "urn:uuid:0193d2f4-a1b7-4c3e-8f5a-6b7c8d9e0f10", + "{0193d2f4-a1b7-4c3e-8f5a-6b7c8d9e0f10}", + ] { + assert!(!is_enrollment_code(candidate), "{candidate:?}"); + } + // The point of the bound: an arbitrarily long string cannot mint a key. + let long = "a".repeat(64 * 1024); + assert!(!is_enrollment_code(&long)); + } + + #[test] + fn the_shape_check_reads_no_store() { + // It is a shape check and must never become an existence check: it takes only a string, + // so it cannot distinguish a pending code from an absent one, and the charge-before- + // resolve ordering that stops this route being an existence oracle is untouched. + let minted = Uuid::new_v4().to_string(); + let never_issued = "0193d2f4-a1b7-4c3e-8f5a-6b7c8d9e0f10"; + assert_eq!( + is_enrollment_code(&minted), + is_enrollment_code(never_issued), + "a code that exists and one that never will are shaped the same" + ); + } +} diff --git a/capsule-server/src/routes/federation.rs b/capsule-server/src/routes/federation.rs new file mode 100644 index 00000000..3d414e31 --- /dev/null +++ b/capsule-server/src/routes/federation.rs @@ -0,0 +1,1339 @@ +//! The federation capability's lifecycle: minting, revoking and refreshing (`S-E2`, `S-C49`). +//! +//! Not the pull path. design/federation.md is explicit that federation adds **no new data +//! protocol** — a peer pulls through `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, which +//! is [`crate::routes::sync`] and [`crate::routes::blob`]. What is here is the credential those +//! two reads accept and the three operations that manage it. +//! +//! ```text +//! POST /v1/albums/{album_id}/capabilities +//! { peer, member, scope, ttl_seconds?, renewable_until? } +//! 201 { token, jti, album_id, peer, member, scope, issued_at, expires_at, not_after, +//! renewable, min_protocol_version } +//! 400 error.federation.capability_malformed +//! 403 error.federation.not_configured | error.moderation.server_blocked +//! 404 error.federation.album_not_found +//! 409 error.federation.member_not_on_roster +//! 500 error.federation.unavailable +//! +//! DELETE /v1/albums/{album_id}/capabilities/{jti} +//! 204 (idempotent) +//! 404 error.federation.album_not_found +//! 500 error.federation.unavailable +//! +//! POST /v1/federation/reports (the report's own signature is the credential) +//! 202 { report_id, received_at } +//! 400 error.moderation.report_malformed +//! 401 error.moderation.report_unsigned +//! 403 error.federation.peer_unknown | error.moderation.server_blocked +//! | error.federation.not_configured +//! 429 error.moderation.report_rate_limited +//! 500 error.moderation.unavailable +//! +//! POST /v1/federation/capabilities/refresh (the capability itself is the credential) +//! 200 { token, jti, expires_at, replayed } +//! 403 error.federation.capability_invalid | error.federation.capability_revoked +//! | error.federation.capability_expired | error.moderation.server_blocked +//! | error.federation.not_configured +//! 409 error.federation.member_not_on_roster +//! 429 error.federation.rate_budget_exceeded +//! 500 error.federation.unavailable +//! ``` +//! +//! # Who mints, and against what +//! +//! **The album's owner, through its own client.** design/federation.md puts minting on the home +//! server at the moment the owner shares, so the credential is `Auth` and the album +//! must be the caller's — answered `404` when it is not, the album ceremonies' "not yours is not +//! found", so a member holding somebody else's album id learns nothing. +//! +//! The mint needs no key of the peer's: the token is signed with **this** server's operational +//! Ed25519 key, the one `/.well-known/capsule/server-info` publishes, and the peer verifies it +//! against that. A pinned peer key is needed only to verify a signed moderation report. +//! +//! Two facts are checked before anything is signed. The peer must not be **blocked** — the +//! server-level blocklist operates at exactly this layer (design/moderation.md) — and the member +//! must be on the album's **current roster**, whose `granted_epoch` is copied into the record. +//! That epoch is the server-side half of the grant: at every presentation the member's current +//! epoch must still equal it, so a member removed and re-admitted later gets a fresh membership +//! and the old capability dies without anyone revoking it. It is a stored fact rather than a +//! claim because the token format is normative and parsed by every peer. +//! +//! # Revoking is not gated on the deployment federating +//! +//! Minting and refreshing refuse `403 error.federation.not_configured` when `FEDERATION_URL` is +//! unset: a server that does not federate does not hand out new grants. **Revoking is not**, and +//! deliberately: turning federation off must never be the thing that takes away an operator's +//! ability to cut a grant that is already out there. Nor does an unset `FEDERATION_URL` stop an +//! already-minted capability verifying — a token is not un-minted by a configuration change, and +//! silently refusing one would cut a peer off with no revocation anybody can see. +//! +//! # Renewability is asked for, never assumed +//! +//! A refresh mints a **successor**, and a successor with a fresh TTL is a grant that outlives the +//! lifetime its owner chose unless something stops it. Nothing about "same peer, same album, same +//! member" does: an owner who mints a deliberate sixty-second capability would get a peer that +//! refreshes inside the minute and chains forever, leaving `ttl_seconds` advisory for exactly one +//! hop and revocation of a `jti` the owner never saw as the only remaining control. +//! +//! So the record carries an **absolute deadline**, [`CapabilityRecord::not_after`], fixed at the +//! original mint and copied unchanged into every successor — the store refuses one that carries a +//! different deadline, so this is structural rather than a property of the route that happens to +//! compute the TTL. The default is `not_after == expires_at`: **a grant is not renewable unless +//! the owner said so**, by naming `renewable_until` at mint. Each successor is minted for +//! `min(DEFAULT_TTL, not_after − now)`, so the last token of a grant is short rather than +//! overhanging, and a refresh past the deadline is `403 error.federation.capability_expired`. +//! +//! The mint response states both `not_after` and a plain `renewable` flag, because an owner +//! deciding how long to share for should not have to infer it from two timestamps. +//! +//! # Refresh, and why it is idempotent by construction +//! +//! The **previous capability** is the credential (design/federation.md: "refresh authenticated +//! by the previous token"), so this operation takes `Auth` and refuses a session +//! principal: an account has nothing to refresh here. The store issues the successor, links the +//! predecessor to it and revokes the predecessor in one critical section, so a replayed refresh +//! finds the link and is answered with **the same token** — the grant is re-signed from its +//! stored record, and because every instant is at whole seconds and Ed25519 is deterministic the +//! bytes are the bytes the peer already holds. That is the `(peer, jti)` idempotency +//! threat-model/validation.md asks for, without an idempotency table. +//! +//! A successor answered to a replay may itself have been revoked since — a block cascades over +//! every live capability of a peer — so its liveness is re-checked before it is re-signed. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_i18n::error_codes; +use jiff::SignedDuration; +use kynos::prelude::*; +use kynos::response::status::NoContent; +use kynos::security::auth::Auth; +use serde::{Deserialize, Serialize}; + +use crate::album::AlbumContext; +use crate::auth::AccessToken; +use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::federation::{ + self, CapabilityRecord, FederationContext, MAX_GRANT_LIFETIME, MintRequest, PeerId, + Presentation, Principal, ReadBearer, Refusal, ReportClaim, Scope, +}; +use crate::membership::{Membership, MembershipContext}; +use crate::moderation::{FederatedReport, ModerationContext}; +use crate::store::{AlbumId, UserId}; + +/// The federation surface: the capability a peer server pulls a shared album with. +#[derive(Tag)] +#[tag( + name = "federation", + description = "Minting, revoking and refreshing the capability a peer server pulls with." +)] +pub struct FederationTag; + +/// The default life of a minted capability when the caller names none. +/// +/// Six hours: long enough that a peer pulling an evening's photos never refreshes mid-pull, +/// short enough that a grant nobody revokes is not a day-long hole. The ceiling is the +/// contract's 24 hours and the codec clamps to it whatever is asked for. +pub const DEFAULT_TTL: SignedDuration = SignedDuration::from_hours(6); + +/// What a capability permits, on the wire. +/// +/// A mirror of [`Scope`] rather than the type itself, for the reason +/// [`WireBlobRole`](crate::routes::upload::WireBlobRole) is one: the domain enum is not a schema +/// type, and the wire spelling is a contract that should not move when an internal name does. +#[derive(Schema, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum WireScope { + /// Everything a member reads: originals, derivatives, metadata, provenance. + Read, + /// Thumbnails and previews only — never originals. + ReadDerivativeOnly, +} + +impl From for Scope { + fn from(scope: WireScope) -> Self { + match scope { + WireScope::Read => Self::Read, + WireScope::ReadDerivativeOnly => Self::ReadDerivativeOnly, + } + } +} + +impl From for WireScope { + fn from(scope: Scope) -> Self { + match scope { + Scope::Read => Self::Read, + Scope::ReadDerivativeOnly => Self::ReadDerivativeOnly, + } + } +} + +/// The mint request. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +pub struct MintCapabilityRequest { + /// The peer server the grant is for, as its own `server-info` names it (`other.tld`). + pub peer: String, + /// The roster member whose access the grant carries, as the owner listed them. + pub member: String, + /// What the grant permits. + pub scope: WireScope, + /// How long **one token** should live, in seconds. Clamped to the 24-hour ceiling; absent is + /// six hours. + pub ttl_seconds: Option, + /// The absolute deadline the whole grant dies at, RFC 3339 — and the only thing that makes + /// it **renewable**. + /// + /// Absent, the default, is a grant that cannot be refreshed at all: it lives exactly + /// `ttl_seconds` and then the owner mints again if they still mean to share. Present, it + /// must be in the future and at most ninety days out. + pub renewable_until: Option, +} + +/// A freshly minted capability. +/// +/// The token is returned **once**. Nothing on this server can produce it again — a stored grant +/// re-signs byte-for-byte, but only the refresh operation does that, and only for its holder. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +pub struct MintedCapabilityResponse { + /// The signed capability, to be carried as `Authorization: Bearer`. + pub token: String, + /// Its identifier, and the key it is revoked by. + pub jti: String, + /// The album it scopes to. + pub album_id: String, + /// The peer it was minted for. + pub peer: String, + /// The roster member whose access it carries. + pub member: String, + /// What it permits. + pub scope: WireScope, + /// When it was minted, RFC 3339. + pub issued_at: String, + /// When **this token** stops being honoured, RFC 3339. + pub expires_at: String, + /// When the **whole grant** dies, RFC 3339. Equal to `expires_at` when it is not renewable. + pub not_after: String, + /// Whether a refresh may issue a successor from this grant. + /// + /// Stated plainly rather than left to be inferred from the two timestamps above: how long + /// an owner is sharing for is the decision this response reports back to them. + pub renewable: bool, + /// The album's pinned protocol date, which the peer must speak to pull. + pub min_protocol_version: String, +} + +/// A refreshed capability. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +pub struct RefreshedCapabilityResponse { + /// The successor token. + pub token: String, + /// Its identifier. + pub jti: String, + /// When this token stops being honoured, RFC 3339. + pub expires_at: String, + /// When the whole grant dies, RFC 3339 — unchanged by this or any refresh. + pub not_after: String, + /// Whether this call issued the successor, or answered one an earlier call already issued. + /// + /// Advisory. A peer never branches on it: both answers mean "here is the token to keep + /// pulling with". + pub replayed: bool, +} + +/// The album a capability is minted over. +#[derive(PathParams, Schema)] +pub struct CapabilitiesPath { + /// The album's id. + pub album_id: String, +} + +/// One capability of one album. +#[derive(PathParams, Schema)] +pub struct CapabilityPath { + /// The album's id. + pub album_id: String, + /// The capability's `jti`. + pub jti: String, +} + +/// Why a capability was not minted. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum MintRejection { + /// A field of the body is not what it must be. + #[error("the capability request is malformed")] + #[problem(status = 400, title = "Malformed request")] + Malformed { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// This deployment does not federate. + #[error("this server does not federate")] + #[problem(status = 403, title = "Federation not configured")] + NotConfigured { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer is on this server's blocklist. + #[error("this server is blocked")] + #[problem(status = 403, title = "Server blocked")] + PeerBlocked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// No such album, or one owned by a different account. One answer for both. + #[error("no such album")] + #[problem(status = 404, title = "Not found")] + NotFound { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The member named is not on the album's current roster. + #[error("that member is not on this album's roster")] + #[problem(status = 409, title = "Member not on roster")] + NotOnRoster { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer, so nothing was minted. + #[error("the capability could not be minted")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl MintRejection { + /// A collaborator could not answer. + fn unavailable() -> Self { + Self::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + } + + /// No such album, or not the caller's. + fn not_found() -> Self { + Self::NotFound { + code: error_codes::FEDERATION_ALBUM_NOT_FOUND, + } + } +} + +/// Why a capability was not revoked. +/// +/// No "unknown capability" answer: revoking is idempotent and a `jti` this album does not hold +/// is a `204` like any other, so the operation cannot be used to probe which `jti`s exist. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum RevokeRejection { + /// No such album, or one owned by a different account. + #[error("no such album")] + #[problem(status = 404, title = "Not found")] + NotFound { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer, so nothing was revoked. + #[error("the capability could not be revoked")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +/// Why a capability was not refreshed. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum RefreshRejection { + /// The credential is not a capability, or names a grant that cannot be continued. + #[error("this credential cannot be refreshed")] + #[problem(status = 403, title = "Capability invalid")] + NotRefreshable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The capability, or the successor a replay names, has been revoked. + #[error("this capability has been revoked")] + #[problem(status = 403, title = "Capability revoked")] + Revoked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer is on this server's blocklist. + #[error("this server is blocked")] + #[problem(status = 403, title = "Server blocked")] + PeerBlocked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The grant's absolute deadline has passed, or the owner never made it renewable. + /// + /// The end of the sharing relationship rather than of one token: no successor will ever be + /// issued from it, and the peer's next move is to ask the album's owner, not this server. + #[error("this grant cannot be renewed any further")] + #[problem(status = 403, title = "Capability expired")] + GrantExpired { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The member the grant was minted for is no longer on the album's roster at its epoch. + #[error("that member is not on this album's roster")] + #[problem(status = 409, title = "Member not on roster")] + NotOnRoster { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// This deployment does not federate. + #[error("this server does not federate")] + #[problem(status = 403, title = "Federation not configured")] + NotConfigured { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer's events-per-hour budget is spent. + #[error("this peer has reached its request budget")] + #[problem(status = 429, title = "Rate budget exceeded")] + RateLimited { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer, so nothing was refreshed. + #[error("the capability could not be refreshed")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl From for RefreshRejection { + fn from(refusal: Refusal) -> Self { + match refusal { + Refusal::Revoked => Self::Revoked { + code: error_codes::FEDERATION_CAPABILITY_REVOKED, + }, + Refusal::PeerBlocked => Self::PeerBlocked { + code: error_codes::MODERATION_SERVER_BLOCKED, + }, + Refusal::RateLimited { .. } => Self::RateLimited { + code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED, + }, + Refusal::Unavailable => Self::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + }, + } + } +} + +// =========================================================================================== +// Operations +// =========================================================================================== + +/// Mint a capability letting one peer server pull one album. +/// +/// The token is in the response and nowhere else: this server keeps the record, never the +/// credential. +#[kynos::post( + "/v1/albums/{album_id}/capabilities", + operation_id = "issue_capability", + tag = FederationTag +)] +pub async fn issue_capability( + Inject(federation): Inject, + Inject(albums): Inject, + Inject(membership): Inject, + Auth(credential): Auth, + Path(path): Path, + Json(request): Json, +) -> Result { + if !federation.is_configured() { + tracing::info!("a capability was refused: this deployment does not federate"); + return Err(MintRejection::NotConfigured { + code: error_codes::FEDERATION_NOT_CONFIGURED, + }); + } + let peer = PeerId::new(request.peer.trim()); + // A peer id is an origin, and an empty or whitespace one is a client bug rather than an + // unknown server. Checked before the store so a blank never becomes a row. + if peer.as_str().is_empty() || request.member.trim().is_empty() { + return Err(MintRejection::Malformed { + code: error_codes::FEDERATION_CAPABILITY_MALFORMED, + }); + } + let member = UserId::new(request.member.trim()); + let album = AlbumId::new(&path.album_id); + + // The album must be the caller's, and "not yours" is "not found" — the same answer the + // roster and upgrade ceremonies give, so an album id somebody else holds discloses nothing. + let record = albums.albums().read(&album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a capability mint"); + MintRejection::unavailable() + })?; + let Some(record) = record.filter(|record| record.owner_id.as_str() == credential.user.as_str()) + else { + tracing::info!(user = %credential.user, %album, "a mint was refused: no such album, or not the caller's"); + return Err(MintRejection::not_found()); + }; + + // The blocklist, at the layer design/moderation.md puts it: a blocked peer is refused a new + // grant as surely as it is refused a presentation. + let blocked = federation + .peers() + .read(&peer) + .await + .map_err(|error| { + tracing::error!(%error, %peer, "the peer store could not answer a capability mint"); + MintRejection::unavailable() + })? + .is_some_and(|record| record.is_blocked()); + if blocked { + tracing::info!(%peer, %album, "a mint was refused: the peer is blocked"); + return Err(MintRejection::PeerBlocked { + code: error_codes::MODERATION_SERVER_BLOCKED, + }); + } + + // The membership, and the epoch it was granted at — the server-side half of the grant. + let membership = membership + .members() + .membership(&album, &member) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer a capability mint"); + MintRejection::unavailable() + })?; + let Membership::Member { granted_epoch, .. } = membership else { + tracing::info!(%album, %member, ?membership, "a mint was refused: the member is not on the roster"); + return Err(MintRejection::NotOnRoster { + code: error_codes::FEDERATION_MEMBER_NOT_ON_ROSTER, + }); + }; + + let scope = Scope::from(request.scope); + let ttl = request + .ttl_seconds + .and_then(|seconds| i64::try_from(seconds).ok()) + .map_or(DEFAULT_TTL, SignedDuration::from_secs); + + // The absolute deadline, and the only way a grant becomes renewable at all. Parsed before + // anything is signed, so a malformed date costs a `400` rather than a recorded grant. + let now = federation.clock().now(); + let renewable_until = match request.renewable_until.as_deref() { + None => None, + Some(text) => { + let Ok(until) = text.parse::() else { + tracing::info!(%album, "a mint named an unreadable renewable_until"); + return Err(MintRejection::Malformed { + code: error_codes::FEDERATION_CAPABILITY_MALFORMED, + }); + }; + if until <= now || until > crate::store::deadline(now, MAX_GRANT_LIFETIME) { + tracing::info!(%album, %until, "a mint named a deadline outside the permitted window"); + return Err(MintRejection::Malformed { + code: error_codes::FEDERATION_CAPABILITY_MALFORMED, + }); + } + Some(until) + } + }; + let minted = federation + .codec() + .mint(&MintRequest { + peer: peer.clone(), + album: album.clone(), + scope, + // The album's pin, not the server's: a peer must speak what the album speaks. + min_protocol_version: record.protocol_version.clone(), + ttl, + }) + .map_err(|error| { + tracing::error!(%error, %album, "a capability could not be signed"); + MintRejection::unavailable() + })?; + + // A deadline earlier than the token's own expiry would be a grant that dies before its first + // token does, which is not a thing an owner can mean; the token wins and the grant is simply + // not renewable. + let not_after = renewable_until.map_or(minted.grant.expires_at, |until| { + until.max(minted.grant.expires_at) + }); + federation + .capabilities() + .issue(CapabilityRecord { + jti: minted.grant.jti.clone(), + album_id: album.clone(), + peer_id: peer.clone(), + member: member.clone(), + scope, + granted_epoch, + min_protocol_version: minted.grant.min_protocol_version.clone(), + issued_at: minted.grant.issued_at, + expires_at: minted.grant.expires_at, + not_after, + revoked_at: None, + refreshed_to: None, + }) + .await + .map_err(|error| { + tracing::error!(%error, %album, jti = %minted.grant.jti, "a minted capability could not be recorded"); + MintRejection::unavailable() + })?; + + Ok(MintReply::Created(MintedCapabilityResponse { + token: minted.token, + jti: minted.grant.jti, + album_id: album.as_str().to_owned(), + peer: peer.as_str().to_owned(), + member: member.as_str().to_owned(), + scope: scope.into(), + issued_at: minted.grant.issued_at.to_string(), + expires_at: minted.grant.expires_at.to_string(), + not_after: not_after.to_string(), + renewable: not_after > minted.grant.expires_at, + min_protocol_version: minted.grant.min_protocol_version, + })) +} + +/// The one way minting succeeds. +/// +/// A `Reply` rather than a bare `Json` because a mint **creates** a grant, and `201` is what +/// says so; Kynos's `Created` wants a `Location` and there is no URL for a capability — the +/// server never serves one back. +#[derive(Reply)] +pub enum MintReply { + /// The grant was minted and recorded. + #[reply(status = 201, description = "The capability was minted")] + Created(MintedCapabilityResponse), +} + +/// Revoke one capability of one album. +/// +/// Idempotent, and silent about what it did: a `jti` that is not a live capability of this +/// album — never issued, already revoked, or another album's — is the same `204` a revocation +/// is, so the operation is not a probe over identifiers. +#[kynos::delete( + "/v1/albums/{album_id}/capabilities/{jti}", + operation_id = "revoke_capability", + tag = FederationTag +)] +pub async fn revoke_capability( + Inject(federation): Inject, + Inject(albums): Inject, + Auth(credential): Auth, + Path(path): Path, +) -> Result { + let album = AlbumId::new(&path.album_id); + let record = albums.albums().read(&album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a capability revoke"); + RevokeRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + if record.is_none_or(|record| record.owner_id.as_str() != credential.user.as_str()) { + tracing::info!(user = %credential.user, %album, "a revoke was refused: no such album, or not the caller's"); + return Err(RevokeRejection::NotFound { + code: error_codes::FEDERATION_ALBUM_NOT_FOUND, + }); + } + + // The grant must be this album's. Without the check an owner could revoke a grant over + // somebody else's album by naming its `jti`. + let held = federation + .capabilities() + .find(&path.jti) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the capability store could not be read for a revoke"); + RevokeRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + match held { + Some(held) if held.album_id == album => { + let outcome = federation + .capabilities() + .revoke_issued(&path.jti, federation.clock().now()) + .await + .map_err(|error| { + tracing::error!(%error, %album, jti = %path.jti, "a capability could not be revoked"); + RevokeRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + tracing::info!(%album, jti = %path.jti, ?outcome, "an owner revoked a federation capability"); + } + _ => { + tracing::debug!(%album, jti = %path.jti, "a revoke named no live capability of this album"); + } + } + Ok(NoContent) +} + +/// Exchange a capability for its successor. +/// +/// The credential **is** the capability being refreshed; a session token has nothing to refresh +/// here and is refused. +#[kynos::post( + "/v1/federation/capabilities/refresh", + operation_id = "refresh_capability", + tag = FederationTag +)] +pub async fn refresh_capability( + Inject(federation): Inject, + Inject(membership): Inject, + Inject(counters): Inject, + Auth(principal): Auth, +) -> Result, RefreshRejection> { + let Principal::Peer(capability) = principal else { + tracing::info!("a session token was presented on the capability refresh"); + return Err(RefreshRejection::NotRefreshable { + code: error_codes::FEDERATION_CAPABILITY_INVALID, + }); + }; + // The same admission every federated read passes: live grant, unblocked peer, budget. + federation::admit(&federation, &counters, &capability, Presentation::Refresh).await?; + if !federation.is_configured() { + // A grant minted while this server federated still *verifies* — a token is not + // un-minted by a configuration change — but it is not continued. + tracing::info!(peer = %capability.record.peer_id, "a refresh was refused: this deployment does not federate"); + return Err(RefreshRejection::NotConfigured { + code: error_codes::FEDERATION_NOT_CONFIGURED, + }); + } + + let predecessor = &capability.record; + let now = federation.clock().now(); + + // The absolute deadline the original mint fixed. A grant the owner did not make renewable + // has `not_after == expires_at` and fails here on its own first refresh, which is the + // point: renewability is asked for, not assumed. + if !predecessor.may_refresh_at(now) { + tracing::info!( + peer = %predecessor.peer_id, + jti = %predecessor.jti, + not_after = %predecessor.not_after, + renewable = predecessor.is_renewable(), + "a refresh was refused: the grant's deadline has passed" + ); + return Err(RefreshRejection::GrantExpired { + code: error_codes::FEDERATION_CAPABILITY_EXPIRED, + }); + } + + // The membership the grant was minted for, re-asked. The read path checks this too, so + // nothing is *exposed* by skipping it — but a server that kept minting successors for a + // membership that has ended would be issuing tokens that can never be used, and writing a + // row for each. + match membership + .members() + .membership(&predecessor.album_id, &predecessor.member) + .await + .map_err(|error| { + tracing::error!(%error, album = %predecessor.album_id, "the membership store could not answer a refresh"); + RefreshRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })? { + Membership::Member { granted_epoch, .. } if granted_epoch == predecessor.granted_epoch => {} + membership => { + tracing::info!( + peer = %predecessor.peer_id, + member = %predecessor.member, + album = %predecessor.album_id, + ?membership, + granted_epoch = predecessor.granted_epoch, + "a refresh was refused: its member is not on the roster at the granted epoch" + ); + return Err(RefreshRejection::NotOnRoster { + code: error_codes::FEDERATION_MEMBER_NOT_ON_ROSTER, + }); + } + } + + // The successor carries everything the predecessor granted, unchanged: the store refuses a + // successor that names another peer, album, member **or deadline**, so a refresh can neither + // widen a grant nor outlive one. Its TTL is whatever is left of the grant, capped at the + // default — so the last token of a grant is short rather than overhanging its deadline. + let remaining = predecessor.not_after.duration_since(now); + let minted = federation + .codec() + .mint(&MintRequest { + peer: predecessor.peer_id.clone(), + album: predecessor.album_id.clone(), + scope: predecessor.scope, + min_protocol_version: predecessor.min_protocol_version.clone(), + ttl: DEFAULT_TTL.min(remaining), + }) + .map_err(|error| { + tracing::error!(%error, "a successor capability could not be signed"); + RefreshRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + let successor = CapabilityRecord { + jti: minted.grant.jti.clone(), + album_id: predecessor.album_id.clone(), + peer_id: predecessor.peer_id.clone(), + member: predecessor.member.clone(), + scope: predecessor.scope, + granted_epoch: predecessor.granted_epoch, + min_protocol_version: predecessor.min_protocol_version.clone(), + issued_at: minted.grant.issued_at, + expires_at: minted.grant.expires_at, + not_after: predecessor.not_after, + revoked_at: None, + refreshed_to: None, + }; + + let outcome = federation + .capabilities() + .refresh(&predecessor.jti, successor, now) + .await + .map_err(|error| { + tracing::error!(%error, jti = %predecessor.jti, "a capability could not be refreshed"); + RefreshRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + + let (record, token, replayed) = match outcome { + crate::federation::RefreshOutcome::Issued(record) => (record, minted.token, false), + crate::federation::RefreshOutcome::AlreadyRefreshed(record) => { + // The successor an earlier call issued. It may have been revoked since — a block + // cascades over every live grant of a peer — so its liveness is asked again before + // it is handed back. + if !record.is_live(now) { + tracing::info!(jti = %record.jti, "a replayed refresh named a successor that is no longer live"); + return Err(RefreshRejection::Revoked { + code: error_codes::FEDERATION_CAPABILITY_REVOKED, + }); + } + let token = federation.codec().sign(&record.grant()).map_err(|error| { + tracing::error!(%error, jti = %record.jti, "a stored grant could not be re-signed"); + RefreshRejection::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + } + })?; + (record, token, true) + } + // The predecessor was revoked without a successor, or was never issued here. Neither is + // continuable, and both are what a revoked grant is told. + outcome => { + tracing::info!(jti = %predecessor.jti, ?outcome, "a refresh named a grant that cannot be continued"); + return Err(RefreshRejection::Revoked { + code: error_codes::FEDERATION_CAPABILITY_REVOKED, + }); + } + }; + + tracing::info!( + peer = %record.peer_id, + album = %record.album_id, + predecessor = %predecessor.jti, + successor = %record.jti, + replayed, + "a federation capability was refreshed" + ); + Ok(Json(RefreshedCapabilityResponse { + token, + jti: record.jti, + expires_at: record.expires_at.to_string(), + not_after: record.not_after.to_string(), + replayed, + })) +} + +// =========================================================================================== +// Federated moderation report intake (S-C49) +// =========================================================================================== + +/// The most bytes any one field of a federated report may carry. +/// +/// Every one of them ends up in a store row, a log line or a counter key, and none of them has a +/// natural bound from the type system: `reported_user`, `asset_hash` and `album_id` are strings a +/// peer chooses. The caps are generous against the real values — a DNS name is at most 253 bytes, +/// a UUID is 36, a SHA-256 hex digest is 64 — and their point is that *some* bound exists before +/// anything is stored or keyed on. +mod report_bounds { + /// A peer's origin: RFC 1035's ceiling on a domain name. + pub(super) const ORIGIN: usize = 253; + /// An account or album identifier: a UUID with room to spare. + pub(super) const IDENTIFIER: usize = 64; + /// A content address: a SHA-256 digest as lowercase hex. + pub(super) const HASH: usize = 64; + /// The peer's short reason. A sentence, not a case file — the contract's "short reason". + pub(super) const REASON: usize = 256; + /// An RFC 3339 instant, with room for any offset spelling. + pub(super) const INSTANT: usize = 64; + /// A base64 Ed25519 signature is 88 bytes; this leaves room for padding variants. + pub(super) const SIGNATURE: usize = 128; +} + +/// A moderation report one peer server files against an account on this one. +/// +/// Every field except `signature` is covered by the signature, in canonical CBOR — see +/// [`ReportClaim`](crate::federation::ReportClaim). +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +pub struct FederatedReportRequest { + /// The peer filing the report, as its own `server-info` names it. + pub reporting_server: String, + /// The account on this server the report is about. + pub reported_user: String, + /// The content address of the asset complained about. + pub asset_hash: String, + /// The album it was pulled from. + pub album_id: String, + /// A short reason, where the peer gives one. + pub reason: Option, + /// When the peer says it was reported, RFC 3339. + pub reported_at: String, + /// The peer's Ed25519 signature over the canonical CBOR of the fields above, base64. + pub signature: String, +} + +/// An accepted report. +/// +/// The identifier is this server's, so an operator and the reporting peer can talk about one +/// report. Nothing about the reported account is echoed — accepting a report says nothing about +/// whether it is true, and a body that reported on the account's standing would say it does. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +pub struct FederatedReportResponse { + /// This server's identifier for the report. + pub report_id: String, + /// When this server accepted it, RFC 3339. + pub received_at: String, +} + +/// Why a report was not accepted. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum ReportRejection { + /// A field of the body is not what it must be. + #[error("the report is malformed")] + #[problem(status = 400, title = "Malformed report")] + Malformed { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The signature does not verify under the peer's pinned key. + #[error("the report's signature could not be verified")] + #[problem(status = 401, title = "Report unsigned")] + Unsigned { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// No operator has pinned a key for the reporting server. + #[error("this server is not one we know")] + #[problem(status = 403, title = "Peer unknown")] + PeerUnknown { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The reporting server is on this server's blocklist. + #[error("this server is blocked")] + #[problem(status = 403, title = "Server blocked")] + PeerBlocked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// This deployment does not federate. + #[error("this server does not federate")] + #[problem(status = 403, title = "Federation not configured")] + NotConfigured { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// Too many reports from this peer about this account. + #[error("too many reports from this server about this account")] + #[problem(status = 429, title = "Report rate limited")] + RateLimited { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer, so nothing was filed. + #[error("the report could not be filed")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl ReportRejection { + /// A field of the body is not what it must be. + fn malformed() -> Self { + Self::Malformed { + code: error_codes::MODERATION_REPORT_MALFORMED, + } + } + + /// A collaborator could not answer. + fn unavailable() -> Self { + Self::Unavailable { + code: error_codes::MODERATION_UNAVAILABLE, + } + } +} + +/// File a signed moderation report from a peer server. +/// +/// # Its only reachable answer today is `403` +/// +/// A report is verified against the peer's **operator-pinned** key, and nothing can pin one: +/// [`boot::assemble`](crate::boot::assemble) refuses the durable backend until #403 lands its +/// adapters, so an operator command that pinned a peer could only run against `serve --memory` +/// and would forget the moment it exited. The command is owed with #476. Until it lands this +/// operation answers `403 error.federation.peer_unknown` to every real peer. +/// +/// It is mounted anyway, deliberately: a peer implementing against the published contract needs +/// the operation to exist and to answer honestly, and what is missing is the command, not the +/// surface. What is *not* acceptable is a route that reads as protection it cannot provide — +/// hence this paragraph, and the matching status notes in design/moderation.md and +/// design/federation.md. +/// +/// # No bearer, and why that is not "unauthenticated" +/// +/// The reporting peer holds no capability here — it is reporting *this* server's content, not +/// pulling it — so there is nothing to present. What it does hold is a key an operator has +/// **pinned**, and the report carries its own Ed25519 signature over the canonical CBOR of every +/// other field. A report from a server nobody has pinned is `403`: intake is not the moment a +/// peer becomes trusted (design/federation.md's TOFU is explicitly not done here). +/// +/// # The order the checks run in +/// +/// Bounds, then how much may be asked for at all, then who is speaking, then whether they are +/// welcome, then whether they really said it, then whose account it is, then whether they have +/// said it too often. +/// +/// Every field is length-capped first, before a store is read or a byte is keyed on. Then +/// [`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) — keyed on the +/// *claimed* origin, so it bounds one origin looping rather than a caller cycling origins, which +/// is the most this server can do without a trusted client address. Everything after it is a +/// store read and an Ed25519 verification, and this is the only place a bound on that work can +/// sit. +/// +/// The **policy** budgets are charged last, after the signature verifies, so a third party +/// spoofing `reporting_server` cannot spend a real peer's allowance. Two of them: the contract's +/// per-`(server, account)` limit, and a per-peer ceiling that ignores the account, because +/// `reported_user` is a string the peer chooses and a peer cycling accounts would otherwise mint +/// itself a fresh allowance each time. +/// +/// What is *not* bounded is bytes parsed per request: a per-operation body cap cannot be +/// expressed against this framework, and the reason is recorded on +/// [`MAX_FEDERATION_BODY_BYTES`](crate::limits::MAX_FEDERATION_BODY_BYTES) (issue #478). +/// +/// # What accepting one does +/// +/// It writes a row an operator will read ([`ModerationStore::pending_reports`]) and **nothing +/// else**. A peer's report is an input to a decision, never a decision: no standing changes, no +/// serving hold appears, and the reported account sees nothing — because nothing has been done +/// to them. +/// +/// # `202` whether or not the account exists +/// +/// A report naming an account this server does not host is **accepted on the wire and dropped**, +/// with a `warn` for the operator. It is not filed: an unresolvable report is a permanent orphan +/// row that nobody can act on, which is the reason the check exists at all. +/// +/// The answer is deliberately the same one a filed report gets. An earlier version refused with a +/// distinct coded `404`, and that manufactured an account-enumeration oracle out of a check that +/// did not need one: a pinned peer could walk identifiers and read existence off the status line. +/// "Pinned" is not "trusted with enumeration" — a peer key can be compromised, and a peer can be +/// adversarial toward its own users while remaining an operator's legitimate partner — and this +/// codebase treats exists-versus-does-not as a first-order defect nearly everywhere else +/// ([`crate::routes::enroll`]'s indistinguishable code refusal, the album ceremonies' "not yours +/// is not found", [`crate::serve::authority`]'s `404`/`403` boundary). +/// +/// Probing is not free even so: every budget above is charged before this point is reached, so a +/// peer sweeping identifiers spends its allowance doing it and an operator sees the `warn`. +#[kynos::post( + "/v1/federation/reports", + operation_id = "submit_federated_report", + tag = FederationTag +)] +pub async fn submit_federated_report( + Inject(federation): Inject, + Inject(moderation): Inject, + Inject(auth): Inject, + Inject(counters): Inject, + Json(request): Json, +) -> Result { + if !federation.is_configured() { + tracing::info!("a federated report was refused: this deployment does not federate"); + return Err(ReportRejection::NotConfigured { + code: error_codes::FEDERATION_NOT_CONFIGURED, + }); + } + // Structural bounds first, on every field, before a store is touched or a byte is keyed on. + // Each of these ends up in a row, a log line or a counter key, and none of them is bounded + // by anything but this: the body cap the federation group mounts stops a caller sending + // megabytes, and this stops one field of a legal body being all of them. + for (name, value, cap) in [ + ( + "reporting_server", + request.reporting_server.trim(), + report_bounds::ORIGIN, + ), + ( + "reported_user", + request.reported_user.trim(), + report_bounds::IDENTIFIER, + ), + ("asset_hash", request.asset_hash.trim(), report_bounds::HASH), + ( + "album_id", + request.album_id.trim(), + report_bounds::IDENTIFIER, + ), + ( + "reported_at", + request.reported_at.trim(), + report_bounds::INSTANT, + ), + ( + "signature", + request.signature.trim(), + report_bounds::SIGNATURE, + ), + ( + "reason", + request.reason.as_deref().unwrap_or("x").trim(), + report_bounds::REASON, + ), + ] { + if value.is_empty() || value.len() > cap { + tracing::info!( + field = name, + length = value.len(), + "a federated report's field is out of bounds" + ); + return Err(ReportRejection::malformed()); + } + } + let peer = PeerId::new(request.reporting_server.trim()); + if peer.as_str().is_empty() { + return Err(ReportRejection::malformed()); + } + let Ok(reported_at) = request.reported_at.parse::() else { + tracing::info!(%peer, "a federated report carried an unreadable reported_at"); + return Err(ReportRejection::malformed()); + }; + let Ok(signature) = BASE64.decode(request.signature.as_bytes()) else { + tracing::info!(%peer, "a federated report's signature is not base64"); + return Err(ReportRejection::malformed()); + }; + + // The bound on how much work an anonymous caller may ask for, charged **before** the peer + // is looked up — everything past this line is a store read and an Ed25519 verification. The + // key is the *claimed* origin, which is attacker-chosen: it bounds one origin looping and + // not a caller cycling origins, because this server has no trusted client address to key on + // instead. Stated here rather than left to look like more than it is. + charge( + &counters, + &CounterKey::FederatedIntake(peer.as_str().to_owned()), + budgets::FEDERATED_INTAKE, + ) + .await?; + + // Who is speaking. A peer nobody pinned, and a peer pinned without a key, are the same + // answer: there is nothing to verify against, so nothing is verified. + let record = federation.peers().read(&peer).await.map_err(|error| { + tracing::error!(%error, %peer, "the peer store could not answer a report intake"); + ReportRejection::unavailable() + })?; + let Some(record) = record else { + tracing::info!(%peer, "a report was refused: the peer is unknown"); + return Err(ReportRejection::PeerUnknown { + code: error_codes::FEDERATION_PEER_UNKNOWN, + }); + }; + if record.is_blocked() { + tracing::info!(%peer, "a report was refused: the peer is blocked"); + return Err(ReportRejection::PeerBlocked { + code: error_codes::MODERATION_SERVER_BLOCKED, + }); + } + let Some(key) = record.signing_key else { + tracing::info!(%peer, "a report was refused: the peer has no pinned key"); + return Err(ReportRejection::PeerUnknown { + code: error_codes::FEDERATION_PEER_UNKNOWN, + }); + }; + + // The claim is built from the body's fields with **surrounding whitespace trimmed and + // nothing else** — the one normalization rule, written into design/federation.md so a peer + // implementing from the doc signs the bytes this verifies. In particular the peer's own + // `reporting_server` string is signed as sent, not folded to the canonical `PeerId` form + // used for the lookup, and `reported_at` is the RFC 3339 text and not a re-rendered instant. + let claim = ReportClaim { + reporting_server: request.reporting_server.trim().to_owned(), + reported_user: request.reported_user.trim().to_owned(), + asset_hash: request.asset_hash.trim().to_owned(), + album_id: request.album_id.trim().to_owned(), + reason: request.reason.clone(), + reported_at: request.reported_at.trim().to_owned(), + }; + // Kept verbatim: these are the bytes the signature covers, and the only thing an operator + // re-verifying months later can use. Every stored field is derived from this claim. + let signed = claim + .signing_bytes() + .map_err(|_| ReportRejection::unavailable())?; + claim + .verify(&signature, &key) + .map_err(|error| match error { + crate::federation::ReportError::NotAuthentic => ReportRejection::Unsigned { + code: error_codes::MODERATION_REPORT_UNSIGNED, + }, + crate::federation::ReportError::Unencodable => ReportRejection::unavailable(), + })?; + + // Does the account exist here? The answer decides whether a row is written and **never what + // the peer is told** — see the module docs. A report naming an account this server does not + // host is accepted on the wire and dropped with a `warn`, which is the pattern + // [`crate::routes::enroll`] uses for unknown-versus-spent-versus-expired codes: log so an + // operator can act, never tell the asker. + let reported_user = UserId::new(&claim.reported_user); + let hosted = crate::auth::AccountProfiles::read(auth.profiles(), &reported_user) + .await + .map_err(|error| { + tracing::error!(%error, %peer, "the account directory could not answer a report intake"); + ReportRejection::unavailable() + })? + .is_some(); + + // Only now are the *policy* budgets charged: a spoofed `reporting_server` must not be able + // to spend a real peer's allowance. Two of them — the contract bounds reports per + // `(server, account)`, and a peer cycling accounts would mint itself a fresh allowance each + // time, so a ceiling that ignores the account is what actually bounds the peer. + charge( + &counters, + &CounterKey::PeerReports(peer.as_str().to_owned()), + budgets::PEER_REPORTS, + ) + .await?; + charge( + &counters, + &CounterKey::FederatedReports(format!("{peer}:{}", claim.reported_user)), + budgets::FEDERATED_REPORTS, + ) + .await?; + + let received_at = federation.clock().now(); + if !hosted { + // Logged at `warn` rather than `info`: a peer repeatedly reporting accounts this server + // does not host is either misrouting or probing, and both are things an operator wants + // to see. The budgets above were charged either way, so probing is not free. + tracing::warn!( + %peer, + "a federated report named an account this server does not host; accepted and dropped" + ); + return Ok(ReportReply::Accepted(FederatedReportResponse { + // A fresh identifier, as an accepted report gets. It names nothing this server + // stored, and that is the point: the answer must not vary with what exists. + report_id: uuid::Uuid::now_v7().to_string(), + received_at: received_at.to_string(), + })); + } + + let report = FederatedReport { + report_id: uuid::Uuid::now_v7().to_string(), + reporting_server: peer.as_str().to_owned(), + reported_user, + asset_hash: claim.asset_hash.clone(), + album_id: AlbumId::new(&claim.album_id), + reason: claim.reason.clone(), + reported_at, + received_at, + signature, + signed, + }; + let report_id = report.report_id.clone(); + moderation + .store() + .file_report(report) + .await + .map_err(|error| { + tracing::error!(%error, %peer, "a federated report could not be filed"); + ReportRejection::unavailable() + })?; + + Ok(ReportReply::Accepted(FederatedReportResponse { + report_id, + received_at: received_at.to_string(), + })) +} + +/// Charge `budget` under `key`, rendering the refusals this route gives. +/// +/// One helper for three budgets so they cannot answer differently: a spent budget is `429` with +/// the contract's code, and a counter that cannot be reached is `500` and never an admission — +/// a limiter that failed open would be one an attacker turns off by loading the counter store. +async fn charge( + counters: &CounterContext, + key: &CounterKey, + budget: crate::counter::Budget, +) -> Result<(), ReportRejection> { + match counters.hit(key, budget).await.map_err(|error| { + tracing::error!(%error, kind = key.as_str(), "a report counter could not be reached"); + ReportRejection::unavailable() + })? { + crate::counter::Verdict::Admitted { .. } => Ok(()), + crate::counter::Verdict::Limited { retry_after } => { + tracing::info!(kind = key.as_str(), %retry_after, "a federated report budget is spent"); + Err(ReportRejection::RateLimited { + code: error_codes::MODERATION_REPORT_RATE_LIMITED, + }) + } + } +} + +/// The one way intake succeeds. +/// +/// `202`, never `201`: this server has accepted the report for an operator to look at, and has +/// created nothing the reporting peer can address. A `200` would read as "handled". +#[derive(Reply)] +pub enum ReportReply { + /// The report was filed for an operator to read. + #[reply(status = 202, description = "The report was accepted for review")] + Accepted(FederatedReportResponse), +} diff --git a/capsule-server/src/routes/mod.rs b/capsule-server/src/routes/mod.rs index 7ac91bdd..e17eee68 100644 --- a/capsule-server/src/routes/mod.rs +++ b/capsule-server/src/routes/mod.rs @@ -15,11 +15,14 @@ pub mod directory; pub mod drop; pub mod enroll; pub mod escrow; +pub mod federation; pub mod moderation; +pub mod oidc; pub mod ops; pub mod profile; pub mod quota; pub mod receipts; +pub mod roster; pub mod sessions; pub mod share; pub mod storage; diff --git a/capsule-server/src/routes/oidc.rs b/capsule-server/src/routes/oidc.rs new file mode 100644 index 00000000..539534f3 --- /dev/null +++ b/capsule-server/src/routes/oidc.rs @@ -0,0 +1,702 @@ +//! `POST /v1/auth/oidc/authorize`, `POST /v1/auth/oidc/callback` — signing in through an +//! external identity provider (slice `S-N1`). +//! +//! # Two requests, one ceremony +//! +//! The client asks for an authorization URL and gets one back with a `state`; it sends the person +//! there; the provider sends them back to the client's own redirect with a `code`; the client +//! posts `state` and `code` here, and gets exactly what a password sign-in gets — a +//! [`LoginReply`]: a token pair, or a second-factor challenge. The session is opened by the same +//! [`open_session_for`] the password path calls, which is the whole of "identical to the +//! password path" (design/authentication.md, "Choosing an Auth Path"). +//! +//! The server never sees the person's browser. The redirect lands at the *client* — a web app's +//! callback route, or a CLI's loopback listener — which is why the redirect URI is a request +//! field the client names and the policy admits, rather than a server route. +//! +//! # What the callback checks, and what it says +//! +//! Every rejection reason is logged with its detail and collapsed on the wire, so the callback +//! is not an oracle over which checks the relying party runs: a foreign signature, a wrong +//! audience, an expired token and a replayed nonce all render `error.auth.oidc_token_invalid`. +//! A burned, expired or unknown `state` is one code, as `error.auth.totp_challenge_invalid` is. +//! What *does* reach the caller distinctly is the remedy: the provider refused the exchange +//! (start again), the provider is down (wait), the address already has an account here (sign in +//! with the password instead). +//! +//! # Not configured +//! +//! Without `OIDC_ISSUER` the authorize answers `404 error.auth.oidc_not_configured`, and +//! `server-info` publishes `auth.oidc: null`, which is how the login chooser decides whether to +//! render the option at all. The callback declares no `404`: an unconfigured deployment holds no +//! pending ceremony, so a callback there is a `401 error.auth.oidc_state_invalid` — the honest +//! answer, and one that adds no oracle. +//! +//! # The second factor is honoured, not bypassed +//! +//! A confirmed TOTP enrollment answers the same `202` here that it does on the password path. +//! Bypassing it would let an account that enrolled a factor be signed into without one through a +//! second door — the `S-C55` defect on a new route. + +use std::fmt; + +use capsule_i18n::error_codes; +use kynos::prelude::*; +use serde::{Deserialize, Serialize}; +use tracing::Instrument as _; + +use super::auth::{LoginReply, TokenResponse, open_session_for}; +use super::totp::SecondFactorChallenge; +use crate::auth::oidc::{ + AuthorizationRequest, FederatedLink, OidcContext, ProviderError, Redemption, code_challenge, + fresh_nonce, fresh_state, fresh_verifier, +}; +use crate::auth::{AuthContext, DirectoryError, EnrollmentState, TotpContext}; +use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::store::{AuthorizationCode, OidcState, PendingAuthorization, StoreError}; + +/// The operations that sign in through an external identity provider. +#[derive(Tag)] +#[tag( + name = "oidc", + description = "Signing in through an external OpenID Connect identity provider." +)] +pub struct OidcTag; + +// =========================================================================================== +// Wire types +// =========================================================================================== + +/// The `POST /v1/auth/oidc/authorize` body. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct OidcAuthorizeRequest { + /// Where the provider should send the person back: the client's own callback. + /// + /// Admitted if it is the deployment's configured redirect URL exactly, or a loopback IP + /// literal (`http://127.0.0.1:{port}/…`, `http://[::1]:{port}/…`) on any port when the + /// deployment allows loopback redirects — the shape a CLI's or desktop app's listener has + /// (RFC 8252 §7.3). Stored with the ceremony and replayed byte for byte to the token endpoint. + pub redirect_uri: String, +} + +/// A begun ceremony: where to send the person, and the `state` that comes back. +#[derive(Schema, Serialize, Deserialize, Clone)] +pub struct OidcAuthorizationResponse { + /// The provider's authorization endpoint with the whole request in its query: `response_type`, + /// `client_id`, `redirect_uri`, `scope`, `state`, `nonce`, `code_challenge`, + /// `code_challenge_method`. + pub authorization_url: String, + /// The `state` the provider will echo on the redirect. Present it, with the `code`, to the + /// callback. Good once, and until `expires_by`. + pub state: String, + /// The **absolute** Unix-seconds instant the ceremony stops being redeemable. + pub expires_by: u64, +} + +impl fmt::Debug for OidcAuthorizationResponse { + /// Redacted: the state is the key to the pending ceremony, and the URL carries it and the + /// nonce. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OidcAuthorizationResponse") + .field("authorization_url", &"") + .field("state", &"") + .field("expires_by", &self.expires_by) + .finish() + } +} + +/// The `POST /v1/auth/oidc/callback` body: what the provider's redirect carried, plus the two +/// advisory identifiers a client may volunteer for the session this request opens. +/// +/// Strict, like [`OidcAuthorizeRequest`]: a client that forwards the provider's whole redirect +/// query — `error`, `error_description`, `iss` (RFC 9207) — gets a `422` naming the field rather +/// than a callback that quietly ignored what the provider said. +#[derive(Schema, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct OidcCallbackRequest { + /// The `state` the authorize answered with, as the redirect echoed it. + pub state: String, + /// The authorization `code` the redirect carried. + pub code: String, + /// An advisory device-cohort hash (slice `S-C13`). Legibility metadata only; an unusable + /// value is dropped rather than refused. + pub cohort_hash: Option, + /// The directory device the client claims to be (slice `S-N3`), as a UUID. Dropped, not + /// refused, when it is not a usable UUID. + pub device_id: Option, +} + +impl fmt::Debug for OidcCallbackRequest { + /// Redacted: the state and the code are the two halves of a live credential. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OidcCallbackRequest") + .field("state", &"") + .field("code", &"") + .field("cohort_hash", &self.cohort_hash) + .field("device_id", &self.device_id) + .finish() + } +} + +// =========================================================================================== +// Rejections +// =========================================================================================== + +/// Why a ceremony could not begin. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum OidcAuthorizeRejection { + /// The redirect URI is neither the configured one nor an admitted loopback address. + #[error("the redirect URI is not one this server will send a person back to")] + #[problem(status = 400, title = "Redirect not admitted")] + RedirectInvalid { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// This deployment has no identity provider. + #[error("single sign-on is not configured on this server")] + #[problem(status = 404, title = "Single sign-on not configured")] + NotConfigured { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// Too many ceremonies were begun for this redirect host in the window (`S-C32`). + #[error("too many sign-ins were started; wait and try again")] + #[problem(status = 429, title = "Too many sign-ins")] + RateLimited { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The pending-ceremony store is at its ceiling. Retryable: ceremonies expire in minutes. + /// + /// Carries `error.auth.oidc_at_capacity`, its own code and not the `error.auth.unavailable` + /// the `500` below carries. `design/api-surfaces.md` is explicit that "REST status is coarse; + /// the stable `error.*` code in the `ApiError` body is the precise discriminator. Clients + /// switch on the code, never on status alone" — so two conditions that answer with different + /// statuses may not share one code, or a client obeying that rule cannot tell "the server + /// said not now, retry in a moment" from "a store could not answer at all". The remedies + /// differ too: the first is a short timer, the second is an outage to surface. + #[error("the server is holding too many unfinished sign-ins; try again shortly")] + #[problem(status = 503, title = "Sign-in capacity reached")] + AtCapacity { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The provider could not be reached, or a store could not answer. + /// + /// One variant carrying one of two codes: `error.auth.oidc_unavailable` when the identity + /// provider is the collaborator that failed, `error.auth.unavailable` when a Capsule store + /// is. "Your identity provider is down" and "our session store is down" are different + /// operator actions, and the code is what tells them apart. + #[error("the sign-in could not be started")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +/// Why a callback did not open a session. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum OidcCallbackRejection { + /// The `state` is unknown, already redeemed, or expired. One answer for all three. + #[error("that sign-in has expired or was already completed; start again")] + #[problem(status = 401, title = "Sign-in expired")] + StateInvalid { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The provider refused to exchange the code. + #[error("the identity provider did not accept the sign-in")] + #[problem(status = 401, title = "Exchange refused")] + ExchangeFailed { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The provider's ID token failed a check. One answer for every check; see the module docs. + #[error("the identity provider's answer could not be verified")] + #[problem(status = 401, title = "ID token refused")] + TokenInvalid { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The asserted address already belongs to an account here, and this identity is not it. + /// + /// The disclosure this makes is the one `error.auth.user_already_exists` already makes at + /// registration, so it adds no new oracle; see `auth::oidc::accounts`. + #[error("an account with that address already exists here; sign in with its password")] + #[problem(status = 409, title = "Address already registered")] + AddressTaken { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The provider could not be reached, or a collaborator could not answer. + /// + /// Two codes, as on the authorize: `error.auth.oidc_unavailable` for the provider, + /// `error.auth.unavailable` for a Capsule store. + #[error("the sign-in could not be completed")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl OidcAuthorizeRejection { + fn redirect_invalid() -> Self { + Self::RedirectInvalid { + code: error_codes::AUTH_OIDC_REDIRECT_INVALID, + } + } + + fn not_configured() -> Self { + Self::NotConfigured { + code: error_codes::AUTH_OIDC_NOT_CONFIGURED, + } + } + + fn rate_limited() -> Self { + Self::RateLimited { + code: error_codes::AUTH_RATE_LIMITED, + } + } + + /// The store refused a write because it is full. + fn at_capacity() -> Self { + Self::AtCapacity { + code: error_codes::AUTH_OIDC_AT_CAPACITY, + } + } + + /// The identity provider could not answer. + fn provider_unavailable() -> Self { + Self::Unavailable { + code: error_codes::AUTH_OIDC_UNAVAILABLE, + } + } + + /// A Capsule store could not answer. + fn store_unavailable() -> Self { + Self::Unavailable { + code: error_codes::AUTH_UNAVAILABLE, + } + } +} + +impl OidcCallbackRejection { + fn state_invalid() -> Self { + Self::StateInvalid { + code: error_codes::AUTH_OIDC_STATE_INVALID, + } + } + + fn exchange_failed() -> Self { + Self::ExchangeFailed { + code: error_codes::AUTH_OIDC_EXCHANGE_FAILED, + } + } + + fn token_invalid() -> Self { + Self::TokenInvalid { + code: error_codes::AUTH_OIDC_TOKEN_INVALID, + } + } + + fn address_taken() -> Self { + Self::AddressTaken { + code: error_codes::AUTH_OIDC_ADDRESS_TAKEN, + } + } + + fn provider_unavailable() -> Self { + Self::Unavailable { + code: error_codes::AUTH_OIDC_UNAVAILABLE, + } + } + + fn store_unavailable() -> Self { + Self::Unavailable { + code: error_codes::AUTH_UNAVAILABLE, + } + } +} + +// =========================================================================================== +// Operations +// =========================================================================================== + +/// Begin a sign-in through the identity provider. +/// +/// Unauthenticated: this is how a person *becomes* a session. Nothing about the account is +/// known yet — the ceremony carries fresh random `state`, `nonce` and PKCE material and the +/// admitted redirect URI, and the record behind the `state` lives for ten minutes. +/// +/// Bounded twice, because it is an unauthenticated write into a store: a budget per redirect +/// host ([`budgets::OIDC_AUTHORIZE`]) answers `429` before anything is done, and the store's +/// own ceiling answers `503` when it is nevertheless full. +#[kynos::post( + "/v1/auth/oidc/authorize", + operation_id = "begin_oidc_login", + tag = OidcTag +)] +pub async fn begin_oidc_login( + Inject(oidc): Inject, + Inject(counters): Inject, + Json(request): Json, +) -> Result, OidcAuthorizeRejection> { + async move { + let redirect_uri = request.redirect_uri.trim(); + + // The redirect is validated *before* the budget is charged, and the order is the + // security property rather than a preference. `redirect_uri` is caller-supplied and + // unbounded; charging a counter keyed on its host before the policy has looked at it + // would let an unauthenticated caller add one permanent row to the counter store per + // request — a `400` every time, and a map that only grows. So the key is picked from + // the policy's verdict: an admitted redirect is keyed on its host (three at most), and + // every refusal shares one fixed bucket. The look-ahead costs a string comparison; the + // `authorization_url` below applies the same policy and remains the authority. + let (key, budget) = if oidc.provider().admits_redirect(redirect_uri) { + match reqwest::Url::parse(redirect_uri) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + { + Some(host) => (CounterKey::OidcAuthorize(host), budgets::OIDC_AUTHORIZE), + // Unreachable: the policy admits an exactly-configured URL, which config + // validated as absolute, or a loopback literal it parsed. Bounded anyway, + // because "unreachable" is not a thing to key a map on. + None => ( + CounterKey::OidcAuthorizeRefused, + budgets::OIDC_AUTHORIZE_REFUSED, + ), + } + } else { + ( + CounterKey::OidcAuthorizeRefused, + budgets::OIDC_AUTHORIZE_REFUSED, + ) + }; + let verdict = counters.hit(&key, budget).await.map_err(|error| { + // Fail closed, like every other limiter in this crate. + tracing::error!(%error, "the OIDC authorize limiter could not be reached"); + OidcAuthorizeRejection::store_unavailable() + })?; + if !verdict.admits() { + tracing::warn!( + counter = key.as_str(), + "an OIDC sign-in was refused: the budget for this bucket is spent" + ); + return Err(OidcAuthorizeRejection::rate_limited()); + } + + // Fresh per ceremony. The verifier never leaves this server; its challenge goes in the + // URL, and the verifier itself is redeemed at the token endpoint by the callback. + let state = fresh_state(); + let nonce = fresh_nonce(); + let verifier = fresh_verifier(); + let challenge = code_challenge(&verifier); + + // The provider is asked first, so a refused redirect or an unconfigured deployment + // writes nothing to the store. + let authorization_url = oidc + .provider() + .authorization_url(&AuthorizationRequest { + redirect_uri, + state: &state, + nonce: &nonce, + code_challenge: &challenge, + }) + .await + .map_err(|error| match error { + ProviderError::NotConfigured => { + tracing::info!("an OIDC sign-in was requested and no provider is configured"); + OidcAuthorizeRejection::not_configured() + } + ProviderError::RedirectRefused { redirect_uri } => { + tracing::info!(%redirect_uri, "an OIDC sign-in named a redirect the policy refuses"); + OidcAuthorizeRejection::redirect_invalid() + } + other => { + tracing::error!(error = %other, "the identity provider could not begin a sign-in"); + OidcAuthorizeRejection::provider_unavailable() + } + })?; + + let issued_at = oidc.clock().now(); + oidc.authorizations() + .begin( + &state, + PendingAuthorization { + nonce, + verifier, + redirect_uri: redirect_uri.to_owned(), + issued_at, + }, + ) + .await + .map_err(|error| match error { + StoreError::Rejected { .. } => { + tracing::warn!(%error, "the pending OIDC authorization store is full"); + OidcAuthorizeRejection::at_capacity() + } + other => { + store_unavailable(&other, "record a pending OIDC authorization"); + OidcAuthorizeRejection::store_unavailable() + } + })?; + + let expires_at = crate::store::deadline(issued_at, oidc.authorizations().ttl()); + tracing::info!("began an OIDC sign-in"); + Ok(Json(OidcAuthorizationResponse { + authorization_url, + state: state.as_str().to_owned(), + expires_by: u64::try_from(expires_at.as_second()).unwrap_or(0), + })) + } + .instrument(tracing::info_span!("oidc.authorize")) + .await +} + +/// Finish a sign-in with what the provider's redirect carried. +/// +/// The `state` is burned first and whatever happens next: a ceremony that survived a failed +/// callback would be a ceremony an attacker could retry a stolen code against. Then the code is +/// exchanged and the ID token verified by the provider adapter, the identity is resolved to an +/// account — created on first sight, keyed on `(issuer, subject)`, never linked by address — and +/// the session is opened exactly as a password sign-in opens one, second factor included. +#[kynos::post( + "/v1/auth/oidc/callback", + operation_id = "complete_oidc_login", + tag = OidcTag +)] +pub async fn complete_oidc_login( + Inject(oidc): Inject, + Inject(auth): Inject, + Inject(totp): Inject, + Json(request): Json, +) -> Result { + async move { + // Burned first. `consume` is destructive on every attempt, so a replayed state — and a + // stolen code arriving on it — finds nothing, and two callbacks racing one state resolve + // to one winner here. + let state = OidcState::new(request.state.trim()); + let pending = oidc + .authorizations() + .consume(&state) + .await + .map_err(|error| { + store_unavailable(&error, "consume a pending OIDC authorization"); + OidcCallbackRejection::store_unavailable() + })?; + let Some(pending) = pending else { + tracing::info!("an OIDC callback presented an unknown, spent or expired state"); + return Err(OidcCallbackRejection::state_invalid()); + }; + + let code = AuthorizationCode::new(request.code.trim()); + let identity = oidc + .provider() + .redeem(&Redemption { + code: &code, + verifier: &pending.verifier, + redirect_uri: &pending.redirect_uri, + nonce: &pending.nonce, + }) + .await + .map_err(|error| match error { + ProviderError::ExchangeRefused { detail } => { + tracing::warn!(%detail, "the identity provider refused a code exchange"); + OidcCallbackRejection::exchange_failed() + } + ProviderError::TokenRejected(reason) => { + // The specific reason for the operator; one code for the wire. + tracing::warn!(%reason, "an ID token was refused"); + OidcCallbackRejection::token_invalid() + } + ProviderError::NotConfigured => { + // A pending ceremony exists and no provider does. Unreachable while the two + // are configured together, and a fault rather than a refusal if it ever is. + tracing::error!("a pending OIDC authorization exists on an unconfigured relying party"); + OidcCallbackRejection::provider_unavailable() + } + other => { + tracing::error!(error = %other, "the identity provider could not complete a sign-in"); + OidcCallbackRejection::provider_unavailable() + } + })?; + + // Minted here, as `register_user` mints one: the id is a fact about this server's + // clock. Discarded unchanged if the identity already has an account. + let now = auth.clock().now(); + let minted = crate::auth::new_user_id(); + let user = match oidc + .accounts() + .resolve_or_create(&identity, &minted, now) + .await + .map_err(|error: DirectoryError| { + tracing::error!(%error, "the federated account directory could not answer"); + OidcCallbackRejection::store_unavailable() + })? { + FederatedLink::Linked(user) => user, + FederatedLink::Created(user) => { + tracing::info!(user_id = %user, issuer = %identity.issuer, "created an account for a federated sign-in"); + user + } + FederatedLink::AddressTaken => { + tracing::info!(issuer = %identity.issuer, "a federated sign-in asserted an address another account holds"); + return Err(OidcCallbackRejection::address_taken()); + } + }; + + // The same second factor the password path honours, read after the identity is + // established and failing closed on a store outage, for the same reasons `login_user` + // records. + let second_factor = totp + .enrollments() + .read(&user) + .await + .map_err(|error: DirectoryError| { + tracing::error!(%error, user_id = %user, "the second-factor store could not answer"); + OidcCallbackRejection::store_unavailable() + })? + .is_some_and(|held| held.state == EnrollmentState::Active); + if second_factor { + let challenge = crate::auth::ChallengeId::generate(); + let issued = auth + .tokens() + .issue_second_factor(&user, &challenge, crate::auth::CHALLENGE_TTL) + .map_err(|error| { + tracing::error!(%error, "a second-factor challenge could not be signed"); + OidcCallbackRejection::store_unavailable() + })?; + tracing::info!(user_id = %user, challenge_id = %challenge, "a federated sign-in needs a second factor"); + return Ok(LoginReply::SecondFactorRequired(SecondFactorChallenge { + mfa_token: issued.token, + expires_by: u64::try_from(issued.expires_at.as_second()).unwrap_or(0), + })); + } + + let issued = open_session_for( + &auth, + &user, + request.cohort_hash.as_deref(), + request.device_id.as_deref(), + now, + ) + .await + .map_err(|error| { + store_unavailable(&error, "open a session for a federated sign-in"); + OidcCallbackRejection::store_unavailable() + })?; + + tracing::info!(user_id = %user, issuer = %identity.issuer, "opened a session for a federated sign-in"); + Ok(LoginReply::Signed(TokenResponse::from(issued))) + } + .instrument(tracing::info_span!("oidc.callback")) + .await +} + +/// One log line for every store failure, so a support report can name the operation. +fn store_unavailable(error: &StoreError, doing: &'static str) { + tracing::error!(%error, operation = doing, "a store could not answer"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_callback_body_never_prints_its_credentials() { + let request = OidcCallbackRequest { + state: "live-state".to_owned(), + code: "live-code".to_owned(), + cohort_hash: Some("cohort-1".to_owned()), + device_id: None, + }; + let printed = format!("{request:?}"); + assert!( + !printed.contains("live-state") && !printed.contains("live-code"), + "{printed}" + ); + assert!(printed.contains("cohort-1"), "{printed}"); + + let response = OidcAuthorizationResponse { + authorization_url: "https://idp/authorize?state=live-state".to_owned(), + state: "live-state".to_owned(), + expires_by: 42, + }; + let printed = format!("{response:?}"); + assert!(!printed.contains("live-state"), "{printed}"); + assert!(printed.contains("42"), "{printed}"); + } + + #[test] + fn every_rejection_publishes_its_catalog_code() { + assert!(matches!( + OidcAuthorizeRejection::redirect_invalid(), + OidcAuthorizeRejection::RedirectInvalid { code } if code == error_codes::AUTH_OIDC_REDIRECT_INVALID + )); + assert!(matches!( + OidcAuthorizeRejection::not_configured(), + OidcAuthorizeRejection::NotConfigured { code } if code == error_codes::AUTH_OIDC_NOT_CONFIGURED + )); + assert!(matches!( + OidcAuthorizeRejection::provider_unavailable(), + OidcAuthorizeRejection::Unavailable { code } if code == error_codes::AUTH_OIDC_UNAVAILABLE + )); + assert!(matches!( + OidcAuthorizeRejection::store_unavailable(), + OidcAuthorizeRejection::Unavailable { code } if code == error_codes::AUTH_UNAVAILABLE + )); + assert!(matches!( + OidcAuthorizeRejection::rate_limited(), + OidcAuthorizeRejection::RateLimited { code } if code == error_codes::AUTH_RATE_LIMITED + )); + assert!(matches!( + OidcAuthorizeRejection::at_capacity(), + OidcAuthorizeRejection::AtCapacity { code } if code == error_codes::AUTH_OIDC_AT_CAPACITY + )); + assert_ne!( + error_codes::AUTH_OIDC_AT_CAPACITY, + error_codes::AUTH_UNAVAILABLE, + "the 503 and the 500 are different conditions and may not share a code" + ); + assert!(matches!( + OidcCallbackRejection::state_invalid(), + OidcCallbackRejection::StateInvalid { code } if code == error_codes::AUTH_OIDC_STATE_INVALID + )); + assert!(matches!( + OidcCallbackRejection::exchange_failed(), + OidcCallbackRejection::ExchangeFailed { code } if code == error_codes::AUTH_OIDC_EXCHANGE_FAILED + )); + assert!(matches!( + OidcCallbackRejection::token_invalid(), + OidcCallbackRejection::TokenInvalid { code } if code == error_codes::AUTH_OIDC_TOKEN_INVALID + )); + assert!(matches!( + OidcCallbackRejection::address_taken(), + OidcCallbackRejection::AddressTaken { code } if code == error_codes::AUTH_OIDC_ADDRESS_TAKEN + )); + assert!(matches!( + OidcCallbackRejection::provider_unavailable(), + OidcCallbackRejection::Unavailable { code } if code == error_codes::AUTH_OIDC_UNAVAILABLE + )); + } +} diff --git a/capsule-server/src/routes/ops.rs b/capsule-server/src/routes/ops.rs index d4394ec0..2c3c70b0 100644 --- a/capsule-server/src/routes/ops.rs +++ b/capsule-server/src/routes/ops.rs @@ -21,6 +21,48 @@ //! chain head and then both write would both pass a handler-side check and double-apply, which //! is the stale revival invariant 17 exists to catch, reintroduced by the code enforcing it. //! +//! # Who a lifecycle record names, and what the server checks about it +//! +//! Every action this surface admits is a **chain continuation**. The allow-list in +//! [`check_op`](crate::upload::envelope::check_op) is `delete | trash-restore | +//! metadata-update | derivative-add | derivative-replace` — the five that do not move blob +//! bytes — and none may carry a null `prior_provenance_hash`. The two that *do* move bytes, +//! `create` and `replace`, are `POST /v1/upload`'s by definition. +//! +//! **`created_by_user` and `created_by_device` name the signer of *this record*, not the asset's +//! creator**, on a continuation exactly as on a create. That is not a convention this server +//! picked: `capsule_core::crypto::verify_asset` resolves the device inside *that account's* +//! published directory (step 6) and verifies `device_sig` under that entry's key (step 8), so a +//! record naming anyone but its own signer cannot verify by any reader. Album write authority is +//! decided separately, by `write_sig` under the epoch's write-tier key at step 10 — which is why +//! a member writing under their own name does not weaken the owner's album. +//! +//! So on a shared album, a writer member's delete of the owner's asset is authored by the +//! **member**, and the asset's creator remains recoverable from the `create` record at the head +//! of the append-only chain. The client half of that is +//! `capsule_core::lifecycle::provenance::sign_lifecycle`, which re-mints both fields per write. +//! +//! What this surface checks, in the order it decides: +//! +//! 1. the **bearer token** — the caller is an authenticated account, and everything below is +//! about that account rather than about a field in the body; +//! 2. **standing** (`S-C8`) — a suspended account may not write, whoever the manifest names; +//! 3. **write capability** — [`WriteAuthority::album_write_access`] answers +//! owner-or-writer-member for *this caller* on *this album*, and a reader, a former member and +//! a stranger get one indistinguishable `403`; +//! 4. **invariant 7, both halves** — `created_by_user` must be the caller, and +//! `created_by_device` must be a device in that caller's **own** published directory with an +//! `added_at` preceding the manifest. The account half stops a member attributing a write to +//! another account; the device half is the one an attacker cannot satisfy by editing a field, +//! because a device id in your directory is not something another account can borrow. +//! +//! And what the server does **not** do, stated plainly so invariant 7 is not read as more than +//! it is: it holds no keys and never parses `manifest_cbor`, so it cannot check `device_sig` at +//! all. It checks that the *claimed* identity is the caller's and that the claimed device is +//! one the caller published — never that the signature over those bytes is real. Deciding +//! whether a stored record is authentic is a key-holder's job and stays one, in `verify_asset` +//! on the client. +//! //! # A rejection writes nothing a client can observe //! //! The bundle's blobs are stored *before* the index is asked to apply the op, so a refusal can @@ -50,6 +92,7 @@ //! | `200` | kept, and now **static**. The retired handler picked its status at run time with `StatusCode::from_u16(result.status)`, which is why salvo-oapi could describe no responses at all and spargen refused the operation outright — and the value was unconditionally `200` every time | //! | `400` (envelope, action, amk) | kept, each with its own `error.*` code | //! | `403 error.upload.album_access_denied` | kept, and it now also answers an asset that is not the caller's — one value, because the asset id is client-chosen | +//! | `403 error.moderation.account_suspended` | **added.** A suspension removes the ability to write and a lifecycle op is a write; `POST /v1/upload` refused one from the start and this surface did not, which stopped being merely inconsistent when `S-C51` widened it from the owner to every writer member | //! | `409 error.upload.stale_revival` | kept — invariant 17, the status this surface exists to be able to give | //! | `401` | kept, and now the framework's | //! | `500` | kept | @@ -64,7 +107,7 @@ use serde::{Deserialize, Serialize}; use crate::auth::AccessToken; use crate::blob::ContentAddress; use crate::index::{LifecycleOp, OpAction, OpOutcome}; -use crate::store::{AlbumId, AssetId, OwnerId}; +use crate::store::{AlbumId, AssetId}; use crate::upload::envelope::{GateContext, GateReject, ManifestEnvelope, check_op}; use crate::upload::{AlbumWriteAccess, UploadContext}; @@ -175,6 +218,20 @@ pub enum OpRejection { code: &'static str, }, + /// The account is suspended (`S-C8`). + /// + /// The same status, code and reasoning as `POST /v1/upload`'s: a suspension removes the + /// ability to *write*, and a lifecycle op is a write. Distinct from the quota `403` and from + /// the permission one because the three send a client to three different screens, which is + /// what design/moderation.md asks a structured code for. + #[error("this account is suspended and cannot write")] + #[problem(status = 403, title = "Account suspended")] + AccountSuspended { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// The account is past its grace window, and this write would grow stored metadata. /// /// Never returned for a `delete` or a `trash-restore`: a user must be able to delete their @@ -211,12 +268,12 @@ pub enum OpRejection { pub async fn apply_op( Inject(upload): Inject, Inject(quota): Inject, + Inject(moderation): Inject, Auth(credential): Auth, Path(path): Path, Json(request): Json, ) -> Result, OpRejection> { let caller = credential.user.clone(); - let owner = OwnerId::new(caller.as_str()); let album = AlbumId::new(&path.album_id); // The envelope must agree with the path it arrived on. A contradiction is a client bug the @@ -228,17 +285,60 @@ pub async fn apply_op( )); } - // Invariant 6, the half only the authority can answer. - let AlbumWriteAccess::Writable { protocol_pin, .. } = upload + // Invariant 7's account half. `created_by_user` names the account whose device signed this + // record — a per-record fact, not the asset's creator — so on this surface it is the caller, + // and a mismatch is a caller attributing a write to somebody else. See the module docs. + // + // A `400` and the envelope-mismatch code, like every other field that contradicts what the + // request itself establishes: the album id in the path, the metadata hash over the bytes in + // hand. The `403`s here are about *capability*; this is a contradiction. + if request.manifest_envelope.created_by_user != caller.as_str() { + tracing::info!( + %caller, %album, + "a lifecycle write was refused: created_by_user is not the caller" + ); + return Err(OpRejection::invalid( + error_codes::UPLOAD_ENVELOPE_MISMATCH, + "created_by_user is not the authenticated caller", + )); + } + + // Account standing (`S-C8`), on the seam `POST /v1/upload` uses and for the same reason: a + // suspension removes the ability to write, and a lifecycle op is a write — the only one that + // never moves blob bytes, which is exactly why it was easy to miss. Checked before the + // authority, before the quota and before anything is stored. + let standing = moderation + .store() + .standing(&crate::store::UserId::new(caller.as_str())) + .await + .map_err(|error| { + tracing::error!(%error, %caller, "the moderation store could not answer"); + OpRejection::unavailable() + })?; + if !standing.may_write() { + tracing::info!(%caller, "a lifecycle write was refused: the account is suspended"); + return Err(OpRejection::AccountSuspended { + code: error_codes::MODERATION_ACCOUNT_SUSPENDED, + }); + } + + // Invariant 6, the half only the authority can answer — and the namespace the op is filed + // under, which is the album owner's whoever the caller is (`S-C51`): the owner's feed is the + // one every member's devices read. + let AlbumWriteAccess::Writable { + owner_id: owner, + protocol_pin, + .. + } = upload .authority() - .album_write_access(&owner, &album) + .album_write_access(&caller, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an album"); OpRejection::unavailable() })? else { - tracing::info!(%owner, %album, "a lifecycle write was refused: no write capability"); + tracing::info!(%caller, %album, "a lifecycle write was refused: no write capability"); return Err(OpRejection::album_access_denied()); }; @@ -395,7 +495,7 @@ pub async fn apply_op( &format!("amk_version regresses against the album's recorded epoch {stored}"), )), OpOutcome::NotFound => { - tracing::info!(%owner, asset = %asset_id, "a lifecycle write was refused: not this caller's asset"); + tracing::info!(%owner, asset = %asset_id, "a lifecycle write was refused: not this album's asset"); Err(OpRejection::album_access_denied()) } } @@ -496,7 +596,7 @@ impl OpRejection { } } - /// The album is not writable, or the asset is not this caller's. + /// The album is not writable, or the asset is not this album's. fn album_access_denied() -> Self { Self::AlbumAccessDenied { code: error_codes::UPLOAD_ALBUM_ACCESS_DENIED, diff --git a/capsule-server/src/routes/roster.rs b/capsule-server/src/routes/roster.rs new file mode 100644 index 00000000..291e55af --- /dev/null +++ b/capsule-server/src/routes/roster.rs @@ -0,0 +1,457 @@ +//! `PUT /v1/albums/{album_id}/roster` — publishing an album's membership roster (slice `S-C51`). +//! +//! The one endpoint that tells the key-free server who may read and write a shared album. +//! [`crate::membership`] owns the port and the reasoning; this is the wire shape. +//! +//! ```text +//! PUT /v1/albums/{album_id}/roster { "roster_cbor": "" } +//! +//! 200 { "album_id": …, "roster_version": 3, "amk_epoch": 2, "member_count": 4, "replayed": false } +//! 400 error.album.roster_malformed +//! 400 error.album.roster_version_leap + current_version, max_version +//! 403 error.album.roster_attester +//! 404 error.album.roster_not_found +//! 409 error.album.roster_stale + current_version +//! 500 error.album.unavailable +//! ``` +//! +//! **Only the owner account publishes.** The trust anchor is the album owner's published device +//! directory — the same anchor the upgrade ceremony verifies its intent against — so the caller +//! must be the album's owner, the roster's `attested_by_user` must be the caller, and the +//! attesting device must be a live device in that directory. A member who tries, even one the +//! MLS group calls an admin, gets the album ceremonies' `404`: not yours is not found. +//! +//! **JSON with base64 CBOR, not `application/cbor`.** The signed bytes are canonical CBOR and +//! must reach the server verbatim, which a CBOR body would carry more directly — but spargen +//! cannot lower `application/cbor`, so a CBOR operation is one the generated SDK cannot call +//! and the client library would need a hand-written request for. Base64 inside a JSON field +//! keeps the bytes verbatim *and* the operation generated. +//! +//! **Removal is a new roster that omits the member.** There is no delete; the epoch bump that +//! accompanies an MLS `Remove` rides `amk_epoch`, and the store records the version and epoch at +//! which the member vanished — the stored fact a former member's blob-route `403` is rendered +//! from once that route consults membership. +//! +//! **Removal reclaims nothing, and this call is where an operator would expect it to.** What a +//! removed writer member uploaded stays in the owner's album and stays charged to the removed +//! member's quota; only the refcount collector releases an attribution, and only once the owner +//! deletes the asset. Whether that is right is a product question no design document answers +//! (issue #473); what this endpoint does is exactly what it says — it records who may read and +//! write, and nothing else. +//! +//! **Idempotent under `(album_id, roster_version)`.** The same bytes again are a `200` with +//! `replayed: true`; the same version with different bytes is the `409`. +//! +//! **The version is bounded above, too.** Strict monotonicity alone lets one publish latch the +//! counter at a value nothing can ever exceed, freezing the album's membership for good. A +//! version more than [`MAX_ROSTER_VERSION_STEP`](crate::membership::MAX_ROSTER_VERSION_STEP) +//! above the held one is refused with `error.album.roster_version_leap` and the held version, so +//! the owner re-signs at `current_version + 1` and says exactly the same thing. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_core::crypto::membership::SignedAlbumRoster; +use capsule_i18n::error_codes; +use kynos::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::album::AlbumContext; +use crate::auth::AccessToken; +use crate::directory::DeviceDirectoryContext; +use crate::federation::{self, FederationContext}; +use crate::membership::{MemberRole, MembershipContext, RosterOutcome, RosterRecord}; +use crate::routes::albums::AlbumsTag; +use crate::routes::upgrade::AlbumPath; +use crate::store::{AlbumId, UserId}; + +/// The largest signed roster this surface accepts, decoded. +/// +/// A roster member is a UUID and a role — well under a hundred bytes each in canonical CBOR — +/// so 512 KiB is several thousand members and still far below the transport backstop +/// (`S-C33`, 32 MiB), which is not a bound on a membership document. +pub const MAX_ROSTER_BYTES: usize = 512 * 1024; + +/// The publish request. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct RosterRequest { + /// The signed roster, as standard base64 of its canonical CBOR encoding. + pub roster_cbor: String, +} + +/// What the server now holds for the album. +#[derive(Schema, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct RosterResponse { + /// The album, echoed. + pub album_id: String, + /// The roster version the server holds after this call. + pub roster_version: u64, + /// The AMK epoch that roster reflects. + pub amk_epoch: u64, + /// How many members the held roster names, the owner excluded. + pub member_count: u64, + /// Whether this call was a replay of the roster already held. Advisory: both answers mean + /// "the server holds this roster". + pub replayed: bool, +} + +/// Why a roster was not published. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum RosterRejection { + /// The body is not a signed roster this server can accept for this album. + #[error("{detail}")] + #[problem(status = 400, title = "Malformed roster")] + Malformed { + /// What was wrong, in English. + detail: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The roster is not attested by a live device in the album owner's published directory. + #[error("the roster's attester could not be verified")] + #[problem(status = 403, title = "Attester not authorized")] + AttesterNotAuthorized { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// No such album, or not this caller's. One answer for both. + #[error("no such album")] + #[problem(status = 404, title = "Not found")] + NotFound { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The roster's version is so far above the held one that accepting it would put the + /// counter out of reach of every later publish. + /// + /// A `400` rather than the `409` a stale roster gets, and the distinction is the one the + /// two statuses carry everywhere else on this surface: a `409` is a client that is + /// *behind* the server and must re-read, while this is a document the server would refuse + /// whatever it held — a version that does not follow from the one before it is a + /// structural fault in the document, in the same family as a roster naming the wrong + /// album. The held version rides it anyway, because the client's repair is to re-sign at + /// `current_version + 1`. + #[error( + "roster version {declared} is past {max_version}, the highest this album will accept \ + while it holds version {current_version}" + )] + #[problem(status = 400, title = "Roster version leap")] + VersionLeap { + /// The version the document declared. + /// + /// **Not** an extension member, deliberately: it is the only number on this response + /// the caller controls, and it is unbounded — a document may declare `u64::MAX`. Every + /// integer spargen lowers from this contract becomes an `i64` (it emits no `u64` at + /// all, `format: uint64` or not), so echoing an out-of-range one as a JSON number makes + /// the generated client fail to *decode* the refusal, which discards the `code` and the + /// recovery hint and leaves a caller unable to tell a refusal from a network fault. It + /// rides the English `detail` instead, where a human can read it and no decoder has to + /// parse it — and the caller already knows what it declared. + declared: u64, + /// The version the server holds; `0` when it holds no roster. Never above + /// [`MAX_ROSTER_VERSION`](crate::membership::MAX_ROSTER_VERSION), so it always decodes. + #[problem(extension)] + current_version: u64, + /// The highest version this album would have accepted. Bounded by the same ceiling. + #[problem(extension)] + max_version: u64, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The server already holds a roster this one does not supersede. + #[error("the server holds roster version {current_version}, which this does not supersede")] + #[problem(status = 409, title = "Roster stale")] + Stale { + /// The version the server holds. + #[problem(extension)] + current_version: u64, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer. + #[error("the roster could not be recorded")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl RosterRejection { + /// The request was not a well-formed roster for this album. + fn malformed(detail: impl Into) -> Self { + Self::Malformed { + detail: detail.into(), + code: error_codes::ALBUM_ROSTER_MALFORMED, + } + } + + /// The attester did not verify. + fn attester() -> Self { + Self::AttesterNotAuthorized { + code: error_codes::ALBUM_ROSTER_ATTESTER, + } + } + + /// No such album, or not this caller's. + fn not_found() -> Self { + Self::NotFound { + code: error_codes::ALBUM_ROSTER_NOT_FOUND, + } + } + + /// A collaborator could not answer. + fn unavailable() -> Self { + Self::Unavailable { + code: error_codes::ALBUM_UNAVAILABLE, + } + } +} + +/// Decode and shape-check a request into the signed roster and its verbatim bytes. +/// +/// Everything here is decidable from the request alone: the encoding, the size, that the +/// document is for the album in the path, and that the member list is one the store can take +/// as a set. The account-level checks — owner, attester — need stores and follow. +fn decode( + request: &RosterRequest, + album: &AlbumId, +) -> Result<(SignedAlbumRoster, Vec), RosterRejection> { + // The cap is on the decoded document, and it is applied to the encoded string first so an + // oversized body is refused before it is decoded into a second buffer: base64 inflates by a + // third, so anything longer than this cannot decode to under the cap. + if request.roster_cbor.len() > MAX_ROSTER_BYTES / 3 * 4 + 4 { + return Err(RosterRejection::malformed(format!( + "a roster may be at most {MAX_ROSTER_BYTES} bytes" + ))); + } + let bytes = BASE64 + .decode(&request.roster_cbor) + .map_err(|_| RosterRejection::malformed("roster_cbor is not standard base64"))?; + if bytes.len() > MAX_ROSTER_BYTES { + return Err(RosterRejection::malformed(format!( + "a roster may be at most {MAX_ROSTER_BYTES} bytes" + ))); + } + let Ok(signed) = capsule_core::cbor::from_slice::(&bytes) else { + return Err(RosterRejection::malformed( + "roster_cbor is not a signed album roster", + )); + }; + // The bytes are stored verbatim and a replay is decided on them, so they must be the one + // canonical encoding: a non-canonical or trailing-garbage document would verify (the + // signature covers the re-encoded roster) and then make its own re-encoding a `409`. + if capsule_core::cbor::canonicalize(&bytes).ok().as_deref() != Some(bytes.as_slice()) { + return Err(RosterRejection::malformed( + "roster_cbor is not canonical CBOR", + )); + } + if signed.roster.album_id.to_string() != album.as_str() { + return Err(RosterRejection::malformed( + "the roster's album_id is not the album this request was addressed to", + )); + } + if signed + .roster + .members + .iter() + .any(|member| member.user_id == signed.roster.attested_by_user) + { + return Err(RosterRejection::malformed( + "the owner is not listed on their own roster", + )); + } + let mut seen = std::collections::BTreeSet::new(); + if !signed + .roster + .members + .iter() + .all(|member| seen.insert(member.user_id)) + { + return Err(RosterRejection::malformed( + "a roster lists each account at most once", + )); + } + Ok((signed, bytes)) +} + +/// Publish the caller's roster for one of their albums. +#[kynos::put( + "/v1/albums/{album_id}/roster", + operation_id = "publish_album_roster", + tag = AlbumsTag +)] +pub async fn publish_album_roster( + Inject(albums): Inject, + Inject(directories): Inject, + Inject(membership): Inject, + Inject(federation): Inject, + Auth(credential): Auth, + Path(path): Path, + Json(request): Json, +) -> Result, RosterRejection> { + let user = UserId::new(credential.user.as_str()); + let album = AlbumId::new(&path.album_id); + + let (signed, bytes) = decode(&request, &album)?; + + // The album must be the caller's. Before the attester check, and answered as not-found, so + // a member holding a valid roster for somebody else's album learns nothing about whether + // the owner's directory would have accepted it. + let record = albums.albums().read(&album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a roster publish"); + RosterRejection::unavailable() + })?; + match record { + Some(record) if record.owner_id.as_str() == user.as_str() => {} + _ => { + tracing::info!(%user, %album, "a roster was refused: no such album, or not the caller's"); + return Err(RosterRejection::not_found()); + } + } + if signed.roster.attested_by_user.to_string() != user.as_str() { + tracing::info!(%user, %album, "a roster was refused: attested_by_user is not the caller"); + return Err(RosterRejection::attester()); + } + + // The attester, against the owner's published directory (`S-C42`'s anchor), exactly as the + // upgrade ceremony verifies its proposer. Without this any holder of the owner's token could + // rewrite who may read the album by PUTting a struct. + let published = directories + .store() + .fetch(&user) + .await + .map_err(|error| { + tracing::error!(%error, %user, "the directory store could not answer a roster publish"); + RosterRejection::unavailable() + })? + .ok_or_else(|| { + tracing::info!(%user, "a roster was refused: no published device directory"); + RosterRejection::attester() + })?; + let Ok(directory) = capsule_core::cbor::from_slice::( + &published.document, + ) else { + // A document this server itself accepted and can no longer read. Its own inconsistency, + // answered as an outage rather than as the caller's fault. + tracing::error!(%user, "a stored device directory does not decode"); + return Err(RosterRejection::unavailable()); + }; + if let Err(error) = signed.verify(&directory) { + tracing::info!(%user, %album, %error, "a roster's attester did not verify"); + return Err(RosterRejection::attester()); + } + + let members: Vec<(UserId, MemberRole)> = signed + .roster + .members + .iter() + .map(|member| (UserId::new(member.user_id.to_string()), member.role)) + .collect(); + let outcome = membership + .members() + .apply_roster( + RosterRecord { + album_id: album.clone(), + roster_version: signed.roster.roster_version, + amk_epoch: u64::from(signed.roster.amk_epoch.0), + attested_by_device: signed.roster.attested_by_device, + received_at: membership.clock().now(), + document: bytes, + }, + members, + ) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not apply a roster"); + RosterRejection::unavailable() + })?; + + let member_count = u64::try_from(signed.roster.members.len()).unwrap_or(u64::MAX); + match outcome { + RosterOutcome::Applied(record) => { + // A roster the owner just narrowed is a set of federated grants the owner just + // withdrew (`S-E5`). Published to `/.well-known/capsule/revoked-jti` here so a peer + // learns from the list rather than from a refusal it cannot explain — and logged + // rather than surfaced, because the roster is the fact this operation answers for + // and a capability whose member has gone is refused at its next presentation + // regardless, membership being re-checked there. + let listed: Vec = signed + .roster + .members + .iter() + .map(|member| UserId::new(member.user_id.to_string())) + .collect(); + if let Err(error) = federation::on_roster_applied(&federation, &album, &listed).await { + tracing::error!( + %error, + %album, + "a roster was applied but its federated grants could not be revoked" + ); + } + Ok(Json(describe(&record, member_count, false))) + } + RosterOutcome::Replayed(record) => Ok(Json(describe(&record, member_count, true))), + RosterOutcome::Stale { current_version } => Err(RosterRejection::Stale { + current_version, + code: error_codes::ALBUM_ROSTER_STALE, + }), + RosterOutcome::VersionLeap { + current_version, + max_version, + } => { + tracing::info!( + %album, + declared = signed.roster.roster_version, + current_version, + max_version, + "a roster was refused: its version is past the window above the held one" + ); + Err(RosterRejection::VersionLeap { + declared: signed.roster.roster_version, + current_version, + max_version, + code: error_codes::ALBUM_ROSTER_VERSION_LEAP, + }) + } + RosterOutcome::EpochRegressed { + current_version, + stored, + } => { + tracing::info!( + %album, + stored_epoch = stored, + submitted_epoch = signed.roster.amk_epoch.0, + "a roster was refused: its AMK epoch regressed" + ); + // The held version, so the client's action is the same re-sync a stale version asks + // for: the roster it holds is not the one the server does. + Err(RosterRejection::Stale { + current_version, + code: error_codes::ALBUM_ROSTER_STALE, + }) + } + } +} + +/// The response an accepted roster renders. +fn describe(record: &RosterRecord, member_count: u64, replayed: bool) -> RosterResponse { + RosterResponse { + album_id: record.album_id.as_str().to_owned(), + roster_version: record.roster_version, + amk_epoch: record.amk_epoch, + member_count, + replayed, + } +} diff --git a/capsule-server/src/routes/share.rs b/capsule-server/src/routes/share.rs index f164d5ec..c86928a9 100644 --- a/capsule-server/src/routes/share.rs +++ b/capsule-server/src/routes/share.rs @@ -46,7 +46,7 @@ use serde::{Deserialize, Serialize}; use crate::auth::AccessToken; use crate::blob::ContentAddress; -use crate::counter::{CounterContext, CounterKey, budgets}; +use crate::counter::{CounterContext, CounterKey, Verdict, budgets, unix_seconds}; use crate::serve::BlobSource; use crate::share::{ShareContext, ShareRecord, is_opaque_id}; use crate::store::UserId; @@ -181,12 +181,22 @@ pub enum ShareRejection { /// because enumeration does not care which of the three it probes with. Deliberately *not* /// folded into the indistinguishable `404`: a `404` that was really a throttle would teach a /// legitimate viewer that a live link is dead. + /// + /// Two causes, one status, told apart by `code`: `error.share.rate_limited` is this link's + /// own budget spent, `error.share.at_capacity` is the limiter's per-link partition full. The + /// second used to render the `500` below, which told a client to report an outage and an + /// operator to go looking for one, when the limiter was working exactly as designed and + /// would clear itself inside the window. #[error("too many requests")] #[problem(status = 429, title = "Too many requests")] RateLimited { /// The stable catalog code. #[problem(extension)] code: &'static str, + /// When the caller may retry, as Unix seconds. An **upper** bound: one limiter window, + /// by which time a live window has lapsed and freed room. + #[problem(extension)] + retry_after: u64, }, /// The store could not answer. @@ -420,16 +430,23 @@ async fn throttle(counters: &CounterContext, opaque_id: &str) -> Result<(), Shar .hit(&key, budgets::SHARE_LINK) .await .map_err(|error| { - // Fail closed, like every other limiter here. - tracing::error!(%error, "the share limiter could not be reached"); - ShareRejection::unavailable() + // Fail closed, like every other limiter here — but say which failure it was. A full + // partition is the limiter working as designed and clears inside the window; only a + // store that could not answer is a `500`. + if let Some(retry_after) = counters.capacity_refusal(&error, budgets::SHARE_LINK) { + tracing::warn!(%error, "the share limiter is at capacity"); + ShareRejection::at_capacity(retry_after) + } else { + tracing::error!(%error, "the share limiter could not be reached"); + ShareRejection::unavailable() + } })?; - if verdict.admits() { - Ok(()) - } else { - Err(ShareRejection::RateLimited { + match verdict { + Verdict::Admitted { .. } => Ok(()), + Verdict::Limited { retry_after } => Err(ShareRejection::RateLimited { code: error_codes::SHARE_RATE_LIMITED, - }) + retry_after: unix_seconds(retry_after), + }), } } @@ -471,4 +488,17 @@ impl ShareRejection { code: error_codes::SHARE_UNAVAILABLE, } } + + /// The limiter is holding as many distinct links as it will hold. + /// + /// A `429` and not the `500` this used to be: the limiter is working as designed and clears + /// itself inside the window, so the caller is told to wait rather than told the server is + /// broken. Deliberately still distinct from the indistinguishable `404` — a `404` that was + /// really a capacity refusal would teach a legitimate viewer that a live link is dead. + fn at_capacity(retry_after: jiff::Timestamp) -> Self { + Self::RateLimited { + code: error_codes::SHARE_AT_CAPACITY, + retry_after: unix_seconds(retry_after), + } + } } diff --git a/capsule-server/src/routes/sync.rs b/capsule-server/src/routes/sync.rs index 56b3485f..9b9f378f 100644 --- a/capsule-server/src/routes/sync.rs +++ b/capsule-server/src/routes/sync.rs @@ -14,6 +14,16 @@ //! | `INTERNAL` | `500`, and now *coded* — `error.sync.unavailable`, a key this slice added because the retired feed had none and a client could not tell a broken server from a broken cursor | //! | page size out of range | **not a rejection.** Clamped; see [`crate::sync::clamp_page_size`] | //! +//! # A peer reads the same page (`S-E5`) +//! +//! `Authorization: Bearer` also carries a federation capability. The album arm is then bound to +//! the capability's album — a peer has no "own feed", so `album_id` absent or different is +//! `403 error.federation.audience_mismatch` — and the member the capability was minted for must +//! still be on the roster at the epoch it was granted, which is the same +//! `403 error.sync.album_access_denied` an account's arm gives. Before any of that the +//! capability is *admitted* ([`federation::admit`]): revoked `403`, blocked peer `403`, over +//! budget `429`. The cursor is bound to `(peer, album)`, its own scope. +//! //! # What a tombstone discloses //! //! A `deleted` entry carries no manifest, no metadata reference and no blob list. The row still @@ -38,12 +48,16 @@ use kynos::prelude::*; use kynos::security::auth::Auth; use serde::{Deserialize, Serialize}; -use crate::auth::AccessToken; use crate::blob::ContentAddress; +use crate::counter::CounterContext; +use crate::federation::{ + self, FederationContext, Principal, ReadBearer, Refusal, VerifiedCapability, +}; use crate::index::{ChangeKind, FeedEntry}; +use crate::membership::Membership; use crate::routes::upload::WireBlobRole; -use crate::store::OwnerId; -use crate::sync::{CursorError, MAX_MANIFEST_BYTES, SyncContext, clamp_page_size}; +use crate::store::{AlbumId, OwnerId, UserId}; +use crate::sync::{CursorError, CursorScope, MAX_MANIFEST_BYTES, SyncContext, clamp_page_size}; /// The operation that tells a client what changed. #[derive(Tag)] @@ -71,6 +85,13 @@ pub struct SyncQuery { /// right to — a schema whose bounds depend on the server's pointer size is a schema no /// client can rely on. pub page_size: Option, + /// One album's page rather than the caller's own feed (`S-C51`). + /// + /// For the album's owner or any account on its current roster. Positions are the owner's + /// sequence numbers filtered to the album, and the cursor is bound to `(caller, album)`, so + /// it cannot be presented on the caller's own feed or on another album. Absent: the caller's + /// own library, as before. + pub album_id: Option, } /// What an entry is, relative to the client that asked for it. @@ -173,6 +194,53 @@ pub enum SyncRejection { code: &'static str, }, + /// The album is not the caller's and the caller is not on its roster (`S-C51`). + /// + /// One answer for unprovisioned, never-a-member and removed alike, as the write routes give. + #[error("no access to that album")] + #[problem(status = 403, title = "Album access denied")] + AlbumAccessDenied { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The capability is revoked (`S-E5`). + #[error("this capability has been revoked")] + #[problem(status = 403, title = "Capability revoked")] + CapabilityRevoked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The capability is for another album, or no album was named (`S-E5`). + #[error("this capability is for a different album")] + #[problem(status = 403, title = "Audience mismatch")] + AudienceMismatch { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer is on this server's blocklist (`S-C49`). + #[error("this server is blocked")] + #[problem(status = 403, title = "Server blocked")] + PeerBlocked { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The peer's events-per-hour budget is spent (invariant 21). + #[error("this peer has reached its request budget")] + #[problem(status = 429, title = "Rate budget exceeded")] + RateLimited { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// A collaborator could not answer. #[error("the sync feed could not be read")] #[problem(status = 500, title = "Internal server error")] @@ -184,6 +252,13 @@ pub enum SyncRejection { } impl SyncRejection { + /// The album page is not the caller's to read. + fn album_access_denied() -> Self { + Self::AlbumAccessDenied { + code: error_codes::SYNC_ALBUM_ACCESS_DENIED, + } + } + /// The one cursor rejection. fn cursor_invalid() -> Self { Self::CursorInvalid { @@ -197,6 +272,41 @@ impl SyncRejection { code: error_codes::SYNC_UNAVAILABLE, } } + + /// The capability names another album, or none was asked for. + fn audience_mismatch() -> Self { + Self::AudienceMismatch { + code: error_codes::FEDERATION_AUDIENCE_MISMATCH, + } + } +} + +impl From for SyncRejection { + fn from(refusal: Refusal) -> Self { + match refusal { + Refusal::Revoked => Self::CapabilityRevoked { + code: error_codes::FEDERATION_CAPABILITY_REVOKED, + }, + Refusal::PeerBlocked => Self::PeerBlocked { + code: error_codes::MODERATION_SERVER_BLOCKED, + }, + Refusal::RateLimited { .. } => Self::RateLimited { + code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED, + }, + Refusal::Unavailable => Self::Unavailable { + code: error_codes::FEDERATION_UNAVAILABLE, + }, + } + } +} + +/// Who is reading, once the credential has been decided. +/// +/// The account's id or the peer's origin, each in its own type: the cursor scope and the log +/// field both need to know which, and a string would let the two be confused. +enum Reader { + Account(OwnerId), + Peer(Box), } // =========================================================================================== @@ -211,17 +321,73 @@ impl SyncRejection { #[kynos::get("/v1/sync", operation_id = "sync_feed", tag = SyncTag)] pub async fn sync_feed( Inject(sync): Inject, - Auth(credential): Auth, + Inject(federation): Inject, + Inject(counters): Inject, + Auth(principal): Auth, Query(query): Query, ) -> Result, SyncRejection> { - // The feed is owner-scoped and the owner is the caller. There is no on-behalf read here for - // the same reason there is no on-behalf upload: the port that would verify the relationship - // does not exist, and inventing one at the read side would be the more dangerous half. - let owner = OwnerId::new(credential.user.as_str()); + let requested = query.album_id.as_deref().map(AlbumId::new); + + // Who is asking, and which page they may have. An account reads its own feed, or — with + // `album_id` — one album's page as its owner or a member of its current roster (`S-C51`). + // A peer reads exactly the album its capability names, as the member it was minted for + // (`S-E5`). Each relationship is decided first, and one refusal covers every way it fails. + let (reader, album) = match principal { + Principal::Session(credential) => { + let album = match requested { + Some(album) => { + let filed_by = album_read_access(&sync, &credential.user, &album).await?; + Some((album, filed_by)) + } + None => None, + }; + ( + Reader::Account(OwnerId::new(credential.user.as_str())), + album, + ) + } + Principal::Peer(capability) => { + federation::admit( + &federation, + &counters, + &capability, + federation::Presentation::Read, + ) + .await?; + let album = match requested { + Some(album) if album == capability.record.album_id => album, + _ => { + tracing::info!( + peer = %capability.record.peer_id, + jti = %capability.record.jti, + "a peer asked for a page its capability does not cover" + ); + return Err(SyncRejection::audience_mismatch()); + } + }; + let filed_by = peer_album_access(&sync, &capability, &album).await?; + (Reader::Peer(capability), Some((album, filed_by))) + } + }; + let scope = match (&reader, &album) { + (Reader::Account(owner), Some((album, _))) => CursorScope::album(owner, album), + (Reader::Account(owner), None) => CursorScope::feed(owner), + (Reader::Peer(capability), Some((album, _))) => { + CursorScope::peer(&capability.record.peer_id, album) + } + // A peer always has an album by the time it is here; the arm above returned otherwise. + (Reader::Peer(_), None) => return Err(SyncRejection::audience_mismatch()), + }; + // The account whose rows are paged: the caller's own, or the album owner's. + let owner = match (&reader, &album) { + (_, Some((_, filed_by))) => filed_by.clone(), + (Reader::Account(owner), None) => owner.clone(), + (Reader::Peer(_), None) => return Err(SyncRejection::audience_mismatch()), + }; let after = sync .cursors() - .decode(&owner, query.cursor.as_deref()) + .decode(&scope, query.cursor.as_deref()) .map_err(|error| { // Logged at `info`, not `warn`: a cursor that stopped authenticating is the normal // consequence of a key rotation, and an operator who has just rotated should not be @@ -239,16 +405,24 @@ pub async fn sync_feed( .page_size .map(|size| usize::try_from(size).unwrap_or(usize::MAX)), ); - let rows = sync - .index() - .feed_page(&owner, after, limit) - .await - .map_err(|error| { - tracing::error!(%error, %owner, "the asset index could not serve a feed page"); - SyncRejection::unavailable() - })?; + let rows = match &album { + Some((album, filed_by)) => { + sync.index() + .album_feed_page(filed_by, album, after, limit) + .await + } + None => sync.index().feed_page(&owner, after, limit).await, + } + .map_err(|error| { + tracing::error!(%error, %owner, "the asset index could not serve a feed page"); + SyncRejection::unavailable() + })?; - let head = sync.index().head_seq(&owner).await.map_err(|error| { + let head = match &album { + Some((album, filed_by)) => sync.index().album_head_seq(filed_by, album).await, + None => sync.index().head_seq(&owner).await, + } + .map_err(|error| { tracing::error!(%error, %owner, "the asset index could not report its head"); SyncRejection::unavailable() })?; @@ -261,6 +435,10 @@ pub async fn sync_feed( tracing::debug!( %owner, + peer = match &reader { + Reader::Peer(capability) => Some(capability.record.peer_id.as_str()), + Reader::Account(_) => None, + }, after, limit, served = entries.len(), @@ -270,13 +448,101 @@ pub async fn sync_feed( Ok(Json(SyncPageResponse { entries, - next_cursor: sync.cursors().encode(&owner, position), + next_cursor: sync.cursors().encode(&scope, position), // Strictly greater: `position == head` is a caught-up client, and telling it otherwise // would make every idle client poll one extra time forever. has_more: head > position, })) } +/// Whether the member `capability` was minted for is still on `album`'s roster at the epoch the +/// grant was made at — answering the album's owner, whose rows the page is (`S-E5`). +/// +/// The epoch is the server-side half of the grant: a member removed and re-admitted at a later +/// epoch gets a fresh membership, and a capability minted for the earlier one is refused without +/// anyone having revoked it. One `403` for every failure, as the account arm gives — the album +/// id is the capability's own, so the answer discloses nothing a peer does not hold already. +async fn peer_album_access( + sync: &SyncContext, + capability: &VerifiedCapability, + album: &AlbumId, +) -> Result { + let record = sync.albums().read(album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a peer's sync page"); + SyncRejection::unavailable() + })?; + let Some(record) = record else { + tracing::info!(peer = %capability.record.peer_id, %album, "a peer's page was refused: no such album"); + return Err(SyncRejection::album_access_denied()); + }; + match sync + .members() + .membership(album, &capability.record.member) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer a peer's sync page"); + SyncRejection::unavailable() + })? { + Membership::Member { granted_epoch, .. } + if granted_epoch == capability.record.granted_epoch => + { + Ok(record.owner_id) + } + membership => { + tracing::info!( + peer = %capability.record.peer_id, + member = %capability.record.member, + %album, + ?membership, + granted_epoch = capability.record.granted_epoch, + "a peer's page was refused: its member is not on the roster at the granted epoch" + ); + Err(SyncRejection::album_access_denied()) + } + } +} + +/// Whether `caller` may read `album`'s page — its owner, or an account on its current roster — +/// answering the album's owner, whose rows the page is. +/// +/// One `403` for every refusal — unprovisioned, never a member, removed — matching the write +/// routes' uniform `album_access_denied`: the album id is client-derived and unguessable, and a +/// distinct answer per reason would say whether it is taken and whether the caller was ever on +/// it. (An unprovisioned id costs one store read and the other two cost two; with a UUIDv7 id +/// space that timing difference buys a guesser nothing, as it does not on the write path.) A +/// store that cannot answer is an outage, never a refusal. +async fn album_read_access( + sync: &SyncContext, + caller: &UserId, + album: &AlbumId, +) -> Result { + let record = sync.albums().read(album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a sync page"); + SyncRejection::unavailable() + })?; + let Some(record) = record else { + tracing::info!(%caller, %album, "an album page was refused: no such album"); + return Err(SyncRejection::album_access_denied()); + }; + if record.owner_id.as_str() == caller.as_str() { + return Ok(record.owner_id); + } + match sync + .members() + .membership(album, caller) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer a sync page"); + SyncRejection::unavailable() + })? { + Membership::Member { .. } => Ok(record.owner_id), + Membership::Revoked(_) | Membership::Never => { + tracing::info!(%caller, %album, "an album page was refused: not a member"); + Err(SyncRejection::album_access_denied()) + } + } +} + /// Render one index entry onto the wire, reading its manifest bytes. async fn render(sync: &SyncContext, entry: FeedEntry) -> SyncEntry { let deleted = entry.change == ChangeKind::Deleted; diff --git a/capsule-server/src/routes/upload.rs b/capsule-server/src/routes/upload.rs index cd13c4fe..c51436ca 100644 --- a/capsule-server/src/routes/upload.rs +++ b/capsule-server/src/routes/upload.rs @@ -17,10 +17,10 @@ //! | create `200` (active session for the tuple) | **kept and now documented.** Salvo declared it `undocumented()`; it is [`CreateReply::Existing`], carrying `X-Capsule-Offset` so a resuming client needs no second round trip | //! | create `400` "Bad request" (untyped) | **deleted.** Never constructed with a code; the 400 a malformed body actually produces is the `Json` extractor's, which Kynos declares | //! | create `401` | kept, and now the framework's — `Auth` declares it and fills the `WWW-Authenticate` challenge | -//! | create `403` | kept — album access, device authorization, on-behalf refusal | +//! | create `403` | kept — album access, device authorization, a declared owner that is not the album's | //! | create `409 duplicate_blob` | **restored, with `S-C22`'s structured `existing_asset`.** It was deleted while this crate had no asset index, because it must name the existing asset and answering from blob presence alone would tell one account what another holds. `S-C37` answers it honestly and owner-scoped | //! | create `413` | kept — the declared size past the deployment ceiling | -//! | create `426` | kept — the protocol handshake, now with the accepted window as problem extensions | +//! | create `426` | kept — the manifest envelope's `protocol_version` pin, refused by the envelope gate. The *header* handshake is no longer this surface's: [`crate::negotiation::ProtocolGate`] answers it before the handler runs, and the accepted window rides `X-Capsule-Protocol-Min`/`-Max` on every response | //! | create `500` | kept — a collaborator that could not answer, with `error.upload.unavailable` | //! | chunk `204` | kept — with the authoritative `X-Capsule-Offset` | //! | chunk `400` | kept, and now *coded*: missing offset, missing checksum, checksum mismatch, empty chunk, misalignment, size exceeded, and the two finalization failures each carry their own `error.upload.*` | @@ -32,7 +32,7 @@ //! | chunk `409 finalize_in_progress` | **deleted.** Losing the finalize claim is a normal race and the chunk that triggered it was still accepted, so it answers `204`. Telling a client its accepted chunk failed was the Salvo behaviour and it was wrong | //! | chunk `500` | kept — storage inconsistency (the stage disagreeing with the counter) and collaborator failure | //! | head `200` | kept — [`HeadReply::Progress`], carrying offset, declared length and state on headers, with `Cache-Control: no-store`. A `Reply` rather than a `NoContent`, because `200 with headers` and `204` are different answers | -//! | head `400` / `401` / `403` / `404` / `426` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look | +//! | head `400` / `401` / `403` / `404` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look. The `400` is the handshake's, declared by the read gate rather than by this surface; the `426` is gone from `HEAD`, because a read is admitted at any protocol date (issue #404) | //! | head `409` | **deleted as unreachable.** `HEAD` reports a state, it does not require one. It would have been declared for free by sharing a rejection type with `DELETE`, which is why they are two types | //! | delete `204` | kept | //! | delete `409` | kept — finalization is not interruptible, and a terminal session has nothing left to cancel | @@ -43,15 +43,19 @@ //! Every status above is produced by a test in `tests/upload.rs`, because //! `assert_declared_responses_covered` fails on any the document promises and none produced. //! -//! # Two places the protocol asks for a header this surface cannot send +//! # One place the protocol asks for a header this surface cannot send //! //! A Kynos `ApiError` renders an RFC 9457 problem and has **no seam for a response header**, so -//! the two headers the protocol's census puts on *rejections* — `X-Capsule-Offset` on a `409` -//! and `X-Capsule-Protocol-Min`/`-Max` on a `426` — ride as problem **extension members** -//! instead. The data a client needs to recover is there and is machine-readable; the spelling -//! is not the one the census names. The alternative was to render those two rejections as -//! plain-JSON `Reply` variants, which would have cost them their `error.*` code — a worse -//! trade, since the code is what a client switches on. Recorded rather than hidden. +//! the `X-Capsule-Offset` the protocol's census puts on a `409` rides as a problem **extension +//! member** instead. The data a client needs to recover is there and is machine-readable; the +//! spelling is not the one the census names. The alternative was to render the rejection as a +//! plain-JSON `Reply` variant, which would have cost it its `error.*` code — a worse trade, +//! since the code is what a client switches on. Recorded rather than hidden. +//! +//! The `X-Capsule-Protocol-Min`/`-Max` pair used to be the second such place. It is not any +//! more: issue #404 moved the handshake onto [`crate::negotiation`], whose advertising +//! interceptor sits outside every rejection and stamps the window on all of them. The seam an +//! `ApiError` lacks, an `Interceptor` has. //! //! # `409 duplicate_blob` refuses, and nothing yet adopts //! @@ -183,10 +187,12 @@ pub struct CreateUploadRequest { /// for the album-upgrade ceremony; **required by this server**, which has no way to check /// invariant 6 without one and refuses rather than skipping it. pub album_id: Option, - /// The owner the asset is filed under, when it is not the uploader. + /// The owner the asset is filed under, when the client wants to say so. /// - /// Refused when it is anyone but the uploader: an on-behalf upload needs a verified - /// relationship, and the port that would answer for one does not exist here. + /// Advisory, never decisive: the asset is filed under the **album's** owner, which the write + /// authority answers from the album record — the uploader when it is their album, the owner + /// when the uploader is a writer on its roster (`S-C51`). A declared owner that is anyone + /// else, the uploading member included, is refused `error.upload.owner_not_permitted`. pub owner_id: Option, /// The album-upgrade intent this write belongs to, when it belongs to one. /// @@ -243,23 +249,13 @@ pub struct CreateHeaders { offset: Option, } -/// The `X-Capsule-Protocol` handshake header, on every upload request. -#[derive(HeaderParams)] -pub struct ProtocolHeader { - /// The protocol date the client speaks. - /// - /// Read as a string rather than a typed value so that a malformed one is *this* surface's - /// coded `400` rather than the framework's uncoded one. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, -} - /// The headers a chunk carries. +/// +/// The `X-Capsule-Protocol` handshake is not among them: [`crate::negotiation::ProtocolGate`] +/// reads and declares it for every operation on this surface, so a chunk handler only sees a +/// request the handshake already admitted. #[derive(HeaderParams)] pub struct ChunkHeaders { - /// The protocol date the client speaks. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, /// Where in the blob this chunk starts. #[header(rename = "X-Capsule-Offset")] offset: Option, @@ -347,19 +343,6 @@ pub enum CreateRejection { code: &'static str, }, - /// The request is not one this surface can read — a missing or unreadable handshake - /// header, most often. - #[error("the request is not a well-formed upload: {detail}")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// What was wrong, in English. Reaches the client as the problem's `detail`, via - /// `Display`, rather than as a second extension member saying the same thing. - detail: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The account is suspended (`S-C8`). /// /// Distinct from a quota refusal and from a permission one, deliberately: the three send a @@ -374,21 +357,16 @@ pub enum CreateRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the window this server accepts. + /// Invariant 1: the manifest envelope pins a `protocol_version` outside the window this + /// server accepts. /// - /// The accepted range rides as problem extensions rather than as the - /// `X-Capsule-Protocol-Min`/`-Max` headers the protocol's census names: a Kynos `ApiError` - /// has no seam for a response header, and a client that cannot read the window cannot show - /// the actionable "update to keep uploading". Recorded as a deviation rather than dropped. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] + /// The header handshake never reaches here — the gate answered it — so this is the + /// *body's* pin, which an album carries for life. The accepted window is not restated as + /// extension members: it rides `X-Capsule-Protocol-Min`/`-Max` on this response like every + /// other, which is where the SDK reads it. + #[error("the envelope pins a protocol version this server does not accept")] #[problem(status = 426, title = "Protocol version unsupported")] ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, /// The stable catalog code. #[problem(extension)] code: &'static str, @@ -471,9 +449,9 @@ pub enum CreateRejection { /// Why a chunk was not accepted, or the finalization it triggered did not commit. #[derive(Debug, thiserror::Error, ApiError)] pub enum ChunkRejection { - /// The request is not a well-formed chunk: a missing handshake header, a missing or - /// unreadable offset or checksum, an empty body, a misaligned chunk, a checksum that does - /// not match the bytes, or bytes past the declared size. + /// The request is not a well-formed chunk: a missing or unreadable offset or checksum, an + /// empty body, a misaligned chunk, a checksum that does not match the bytes, or bytes past + /// the declared size. #[error("{detail}")] #[problem(status = 400, title = "Invalid chunk")] Invalid { @@ -485,21 +463,6 @@ pub enum ChunkRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// Only the uploader may append to a session. #[error("this session belongs to another uploader")] #[problem(status = 403, title = "Not the uploader")] @@ -618,30 +581,6 @@ pub enum ChunkRejection { /// afterwards. Two identical enums would be two places for the answers to drift apart. #[derive(Debug, thiserror::Error, ApiError)] pub enum SessionRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -679,30 +618,6 @@ pub enum SessionRejection { /// exact `S-C28` defect this rebuild removes. #[derive(Debug, thiserror::Error, ApiError)] pub enum CancelRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -763,16 +678,6 @@ impl CancelRejection { impl From for CancelRejection { fn from(rejection: SessionRejection) -> Self { match rejection { - SessionRejection::MalformedRequest { code } => Self::MalformedRequest { code }, - SessionRejection::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - } => Self::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - }, SessionRejection::Forbidden { code } => Self::Forbidden { code }, SessionRejection::SessionNotFound { code } => Self::SessionNotFound { code }, SessionRejection::Unavailable { code } => Self::Unavailable { code }, @@ -781,12 +686,6 @@ impl From for CancelRejection { } impl SessionRejection { - fn malformed_request() -> Self { - Self::MalformedRequest { - code: error_codes::UPLOAD_MALFORMED_REQUEST, - } - } - fn forbidden() -> Self { Self::Forbidden { code: error_codes::UPLOAD_FORBIDDEN, @@ -822,11 +721,8 @@ pub async fn create_upload( Inject(quota): Inject, Inject(moderation): Inject, Auth(credential): Auth, - Headers(handshake): Headers, Json(request): Json, ) -> Result, CreateRejection> { - handshake_ok(upload.policy(), handshake.protocol.as_deref())?; - let uploader = credential.user.clone(); // Account standing (`S-C8`), checked before anything is reserved. A suspension removes the @@ -848,28 +744,31 @@ pub async fn create_upload( }); } - let owner = resolve_owner(&uploader, request.owner_id.as_deref())?; - // Invariant 6, first half: this surface has no way to check an album it was not given. let Some(album) = request.album_id.as_deref().map(AlbumId::new) else { return Err(CreateRejection::album_access_denied()); }; let AlbumWriteAccess::Writable { + owner_id: owner, + role, protocol_pin, quiescing_under, } = upload .authority() - .album_write_access(&owner, &album) + .album_write_access(&uploader, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an album"); CreateRejection::unavailable() })? else { - tracing::info!(%owner, %album, "an upload was refused: no write capability"); + tracing::info!(%uploader, %album, "an upload was refused: no write capability"); return Err(CreateRejection::album_access_denied()); }; + tracing::debug!(%uploader, %owner, ?role, %album, "the album admits this upload"); + // The namespace is the authority's answer; a declared owner may only agree with it. + resolve_owner(&uploader, &owner, request.owner_id.as_deref())?; // Upgrade quiescence (`S-C24`, versioning.md step 2). An album whose members have stopped // writing and are draining accepts **only** the ceremony's own writes, so a stale client that @@ -891,6 +790,43 @@ pub async fn create_upload( }); } + // Invariant 7's account half — and it belongs on **this** surface only. + // + // This endpoint admits exactly the two actions that move blob bytes, `create` and `replace` + // (the dispatch below; a write that moves bytes is an upload by definition). Both mint + // freshly authored ciphertext, and every client path that builds one sets the author to the + // signing account: `lifecycle/import.rs`, `lifecycle/drops.rs` and `drop/mod.rs` all write + // `created_by_user: self.account.user_id` — the last of them for an *adopted* web-upload + // drop, which design/web-upload.md is explicit about ("set `created_by_user`/ + // `created_by_device` to the **adopter** (the cryptographic author)"). So on this surface + // the author is the caller, and a mismatch is a client contradicting itself. + // + // `POST /v1/albums/{album_id}/ops` makes the **same** comparison, for the same reason. It + // briefly did not: the check was removed there when a writer member's delete of the owner's + // asset was refused, on the reading that a continuation's author travels down from the + // creator. That was right about the symptom and wrong about the cause — `sign_lifecycle` + // re-mints `created_by_user` and `created_by_device` per record, so on a continuation as on + // a create the field names the signer of *this* record — and the check was restored once the + // client half was understood. See that module's docs. + // + // Without this, a writer member could file a *new* asset into the owner's album attributed + // to a third account: the manifest is stored verbatim and served back as provenance, and + // nothing later re-derives who wrote it. A `400` with the envelope-mismatch code, like every + // other field that contradicts what the request itself establishes. + // + // `replace` has no client builder in this tree yet (no `Action::Replace` is constructed + // anywhere under `capsule-core/src/lifecycle/`), so whether a replace re-mints its + // attribution or inherits it is still open — #475. It is held to the same rule as `create` + // here because it authors new ciphertext under a fresh file key, which is what makes an + // author an author on this surface. + if request.manifest_envelope.created_by_user != uploader.as_str() { + tracing::info!(%uploader, "an upload was refused: created_by_user is not the caller"); + return Err(CreateRejection::Invalid { + detail: "created_by_user is not the authenticated caller".to_owned(), + code: error_codes::UPLOAD_ENVELOPE_MISMATCH, + }); + } + // Invariant 7: the device the manifest names must be in the uploader's published // directory, and the battery compares the moment it was admitted against the manifest. let device = crate::upload::envelope::created_by_device(&request.manifest_envelope) @@ -1012,7 +948,7 @@ pub async fn create_upload( })? { // A new bundle, or a sibling session of one already open. Both are the normal case. crate::index::Reservation::Created(_) | crate::index::Reservation::Joined(_) => {} - // The id names a row this caller does not own, or one filed under a different album or + // The id names a row filed under another album's owner, or under a different album or // pin. Answered as a plain refusal carrying nothing: the id is client-chosen, so a // guess costs the caller nothing and must buy them nothing. crate::index::Reservation::Conflict => { @@ -1109,8 +1045,6 @@ pub async fn append_chunk( Headers(headers): Headers, body: ChunkBody, ) -> Result, ChunkRejection> { - chunk_handshake_ok(upload.policy(), headers.protocol.as_deref())?; - let id = UploadId::new(path.id); let record = upload .sessions() @@ -1228,15 +1162,8 @@ pub async fn head_upload( Inject(upload): Inject, Auth(credential): Auth, Path(path): Path, - Headers(handshake): Headers, ) -> Result, SessionRejection> { - let record = session_for( - &upload, - &path, - handshake.protocol.as_deref(), - &credential.user, - ) - .await?; + let record = session_for(&upload, &path, &credential.user).await?; Ok(WithHeaders::new( HeadReply::Progress, @@ -1262,15 +1189,8 @@ pub async fn cancel_upload( Inject(quota): Inject, Auth(credential): Auth, Path(path): Path, - Headers(handshake): Headers, ) -> Result { - let record = session_for( - &upload, - &path, - handshake.protocol.as_deref(), - &credential.user, - ) - .await?; + let record = session_for(&upload, &path, &credential.user).await?; if !record.status.is_active() || record.status == UploadSessionStatus::WaitingForProcessing { return Err(CancelRejection::not_active()); @@ -1315,23 +1235,8 @@ pub async fn cancel_upload( async fn session_for( upload: &UploadContext, path: &UploadPath, - presented: Option<&str>, caller: &UserId, ) -> Result { - match handshake(upload.policy(), presented) { - Handshake::Ok => {} - Handshake::Missing | Handshake::Malformed => { - return Err(SessionRejection::malformed_request()); - } - Handshake::OutOfRange => { - return Err(SessionRejection::ProtocolUnsupported { - protocol_min: upload.policy().protocol_min().to_owned(), - protocol_max: upload.policy().protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }); - } - } - let id = UploadId::new(path.id.clone()); let record = upload .sessions() @@ -1350,90 +1255,24 @@ async fn session_for( Ok(record) } -/// The handshake, for an operation that answers with [`CreateRejection`]. -fn handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), CreateRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is required on every upload request".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::Malformed => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is not a YYYY-MM-DD date".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(CreateRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// The handshake, for an operation that answers with [`ChunkRejection`]. -fn chunk_handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), ChunkRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing | Handshake::Malformed => Err(ChunkRejection::Invalid { - detail: "X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request" - .to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(ChunkRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// What the handshake header said. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Handshake { - /// Present and inside the accepted window. - Ok, - /// Absent. - Missing, - /// Present but not a `YYYY-MM-DD` date. - Malformed, - /// A date outside the accepted window. - OutOfRange, -} - -/// The one-shot compatibility gate: a client either speaks a version this server accepts, or -/// it does not upload. There is no negotiation and no degrade. -fn handshake(policy: &crate::upload::UploadPolicy, presented: Option<&str>) -> Handshake { - let Some(version) = presented else { - return Handshake::Missing; - }; - match capsule_core::validation::protocol_gate( - version, - policy.protocol_min(), - policy.protocol_max(), - ) { - Ok(()) => Handshake::Ok, - Err(capsule_core::validation::HandshakeReject::ProtocolOutOfRange) => Handshake::OutOfRange, - Err(_) => Handshake::Malformed, - } -} - -/// The owner an upload is filed under. +/// Check a declared `owner_id` against the namespace the authority filed the write under. /// -/// An on-behalf upload needs a verified relationship between two accounts, and the port that -/// would answer for one is not part of this slice. So it is refused rather than assumed: a -/// server that cannot check a permission must not act as though it passed. -fn resolve_owner(uploader: &UserId, declared: Option<&str>) -> Result { +/// The owner is **not** taken from the request: it is the album's own, answered by the write +/// authority from the album record, and an uploader who is a writer member of somebody else's +/// album is filed under that owner whether or not they said so (`S-C51`). What the request may +/// do is *agree* — name the album owner, or, when the uploader is the owner, themselves. Naming +/// anyone else is refused: a member declaring their own account as owner would be asking for an +/// asset the owner's feed never carries, and a stranger's declaration is not a permission. +fn resolve_owner( + uploader: &UserId, + owner: &OwnerId, + declared: Option<&str>, +) -> Result<(), CreateRejection> { match declared { - None => Ok(OwnerId::new(uploader.as_str())), - Some(owner) if owner == uploader.as_str() => Ok(OwnerId::new(owner)), + None => Ok(()), + Some(named) if named == owner.as_str() => Ok(()), Some(_) => { - tracing::info!(%uploader, "an on-behalf upload was refused: no relationship port"); + tracing::info!(%uploader, %owner, "an upload was refused: the declared owner is not the album's"); Err(CreateRejection::owner_not_permitted()) } } @@ -1530,8 +1369,6 @@ impl CreateRejection { "protocol_version is not a YYYY-MM-DD date", ), GateReject::ProtocolOutOfRange => Self::ProtocolUnsupported { - protocol_min: String::new(), - protocol_max: String::new(), code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, }, GateReject::UnknownCryptoSuite => invalid( @@ -1596,7 +1433,7 @@ impl CreateRejection { fn owner_not_permitted() -> Self { Self::Forbidden { - detail: "uploading on behalf of another owner is not permitted".to_owned(), + detail: "the declared owner is not the album's owner".to_owned(), code: error_codes::UPLOAD_OWNER_NOT_PERMITTED, } } diff --git a/capsule-server/src/routes/well_known.rs b/capsule-server/src/routes/well_known.rs index 872b38bc..75512ac0 100644 --- a/capsule-server/src/routes/well_known.rs +++ b/capsule-server/src/routes/well_known.rs @@ -140,6 +140,19 @@ pub struct AuthEndpointsResponse { pub refresh: String, /// Where a session is ended. pub logout: String, + /// Where a sign-in through an external identity provider begins and ends, or `null` when + /// this deployment has none. Always present, so a client reads one field rather than + /// probing for one. + pub oidc: Option, +} + +/// The OIDC ceremony's endpoints (slice `S-N1`). +#[derive(Schema, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct OidcEndpointsResponse { + /// Where a client asks for an authorization URL. + pub authorize: String, + /// Where a client presents the `state` and `code` the provider's redirect carried. + pub callback: String, } /// The accepted `protocol_version` range. @@ -225,6 +238,14 @@ pub async fn server_info(Inject(discovery): Inject) -> Json, seed: u8, ) -> Vec { - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; + use capsule_core::crypto::keys::HybridSigningKey; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::AssetManifest; @@ -85,8 +85,7 @@ fn decoded(bytes: &[u8]) -> capsule_core::crypto::provenance::manifest::AssetMan /// decodes — but a fixture that hand-rolled a struct with placeholder signature bytes would be /// asserting against a shape rather than against the artifact. fn manifest_bytes(name: &str, metadata: &ContentAddress) -> Vec { - use capsule_core::crypto::keys::AmkVersion; - use capsule_core::crypto::keys::hybrid_sig::HybridSigningKey; + use capsule_core::crypto::keys::{AmkVersion, HybridSigningKey}; use capsule_core::crypto::provenance::action::Action; use capsule_core::crypto::provenance::manifest::{ ASSET_MANIFEST_VERSION, KeyMode, ManifestCore, diff --git a/capsule-server/src/serve/authority.rs b/capsule-server/src/serve/authority.rs index e116a536..8e431064 100644 --- a/capsule-server/src/serve/authority.rs +++ b/capsule-server/src/serve/authority.rs @@ -1,8 +1,9 @@ -//! [`ReadAuthority`] — who may fetch a blob, and the `403` the contract asks for (`S-C39`). +//! [`ReadAuthority`] — who may fetch a blob, and the `403` the contract asks for (`S-C39`, +//! `S-C51`). //! -//! # The hole this closes +//! # The hole `S-C39` closed //! -//! Before this, `GET /v1/blob/{hash}` authorized on "a valid access token" and nothing else, on +//! Before it, `GET /v1/blob/{hash}` authorized on "a valid access token" and nothing else, on //! both the Salvo surface and its Kynos port. **Any authenticated account could fetch any live //! ciphertext whose address it could name.** That was defended as a capability model — a content //! address is the hash of ciphertext, so producing one without holding the bytes is producing a @@ -11,15 +12,16 @@ //! backup, a log, a screenshot of a debug tool, and the capability is permanent because the //! address is. //! -//! So the serve path now asks a question, and the question has a port. +//! So the serve path asks a question, and the question has a port. //! //! # Three answers, and the middle one is the whole disclosure argument //! //! | Answer | Status | What it tells the caller | //! | --- | --- | --- | //! | [`BlobReadAccess::Granted`] | `200`/`206` | the bytes | -//! | *(no variant yet — `S-C51`)* | `403` | *"you had this and you do not now"* — re-sync membership, then degrade | +//! | [`BlobReadAccess::Revoked`] | `403` | *"you had this and you do not now"* — re-sync membership, then degrade | //! | [`BlobReadAccess::Unrelated`] | `404` | nothing. Byte-identical to an address the server never heard of | +//! | [`BlobReadAccess::ScopeInsufficient`] | `403` | *"this grant does not cover originals"* — a peer only (`S-E5`) | //! //! **A `403` is a disclosure and a `404` is not**, which is why the boundary is drawn where it //! is. Answering `403` to a caller with no relationship to an asset would confirm that the @@ -28,41 +30,53 @@ //! authorization *change*, and a change presupposes a prior state: the caller has to be someone //! the server can see once had access. Everyone else is told what an unknown address is told. //! -//! # What the production authority can actually decide today, stated plainly +//! # Where the middle row's fact comes from (`S-C51`) //! -//! [`OwnedAssetAuthority`] grants a fetch to the account the referencing asset is filed under, -//! and answers [`BlobReadAccess::Unrelated`] to everyone else. That is the whole of it, and it -//! is the whole of it because **this server has no record of album membership**: +//! [`MembershipAuthority`] grants a fetch to the account the referencing asset is filed under +//! and to any account on the current roster of the album it belongs to, in either role — a +//! reader reads, that is what the role is for. An account the roster once carried and no longer +//! does is [`BlobReadAccess::Revoked`]: the membership store keeps the row and marks it, rather +//! than deleting it, precisely so this answer has a stored fact behind it. An account the roster +//! never named is [`BlobReadAccess::Unrelated`], indistinguishable from a stranger, because it +//! is one. //! -//! - Album sharing between accounts is an MLS group. The server holds no key and cannot read -//! the roster, by design — that is the product, not a limitation of this module. -//! - The share and drop surfaces that *do* let a non-owner reach ciphertext serve it on their -//! own routes, from their own capabilities: `/s/{id}/blob/{hash}` serves exactly the addresses -//! its link record enumerates, and a revoked link is a `404` there. Neither of them routes -//! through here. +//! # And where a peer's fact comes from (`S-E5`) //! -//! So the middle row has **no production source**, and this enum therefore does not carry a -//! variant for it and the blob route does not declare the `403`. That is the `S-C28` rule -//! applied to a status the author would have liked to have: an enum arm nothing produces and a -//! status nothing can reach are the same defect, and writing the taxonomy into a doc comment is -//! the honest way to keep the design without shipping the dead code. +//! A federated peer is not an account, so it is not asked the account's question. Its +//! relationship to an asset is the **capability** this server minted: one album, one roster +//! member, one epoch, one scope. So [`ReadPrincipal::Peer`] is decided as — is this blob in the +//! album the capability names (anything else is a stranger's, `404`), is that member still on +//! the roster at the epoch the grant was made at (removed, or re-admitted later, is `403` +//! [`BlobReadAccess::Revoked`]: the peer held the grant, so the change is a disclosure it is +//! owed), was that member ever on it at all (`404`), and finally does the grant's scope cover +//! this blob's **role** — a `read-derivative-only` capability is refused an `original` with +//! [`BlobReadAccess::ScopeInsufficient`]. A **backup** is refused under every scope and is +//! refused as [`BlobReadAccess::Unrelated`] rather than as a scope failure: it is the owner's own +//! durability artefact rather than part of what was shared, the feed never names one, and the +//! `403`'s justification — the peer already knows the asset is there — does not hold for a blob +//! it was never told about. //! -//! What changed is the **shape of the gap**. It was "there is no read authority", a hypothesis -//! about missing code — a plausible afternoon's work that would have been wrong. It is now -//! "there is no membership fact", a named thing the *write* path wants too: -//! `AlbumWriteAccess::Denied` has been unable to widen from owner to member since `S-C25` for -//! exactly the same reason. One fact unblocks both. Filed as `S-C51`. +//! Whether the grant is still *live* — unrevoked, unexpired — is not asked here: it has no +//! clock, and the route admits the capability through +//! [`federation::admit`](crate::federation::admit) before it resolves anything. What is asked +//! here is only what the stores know. //! -//! **The takedown `410` was deliberately left alone**, and it is worth saying why, because -//! owner-scoping dissolved the argument that put it there. `crate::serve` justifies collapsing a -//! serving hold into `410` partly on the grounds that a distinguishable answer would make the -//! path a moderation oracle *for an anonymous fetcher* — and after `S-C39` the only caller who -//! can reach a held asset's blob is the account that owns it, so there is no anonymous fetcher -//! left to protect from. A `403` would arguably serve that owner better: a takedown is -//! reversible, the bytes are untouched, and `410` tells a client to degrade permanently. It is -//! not changed here because design/moderation.md states the per-surface rule as *"takedown of -//! known content → `410`"* and changing a landed, tested contract on an inference is not this -//! slice's to do. Recorded on `S-C39` for whoever owns that question. +//! The roster itself is the album owner's signed statement, verified against the owner's +//! published device directory before it is stored ([`crate::membership`]). This server still +//! cannot read the MLS group, and the roster does not change that: it is a **transport** +//! control over who is handed bytes, not a confidentiality control over who can read them. +//! +//! The membership question is asked from the reference the index returned, which carries the +//! asset's `album_id` and `owner_id` for exactly this reason: the decision comes from the same +//! read that found the reference, so there is no window in which ownership and the answer +//! disagree. It costs one membership lookup per fetch by a non-owner and none for the owner. +//! +//! **The takedown `410` was deliberately left alone** by `S-C39`, and the reasoning still holds +//! with members in the picture: design/moderation.md states the per-surface rule as *"takedown +//! of known content → `410`"*, and changing a landed, tested contract on an inference is not +//! this module's to do. What `S-C51` adds is that a *former* member is answered `403` before any +//! `410` is reached, so the authority-first ordering `S-C39` established keeps every policy +//! refusal illegible to anyone who is not currently entitled to the bytes. //! //! [Download & Sync]: ../../../capsule-docs/src/content/docs/design/import/download-sync.md @@ -71,8 +85,10 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use crate::federation::VerifiedCapability; use crate::index::BlobReference; -use crate::store::OwnerId; +use crate::membership::{Membership, MembershipStore}; +use crate::store::{OwnerId, UserId}; /// The future a read-authority question returns. /// @@ -107,83 +123,266 @@ impl ReadAuthorityError { pub enum BlobReadAccess { /// Serve them. Granted, + /// The caller was on the album's roster and has been removed (`S-C51`). + /// + /// Rendered as `403`: the one answer that discloses the address is live, given only to an + /// account the server holds a revoked membership row for. + Revoked, /// The caller has no relationship to the asset the server can see. /// /// Rendered as `404`, byte-identical to an address nothing references — which is the point. Unrelated, + /// The caller is entitled to the album, but its grant does not cover this blob's role + /// (`S-E5`). + /// + /// Only a peer under a capability ever sees this: an account's membership carries no scope. + /// Rendered as `403 error.federation.scope_insufficient` rather than `404`, because the peer + /// already knows the album holds the asset — the feed told it — and a `404` would send it + /// looking for an address that is there. + ScopeInsufficient, +} + +/// Who a blob is being served to (`S-C39`, `S-E5`). +/// +/// An account and a peer are decided from the same stores, but they are not the same reader: +/// an account's relationship to an asset is its own membership, while a peer's is the +/// membership of the roster member its capability was minted for, inside the one album that +/// capability names. A bare identifier would have let either be read as the other, and a peer +/// origin and an account id can spell the same string. +#[derive(Debug, Clone, Copy)] +pub enum ReadPrincipal<'a> { + /// An account, through a session access token. + Account(&'a OwnerId), + /// A peer server, through a federation capability (`S-E5`). + Peer(&'a VerifiedCapability), +} + +impl<'a> ReadPrincipal<'a> { + /// The account whose own in-flight uploads may answer a fetch (`S-C40`), or `None`. + /// + /// A peer has none. The transient `409` reports the caller's *own device* still sending + /// exactly these bytes; a peer has no device here, so it is told what an unreferenced + /// address tells everyone and waits for the feed's `original_held` to flip instead. + #[must_use] + pub fn own_account(self) -> Option<&'a OwnerId> { + match self { + Self::Account(owner) => Some(owner), + Self::Peer(_) => None, + } + } +} + +impl fmt::Display for ReadPrincipal<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Account(owner) => write!(f, "account {owner}"), + Self::Peer(capability) => write!(f, "peer {}", capability.record.peer_id), + } + } } /// Who may read a blob. /// /// A port rather than a function on the serve context, for the reason /// [`WriteAuthority`](crate::upload::WriteAuthority) is one: the facts it decides from live in -/// stores that will grow (membership, federation), and a serving path that reached into them -/// directly would have to grow with them. +/// stores that will grow (federation next), and a serving path that reached into them directly +/// would have to grow with them. pub trait ReadAuthority: fmt::Debug + Send + Sync { - /// May `caller` fetch the bytes `reference` names? + /// May `principal` fetch the bytes `reference` names? /// /// Takes the whole reference rather than an asset id so the decision comes from the same /// read that found it. An authority that re-looked-up the asset would open a window in /// which the two reads disagree, and would cost a round trip to do it. fn blob_read_access<'a>( &'a self, - caller: &'a OwnerId, + principal: ReadPrincipal<'a>, reference: &'a BlobReference, ) -> ReadAuthorityFuture<'a, BlobReadAccess>; } -/// The authority the server runs on: an account reads its own assets' blobs. -/// -/// Holds nothing. The fact it decides on travels on the reference, which is deliberate — the -/// alternative is a store lookup per fetch to learn something the index already read. -#[derive(Debug, Clone, Copy, Default)] -pub struct OwnedAssetAuthority; +/// The authority the server runs on: an account reads its own assets' blobs and the blobs of +/// every album it is currently a member of, and a peer reads what its capability names. +#[derive(Debug, Clone)] +pub struct MembershipAuthority { + members: Arc, +} -impl OwnedAssetAuthority { - /// The authority. +impl MembershipAuthority { + /// The authority over `members`. #[must_use] - pub fn new() -> Self { - Self + pub fn new(members: Arc) -> Self { + Self { members } } } -impl ReadAuthority for OwnedAssetAuthority { - fn blob_read_access<'a>( - &'a self, - caller: &'a OwnerId, - reference: &'a BlobReference, - ) -> ReadAuthorityFuture<'a, BlobReadAccess> { - Box::pin(async move { +impl MembershipAuthority { + /// What an account may read: its own assets, and the albums it is on the roster of. + async fn account_access( + &self, + caller: &OwnerId, + reference: &BlobReference, + ) -> Result { + { if &reference.owner_id == caller { return Ok(BlobReadAccess::Granted); } - // Not `Revoked`. The caller never had it, and saying otherwise would confirm the - // address is live — see the module docs on why the boundary is here. + // Somebody else's asset: the album's roster decides. The store is asked with the + // caller's account id, which is the same string the owner id is. + let user = UserId::new(caller.as_str()); + let membership = self + .members + .membership(&reference.album_id, &user) + .await + .map_err(|error| { + tracing::error!(%error, album = %reference.album_id, "the membership store could not answer a fetch"); + ReadAuthorityError::unavailable(error.to_string()) + })?; + Ok(match membership { + // Either role reads: that is what a reader is. + Membership::Member { .. } => BlobReadAccess::Granted, + Membership::Revoked(revocation) => { + tracing::info!( + asset = %reference.asset_id, + album = %reference.album_id, + at_version = revocation.at_version, + "a former member's blob fetch was refused" + ); + BlobReadAccess::Revoked + } + // Never a member. Not `Revoked`: the caller never had it, and saying otherwise + // would confirm the address is live — see the module docs on the boundary. + Membership::Never => { + tracing::info!( + asset = %reference.asset_id, + "a blob fetch named an address belonging to another account" + ); + BlobReadAccess::Unrelated + } + }) + } + } + + /// What a peer may read: the album its capability names, as the member it was minted for, + /// within the scope it was granted (`S-E5`). + async fn peer_access( + &self, + capability: &VerifiedCapability, + reference: &BlobReference, + ) -> Result { + let record = &capability.record; + // A capability covers exactly one album. A blob in any other is answered as a + // stranger's: the peer holds no fact about that album and must not acquire one here, + // and `404` is byte-identical to an address nothing references. + if reference.album_id != record.album_id { tracing::info!( + peer = %record.peer_id, asset = %reference.asset_id, - "a blob fetch named an address belonging to another account" + "a peer named an address outside its capability's album" ); - Ok(BlobReadAccess::Unrelated) + return Ok(BlobReadAccess::Unrelated); + } + let membership = self + .members + .membership(&reference.album_id, &record.member) + .await + .map_err(|error| { + tracing::error!(%error, album = %reference.album_id, "the membership store could not answer a peer's fetch"); + ReadAuthorityError::unavailable(error.to_string()) + })?; + match membership { + Membership::Member { granted_epoch, .. } if granted_epoch == record.granted_epoch => { + // Entitled to the album. The last question is the grant's own: a scope is + // enforced against the blob's server-visible **role**, so a derivative-only + // capability cannot fetch an original whatever the peer says it is fetching. + if record.scope.permits(reference.role) { + return Ok(BlobReadAccess::Granted); + } + // A **backup** is not part of what was shared at all — it is the owner's own + // durability artefact — so no capability over the album covers it and a peer has + // no relationship to it to be told about. `404`, as a stranger gets, and *not* + // the `403` below: that answer's whole justification is that the feed already + // told the peer the asset is there, which is true of an original under a + // derivative-only grant and false of a backup, which the feed never names. + if reference.role == crate::store::BlobRole::Backup { + tracing::info!( + peer = %record.peer_id, + asset = %reference.asset_id, + "a peer named a backup, which no capability covers" + ); + return Ok(BlobReadAccess::Unrelated); + } + tracing::info!( + peer = %record.peer_id, + asset = %reference.asset_id, + role = reference.role.as_str(), + scope = record.scope.as_str(), + "a peer's capability does not cover this blob's role" + ); + Ok(BlobReadAccess::ScopeInsufficient) + } + // The member was never on this roster at all. Not the peer's business that the + // album exists, so it is told what a stranger is told. + Membership::Never => { + tracing::info!( + peer = %record.peer_id, + member = %record.member, + album = %reference.album_id, + "a peer's capability names a member the roster never carried" + ); + Ok(BlobReadAccess::Unrelated) + } + // Removed, or re-admitted at a later epoch: either way the membership this grant + // was minted for has ended. The peer held it, so the change is a disclosure it is + // owed — the same `403` a former member gets. + membership => { + tracing::info!( + peer = %record.peer_id, + member = %record.member, + album = %reference.album_id, + ?membership, + granted_epoch = record.granted_epoch, + "a peer's capability outlived the membership it was minted for" + ); + Ok(BlobReadAccess::Revoked) + } + } + } +} + +impl ReadAuthority for MembershipAuthority { + fn blob_read_access<'a>( + &'a self, + principal: ReadPrincipal<'a>, + reference: &'a BlobReference, + ) -> ReadAuthorityFuture<'a, BlobReadAccess> { + Box::pin(async move { + match principal { + ReadPrincipal::Account(caller) => self.account_access(caller, reference).await, + ReadPrincipal::Peer(capability) => self.peer_access(capability, reference).await, + } }) } } /// A convenience for wiring the production authority. #[must_use] -pub fn owned_assets() -> Arc { - Arc::new(OwnedAssetAuthority::new()) +pub fn membership_reads(members: Arc) -> Arc { + Arc::new(MembershipAuthority::new(members)) } #[cfg(test)] mod tests { use super::*; + use crate::federation::{CapabilityRecord, PeerId, Scope}; use crate::index::AssetState; - use crate::store::{AssetId, BlobRole}; + use crate::membership::{InMemoryMembership, MemberRole, RosterRecord}; + use crate::store::{AlbumId, AssetId, BlobRole}; - /// A reference to `owner`'s asset. + /// A reference to `owner`'s asset in the one album these cases share. fn reference(owner: &str) -> BlobReference { BlobReference { asset_id: AssetId::new("asset"), + album_id: AlbumId::new("album"), owner_id: OwnerId::new(owner), role: BlobRole::Original, state: AssetState::Visible, @@ -192,49 +391,342 @@ mod tests { } } + /// The album's roster at `version`, naming `members`. + async fn roster(store: &InMemoryMembership, version: u64, members: &[(&str, MemberRole)]) { + store + .apply_roster( + RosterRecord { + album_id: AlbumId::new("album"), + roster_version: version, + amk_epoch: version, + attested_by_device: uuid::Uuid::from_u128(0xD1), + received_at: jiff::Timestamp::UNIX_EPOCH, + document: format!("v{version}").into_bytes(), + }, + members + .iter() + .map(|(user, role)| (UserId::new(*user), *role)) + .collect(), + ) + .await + .expect("the store applies"); + } + + /// An authority over a store where `bob` is a reader and `carol` a writer — both since + /// version 1, so their membership was granted at epoch 1 — `dave` a former member removed + /// at version 2, and `erin` a member removed at version 2 and **re-admitted** at version 3, + /// whose membership was therefore granted at epoch 3. + /// + /// The re-admission is what a peer capability's epoch binding is tested against: the + /// membership store keeps a member's original `granted_epoch` while they stay listed, so + /// only an interruption moves it. + async fn authority() -> MembershipAuthority { + let store = Arc::new(InMemoryMembership::new()); + roster( + &store, + 1, + &[ + ("bob", MemberRole::Reader), + ("carol", MemberRole::Writer), + ("dave", MemberRole::Writer), + ("erin", MemberRole::Reader), + ], + ) + .await; + roster( + &store, + 2, + &[("bob", MemberRole::Reader), ("carol", MemberRole::Writer)], + ) + .await; + roster( + &store, + 3, + &[ + ("bob", MemberRole::Reader), + ("carol", MemberRole::Writer), + ("erin", MemberRole::Reader), + ], + ) + .await; + MembershipAuthority::new(store) + } + + async fn decide( + authority: &MembershipAuthority, + caller: &str, + reference: &BlobReference, + ) -> BlobReadAccess { + let owner = OwnerId::new(caller); + authority + .blob_read_access(ReadPrincipal::Account(&owner), reference) + .await + .expect("the authority decides") + } + + /// A capability over the shared album, minted for `member` at `granted_epoch` with `scope`. + /// + /// Built as the store holds one rather than through the codec: what this unit decides from + /// is the *record*, and the token behind it is `federation::capability`'s subject. + fn capability(member: &str, granted_epoch: u64, scope: Scope) -> VerifiedCapability { + let record = CapabilityRecord { + jti: "01937b7c-0000-7000-8000-0000000000aa".to_owned(), + album_id: AlbumId::new("album"), + peer_id: PeerId::new("other.test"), + member: UserId::new(member), + scope, + granted_epoch, + min_protocol_version: "2026-06-01".to_owned(), + issued_at: jiff::Timestamp::UNIX_EPOCH, + expires_at: jiff::Timestamp::UNIX_EPOCH + jiff::SignedDuration::from_hours(6), + not_after: jiff::Timestamp::UNIX_EPOCH + jiff::SignedDuration::from_hours(6), + revoked_at: None, + refreshed_to: None, + }; + VerifiedCapability { + grant: record.grant(), + record, + } + } + + async fn decide_peer( + authority: &MembershipAuthority, + capability: &VerifiedCapability, + reference: &BlobReference, + ) -> BlobReadAccess { + authority + .blob_read_access(ReadPrincipal::Peer(capability), reference) + .await + .expect("the authority decides") + } + #[tokio::test] - async fn an_account_reads_its_own() { + async fn an_account_reads_its_own_without_asking_the_roster() { + // No roster at all: the owner's access is the album record's fact, not the roster's. + let authority = MembershipAuthority::new(Arc::new(InMemoryMembership::new())); assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("alice"), &reference("alice")) - .await - .expect("the authority decides"), + decide(&authority, "alice", &reference("alice")).await, BlobReadAccess::Granted ); } #[tokio::test] - async fn anybody_else_is_unrelated_rather_than_forbidden() { - // The disclosure boundary, at the unit that decides it. `Unrelated` becomes a `404` - // identical to an unknown address; a `403` here would confirm the reference exists. + async fn a_member_of_either_role_reads() { + let authority = authority().await; assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("mallory"), &reference("alice")) - .await - .expect("the authority decides"), + decide(&authority, "bob", &reference("alice")).await, + BlobReadAccess::Granted, + "a reader reads; that is what the role is for" + ); + assert_eq!( + decide(&authority, "carol", &reference("alice")).await, + BlobReadAccess::Granted + ); + } + + #[tokio::test] + async fn a_former_member_is_revoked_and_a_stranger_is_unrelated() { + // The disclosure boundary, at the unit that decides it. `Revoked` becomes the `403` the + // contract describes; `Unrelated` becomes a `404` identical to an unknown address. + let authority = authority().await; + assert_eq!( + decide(&authority, "dave", &reference("alice")).await, + BlobReadAccess::Revoked + ); + assert_eq!( + decide(&authority, "mallory", &reference("alice")).await, BlobReadAccess::Unrelated ); } /// State the caller cannot see does not change the answer. /// - /// A tombstoned or held asset of somebody else's is `Unrelated` exactly as a live one is — - /// the authority decides on ownership alone, so no lifecycle fact leaks through it. The - /// serving path relies on this by asking it **first**. + /// A tombstoned or held asset of somebody else's is `Unrelated` to a stranger and `Revoked` + /// to a former member exactly as a live one is — the authority decides on membership alone, + /// so no lifecycle fact leaks through it. The serving path relies on this by asking it + /// **first**. #[tokio::test] - async fn a_strangers_answer_does_not_vary_with_the_assets_state() { + async fn a_non_members_answer_does_not_vary_with_the_assets_state() { + let authority = authority().await; for state in [AssetState::Visible, AssetState::Tombstoned] { let mut reference = reference("alice"); reference.state = state; reference.hold = Some(crate::index::ServingHold::Takedown); assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("mallory"), &reference) - .await - .expect("the authority decides"), + decide(&authority, "mallory", &reference).await, BlobReadAccess::Unrelated, "a stranger's refusal must not vary with facts about the owner's asset" ); + assert_eq!( + decide(&authority, "dave", &reference).await, + BlobReadAccess::Revoked, + "nor a former member's" + ); + } + } + + #[tokio::test] + async fn a_peer_reads_the_album_its_capability_names_as_the_member_it_was_minted_for() { + // Bob is on the roster at epoch 2, which is what the grant is bound to. + let authority = authority().await; + assert_eq!( + decide_peer( + &authority, + &capability("bob", 1, Scope::Read), + &reference("alice") + ) + .await, + BlobReadAccess::Granted + ); + assert_eq!( + decide_peer( + &authority, + &capability("erin", 3, Scope::Read), + &reference("alice") + ) + .await, + BlobReadAccess::Granted, + "the epoch a re-admission was granted at" + ); + } + + #[tokio::test] + async fn a_peer_outside_its_capabilitys_album_is_a_stranger() { + // Not `Revoked`: the peer has no relationship to another album, and a `403` would tell + // it the address is referenced by somebody. + let authority = authority().await; + let mut elsewhere = reference("alice"); + elsewhere.album_id = AlbumId::new("another-album"); + assert_eq!( + decide_peer(&authority, &capability("bob", 1, Scope::Read), &elsewhere).await, + BlobReadAccess::Unrelated + ); + // And a member the roster never carried is a stranger inside the album too. + assert_eq!( + decide_peer( + &authority, + &capability("mallory", 1, Scope::Read), + &reference("alice") + ) + .await, + BlobReadAccess::Unrelated + ); + } + + #[tokio::test] + async fn a_peers_grant_does_not_outlive_the_membership_it_was_minted_for() { + // Dave was removed at version 2 and never came back. Erin was removed at 2 and + // re-admitted at 3, so a grant naming epoch 1 covers a membership that ended even + // though she is on the roster right now. Both are the `403` a former member gets, + // because the peer held the grant and the change is a disclosure it is owed. + let authority = authority().await; + assert_eq!( + decide_peer( + &authority, + &capability("dave", 1, Scope::Read), + &reference("alice") + ) + .await, + BlobReadAccess::Revoked + ); + assert_eq!( + decide_peer( + &authority, + &capability("erin", 1, Scope::Read), + &reference("alice") + ) + .await, + BlobReadAccess::Revoked, + "re-admission at a later epoch does not revive an older grant" + ); + } + + #[tokio::test] + async fn a_derivative_only_grant_is_refused_an_original_and_every_grant_a_backup() { + // The scope is enforced against the blob's server-visible role, never against what the + // peer says it is fetching. + let authority = authority().await; + let mut original = reference("alice"); + original.role = BlobRole::Original; + assert_eq!( + decide_peer( + &authority, + &capability("bob", 1, Scope::ReadDerivativeOnly), + &original + ) + .await, + BlobReadAccess::ScopeInsufficient + ); + assert_eq!( + decide_peer(&authority, &capability("bob", 1, Scope::Read), &original).await, + BlobReadAccess::Granted + ); + + for role in [ + BlobRole::Derivative, + BlobRole::Metadata, + BlobRole::Provenance, + ] { + let mut derived = reference("alice"); + derived.role = role; + assert_eq!( + decide_peer( + &authority, + &capability("bob", 1, Scope::ReadDerivativeOnly), + &derived + ) + .await, + BlobReadAccess::Granted, + "{role:?} is what a derivative-only grant is for" + ); + } + + // A backup is refused as a *stranger's* blob, not as a scope failure: the feed never + // names one, so the peer holds no fact about it and the `403`'s premise does not apply. + let mut backup = reference("alice"); + backup.role = BlobRole::Backup; + for scope in [Scope::Read, Scope::ReadDerivativeOnly] { + assert_eq!( + decide_peer(&authority, &capability("bob", 1, scope), &backup).await, + BlobReadAccess::Unrelated, + "a backup is the owner's durability artefact, not part of what was shared" + ); + } + } + + /// A peer's refusal does not vary with the asset's state either. + #[tokio::test] + async fn a_peers_answer_does_not_vary_with_the_assets_state() { + let authority = authority().await; + for state in [AssetState::Visible, AssetState::Tombstoned] { + let mut reference = reference("alice"); + reference.state = state; + reference.hold = Some(crate::index::ServingHold::Takedown); + assert_eq!( + decide_peer( + &authority, + &capability("mallory", 1, Scope::Read), + &reference + ) + .await, + BlobReadAccess::Unrelated + ); + assert_eq!( + decide_peer(&authority, &capability("dave", 1, Scope::Read), &reference).await, + BlobReadAccess::Revoked + ); } } + + #[tokio::test] + async fn membership_is_asked_about_the_references_own_album() { + // The roster is per album: a member of *this* album is a stranger to another one. + let authority = authority().await; + let mut elsewhere = reference("alice"); + elsewhere.album_id = AlbumId::new("another-album"); + assert_eq!( + decide(&authority, "bob", &elsewhere).await, + BlobReadAccess::Unrelated + ); + } } diff --git a/capsule-server/src/serve/mod.rs b/capsule-server/src/serve/mod.rs index e2775557..9217f2e2 100644 --- a/capsule-server/src/serve/mod.rs +++ b/capsule-server/src/serve/mod.rs @@ -13,6 +13,7 @@ //! | [`ServeResolution::AwaitingUpload`] | `409` | nothing references the address **yet** — the caller's own device has an upload of exactly these bytes in flight. **Transient**, so the client waits | //! | [`ServeResolution::NotFound`] | `404` | no live reference names the address, or it is malformed | //! | [`ServeResolution::Gone`] | `410` | referenced but not retrievable per policy — **permanent**, so the client degrades to a lower representation | +//! | [`ServeResolution::ScopeInsufficient`] | `403` | a peer's capability does not cover this blob's role (`S-E5`) | //! //! Three distinct facts collapse into that one `410` — a deleted asset, a blob awaiting //! collection, and a moderation hold — and they collapse deliberately. The client's action is @@ -60,24 +61,38 @@ //! arbitrary strings. A reference is looked up **before** the store is touched, so a fetch for //! an address nothing references never reaches the bytes. Only then is presence asked about. //! -//! # What is disclosed, stated plainly (`S-C39`) +//! # What is disclosed, stated plainly (`S-C39`, `S-C51`) //! -//! **An account fetches its own assets' blobs and nothing else.** Until `S-C39` any -//! authenticated account could fetch any live address it could name, defended as a capability -//! model — a content address is the hash of ciphertext, so producing one without holding the -//! bytes is producing a preimage. The defence is not wrong and it is not the contract, and it -//! stacks badly besides: an address that leaks once is a permanent capability, because the -//! address never changes. +//! **An account fetches the blobs of its own assets and of the albums it is currently a member +//! of, and nothing else.** Until `S-C39` any authenticated account could fetch any live address +//! it could name, defended as a capability model — a content address is the hash of ciphertext, +//! so producing one without holding the bytes is producing a preimage. The defence is not wrong +//! and it is not the contract, and it stacks badly besides: an address that leaks once is a +//! permanent capability, because the address never changes. //! -//! The decision is [`ReadAuthority`]'s, and a stranger is told exactly what a caller naming an -//! unknown address is told. See [`crate::serve::authority`] for why the `403`/`404` boundary is -//! drawn there and not one step further out, and for why the `403` itself still has no -//! production source: it is no longer a missing authority, it is a missing **membership fact**, -//! and that fact is `S-C51`'s. +//! The decision is [`ReadAuthority`]'s, asked from the reference the index returned and asked +//! **first**. A former member of the album — an account the owner's roster once named and no +//! longer does — is answered [`ServeResolution::Forbidden`], the `403` the download contract +//! describes as an authorization change; everyone else with no relationship is told exactly +//! what a caller naming an unknown address is told. See [`crate::serve::authority`] for why the +//! `403`/`404` boundary is drawn there and not one step further out, and [`crate::membership`] +//! for where the fact behind the `403` comes from. //! -//! Non-owners are not locked out of shared content: `/s/{id}/blob/{hash}` serves exactly the -//! addresses a share link enumerates, and the drop surface serves its own. Neither routes -//! through here, which is what makes owner-scoping this path safe to do at all. +//! # A peer reads through the same path (`S-E5`) +//! +//! A federated peer presenting a capability on `GET /v1/blob/{hash}` resolves here too, as +//! [`ReadPrincipal::Peer`], and every rule above holds unchanged: the authority is asked first, +//! so a takedown `410` is still only legible to a reader entitled to the bytes. Two things +//! differ, and both are the principal's rather than the path's. The transient `409` is not +//! offered to a peer — it reports the *caller's own device* still sending the bytes, and a peer +//! has no device here — so a peer gets the `404` an unreferenced address gets and waits for the +//! feed's `original_held` to flip. And the authority may answer a fourth way, +//! [`ServeResolution::ScopeInsufficient`], when the grant's scope does not cover the blob's +//! role. +//! +//! Non-accounts are not locked out of shared content either: `/s/{id}/blob/{hash}` serves +//! exactly the addresses a share link enumerates, and the drop surface serves its own. Neither +//! routes through here. //! //! # What is missing, and owned elsewhere //! @@ -102,8 +117,8 @@ use bytes::Bytes; pub mod authority; pub use self::authority::{ - BlobReadAccess, OwnedAssetAuthority, ReadAuthority, ReadAuthorityError, ReadAuthorityFuture, - owned_assets, + BlobReadAccess, MembershipAuthority, ReadAuthority, ReadAuthorityError, ReadAuthorityFuture, + ReadPrincipal, membership_reads, }; use crate::blob::{BlobError, BlobStore, ContentAddress}; use crate::index::{AssetIndex, AssetState}; @@ -198,6 +213,19 @@ pub enum ServeResolution { /// No live reference names the address, it is not an address at all, or the caller has no /// relationship to the asset that holds it. NotFound, + /// The caller once had access to the album that holds it and does not now (`S-C51`). + /// + /// The one refusal that discloses the address is live, and it discloses it only to an + /// account the server holds a revoked membership row for. Decided **before** every policy + /// refusal below, so a former member learns nothing about holds or deletions either. + Forbidden, + /// The reader is entitled to the album but its grant does not cover this blob's role + /// (`S-E5`). + /// + /// Only a peer under a capability reaches it. Decided in the same place as + /// [`Self::Forbidden`] and for the same reason: it is an authorization answer, and it is + /// given only to a reader that already knows the asset is there. + ScopeInsufficient, /// Referenced but not retrievable per policy: a deleted asset, or a dangling reference. Gone, } @@ -213,10 +241,10 @@ pub struct ServeUnavailable(String); /// /// Returns [`ServeUnavailable`] when the index or the blob store could not answer — never for a /// blob that is simply absent, which is a decision rather than a failure. -#[tracing::instrument(skip(context), fields(hash = %hash, owner = %owner))] +#[tracing::instrument(skip(context), fields(hash = %hash, reader = %principal))] pub async fn resolve( context: &ServeContext, - owner: &crate::store::OwnerId, + principal: ReadPrincipal<'_>, hash: &str, ) -> Result { // A string that is not a content address can address no committed blob. Answered as @@ -239,15 +267,18 @@ pub async fn resolve( // Nothing references it. Before answering "unknown", ask whether the caller's own // account is in the middle of putting it there (`S-C40`) — the difference between // "never heard of it" and "your other device is still sending it" is the difference - // between a client degrading permanently and a client waiting. - if let Some(upload) = context - .uploads() - .pending_for_address(owner, hash) - .await - .map_err(|error| { - tracing::error!(%error, "the upload sessions could not be asked about an address"); - ServeUnavailable("the upload sessions could not answer".to_owned()) - })? + // between a client degrading permanently and a client waiting. Asked only for an + // account: a peer has no upload here, and offering it the answer would report on + // somebody else's transfer. + if let Some(owner) = principal.own_account() + && let Some(upload) = context + .uploads() + .pending_for_address(owner, hash) + .await + .map_err(|error| { + tracing::error!(%error, "the upload sessions could not be asked about an address"); + ServeUnavailable("the upload sessions could not answer".to_owned()) + })? { tracing::debug!(%upload, "the address is not referenced yet: an upload is in flight"); return Ok(ServeResolution::AwaitingUpload { upload }); @@ -264,14 +295,16 @@ pub async fn resolve( // deletions. match context .authority() - .blob_read_access(owner, &reference) + .blob_read_access(principal, &reference) .await .map_err(|error| { tracing::error!(%error, "the read authority could not decide a blob fetch"); ServeUnavailable("the read authority could not decide".to_owned()) })? { BlobReadAccess::Granted => {} + BlobReadAccess::Revoked => return Ok(ServeResolution::Forbidden), BlobReadAccess::Unrelated => return Ok(ServeResolution::NotFound), + BlobReadAccess::ScopeInsufficient => return Ok(ServeResolution::ScopeInsufficient), } // Moderation takedown (`S-C17`). First among the refusals and **before any read**: a held diff --git a/capsule-server/src/share/mod.rs b/capsule-server/src/share/mod.rs index b29289b2..330e3f77 100644 --- a/capsule-server/src/share/mod.rs +++ b/capsule-server/src/share/mod.rs @@ -13,8 +13,10 @@ //! strip"*. **A key-free server cannot.** The metadata a share serves is ciphertext sealed //! under material the server does not hold — the fragment secret never leaves the client — so //! there is nothing here to read, let alone redact. design/metadata.md is the one that is -//! consistent with the architecture: *"Stripping happens at the moment of export"*, in -//! `capsule_core::metadata::export_policy`, client-side. +//! consistent with the architecture: *"Stripping happens at the moment of export"* — in the +//! issuing client, which is what `S-C50` settled. No strip is implemented anywhere yet: the +//! core module that claimed to be one had no callers and is gone, and no slice owns writing +//! the real one. //! //! So the strip is the **issuing client's**, and what this server enforces is the property that //! makes it stick: a link serves **only the addresses its own record enumerates**, never the diff --git a/capsule-server/src/store/ceremony.rs b/capsule-server/src/store/ceremony.rs index 3f7d6bcb..841f0002 100644 --- a/capsule-server/src/store/ceremony.rs +++ b/capsule-server/src/store/ceremony.rs @@ -31,15 +31,18 @@ //! //! # Single-use is also a property, not a convention //! -//! Two of these three ceremonies are one-shot. The generic store made that the caller's job: +//! Three of these four ceremonies are one-shot. The generic store made that the caller's job: //! `get_temp_data` then `delete_temp_data`, two calls, and a route that forgot the second left -//! a replayable credential. Here the read *is* the removal — [`ChallengeStore::consume`] and -//! [`EnrollmentStore::redeem`] have no non-destructive counterpart, so a replay window cannot be -//! left open by omission. +//! a replayable credential. Here the read *is* the removal — [`ChallengeStore::consume`], +//! [`EnrollmentStore::redeem`] and [`OidcAuthorizationStore::consume`] have no non-destructive +//! counterpart, so a replay window cannot be left open by omission. use jiff::{SignedDuration, Timestamp}; -use super::{ChallengeToken, ChannelId, EnrollmentCode, StoreFuture, UserId}; +use super::{ + ChallengeToken, ChannelId, EnrollmentCode, OidcNonce, OidcState, PkceVerifier, StoreFuture, + UserId, +}; // ------------------------------------------------------------------------------------------- // Revoke-all challenge @@ -272,6 +275,68 @@ pub trait ChannelStore: std::fmt::Debug + Send + Sync { fn close<'a>(&'a self, channel: &'a ChannelId) -> StoreFuture<'a, bool>; } +// ------------------------------------------------------------------------------------------- +// OIDC authorization (slice `S-N1`) +// ------------------------------------------------------------------------------------------- + +/// What the server holds between the two legs of an OIDC authorization-code ceremony. +/// +/// Keyed by the [`OidcState`] the client carries to the identity provider and back. Everything +/// here exists to be checked **once**, at the callback: the nonce against the ID token, the +/// verifier against the token endpoint, and the redirect URI byte-for-byte against the one the +/// authorization request named (RFC 6749 §4.1.3 requires the two to be identical). +/// +/// No `expires_at` field, for the reason [`RevokeAllChallenge`] has none: expiry is the store's, +/// and the route publishes `issued_at + ttl()`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingAuthorization { + /// The nonce the authorization request carried; the ID token must echo it. + pub nonce: OidcNonce, + /// The PKCE verifier whose S256 challenge the authorization request carried. + pub verifier: PkceVerifier, + /// The redirect URI the authorization request named, replayed verbatim to the token + /// endpoint. Client-supplied and allow-listed by the route before it is stored here. + pub redirect_uri: String, + /// When the ceremony began. The route renders `issued_at + ttl()` as the published expiry. + pub issued_at: Timestamp, +} + +/// How long a begun OIDC authorization waits for its callback. +/// +/// Ten minutes: long enough for a person to sign in at an identity provider that asks for a +/// second factor of its own, short enough that a state captured from a URL bar is not worth +/// keeping. The same figure as [`ENROLLMENT_CODE_TTL`], which bounds the same kind of thing — +/// a human completing a ceremony on another surface. +pub const OIDC_AUTHORIZATION_TTL: SignedDuration = SignedDuration::from_mins(10); + +/// Pending OIDC authorizations, keyed by `state`. +/// +/// A ceremony store and not a field on [`AuthStateStore`](super::AuthStateStore), which owns +/// durable session records and the record-plus-index atomicity its conformance suite is built +/// around. A pending authorization is a single-use, short-window ceremony credential — exactly +/// the shape this module exists for. +pub trait OidcAuthorizationStore: std::fmt::Debug + Send + Sync { + /// How long a begun authorization waits for its callback. A property of the ceremony. + fn ttl(&self) -> SignedDuration; + + /// Record a freshly begun authorization under its `state`. + fn begin<'a>( + &'a self, + state: &'a OidcState, + record: PendingAuthorization, + ) -> StoreFuture<'a, ()>; + + /// Burn `state` and return what it holds, or `None` if it is unknown, already consumed, or + /// expired. + /// + /// Destructive on **every** attempt, like [`ChallengeStore::consume`]: that is what makes a + /// replayed `state` — and therefore a replayed authorization code arriving on a stolen + /// redirect — unrepeatable, and it is why the nonce can never be checked twice. Two callbacks + /// racing the same `state` resolve here: one gets the record, the other gets `None`. + fn consume<'a>(&'a self, state: &'a OidcState) + -> StoreFuture<'a, Option>; +} + // ------------------------------------------------------------------------------------------- // WebAuthn ceremonies #[cfg(test)] diff --git a/capsule-server/src/store/cohorts_postgres.rs b/capsule-server/src/store/cohorts_postgres.rs new file mode 100644 index 00000000..a1ce2b43 --- /dev/null +++ b/capsule-server/src/store/cohorts_postgres.rs @@ -0,0 +1,186 @@ +//! [`PostgresCohorts`] — the durable device-cohort map (`S-C13`, #402). +//! +//! # Why this one port in `store` is Postgres and the other five are not +//! +//! Everything else in [`super`] is volatile TTL state whose production adapter is Valkey. The +//! cohort map is the exception the port's own docs argue for: *"A session store forgets a cohort +//! exactly when the 'have I seen this device before?' question becomes worth asking"* — the user +//! reinstalls, gets a new `device_id` by design, and the sessions that carried the old one have +//! long expired. A map that expired with them would answer the question it exists for with +//! "no, never" every time. +//! +//! That is why [`super::conformance`] splits `CohortHarness` out of `Harness`: this adapter +//! implements one port, and a suite that made it implement six to run four cases would make it +//! invent five adapters it will never have. +//! +//! # The whole adapter is one upsert +//! +//! `observe` is `INSERT … ON CONFLICT (user_id, cohort_hash) DO UPDATE SET last_seen = … +//! RETURNING`, and the composite primary key **is** the idempotence the port states: seeing the +//! same cohort twice is one row, not two. A read-then-branch would be the same statement with a +//! race in the middle, and the race is two devices of one account signing in at once. +//! +//! `first_seen` is untouched by the update, which is the half that matters: it is what lets a +//! client say *"a device you've used before (last seen March)"* rather than presenting a +//! stranger. + +use jiff::Timestamp; +use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, Statement, Value}; + +use super::auth::{CohortRecord, CohortStore}; +use super::{StoreFuture, UserId}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, to_micros}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "device-cohorts", + record: "CohortRecord", +}; + +/// The durable device-cohort map. +#[derive(Debug, Clone)] +pub struct PostgresCohorts { + connection: DatabaseConnection, +} + +impl PostgresCohorts { + /// A cohort map over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// Read one `device_cohorts` row back. +fn record_from(row: &sea_orm::QueryResult) -> Result { + let failed = PORT.failing("reading a cohort row"); + let user_id: String = row.try_get("", "user_id").map_err(&failed)?; + let cohort_hash: String = row.try_get("", "cohort_hash").map_err(&failed)?; + let first_seen: i64 = row.try_get("", "first_seen").map_err(&failed)?; + let last_seen: i64 = row.try_get("", "last_seen").map_err(&failed)?; + let instant = |micros: i64| { + from_micros(micros) + .ok_or_else(|| PORT.undecodable(format!("{micros}µs is not a representable instant"))) + }; + Ok(CohortRecord { + user_id: UserId::new(user_id), + cohort_hash, + first_seen: instant(first_seen)?, + last_seen: instant(last_seen)?, + }) +} + +impl CohortStore for PostgresCohorts { + fn observe<'a>( + &'a self, + user: &'a UserId, + cohort_hash: &'a str, + at: Timestamp, + ) -> StoreFuture<'a, CohortRecord> { + Box::pin(async move { + let observed = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO device_cohorts (user_id, cohort_hash, first_seen, last_seen) \ + VALUES ($1, $2, $3, $3) \ + ON CONFLICT (user_id, cohort_hash) \ + DO UPDATE SET last_seen = EXCLUDED.last_seen \ + RETURNING user_id, cohort_hash, first_seen, last_seen, \ + (xmax = 0) AS inserted", + [ + Value::from(user.as_str().to_owned()), + Value::from(cohort_hash.to_owned()), + Value::from(to_micros(at)), + ], + )) + .await + .map_err(PORT.failing("observing a device cohort"))? + .ok_or_else(|| crate::store::StoreError::Rejected { + store: PORT.store, + detail: "the cohort upsert returned no row".to_owned(), + })?; + let record = record_from(&observed)?; + // `xmax = 0` is how an upsert says which half it took: PostgreSQL leaves the + // deleting-transaction id at zero on a freshly inserted tuple and sets it on one the + // `DO UPDATE` rewrote. Derived rather than inferred from `first_seen == last_seen`, + // which is the same thing being said by a coincidence — a device observed twice at + // one instant, which a test clock does routinely, would log a new cohort twice. + let inserted: bool = observed + .try_get("", "inserted") + .map_err(PORT.failing("reading whether a cohort row was new"))?; + if inserted { + tracing::info!(%user, "an account was seen under a new device cohort"); + } + Ok(record) + }) + } + + fn cohorts_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec> { + Box::pin(async move { + // Oldest first sighting first, ties broken by the hash so the order is total. The + // order is part of the contract rather than an accident of the backend: a + // user-visible listing whose order depends on the storage engine is a listing that + // reshuffles itself between page loads. `COLLATE "C"` on the tie-break so the total + // order is the same one the deterministic double's `BTreeMap` produces — a cohort + // hash is client-asserted text, and a locale collation orders it differently. + let found = self + .connection + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT user_id, cohort_hash, first_seen, last_seen FROM device_cohorts \ + WHERE user_id = $1 \ + ORDER BY first_seen, cohort_hash COLLATE \"C\"", + [Value::from(user.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("listing an account's device cohorts"))?; + found.iter().map(record_from).collect() + }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use jiff::SignedDuration; + + use super::super::PostgresCohorts; + use crate::postgres::testing; + use crate::store::conformance::{self, CohortHarness}; + use crate::store::{CohortStore, StoreFuture}; + + /// A harness over one container. + /// + /// `advance` is a **no-op**, and that is the honest implementation rather than a stub: + /// the cohort map has no TTL at all, so there is no clock to move. The one case that + /// calls it — `the_cohort_map_does_not_expire` — asserts a record survives a year, and a + /// store that expires nothing passes it whatever the clock says. + #[derive(Debug)] + struct Harness { + cohorts: PostgresCohorts, + } + + impl CohortHarness for Harness { + fn cohorts(&self) -> &dyn CohortStore { + &self.cohorts + } + + fn advance(&self, _by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async { Ok(()) }) + } + } + + #[tokio::test] + async fn the_postgres_cohort_map_conforms() { + let Some(database) = testing::start("the Postgres device-cohort map").await else { + return; + }; + let harness = Harness { + cohorts: PostgresCohorts::new(database.connection().clone()), + }; + conformance::run_all_cohorts(&harness).await; + } + } +} diff --git a/capsule-server/src/store/conformance.rs b/capsule-server/src/store/conformance.rs index 1103f95f..92ea3ac3 100644 --- a/capsule-server/src/store/conformance.rs +++ b/capsule-server/src/store/conformance.rs @@ -40,11 +40,13 @@ use uuid::Uuid; use super::auth::{AuthStateStore, CohortStore, SessionRecord}; use super::ceremony::{ - ChallengeStore, ChannelStore, Direction, DrainOutcome, EnrollmentStore, PendingEnrollment, - RelayChannel, RelayOutcome, RelayPayload, RevokeAllChallenge, + ChallengeStore, ChannelStore, Direction, DrainOutcome, EnrollmentStore, OidcAuthorizationStore, + PendingAuthorization, PendingEnrollment, RelayChannel, RelayOutcome, RelayPayload, + RevokeAllChallenge, }; use super::ids::{ - AssetId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, UserId, + AssetId, ChallengeToken, ChannelId, EnrollmentCode, OidcNonce, OidcState, OwnerId, + PkceVerifier, SessionId, UploadId, UserId, }; use super::upload::{ AcceptedChunk, BlobRole, FinalizeClaim, UploadSessionRecord, UploadSessionStatus, @@ -52,12 +54,36 @@ use super::upload::{ }; use super::{StoreError, StoreFuture, deadline}; -/// The six stores under test, plus the one thing a suite cannot do through a port: move time. +/// The durable device-cohort map, on its own. /// -/// `advance` is the seam that keeps the suite backend-agnostic. The deterministic double -/// advances a manual clock; a Valkey- or Postgres-backed harness sleeps, or resets its stores -/// with a lifetime short enough to wait out. Either way the cases below are identical. -pub trait Harness: Send + Sync { +/// Split out of [`Harness`] because [`CohortStore`] is the one port in this module that is +/// **not** Valkey's. Everything else here is volatile TTL state — sessions, upload progress, four +/// ceremonies — and the cohort map deliberately outlives all of it: a cohort becomes worth +/// knowing exactly when the sessions that carried it have expired. Its production adapter is +/// therefore Postgres (#402) while the other five are Valkey's (#403), and a Postgres-backed +/// harness that had to implement `auth()`, `uploads()` and three ceremony stores to run four +/// cohort cases would have to invent five adapters it will never have. +/// +/// `advance` rides along rather than staying on [`Harness`], for the same reason: a cohort case +/// asserts the map does *not* expire, and a suite that could not move time could not assert it. +pub trait CohortHarness: Send + Sync { + /// The durable device-cohort map under test. + fn cohorts(&self) -> &dyn CohortStore; + + /// Move every store in this harness `by` forward in its own time. + /// + /// The seam that keeps the suite backend-agnostic. The deterministic double advances a + /// manual clock; a Valkey- or Postgres-backed harness sleeps, or resets its stores with a + /// lifetime short enough to wait out — and for a store with no TTL at all it is legitimately + /// a no-op, because there is nothing to move. + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; +} + +/// The five volatile stores under test, plus the time seam it inherits. +/// +/// `Harness` extends [`CohortHarness`] rather than restating `advance`, so one deterministic +/// double still implements both and [`run_all`] still runs every case in this module against it. +pub trait Harness: CohortHarness { /// The authentication-state store under test. fn auth(&self) -> &dyn AuthStateStore; /// The upload-session store under test. @@ -68,11 +94,16 @@ pub trait Harness: Send + Sync { fn enrollments(&self) -> &dyn EnrollmentStore; /// The enrollment relay-channel store under test. fn channels(&self) -> &dyn ChannelStore; - /// The durable device-cohort map under test. - fn cohorts(&self) -> &dyn CohortStore; - - /// Move every store in this harness `by` forward in its own time. - fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; + /// The pending OIDC authorization store under test (slice `S-N1`), if this harness has one. + /// + /// Optional **for now**, and the default is the whole reason: the port landed with its + /// in-memory adapter while the Valkey adapter is owed, and a required accessor would stop a + /// container-backed harness compiling until it exists. The rows that read it panic on + /// `None` when driven individually and are skipped by [`run_all`]; the slice that lands the + /// Valkey adapter removes the `Option` and the skip with it. + fn oidc_authorizations(&self) -> Option<&dyn OidcAuthorizationStore> { + None + } } /// Unwrap a store result, failing with the operation that was expected to work. @@ -144,6 +175,28 @@ fn upload(case: &str, tag: &str, uploader: &str, offset: i64) -> UploadSessionRe } } +/// The OIDC authorization store, or a failure naming what the harness lacks. +fn oidc_authorizations(h: &dyn Harness) -> &dyn OidcAuthorizationStore { + match h.oidc_authorizations() { + Some(store) => store, + None => { + panic!( + "this harness offers no OidcAuthorizationStore; see Harness::oidc_authorizations" + ) + } + } +} + +/// A pending authorization for `case`, begun `offset` seconds after [`base`]. +fn pending(case: &str, offset: i64) -> PendingAuthorization { + PendingAuthorization { + nonce: OidcNonce::new(format!("{case}-nonce")), + verifier: PkceVerifier::new(format!("{case}-verifier")), + redirect_uri: format!("http://127.0.0.1:4242/{case}"), + issued_at: deadline(base(), SignedDuration::from_secs(offset)), + } +} + // =========================================================================================== // AuthStateStore // =========================================================================================== @@ -697,6 +750,55 @@ pub async fn finalization_is_claimed_exactly_once(h: &dyn Harness) { ); } +/// A claimed session is no longer an eviction candidate — the promise the claim makes. +/// +/// The winner finalizes from the record it was handed and must not have the bytes discarded +/// out from under it by the pressure sweep. `WaitingForProcessing` is in flight for every other +/// purpose, so this is asserted through the eviction view specifically. Works in its own band +/// of progress time, below every other case's, and clears up after itself. +pub async fn a_claimed_session_leaves_the_eviction_view(h: &dyn Harness) { + let store = h.uploads(); + let record = upload("claimed", "a", "uploader", -20_000); + ok(store.open(record.clone()).await, "open"); + + let horizon = deadline(base(), SignedDuration::from_secs(-19_000)); + assert_eq!( + ok( + store.least_recently_progressed(horizon, 10).await, + "least_recently_progressed" + ), + vec![record.upload_id.clone()], + "an unclaimed, stalled session is a candidate" + ); + + match ok( + store.claim_finalize(&record.upload_id).await, + "claim_finalize", + ) { + FinalizeClaim::Won(_) => {} + other => panic!("the first claim must win, got {other:?}"), + } + assert!( + ok( + store.least_recently_progressed(horizon, 10).await, + "least_recently_progressed" + ) + .is_empty(), + "a claimed session has left the eviction view" + ); + assert_eq!( + present( + ok(store.read(&record.upload_id).await, "read"), + "the session" + ) + .status, + UploadSessionStatus::WaitingForProcessing, + "and is still in flight for every other purpose" + ); + + ok(store.discard(&record.upload_id).await, "discard"); +} + /// The startup scrub sets the byte counter absolutely and does not fake progress. pub async fn reconciling_received_bytes_does_not_move_the_progress_clock(h: &dyn Harness) { let store = h.uploads(); @@ -973,6 +1075,59 @@ pub async fn a_challenge_expires_with_its_store(h: &dyn Harness) { ); } +/// A begun authorization is redeemed by the first callback, successful or not. +/// +/// The property that makes a replayed `state` — and therefore a replayed authorization code +/// on a stolen redirect — unrepeatable, and the reason the nonce can never be checked twice. +pub async fn an_oidc_authorization_is_single_use(h: &dyn Harness) { + let store = oidc_authorizations(h); + let state = OidcState::new("oidc-single-use"); + let record = pending("oidc-single-use", 0); + ok( + store.begin(&state, record.clone()).await, + "begin an authorization", + ); + + assert_eq!( + ok(store.consume(&state).await, "consume"), + Some(record), + "the first callback gets the record, every field intact" + ); + assert_eq!( + ok(store.consume(&state).await, "consume again"), + None, + "a consumed state cannot be replayed" + ); + assert_eq!( + ok( + store.consume(&OidcState::new("oidc-never-begun")).await, + "consume unknown" + ), + None, + "an unknown state is indistinguishable from a spent one" + ); +} + +/// A pending authorization dies at its store's TTL, with no caller involved. +pub async fn an_oidc_authorization_expires_with_its_store(h: &dyn Harness) { + let store = oidc_authorizations(h); + let state = OidcState::new("oidc-expiry"); + ok( + store.begin(&state, pending("oidc-expiry", 0)).await, + "begin an authorization", + ); + + ok( + h.advance(store.ttl()).await, + "advance to the authorization TTL", + ); + assert_eq!( + ok(store.consume(&state).await, "consume at the deadline"), + None, + "an authorization is gone at its TTL, and the expired record is burned with it" + ); +} + /// An enrollment redeems under either spelling, and redeeming burns both. pub async fn an_enrollment_redeems_by_either_spelling_and_burns_both(h: &dyn Harness) { let store = h.enrollments(); @@ -1242,7 +1397,7 @@ pub async fn closing_a_channel_drops_both_mailboxes(h: &dyn Harness) { // ------------------------------------------------------------------------------------------- /// A cohort is a fact about a device, not an event: seeing it twice is one row. -pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn Harness) { +pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn CohortHarness) { let user = UserId::new("cohort-user-1"); let at = Timestamp::UNIX_EPOCH; @@ -1272,7 +1427,7 @@ pub async fn observing_a_cohort_twice_is_one_row_that_moves_last_seen(h: &dyn Ha } /// Cohorts are listed oldest first sighting first, and the order is total. -pub async fn cohorts_are_listed_oldest_first(h: &dyn Harness) { +pub async fn cohorts_are_listed_oldest_first(h: &dyn CohortHarness) { let user = UserId::new("cohort-user-2"); let base = Timestamp::UNIX_EPOCH; for (hash, hours) in [ @@ -1298,7 +1453,7 @@ pub async fn cohorts_are_listed_oldest_first(h: &dyn Harness) { } /// A cohort is scoped to its account, and the hash folds the account in besides. -pub async fn a_cohort_is_scoped_to_its_account(h: &dyn Harness) { +pub async fn a_cohort_is_scoped_to_its_account(h: &dyn CohortHarness) { let mine = UserId::new("cohort-user-3"); let theirs = UserId::new("cohort-user-4"); ok( @@ -1316,7 +1471,7 @@ pub async fn a_cohort_is_scoped_to_its_account(h: &dyn Harness) { } /// The cohort map does not expire with the sessions that carried it. -pub async fn the_cohort_map_does_not_expire(h: &dyn Harness) { +pub async fn the_cohort_map_does_not_expire(h: &dyn CohortHarness) { // The one store in this module with no TTL, and deliberately: a cohort is worth recording // precisely because it outlives the sessions that named it. A map that expired with them // would forget exactly when "have I seen this device before?" starts being worth asking. @@ -1364,6 +1519,7 @@ pub async fn run_all(h: &dyn Harness) { recording_progress_advances_bytes_clock_and_replay_together(h).await; chunk_replay_is_offset_addressed(h).await; finalization_is_claimed_exactly_once(h).await; + a_claimed_session_leaves_the_eviction_view(h).await; reconciling_received_bytes_does_not_move_the_progress_clock(h).await; a_terminal_session_is_not_an_eviction_candidate(h).await; discarding_removes_the_record_its_chunks_and_its_listing(h).await; @@ -1378,6 +1534,24 @@ pub async fn run_all(h: &dyn Harness) { relaying_requires_a_live_channel(h).await; relayed_payloads_drain_in_order_and_by_direction(h).await; closing_a_channel_drops_both_mailboxes(h).await; + + // Skipped, not failed, for a harness without the store — see `Harness::oidc_authorizations`. + // These run here rather than in `run_all_cohorts` because the accessor is on `Harness`: the + // OIDC ceremony store is one of the volatile five's kind, not the cohort map's. + if h.oidc_authorizations().is_some() { + an_oidc_authorization_is_single_use(h).await; + an_oidc_authorization_expires_with_its_store(h).await; + } + + run_all_cohorts(h).await; +} + +/// Run every [`CohortHarness`] case above, in order. +/// +/// A second entry point rather than a subset of [`run_all`], because the cohort map's adapter is +/// a different backend from the other five stores' — so the two suites are run against different +/// harnesses, and only the deterministic double is both. +pub async fn run_all_cohorts(h: &dyn CohortHarness) { observing_a_cohort_twice_is_one_row_that_moves_last_seen(h).await; cohorts_are_listed_oldest_first(h).await; a_cohort_is_scoped_to_its_account(h).await; diff --git a/capsule-server/src/store/ids.rs b/capsule-server/src/store/ids.rs index 7d1013dd..a79e2d8e 100644 --- a/capsule-server/src/store/ids.rs +++ b/capsule-server/src/store/ids.rs @@ -124,6 +124,41 @@ secret_id! { EnrollmentCode } +secret_id! { + /// The `state` an OIDC authorization request carries (slice `S-N1`). + /// + /// The key to one pending authorization: whoever presents it at the callback redeems the + /// nonce and PKCE verifier it names, so it is a bearer credential for the length of the + /// ceremony and is burned on the first presentation, successful or not. + OidcState +} + +secret_id! { + /// The `nonce` an OIDC authorization request carries and the ID token must echo. + /// + /// Not a bearer secret in the strict sense — it travels in the authorization URL — but a + /// predictable one would let a captured ID token be replayed against a fresh ceremony, so + /// it is generated with the same entropy as the state and kept out of logs with it. + OidcNonce +} + +secret_id! { + /// The PKCE `code_verifier` (RFC 7636) held server-side between the two legs of an OIDC + /// authorization-code ceremony. + /// + /// The one value that turns an intercepted authorization code into nothing: the token + /// endpoint refuses a code presented without the verifier its challenge was derived from. + PkceVerifier +} + +secret_id! { + /// The authorization code an identity provider hands back through the client's redirect. + /// + /// Single-use at the provider, and worthless without the [`PkceVerifier`] — but a code in a + /// log line is still half of a credential, so it redacts itself like the rest. + AuthorizationCode +} + #[cfg(test)] mod tests { use super::*; diff --git a/capsule-server/src/store/memory.rs b/capsule-server/src/store/memory.rs index f4d7f100..96bfbde0 100644 --- a/capsule-server/src/store/memory.rs +++ b/capsule-server/src/store/memory.rs @@ -24,11 +24,13 @@ use jiff::{SignedDuration, Timestamp}; use super::auth::{AuthStateStore, CohortRecord, CohortStore, DEFAULT_SESSION_TTL, SessionRecord}; use super::ceremony::{ CHALLENGE_TTL, ChallengeStore, ChannelStore, Direction, DrainOutcome, ENROLLMENT_CODE_TTL, - EnrollmentStore, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, - RelayPayload, RevokeAllChallenge, + EnrollmentStore, OIDC_AUTHORIZATION_TTL, OidcAuthorizationStore, PendingAuthorization, + PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, RelayPayload, + RevokeAllChallenge, }; use super::ids::{ - AlbumId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, UserId, + AlbumId, ChallengeToken, ChannelId, EnrollmentCode, OidcState, OwnerId, SessionId, UploadId, + UserId, }; use super::upload::{ AcceptedChunk, FinalizeClaim, LIFETIME_CAP, UploadSessionRecord, UploadSessionStatus, @@ -625,7 +627,7 @@ impl UploadSessionStore for InMemoryUploadSessions { .values() .map(|entry| &entry.record) .filter(|record| { - record.status.is_active() && record.last_progress_at < not_progressed_since + record.status.is_evictable() && record.last_progress_at < not_progressed_since }) .collect(); candidates.sort_by(|a, b| { @@ -783,6 +785,112 @@ impl ChallengeStore for InMemoryChallenges { } } +/// How many pending OIDC authorizations the in-memory store will hold at once. +/// +/// Ten thousand: at the ten-minute TTL that is a thousand begun-and-abandoned ceremonies a +/// minute before anything is refused, which is far beyond a self-hosted deployment's sign-in +/// rate and well inside the memory a record of four short strings costs. The ceiling exists so +/// that a caller who begins ceremonies without ever finishing them grows this map to a bound and +/// not to the heap; the Valkey adapter (#460) gets the same property from the TTL alone. +pub const PENDING_AUTHORIZATION_CEILING: usize = 10_000; + +/// In-memory [`OidcAuthorizationStore`] (slice `S-N1`). +/// +/// Expired records are purged on every `begin`, so the map holds live ceremonies plus whatever +/// expired since the last one — never everything ever begun — and a full map answers +/// [`StoreError::Rejected`], which the route renders as a `503`. +#[derive(Debug)] +pub struct InMemoryOidcAuthorizations { + clock: Arc, + ttl: SignedDuration, + ceiling: usize, + state: Mutex>>, +} + +impl InMemoryOidcAuthorizations { + /// A store on `clock` with the given authorization lifetime and the default ceiling. + pub fn new(clock: Arc, ttl: SignedDuration) -> Self { + Self { + clock, + ttl, + ceiling: PENDING_AUTHORIZATION_CEILING, + state: Mutex::new(BTreeMap::new()), + } + } + + /// A store on `clock` with the [`OIDC_AUTHORIZATION_TTL`]. + pub fn with_default_ttl(clock: Arc) -> Self { + Self::new(clock, OIDC_AUTHORIZATION_TTL) + } + + /// The same store holding at most `ceiling` pending ceremonies. + #[must_use] + pub fn with_ceiling(mut self, ceiling: usize) -> Self { + self.ceiling = ceiling; + self + } + + /// Drop every record past its deadline. + fn purge(state: &mut BTreeMap>, now: Timestamp) { + state.retain(|_, entry| entry.is_live_at(now)); + } +} + +impl OidcAuthorizationStore for InMemoryOidcAuthorizations { + fn ttl(&self) -> SignedDuration { + self.ttl + } + + fn begin<'a>( + &'a self, + state: &'a OidcState, + record: PendingAuthorization, + ) -> StoreFuture<'a, ()> { + Box::pin(async move { + let now = self.clock.now(); + let mut held = lock(&self.state); + Self::purge(&mut held, now); + if held.len() >= self.ceiling && !held.contains_key(state) { + tracing::warn!( + pending = held.len(), + ceiling = self.ceiling, + "the pending OIDC authorization store is full; a ceremony was refused" + ); + return Err(StoreError::Rejected { + store: "oidc authorizations", + detail: format!("{} pending ceremonies is the ceiling", self.ceiling), + }); + } + held.insert( + state.clone(), + Entry { + record, + expires_at: deadline(now, self.ttl), + }, + ); + tracing::debug!("recorded a pending OIDC authorization"); + Ok(()) + }) + } + + fn consume<'a>( + &'a self, + state: &'a OidcState, + ) -> StoreFuture<'a, Option> { + Box::pin(async move { + let now = self.clock.now(); + let mut held = lock(&self.state); + // Burned on every attempt, live or not: a replayed `state` finds nothing. + let taken = held.remove(state).filter(|entry| entry.is_live_at(now)); + tracing::debug!( + hit = taken.is_some(), + "consumed a pending OIDC authorization" + ); + Ok(taken.map(|entry| entry.record)) + }) + } +} + /// In-memory [`EnrollmentStore`]. /// /// Both spellings index the same record and are inserted and removed together, so one @@ -1053,6 +1161,7 @@ pub struct InMemoryStores { channels: InMemoryChannels, /// The one store here with no TTL and no clock — see [`InMemoryCohorts`]. cohorts: InMemoryCohorts, + oidc_authorizations: InMemoryOidcAuthorizations, } impl InMemoryStores { @@ -1065,6 +1174,7 @@ impl InMemoryStores { CHALLENGE_TTL, ENROLLMENT_CODE_TTL, RELAY_CHANNEL_TTL, + OIDC_AUTHORIZATION_TTL, ) } @@ -1074,9 +1184,13 @@ impl InMemoryStores { /// one operation rather than five, and it is legitimate precisely because the TTL is a /// property of the *store instance* — varying it is configuration, not a per-call argument. pub fn with_uniform_ttl(ttl: SignedDuration) -> Self { - Self::with_ttl(ManualClock::default(), ttl, ttl, ttl, ttl, ttl) + Self::with_ttl(ManualClock::default(), ttl, ttl, ttl, ttl, ttl, ttl) } + #[allow( + clippy::too_many_arguments, + reason = "one lifetime per store, named in the order the stores are declared" + )] fn with_ttl( clock: ManualClock, session: SignedDuration, @@ -1084,6 +1198,7 @@ impl InMemoryStores { challenge: SignedDuration, enrollment: SignedDuration, channel: SignedDuration, + oidc_authorization: SignedDuration, ) -> Self { let shared: Arc = Arc::new(clock.clone()); Self { @@ -1093,6 +1208,10 @@ impl InMemoryStores { enrollments: InMemoryEnrollments::new(Arc::clone(&shared), enrollment), channels: InMemoryChannels::new(Arc::clone(&shared), channel), cohorts: InMemoryCohorts::new(), + oidc_authorizations: InMemoryOidcAuthorizations::new( + Arc::clone(&shared), + oidc_authorization, + ), clock, } } @@ -1109,6 +1228,19 @@ impl Default for InMemoryStores { } } +impl super::conformance::CohortHarness for InMemoryStores { + fn cohorts(&self) -> &dyn CohortStore { + &self.cohorts + } + + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { + Box::pin(async move { + self.clock.advance(by); + Ok::<(), StoreError>(()) + }) + } +} + impl super::conformance::Harness for InMemoryStores { fn auth(&self) -> &dyn AuthStateStore { &self.auth @@ -1126,19 +1258,12 @@ impl super::conformance::Harness for InMemoryStores { &self.enrollments } - fn cohorts(&self) -> &dyn CohortStore { - &self.cohorts - } - fn channels(&self) -> &dyn ChannelStore { &self.channels } - fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { - Box::pin(async move { - self.clock.advance(by); - Ok::<(), StoreError>(()) - }) + fn oidc_authorizations(&self) -> Option<&dyn OidcAuthorizationStore> { + Some(&self.oidc_authorizations) } } @@ -1185,6 +1310,7 @@ mod tests { recording_progress_advances_bytes_clock_and_replay_together, chunk_replay_is_offset_addressed, finalization_is_claimed_exactly_once, + a_claimed_session_leaves_the_eviction_view, reconciling_received_bytes_does_not_move_the_progress_clock, a_terminal_session_is_not_an_eviction_candidate, discarding_removes_the_record_its_chunks_and_its_listing, @@ -1198,6 +1324,32 @@ mod tests { relaying_requires_a_live_channel, relayed_payloads_drain_in_order_and_by_direction, closing_a_channel_drops_both_mailboxes, + an_oidc_authorization_is_single_use, + an_oidc_authorization_expires_with_its_store, + } + + /// The same, for the cases that take a [`conformance::CohortHarness`]. + /// + /// A second macro because the cohort map is a different port with a different production + /// backend, so its cases take the narrower harness — and one `#[tokio::test]` each here + /// rather than only inside `run_all`, which is what makes a cohort failure name the property + /// that broke. + macro_rules! cohort_conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + conformance::$case(&harness()).await; + } + )+ + }; + } + + cohort_conformance_cases! { + observing_a_cohort_twice_is_one_row_that_moves_last_seen, + cohorts_are_listed_oldest_first, + a_cohort_is_scoped_to_its_account, + the_cohort_map_does_not_expire, } /// The whole suite, in one pass on one harness. @@ -1226,6 +1378,10 @@ mod tests { ENROLLMENT_CODE_TTL ); assert_eq!(ChannelStore::ttl(&stores.channels), RELAY_CHANNEL_TTL); + assert_eq!( + OidcAuthorizationStore::ttl(&stores.oidc_authorizations), + OIDC_AUTHORIZATION_TTL + ); assert_ne!( CHALLENGE_TTL, ENROLLMENT_CODE_TTL, "a ceremony's window belongs to what it is; if these ever coincide by accident \ @@ -1233,6 +1389,52 @@ mod tests { ); } + /// A full OIDC ceremony store refuses, and expired ceremonies never count against it. + #[tokio::test] + async fn a_full_oidc_store_refuses_until_its_ceremonies_expire() { + use super::super::ceremony::{OidcAuthorizationStore, PendingAuthorization}; + use super::super::ids::{OidcNonce, OidcState, PkceVerifier}; + + let clock = ManualClock::default(); + let store = + InMemoryOidcAuthorizations::new(Arc::new(clock.clone()), SignedDuration::from_mins(10)) + .with_ceiling(2); + let pending = |tag: &str| PendingAuthorization { + nonce: OidcNonce::new(format!("{tag}-nonce")), + verifier: PkceVerifier::new(format!("{tag}-verifier")), + redirect_uri: "http://127.0.0.1:1/cb".to_owned(), + issued_at: clock.now(), + }; + store + .begin(&OidcState::new("a"), pending("a")) + .await + .expect("room"); + store + .begin(&OidcState::new("b"), pending("b")) + .await + .expect("room"); + assert!( + matches!( + store.begin(&OidcState::new("c"), pending("c")).await, + Err(StoreError::Rejected { .. }) + ), + "the third is refused at a ceiling of two" + ); + // The expired ones are purged on the next begin, so the refusal is not permanent. + clock.advance(SignedDuration::from_mins(10)); + store + .begin(&OidcState::new("c"), pending("c")) + .await + .expect("the expired ceremonies made room"); + assert!( + store + .consume(&OidcState::new("a")) + .await + .expect("answers") + .is_none() + ); + } + /// The manual clock only moves when a test moves it. #[test] fn the_manual_clock_is_deterministic() { diff --git a/capsule-server/src/store/mod.rs b/capsule-server/src/store/mod.rs index 23b33ae1..4ff93872 100644 --- a/capsule-server/src/store/mod.rs +++ b/capsule-server/src/store/mod.rs @@ -36,12 +36,27 @@ //! //! # Adapters, and what they are for //! -//! Three adapters are planned per port — Postgres, Valkey, and a deterministic in-memory one — -//! and **three adapters are not three deployment modes**. Valkey is required; the server -//! refuses to boot without `VALKEY_URL` (design/filesystem/server.md, "Required Services"). -//! The in-memory adapter in [`memory`] is a **test double**, never a deployment profile. The -//! rejected alternative was a Postgres fallback removing Valkey, which would mean emulating -//! TTL and expiry in SQL — the generic TTL abstraction this slice exists to delete. +//! **Two** adapters per port, and which two is a property of the port rather than a menu. +//! The five volatile stores — [`AuthStateStore`], [`UploadSessionStore`] and the three ceremony +//! stores — get the Valkey adapter in [`valkey`] and the deterministic in-memory double in +//! [`memory`], and nothing else: Valkey is required, the server refuses to boot without +//! `VALKEY_URL` (design/filesystem/server.md, "Required Services"), and [`crate::boot`] connects +//! to it and proves it answers `PING` before anything else is assembled. The rejected +//! alternative was a Postgres fallback removing Valkey, which would mean emulating TTL and +//! expiry in SQL — the generic TTL abstraction this slice exists to delete. The in-memory +//! adapter is a **test double**, never a deployment profile. +//! +//! [`CohortStore`] is the exception and gets **Postgres** and the double, because it is the one +//! record here that deliberately outlives the sessions that carried it (see [`auth`]); its +//! adapter is [`cohorts_postgres::PostgresCohorts`]. [`valkey::ValkeyCohorts`] also exists — a +//! Valkey hash with no expiry, written as the interim home while the Postgres adapter was owed — +//! and `PostgresCohorts` supersedes it: it is the adapter [`crate::boot`] will bind when the +//! durable arm can be assembled at all (#446), and the Valkey one is kept only so +//! `tests/valkey.rs` can drive the whole port set on one connection. +//! +//! An earlier version of this paragraph said three adapters were planned per port. That was +//! never true of any port in this module and was the one line in the tree pointing at a +//! Postgres session table. //! //! Whichever adapter is in play, it must pass the one shared suite in [`conformance`]. That //! suite is what makes "the in-memory adapter behaves like Valkey" an assertion rather than an @@ -57,10 +72,12 @@ pub mod auth; pub mod ceremony; +pub mod cohorts_postgres; pub mod conformance; pub mod ids; pub mod memory; pub mod upload; +pub mod valkey; use std::fmt; use std::future::Future; @@ -71,12 +88,14 @@ use jiff::{SignedDuration, Timestamp}; pub use self::auth::{AuthStateStore, CohortRecord, CohortStore, SessionRecord}; pub use self::ceremony::{ CHALLENGE_TTL, ChallengeStore, ChannelStore, Direction, DrainOutcome, ENROLLMENT_CODE_TTL, - EnrollmentStore, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, - RelayPayload, RevokeAllChallenge, + EnrollmentStore, OIDC_AUTHORIZATION_TTL, OidcAuthorizationStore, PendingAuthorization, + PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, RelayPayload, + RevokeAllChallenge, }; +pub use self::cohorts_postgres::PostgresCohorts; pub use self::ids::{ - AlbumId, AssetId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, - UserId, + AlbumId, AssetId, AuthorizationCode, ChallengeToken, ChannelId, EnrollmentCode, OidcNonce, + OidcState, OwnerId, PkceVerifier, SessionId, UploadId, UserId, }; pub use self::upload::{ AcceptedChunk, BlobRole, FinalizeClaim, UploadSessionRecord, UploadSessionStatus, diff --git a/capsule-server/src/store/upload.rs b/capsule-server/src/store/upload.rs index 61d42bb2..47e5e17b 100644 --- a/capsule-server/src/store/upload.rs +++ b/capsule-server/src/store/upload.rs @@ -86,6 +86,17 @@ impl UploadSessionStatus { matches!(self, Self::Completed | Self::FailedProcessing) } + /// Whether pressure eviction may still pick the session — `Pending` or `Uploading`. + /// + /// Narrower than [`Self::is_active`], and the one predicate every adapter's + /// [`UploadSessionStore::least_recently_progressed`] applies: a `WaitingForProcessing` + /// session is in flight for every other purpose, but the finalize claim that moved it there + /// is the promise that it will not be evicted out from under the finalizer (upload-protocol + /// design doc, the finalization claim). + pub fn is_evictable(self) -> bool { + matches!(self, Self::Pending | Self::Uploading) + } + /// The token the `X-Capsule-Upload-Status` response header carries. pub fn as_str(self) -> &'static str { match self { @@ -235,8 +246,10 @@ pub trait UploadSessionStore: std::fmt::Debug + Send + Sync { /// Move a live session to `status`, returning the updated record. /// - /// A terminal status also drops the session from [`Self::least_recently_progressed`], so - /// pressure eviction cannot pick a session whose bytes are already committed. + /// A status that is not [`UploadSessionStatus::is_evictable`] — a terminal one, or + /// `WaitingForProcessing` — also drops the session from [`Self::least_recently_progressed`], + /// so pressure eviction cannot pick a session whose bytes are already committed or being + /// committed. fn set_status<'a>( &'a self, upload: &'a UploadId, @@ -291,12 +304,15 @@ pub trait UploadSessionStore: std::fmt::Debug + Send + Sync { /// mid-write at the cutover. fn in_flight_for_album<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, u64>; - /// Up to `limit` active sessions that have not progressed since `not_progressed_since`, - /// least recently progressed first. + /// Up to `limit` evictable sessions — `Pending` or `Uploading`, see + /// [`UploadSessionStatus::is_evictable`] — that have not progressed since + /// `not_progressed_since`, least recently progressed first. /// /// The eviction *policy* — the ≥1-hour survival floor, when pressure is high enough to - /// discard at all — belongs to the caller; the store only orders candidates. Terminal - /// sessions are never returned. + /// discard at all — belongs to the caller; the store only orders candidates. Neither a + /// terminal session nor a claimed one is ever returned: the first has nothing left to + /// evict, the second is being finalized and [`Self::claim_finalize`] promised it would not + /// be evicted out from under that. fn least_recently_progressed( &self, not_progressed_since: Timestamp, diff --git a/capsule-server/src/store/valkey.rs b/capsule-server/src/store/valkey.rs new file mode 100644 index 00000000..e15c404d --- /dev/null +++ b/capsule-server/src/store/valkey.rs @@ -0,0 +1,2536 @@ +//! The Valkey adapters — the required backend behind every state port (slice `S-C29`'s owed +//! half, #403). +//! +//! # Shape +//! +//! One struct per port, mirroring [`super::memory`], sharing one [`Valkey`] handle: a single +//! `redis::aio::ConnectionManager`, which is a multiplexed, self-reconnecting connection. There +//! is no pool. A server talking to one Valkey needs exactly one connection that survives a +//! restart of the other side, and that is what the manager is. +//! +//! # Every multi-key mutation is one Lua script +//! +//! A multiplexed connection cannot hold `WATCH`: the optimistic transaction needs an exclusive +//! connection across the whole read-then-`MULTI` loop, which is the read-then-write window the +//! ports exist to close (`claim_finalize`, the counter's `hit`, `consume`, `redeem`). So every +//! operation that touches more than one key, or decides and writes, is a script — `EVALSHA` +//! with an automatic `SCRIPT LOAD` on `NOSCRIPT`, which is what `redis::Script` does — and the +//! server runs it atomically. The scripts are the whole of the CAS story here; each is a `const` +//! beside the method that invokes it. +//! +//! # Expiry: the injected clock is the contract, `PEXPIRE` is the collector +//! +//! Every record hash carries an adapter-internal `expires_at` written from the injected +//! [`Clock`] at the moment the record is opened, and every script that reads a record checks +//! it before answering: a record past it is reported absent. Valkey's own `PEXPIRE` is set on +//! the same key with the same lifetime and is what actually removes it. +//! +//! The check is **non-destructive**, deliberately. A replica whose clock runs ahead would +//! otherwise delete, for every other replica, state that is still live by the store's own +//! lifetime. For the direct-read scripts (`READ_RECORD`, `READ_UPLOAD`, `CHUNK_AT`, +//! `IS_LIVE`, `LOOKUP_CHANNEL` and the mutations guarded by `live`) a false "not live" verdict +//! costs that replica one early miss and nobody else anything. The index-listing scripts +//! (`SESSIONS_FOR_USER`, `UPLOADS_FOR_UPLOADER`, `PENDING_FOR_ADDRESS`, `IN_FLIGHT_FOR_ALBUM`, +//! `LEAST_RECENTLY_PROGRESSED`) are the residual: they `SREM`/`ZREM` a member whose record they +//! judge dead, and the index is **shared**, so a fast clock on one replica hides a still-live +//! record from every replica's listings until a state change re-indexes it or the record +//! expires for real. The record itself is untouched — a direct read on a well-clocked replica +//! still finds it — and the exposure is bounded by the clock skew, which is why this is the +//! accepted cost of not requiring synchronised clocks (the #403 decision record, decision 12) +//! rather than a reason to let a reader delete. So one fact, one collector, and a read gate +//! that only ever answers. The port's `Clock` seam is what makes expiry *testable*: +//! the [`conformance`](super::conformance) suite advances a manual clock to one nanosecond +//! either side of a boundary and expects a different answer on each side, which no harness can +//! arrange against a real clock by sleeping. Gating on the injected clock lets the same suite +//! drive this adapter exactly as it drives the double, with no sleeps and no margins, and in +//! production the gate and the collector read the same wall clock. +//! +//! # Indexes are derived, and heal on read +//! +//! The per-user session set, the per-uploader set, the per-album set, the pending-address set +//! and the global progress sorted-set are all derived from the record hashes. Every listing +//! resolves each member through its record inside the script and drops — `SREM`/`ZREM` — any +//! member whose record is gone or no longer matches. That is what makes +//! `an_expired_session_leaves_no_listing_entry_behind` hold without a second lifetime on the +//! index: the two independent TTLs the Salvo adapter kept were exactly what leaked. A heal is +//! logged at `warn`, because in production it is the signal that TTL and index have drifted. +//! +//! # Keys +//! +//! `capsule:` throughout, keeping the retired server's namespace: +//! +//! | key | type | lifetime | +//! |---|---|---| +//! | `capsule:session:{sid}` | hash | session TTL | +//! | `capsule:user_sessions:{uid}` | set | refreshed to the session TTL on every open | +//! | `capsule:upload:session:{id}` | hash | lifetime cap | +//! | `capsule:upload:chunks:{id}` | hash (`offset` → chunk) | the record's remaining TTL | +//! | `capsule:upload:uploader:{uid}` | set | refreshed on open | +//! | `capsule:upload:album:{album}` | set | refreshed on open | +//! | `capsule:upload:pending:{owner}:{hash}` | set | refreshed on open | +//! | `capsule:upload:progress` | zset (score = `last_progress_at` µs) | none — heals only when the pressure sweep (`least_recently_progressed`) runs | +//! | `capsule:challenge:{token}` | hash | challenge TTL | +//! | `capsule:enroll:code:{spelling}` | hash, written under both spellings | code TTL | +//! | `capsule:enroll:channel:{id}` | hash | channel TTL | +//! | `capsule:enroll:mbox:{id}:{a\|b}` | list | the channel's remaining TTL | +//! | `capsule:cohorts:{uid}` | hash (`cohort_hash` → `first last`) | **none** | +//! +//! Scripts derive a few of these keys from a record they have just read (`SREM` the previous +//! user's index on close, for instance) rather than taking them as `KEYS`. That is legal on a +//! standalone server, which is the only topology this adapter supports; Redis Cluster is out of +//! scope by the #403 decision record. +//! +//! # Encoding +//! +//! One hash field per record field; timestamps as RFC 3339 (`jiff` round-trips them +//! nanosecond-exact); enums by their existing `as_str()`; `expires_at` and sorted-set scores as +//! integer microseconds, because a Lua number is a double and microseconds stay exact in one +//! until the year 2255 while nanoseconds do not. That is a floor: a record expires at the +//! microsecond its nanosecond deadline falls in, and two progress instants inside one +//! microsecond order by upload id (the exact `<` against the record's own timestamp is applied +//! in Rust where a horizon is compared). `PEXPIRE` takes whole milliseconds, rounded **up**, so +//! the collector never removes a record before its logical lifetime has passed. A field that +//! will not parse is [`StoreError::Corrupt`], which is the variant that exists for exactly this. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use jiff::{SignedDuration, Timestamp}; +use redis::aio::{ConnectionManager, ConnectionManagerConfig}; +use redis::{ + ErrorKind, FromRedisValue, RedisError, Script, ScriptInvocation, ServerErrorKind, Value, +}; +use uuid::Uuid; + +use super::auth::{AuthStateStore, CohortRecord, CohortStore, DEFAULT_SESSION_TTL, SessionRecord}; +use super::ceremony::{ + CHALLENGE_TTL, ChallengeStore, ChannelStore, Direction, DrainOutcome, ENROLLMENT_CODE_TTL, + EnrollmentStore, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, + RelayPayload, RevokeAllChallenge, +}; +use super::ids::{ + AlbumId, AssetId, ChallengeToken, ChannelId, EnrollmentCode, OwnerId, SessionId, UploadId, + UserId, +}; +use super::upload::{ + AcceptedChunk, BlobRole, FinalizeClaim, LIFETIME_CAP, UploadSessionRecord, UploadSessionStatus, + UploadSessionStore, +}; +use super::{Clock, StoreError, StoreFuture, deadline}; + +// =========================================================================================== +// The connection +// =========================================================================================== + +/// How long one command may take before the adapter reports the store unavailable. +/// +/// A hung Valkey must surface as [`StoreError::Unavailable`] — which every consumer treats as a +/// refusal — rather than as a request that never answers. +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long one connection attempt may take. +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// How many times a lost connection is retried, with exponential backoff, before a command +/// fails. Also the number of attempts the initial connection gets, so a server started a +/// moment before its Valkey (compose ordering) does not refuse for that alone. +const RECONNECT_ATTEMPTS: usize = 3; + +/// One Valkey server, reached over one multiplexed connection. +/// +/// `Clone` is a refcount bump: every adapter holds a clone of the same manager. +#[derive(Clone)] +pub struct Valkey { + manager: ConnectionManager, +} + +impl fmt::Debug for Valkey { + /// Never the URL: a `VALKEY_URL` carries the password. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Valkey()") + } +} + +impl Valkey { + /// Connect to `url` (`redis://` or `rediss://`, TLS in rustls) and prove it answers `PING`. + /// + /// # Errors + /// + /// [`StoreError::Unavailable`] if the URL is not one this adapter can open or the server + /// does not answer within the retry budget. The detail never quotes the URL. + pub async fn connect(url: &str) -> Result { + const STORE: &str = "valkey"; + let client = redis::Client::open(url).map_err(|_| StoreError::Unavailable { + store: STORE, + detail: "VALKEY_URL is not a redis:// or rediss:// URL this adapter can open" + .to_owned(), + })?; + let config = ConnectionManagerConfig::new() + .set_number_of_retries(RECONNECT_ATTEMPTS) + .set_min_delay(Duration::from_millis(200)) + .set_max_delay(Duration::from_secs(2)) + .set_connection_timeout(Some(CONNECTION_TIMEOUT)) + .set_response_timeout(Some(RESPONSE_TIMEOUT)); + // Nothing was sent yet, whatever the driver says went wrong, so this is the one place + // every failure is `Unavailable`. + let manager = ConnectionManager::new_with_config(client, config) + .await + .map_err(|error| StoreError::Unavailable { + store: STORE, + detail: error.to_string(), + })?; + let valkey = Self { manager }; + let pong: String = valkey.command(STORE, redis::cmd("PING")).await?; + if pong != "PONG" { + return Err(StoreError::Rejected { + store: STORE, + detail: format!("PING was answered with {pong:?}"), + }); + } + tracing::info!("connected to Valkey"); + Ok(valkey) + } + + /// Run one plain command. + pub(crate) async fn command( + &self, + store: &'static str, + cmd: redis::Cmd, + ) -> Result { + let mut connection = self.manager.clone(); + cmd.query_async(&mut connection) + .await + .map_err(|error| classify(store, "command", error)) + } + + /// Run one script, `EVALSHA` first and `SCRIPT LOAD` on a `NOSCRIPT`. + async fn eval( + &self, + store: &'static str, + script: &Lua, + keys: &[String], + args: &[String], + ) -> Result { + let mut invocation = script.script().prepare_invoke(); + for key in keys { + invocation.key(key.as_str()); + } + for arg in args { + invocation.arg(arg.as_str()); + } + self.invoke(store, &invocation).await + } + + /// Run one prepared script invocation, `EVALSHA` first and `SCRIPT LOAD` on a `NOSCRIPT`. + pub(crate) async fn invoke( + &self, + store: &'static str, + invocation: &ScriptInvocation<'_>, + ) -> Result { + let mut connection = self.manager.clone(); + invocation + .invoke_async(&mut connection) + .await + .map_err(|error| classify(store, "script", error)) + } +} + +/// Sort a driver error into the port's three failure modes. +/// +/// The line that matters is between *the operation certainly did not happen* and *the server +/// was reached and refused, or may have run it*: a caller that gets [`StoreError::Unavailable`] +/// may retry, one that gets [`StoreError::Rejected`] must not assume anything about state. So +/// only a failure the driver can place **before** the command was sent — a connection it could +/// not open, a server that answered `LOADING`/`TRYAGAIN`/`MASTERDOWN`/`CLUSTERDOWN` without +/// executing — is `Unavailable`. A response timeout or a connection dropped mid-flight means +/// the script may already have burned the challenge or won the claim, and that is `Rejected`. +/// +/// A reply of the wrong shape is [`StoreError::Corrupt`]. Its detail carries the driver's +/// *kind* and never its text: redis-rs quotes the offending value in a type error, and for the +/// ceremony stores that value is the record, which carries the bearer secret the typed ids +/// redact from every other log line. +fn classify(store: &'static str, what: &'static str, error: RedisError) -> StoreError { + // `NOSCRIPT` belongs here too: the server declined to run a script it does not hold, and + // `redis::Script` normally answers it with a `SCRIPT LOAD` and a retry. Reaching this point + // means that retry failed as well, and nothing was executed either time. + let never_sent = error.is_connection_refusal() + || matches!( + error.kind(), + ErrorKind::Server( + ServerErrorKind::BusyLoading + | ServerErrorKind::TryAgain + | ServerErrorKind::MasterDown + | ServerErrorKind::ClusterDown + | ServerErrorKind::NoScript + ) | ErrorKind::ClusterConnectionNotFound + ); + if never_sent { + tracing::warn!(store, what, %error, "the Valkey store is unavailable"); + return StoreError::Unavailable { + store, + detail: error.to_string(), + }; + } + if matches!( + error.kind(), + ErrorKind::Parse | ErrorKind::UnexpectedReturnType + ) { + let kind = format!("{:?}", error.kind()); + tracing::error!( + store, + what, + kind, + "the Valkey store answered a shape it should not" + ); + return StoreError::Corrupt { + store, + record: what, + detail: format!("the reply could not be decoded ({kind})"), + }; + } + if error.is_io_error() || error.is_timeout() || error.is_connection_dropped() { + tracing::warn!(store, what, %error, "the Valkey connection failed mid-operation"); + } else { + tracing::error!(store, what, %error, "the Valkey store rejected an operation"); + } + StoreError::Rejected { + store, + detail: error.to_string(), + } +} + +/// Log a listing's self-heal. A member whose record is simply gone is the routine consequence of +/// an index outliving its expired members and is `debug`; one whose record is present but names +/// a different owner is drift between record and index, which is the `warn` the module doc +/// promises. +fn healed(store: &'static str, scope: &str, gone: u64, mismatched: u64) { + if gone > 0 { + tracing::debug!(store, scope, gone, "expired index entries were reclaimed"); + } + if mismatched > 0 { + tracing::warn!( + store, + scope, + mismatched, + "stale index entries were reclaimed" + ); + } +} + +// =========================================================================================== +// Encoding +// =========================================================================================== + +/// Microseconds since the epoch, the unit every Lua comparison and sorted-set score uses. +pub(crate) fn micros(at: Timestamp) -> i64 { + at.as_microsecond() +} + +/// A microsecond count back to a [`Timestamp`], for a value a script computed. +pub(crate) fn from_micros( + store: &'static str, + record: &'static str, + value: i64, +) -> Result { + Timestamp::from_microsecond(value).map_err(|error| StoreError::Corrupt { + store, + record, + detail: format!("microsecond timestamp {value} is out of range: {error}"), + }) +} + +/// The flat `field value field value …` list `HSET` takes and `HGETALL` returns. +#[derive(Debug, Default)] +struct Encoder(Vec); + +impl Encoder { + fn field(&mut self, name: &str, value: impl fmt::Display) -> &mut Self { + self.0.push(name.to_owned()); + self.0.push(value.to_string()); + self + } + + fn optional(&mut self, name: &str, value: Option) -> &mut Self { + if let Some(value) = value { + self.field(name, value); + } + self + } + + fn finish(self) -> Vec { + self.0 + } +} + +/// One record's hash, as read back, with typed accessors that fail as [`StoreError::Corrupt`]. +struct Fields { + store: &'static str, + record: &'static str, + map: BTreeMap, +} + +impl Fields { + fn from_flat( + store: &'static str, + record: &'static str, + flat: Vec, + ) -> Result { + if !flat.len().is_multiple_of(2) { + return Err(StoreError::Corrupt { + store, + record, + detail: format!("a hash came back with {} entries", flat.len()), + }); + } + let mut map = BTreeMap::new(); + let mut items = flat.into_iter(); + while let (Some(name), Some(value)) = (items.next(), items.next()) { + map.insert(name, value); + } + Ok(Self { store, record, map }) + } + + fn corrupt(&self, detail: String) -> StoreError { + tracing::error!( + store = self.store, + record = self.record, + %detail, + "a stored record could not be decoded" + ); + StoreError::Corrupt { + store: self.store, + record: self.record, + detail, + } + } + + fn required(&self, name: &str) -> Result<&str, StoreError> { + self.map + .get(name) + .map(String::as_str) + .ok_or_else(|| self.corrupt(format!("field `{name}` is missing"))) + } + + fn optional(&self, name: &str) -> Option { + self.map.get(name).cloned() + } + + fn timestamp(&self, name: &str) -> Result { + let text = self.required(name)?; + text.parse() + .map_err(|error| self.corrupt(format!("field `{name}` is not a timestamp: {error}"))) + } + + fn number(&self, name: &str) -> Result + where + T::Err: fmt::Display, + { + let text = self.required(name)?; + text.parse() + .map_err(|error| self.corrupt(format!("field `{name}` is not a number: {error}"))) + } +} + +fn encode_session(record: &SessionRecord, expires_at: Timestamp) -> Vec { + let mut encoder = Encoder::default(); + encoder + .field("session_id", &record.session_id) + .field("user_id", &record.user_id) + .field("created_at", record.created_at) + .field("authenticated_at", record.authenticated_at) + .field("last_active_at", record.last_active_at) + .optional("user_agent", record.user_agent.as_deref()) + .optional("ip_address", record.ip_address.as_deref()) + .optional("cohort_hash", record.cohort_hash.as_deref()) + .optional("device_id", record.device_id) + .field("expires_at", micros(expires_at)); + encoder.finish() +} + +fn decode_session(flat: Vec) -> Result { + let fields = Fields::from_flat(AUTH, "SessionRecord", flat)?; + let device_id = match fields.optional("device_id") { + Some(text) => Some( + Uuid::parse_str(&text) + .map_err(|error| fields.corrupt(format!("device_id is not a uuid: {error}")))?, + ), + None => None, + }; + Ok(SessionRecord { + session_id: SessionId::new(fields.required("session_id")?), + user_id: UserId::new(fields.required("user_id")?), + created_at: fields.timestamp("created_at")?, + authenticated_at: fields.timestamp("authenticated_at")?, + last_active_at: fields.timestamp("last_active_at")?, + user_agent: fields.optional("user_agent"), + ip_address: fields.optional("ip_address"), + cohort_hash: fields.optional("cohort_hash"), + device_id, + }) +} + +fn parse_role(text: &str) -> Option { + [ + BlobRole::Original, + BlobRole::Derivative, + BlobRole::Metadata, + BlobRole::Provenance, + BlobRole::Backup, + ] + .into_iter() + .find(|role| role.as_str() == text) +} + +fn parse_status(text: &str) -> Option { + [ + UploadSessionStatus::Pending, + UploadSessionStatus::Uploading, + UploadSessionStatus::WaitingForProcessing, + UploadSessionStatus::Completed, + UploadSessionStatus::FailedProcessing, + ] + .into_iter() + .find(|status| status.as_str() == text) +} + +fn encode_upload(record: &UploadSessionRecord, expires_at: Timestamp) -> Vec { + let mut encoder = Encoder::default(); + encoder + .field("upload_id", &record.upload_id) + .field("asset_id", &record.asset_id) + .field("owner_id", &record.owner_id) + .field("upload_user_id", &record.upload_user_id) + .optional("album_id", record.album_id.as_ref()) + .optional("content_type", record.content_type.as_deref()) + .field("expected_hash", &record.expected_hash) + .field("crypto_suite_id", record.crypto_suite_id) + .field("protocol_version", &record.protocol_version) + .field("blob_role", record.blob_role.as_str()) + .optional("intent_id", record.intent_id.as_deref()) + .field("manifest_envelope", &record.manifest_envelope) + .field("received_bytes", record.received_bytes) + .field("total_size", record.total_size) + .field("status", record.status.as_str()) + .field("created_at", record.created_at) + .field("last_progress_at", record.last_progress_at) + .field("progress_score", micros(record.last_progress_at)) + .field("expires_at", micros(expires_at)); + encoder.finish() +} + +fn decode_upload(flat: Vec) -> Result { + let fields = Fields::from_flat(UPLOADS, "UploadSessionRecord", flat)?; + let blob_role = parse_role(fields.required("blob_role")?) + .ok_or_else(|| fields.corrupt("blob_role is not a known role".to_owned()))?; + let status = parse_status(fields.required("status")?) + .ok_or_else(|| fields.corrupt("status is not a known status".to_owned()))?; + Ok(UploadSessionRecord { + upload_id: UploadId::new(fields.required("upload_id")?), + asset_id: AssetId::new(fields.required("asset_id")?), + owner_id: OwnerId::new(fields.required("owner_id")?), + upload_user_id: UserId::new(fields.required("upload_user_id")?), + album_id: fields.optional("album_id").map(AlbumId::new), + content_type: fields.optional("content_type"), + expected_hash: fields.required("expected_hash")?.to_owned(), + crypto_suite_id: fields.number("crypto_suite_id")?, + protocol_version: fields.required("protocol_version")?.to_owned(), + blob_role, + intent_id: fields.optional("intent_id"), + manifest_envelope: fields.required("manifest_envelope")?.to_owned(), + received_bytes: fields.number("received_bytes")?, + total_size: fields.number("total_size")?, + status, + created_at: fields.timestamp("created_at")?, + last_progress_at: fields.timestamp("last_progress_at")?, + }) +} + +/// `chunk_hash \t next_offset \t accepted_at`: the hash is hex and a timestamp has no tab. +fn encode_chunk(chunk: &AcceptedChunk) -> String { + format!( + "{}\t{}\t{}", + chunk.chunk_hash, chunk.next_offset, chunk.accepted_at + ) +} + +fn decode_chunk(offset: u64, packed: &str) -> Result { + let corrupt = |detail: String| StoreError::Corrupt { + store: UPLOADS, + record: "AcceptedChunk", + detail, + }; + let mut parts = packed.split('\t'); + let chunk_hash = parts + .next() + .ok_or_else(|| corrupt("no chunk hash".to_owned()))? + .to_owned(); + let next_offset = parts + .next() + .ok_or_else(|| corrupt("no next offset".to_owned()))? + .parse() + .map_err(|error| corrupt(format!("next offset is not a number: {error}")))?; + let accepted_at = parts + .next() + .ok_or_else(|| corrupt("no accepted_at".to_owned()))? + .parse() + .map_err(|error| corrupt(format!("accepted_at is not a timestamp: {error}")))?; + Ok(AcceptedChunk { + offset, + chunk_hash, + next_offset, + accepted_at, + }) +} + +// =========================================================================================== +// Keys +// =========================================================================================== + +/// Port names, for the log line and the error. +const AUTH: &str = "auth"; +const UPLOADS: &str = "uploads"; +const CHALLENGES: &str = "challenges"; +const ENROLLMENTS: &str = "enrollments"; +const CHANNELS: &str = "channels"; +const COHORTS: &str = "cohorts"; + +/// The global eviction view. +const PROGRESS_KEY: &str = "capsule:upload:progress"; + +fn session_key(session: &SessionId) -> String { + format!("capsule:session:{session}") +} + +fn user_sessions_key(user: &UserId) -> String { + format!("capsule:user_sessions:{user}") +} + +fn upload_key(upload: &UploadId) -> String { + format!("capsule:upload:session:{upload}") +} + +fn chunks_key(upload: &UploadId) -> String { + format!("capsule:upload:chunks:{upload}") +} + +fn uploader_key(user: &UserId) -> String { + format!("capsule:upload:uploader:{user}") +} + +fn album_key(album: &AlbumId) -> String { + format!("capsule:upload:album:{album}") +} + +/// The one key with two variable parts, which is why the port's ids must contain no `:` — a +/// precondition the scripts that rebuild this key from a record (`SET_STATUS`, `DISCARD_UPLOAD`, +/// `OPEN_UPLOAD`) cannot check for themselves. Owner ids are UUIDs and the hash is hex, so the +/// assertion is a guard against a future id space, not a live case. +fn pending_key(owner: &OwnerId, expected_hash: &str) -> String { + debug_assert!( + !owner.as_str().contains(':') && !expected_hash.contains(':'), + "an owner id or a content hash must contain no `:`; it is a key segment" + ); + format!("capsule:upload:pending:{owner}:{expected_hash}") +} + +fn challenge_key(token: &ChallengeToken) -> String { + format!("capsule:challenge:{}", token.as_str()) +} + +fn enrollment_key(code: &EnrollmentCode) -> String { + format!("capsule:enroll:code:{}", code.as_str()) +} + +fn channel_key(channel: &ChannelId) -> String { + format!("capsule:enroll:channel:{channel}") +} + +fn mailbox_key(channel: &ChannelId, direction: Direction) -> String { + format!("capsule:enroll:mbox:{channel}:{}", direction.as_str()) +} + +fn cohorts_key(user: &UserId) -> String { + format!("capsule:cohorts:{user}") +} + +// =========================================================================================== +// Scripts +// =========================================================================================== + +/// One Lua script: its source, and the `redis::Script` (SHA-1 for `EVALSHA`) built on first use. +/// +/// The source is kept beside the script so a unit test can assert that every key a script +/// derives from a record it read spells the same prefix the Rust key functions write. +pub(crate) struct Lua { + source: &'static str, + script: OnceLock