diff --git a/Cargo.lock b/Cargo.lock index 575bda5e..b3ea930b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -761,7 +761,10 @@ dependencies = [ "jiff", "jsonwebtoken", "kynos", + "rcgen", + "reqwest", "ring", + "rustls", "secrecy", "serde", "serde_json", @@ -769,6 +772,7 @@ dependencies = [ "tempfile", "thiserror 2.0.20", "tokio", + "tokio-rustls", "totp-rs", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index ffdbdaf4..6ed21c01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,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/SLICES.md b/SLICES.md index ce7d3266..8e83616c 100644 --- a/SLICES.md +++ b/SLICES.md @@ -349,8 +349,8 @@ row's remainder now lives. | 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-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 | | @@ -4887,6 +4887,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 +4914,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 diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 4c874840..75c2c626 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1827,6 +1827,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. @@ -1856,6 +1864,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 +1876,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. @@ -1902,6 +1912,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. 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-docs/src/content/docs/design/api-surfaces.md b/capsule-docs/src/content/docs/design/api-surfaces.md index b3d664da..9a29b1fe 100644 --- a/capsule-docs/src/content/docs/design/api-surfaces.md +++ b/capsule-docs/src/content/docs/design/api-surfaces.md @@ -123,12 +123,46 @@ 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 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/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 548962bc..ba1698bb 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -25,14 +25,15 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | 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. | — | +| 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-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`. | | 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. | diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 318b9021..e9223171 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1836,6 +1836,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.", @@ -1865,6 +1873,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 +1885,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.", @@ -1911,6 +1921,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.", diff --git a/capsule-i18n/src/generated.rs b/capsule-i18n/src/generated.rs index a71cd1ac..1813048d 100644 --- a/capsule-i18n/src/generated.rs +++ b/capsule-i18n/src/generated.rs @@ -62,6 +62,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"; @@ -149,6 +173,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 +209,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"; @@ -287,6 +317,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"; diff --git a/capsule-sdk/src/albums.rs b/capsule-sdk/src/albums.rs index 5e969203..4067a94d 100644 --- a/capsule-sdk/src/albums.rs +++ b/capsule-sdk/src/albums.rs @@ -99,6 +99,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, diff --git a/capsule-sdk/src/auth.rs b/capsule-sdk/src/auth.rs index 6c7db931..f8b8259c 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))) } @@ -799,18 +1048,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) +} - match status.as_u16() { - 401 => endpoint.unauthorized_error(), +/// 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()) +} + +/// 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 +1090,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 +1842,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..bf32b4a1 100644 --- a/capsule-sdk/src/client.rs +++ b/capsule-sdk/src/client.rs @@ -46,7 +46,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, @@ -126,12 +129,11 @@ 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. fn reqwest_client() -> reqwest::Client { - reqwest::Client::builder() - .build() - .expect("a default rustls reqwest client is always constructible") + crate::net::http_client().expect("a default rustls reqwest client is always constructible") } #[cfg(test)] @@ -154,6 +156,8 @@ mod tests { struct Recorded { path: String, authorization: Option, + protocol: Option, + crypto_suite: Option, } struct MockResponse { @@ -248,6 +252,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 +326,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 +389,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(); @@ -398,7 +448,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/net.rs b/capsule-sdk/src/net.rs index 4bc3bd7f..8f7589ff 100644 --- a/capsule-sdk/src/net.rs +++ b/capsule-sdk/src/net.rs @@ -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/sync.rs b/capsule-sdk/src/sync.rs index 02035411..eb0feb79 100644 --- a/capsule-sdk/src/sync.rs +++ b/capsule-sdk/src/sync.rs @@ -527,14 +527,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,6 +598,9 @@ 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. rest::SyncFeedError::Status400(problem) | rest::SyncFeedError::Status401(problem) | rest::SyncFeedError::Status403(problem) 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 index 8fa7239c..21f85e51 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -111,12 +111,47 @@ VALKEY_URL=redis://127.0.0.1:6379 # 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, both published, and both default to the version `capsule-core` speaks. -# Widen `PROTOCOL_MIN` only with a deprecation announcement behind it. -# PROTOCOL_MIN=2026-05-31 -# PROTOCOL_MAX=2026-05-31 +# 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 ──────────────────────────────────────────────────────────────────────── # diff --git a/capsule-server/Cargo.toml b/capsule-server/Cargo.toml index c60c08da..68ff30a8 100644 --- a/capsule-server/Cargo.toml +++ b/capsule-server/Cargo.toml @@ -124,6 +124,12 @@ subtle = { workspace = true } # 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 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` @@ -158,9 +164,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. diff --git a/capsule-server/compose.yaml b/capsule-server/compose.yaml index 60013ef1..8fbb9b4e 100644 --- a/capsule-server/compose.yaml +++ b/capsule-server/compose.yaml @@ -71,6 +71,65 @@ services: 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/openapi.json b/capsule-server/openapi.json index 3e80f9f5..cb055eca 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": { + "429": { + "description": "Too many attempts", + "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}$" } - } - } - }, - "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}$" } - } - } - }, - "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": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/DrainResponse" } } } }, - "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": { + "X-Capsule-Min-Client-Build": { + "description": "The semver 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", + "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,6 +8651,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": { @@ -5196,6 +8687,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": { @@ -5205,98 +8722,62 @@ } }, "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "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/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "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": { + }, + "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": { + "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}$" } - } - } - }, - "201": { - "description": "A drop session is open and accepting chunks", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest 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-Min-Client-Build": { + "description": "The semver client build below which this server will 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": { @@ -5305,18 +8786,34 @@ } } }, - "403": { - "description": "Passphrase required", - "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]+$" } } - } - }, - "409": { - "description": "Link capacity exhausted", + }, "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,38 +9834,10345 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] + "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": [] + } + ] + }, + "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/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/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": "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" + } + } + } + }, + "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. 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", + "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" + } + } + } + }, + "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": { - "VersionResponse": { - "properties": { - "name": { - "type": "string", - "description": "The server package name." - }, - "version": { - "type": "string", - "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." - }, "RegisterRequest": { "properties": { "email": { @@ -5989,113 +20367,31 @@ ], "description": "What a global sign-out closed." }, - "SessionView": { + "ReauthenticateRequest": { "properties": { - "session_id": { - "type": "string", - "description": "The session's identifier — what a revoke names." - }, - "created_at": { - "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\"." - }, - "authenticated_at": { - "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." - }, - "last_active_at": { + "password": { "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 `User-Agent` the opening ceremony carried, if any." - }, - "ip_address": { - "type": [ - "string", - "null" - ], - "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." + "description": "The account's password." } }, "type": "object", "required": [ - "session_id", - "created_at", - "authenticated_at", - "last_active_at", - "current" + "password" ], - "description": "One live session." + "description": "A password, re-presented on a session that already exists." }, - "CohortView": { + "ReauthenticateResponse": { "properties": { - "cohort_hash": { - "type": "string", - "description": "The advisory hash." - }, - "first_seen": { - "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." - }, - "last_seen": { + "authenticated_at": { "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." - }, - "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\"." + "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": [ - "sessions", - "cohorts" + "authenticated_at" ], - "description": "The session ledger." + "description": "When the re-authenticated session's freshness window last opened." }, "PublishDirectoryResponse": { "properties": { @@ -6130,31 +20426,18 @@ ], "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": { + "UpdateProfileRequest": { "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." + "display_name": { + "type": [ + "string", + "null" + ], + "description": "The display name to set, clear (`null`), or leave alone (absent)." } }, "type": "object", - "required": [ - "authenticated_at" - ], - "description": "When the re-authenticated session's freshness window last opened." + "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": { @@ -6180,24 +20463,11 @@ }, "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": { - "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." + "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": { @@ -6275,6 +20545,76 @@ ], "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`)." }, + "OidcAuthorizeRequest": { + "properties": { + "redirect_uri": { + "type": "string", + "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 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`." + }, + "state": { + "type": "string", + "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 the ceremony stops being redeemable." + } + }, + "type": "object", + "required": [ + "authorization_url", + "state", + "expires_by" + ], + "description": "A begun ceremony: where to send the person, and the `state` that comes back." + }, + "OidcCallbackRequest": { + "properties": { + "state": { + "type": "string", + "description": "The `state` the authorize answered with, as the redirect echoed it." + }, + "code": { + "type": "string", + "description": "The authorization `code` the redirect carried." + }, + "cohort_hash": { + "type": [ + "string", + "null" + ], + "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. Dropped, not\nrefused, when it is not a usable UUID." + } + }, + "type": "object", + "required": [ + "state", + "code" + ], + "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." + }, "EnrollmentCodeResponse": { "properties": { "code": { @@ -6347,22 +20687,6 @@ ], "description": "One relayed payload." }, - "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." - }, "ProvisionAlbumRequest": { "properties": { "album_id": { @@ -6403,607 +20727,880 @@ "properties": { "album_id": { "type": "string", - "description": "The album, echoed." + "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." + }, + "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": { + "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": { + "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." + }, + "ciphertext_hash": { + "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." + }, + "key_mode": { + "type": "string", + "description": "`derived` or `wrapped`." + }, + "metadata_blob_hash": { + "type": [ + "string", + "null" + ], + "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." + }, + "original_blob_hash": { + "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." + }, + "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 provenance chain position this write continues from." + }, + "retention_until": { + "type": [ + "string", + "null" + ], + "description": "The retention floor the manifest carries, when it carries one." + } + }, + "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" + ], + "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." + }, + "CreateUploadRequest": { + "properties": { + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The ciphertext length in bytes. Immutable for the session's life." + }, + "hash": { + "type": "string", + "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." + }, + "content_type": { + "type": "string", + "description": "The media type, from the closed enum this protocol version fixes." + }, + "crypto_suite_id": { + "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." }, - "intent_id": { + "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 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." + "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." }, - "to_protocol_version": { + "owner_id": { "type": [ "string", "null" ], - "description": "The protocol version the fork will be pinned to, when a ceremony is in flight." + "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." }, - "expires_at": { + "intent_id": { "type": [ "string", "null" ], - "description": "When the window closes, RFC 3339, on the **server's** clock." + "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": [ + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "blob_role", + "manifest_envelope" + ], + "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." + }, + "CreateUploadResponse": { + "properties": { + "id": { + "type": "string", + "description": "The session's identifier." }, - "in_flight": { + "upload_url": { + "type": "string", + "description": "Where to send chunks." + }, + "suggested_chunk_size": { "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": "A starting chunk size. A suggestion only — the client owns adaptation." } }, "type": "object", "required": [ - "album_id", - "in_flight" + "id", + "upload_url", + "suggested_chunk_size" ], - "description": "The ceremony this album is in, as a client polls it." + "description": "What a client needs to start sending bytes." }, - "QuotaResponse": { + "OpRequest": { "properties": { - "used": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Bytes charged to the caller." + "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." }, - "soft_limit": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "Where the warning starts, or absent on an unlimited deployment." + "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`." }, - "hard_limit": { + "metadata_blob": { "type": [ - "integer", + "string", "null" ], - "minimum": 0.0, - "format": "uint64", - "description": "Where uploads stop, or absent on an unlimited deployment." - }, - "state": { - "type": "string", - "description": "The classified state: `ok`, `soft_warning`, `hard_exceeded`, `grace_expired`." + "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": [ - "used", - "state" + "manifest_envelope", + "manifest_cbor" ], - "description": "A user's quota snapshot." + "description": "The signed manifest bundle a lifecycle write carries." }, - "ModerationEventResponse": { + "OpResponse": { "properties": { - "action": { + "asset_id": { "type": "string", - "description": "What was done: `suspended`, `reinstated`, `taken_down`, `legal_hold`, `hold_lifted`." + "description": "The asset the op chained onto." }, - "asset_id": { - "type": [ - "string", - "null" - ], - "description": "The asset, when the action was about one rather than about the account." + "sync_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The feed position it occupies. On a replay, the position the *first* application took." }, - "at": { + "action": { "type": "string", - "description": "When it happened, RFC 3339." + "description": "The action that was applied." }, - "reason": { - "type": [ - "string", - "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." + "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", - "at" + "replayed" ], - "description": "One thing that was done to the account." + "description": "What a lifecycle write did." }, - "ModerationRecordResponse": { + "AssetVerifyRequest": { "properties": { - "standing": { + "asset_id": { "type": "string", - "description": "`active` or `suspended`." - }, - "suspended_since": { - "type": [ - "string", - "null" - ], - "description": "When a suspension began, RFC 3339. Absent while the account is active." + "description": "The asset." }, - "events": { + "blob_hashes": { "items": { - "$ref": "#/components/schemas/ModerationEventResponse" + "type": "string" }, "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." + "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": [ - "standing", - "events" + "asset_id", + "blob_hashes" ], - "description": "The caller's moderation record." + "description": "One asset to verify, with the exact copies the client is relying on." }, - "PublishedKeyResponse": { + "StorageVerifyRequest": { "properties": { - "key_id": { - "type": "string", - "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." + "assets": { + "items": { + "$ref": "#/components/schemas/AssetVerifyRequest" + }, + "type": "array", + "description": "The assets to verify." }, - "public": { + "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": [ + "assets" + ], + "description": "The `POST /v1/storage/verify` body." + }, + "BlobVerdictResponse": { + "properties": { + "hash": { "type": "string", - "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." + "description": "The address, as the client declared it." }, - "algorithm": { + "role": { "type": "string", - "description": "The signature algorithm this key is used with." + "description": "The role the asset holds it under — `unknown` for a hash the asset does not hold." }, - "active_from": { - "type": "string", - "description": "When it began signing, RFC 3339." + "stored": { + "type": "boolean", + "description": "The bytes are present at that address." }, - "active_to": { + "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": "When it stopped, or absent while it is the active key." + "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": [ - "key_id", - "public", - "algorithm", - "active_from" + "hash", + "role", + "stored", + "indexed", + "retrievable" ], - "description": "One published attestation key." + "description": "One declared blob's verdict." }, - "AttestationKeysResponse": { + "StorageVerdictResponse": { "properties": { - "server_id": { + "asset_id": { "type": "string", - "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." + "description": "The asset the client asked about." }, - "keys": { + "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": { + "$ref": "#/components/schemas/BlobVerdictResponse" + }, + "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." + } + }, + "type": "object", + "required": [ + "asset_id", + "durable", + "blobs", + "checked_at" + ], + "description": "One asset's verdict." + }, + "StorageVerifyResponse": { + "properties": { + "verdicts": { "items": { - "$ref": "#/components/schemas/PublishedKeyResponse" + "$ref": "#/components/schemas/StorageVerdictResponse" }, "type": "array", - "description": "Every key this server has signed with, oldest first, the active one last." + "description": "One verdict per requested asset, in request order." } }, "type": "object", "required": [ - "server_id", - "keys" + "verdicts" ], - "description": "The `.well-known/capsule/attestation-keys` record." + "description": "The `POST /v1/storage/verify` response." }, - "AuthEndpointsResponse": { + "IssueShareRequest": { "properties": { - "login": { + "opaque_id": { "type": "string", - "description": "Where a session is opened." + "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." }, - "refresh": { + "metadata_hash": { "type": "string", - "description": "Where an access token is rotated." + "description": "The metadata blob a viewer starts from. Must appear in `serves`." }, - "logout": { - "type": "string", - "description": "Where a session is ended." + "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." + }, + "wrapped_secret": { + "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." + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "When the link stops being live, RFC 3339. Absent means no expiry." } }, "type": "object", "required": [ - "login", - "refresh", - "logout" + "opaque_id", + "metadata_hash", + "serves" ], - "description": "The auth ceremony's endpoints." + "description": "A link the owner's client has issued." }, - "ProtocolWindowResponse": { + "IssueShareResponse": { "properties": { - "min": { - "type": "string", - "description": "The oldest version still accepted for writes." - }, - "max": { + "opaque_id": { "type": "string", - "description": "The newest version this server speaks." + "description": "The opaque id, echoed." } }, "type": "object", "required": [ - "min", - "max" + "opaque_id" ], - "description": "The accepted `protocol_version` range." + "description": "Confirmation that a link is now servable." }, - "DeprecationResponse": { + "ProvisionLinkRequest": { "properties": { - "min_protocol_version": { + "opaque_id": { "type": "string", - "description": "The lowest `protocol_version` that remains accepted after the cutoff." + "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." }, - "announced_at": { + "drop_pubkey": { "type": "string", - "description": "When the announcement was first published, RFC 3339." + "description": "The Drop Key's public half, base64. Opaque here — the server never decapsulates." }, - "cutoff": { - "type": "string", - "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The suite a drop must be sealed under." }, - "detail_url": { + "expires_at": { "type": [ "string", "null" ], - "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." - }, - "api_base_url": { - "type": "string", - "description": "Where the versioned API lives." - }, - "auth": { - "$ref": "#/components/schemas/AuthEndpointsResponse", - "description": "Where a client performs the auth ceremony." + "description": "When the link stops accepting drops, RFC 3339." }, - "federation_url": { + "max_total_bytes": { "type": [ - "string", + "integer", "null" ], - "description": "Where federated peers talk to this server. Absent when it does not federate." + "minimum": 0.0, + "format": "uint64", + "description": "Cumulative bytes across every drop on this link." }, - "protocol_version": { - "$ref": "#/components/schemas/ProtocolWindowResponse", - "description": "The `protocol_version` range accepted for writes today, both ends inclusive." + "max_file_count": { + "type": [ + "integer", + "null" + ], + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "How many files the link may deposit." }, - "signing_key": { - "type": "string", - "description": "The raw Ed25519 public key this server's tokens verify under, base64." + "max_file_size": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "The largest single file." }, - "signing_algorithm": { - "type": "string", - "description": "The signature algorithm that key is used with." + "single_use": { + "type": "boolean", + "description": "Whether the link dies after its first successful drop." }, - "deprecations": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Announced deprecation cutoffs, in announcement order. Empty when none is pending." + "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." } }, "type": "object", "required": [ - "server_id", - "api_base_url", - "auth", - "protocol_version", - "signing_key", - "signing_algorithm", - "deprecations" + "opaque_id", + "drop_pubkey", + "crypto_suite_id" ], - "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." + "description": "A link the owner is provisioning." }, - "DeprecationsResponse": { + "ProvisionLinkResponse": { "properties": { - "announcements": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Every announced cutoff, in announcement order." + "opaque_id": { + "type": "string", + "description": "The opaque id, echoed." } }, "type": "object", "required": [ - "announcements" + "opaque_id" ], - "description": "The `.well-known/capsule/deprecation` record." + "description": "Confirmation that a link is live." }, - "RevokedTokenResponse": { + "AdoptRequest": { "properties": { - "jti": { + "album_id": { "type": "string", - "description": "The token's `jti` claim." + "description": "The album to adopt into." }, - "expires_at": { - "type": "string", - "description": "The token's own `exp`, RFC 3339. After this the entry is pruned." - } - }, - "type": "object", - "required": [ - "jti", - "expires_at" - ], - "description": "One revoked capability token." - }, - "RevokedJtiResponse": { - "properties": { - "generated_at": { + "asset_id": { "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 asset the drop becomes." }, - "max_staleness_seconds": { + "size": { "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." + "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." + }, + "content_type": { + "type": "string", + "description": "The declared content type." }, - "revoked": { - "items": { - "$ref": "#/components/schemas/RevokedTokenResponse" - }, - "type": "array", - "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." - } - }, - "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" - ], - "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": { "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." + "description": "The crypto suite." }, "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." + "description": "The protocol version the manifest is written against." }, - "file_id": { + "key_mode": { "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": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." }, - "ciphertext_hash": { + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The signed manifest envelope, verbatim." + } + }, + "type": "object", + "required": [ + "album_id", + "asset_id", + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "key_mode", + "manifest_envelope" + ], + "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." + }, + "AdoptResponse": { + "properties": { + "asset_id": { "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`)." + "description": "The asset the drop became." + } + }, + "type": "object", + "required": [ + "asset_id" + ], + "description": "What adoption produced." + }, + "SessionView": { + "properties": { + "session_id": { + "type": "string", + "description": "The session's identifier — what a revoke names." }, - "plaintext_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The plaintext length the manifest commits to." + "created_at": { + "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\"." }, - "chunk_size": { - "type": "integer", - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "The STREAM plaintext chunk size." + "authenticated_at": { + "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." }, - "key_mode": { + "last_active_at": { "type": "string", - "description": "`derived` or `wrapped`." + "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." }, - "metadata_blob_hash": { + "user_agent": { "type": [ "string", "null" ], - "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." + "description": "The `User-Agent` the opening ceremony carried, if any." }, - "original_blob_hash": { + "ip_address": { "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." - }, - "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." + "description": "The address the opening ceremony came from, if any." }, - "prior_provenance_hash": { + "cohort_hash": { "type": [ "string", "null" ], - "description": "The provenance chain position this write continues from." + "description": "The advisory cohort this session asserted, if any. Grouping only." }, - "retention_until": { + "device_id": { "type": [ "string", "null" ], - "description": "The retention floor the manifest carries, when it carries one." + "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." + "format": "uint64", + "description": "Bytes charged to the caller." }, - "protocol_version": { - "type": "string", - "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." + "soft_limit": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "Where the warning starts, or absent on an unlimited deployment." }, - "blob_role": { - "$ref": "#/components/schemas/WireBlobRole", - "description": "The blob's role in its bundle." + "hard_limit": { + "type": [ + "integer", + "null" + ], + "minimum": 0.0, + "format": "uint64", + "description": "Where uploads stop, or absent on an unlimited deployment." }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The unencrypted manifest fields the server validates." + "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`." }, - "album_id": { + "asset_id": { "type": [ "string", "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." + "description": "The asset, when the action was about one rather than about the account." }, - "owner_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." + "at": { + "type": "string", + "description": "When it happened, RFC 3339." }, - "intent_id": { + "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 +21677,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": [ @@ -7181,214 +21723,86 @@ }, "protocol_version": { "type": "string", - "description": "The album's pinned protocol date. A client refuses an entry above its own maximum\nrather than applying it partially." - }, - "sync_seq": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The entry's position. Strictly increasing within a page, and therefore within any album\nthe page touches." - }, - "change": { - "$ref": "#/components/schemas/WireChangeKind", - "description": "What this is to the client that asked." - }, - "manifest_cbor": { - "type": [ - "string", - "null" - ], - "description": "The signed manifest, base64 of the provenance blob's exact bytes (`S-C30`).\n\nAbsent on a tombstone, and absent — with a loud server-side log — when the index names a\nprovenance blob the store cannot produce." - }, - "metadata_blob": { - "type": [ - "string", - "null" - ], - "description": "The encrypted metadata blob's content address." - }, - "blobs": { - "items": { - "$ref": "#/components/schemas/SyncBlobRef" - }, - "type": "array", - "description": "The asset's original and derivative blobs." - }, - "original_held": { - "type": "boolean", - "description": "Whether the original has landed. `false` is the derived `awaiting-original` state." - }, - "changed_at": { - "type": "string", - "description": "When the change happened, RFC 3339." - } - }, - "type": "object", - "required": [ - "asset_id", - "album_id", - "protocol_version", - "sync_seq", - "change", - "blobs", - "original_held", - "changed_at" - ], - "description": "One change in a library." - }, - "SyncPageResponse": { - "properties": { - "entries": { - "items": { - "$ref": "#/components/schemas/SyncEntry" - }, - "type": "array", - "description": "The changes, in `sync_seq` order." - }, - "next_cursor": { - "type": "string", - "description": "The cursor that resumes after the last entry.\n\nAlways present, including on an empty page, where it re-mints the position the client\narrived with. A client therefore never has to decide whether to keep its old cursor." - }, - "has_more": { - "type": "boolean", - "description": "Whether the server holds changes beyond this page.\n\nAnswered from the owner's high-water mark rather than by fetching one more entry, so a\ncaught-up client is told so without paying for a page it will not receive." - } - }, - "type": "object", - "required": [ - "entries", - "next_cursor", - "has_more" - ], - "description": "A page of the feed." - }, - "AssetVerifyRequest": { - "properties": { - "asset_id": { - "type": "string", - "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": [ - "asset_id", - "blob_hashes" - ], - "description": "One asset to verify, with the exact copies the client is relying on." - }, - "StorageVerifyRequest": { - "properties": { - "assets": { - "items": { - "$ref": "#/components/schemas/AssetVerifyRequest" - }, - "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." - } - }, - "type": "object", - "required": [ - "assets" - ], - "description": "The `POST /v1/storage/verify` body." - }, - "BlobVerdictResponse": { - "properties": { - "hash": { - "type": "string", - "description": "The address, as the client declared it." - }, - "role": { - "type": "string", - "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." + "description": "The album's pinned protocol date. A client refuses an entry above its own maximum\nrather than applying it partially." }, - "indexed": { - "type": "boolean", - "description": "A live asset of the caller's references the address." + "sync_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The entry's position. Strictly increasing within a page, and therefore within any album\nthe page touches." }, - "retrievable": { - "type": "boolean", - "description": "Nothing is withholding it." + "change": { + "$ref": "#/components/schemas/WireChangeKind", + "description": "What this is to the client that asked." }, - "deep": { + "manifest_cbor": { "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": [ - "hash", - "role", - "stored", - "indexed", - "retrievable" - ], - "description": "One declared blob's verdict." - }, - "StorageVerdictResponse": { - "properties": { - "asset_id": { - "type": "string", - "description": "The asset the client asked about." + "description": "The signed manifest, base64 of the provenance blob's exact bytes (`S-C30`).\n\nAbsent on a tombstone, and absent — with a loud server-side log — when the index names a\nprovenance blob the store cannot produce." }, - "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." + "metadata_blob": { + "type": [ + "string", + "null" + ], + "description": "The encrypted metadata blob's content address." }, "blobs": { "items": { - "$ref": "#/components/schemas/BlobVerdictResponse" + "$ref": "#/components/schemas/SyncBlobRef" }, "type": "array", - "description": "One entry per declared hash, in declaration order and never shortened." + "description": "The asset's original and derivative blobs." }, - "checked_at": { + "original_held": { + "type": "boolean", + "description": "Whether the original has landed. `false` is the derived `awaiting-original` state." + }, + "changed_at": { "type": "string", - "description": "The server's own clock at verification, RFC 3339. Never the client's." + "description": "When the change happened, RFC 3339." } }, "type": "object", "required": [ "asset_id", - "durable", + "album_id", + "protocol_version", + "sync_seq", + "change", "blobs", - "checked_at" + "original_held", + "changed_at" ], - "description": "One asset's verdict." + "description": "One change in a library." }, - "StorageVerifyResponse": { + "SyncPageResponse": { "properties": { - "verdicts": { + "entries": { "items": { - "$ref": "#/components/schemas/StorageVerdictResponse" + "$ref": "#/components/schemas/SyncEntry" }, "type": "array", - "description": "One verdict per requested asset, in request order." + "description": "The changes, in `sync_seq` order." + }, + "next_cursor": { + "type": "string", + "description": "The cursor that resumes after the last entry.\n\nAlways present, including on an empty page, where it re-mints the position the client\narrived with. A client therefore never has to decide whether to keep its old cursor." + }, + "has_more": { + "type": "boolean", + "description": "Whether the server holds changes beyond this page.\n\nAnswered from the owner's high-water mark rather than by fetching one more entry, so a\ncaught-up client is told so without paying for a page it will not receive." } }, "type": "object", "required": [ - "verdicts" + "entries", + "next_cursor", + "has_more" ], - "description": "The `POST /v1/storage/verify` response." + "description": "A page of the feed." }, "AssetReceipt": { "properties": { @@ -7482,369 +21896,450 @@ ], "description": "The chain." }, - "IssueShareRequest": { + "InboxEntryResponse": { "properties": { + "drop_id": { + "type": "string", + "description": "The drop's identifier, which adoption and discard name." + }, "opaque_id": { "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 link it arrived through." }, - "metadata_hash": { + "ciphertext_hash": { "type": "string", - "description": "The metadata blob a viewer starts from. Must appear in `serves`." + "description": "The ciphertext's content address." }, - "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." + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many bytes." }, - "wrapped_secret": { - "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." + "content_type": { + "type": "string", + "description": "The guest's declared content type." }, - "expires_at": { + "kem_ct": { + "type": "string", + "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + }, + "suggested_filename": { "type": [ "string", "null" ], - "description": "When the link stops being live, RFC 3339. Absent means no expiry." + "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", - "metadata_hash", - "serves" + "ciphertext_hash", + "size", + "content_type", + "kem_ct", + "received_at", + "adopting" ], - "description": "A link the owner's client has issued." + "description": "One drop waiting for the owner." }, - "IssueShareResponse": { + "InboxResponse": { "properties": { - "opaque_id": { - "type": "string", - "description": "The opaque id, echoed." + "drops": { + "items": { + "$ref": "#/components/schemas/InboxEntryResponse" + }, + "type": "array", + "description": "Everything waiting, oldest first." } }, "type": "object", "required": [ - "opaque_id" + "drops" ], - "description": "Confirmation that a link is now servable." + "description": "The owner's pending drops." }, - "SharedMetadataResponse": { + "VersionResponse": { "properties": { - "metadata_hash": { + "name": { "type": "string", - "description": "The metadata blob's content address; fetch it from `/s/{opaque_id}/blob/{hash}`." + "description": "The server package name." }, - "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." + "version": { + "type": "string", + "description": "The server package version." } }, "type": "object", "required": [ - "metadata_hash", - "passphrase_protected" + "name", + "version" ], - "description": "What a viewer needs to start." + "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." }, - "ProvisionLinkRequest": { + "PublishedKeyResponse": { "properties": { - "opaque_id": { + "key_id": { "type": "string", - "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." + "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." }, - "drop_pubkey": { + "public": { "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": "When the link stops accepting drops, RFC 3339." + "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." }, - "max_total_bytes": { - "type": [ - "integer", - "null" - ], - "minimum": 0.0, - "format": "uint64", - "description": "Cumulative bytes across every drop on this link." + "algorithm": { + "type": "string", + "description": "The signature algorithm this key is used with." }, - "max_file_count": { - "type": [ - "integer", - "null" - ], - "maximum": 4294967295.0, - "minimum": 0.0, - "format": "uint32", - "description": "How many files the link may deposit." + "active_from": { + "type": "string", + "description": "When it began signing, RFC 3339." }, - "max_file_size": { + "active_to": { "type": [ - "integer", + "string", "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": "When it stopped, or absent while it is the active key." + } + }, + "type": "object", + "required": [ + "key_id", + "public", + "algorithm", + "active_from" + ], + "description": "One published attestation key." + }, + "AttestationKeysResponse": { + "properties": { + "server_id": { + "type": "string", + "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." }, - "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." + "keys": { + "items": { + "$ref": "#/components/schemas/PublishedKeyResponse" + }, + "type": "array", + "description": "Every key this server has signed with, oldest first, the active one last." } }, "type": "object", "required": [ - "opaque_id", - "drop_pubkey", - "crypto_suite_id" + "server_id", + "keys" ], - "description": "A link the owner is provisioning." + "description": "The `.well-known/capsule/attestation-keys` record." }, - "ProvisionLinkResponse": { + "OidcEndpointsResponse": { "properties": { - "opaque_id": { + "authorize": { "type": "string", - "description": "The opaque id, echoed." + "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": [ - "opaque_id" + "authorize", + "callback" ], - "description": "Confirmation that a link is live." + "description": "The OIDC ceremony's endpoints (slice `S-N1`)." }, - "CreateDropRequest": { + "AuthEndpointsResponse": { "properties": { - "content_type": { + "login": { "type": "string", - "description": "The declared content type, from the link's pinned protocol enum (invariant 27)." - }, - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The ciphertext's total length." + "description": "Where a session is opened." }, - "ciphertext_hash": { + "refresh": { "type": "string", - "description": "The lowercase-hex SHA-256 finalization verifies against." + "description": "Where an access token is rotated." }, - "kem_ct": { + "logout": { "type": "string", - "description": "`K` encapsulated to the link's Drop Key, base64. Length fixed by the suite\n(invariant 30)." - }, - "passphrase_proof": { - "type": [ - "string", - "null" - ], - "description": "The Argon2id **proof** for a passphrase-gated link, base64.\n\nRequired exactly when the link carries a verifier, and absent otherwise. The passphrase\nitself is never transmitted: what travels is the derived proof, and the KDF's cost is\nwhat rate-limits guessing on top of the per-link limiter.\n\nIt gates a **write** and adds no confidentiality — the guest already encrypts every\nasset. What it limits is *who may spend the owner's quota*\n([Web Upload](../../../capsule-docs/src/content/docs/design/web-upload.md))." + "description": "Where a session is ended." }, - "suggested_filename": { - "type": [ - "string", - "null" + "oidc": { + "anyOf": [ + { + "$ref": "#/components/schemas/OidcEndpointsResponse" + }, + { + "type": "null" + } ], - "description": "Guest-supplied and unverified. Advisory only." + "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": [ - "content_type", - "size", - "ciphertext_hash", - "kem_ct" + "login", + "refresh", + "logout" ], - "description": "A guest's declared drop.\n\n**No `album_id`, no `amk_version`, no manifest, no provenance.** `deny_unknown_fields` is\nwhat enforces invariant 30's *absence* clause: a drop that names an album is a `400` rather\nthan a field the server quietly ignores, because ignoring it would let a guest believe they\nhad written into an album." + "description": "The auth ceremony's endpoints." }, - "CreateDropResponse": { + "ProtocolWindowResponse": { "properties": { - "upload_id": { + "min": { "type": "string", - "description": "The session id, and the last path segment of the chunk endpoint." + "description": "The oldest version still accepted for writes." }, - "suggested_chunk_size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The chunk size to start with." + "max": { + "type": "string", + "description": "The newest version this server speaks." } }, "type": "object", "required": [ - "upload_id", - "suggested_chunk_size" + "min", + "max" ], - "description": "The session a guest uploads into." + "description": "The accepted `protocol_version` range." }, - "InboxEntryResponse": { + "DeprecationResponse": { "properties": { - "drop_id": { + "min_protocol_version": { "type": "string", - "description": "The drop's identifier, which adoption and discard name." + "description": "The lowest `protocol_version` that remains accepted after the cutoff." }, - "opaque_id": { + "announced_at": { "type": "string", - "description": "The link it arrived through." + "description": "When the announcement was first published, RFC 3339." }, - "ciphertext_hash": { + "cutoff": { "type": "string", - "description": "The ciphertext's content address." - }, - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "How many bytes." + "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." }, - "content_type": { + "detail_url": { + "type": [ + "string", + "null" + ], + "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": "The guest's declared content type." + "description": "This server's canonical origin." }, - "kem_ct": { + "api_base_url": { "type": "string", - "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + "description": "Where the versioned API lives." }, - "suggested_filename": { + "auth": { + "$ref": "#/components/schemas/AuthEndpointsResponse", + "description": "Where a client performs the auth ceremony." + }, + "federation_url": { "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." + "description": "Where federated peers talk to this server. Absent when it does not federate." }, - "received_at": { + "protocol_version": { + "$ref": "#/components/schemas/ProtocolWindowResponse", + "description": "The `protocol_version` range accepted for writes today, both ends inclusive." + }, + "signing_key": { "type": "string", - "description": "When it landed, RFC 3339." + "description": "The raw Ed25519 public key this server's tokens verify under, base64." }, - "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." + "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": [ - "drop_id", - "opaque_id", - "ciphertext_hash", - "size", - "content_type", - "kem_ct", - "received_at", - "adopting" + "server_id", + "api_base_url", + "auth", + "protocol_version", + "signing_key", + "signing_algorithm", + "deprecations" ], - "description": "One drop waiting for the owner." + "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." }, - "InboxResponse": { + "DeprecationsResponse": { "properties": { - "drops": { + "announcements": { "items": { - "$ref": "#/components/schemas/InboxEntryResponse" + "$ref": "#/components/schemas/DeprecationResponse" }, "type": "array", - "description": "Everything waiting, oldest first." + "description": "Every announced cutoff, in announcement order." } }, "type": "object", "required": [ - "drops" + "announcements" + ], + "description": "The `.well-known/capsule/deprecation` record." + }, + "RevokedTokenResponse": { + "properties": { + "jti": { + "type": "string", + "description": "The token's `jti` claim." + }, + "expires_at": { + "type": "string", + "description": "The token's own `exp`, RFC 3339. After this the entry is pruned." + } + }, + "type": "object", + "required": [ + "jti", + "expires_at" + ], + "description": "One revoked capability token." + }, + "RevokedJtiResponse": { + "properties": { + "generated_at": { + "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." + }, + "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." + }, + "revoked": { + "items": { + "$ref": "#/components/schemas/RevokedTokenResponse" + }, + "type": "array", + "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." + } + }, + "type": "object", + "required": [ + "generated_at", + "max_staleness_seconds", + "revoked" + ], + "description": "The `.well-known/capsule/revoked-jti` record." + }, + "SharedMetadataResponse": { + "properties": { + "metadata_hash": { + "type": "string", + "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": [ + "metadata_hash", + "passphrase_protected" ], - "description": "The owner's pending drops." + "description": "What a viewer needs to start." }, - "AdoptRequest": { + "CreateDropRequest": { "properties": { - "album_id": { - "type": "string", - "description": "The album to adopt into." - }, - "asset_id": { + "content_type": { "type": "string", - "description": "The asset the drop becomes." + "description": "The declared content type, from the link's pinned protocol enum (invariant 27)." }, "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": "The ciphertext hash, which must name **this drop's** blob." + "description": "The ciphertext's total length." }, - "content_type": { + "ciphertext_hash": { "type": "string", - "description": "The declared content type." - }, - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite." + "description": "The lowercase-hex SHA-256 finalization verifies against." }, - "protocol_version": { + "kem_ct": { "type": "string", - "description": "The protocol version the manifest is written against." + "description": "`K` encapsulated to the link's Drop Key, base64. Length fixed by the suite\n(invariant 30)." }, - "key_mode": { - "type": "string", - "description": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." + "passphrase_proof": { + "type": [ + "string", + "null" + ], + "description": "The Argon2id **proof** for a passphrase-gated link, base64.\n\nRequired exactly when the link carries a verifier, and absent otherwise. The passphrase\nitself is never transmitted: what travels is the derived proof, and the KDF's cost is\nwhat rate-limits guessing on top of the per-link limiter.\n\nIt gates a **write** and adds no confidentiality — the guest already encrypts every\nasset. What it limits is *who may spend the owner's quota*\n([Web Upload](../../../capsule-docs/src/content/docs/design/web-upload.md))." }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The signed manifest envelope, verbatim." + "suggested_filename": { + "type": [ + "string", + "null" + ], + "description": "Guest-supplied and unverified. Advisory only." } }, "type": "object", "required": [ - "album_id", - "asset_id", - "size", - "hash", "content_type", - "crypto_suite_id", - "protocol_version", - "key_mode", - "manifest_envelope" + "size", + "ciphertext_hash", + "kem_ct" ], - "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." + "description": "A guest's declared drop.\n\n**No `album_id`, no `amk_version`, no manifest, no provenance.** `deny_unknown_fields` is\nwhat enforces invariant 30's *absence* clause: a drop that names an album is a `400` rather\nthan a field the server quietly ignores, because ignoring it would let a guest believe they\nhad written into an album." }, - "AdoptResponse": { + "CreateDropResponse": { "properties": { - "asset_id": { + "upload_id": { "type": "string", - "description": "The asset the drop became." + "description": "The session id, and the last path segment of the chunk endpoint." + }, + "suggested_chunk_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The chunk size to start with." } }, "type": "object", "required": [ - "asset_id" + "upload_id", + "suggested_chunk_size" ], - "description": "What adoption produced." + "description": "The session a guest uploads into." }, "CodedProblem": { "properties": { @@ -8125,6 +22620,201 @@ ], "title": "FileTooLargeProblem", "description": "An RFC 9457 problem detail." + }, + "DropRateLimitedProblem": { + "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." + }, + "retry_after": { + "type": "integer", + "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, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "DropRateLimitedProblem", + "description": "An RFC 9457 problem detail." + }, + "EnrollmentRateLimitedProblem": { + "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." + }, + "retry_after": { + "type": "integer", + "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, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "EnrollmentRateLimitedProblem", + "description": "An RFC 9457 problem detail." + }, + "ShareMetadataRateLimitedProblem": { + "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." + }, + "retry_after": { + "type": "integer", + "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, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "ShareMetadataRateLimitedProblem", + "description": "An RFC 9457 problem detail." + }, + "ShareSecretRateLimitedProblem": { + "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." + }, + "retry_after": { + "type": "integer", + "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, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "ShareSecretRateLimitedProblem", + "description": "An RFC 9457 problem detail." + }, + "ShareBlobRateLimitedProblem": { + "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." + }, + "retry_after": { + "type": "integer", + "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, + "type": "object", + "required": [ + "type", + "status", + "code" + ], + "title": "ShareBlobRateLimitedProblem", + "description": "An RFC 9457 problem detail." } }, "securitySchemes": { diff --git a/capsule-server/src/app.rs b/capsule-server/src/app.rs index ecc54486..8e2e2e8e 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; @@ -61,6 +62,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. @@ -106,6 +109,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. @@ -144,6 +149,7 @@ impl App { let Modules { auth, totp, + oidc, upload, sync, serve, @@ -163,6 +169,7 @@ impl App { Self { auth, totp, + oidc, upload, sync, serve, diff --git a/capsule-server/src/auth/mod.rs b/capsule-server/src/auth/mod.rs index 564b8010..acb6e68a 100644 --- a/capsule-server/src/auth/mod.rs +++ b/capsule-server/src/auth/mod.rs @@ -49,6 +49,7 @@ pub mod accounts_memory; pub mod credential; pub mod directory; +pub mod oidc; pub mod profile; pub mod registry; pub mod scheme; 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/boot.rs b/capsule-server/src/boot.rs index 9552433e..a6384920 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -45,6 +45,10 @@ 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, @@ -69,7 +73,7 @@ use crate::share::{InMemoryShares, ShareContext}; use crate::store::SystemClock; use crate::store::memory::{ InMemoryAuthState, InMemoryChallenges, InMemoryChannels, InMemoryCohorts, InMemoryEnrollments, - InMemoryUploadSessions, + InMemoryOidcAuthorizations, InMemoryUploadSessions, }; use crate::sync::{CursorCodec, SyncContext}; use crate::upload::{UploadContext, UploadPolicy}; @@ -120,6 +124,13 @@ pub enum BootError { /// The algorithm's own description. 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 @@ -209,7 +220,20 @@ pub async fn assemble(config: &Config) -> Result { let stores = stores(config).await?; match config.backends { Backends::Memory => memory(config, stores), - Backends::Durable => Err(durable()), + 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)", + }); + } + Err(durable()) + } } } @@ -243,6 +267,29 @@ pub async fn assemble_maintenance(config: &Config) -> Result 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. /// /// `Config::load` already turned "no `VALKEY_URL` and no `--memory`" into a configuration fault @@ -394,7 +441,7 @@ fn memory(config: &Config, stores: Stores) -> Result { capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), )); - let server_info = Arc::new(ServerInfo::new( + let mut server_info = ServerInfo::new( config.server_domain.clone(), config.api_base_url.clone(), ProtocolWindow { @@ -402,7 +449,49 @@ fn memory(config: &Config, stores: Stores) -> Result { 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(); + } + 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 { @@ -422,13 +511,19 @@ fn memory(config: &Config, stores: Stores) -> Result { // 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(), - UploadPolicy::default(), + // 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(), @@ -533,6 +628,10 @@ mod tests { 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() @@ -547,6 +646,10 @@ mod tests { ) -> 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() @@ -639,6 +742,101 @@ mod tests { assert!(format!("{error}").contains("#403"), "{error}"); } + #[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 @@ -696,6 +894,62 @@ mod tests { 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 @@ -708,6 +962,10 @@ mod tests { 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", @@ -721,6 +979,10 @@ mod tests { 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", @@ -811,6 +1073,10 @@ mod tests { 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", @@ -821,6 +1087,10 @@ mod tests { .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", diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs index 806f8640..44813c7e 100644 --- a/capsule-server/src/config.rs +++ b/capsule-server/src/config.rs @@ -40,6 +40,7 @@ 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. @@ -120,6 +121,26 @@ impl Environment for BTreeMap { } } +/// 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); @@ -272,6 +293,26 @@ pub struct Overrides { 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 { @@ -293,16 +334,20 @@ pub struct Config { 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. + /// The oldest `protocol_version` accepted for writes (`YYYY-MM-DD`, validated). pub protocol_min: String, - /// The newest `protocol_version` this server speaks. + /// 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. @@ -437,13 +482,35 @@ impl Config { 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(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + .unwrap_or_else(|| crate::upload::policy::DEFAULT_PROTOCOL_MAX.to_owned()); let protocol_min = env .var("PROTOCOL_MIN") - .unwrap_or_else(|| capsule_core::crypto::PROTOCOL_VERSION.to_owned()); + .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", @@ -451,6 +518,19 @@ impl Config { }); } + // 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 @@ -568,9 +648,11 @@ impl Config { attestation_key_seed, protocol_min, protocol_max, + min_client_build, grace_window, lockout_window, lockout_attempts, + oidc, shutdown_timeout, max_connections, log_format, @@ -582,6 +664,129 @@ impl Config { } } +/// 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 { @@ -751,9 +956,68 @@ mod tests { 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 @@ -1074,6 +1338,171 @@ mod tests { 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"); diff --git a/capsule-server/src/counter/budgets.rs b/capsule-server/src/counter/budgets.rs index 2c53cd5f..1f4763bf 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,6 +71,27 @@ 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 diff --git a/capsule-server/src/counter/mod.rs b/capsule-server/src/counter/mod.rs index be6601dd..795aea90 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,6 +167,35 @@ 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, } impl CounterKey { @@ -91,6 +204,7 @@ impl CounterKey { 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 +212,29 @@ impl CounterKey { Self::DeepVerify(_) => "deep_verify", Self::SecondFactor(_) => "second_factor", Self::RegistrationSource(_) => "registration_source", + Self::OidcAuthorize(_) => "oidc_authorize", + Self::OidcAuthorizeRefused => "oidc_authorize_refused", + } + } + + /// 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, } } } @@ -152,6 +289,12 @@ pub trait CounterStore: std::fmt::Debug + Send + Sync { /// 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`. + /// + /// 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 +321,96 @@ 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; +} + /// 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 +418,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 +527,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 +571,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 +599,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,6 +672,59 @@ 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; diff --git a/capsule-server/src/counter/tests.rs b/capsule-server/src/counter/tests.rs index 0fdf9a78..711b4c6f 100644 --- a/capsule-server/src/counter/tests.rs +++ b/capsule-server/src/counter/tests.rs @@ -5,7 +5,7 @@ //! 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. -use super::{budgets, *}; +use super::{budgets, ceilings, *}; fn key() -> CounterKey { CounterKey::LoginAttempts(UserId::new("01937b7c-0000-7000-8000-000000000001")) @@ -206,6 +206,340 @@ async fn no_sequence_of_calls_admits_more_than_the_budget() { assert_eq!(admitted, 3); } +#[tokio::test] +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 index in 0..50 { + let key = CounterKey::ShareLink(format!("link-{index}")); + assert!( + counters + .hit(&key, budget(), at(0)) + .await + .expect("answers") + .admits() + ); + } + assert_eq!(counters.len(), 50); + + // 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(11)) + .await + .expect("answers") + .admits() + ); + assert_eq!(counters.len(), 1, "only the live window survives"); +} + +#[tokio::test] +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(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(&first, budget(), at(0)) + .await + .expect("answers") + .admits() + ); + // Right up to its own budget, which is still the thing that limits it. + assert!( + counters + .hit(&first, budget(), at(0)) + .await + .expect("answers") + .admits() + ); + assert!( + !counters + .hit(&first, budget(), at(0)) + .await + .expect("answers") + .admits(), + "the budget still ends the run" + ); + + // And the ceiling is not a one-way door: once the windows lapse, a new key fits again. + assert!( + counters + .hit(&CounterKey::ShareLink("c".to_owned()), budget(), at(11)) + .await + .expect("answers") + .admits() + ); +} + +#[tokio::test] +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(); + let wide = Budget::new(3, SignedDuration::from_mins(60)); + assert!( + counters + .hit(&key(), wide, at(0)) + .await + .expect("answers") + .admits() + ); + // 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(), 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 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 + .hit(&key, budgets::DROP_LINK, at(0)) + .await + .expect("answers") + .admits() + ); + } + assert_eq!(counters.len_of(&CounterKey::DropLink(String::new())), 50); + + // 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 + .hit(&held, budgets::DROP_LINK, at(0)) + .await + .expect("answers") + .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 the_real_drop_ceiling_is_the_drop_partition_and_nobody_else_s() { + let counters = InMemoryCounters::new(); + + 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() + ); +} + +#[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:?}"); + } + + // 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] fn every_budget_is_declared_in_one_place() { // A budget written inline at its call site is a budget nobody can review against the threat @@ -217,3 +551,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/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/lib.rs b/capsule-server/src/lib.rs index 5d018f32..96d81e7b 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -26,7 +26,8 @@ //! //! 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`], [`gc`], [`index`], [`moderation`], [`negotiation`], [`quota`], +//! [`scrub`], [`serve`], //! [`share`], [`store`], //! [`sync`], [`upload`], //! [`verify`] — is framework-free and testable without a router, which is why the operator @@ -68,6 +69,7 @@ pub mod gc; pub mod index; pub mod limits; pub mod moderation; +pub mod negotiation; mod openapi; pub mod problem; pub mod quota; @@ -84,6 +86,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; @@ -104,95 +107,133 @@ 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::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, + ]), + ) + // 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, ]) } @@ -202,7 +243,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. /// @@ -254,5 +299,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/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..822ca3d0 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 @@ -110,30 +110,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", @@ -207,9 +183,60 @@ const EXTRAS: &[Extra] = &[ 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", + 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", @@ -308,6 +335,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; diff --git a/capsule-server/src/routes/auth.rs b/capsule-server/src/routes/auth.rs index 277ec2c9..4ee736c0 100644 --- a/capsule-server/src/routes/auth.rs +++ b/capsule-server/src/routes/auth.rs @@ -642,9 +642,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/drop.rs b/capsule-server/src/routes/drop.rs index c82381b5..3e4b09e0 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), }); } @@ -1347,6 +1366,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/mod.rs b/capsule-server/src/routes/mod.rs index 7ac91bdd..f7b34d2c 100644 --- a/capsule-server/src/routes/mod.rs +++ b/capsule-server/src/routes/mod.rs @@ -16,6 +16,7 @@ pub mod drop; pub mod enroll; pub mod escrow; pub mod moderation; +pub mod oidc; pub mod ops; pub mod profile; pub mod quota; 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/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/upload.rs b/capsule-server/src/routes/upload.rs index cd13c4fe..3727a824 100644 --- a/capsule-server/src/routes/upload.rs +++ b/capsule-server/src/routes/upload.rs @@ -20,7 +20,7 @@ //! | create `403` | kept — album access, device authorization, on-behalf refusal | //! | 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 //! @@ -243,23 +247,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 +341,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 +355,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 +447,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 +461,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 +579,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 +616,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 +676,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 +684,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 +719,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 @@ -1109,8 +1003,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 +1120,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 +1147,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 +1193,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,79 +1213,6 @@ 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. /// /// An on-behalf upload needs a verified relationship between two accounts, and the port that @@ -1530,8 +1320,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( 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(&'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/conformance.rs b/capsule-server/src/store/conformance.rs index 1103f95f..7a46072f 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, @@ -70,6 +72,16 @@ pub trait Harness: Send + Sync { fn channels(&self) -> &dyn ChannelStore; /// The durable device-cohort map under test. fn cohorts(&self) -> &dyn CohortStore; + /// 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 + } /// Move every store in this harness `by` forward in its own time. fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()>; @@ -144,6 +156,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 // =========================================================================================== @@ -973,6 +1007,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(); @@ -1382,4 +1469,10 @@ pub async fn run_all(h: &dyn Harness) { cohorts_are_listed_oldest_first(h).await; a_cohort_is_scoped_to_its_account(h).await; the_cohort_map_does_not_expire(h).await; + + // Skipped, not failed, for a harness without the store — see `Harness::oidc_authorizations`. + if h.oidc_authorizations().is_some() { + an_oidc_authorization_is_single_use(h).await; + an_oidc_authorization_expires_with_its_store(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..ee32a90e 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, @@ -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, } } @@ -1134,6 +1253,10 @@ impl super::conformance::Harness for InMemoryStores { &self.channels } + fn oidc_authorizations(&self) -> Option<&dyn OidcAuthorizationStore> { + Some(&self.oidc_authorizations) + } + fn advance(&self, by: SignedDuration) -> StoreFuture<'_, ()> { Box::pin(async move { self.clock.advance(by); @@ -1198,6 +1321,8 @@ 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 whole suite, in one pass on one harness. @@ -1226,6 +1351,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 +1362,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..c9f1cfbf 100644 --- a/capsule-server/src/store/mod.rs +++ b/capsule-server/src/store/mod.rs @@ -71,12 +71,13 @@ 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::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/upload/policy.rs b/capsule-server/src/upload/policy.rs index ad59c865..4ade86de 100644 --- a/capsule-server/src/upload/policy.rs +++ b/capsule-server/src/upload/policy.rs @@ -9,9 +9,14 @@ //! - **Protocol surface** — the 4 KiB alignment, the `[4 KiB, 16 MiB]` chunk range, the //! offset semantics — is *not* here. It is fixed for a protocol version, so it lives as //! constants in [`super::chunk`] where no deployment can move it. -//! - **Server-tunable** — the accepted protocol window, the per-file ceiling, the closed -//! `content_type` enum, the timestamp-drift bound, and the suggested chunk-size tiers — is -//! here, because a self-hosted deployment legitimately sets them differently. +//! - **Server-tunable** — the accepted protocol window and the client-build cutoff it +//! advertises beside it, the per-file ceiling, the closed `content_type` enum, the +//! timestamp-drift bound, and the suggested chunk-size tiers — is here, because a self-hosted +//! deployment legitimately sets them differently. +//! +//! The protocol window is read by more than the upload surface: [`crate::negotiation`] +//! advertises it on every response and gates every covered operation against it, from this one +//! value, so the window a client is told and the window it is held to cannot be two numbers. //! //! Every value carries the Salvo deployment's default, so the rebuild starts from the //! behaviour clients already see rather than from a fresh set of numbers. @@ -44,6 +49,14 @@ pub const DEFAULT_PROTOCOL_MIN: &str = "2026-01-01"; /// Highest protocol date this server accepts (`X-Capsule-Protocol-Max`). pub const DEFAULT_PROTOCOL_MAX: &str = "2026-12-31"; +/// The semver client build below which this server stops answering +/// (`X-Capsule-Min-Client-Build`). +/// +/// `0.0.0` is "no cutoff announced": every build satisfies it. The header is advisory until a +/// path is hard-deprecated (threat-model/validation.md), and no path is, so nothing refuses on +/// it — but it is sent on every response so a client that reads it today reads a real value. +pub const DEFAULT_MIN_CLIENT_BUILD: &str = "0.0.0"; + /// Gross-drift sanity bound for the envelope timestamp, in days (invariant 8). pub const DEFAULT_DRIFT_DAYS: i64 = 30; @@ -64,6 +77,8 @@ pub struct UploadPolicy { protocol_min: String, /// Highest accepted protocol date (`YYYY-MM-DD`). protocol_max: String, + /// The advisory semver deprecation cutoff advertised on every response. + min_client_build: String, /// The closed `content_type` allow-list (invariant 5). content_types: Vec, /// Gross-drift sanity bound in days for the envelope timestamp (invariant 8). @@ -77,6 +92,7 @@ impl Default for UploadPolicy { Self { protocol_min: DEFAULT_PROTOCOL_MIN.to_owned(), protocol_max: DEFAULT_PROTOCOL_MAX.to_owned(), + min_client_build: DEFAULT_MIN_CLIENT_BUILD.to_owned(), content_types: DEFAULT_CONTENT_TYPES .iter() .map(|kind| (*kind).to_owned()) @@ -98,6 +114,11 @@ impl UploadPolicy { &self.protocol_max } + /// The semver client build below which this server stops answering. + pub fn min_client_build(&self) -> &str { + &self.min_client_build + } + /// The closed `content_type` allow-list, as the shared predicate wants it. pub fn content_types(&self) -> Vec<&str> { self.content_types.iter().map(String::as_str).collect() @@ -121,6 +142,13 @@ impl UploadPolicy { self } + /// Announce a client-build cutoff. + #[must_use] + pub fn with_min_client_build(mut self, build: impl Into) -> Self { + self.min_client_build = build.into(); + self + } + /// Replace the closed `content_type` enum. #[must_use] pub fn with_content_types(mut self, kinds: I) -> Self @@ -170,6 +198,11 @@ mod tests { ); } + #[test] + fn no_cutoff_is_the_build_every_client_satisfies() { + assert_eq!(UploadPolicy::default().min_client_build(), "0.0.0"); + } + #[test] fn the_allow_list_carries_the_opaque_blob_type() { // Metadata, provenance and backup blobs all declare `application/octet-stream`; an @@ -185,12 +218,14 @@ mod tests { fn a_deployment_can_narrow_every_tunable() { let policy = UploadPolicy::default() .with_protocol_window("2026-06-01", "2026-06-30") + .with_min_client_build("1.2.3") .with_content_types(["image/jpeg"]) .with_max_file_bytes(1024) .with_drift_days(1); assert_eq!(policy.protocol_min(), "2026-06-01"); assert_eq!(policy.protocol_max(), "2026-06-30"); + assert_eq!(policy.min_client_build(), "1.2.3"); assert_eq!(policy.content_types(), vec!["image/jpeg"]); assert_eq!(policy.max_file_bytes(), 1024); assert_eq!(policy.drift_days(), 1); diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs index 90f0434c..ba0f5535 100644 --- a/capsule-server/tests/conformance.rs +++ b/capsule-server/tests/conformance.rs @@ -161,6 +161,8 @@ async fn every_declared_response_is_exercised() { ("GET", "/v1/drops"), ("POST", "/v1/drops/anything/adopt"), ("DELETE", "/v1/drops/anything"), + ("POST", "/v1/auth/oidc/authorize"), + ("POST", "/v1/auth/oidc/callback"), ] { let request = match method { "GET" => client.get(path), @@ -170,12 +172,68 @@ async fn every_declared_response_is_exercised() { "DELETE" => client.delete(path), _ => client.post(path), }; - request + let refused = request .header("content-length", &oversized().to_string()) .body("application/json", "{}") .send() + .await; + refused.assert_status(StatusCode::PAYLOAD_TOO_LARGE); + // The body-size limit sits inside the advertising interceptor, so even the refusal + // that reads no byte of the request leaves with the window on it. + for name in [ + "x-capsule-protocol-min", + "x-capsule-protocol-max", + "x-capsule-min-client-build", + ] { + assert!( + refused.header(name).is_some(), + "{method} {path}: a 413 left without {name}" + ); + } + } + + // 400 is declared on every gated operation and 426 on every gated write, because the two + // protocol gates are mounted on the groups that hold them and a Kynos interceptor's + // declaration is its type. So every gated operation produces what its gate answers here, + // before anything else is read: a write an ancient protocol date, and every operation one + // that is not a date at all. + let document = capsule_server::openapi().expect("router describes itself"); + let document = serde_json::to_value(&document).expect("a document serializes"); + for (method, template, operation) in operations(&document) { + if declares_header(&operation, "X-Capsule-Protocol").is_none() { + continue; + } + let verb = kynos::http::Method::from_bytes(method.as_bytes()).expect("a method"); + if !is_read(&method) { + client + .method(verb.clone(), &concrete(&template)) + .header("x-capsule-protocol", "2000-01-01") + .send() + .await + .assert_status(StatusCode::UPGRADE_REQUIRED); + } + client + .method(verb, &concrete(&template)) + .header("x-capsule-protocol", "yesterday") + .send() .await - .assert_status(StatusCode::PAYLOAD_TOO_LARGE); + .assert_status(StatusCode::BAD_REQUEST); + } + + // A valid handshake and no credential: the bearer scheme's 401, carrying the window like + // every other refusal — the gate admitted the request and authentication refused it, in + // that order. + let unauthenticated = client.get("/v1/quota").send().await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + for name in [ + "x-capsule-protocol-min", + "x-capsule-protocol-max", + "x-capsule-min-client-build", + ] { + assert!( + unauthenticated.header(name).is_some(), + "a 401 left without {name}" + ); } // ── POST /v1/auth/register ───────────────────────────────────────────────────────────── @@ -417,8 +475,9 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::OK); - // POST 400 / 415 / 422 / 426 / 401 / 403 / 500. + // POST 400 / 415 / 422 / 426 / 401 / 403 / 500. The 400 is the gate's: no handshake at all. client + .raw() .post("/v1/upload") .header("authorization", &bearer) .json(&create_request(&fixture.clock, &whole, "original")) @@ -470,7 +529,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::FORBIDDEN); - // HEAD 200 / 400 / 401 / 403 / 404 / 426. + // HEAD 200 / 400 / 401 / 403 / 404. client .head(&session) .header("authorization", &bearer) @@ -479,6 +538,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::OK); client + .raw() .head(&session) .header("authorization", &bearer) .send() @@ -507,13 +567,15 @@ async fn every_declared_response_is_exercised() { .send() .await .assert_status(StatusCode::NOT_FOUND); + // A read is admitted at any protocol date (threat-model/validation.md): the client pinned + // to a version this server no longer accepts for writes can still ask where it got to. client .head(&session) .header("authorization", &bearer) .header("x-capsule-protocol", "2020-01-01") .send() .await - .assert_status(StatusCode::UPGRADE_REQUIRED); + .assert_status(StatusCode::OK); // PATCH 400 / 401 / 403 / 404 / 409 / 415 / 426. client @@ -612,6 +674,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::UPGRADE_REQUIRED); client + .raw() .delete(&session) .header("authorization", &bearer) .send() @@ -1814,16 +1877,19 @@ async fn every_declared_response_is_exercised() { fixture.channels.set_unavailable(false); // 429 on redeem: the per-code budget, spent against one consistent wrong guess (`S-C32`). + // Eight digits, because only a guess shaped like a code gets a per-code budget — anything + // else is charged to the one malformed bucket, whose budget is far larger. + const GRIND: &str = "00000099"; for _ in 0..10 { client .post("/v1/auth/devices/enroll/redeem") - .json(&serde_json::json!({ "code": "conformance-grind" })) + .json(&serde_json::json!({ "code": GRIND })) .send() .await; } client .post("/v1/auth/devices/enroll/redeem") - .json(&serde_json::json!({ "code": "conformance-grind" })) + .json(&serde_json::json!({ "code": GRIND })) .send() .await .assert_status(StatusCode::TOO_MANY_REQUESTS); @@ -2288,6 +2354,9 @@ async fn every_declared_response_is_exercised() { // ── The second factor (`S-C55`) ──────────────────────────────────────────────────────── totp_block(client, &fixture).await; + // ── Signing in through an identity provider (`S-N1`) ─────────────────────────────────── + oidc_block(client, &fixture).await; + // ── The resumption listing (`S-C57`) and the durable custody chain (`S-C58`) ─────────── // Both are plain authenticated reads over stores this walk has already filled, so they sit // here rather than in a block of their own. @@ -2496,6 +2565,387 @@ async fn every_declared_response_is_exercised() { client.assert_declared_responses_covered(); } +// =========================================================================================== +// The protocol handshake census (issue #404) +// =========================================================================================== + +/// The three response headers the design puts on **every** response. +const WINDOW_HEADERS: [&str; 3] = [ + "X-Capsule-Protocol-Min", + "X-Capsule-Protocol-Max", + "X-Capsule-Min-Client-Build", +]; + +/// The three request headers the gate reads. +const HANDSHAKE_HEADERS: [&str; 3] = [ + "X-Capsule-Protocol", + "X-Capsule-Crypto-Suite", + "X-Capsule-Sidecar-Schema", +]; + +/// The operations the design exempts from the gate; every other operation is gated. +/// +/// **Pinned on purpose.** The gate is a Kynos `Group` in `lib.rs::router`, so an operation +/// leaves it by a mount call moving — and a mount call moving must fail this test, because a +/// route outside the group silently drops a fail-closed rule. Asserted **never** to declare the +/// handshake: `/v1/version` is the reachability probe a +/// client hits before it knows the window; the `/.well-known/capsule/*` records are public +/// discovery; the `/s/{opaque_id}*` reads must answer an indistinguishable `404` +/// (share-links.md), which a `426` would turn into a probing oracle; the `/d/{opaque_id}*` +/// guest deposits have their protocol pinned at link issuance (web-upload.md). +const EXEMPT: &[(&str, &str)] = &[ + ("GET", "/v1/version"), + ("GET", "/.well-known/capsule/attestation-keys"), + ("GET", "/.well-known/capsule/server-info"), + ("GET", "/.well-known/capsule/deprecation"), + ("GET", "/.well-known/capsule/revoked-jti"), + ("GET", "/s/{opaque_id}"), + ("GET", "/s/{opaque_id}/wrapped-secret"), + ("GET", "/s/{opaque_id}/blob/{hash}"), + ("POST", "/d/{opaque_id}"), + ("PATCH", "/d/{opaque_id}/{upload_id}"), +]; + +/// The HTTP methods a path item may carry, as the document spells them. +const METHODS: [&str; 9] = [ + "get", "put", "post", "delete", "options", "head", "patch", "trace", "query", +]; + +/// Every `(METHOD, template, operation)` in the emitted document. +fn operations(document: &serde_json::Value) -> Vec<(String, String, serde_json::Value)> { + let mut found = Vec::new(); + for (path, item) in document["paths"].as_object().expect("paths") { + for (method, operation) in item.as_object().expect("a path item") { + if METHODS.contains(&method.as_str()) { + found.push((method.to_uppercase(), path.clone(), operation.clone())); + } + } + } + assert!(!found.is_empty(), "the document describes no operation"); + found +} + +/// A template with every `{variable}` replaced by a placeholder, so it can be requested. +fn concrete(template: &str) -> String { + let mut path = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + path.push_str(&rest[..open]); + path.push_str("anything"); + let close = rest[open..].find('}').expect("a balanced template") + open; + rest = &rest[close + 1..]; + } + path.push_str(rest); + path +} + +/// Whether `operation` declares a header parameter called `name`. +fn declares_header(operation: &serde_json::Value, name: &str) -> Option { + operation["parameters"] + .as_array() + .into_iter() + .flatten() + .find(|parameter| { + parameter["in"] == "header" + && parameter["name"] + .as_str() + .is_some_and(|declared| declared.eq_ignore_ascii_case(name)) + }) + .cloned() +} + +/// Every response of every operation declares the three window headers, required. +/// +/// Over the emitted document rather than per route, because the property is the router's: the +/// advertising interceptor is mounted once, and Kynos describes an interceptor's headers on +/// success responses only, so the walk in `capsule_server::openapi` is what puts them on the +/// `426` a client needs them on most. This is the test that fails if either half goes missing. +#[test] +fn every_response_of_every_operation_declares_the_protocol_window() { + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut responses = 0_usize; + for (method, path, operation) in operations(&json) { + for (status, response) in operation["responses"].as_object().expect("responses") { + for name in WINDOW_HEADERS { + let header = &response["headers"][name]; + assert!( + header.is_object(), + "{method} {path} -> {status} does not declare {name}" + ); + assert_eq!( + header["required"], true, + "{method} {path} -> {status} declares {name} as optional" + ); + } + responses += 1; + } + } + assert!(responses > 100, "only {responses} responses were walked"); +} + +/// Whether `method` is one the design holds to the handshake's grammar only. +/// +/// "Reads of any past version succeed" (threat-model/validation.md, Fail-Closed Rules): a +/// `GET` or `HEAD` with a grammatical `X-Capsule-Protocol` outside the window is admitted, and +/// only a write is refused with `426`. +fn is_read(method: &str) -> bool { + method == "GET" || method == "HEAD" +} + +/// Whether `(method, template)` is one the design exempts from the gate. +fn is_exempt(method: &str, template: &str) -> bool { + EXEMPT + .iter() + .any(|(exempt_method, exempt_path)| *exempt_method == method && *exempt_path == template) +} + +/// The operations declaring the handshake are exactly the ones outside [`EXEMPT`]. +#[test] +fn the_handshake_is_declared_on_every_operation_but_the_exempt_ten() { + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut gated: Vec<(String, String)> = Vec::new(); + for (method, path, operation) in operations(&json) { + let declared: Vec<&str> = HANDSHAKE_HEADERS + .into_iter() + .filter(|name| declares_header(&operation, name).is_some()) + .collect(); + if declared.is_empty() { + continue; + } + assert_eq!( + declared, HANDSHAKE_HEADERS, + "{method} {path} declares part of the handshake, which no interceptor does" + ); + let protocol = declares_header(&operation, "X-Capsule-Protocol").expect("declared"); + assert_eq!( + protocol["required"], true, + "{method} {path} declares X-Capsule-Protocol as optional and refuses without it" + ); + assert!( + operation["responses"]["400"].is_object(), + "{method} {path} is gated and does not declare the gate's 400" + ); + if is_read(&method) { + assert!( + !operation["responses"]["426"].is_object(), + "{method} {path} is a read, admitted at any protocol date, and declares a 426 \ + it never renders" + ); + } else { + assert!( + operation["responses"]["426"].is_object(), + "{method} {path} is a write and does not declare the gate's 426" + ); + } + gated.push((method, path)); + } + gated.sort(); + + let all: Vec<(String, String)> = operations(&json) + .into_iter() + .map(|(method, path, _)| (method, path)) + .collect(); + let mut expected: Vec<(String, String)> = all + .iter() + .filter(|(method, path)| !is_exempt(method, path)) + .cloned() + .collect(); + expected.sort(); + assert_eq!( + gated, expected, + "the gated set is not \"everything but EXEMPT\"; `lib.rs::router` and this pin change \ + together" + ); + assert_eq!(EXEMPT.len(), 10, "the design names ten exemptions"); + + for (method, path) in EXEMPT { + assert!( + all.contains(&((*method).to_owned(), (*path).to_owned())), + "{method} {path} is named exempt and is not in the document" + ); + assert!( + !gated.contains(&((*method).to_owned(), (*path).to_owned())), + "{method} {path} is exempt by design and is gated" + ); + } +} + +/// On the wire: every operation answers with the window, and the gated ones refuse on it. +/// +/// Driven by the document rather than a list, so an operation added tomorrow is walked +/// tomorrow. Path variables are filled with a placeholder; the gate runs before the path, +/// the credential or the body is looked at, so a `426` needs none of them to be right — and +/// an exempt operation answering anything *but* `426` to an ancient protocol is the exemption +/// observed rather than assumed. +#[tokio::test] +async fn the_protocol_window_rides_every_response_on_the_wire() { + use capsule_server::upload::policy::{ + DEFAULT_MIN_CLIENT_BUILD, DEFAULT_PROTOCOL_MAX, DEFAULT_PROTOCOL_MIN, + }; + + let fixture = Fixture::working(); + let document = capsule_server::openapi().expect("router describes itself"); + let json = serde_json::to_value(&document).expect("a document serializes"); + + let mut walked = 0_usize; + for (method, template, operation) in operations(&json) { + let path = concrete(&template); + let gated = declares_header(&operation, "X-Capsule-Protocol").is_some(); + let verb = kynos::http::Method::from_bytes(method.as_bytes()).expect("a method"); + + // Ancient, and therefore outside any window this server will ever accept. + let ancient = fixture + .client + .method(verb.clone(), &path) + .header("x-capsule-protocol", "2000-01-01") + .send() + .await; + for (name, expected) in [ + ("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN), + ("x-capsule-protocol-max", DEFAULT_PROTOCOL_MAX), + ("x-capsule-min-client-build", DEFAULT_MIN_CLIENT_BUILD), + ] { + assert_eq!( + ancient.header(name), + Some(expected), + "{method} {template} answered {} without {name}", + ancient.status() + ); + } + + if !gated || is_read(&method) { + // Not gated, or a read: an ancient protocol date is never a 426 here. What the + // operation answers instead is its own business (a 401 with no credential, most + // often), and it carries the window either way. + assert_ne!( + ancient.status(), + StatusCode::UPGRADE_REQUIRED, + "{method} {template} refused a read on the protocol; reads of any version succeed" + ); + } else { + ancient.assert_status(StatusCode::UPGRADE_REQUIRED); + let body: serde_json::Value = ancient.json(); + assert_eq!( + body["code"], "error.protocol.version_unsupported", + "{method} {template}: {body}" + ); + } + if !gated { + walked += 1; + continue; + } + + // Each malformed spelling is the gate's coded 400, and each leaves with the window. + for (name, value) in [ + ("x-capsule-protocol", "yesterday"), + ("x-capsule-crypto-suite", "9999"), + ("x-capsule-crypto-suite", "one"), + ("x-capsule-sidecar-schema", "9"), + ] { + let refused = fixture + .client + .method(verb.clone(), &path) + .header(name, value) + .send() + .await; + refused.assert_status(StatusCode::BAD_REQUEST); + refused.assert_header("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN); + if method != "HEAD" { + let body: serde_json::Value = refused.json(); + assert_eq!( + body["code"], "error.request.malformed", + "{method} {template} with {name}: {value}: {body}" + ); + } + } + fixture + .client + .raw() + .method(verb, &path) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST); + walked += 1; + } + assert_eq!(walked, operations(&json).len()); +} + +/// One gated route per module, held to the handshake before anything else is read (issue #404). +/// +/// The wire census above walks every operation; this is the same fact at reading size, in the +/// shape `api-surfaces.md` asks for — "drive every fail-closed handshake rule through +/// representative routes in each module and assert the same headers, status, and `error.*` +/// code". No credential, no body, no real path variable: the gate answers first. A write with an +/// ancient protocol date is `426`; a read with the same date is admitted and answers whatever it +/// answers — a `401` here, since nothing is signed in — with the window on it; every gated +/// operation without the header is the gate's `400`. +#[tokio::test] +async fn a_representative_route_per_module_holds_the_handshake_before_anything_else() { + use capsule_server::upload::policy::{DEFAULT_PROTOCOL_MAX, DEFAULT_PROTOCOL_MIN}; + + let fixture = Fixture::working(); + for (method, path) in [ + ("POST", "/v1/auth/login"), + ("GET", "/v1/auth/profile"), + ("POST", "/v1/auth/totp/enroll"), + ("GET", "/v1/auth/devices"), + ("POST", "/v1/auth/devices/directory"), + ("PUT", "/v1/auth/escrow"), + ("POST", "/v1/auth/devices/enroll"), + ("POST", "/v1/albums"), + ("POST", "/v1/albums/anything/upgrade"), + ("GET", "/v1/quota"), + ("GET", "/v1/moderation/record"), + ("POST", "/v1/upload"), + ("GET", "/v1/upload/sessions"), + ("GET", "/v1/upload/anything/receipt"), + ("POST", "/v1/albums/anything/ops"), + ("GET", "/v1/sync"), + ("GET", "/v1/blob/deadbeef"), + ("POST", "/v1/storage/verify"), + ("GET", "/v1/assets/anything/receipts"), + ("POST", "/v1/shares"), + ("POST", "/v1/drops/links"), + ] { + let verb = kynos::http::Method::from_bytes(method.as_bytes()).expect("a method"); + + // Outside the window. + let ancient = fixture + .client + .method(verb.clone(), path) + .header("x-capsule-protocol", "2000-01-01") + .send() + .await; + ancient.assert_header("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN); + ancient.assert_header("x-capsule-protocol-max", DEFAULT_PROTOCOL_MAX); + ancient.assert_header("x-capsule-min-client-build", "0.0.0"); + if is_read(method) { + ancient.assert_status(StatusCode::UNAUTHORIZED); + } else { + ancient.assert_status(StatusCode::UPGRADE_REQUIRED); + let body: serde_json::Value = ancient.json(); + assert_eq!( + body["code"], "error.protocol.version_unsupported", + "{method} {path}: {body}" + ); + } + + // Absent: the gate's 400, still carrying the window. + let missing = fixture.client.raw().method(verb, path).send().await; + missing.assert_status(StatusCode::BAD_REQUEST); + missing.assert_header("x-capsule-protocol-min", DEFAULT_PROTOCOL_MIN); + let body: serde_json::Value = missing.json(); + assert_eq!( + body["code"], "error.request.malformed", + "{method} {path}: {body}" + ); + } +} + /// The router builds and describes itself. /// /// `openapi()` is the only path from this code to a description — there is no document to @@ -2587,7 +3037,7 @@ fn the_document_declares_openapi_32() { /// On an account of its own, for the reason [`profile_block`] uses one: switching a second /// factor on for the fixture's shared account would make every later `POST /v1/auth/login` in the /// walk answer `202`. -async fn totp_block(client: &kynos::test::TestClient, fixture: &Fixture) { +async fn totp_block(client: &support::Client, fixture: &Fixture) { const OWN_EMAIL: &str = "totp-walk@example.test"; const OWN_PASSWORD: &str = "correct horse battery staple"; @@ -2865,7 +3315,7 @@ async fn totp_block(client: &kynos::test::TestClient, fixtu /// password change on this surface closes every other session of the account it acts on, so /// running it against the shared account would sign the rest of the walk out — and the walk /// would then be testing the bearer scheme instead of the operations it had reached. -async fn profile_block(client: &kynos::test::TestClient, fixture: &Fixture) { +async fn profile_block(client: &support::Client, fixture: &Fixture) { const OWN_EMAIL: &str = "profile-walk@example.test"; const OWN_PASSWORD: &str = "correct horse battery staple"; const OWN_NEW_PASSWORD: &str = "a different correct horse"; @@ -3110,7 +3560,7 @@ async fn profile_block(client: &kynos::test::TestClient, fi /// Extracted so [`every_declared_response_is_exercised`] does not build one generator larger /// than a thread stack. Same client, so the recorder still sees these. async fn drops_block( - client: &kynos::test::TestClient, + client: &support::Client, fixture: &Fixture, bearer: &str, refresh_token: &str, @@ -3570,7 +4020,7 @@ async fn drops_block( /// Its own function so the walk's generator stays inside the test thread's stack; see the drops /// block for the same note. async fn upgrade_block( - client: &kynos::test::TestClient, + client: &support::Client, fixture: &Fixture, bearer: &str, refresh_token: &str, @@ -3773,3 +4223,192 @@ async fn upgrade_block( .await .assert_status(StatusCode::BAD_REQUEST); } + +/// Every declared response of `POST /v1/auth/oidc/authorize` and `POST /v1/auth/oidc/callback` +/// (`S-N1`), driven through the fixture's provider double. +/// +/// The 400 and 426 are the gate's and are produced by the document walk above; the 413 by the +/// body-size loop. Everything else is this block's: the extractor's 415/422, the ceremony's own +/// answers, and the two collaborators broken for one request each and repaired. +async fn oidc_block(client: &support::Client, fixture: &Fixture) { + const REDIRECT: &str = "http://127.0.0.1:4242/callback"; + + // 415 and 422 are the `Json` extractor's, on both operations. + for path in ["/v1/auth/oidc/authorize", "/v1/auth/oidc/callback"] { + client + .post(path) + .body("text/plain", "{}") + .send() + .await + .assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + client + .post(path) + .json(&json!({ "redirect_uri": 42, "state": 42, "code": 42 })) + .send() + .await + .assert_status(StatusCode::UNPROCESSABLE_ENTITY); + } + + // authorize: 503 (the store at its ceiling), 429 (the redirect host's budget), then 200, + // 404 (no provider), 500 (the provider), 500 (the ceremony store). + fixture.oidc_authorizations.set_full(true); + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::SERVICE_UNAVAILABLE); + fixture.oidc_authorizations.set_full(false); + for _ in 0..60 { + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": "http://[::1]:4242/budget" })) + .send() + .await; + } + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": "http://[::1]:4242/budget" })) + .send() + .await + .assert_status(StatusCode::TOO_MANY_REQUESTS); + let begun: serde_json::Value = client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + fixture.idp.set_configured(false); + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::NOT_FOUND); + fixture.idp.set_configured(true); + fixture.idp.set_unavailable(true); + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR); + fixture.idp.set_unavailable(false); + fixture.oidc_authorizations.set_unavailable(true); + client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR); + client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": begun["state"], "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR); + fixture.oidc_authorizations.set_unavailable(false); + + // callback: 401 (unknown state), 500 (the provider), 200, then 409 and 202. + client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": "never-issued", "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::UNAUTHORIZED); + fixture.idp.set_unavailable(true); + client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": begun["state"], "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR); + fixture.idp.set_unavailable(false); + + let begun: serde_json::Value = client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + let signed: TokenResponse = client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": begun["state"], "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + + // 409: a provider identity asserting the seeded password account's address. Never linked. + fixture + .idp + .set_identity(capsule_server::auth::oidc::VerifiedIdentity { + issuer: "https://idp.test".to_owned(), + subject: "impersonator".to_owned(), + email: Some(EMAIL.to_owned()), + email_verified: true, + }); + let begun: serde_json::Value = client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": begun["state"], "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::CONFLICT); + fixture + .idp + .set_identity(capsule_server::auth::oidc::VerifiedIdentity { + issuer: "https://idp.test".to_owned(), + subject: "subject-1".to_owned(), + email: Some("federated@example.test".to_owned()), + email_verified: true, + }); + + // 202: enroll and confirm a factor on the federated account, then sign in again. + let user = fixture + .tokens + .verify( + &signed.access_token, + capsule_server::auth::TokenKind::Access, + ) + .expect("the server's own signer reads it") + .user; + let bearer = format!("Bearer {}", signed.access_token); + client + .post("/v1/auth/totp/enroll") + .header("authorization", &bearer) + .send() + .await + .assert_status(StatusCode::OK); + fixture.clock.advance(jiff::SignedDuration::from_secs( + i64::try_from(capsule_server::auth::totp::STEP_SECONDS).expect("in range"), + )); + client + .post("/v1/auth/totp/verify-enrollment") + .header("authorization", &bearer) + .json(&json!({ "totp_code": support::totp_code(fixture, &user) })) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + let begun: serde_json::Value = client + .post("/v1/auth/oidc/authorize") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + client + .post("/v1/auth/oidc/callback") + .json(&json!({ "state": begun["state"], "code": support::GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::ACCEPTED); +} diff --git a/capsule-server/tests/drops.rs b/capsule-server/tests/drops.rs index 83450e8b..65026503 100644 --- a/capsule-server/tests/drops.rs +++ b/capsule-server/tests/drops.rs @@ -920,3 +920,68 @@ async fn a_gated_link_that_does_not_exist_is_still_indistinguishable() { .await; create_drop_with(&fixture, &opaque(65), &bytes, None, StatusCode::NOT_FOUND).await; } + +/// A saturated limiter partition must not impersonate an outage. +/// +/// The drop path charges a caller-supplied opaque id before it resolves the link, and its window +/// is an hour long — the cheapest partition on the surface to hold saturated, at under six +/// fabricated ids a second. Partitioning (decision 22) keeps that off the other surfaces; it +/// does not keep it off this one, so every first-time visitor to *any* drop link is refused while +/// the flood runs. They are told `429 error.drop.at_capacity` with a retry hint rather than the +/// `500 error.drop.unavailable` this used to render, which is the difference between a client +/// backing off and an operator being paged for an outage that is not happening. +#[tokio::test] +async fn a_saturated_limiter_partition_is_a_429_and_not_an_outage() { + let fixture = Fixture::with_counter_ceiling(1); + let bearer = fixture.bearer().await; + let bytes = payload(b'h', 64); + + let id = opaque(1); + provision( + &fixture, + &bearer, + &link_body(&id, json!({ "max_file_count": 1000 })), + StatusCode::CREATED, + ) + .await; + let other = opaque(2); + provision( + &fixture, + &bearer, + &link_body(&other, json!({ "max_file_count": 1000 })), + StatusCode::CREATED, + ) + .await; + + // One link fills the partition. + create_drop(&fixture, &id, &bytes, StatusCode::CREATED).await; + + // A guest arriving at a different link finds no room, and is told to wait rather than told + // the server is broken. + let problem = create_drop(&fixture, &other, &bytes, StatusCode::TOO_MANY_REQUESTS).await; + assert_eq!( + problem["code"], "error.drop.at_capacity", + "a full partition has a code of its own, distinct from a spent budget and from an outage" + ); + assert!(problem["retry_after"].as_u64().is_some_and(|s| s > 0)); + + // The link already being counted keeps working, to its own budget, and the spent-budget + // refusal keeps its own code — the two causes stay distinguishable on the wire. + create_drop(&fixture, &id, &bytes, StatusCode::CREATED).await; + for _ in 0..30 { + fixture + .client + .post(&format!("/d/{id}")) + .json(&json!({ + "content_type": "image/jpeg", + "size": bytes.len(), + "ciphertext_hash": checksum(&bytes), + "kem_ct": BASE64.encode([9_u8; 64]), + })) + .send() + .await; + } + let problem = create_drop(&fixture, &id, &bytes, StatusCode::TOO_MANY_REQUESTS).await; + assert_eq!(problem["code"], "error.drop.rate_limited"); + assert!(problem["retry_after"].as_u64().is_some_and(|s| s > 0)); +} diff --git a/capsule-server/tests/enroll.rs b/capsule-server/tests/enroll.rs index 1c8979ff..3dd3317c 100644 --- a/capsule-server/tests/enroll.rs +++ b/capsule-server/tests/enroll.rs @@ -392,13 +392,18 @@ async fn redemption_is_rate_limited_per_code() { let issued: Value = issue(&fixture, &bearer, StatusCode::OK).await.json(); let code = issued["code"].as_str().expect("a code").to_owned(); - // Wrong guesses against the *same* string are what the budget counts. + // Wrong guesses against the *same* string are what the budget counts. The string has to be + // shaped like a code — eight digits is the transcribable fallback's shape — because only a + // well-formed guess can name a pending enrollment, and only a well-formed guess gets a + // counter key of its own. Anything else shares one bucket; see + // `redemption_that_is_not_even_shaped_like_a_code_shares_one_bucket`. + const WRONG_BUT_CONSISTENT: &str = "00000001"; for _ in 0..10 { - redeem(&fixture, "wrong-but-consistent", StatusCode::NOT_FOUND).await; + redeem(&fixture, WRONG_BUT_CONSISTENT, StatusCode::NOT_FOUND).await; } let problem: Value = redeem( &fixture, - "wrong-but-consistent", + WRONG_BUT_CONSISTENT, StatusCode::TOO_MANY_REQUESTS, ) .await @@ -410,6 +415,36 @@ async fn redemption_is_rate_limited_per_code() { redeem(&fixture, &code, StatusCode::OK).await; } +/// A code that is not even shaped like one cannot mint a counter key. +/// +/// The presented value is an arbitrary caller-supplied string, and the limiter is charged before +/// the code is resolved — deliberately, so probing costs the prober. Keying on an unchecked +/// string would let an unauthenticated caller fill this partition a row at a time, so anything +/// malformed is charged to one fixed bucket instead. Well-formed guesses keep their own budgets, +/// which is the property the entropy argument for the short fallback rests on. +#[tokio::test] +async fn redemption_that_is_not_even_shaped_like_a_code_shares_one_bucket() { + let fixture = Fixture::working(); + + // Sixty distinct malformed strings: all refused on their merits, none rate-limited, because + // they share one bucket whose budget is sixty a minute. + for index in 0..60 { + redeem(&fixture, &format!("garbage-{index}"), StatusCode::NOT_FOUND).await; + } + // The sixty-first spends that shared bucket, on a string nothing has ever seen. + let problem: Value = redeem(&fixture, "garbage-fresh", StatusCode::TOO_MANY_REQUESTS) + .await + .json(); + assert_eq!(problem["code"], "error.enrollment.rate_limited"); + + // And a *well-formed* code is a different bucket, unaffected by the flood: the malformed + // path must not be able to deny a real redemption. + let bearer = fresh(&fixture).await; + let issued: Value = issue(&fixture, &bearer, StatusCode::OK).await.json(); + let code = issued["code"].as_str().expect("a code").to_owned(); + redeem(&fixture, &code, StatusCode::OK).await; +} + #[tokio::test] async fn the_redemption_limiter_counts_successes_too() { // Charged on every attempt whatever the outcome. A limiter that only counted failures would @@ -417,12 +452,51 @@ async fn the_redemption_limiter_counts_successes_too() { let fixture = Fixture::working(); let bearer = fresh(&fixture).await; + // Eight digits: the transcribable fallback's shape, so this guess gets its own budget the + // way a real code does. + const ONE_STRING: &str = "00000002"; for _ in 0..10 { - redeem(&fixture, "one-string", StatusCode::NOT_FOUND).await; + redeem(&fixture, ONE_STRING, StatusCode::NOT_FOUND).await; } // Even the *right* code, presented under a spent budget, is refused — because the budget is // keyed on the string presented, and this one has been presented ten times. let issued: Value = issue(&fixture, &bearer, StatusCode::OK).await.json(); let _ = issued; - redeem(&fixture, "one-string", StatusCode::TOO_MANY_REQUESTS).await; + redeem(&fixture, ONE_STRING, StatusCode::TOO_MANY_REQUESTS).await; +} + +/// A saturated limiter partition must not impersonate an outage. +/// +/// The redemption limiter charges the presented code before it is resolved, so a caller minting +/// well-formed codes fills the `EnrollmentRedemption` partition. A first-time redemption is then +/// refused while it lasts — and it is told `429 error.enrollment.at_capacity` with a retry hint, +/// not the `500 error.auth.unavailable` this used to render, which said the server was broken +/// when the limiter was working exactly as designed. +#[tokio::test] +async fn a_saturated_limiter_partition_is_a_429_and_not_an_outage() { + let fixture = Fixture::with_counter_ceiling(1); + + // One well-formed code fills the partition. + redeem(&fixture, "00000010", StatusCode::NOT_FOUND).await; + + // A second, never-seen code finds no room. + let problem: Value = redeem(&fixture, "00000011", StatusCode::TOO_MANY_REQUESTS) + .await + .json(); + assert_eq!( + problem["code"], "error.enrollment.at_capacity", + "a full partition has a code of its own, distinct from a spent budget and from an outage" + ); + assert!(problem["retry_after"].as_u64().is_some_and(|s| s > 0)); + + // The code already being counted keeps being counted, to its own budget, and the spent + // budget keeps its own code — so the two causes stay distinguishable. + for _ in 0..9 { + redeem(&fixture, "00000010", StatusCode::NOT_FOUND).await; + } + let problem: Value = redeem(&fixture, "00000010", StatusCode::TOO_MANY_REQUESTS) + .await + .json(); + assert_eq!(problem["code"], "error.enrollment.rate_limited"); + assert!(problem["retry_after"].as_u64().is_some_and(|s| s > 0)); } diff --git a/capsule-server/tests/oidc.rs b/capsule-server/tests/oidc.rs new file mode 100644 index 00000000..5cdeccef --- /dev/null +++ b/capsule-server/tests/oidc.rs @@ -0,0 +1,1134 @@ +//! The OIDC relying party (slice `S-N1`), over the real wire. +//! +//! `HttpIdentityProvider` is driven against the in-process mock provider in +//! `support::idp`, which serves discovery JSON, a JWK Set and a form-decoding token endpoint +//! that mints EdDSA-signed ID tokens. No network beyond loopback; no container. + +mod support; + +use std::sync::Arc; + +use capsule_server::auth::oidc::{ + AuthorizationRequest, ClaimRejection, HttpIdentityProvider, IdentityProvider, OidcSettings, + ProviderError, Redemption, RedirectPolicy, code_challenge, fresh_nonce, fresh_state, + fresh_verifier, +}; +use capsule_server::store::memory::ManualClock; +use capsule_server::store::{AuthorizationCode, OidcNonce, PkceVerifier}; +use jiff::SignedDuration; +use support::idp::{CLIENT_ID, Grant, MockIdp, Tamper}; + +const REDIRECT: &str = "http://127.0.0.1:4242/callback"; + +/// A clock the test drives, started at the wall clock: the mock provider mints `iat`/`exp` from +/// real time, and a relying party judging them from the epoch would refuse every token as not +/// yet valid. +fn wall_clock() -> Arc { + Arc::new(ManualClock::new(jiff::Timestamp::now())) +} + +/// A relying party over `idp`, on a clock the test drives. +fn relying_party(idp: &MockIdp, clock: Arc) -> HttpIdentityProvider { + HttpIdentityProvider::new( + OidcSettings { + issuer: idp.issuer(), + client_id: CLIENT_ID.to_owned(), + client_secret: None, + redirects: RedirectPolicy::new(None, true), + }, + HttpIdentityProvider::http_client(&[]).expect("the client builds"), + clock, + ) +} + +/// A relying party over `idp` trusting `roots` beyond the public ones. +fn relying_party_trusting( + idp: &MockIdp, + clock: Arc, + roots: &[reqwest::Certificate], +) -> HttpIdentityProvider { + HttpIdentityProvider::new( + OidcSettings { + issuer: idp.issuer(), + client_id: CLIENT_ID.to_owned(), + client_secret: None, + redirects: RedirectPolicy::new(None, true), + }, + HttpIdentityProvider::http_client(roots).expect("the client builds"), + clock, + ) +} + +/// One begun ceremony: what the relying party sent, and what it holds. +struct Ceremony { + url: reqwest::Url, + nonce: OidcNonce, + verifier: PkceVerifier, +} + +async fn begin(rp: &HttpIdentityProvider) -> Ceremony { + let state = fresh_state(); + let nonce = fresh_nonce(); + let verifier = fresh_verifier(); + let challenge = code_challenge(&verifier); + let url = rp + .authorization_url(&AuthorizationRequest { + redirect_uri: REDIRECT, + state: &state, + nonce: &nonce, + code_challenge: &challenge, + }) + .await + .expect("the provider answers"); + Ceremony { + url: reqwest::Url::parse(&url).expect("a URL"), + nonce, + verifier, + } +} + +fn query(url: &reqwest::Url, name: &str) -> String { + url.query_pairs().find(|(k, _)| k == name).map_or_else( + || panic!("the authorization URL carries {name}"), + |(_, v)| v.into_owned(), + ) +} + +/// A grant the provider will mint a conforming token for, from what the ceremony sent it. +fn grant_for(ceremony: &Ceremony, tamper: Tamper) -> Grant { + Grant { + code_challenge: query(&ceremony.url, "code_challenge"), + nonce: query(&ceremony.url, "nonce"), + redirect_uri: query(&ceremony.url, "redirect_uri"), + subject: "subject-1".to_owned(), + email: Some("somebody@example.test".to_owned()), + tamper, + } +} + +async fn redeem( + rp: &HttpIdentityProvider, + ceremony: &Ceremony, + code: &str, +) -> Result { + let code = AuthorizationCode::new(code); + rp.redeem(&Redemption { + code: &code, + verifier: &ceremony.verifier, + redirect_uri: REDIRECT, + nonce: &ceremony.nonce, + }) + .await +} + +#[tokio::test] +async fn the_authorization_url_carries_the_whole_request_and_discovery_is_fetched_once() { + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + + let first = begin(&rp).await; + assert_eq!(first.url.path(), "/idp/authorize"); + assert_eq!(query(&first.url, "response_type"), "code"); + assert_eq!(query(&first.url, "client_id"), CLIENT_ID); + assert_eq!(query(&first.url, "redirect_uri"), REDIRECT); + assert_eq!(query(&first.url, "scope"), "openid email"); + assert_eq!(query(&first.url, "code_challenge_method"), "S256"); + assert_eq!( + query(&first.url, "code_challenge"), + code_challenge(&first.verifier) + ); + assert_eq!(query(&first.url, "nonce"), first.nonce.as_str()); + assert!(!query(&first.url, "state").is_empty()); + + let _second = begin(&rp).await; + assert_eq!( + idp.discovery_hits(), + 1, + "the discovery document is cached across ceremonies" + ); +} + +#[tokio::test] +async fn a_redirect_the_policy_refuses_costs_no_round_trip() { + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + let state = fresh_state(); + let nonce = fresh_nonce(); + let error = rp + .authorization_url(&AuthorizationRequest { + redirect_uri: "https://evil.example.test/cb", + state: &state, + nonce: &nonce, + code_challenge: "x", + }) + .await + .expect_err("refused"); + assert!( + matches!(error, ProviderError::RedirectRefused { .. }), + "{error:?}" + ); + assert_eq!(idp.discovery_hits(), 0); +} + +#[tokio::test] +async fn a_provider_that_is_down_is_unavailable_not_a_refusal() { + let idp = MockIdp::start().await; + idp.set_discovery_down(true); + let rp = relying_party(&idp, wall_clock()); + let state = fresh_state(); + let nonce = fresh_nonce(); + let error = rp + .authorization_url(&AuthorizationRequest { + redirect_uri: REDIRECT, + state: &state, + nonce: &nonce, + code_challenge: "x", + }) + .await + .expect_err("unavailable"); + assert!( + matches!(error, ProviderError::Unavailable { .. }), + "{error:?}" + ); +} + +#[tokio::test] +async fn a_conforming_exchange_yields_the_identity_over_a_real_form_post() { + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + let ceremony = begin(&rp).await; + let code = idp.grant(grant_for(&ceremony, Tamper::None)); + + let identity = redeem(&rp, &ceremony, &code).await.expect("verifies"); + assert_eq!(identity.issuer, idp.issuer()); + assert_eq!(identity.subject, "subject-1"); + assert_eq!(identity.email.as_deref(), Some("somebody@example.test")); + assert!(identity.email_verified); + assert_eq!(idp.token_hits(), 1); + assert_eq!(idp.jwks_hits(), 1, "the key set is fetched on first use"); + + // A second ceremony: the key set is reused, and the provider's single-use code is spent. + let again = begin(&rp).await; + let code = idp.grant(grant_for(&again, Tamper::None)); + redeem(&rp, &again, &code).await.expect("verifies"); + assert_eq!(idp.jwks_hits(), 1, "a known kid needs no refetch"); + let replay = redeem(&rp, &again, &code).await.expect_err("spent"); + assert!( + matches!(replay, ProviderError::ExchangeRefused { .. }), + "{replay:?}" + ); +} + +#[tokio::test] +async fn a_wrong_verifier_is_refused_at_the_token_endpoint() { + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + let ceremony = begin(&rp).await; + let code = idp.grant(grant_for(&ceremony, Tamper::None)); + + // The relying party redeems with a verifier from *another* ceremony: the S256 challenge the + // provider holds does not match, and the exchange — not the token — is what fails. + let other = Ceremony { + url: ceremony.url.clone(), + nonce: ceremony.nonce.clone(), + verifier: fresh_verifier(), + }; + let error = redeem(&rp, &other, &code).await.expect_err("refused"); + match error { + ProviderError::ExchangeRefused { detail } => assert!(detail.contains("PKCE"), "{detail}"), + other => panic!("expected an exchange refusal, got {other:?}"), + } +} + +#[tokio::test] +async fn a_rotated_key_is_fetched_once_and_then_trusted() { + let idp = MockIdp::start().await; + let clock = wall_clock(); + let rp = relying_party(&idp, clock.clone()); + + let first = begin(&rp).await; + let code = idp.grant(grant_for(&first, Tamper::None)); + redeem(&rp, &first, &code).await.expect("verifies under k1"); + assert_eq!(idp.jwks_hits(), 1); + + // A rotation happens minutes after the last fetch, not inside the refetch floor. + clock.advance(SignedDuration::from_secs(61)); + idp.rotate("k2"); + let second = begin(&rp).await; + let code = idp.grant(grant_for(&second, Tamper::None)); + redeem(&rp, &second, &code) + .await + .expect("an unknown kid triggers one refetch, and then verifies"); + assert_eq!(idp.jwks_hits(), 2); + + let third = begin(&rp).await; + let code = idp.grant(grant_for(&third, Tamper::None)); + redeem(&rp, &third, &code).await.expect("k2 is now known"); + assert_eq!(idp.jwks_hits(), 2, "no refetch for a key already cached"); +} + +#[tokio::test] +async fn an_unpublished_key_is_refetched_once_refused_and_then_floored() { + let idp = MockIdp::start().await; + let clock = wall_clock(); + let rp = relying_party(&idp, clock.clone()); + + let warm = begin(&rp).await; + let code = idp.grant(grant_for(&warm, Tamper::None)); + redeem(&rp, &warm, &code).await.expect("verifies"); + assert_eq!(idp.jwks_hits(), 1); + clock.advance(SignedDuration::from_secs(61)); + + // A token signed by a key the provider never published: one refetch, still unknown, refused. + let forged = begin(&rp).await; + let code = idp.grant(grant_for(&forged, Tamper::UnpublishedKey)); + let error = redeem(&rp, &forged, &code).await.expect_err("refused"); + assert!( + matches!( + error, + ProviderError::TokenRejected(ClaimRejection::UnknownKey { .. }) + ), + "{error:?}" + ); + assert_eq!(idp.jwks_hits(), 2); + + // Inside the floor, a second forged kid is refused **without** another fetch. + let forged = begin(&rp).await; + let code = idp.grant(grant_for(&forged, Tamper::UnpublishedKey)); + let error = redeem(&rp, &forged, &code).await.expect_err("refused"); + assert!( + matches!( + error, + ProviderError::TokenRejected(ClaimRejection::UnknownKey { .. }) + ), + "{error:?}" + ); + assert_eq!(idp.jwks_hits(), 2, "the floor held"); + + // Past the floor, the evidence is honoured again. + clock.advance(SignedDuration::from_secs(61)); + let forged = begin(&rp).await; + let code = idp.grant(grant_for(&forged, Tamper::UnpublishedKey)); + let _ = redeem(&rp, &forged, &code).await.expect_err("refused"); + assert_eq!(idp.jwks_hits(), 3); +} + +#[tokio::test] +async fn every_tampered_token_is_refused_with_its_own_reason() { + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + + idp.publish_oct_key("k-oct"); + + /// Whether a rejection is the one a tamper should produce. + type Expected = fn(&ClaimRejection) -> bool; + let cases: [(Tamper, Expected); 7] = [ + (Tamper::BadSignature, |r| { + matches!(r, ClaimRejection::Signature) + }), + (Tamper::OctKey, |r| { + matches!(r, ClaimRejection::UnusableKey { .. }) + }), + ( + Tamper::Issuer("https://somebody-else.test".to_owned()), + |r| matches!(r, ClaimRejection::Issuer { .. }), + ), + (Tamper::Audience("another-client".to_owned()), |r| { + matches!(r, ClaimRejection::Audience) + }), + (Tamper::Expired, |r| { + matches!(r, ClaimRejection::Expired { .. }) + }), + ( + Tamper::Nonce("a-nonce-from-another-ceremony".to_owned()), + |r| matches!(r, ClaimRejection::Nonce), + ), + (Tamper::AlgNone, |r| { + matches!( + r, + ClaimRejection::Malformed { .. } | ClaimRejection::AlgorithmRefused { .. } + ) + }), + ]; + for (tamper, expected) in cases { + let ceremony = begin(&rp).await; + let code = idp.grant(grant_for(&ceremony, tamper.clone())); + match redeem(&rp, &ceremony, &code).await { + Err(ProviderError::TokenRejected(reason)) => { + assert!(expected(&reason), "{tamper:?} was refused as {reason:?}"); + } + other => panic!("{tamper:?} was not refused as a token rejection: {other:?}"), + } + } +} + +#[tokio::test] +async fn a_provider_behind_a_private_ca_is_reached_only_with_its_bundle() { + // The enterprise case `OIDC_CA_BUNDLE` exists for: the provider's certificate chains to a + // CA no public root store knows. + let idp = MockIdp::start_tls().await; + assert!(idp.issuer().starts_with("https://127.0.0.1:")); + + // Without the bundle the handshake fails, and it fails as the provider being unreachable — + // not as a token or exchange refusal. + let untrusting = relying_party(&idp, wall_clock()); + let state = fresh_state(); + let nonce = fresh_nonce(); + let error = untrusting + .authorization_url(&AuthorizationRequest { + redirect_uri: REDIRECT, + state: &state, + nonce: &nonce, + code_challenge: "x", + }) + .await + .expect_err("refused at the handshake"); + assert!( + matches!(error, ProviderError::Unavailable { .. }), + "{error:?}" + ); + assert_eq!(idp.discovery_hits(), 0, "nothing reached the provider"); + + // With it, the whole handshake round-trips over TLS. + let roots = reqwest::Certificate::from_pem_bundle(idp.ca_pem().as_bytes()).expect("a bundle"); + assert_eq!(roots.len(), 1); + let trusting = relying_party_trusting(&idp, wall_clock(), &roots); + let ceremony = begin(&trusting).await; + let code = idp.grant(grant_for(&ceremony, Tamper::None)); + let identity = redeem(&trusting, &ceremony, &code) + .await + .expect("verifies over TLS"); + assert_eq!(identity.subject, "subject-1"); +} + +#[tokio::test] +async fn a_key_set_older_than_an_hour_is_read_again_without_evidence() { + // A revoked key never produces the unknown-kid evidence; the ceiling is what retires it. + let idp = MockIdp::start().await; + let clock = wall_clock(); + let rp = relying_party(&idp, clock.clone()); + + let first = begin(&rp).await; + let code = idp.grant(grant_for(&first, Tamper::None)); + redeem(&rp, &first, &code).await.expect("verifies"); + assert_eq!(idp.jwks_hits(), 1); + + clock.advance(SignedDuration::from_mins(59)); + let second = begin(&rp).await; + let code = idp.grant(grant_for(&second, Tamper::None)); + redeem(&rp, &second, &code).await.expect("verifies"); + assert_eq!(idp.jwks_hits(), 1, "inside the ceiling the set is trusted"); + + clock.advance(SignedDuration::from_mins(2)); + let third = begin(&rp).await; + let code = idp.grant(grant_for(&third, Tamper::None)); + redeem(&rp, &third, &code).await.expect("verifies"); + assert_eq!(idp.jwks_hits(), 2, "past the ceiling it is read again"); +} + +#[tokio::test] +async fn a_nonce_from_another_ceremony_is_refused_even_when_the_provider_echoes_it() { + // The provider echoes whatever nonce the authorization request carried; here the relying + // party redeems with a *different* pending record's nonce, which is what a code stolen from + // one ceremony and replayed into another looks like from the callback. + let idp = MockIdp::start().await; + let rp = relying_party(&idp, wall_clock()); + let ceremony = begin(&rp).await; + let code = idp.grant(grant_for(&ceremony, Tamper::None)); + let other = Ceremony { + url: ceremony.url.clone(), + nonce: fresh_nonce(), + verifier: ceremony.verifier.clone(), + }; + let error = redeem(&rp, &other, &code).await.expect_err("refused"); + assert!( + matches!(error, ProviderError::TokenRejected(ClaimRejection::Nonce)), + "{error:?}" + ); +} + +// =========================================================================================== +// The routes, over the fixture +// =========================================================================================== + +use capsule_server::auth::oidc::VerifiedIdentity; +use capsule_server::routes::auth::TokenResponse; +use capsule_server::store::{AuthStateStore, Clock as _, OIDC_AUTHORIZATION_TTL}; +use kynos::http::StatusCode; +use serde_json::{Value, json}; +use support::{Fixture, GOOD_CODE}; + +/// The `error.*` code an RFC 9457 problem body publishes as its `code` extension member. +fn code_of(body: &Value) -> &str { + body.get("code") + .and_then(Value::as_str) + .unwrap_or("") +} + +/// Begin a ceremony through the route and return its body. +async fn authorize(fixture: &Fixture, redirect_uri: &str) -> Value { + fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": redirect_uri })) + .send() + .await + .assert_status(StatusCode::OK) + .json() +} + +/// Present `state` and `code` to the callback, with the advisory fields a client may add. +async fn callback(fixture: &Fixture, body: Value) -> kynos::test::TestResponse { + fixture + .client + .post("/v1/auth/oidc/callback") + .header("accept", "application/json") + .json(&body) + .send() + .await +} + +#[tokio::test] +async fn a_begun_ceremony_publishes_the_url_the_state_and_its_deadline() { + let fixture = Fixture::working(); + let body = authorize(&fixture, REDIRECT).await; + + let state = body["state"].as_str().expect("a state"); + assert!(!state.is_empty()); + let url = body["authorization_url"].as_str().expect("a URL"); + assert!(url.contains(state), "the URL carries the state: {url}"); + assert_eq!( + body["expires_by"], + u64::try_from((fixture.clock.now() + OIDC_AUTHORIZATION_TTL).as_second()) + .expect("positive"), + "the deadline is the store's TTL from now, absolute" + ); + + // The double saw exactly one request, carrying the admitted redirect and an S256 challenge. + let requests = fixture.idp.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].redirect_uri, REDIRECT); + assert_eq!(requests[0].state, state); + assert_eq!(requests[0].code_challenge.len(), 43); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_callback_opens_a_session_exactly_as_a_password_login_does() { + let fixture = Fixture::working(); + let begun = authorize(&fixture, REDIRECT).await; + + let body: TokenResponse = callback( + &fixture, + json!({ + "state": begun["state"], + "code": GOOD_CODE, + "cohort_hash": "a-physical-phone", + "device_id": "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e6f", + }), + ) + .await + .assert_status(StatusCode::OK) + .json(); + assert_eq!(body.token_type, "Bearer"); + + // The token names a session the store holds, with the advisory provenance recorded. + let verified = fixture + .tokens + .verify(&body.access_token, capsule_server::auth::TokenKind::Access) + .expect("the server's own signer reads it"); + let open = fixture + .sessions + .sessions_for_user(&verified.user) + .await + .expect("store answers"); + assert_eq!(open.len(), 1); + assert_eq!(open[0].cohort_hash.as_deref(), Some("a-physical-phone")); + assert!(open[0].device_id.is_some()); + + // A second sign-in for the same `(issuer, subject)` is the **same** account. + let again = authorize(&fixture, REDIRECT).await; + let second: TokenResponse = callback( + &fixture, + json!({ "state": again["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::OK) + .json(); + let second = fixture + .tokens + .verify( + &second.access_token, + capsule_server::auth::TokenKind::Access, + ) + .expect("verifies"); + assert_eq!(second.user, verified.user); + + // And the pair refreshes, which is the acceptance test's "immediately usable". + fixture + .client + .post("/v1/auth/refresh") + .header("accept", "application/json") + .json(&json!({ "refresh_token": body.refresh_token })) + .send() + .await + .assert_status(StatusCode::OK); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_replayed_state_is_refused_and_so_is_an_expired_one() { + let fixture = Fixture::working(); + let begun = authorize(&fixture, REDIRECT).await; + callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::OK); + + let replayed: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&replayed), "error.auth.oidc_state_invalid"); + + let stale = authorize(&fixture, REDIRECT).await; + fixture.clock.advance(OIDC_AUTHORIZATION_TTL); + let expired: Value = callback( + &fixture, + json!({ "state": stale["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&expired), "error.auth.oidc_state_invalid"); + + let unknown: Value = callback( + &fixture, + json!({ "state": "never-issued", "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&unknown), "error.auth.oidc_state_invalid"); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_failed_callback_burns_the_state_too() { + // A ceremony that survived a failed callback would be one an attacker could retry a stolen + // code against; the state is consumed whatever the provider says next. + let fixture = Fixture::working(); + let begun = authorize(&fixture, REDIRECT).await; + let refused: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": "not-the-code" }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&refused), "error.auth.oidc_exchange_failed"); + + let retried: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&retried), "error.auth.oidc_state_invalid"); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_refused_token_is_one_code_on_the_wire() { + let fixture = Fixture::working(); + fixture.idp.set_token_rejected(true); + let begun = authorize(&fixture, REDIRECT).await; + let body: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_token_invalid"); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn an_address_another_account_holds_is_refused_never_linked() { + let fixture = Fixture::working(); + // The seeded password account's address, asserted by a provider identity. + fixture.idp.set_identity(VerifiedIdentity { + issuer: "https://idp.test".to_owned(), + subject: "impersonator".to_owned(), + email: Some(support::EMAIL.to_owned()), + email_verified: true, + }); + let begun = authorize(&fixture, REDIRECT).await; + let body: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::CONFLICT) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_address_taken"); + + // The password account's sessions are untouched: nothing was linked and nothing opened. + let open = fixture + .sessions + .sessions_for_user(&support::user()) + .await + .expect("store answers"); + assert!(open.is_empty()); + + // The same address asserted **unverified** is not a claim on anything: the identity gets + // an account of its own rather than a refusal, so an unverified sign-up at the provider + // cannot hold the address's owner out. + fixture.idp.set_identity(VerifiedIdentity { + issuer: "https://idp.test".to_owned(), + subject: "unverified-claimant".to_owned(), + email: Some(support::EMAIL.to_owned()), + email_verified: false, + }); + let begun = authorize(&fixture, REDIRECT).await; + callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::OK); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_confirmed_second_factor_turns_the_callback_into_a_challenge() { + let fixture = Fixture::working(); + // Sign in once so the federated account exists, then enroll a factor on it. + let begun = authorize(&fixture, REDIRECT).await; + let first: TokenResponse = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::OK) + .json(); + let user = fixture + .tokens + .verify(&first.access_token, capsule_server::auth::TokenKind::Access) + .expect("verifies") + .user; + let bearer = format!("Bearer {}", first.access_token); + fixture + .client + .post("/v1/auth/totp/enroll") + .header("authorization", &bearer) + .header("accept", "application/json") + .send() + .await + .assert_status(StatusCode::OK); + fixture + .client + .post("/v1/auth/totp/verify-enrollment") + .header("authorization", &bearer) + .header("accept", "application/json") + .json(&json!({ "totp_code": support::totp_code(&fixture, &user) })) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + + // The next federated sign-in is a `202`, exactly as a password sign-in would be. + let again = authorize(&fixture, REDIRECT).await; + let challenge: Value = callback( + &fixture, + json!({ "state": again["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::ACCEPTED) + .json(); + assert!(challenge["mfa_token"].is_string()); + assert!( + challenge.get("access_token").is_none(), + "no session was opened" + ); + + // And the same completing request finishes it, with the cohort landing on the session. + fixture.clock.advance(SignedDuration::from_secs( + i64::try_from(capsule_server::auth::totp::STEP_SECONDS).expect("fits"), + )); + fixture + .client + .post("/v1/auth/login/verify-totp") + .header("accept", "application/json") + .json(&json!({ + "mfa_token": challenge["mfa_token"], + "totp_code": support::totp_code(&fixture, &user), + "cohort_hash": "the-phone", + })) + .send() + .await + .assert_status(StatusCode::OK); + let open = fixture + .sessions + .sessions_for_user(&user) + .await + .expect("store answers"); + assert!( + open.iter() + .any(|s| s.cohort_hash.as_deref() == Some("the-phone")) + ); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_redirect_the_policy_refuses_is_a_400_and_writes_nothing() { + let fixture = Fixture::working(); + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": "https://evil.example.test/cb" })) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_redirect_invalid"); + assert!(fixture.idp.requests().is_empty()); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn an_unconfigured_deployment_answers_404_and_publishes_no_endpoints() { + let fixture = Fixture::working(); + fixture.idp.set_configured(false); + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::NOT_FOUND) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_not_configured"); + + // A callback on such a deployment finds no ceremony, and says so with the one code. + let body: Value = callback(&fixture, json!({ "state": "anything", "code": GOOD_CODE })) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_state_invalid"); + fixture.client.assert_conformance(); + + // The record, built without `with_oidc`, publishes `null`. + let info = capsule_server::discovery::ServerInfo::new( + "capsule.test", + "https://capsule.test/v1", + capsule_server::discovery::ProtocolWindow { + min: "2026-01-01".to_owned(), + max: "2026-01-01".to_owned(), + }, + Vec::new(), + ); + assert!(info.auth().oidc.is_none()); +} + +#[tokio::test] +async fn the_published_oidc_endpoints_are_the_ones_the_server_serves() { + let fixture = Fixture::working(); + let record: Value = fixture + .client + .raw() + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(StatusCode::OK) + .json(); + let authorize_url = record["auth"]["oidc"]["authorize"] + .as_str() + .expect("published") + .to_owned(); + let callback_url = record["auth"]["oidc"]["callback"] + .as_str() + .expect("published") + .to_owned(); + assert_eq!(authorize_url, "https://capsule.test/v1/auth/oidc/authorize"); + assert_eq!(callback_url, "https://capsule.test/v1/auth/oidc/callback"); + + let path = authorize_url + .strip_prefix("https://capsule.test") + .expect("under the base"); + let begun: Value = fixture + .client + .post(path) + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::OK) + .json(); + let path = callback_url + .strip_prefix("https://capsule.test") + .expect("under the base"); + fixture + .client + .post(path) + .header("accept", "application/json") + .json(&json!({ "state": begun["state"], "code": GOOD_CODE })) + .send() + .await + .assert_status(StatusCode::OK); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn beginning_ceremonies_is_budgeted_per_redirect_host_and_bounded_by_the_store() { + let fixture = Fixture::working(); + // The budget: sixty a minute per host, then 429 with the auth catalog's retry code. + for _ in 0..60 { + authorize(&fixture, REDIRECT).await; + } + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::TOO_MANY_REQUESTS) + .json(); + assert_eq!(code_of(&body), "error.auth.rate_limited"); + // Another host is another bucket. + authorize(&fixture, "http://[::1]:4242/callback").await; + // And the window passes. + fixture.clock.advance(SignedDuration::from_mins(1)); + authorize(&fixture, REDIRECT).await; + + // The store's ceiling: a 503 with the retryable code, and nothing is asked of the provider + // that the store then cannot keep. + fixture.oidc_authorizations.set_full(true); + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::SERVICE_UNAVAILABLE) + .json(); + assert_eq!( + code_of(&body), + "error.auth.oidc_at_capacity", + "at capacity is its own code: a client switching on the code, as api-surfaces.md \ + requires, must be able to tell a short retry from an outage" + ); + fixture.oidc_authorizations.set_full(false); + fixture.client.assert_conformance(); +} + +/// A refused redirect must not be able to mint a counter key. +/// +/// The authorize's budget is keyed on the redirect URI's host, and the redirect URI is +/// caller-supplied. If the key were charged before the policy validated the URI, an +/// unauthenticated caller looping this route with a fresh host each time would get a `400` every +/// time — nothing written to the ceremony store, decision 17 satisfied — while permanently adding +/// one row per request to the counter store, which never purges and is shared with the login and +/// second-factor limiters. So: many distinct invalid hosts, and afterwards exactly one key. +#[tokio::test] +async fn a_refused_redirect_cannot_mint_a_counter_key_and_is_still_throttled() { + let fixture = Fixture::working(); + assert_eq!( + fixture.counters.len(), + 0, + "the fixture starts with no counter windows" + ); + + // Sixty distinct hosts, none of them admitted: sixty `400`s, and the refusal budget is + // exactly spent. + for index in 0..60 { + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": format!("https://{index}.attacker.example/cb") })) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_redirect_invalid"); + } + + assert_eq!( + fixture.counters.len(), + 1, + "sixty caller-chosen hosts must share one bucket, not mint sixty" + ); + + // And the refusals are throttled rather than free: the sixty-first is `429`, on a host + // nothing has ever seen before. + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": "https://fresh.attacker.example/cb" })) + .send() + .await + .assert_status(StatusCode::TOO_MANY_REQUESTS) + .json(); + assert_eq!(code_of(&body), "error.auth.rate_limited"); + assert_eq!(fixture.counters.len(), 1, "the 429 minted nothing either"); + + // The admitted path is a different bucket and is unaffected by the spent refusal budget: + // a flood of invalid redirects must not deny sign-in to the clients the policy admits. + authorize(&fixture, REDIRECT).await; + assert_eq!( + fixture.counters.len(), + 2, + "the admitted host is its own bucket" + ); + fixture.client.assert_conformance(); +} + +/// A URI that is not a URL at all is refused and buckets with the other refusals. +#[tokio::test] +async fn a_redirect_that_is_not_a_url_mints_no_key_of_its_own() { + let fixture = Fixture::working(); + for candidate in [ + "not a url", + "", + " ", + "javascript:alert(1)", + "/relative/cb", + ] { + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": candidate })) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_redirect_invalid"); + } + assert_eq!(fixture.counters.len(), 1); + fixture.client.assert_conformance(); +} + +#[tokio::test] +async fn a_provider_or_store_outage_is_a_500_with_the_code_that_names_it() { + let fixture = Fixture::working(); + + fixture.idp.set_unavailable(true); + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_unavailable"); + fixture.idp.set_unavailable(false); + + fixture.oidc_authorizations.set_unavailable(true); + let body: Value = fixture + .client + .post("/v1/auth/oidc/authorize") + .header("accept", "application/json") + .json(&json!({ "redirect_uri": REDIRECT })) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR) + .json(); + assert_eq!(code_of(&body), "error.auth.unavailable"); + let body: Value = callback(&fixture, json!({ "state": "x", "code": GOOD_CODE })) + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR) + .json(); + assert_eq!(code_of(&body), "error.auth.unavailable"); + fixture.oidc_authorizations.set_unavailable(false); + + let begun = authorize(&fixture, REDIRECT).await; + fixture.idp.set_unavailable(true); + let body: Value = callback( + &fixture, + json!({ "state": begun["state"], "code": GOOD_CODE }), + ) + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR) + .json(); + assert_eq!(code_of(&body), "error.auth.oidc_unavailable"); + fixture.idp.set_unavailable(false); + fixture.client.assert_conformance(); +} + +// =========================================================================================== +// The routes, over the real adapter and the mock provider +// =========================================================================================== + +#[tokio::test] +async fn the_whole_handshake_round_trips_over_the_wire() { + let idp = MockIdp::start().await; + let provider = Arc::new(relying_party(&idp, wall_clock())); + let fixture = Fixture::with_identity_provider(provider); + + let begun = authorize(&fixture, REDIRECT).await; + let url = + reqwest::Url::parse(begun["authorization_url"].as_str().expect("a URL")).expect("parses"); + // The provider's redirect carries the code; the client posts it with the state. + let code = idp.grant(Grant { + code_challenge: query(&url, "code_challenge"), + nonce: query(&url, "nonce"), + redirect_uri: query(&url, "redirect_uri"), + subject: "wire-subject".to_owned(), + email: Some("wire@example.test".to_owned()), + tamper: Tamper::None, + }); + let body: TokenResponse = callback(&fixture, json!({ "state": begun["state"], "code": code })) + .await + .assert_status(StatusCode::OK) + .json(); + assert!(!body.access_token.is_empty()); + assert_eq!(idp.token_hits(), 1); + + // Tampered tokens through the route: each is the one wire code. + for tamper in [ + Tamper::Issuer("https://somebody-else.test".to_owned()), + Tamper::Audience("another-client".to_owned()), + Tamper::Expired, + Tamper::Nonce("another-nonce".to_owned()), + Tamper::AlgNone, + ] { + let begun = authorize(&fixture, REDIRECT).await; + let url = reqwest::Url::parse(begun["authorization_url"].as_str().expect("a URL")) + .expect("parses"); + let code = idp.grant(Grant { + code_challenge: query(&url, "code_challenge"), + nonce: query(&url, "nonce"), + redirect_uri: query(&url, "redirect_uri"), + subject: "wire-subject".to_owned(), + email: None, + tamper: tamper.clone(), + }); + let body: Value = callback(&fixture, json!({ "state": begun["state"], "code": code })) + .await + .assert_status(StatusCode::UNAUTHORIZED) + .json(); + assert_eq!( + code_of(&body), + "error.auth.oidc_token_invalid", + "{tamper:?}" + ); + } + fixture.client.assert_conformance(); +} diff --git a/capsule-server/tests/share.rs b/capsule-server/tests/share.rs index 70964603..ed8a5700 100644 --- a/capsule-server/tests/share.rs +++ b/capsule-server/tests/share.rs @@ -451,3 +451,53 @@ async fn probing_a_link_that_does_not_exist_still_costs_the_prober() { ) .await; } + +/// A saturated limiter partition must not impersonate an outage. +/// +/// The share path charges a caller-supplied opaque id before it resolves it, so an attacker +/// minting ids fills the `ShareLink` partition. Partitioning (decision 22) stops that reaching +/// the other surfaces; it does not stop it reaching *this* one, and a first-time visitor to any +/// share link is refused while it lasts. What they are told matters: `429` with a code of its own +/// and a retry hint means a client backs off and an operator paged on `5xx` is not paged at all, +/// where the `500 error.share.unavailable` this used to render said "the server is broken". +#[tokio::test] +async fn a_saturated_limiter_partition_is_a_429_and_not_an_outage() { + let fixture = Fixture::with_counter_ceiling(1); + let bearer = fixture.bearer().await; + let (id, _, _) = live_link(&fixture, &bearer, 1).await; + + // One key fills the partition. + fetch(&fixture, &format!("/s/{id}"), StatusCode::OK).await; + + // A second, never-seen link finds no room. It is told to wait, not that the server broke. + let (other, _, _) = live_link(&fixture, &bearer, 2).await; + let problem: Value = fetch( + &fixture, + &format!("/s/{other}"), + StatusCode::TOO_MANY_REQUESTS, + ) + .await + .json(); + assert_eq!( + problem["code"], "error.share.at_capacity", + "a full partition has a code of its own, distinct from a spent budget and from an outage" + ); + let retry_after = problem["retry_after"] + .as_u64() + .expect("a retry hint the client can act on"); + assert!(retry_after > 0); + + // And the link that already holds a window keeps being served: the flood locks out new + // keys, never the ones already counted. + fetch(&fixture, &format!("/s/{id}"), StatusCode::OK).await; + + // The spent-budget 429 stays its own code, so the two causes remain distinguishable. + for _ in 0..60 { + fixture.client.get(&format!("/s/{id}")).send().await; + } + let problem: Value = fetch(&fixture, &format!("/s/{id}"), StatusCode::TOO_MANY_REQUESTS) + .await + .json(); + assert_eq!(problem["code"], "error.share.rate_limited"); + assert!(problem["retry_after"].as_u64().is_some_and(|s| s > 0)); +} diff --git a/capsule-server/tests/support/idp.rs b/capsule-server/tests/support/idp.rs new file mode 100644 index 00000000..a2325de4 --- /dev/null +++ b/capsule-server/tests/support/idp.rs @@ -0,0 +1,554 @@ +//! An in-process OpenID Connect provider, speaking the real wire on loopback. +//! +//! Serves the discovery document, a JWK Set and a form-decoding token endpoint that mints +//! EdDSA-signed ID tokens, so `HttpIdentityProvider` is exercised over exactly the bytes a real +//! provider sends: discovery JSON, `application/jwk-set+json`, an +//! `application/x-www-form-urlencoded` `POST`, a compact JWS. Every negative case the relying +//! party has to refuse is a [`Tamper`] on the grant the test issues. +//! +//! `mise run test-rust` runs offline and container-free, so this stands in for the testcontainer +//! provider `design/authentication.md` names; the dex service in `capsule-server/compose.yaml` is +//! the manual run against a real one. + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jsonwebtoken::jwk::{ + AlgorithmParameters, CommonParameters, EllipticCurve, Jwk, JwkSet, KeyAlgorithm, + OctetKeyPairParameters, OctetKeyPairType, +}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use ring::signature::KeyPair as _; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// The `client_id` the provider knows. +pub(crate) const CLIENT_ID: &str = "capsule"; + +/// How the provider should misbehave when it mints a token for one grant. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum Tamper { + /// A conforming token. + #[default] + None, + /// `iss` set to this. + Issuer(String), + /// `aud` set to this. + Audience(String), + /// `exp` an hour in the past. + Expired, + /// `nonce` set to this instead of the grant's. + Nonce(String), + /// Signed by a key the JWK Set has never published, under a `kid` it never listed. + UnpublishedKey, + /// `alg: none`, unsigned. + AlgNone, + /// Signed by the published key, then one byte of the signature flipped. + BadSignature, + /// Signed under the `kid` of a symmetric (`oct`) key the JWK Set publishes, with `alg` + /// EdDSA: the key resolves, and cannot verify anything. + OctKey, +} + +/// A code the test issues for the relying party to redeem. +#[derive(Debug, Clone)] +pub(crate) struct Grant { + pub(crate) code_challenge: String, + pub(crate) nonce: String, + pub(crate) redirect_uri: String, + pub(crate) subject: String, + pub(crate) email: Option, + pub(crate) tamper: Tamper, +} + +/// One signing key and its published form. +struct Signer { + kid: String, + encoding: EncodingKey, + public: Vec, +} + +impl Signer { + fn generate(kid: String) -> 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.clone()), + 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), + }), + } + } +} + +struct State { + issuer: String, + /// Every key the JWK Set publishes; the last one signs. + published: Mutex>, + /// Symmetric keys the JWK Set publishes beside them, by `kid`. + oct_keys: Mutex>, + grants: Mutex>, + next_code: AtomicUsize, + discovery_hits: AtomicUsize, + jwks_hits: AtomicUsize, + token_hits: AtomicUsize, + discovery_down: AtomicBool, +} + +/// A running mock provider. Dropping it aborts the accept loop and every connection. +pub(crate) struct MockIdp { + state: Arc, + handle: tokio::task::JoinHandle<()>, + /// The private CA's certificate, PEM, when the provider serves TLS. + ca_pem: Option, +} + +/// `der` as a PEM `CERTIFICATE` block (RFC 7468), without rcgen's `pem` feature. +fn pem_of(der: &[u8]) -> String { + let body = base64::engine::general_purpose::STANDARD.encode(der); + let lines: Vec<&str> = body + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).expect("base64 is ASCII")) + .collect(); + format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + lines.join("\n") + ) +} + +/// A private CA and the leaf it signed for `127.0.0.1`, for the TLS provider. +struct PrivateCa { + ca_pem: String, + config: Arc, +} + +impl PrivateCa { + fn generate() -> Self { + let ca_key = rcgen::KeyPair::generate().expect("a CA key"); + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).expect("params"); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).expect("a CA certificate"); + + let leaf_key = rcgen::KeyPair::generate().expect("a leaf key"); + let leaf_params = + rcgen::CertificateParams::new(vec!["127.0.0.1".to_owned()]).expect("params"); + let leaf = leaf_params + .signed_by(&leaf_key, &ca, &ca_key) + .expect("a leaf signed by the CA"); + + let config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![leaf.der().clone(), ca.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(leaf_key.serialize_der().into()), + ) + .expect("a server config"); + Self { + ca_pem: pem_of(ca.der()), + config: Arc::new(config), + } + } +} + +impl Drop for MockIdp { + fn drop(&mut self) { + self.handle.abort(); + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl MockIdp { + /// Bind on loopback and start serving plain HTTP, with one published key. + pub(crate) async fn start() -> Self { + Self::start_with(None).await + } + + /// The same provider behind TLS, with a leaf signed by a private CA the test can read. + /// + /// Its issuer is `https://127.0.0.1:{port}/idp`, so nothing about it is the loopback + /// carve-out: the relying party has to trust the CA, which is what `OIDC_CA_BUNDLE` is for. + pub(crate) async fn start_tls() -> Self { + Self::start_with(Some(PrivateCa::generate())).await + } + + async fn start_with(tls: Option) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback binds"); + let addr = listener.local_addr().expect("a bound address"); + let scheme = if tls.is_some() { "https" } else { "http" }; + let ca_pem = tls.as_ref().map(|ca| ca.ca_pem.clone()); + let acceptor = tls.map(|ca| tokio_rustls::TlsAcceptor::from(ca.config)); + let state = Arc::new(State { + issuer: format!("{scheme}://{addr}/idp"), + published: Mutex::new(vec![Signer::generate("k1".to_owned())]), + oct_keys: Mutex::new(Vec::new()), + grants: Mutex::new(BTreeMap::new()), + next_code: AtomicUsize::new(1), + discovery_hits: AtomicUsize::new(0), + jwks_hits: AtomicUsize::new(0), + token_hits: AtomicUsize::new(0), + discovery_down: AtomicBool::new(false), + }); + let serving = Arc::clone(&state); + let handle = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + while connections.try_join_next().is_some() {} + let state = Arc::clone(&serving); + let acceptor = acceptor.clone(); + connections.spawn(async move { + match acceptor { + Some(acceptor) => { + // A handshake the client refuses (an untrusted CA) ends here, which + // is the refusal the `OIDC_CA_BUNDLE` case asserts from the other side. + if let Ok(stream) = acceptor.accept(stream).await { + serve(stream, state).await; + } + } + None => serve(stream, state).await, + } + }); + } + }); + Self { + state, + handle, + ca_pem, + } + } + + /// The private CA's certificate, PEM — what an operator would put in `OIDC_CA_BUNDLE`. + pub(crate) fn ca_pem(&self) -> &str { + self.ca_pem.as_deref().expect("started with start_tls") + } + + /// The issuer the relying party is configured with. Loopback `http`, the carve-out. + pub(crate) fn issuer(&self) -> String { + self.state.issuer.clone() + } + + /// Issue a code the token endpoint will redeem for a token minted from `grant`. + pub(crate) fn grant(&self, grant: Grant) -> String { + let code = format!( + "code-{}", + self.state.next_code.fetch_add(1, Ordering::SeqCst) + ); + lock(&self.state.grants).insert(code.clone(), grant); + code + } + + /// Publish a new key and sign with it from now on. + pub(crate) fn rotate(&self, kid: &str) { + lock(&self.state.published).push(Signer::generate(kid.to_owned())); + } + + /// Publish a symmetric key under `kid`, as a misconfigured provider might. + pub(crate) fn publish_oct_key(&self, kid: &str) { + lock(&self.state.oct_keys).push(kid.to_owned()); + } + + /// Whether the discovery endpoint answers at all. + pub(crate) fn set_discovery_down(&self, down: bool) { + self.state.discovery_down.store(down, Ordering::SeqCst); + } + + pub(crate) fn discovery_hits(&self) -> usize { + self.state.discovery_hits.load(Ordering::SeqCst) + } + + pub(crate) fn jwks_hits(&self) -> usize { + self.state.jwks_hits.load(Ordering::SeqCst) + } + + pub(crate) fn token_hits(&self) -> usize { + self.state.token_hits.load(Ordering::SeqCst) + } +} + +/// The relying party's `code_challenge`, S256 (RFC 7636 §4.2). +fn challenge_of(verifier: &str) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest.as_ref()) +} + +/// The token endpoint: verify the grant and mint the ID token. +fn token_response( + state: &State, + form: &BTreeMap, + basic: Option<&str>, +) -> (u16, String) { + let refuse = |error: &str, description: &str| { + ( + 400, + serde_json::json!({ "error": error, "error_description": description }).to_string(), + ) + }; + if form.get("grant_type").map(String::as_str) != Some("authorization_code") { + return refuse("unsupported_grant_type", "only authorization_code"); + } + // A public client names itself in the body; a confidential one authenticates instead. + let client_id = form + .get("client_id") + .cloned() + .or_else(|| basic.map(str::to_owned)); + if client_id.as_deref() != Some(CLIENT_ID) { + return refuse("invalid_client", "unknown client"); + } + let Some(code) = form.get("code") else { + return refuse("invalid_request", "no code"); + }; + // Single-use at the provider, like a real one. + let Some(grant) = lock(&state.grants).remove(code) else { + return refuse("invalid_grant", "unknown or spent code"); + }; + if form.get("redirect_uri") != Some(&grant.redirect_uri) { + return refuse("invalid_grant", "redirect_uri mismatch"); + } + let verifier_ok = form + .get("code_verifier") + .is_some_and(|verifier| challenge_of(verifier) == grant.code_challenge); + if !verifier_ok { + return refuse("invalid_grant", "PKCE verification failed"); + } + + let now = jiff::Timestamp::now().as_second(); + let mut claims = serde_json::json!({ + "iss": state.issuer, + "sub": grant.subject, + "aud": CLIENT_ID, + // Three hours, not the five minutes a real provider mints: the relying party under + // test judges time by a clock the tests move forward by more than an hour to exercise + // the key-cache ceiling, and a five-minute token would expire under it. + "exp": now + 3 * 3600, + "iat": now, + "nonce": grant.nonce, + }); + if let Some(email) = &grant.email { + claims["email"] = serde_json::json!(email); + claims["email_verified"] = serde_json::json!(true); + } + match &grant.tamper { + Tamper::None + | Tamper::UnpublishedKey + | Tamper::AlgNone + | Tamper::BadSignature + | Tamper::OctKey => {} + Tamper::Issuer(issuer) => claims["iss"] = serde_json::json!(issuer), + Tamper::Audience(audience) => claims["aud"] = serde_json::json!(audience), + Tamper::Expired => claims["exp"] = serde_json::json!(now - 3600), + Tamper::Nonce(nonce) => claims["nonce"] = serde_json::json!(nonce), + } + + let id_token = match &grant.tamper { + Tamper::AlgNone => { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode(claims.to_string()); + format!("{header}.{payload}.") + } + Tamper::UnpublishedKey => { + let rogue = Signer::generate("k-never-published".to_owned()); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(rogue.kid.clone()); + jsonwebtoken::encode(&header, &claims, &rogue.encoding).expect("the key signs") + } + Tamper::OctKey => { + let kid = lock(&state.oct_keys) + .last() + .cloned() + .expect("publish_oct_key was called"); + let rogue = Signer::generate(kid.clone()); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(kid); + jsonwebtoken::encode(&header, &claims, &rogue.encoding).expect("the key signs") + } + Tamper::BadSignature => { + let published = lock(&state.published); + let signer = published.last().expect("a signing key"); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(signer.kid.clone()); + let token = + jsonwebtoken::encode(&header, &claims, &signer.encoding).expect("the key signs"); + // Flip the first character of the signature to a different base64url character. + // The first, not the last: the last symbol of an unpadded base64url string carries + // padding bits, so not every character is valid there and the token would be + // malformed rather than mis-signed. + let mut bytes = token.into_bytes(); + let signature_start = bytes + .iter() + .rposition(|byte| *byte == b'.') + .expect("a compact JWS has two dots") + + 1; + let first = &mut bytes[signature_start]; + *first = if *first == b'A' { b'B' } else { b'A' }; + String::from_utf8(bytes).expect("still ASCII") + } + _ => { + let published = lock(&state.published); + let signer = published.last().expect("a signing key"); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(signer.kid.clone()); + jsonwebtoken::encode(&header, &claims, &signer.encoding).expect("the key signs") + } + }; + ( + 200, + serde_json::json!({ + "id_token": id_token, + "access_token": "opaque-access-token", + "token_type": "Bearer", + }) + .to_string(), + ) +} + +async fn serve(mut stream: S, state: Arc) { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + let header_end = loop { + if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") { + break pos; + } + match stream.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + }; + 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 mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_owned(); + let path = parts.next().unwrap_or_default().to_owned(); + let mut content_length = 0usize; + let mut basic = None; + for line in lines { + if let Some((name, value)) = line.split_once(':') { + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().unwrap_or(0); + } + if name.eq_ignore_ascii_case("authorization") + && let Some(encoded) = value.strip_prefix("Basic ") + && let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded) + && let Ok(text) = String::from_utf8(decoded) + && let Some((user, _)) = text.split_once(':') + { + basic = Some(user.to_owned()); + } + } + } + let mut body = buf[header_end + 4..].to_vec(); + while body.len() < content_length { + match stream.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => body.extend_from_slice(&tmp[..n]), + } + } + body.truncate(content_length); + + let (status, content_type, payload) = match (method.as_str(), path.as_str()) { + ("GET", "/idp/.well-known/openid-configuration") => { + state.discovery_hits.fetch_add(1, Ordering::SeqCst); + if state.discovery_down.load(Ordering::SeqCst) { + (503, "text/plain", "down".to_owned()) + } else { + let base = state.issuer.trim_end_matches("/idp"); + ( + 200, + "application/json", + serde_json::json!({ + "issuer": state.issuer, + "authorization_endpoint": format!("{base}/idp/authorize"), + "token_endpoint": format!("{base}/idp/token"), + "jwks_uri": format!("{base}/idp/keys"), + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["EdDSA"], + }) + .to_string(), + ) + } + } + ("GET", "/idp/keys") => { + state.jwks_hits.fetch_add(1, Ordering::SeqCst); + let mut keys: Vec = lock(&state.published).iter().map(Signer::jwk).collect(); + keys.extend(lock(&state.oct_keys).iter().map(|kid| Jwk { + common: CommonParameters { + key_id: Some(kid.clone()), + key_algorithm: Some(KeyAlgorithm::HS256), + ..CommonParameters::default() + }, + algorithm: AlgorithmParameters::OctetKey(jsonwebtoken::jwk::OctetKeyParameters { + key_type: jsonwebtoken::jwk::OctetKeyType::Octet, + value: URL_SAFE_NO_PAD.encode(b"a shared secret"), + }), + })); + let set = JwkSet { keys }; + ( + 200, + "application/jwk-set+json", + serde_json::to_string(&set).expect("a set serializes"), + ) + } + ("POST", "/idp/token") => { + state.token_hits.fetch_add(1, Ordering::SeqCst); + // `Url` decodes form bodies for free; the query-pair parser is the form parser. + let form: BTreeMap = + reqwest::Url::parse(&format!("http://x/?{}", String::from_utf8_lossy(&body))) + .map(|url| { + url.query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() + }) + .unwrap_or_default(); + let (status, payload) = token_response(&state, &form, basic.as_deref()); + (status, "application/json", payload) + } + _ => (404, "text/plain", "no such route".to_owned()), + }; + + let response = format!( + "HTTP/1.1 {status} STATUS\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}", + payload.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +/// The address the mock is bound to, for a case that wants to name it. +#[allow(dead_code, reason = "kept for a case that reads the socket address")] +pub(crate) fn address_of(issuer: &str) -> SocketAddr { + reqwest::Url::parse(issuer) + .ok() + .and_then(|url| url.socket_addrs(|| None).ok()) + .and_then(|addrs| addrs.first().copied()) + .expect("the issuer names a loopback socket") +} diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index d5aba864..08bb5083 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -35,6 +35,10 @@ use capsule_server::album::{ }; use capsule_server::app::Modules; use capsule_server::attestation::{AttestationContext, InMemoryReceipts, LocalAttestationKey}; +use capsule_server::auth::oidc::{ + AuthorizationRequest, FederatedAccounts, FederatedLink, IdentityProvider, OidcCollaborators, + OidcContext, ProviderError, ProviderFuture, Redemption, VerifiedIdentity, +}; use capsule_server::auth::{ AccountDirectory, AccountProfiles, ActivateOutcome, AuthCollaborators, AuthContext, Authentication, BeginOutcome, ConsumeOutcome, DirectoryError, DirectoryFuture, EnrollmentState, @@ -75,16 +79,18 @@ use capsule_server::serve::ServeContext; use capsule_server::share::{InMemoryShares, ShareContext, ShareRecord, ShareStore}; use capsule_server::store::memory::{ InMemoryAuthState, InMemoryChallenges, InMemoryChannels, InMemoryCohorts, InMemoryEnrollments, - InMemoryUploadSessions, ManualClock, + InMemoryOidcAuthorizations, InMemoryUploadSessions, ManualClock, }; use capsule_server::store::{ AcceptedChunk, AlbumId, AssetId, AuthStateStore, ChallengeStore, ChallengeToken, ChannelId, ChannelStore, Clock, CohortRecord, CohortStore, Direction, DrainOutcome, ENROLLMENT_CODE_TTL, - EnrollmentCode, EnrollmentStore, FinalizeClaim, OwnerId, PendingEnrollment, RELAY_CHANNEL_TTL, - RelayChannel, RelayOutcome, RelayPayload, RevokeAllChallenge, SessionId, SessionRecord, - StoreError, StoreFuture, UploadId, UploadSessionRecord, UploadSessionStatus, - UploadSessionStore, UserId, + EnrollmentCode, EnrollmentStore, FinalizeClaim, OidcAuthorizationStore, OidcState, OwnerId, + PendingAuthorization, PendingEnrollment, RELAY_CHANNEL_TTL, RelayChannel, RelayOutcome, + RelayPayload, RevokeAllChallenge, SessionId, SessionRecord, StoreError, StoreFuture, UploadId, + UploadSessionRecord, UploadSessionStatus, UploadSessionStore, UserId, }; + +pub(crate) mod idp; use capsule_server::sync::{CURSOR_KEY_LEN, CursorCodec, SyncContext}; use capsule_server::upload::authority::{ AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority, @@ -206,6 +212,239 @@ const REFUSAL: &str = "the double refuses on purpose"; /// written without reaching inside the codec. pub(crate) const CURSOR_KEY: [u8; CURSOR_KEY_LEN] = [0x5C; CURSOR_KEY_LEN]; +// =========================================================================================== +// Identity provider double (`S-N1`) +// =========================================================================================== + +/// The authorization code the double redeems; every other code is `invalid_grant`. +pub(crate) const GOOD_CODE: &str = "good-code"; + +/// An identity provider that answers whatever the test told it to. +/// +/// The routes are tested against this; the real [`HttpIdentityProvider`] is tested against the +/// in-process mock provider in [`idp`], which speaks the real wire. Every refusal the port can +/// make is a switch here, so the conformance walk can produce each declared response. +#[derive(Debug)] +pub(crate) struct SwitchableIdentityProvider { + configured: AtomicBool, + unavailable: AtomicBool, + identity: Mutex, + token_rejected: AtomicBool, + /// Every authorization request the server built, for a case that reads the ceremony back. + requests: Mutex>, +} + +/// One authorization request as the double saw it. +#[derive(Debug, Clone)] +pub(crate) struct RecordedAuthorization { + pub(crate) redirect_uri: String, + pub(crate) state: String, + pub(crate) nonce: String, + pub(crate) code_challenge: String, +} + +impl SwitchableIdentityProvider { + pub(crate) fn new() -> Self { + Self { + configured: AtomicBool::new(true), + unavailable: AtomicBool::new(false), + identity: Mutex::new(VerifiedIdentity { + issuer: "https://idp.test".to_owned(), + subject: "subject-1".to_owned(), + email: Some("federated@example.test".to_owned()), + email_verified: true, + }), + token_rejected: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + } + } + + /// Whether the deployment has a provider at all. + pub(crate) fn set_configured(&self, configured: bool) { + self.configured.store(configured, Ordering::SeqCst); + } + + /// Make every operation fail as an unreachable provider, or stop. + pub(crate) fn set_unavailable(&self, unavailable: bool) { + self.unavailable.store(unavailable, Ordering::SeqCst); + } + + /// Make the next redemptions fail as a refused ID token, or stop. + pub(crate) fn set_token_rejected(&self, rejected: bool) { + self.token_rejected.store(rejected, Ordering::SeqCst); + } + + /// The identity every successful redemption yields. + pub(crate) fn set_identity(&self, identity: VerifiedIdentity) { + *self.identity.lock().unwrap_or_else(PoisonError::into_inner) = identity; + } + + /// The authorization requests the server has built so far. + pub(crate) fn requests(&self) -> Vec { + self.requests + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } +} + +impl SwitchableIdentityProvider { + /// The real policy, so the double's redirect decision is the adapter's decision. + /// + /// One function for both `admits_redirect` and `authorization_url`: a double that answered + /// the look-ahead and the enforcement differently would let the route's counter-key choice + /// pass a test the shipped adapter fails. + fn policy() -> capsule_server::auth::oidc::RedirectPolicy { + capsule_server::auth::oidc::RedirectPolicy::new( + Some("https://app.test/oidc/callback".to_owned()), + true, + ) + } +} + +impl IdentityProvider for SwitchableIdentityProvider { + fn admits_redirect(&self, redirect_uri: &str) -> bool { + self.configured.load(Ordering::SeqCst) && Self::policy().admits(redirect_uri) + } + + fn authorization_url<'a>( + &'a self, + request: &'a AuthorizationRequest<'a>, + ) -> ProviderFuture<'a, String> { + Box::pin(async move { + if !self.configured.load(Ordering::SeqCst) { + return Err(ProviderError::NotConfigured); + } + if !Self::policy().admits(request.redirect_uri) { + return Err(ProviderError::RedirectRefused { + redirect_uri: request.redirect_uri.to_owned(), + }); + } + if self.unavailable.load(Ordering::SeqCst) { + return Err(ProviderError::Unavailable { + detail: REFUSAL.to_owned(), + }); + } + self.requests + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(RecordedAuthorization { + redirect_uri: request.redirect_uri.to_owned(), + state: request.state.as_str().to_owned(), + nonce: request.nonce.as_str().to_owned(), + code_challenge: request.code_challenge.to_owned(), + }); + Ok(format!( + "https://idp.test/authorize?state={}&nonce={}&code_challenge={}", + request.state.as_str(), + request.nonce.as_str(), + request.code_challenge + )) + }) + } + + fn redeem<'a>( + &'a self, + redemption: &'a Redemption<'a>, + ) -> ProviderFuture<'a, VerifiedIdentity> { + Box::pin(async move { + if !self.configured.load(Ordering::SeqCst) { + return Err(ProviderError::NotConfigured); + } + if self.unavailable.load(Ordering::SeqCst) { + return Err(ProviderError::Unavailable { + detail: REFUSAL.to_owned(), + }); + } + if redemption.code.as_str() != GOOD_CODE { + return Err(ProviderError::ExchangeRefused { + detail: "invalid_grant".to_owned(), + }); + } + if self.token_rejected.load(Ordering::SeqCst) { + return Err(ProviderError::TokenRejected( + capsule_server::auth::oidc::ClaimRejection::Nonce, + )); + } + Ok(self + .identity + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone()) + }) + } +} + +/// The OIDC ceremony store, with two switches: unreachable, and full. +#[derive(Debug)] +pub(crate) struct SwitchableOidcAuthorizations { + inner: InMemoryOidcAuthorizations, + unavailable: AtomicBool, + full: AtomicBool, +} + +impl SwitchableOidcAuthorizations { + pub(crate) fn new(clock: Arc) -> Self { + Self { + inner: InMemoryOidcAuthorizations::with_default_ttl(clock), + unavailable: AtomicBool::new(false), + full: AtomicBool::new(false), + } + } + + pub(crate) fn set_unavailable(&self, unavailable: bool) { + self.unavailable.store(unavailable, Ordering::SeqCst); + } + + /// Make every `begin` answer as a store at its ceiling, or stop. + pub(crate) fn set_full(&self, full: bool) { + self.full.store(full, Ordering::SeqCst); + } + + fn refuse(&self) -> Result<(), StoreError> { + if self.unavailable.load(Ordering::SeqCst) { + return Err(StoreError::Unavailable { + store: "oidc authorizations", + detail: REFUSAL.to_owned(), + }); + } + Ok(()) + } +} + +impl OidcAuthorizationStore for SwitchableOidcAuthorizations { + fn ttl(&self) -> SignedDuration { + self.inner.ttl() + } + + fn begin<'a>( + &'a self, + state: &'a OidcState, + record: PendingAuthorization, + ) -> StoreFuture<'a, ()> { + Box::pin(async move { + self.refuse()?; + if self.full.load(Ordering::SeqCst) { + return Err(StoreError::Rejected { + store: "oidc authorizations", + detail: REFUSAL.to_owned(), + }); + } + self.inner.begin(state, record).await + }) + } + + fn consume<'a>( + &'a self, + state: &'a OidcState, + ) -> StoreFuture<'a, Option> { + Box::pin(async move { + self.refuse()?; + self.inner.consume(state).await + }) + } +} + // =========================================================================================== // Account directory double // =========================================================================================== @@ -219,6 +458,8 @@ pub(crate) const CURSOR_KEY: [u8; CURSOR_KEY_LEN] = [0x5C; CURSOR_KEY_LEN]; #[derive(Debug, Default)] pub(crate) struct InMemoryAccounts { accounts: Mutex>, + /// `(issuer, subject)` → the account a federated sign-in resolved to (`S-N1`). + federated: Mutex>, unavailable: AtomicBool, forget_after_authentication: AtomicBool, } @@ -302,6 +543,47 @@ impl InMemoryAccounts { } } +impl FederatedAccounts for InMemoryAccounts { + /// Shares the password directory's rows, as the Postgres adapter will: an asserted address a + /// password account holds is `AddressTaken`, and a created federated account is one nothing + /// can sign into with a password, because no password row exists for it. + /// + /// This double encodes the contract #460 owes — one account table, the cross-directory + /// `409`, a null credential `authenticate` refuses — and not behaviour the server ships: + /// the development profile's `InMemoryFederatedAccounts` holds rows of its own. Only a + /// **verified** address is compared, as the shipped adapter does. + fn resolve_or_create<'a>( + &'a self, + identity: &'a VerifiedIdentity, + user: &'a UserId, + _at: Timestamp, + ) -> DirectoryFuture<'a, FederatedLink> { + Box::pin(async move { + if self.unavailable.load(Ordering::SeqCst) { + return Err(DirectoryError::Unavailable { + detail: REFUSAL.to_owned(), + }); + } + let mut federated = self + .federated + .lock() + .unwrap_or_else(PoisonError::into_inner); + let key = (identity.issuer.clone(), identity.subject.clone()); + if let Some(existing) = federated.get(&key) { + return Ok(FederatedLink::Linked(existing.clone())); + } + if let Some(email) = &identity.email + && identity.email_verified + && self.accounts().contains_key(email) + { + return Ok(FederatedLink::AddressTaken); + } + federated.insert(key, user.clone()); + Ok(FederatedLink::Created(user.clone())) + }) + } +} + impl AccountDirectory for InMemoryAccounts { fn authenticate<'a>( &'a self, @@ -2249,13 +2531,83 @@ impl AssetIndex for SwitchableIndex { } } +/// The fixture's client: Kynos's in-process `TestClient`, sending the protocol handshake. +/// +/// Every request a real client makes carries `X-Capsule-Protocol` — the SDK sets it as a +/// default header on its transport — so the fixture does the same, once, here, rather than at +/// every one of the suite's several hundred request sites. A case about the handshake itself +/// overrides the header (a later `header` call replaces an earlier one) or reaches for +/// [`Client::raw`] to send none at all; the two are the only ways a request leaves without it, +/// which is what keeps "the gate refused this" a deliberate assertion rather than a fixture +/// accident. +/// +/// Deliberately not `Deref` to the inner client: a function taking `&TestClient` would +/// then accept this and silently drive the router without the handshake. +pub(crate) struct Client { + inner: TestClient, +} + +impl Client { + pub(crate) fn new(inner: TestClient) -> Self { + Self { inner } + } + + /// The bare client, for a request that must **not** carry the handshake. + pub(crate) fn raw(&self) -> &TestClient { + &self.inner + } + + fn handshake<'a>(request: TestRequest<'a, App>) -> TestRequest<'a, App> { + request.header("x-capsule-protocol", PROTOCOL_VERSION) + } + + pub(crate) fn get(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.get(path)) + } + + pub(crate) fn post(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.post(path)) + } + + pub(crate) fn put(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.put(path)) + } + + pub(crate) fn patch(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.patch(path)) + } + + pub(crate) fn delete(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.delete(path)) + } + + pub(crate) fn head(&self, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.head(path)) + } + + /// A request with any method, for a walk driven by the document rather than by a verb. + pub(crate) fn method(&self, method: kynos::http::Method, path: &str) -> TestRequest<'_, App> { + Self::handshake(self.inner.method(method, path)) + } + + /// Every response this client observed was one the description predicts. + pub(crate) fn assert_conformance(&self) { + self.inner.assert_conformance(); + } + + /// Every response the description predicts was produced through this client. + pub(crate) fn assert_declared_responses_covered(&self) { + self.inner.assert_declared_responses_covered(); + } +} + /// A built server, plus handles on everything behind it. /// /// The handles matter: an assertion about a session is made against the store the server just /// wrote to, not against a second reading of the response body. pub(crate) struct Fixture { - /// The in-process client. No socket, no port, no runtime flavour. - pub(crate) client: TestClient, + /// The in-process client, sending the handshake on every request. No socket, no port. + pub(crate) client: Client, /// The context the client drives, for the one case that has to serve it on a socket. app: App, /// The store the server opened its sessions in. @@ -2316,6 +2668,10 @@ pub(crate) struct Fixture { /// The code generator the server verifies with — the *same* one, so a case can compute the /// code an authenticator app would be showing rather than guessing at one. pub(crate) codes: Arc, + /// The identity provider double (`S-N1`), when the fixture was built with one. + pub(crate) idp: Arc, + /// The pending OIDC ceremonies. + pub(crate) oidc_authorizations: Arc, } impl Fixture { @@ -2329,6 +2685,32 @@ impl Fixture { /// The same server, with a deployment's quota thresholds. pub(crate) fn with_quota(quota_limits: QuotaLimits) -> Self { + Self::build(quota_limits, None, None) + } + + /// The working server over a **real** identity provider adapter, for the cases that drive + /// the wire against the mock provider in [`idp`]. `fixture.idp` is present but unused. + pub(crate) fn with_identity_provider(provider: Arc) -> Self { + Self::build(QuotaLimits::unlimited(), Some(provider), None) + } + + /// The working server whose rate-limit counters hold at most `ceiling` keys **per + /// partition**. + /// + /// The shipped ceilings are twenty thousand keys wide, which is the right number for a + /// deployment and the wrong number for a wire test: saturating one over HTTP would be twenty + /// thousand requests. The property a saturated partition has to have — `429` with the + /// at-capacity code rather than a `500` that impersonates an outage — does not depend on how + /// wide it is, so the tests that assert it shrink the partitions and spend two requests. + pub(crate) fn with_counter_ceiling(ceiling: usize) -> Self { + Self::build(QuotaLimits::unlimited(), None, Some(ceiling)) + } + + fn build( + quota_limits: QuotaLimits, + provider: Option>, + counter_ceiling: Option, + ) -> Self { let clock = Arc::new(ManualClock::default()); let sessions = Arc::new(SwitchableSessions::new(clock.clone())); let accounts = Arc::new(InMemoryAccounts::new()); @@ -2368,9 +2750,15 @@ impl Fixture { let moderation = Arc::new(SwitchableModeration::new()); let shares = Arc::new(SwitchableShares::new()); let dropstore = Arc::new(SwitchableDrops::new()); - let counters = Arc::new(InMemoryCounters::new()); + let counters = Arc::new(match counter_ceiling { + Some(ceiling) => InMemoryCounters::new().with_ceiling(ceiling), + None => InMemoryCounters::new(), + }); let totp = Arc::new(InMemoryTotp::new()); let codes = Arc::new(TotpCodes::new("Capsule")); + let idp = Arc::new(SwitchableIdentityProvider::new()); + let oidc_authorizations = Arc::new(SwitchableOidcAuthorizations::new(clock.clone())); + let provider: Arc = provider.unwrap_or_else(|| idp.clone()); // One index behind both modules, which is what makes "upload it, then read it back off // the feed" a test of the server rather than of two disconnected doubles. @@ -2428,12 +2816,18 @@ impl Fixture { ), counters: CounterContext::new(counters.clone(), clock.clone()), totp: TotpContext::new(totp.clone(), codes.clone()), + oidc: OidcContext::new(OidcCollaborators { + provider, + authorizations: oidc_authorizations.clone(), + accounts: accounts.clone(), + clock: clock.clone(), + }), }); Self { - client: TestClient::new( + client: Client::new(TestClient::new( capsule_server::service(app.clone()).expect("the router builds"), - ), + )), app, sessions, accounts, @@ -2462,6 +2856,8 @@ impl Fixture { counters, totp, codes, + idp, + oidc_authorizations, } } @@ -2576,6 +2972,7 @@ impl Fixture { Arc::new(InMemoryTotp::new()), Arc::new(TotpCodes::new("Capsule")), ), + oidc: OidcContext::disabled(clock.clone()), }); (app, clock) } @@ -2653,7 +3050,6 @@ impl Fixture { .client .post("/v1/upload") .header("authorization", bearer) - .header("x-capsule-protocol", PROTOCOL_VERSION) .json(request) .send() .await @@ -2664,8 +3060,8 @@ impl Fixture { /// A well-formed `PATCH` of `payload` at `offset`. /// - /// Every header the protocol requires is set, so a test that wants one wrong overrides it - /// — the later `header` call wins. + /// Every header the protocol requires is set — the handshake by the client, the rest here — + /// so a test that wants one wrong overrides it: the later `header` call wins. pub(crate) fn chunk<'a>( &'a self, id: &str, @@ -2676,7 +3072,6 @@ impl Fixture { self.client .patch(&format!("/v1/upload/{id}")) .header("authorization", bearer) - .header("x-capsule-protocol", PROTOCOL_VERSION) .header("x-capsule-offset", &offset.to_string()) .header("x-capsule-checksum", &checksum(payload)) .body("application/octet-stream", payload.to_vec()) @@ -2824,6 +3219,9 @@ pub(crate) fn server_info(tokens: &SessionTokens) -> ServerInfo { }, tokens.public_key().to_vec(), ) + // The fixture's provider double is configured, so the record says so — and a case can post + // to the *published* OIDC endpoints as it does to the published login. + .with_oidc() } pub(crate) fn signer(clock: Arc) -> SessionTokens { diff --git a/capsule-server/tests/upload.rs b/capsule-server/tests/upload.rs index 3eb2211d..4215b7f9 100644 --- a/capsule-server/tests/upload.rs +++ b/capsule-server/tests/upload.rs @@ -329,9 +329,11 @@ async fn the_handshake_gates_every_upload_request() { let (_, _, whole) = blob(); let id = fixture.open_session(&whole, "original", &bearer).await; - // Missing: a coded 400, on every operation. + // Missing: a coded 400, on every operation — the gate's, not this surface's, which is why + // the code is the request-level one. `raw()` is the only way the fixture sends no handshake. let missing = fixture .client + .raw() .post("/v1/upload") .header("authorization", &bearer) .json(&create_request(&fixture.clock, &whole, "original")) @@ -340,18 +342,21 @@ async fn the_handshake_gates_every_upload_request() { missing.assert_status(StatusCode::BAD_REQUEST); assert_eq!( code(&missing.json::()), - "error.upload.malformed_request" + "error.request.malformed" ); let head = fixture .client + .raw() .head(&format!("/v1/upload/{id}")) .header("authorization", &bearer) .send() .await; head.assert_status(StatusCode::BAD_REQUEST); - // Out of the window: `426`, carrying the window a client can act on. + // Out of the window: `426`, carrying the window a client can act on — **on the headers**, + // which is where `capsule-sdk/src/upload.rs` reads it (issue #404). The body carries the + // code and no second spelling of the window. let refused = fixture .client .post("/v1/upload") @@ -361,10 +366,38 @@ async fn the_handshake_gates_every_upload_request() { .send() .await; refused.assert_status(StatusCode::UPGRADE_REQUIRED); + refused.assert_header("x-capsule-protocol-min", "2026-01-01"); + refused.assert_header("x-capsule-protocol-max", "2026-12-31"); + refused.assert_header("x-capsule-min-client-build", "0.0.0"); let body: serde_json::Value = refused.json(); assert_eq!(code(&body), "error.protocol.version_unsupported"); - assert_eq!(body["protocol_min"], "2026-01-01"); - assert_eq!(body["protocol_max"], "2026-12-31"); + assert!( + body.get("protocol_min").is_none() && body.get("protocol_max").is_none(), + "the window has one spelling, the headers: {body}" + ); + + // The gate runs before authentication: a client learns it must update without a token. + fixture + .client + .raw() + .post("/v1/upload") + .header("x-capsule-protocol", "2020-01-01") + .json(&create_request(&fixture.clock, &whole, "original")) + .send() + .await + .assert_status(StatusCode::UPGRADE_REQUIRED); + + // And a read is admitted at any protocol date: the same client can still ask where its + // session got to, and learns the window from the headers rather than from a refusal. + let progress = fixture + .client + .head(&format!("/v1/upload/{id}")) + .header("authorization", &bearer) + .header("x-capsule-protocol", "2020-01-01") + .send() + .await; + progress.assert_status(StatusCode::OK); + progress.assert_header("x-capsule-protocol-min", "2026-01-01"); } // =========================================================================================== diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index d16f811c..67327478 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -37119,6 +37119,86 @@ } } }, + "error.auth.oidc_address_taken": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "An account with that email address already exists here. Sign in with its password instead." + } + } + } + }, + "error.auth.oidc_at_capacity": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many sign-ins are already in progress. Please try again in a moment." + } + } + } + }, + "error.auth.oidc_exchange_failed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your identity provider didn't accept that sign-in. Try again." + } + } + } + }, + "error.auth.oidc_not_configured": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Single sign-on isn't set up on this server." + } + } + } + }, + "error.auth.oidc_redirect_invalid": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That sign-in can't return to this app." + } + } + } + }, + "error.auth.oidc_state_invalid": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That sign-in has expired. Start again." + } + } + } + }, + "error.auth.oidc_token_invalid": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your identity provider's answer couldn't be verified." + } + } + } + }, + "error.auth.oidc_unavailable": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capsule couldn't reach your identity provider just now. Please try again." + } + } + } + }, "error.auth.password_invalid": { "localizations": { "ar": { @@ -38777,6 +38857,16 @@ } } }, + "error.drop.at_capacity": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This server is handling too many upload links right now. Please try again shortly." + } + } + } + }, "error.drop.cap_exceeded": { "localizations": { "ar": { @@ -39247,6 +39337,16 @@ } } }, + "error.enrollment.at_capacity": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This server is handling too many enrollment attempts right now. Please try again shortly." + } + } + } + }, "error.enrollment.channel_not_found": { "localizations": { "ar": { @@ -41181,6 +41281,16 @@ } } }, + "error.share.at_capacity": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This server is handling too many shared links right now. Please try again shortly." + } + } + } + }, "error.share.malformed": { "localizations": { "en": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 318b9021..e9223171 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1836,6 +1836,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.", @@ -1865,6 +1873,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 +1885,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.", @@ -1911,6 +1921,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.", diff --git a/capsule-web/src/lib/api.test.ts b/capsule-web/src/lib/api.test.ts new file mode 100644 index 00000000..80dfcf41 --- /dev/null +++ b/capsule-web/src/lib/api.test.ts @@ -0,0 +1,165 @@ +// The browser client sends the protocol handshake (issue #404, decision 24). +// +// Every gated route refuses a request without `X-Capsule-Protocol`, and `api.ts` is hand-written +// — the browser holds no Rust — so this is the one place the header can silently go missing +// again. Driven against a recording mock of the global `fetch`: each of the five request +// builders is called once and the header it sent is compared with `PROTOCOL_VERSION`, and +// `PROTOCOL_VERSION` itself is compared with the literal in the Rust source of truth, read at +// test time, so the restated constant cannot drift from `capsule_core`. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + authFetch, + login, + PROTOCOL_VERSION, + refreshAccessToken, + register, + verifyTotpLogin, +} from './api'; + +/** One request the mock saw. */ +interface Call { + url: string; + protocol: string | null; +} + +/** A minimal `localStorage`, for a runtime without one. Only the four methods `auth.ts` uses. */ +class MemoryStorage { + private readonly items = new Map(); + getItem(key: string): string | null { + return this.items.get(key) ?? null; + } + setItem(key: string, value: string): void { + this.items.set(key, value); + } + removeItem(key: string): void { + this.items.delete(key); + } + clear(): void { + this.items.clear(); + } +} + +const realFetch = globalThis.fetch; +const realStorage = (globalThis as { localStorage?: unknown }).localStorage; +const calls: Call[] = []; + +/** Every request succeeds with a token pair, so no builder takes its failure branch. */ +function recordingFetch(): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const headers = new Headers( + init?.headers ?? + (input instanceof Request ? input.headers : undefined), + ); + calls.push({ url, protocol: headers.get('X-Capsule-Protocol') }); + return new Response( + JSON.stringify({ + access_token: 'access', + refresh_token: 'refresh', + token_type: 'Bearer', + expires_by: Math.floor(Date.now() / 1000) + 3600, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }) as typeof fetch; +} + +beforeEach(() => { + calls.length = 0; + globalThis.fetch = recordingFetch(); + (globalThis as { localStorage: unknown }).localStorage = + new MemoryStorage(); + // A live session, so `authFetch` neither refreshes nor redirects. + localStorage.setItem('capsule_access_token', 'access'); + localStorage.setItem('capsule_refresh_token', 'refresh'); + localStorage.setItem( + 'capsule_token_expiry', + String(Math.floor(Date.now() / 1000) + 3600), + ); +}); + +afterEach(() => { + globalThis.fetch = realFetch; + (globalThis as { localStorage?: unknown }).localStorage = realStorage; +}); + +/** The one request the mock saw, and its handshake. */ +function theRequest(): Call { + expect(calls).toHaveLength(1); + return calls[0]; +} + +describe('the protocol handshake', () => { + test('is the version capsule-core speaks', () => { + // `import.meta.dir` is `capsule-web/src/lib`; three levels up is the repository root. + const primitives = readFileSync( + join( + import.meta.dir, + '..', + '..', + '..', + 'capsule-core', + 'src', + 'crypto', + 'primitives.rs', + ), + 'utf8', + ); + const declared = primitives.match( + /pub const PROTOCOL_VERSION: &str = "(\d{4}-\d{2}-\d{2})";/, + ); + expect(declared).not.toBeNull(); + expect(PROTOCOL_VERSION).toBe(declared?.[1]); + }); + + test('rides refreshAccessToken', async () => { + expect(await refreshAccessToken()).toBe(true); + const call = theRequest(); + expect(call.url).toEndWith('/v1/auth/refresh'); + expect(call.protocol).toBe(PROTOCOL_VERSION); + }); + + test('rides authFetch', async () => { + const res = await authFetch('/profile'); + expect(res.status).toBe(200); + const call = theRequest(); + expect(call.url).toEndWith('/v1/auth/profile'); + expect(call.protocol).toBe(PROTOCOL_VERSION); + }); + + test('rides login', async () => { + await login({ + email: 'a@example.test', + password: 'correct horse battery staple', + }); + const call = theRequest(); + expect(call.url).toEndWith('/v1/auth/login'); + expect(call.protocol).toBe(PROTOCOL_VERSION); + }); + + test('rides register', async () => { + await register({ + email: 'a@example.test', + password: 'correct horse battery staple', + }); + const call = theRequest(); + expect(call.url).toEndWith('/v1/auth/register'); + expect(call.protocol).toBe(PROTOCOL_VERSION); + }); + + test('rides verifyTotpLogin', async () => { + await verifyTotpLogin('mfa-token', '123456'); + const call = theRequest(); + expect(call.url).toEndWith('/v1/auth/login/verify-totp'); + expect(call.protocol).toBe(PROTOCOL_VERSION); + }); +}); diff --git a/capsule-web/src/lib/api.ts b/capsule-web/src/lib/api.ts index 98284c67..9d53e0e6 100644 --- a/capsule-web/src/lib/api.ts +++ b/capsule-web/src/lib/api.ts @@ -21,6 +21,17 @@ import { const API_BASE = import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3000'; const AUTH_BASE = `${API_BASE}/v1/auth`; +/** + * The `YYYY-MM-DD` protocol version this client is written against, sent as `X-Capsule-Protocol` + * on every request. Every gated route refuses a request without it (`400`), and a write outside + * the server's `[X-Capsule-Protocol-Min, -Max]` window is `426`. + * + * Source of truth: `capsule_core::crypto::primitives::PROTOCOL_VERSION`. The browser holds no + * Rust and the wasm surface does not export the constant, so it is restated here and must move + * with it. + */ +export const PROTOCOL_VERSION = '2026-05-31'; + export class ApiError extends Error { constructor( public readonly status: number, @@ -57,7 +68,10 @@ export async function refreshAccessToken(): Promise { try { const res = await fetch(`${AUTH_BASE}/refresh`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Capsule-Protocol': PROTOCOL_VERSION, + }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!res.ok) { @@ -94,6 +108,7 @@ export async function authFetch( if (!token) throw new ApiError(401, 'Session expired'); const headers = new Headers(init.headers); headers.set('Authorization', `Bearer ${token}`); + headers.set('X-Capsule-Protocol', PROTOCOL_VERSION); headers.set( 'Content-Type', headers.get('Content-Type') ?? 'application/json', @@ -146,6 +161,7 @@ export async function login( headers: { 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Capsule-Protocol': PROTOCOL_VERSION, }, body: JSON.stringify(body), }); @@ -173,7 +189,10 @@ export interface RegisterRequest { export async function register(body: RegisterRequest): Promise { const res = await fetch(`${AUTH_BASE}/register`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Capsule-Protocol': PROTOCOL_VERSION, + }, body: JSON.stringify(body), }); if (!res.ok) throw await parseError(res); @@ -206,6 +225,7 @@ export async function verifyTotpLogin( headers: { 'Content-Type': 'application/json', Accept: 'application/json', + 'X-Capsule-Protocol': PROTOCOL_VERSION, }, body: JSON.stringify({ mfa_token: mfaToken, totp_code: totpCode }), }); diff --git a/locales/en.json b/locales/en.json index aca7aaf8..b5ba9211 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7347,6 +7347,38 @@ "message": "Invalid email or password.", "context": "High-level error for HTTP 401 on login. Server sends code `error.auth.invalid_credentials`." }, + "error.auth.oidc_address_taken": { + "message": "An account with that email address already exists here. Sign in with its password instead.", + "context": "HTTP 409 on POST /v1/auth/oidc/callback (slice S-N1): the identity provider asserted an email address that already belongs to a local account, and this identity is not linked to it. Capsule never links accounts by an address the provider controls; the person signs in with the existing account's password." + }, + "error.auth.oidc_at_capacity": { + "message": "Too many sign-ins are already in progress. Please try again in a moment.", + "context": "HTTP 503 on POST /v1/auth/oidc/authorize (slice S-N1): the server is already holding as many unfinished single sign-on ceremonies as it will hold, so this one was not begun. Retryable within minutes - pending ceremonies expire ten minutes after they start. Distinct from error.auth.unavailable, which is a 500 meaning a store could not answer at all: this one means the server answered and said 'not now', and a client may retry it on a short timer rather than surfacing an outage." + }, + "error.auth.oidc_exchange_failed": { + "message": "Your identity provider didn't accept that sign-in. Try again.", + "context": "HTTP 401 on POST /v1/auth/oidc/callback (slice S-N1): the identity provider refused to exchange the authorization code (an invalid, spent or mismatched code). The person starts the sign-in again." + }, + "error.auth.oidc_not_configured": { + "message": "Single sign-on isn't set up on this server.", + "context": "HTTP 404 on POST /v1/auth/oidc/authorize (slice S-N1): this deployment has no OIDC identity provider configured, so there is nowhere to send the person. The server-info record publishes auth.oidc as null in this case; a client that shows the option anyway sees this." + }, + "error.auth.oidc_redirect_invalid": { + "message": "That sign-in can't return to this app.", + "context": "HTTP 400 on POST /v1/auth/oidc/authorize (slice S-N1): the redirect URI the client asked for is neither the deployment's configured one nor a loopback address it admits, so the server will not send the person back there. A client bug or a misconfigured deployment, not something the person can fix." + }, + "error.auth.oidc_state_invalid": { + "message": "That sign-in has expired. Start again.", + "context": "HTTP 401 on POST /v1/auth/oidc/callback (slice S-N1): the state is unknown, already redeemed, or past its ten-minute window. One answer for all three, so the callback is not an oracle; the person starts the sign-in again." + }, + "error.auth.oidc_token_invalid": { + "message": "Your identity provider's answer couldn't be verified.", + "context": "HTTP 401 on POST /v1/auth/oidc/callback (slice S-N1): the identity provider's ID token failed a check (signature, issuer, audience, expiry or nonce). One answer for every check, deliberately; the specific reason reaches the server log, not the client." + }, + "error.auth.oidc_unavailable": { + "message": "Capsule couldn't reach your identity provider just now. Please try again.", + "context": "HTTP 500 on the /v1/auth/oidc routes (slice S-N1): the identity provider could not be reached, or its published metadata or keys could not be used. Retryable, and distinct from error.auth.unavailable because 'the identity provider is down' and 'the session store is down' are different operator actions." + }, "error.auth.password_invalid": { "message": "That password cannot be used.", "context": "HTTP 400 on POST /v1/auth/password (slice S-C54): the proposed new password is under the 12-character floor, or is the password already in use. The second case matters because a change that changes nothing leaves the caller believing a leaked credential has been rotated." @@ -7499,6 +7531,10 @@ "message": "This upload link needs its passphrase.", "context": "HTTP 403 on POST /u/{opaque-id}/drop (web-upload): the link carries an Argon2id abuse-gate verifier and the guest supplied no proof, or a proof that did not verify." }, + "error.drop.at_capacity": { + "message": "This server is handling too many upload links right now. Please try again shortly.", + "context": "HTTP 429 on POST /d/{opaque_id} (slice S-C5): the per-link rate-limit store is holding as many distinct upload links as it will hold, so this link could not be given a window of its own. Saturated by design rather than broken - distinct from error.drop.unavailable, which is a 500 meaning a store could not answer at all. The `retry_after` member is an upper bound: it is the limiter window, after which a lapsed window frees room." + }, "error.drop.rate_limited": { "message": "Too many attempts. Please wait and try again.", "context": "HTTP 429 on POST /u/{opaque-id}/drop (web-upload, invariant 31): the per-{opaque-id} or per-source-IP drop-session rate limit engaged." @@ -7519,6 +7555,10 @@ "message": "Confirm it's you on this device to add another device.", "context": "HTTP 403 on POST /devices/enroll (slice S-C7): the access token is not fresh enough to stand in for the doc's fresh local device authorization. A valid session token alone cannot start a cross-device add — the caller must re-authenticate locally so a stolen, stale token cannot enroll a rogue device." }, + "error.enrollment.at_capacity": { + "message": "This server is handling too many enrollment attempts right now. Please try again shortly.", + "context": "HTTP 429 on POST /v1/auth/devices/enroll/redeem (slice S-C7): the redemption rate-limit store is holding as many distinct codes as it will hold, so this code could not be given a window of its own. Saturated by design rather than broken - distinct from error.auth.unavailable, which is a 500 meaning a store could not answer at all. The `retry_after` member is an upper bound: it is the limiter window, after which a lapsed window frees room." + }, "error.enrollment.rate_limited": { "message": "Too many device-add attempts. Please wait and try again.", "context": "HTTP 429 on POST /devices/enroll (slice S-C7): the caller exceeded the per-user enrollment-code issuance budget for the current window." @@ -7651,6 +7691,10 @@ "message": "That share link could not be created.", "context": "HTTP 400 on POST /v1/shares (slice S-C4): the record is not a usable share link — an opaque id that is not 32 lowercase hex characters, a malformed content address, a `serves` set that omits the metadata blob, an unparseable expiry, or a non-base64 wrapped secret. The owner's own operation, so the detail is specific; the public serve path never distinguishes anything." }, + "error.share.at_capacity": { + "message": "This server is handling too many shared links right now. Please try again shortly.", + "context": "HTTP 429 on the public /s/{opaque_id} routes (slice S-C4): the per-link rate-limit store is holding as many distinct share links as it will hold, so this link could not be given a window of its own. Saturated by design rather than broken - distinct from the 500 this surface renders when a store could not answer at all. The `retry_after` member is an upper bound: it is the limiter window, after which a lapsed window frees room." + }, "error.share.rate_limited": { "message": "Too many attempts. Please wait and try again.", "context": "HTTP 429 on the public share serve path (slice S-C4): the per-source-IP or per-{opaque-id} serve rate limit engaged. Both limiters are charged on every /s/{opaque-id} request (metadata, blob, wrapped-secret) to throttle link enumeration; a not-found/revoked/expired link is an indistinguishable bodyless 404, never a distinct code."