diff --git a/SLICES.md b/SLICES.md index 9ac8c572..ca3059ea 100644 --- a/SLICES.md +++ b/SLICES.md @@ -252,7 +252,7 @@ row's remainder now lives. | S-C22 | Structured `duplicate_blob` ref + adopt in OpenAPI | server | S-C37 | S | RETIRED | done\* | server half; adopt endpoint → `S-C5`; undescribed extension → `S-C38` | | S-C23 | `revoke_all_sessions` with master-key proof | server | S-C42 | M | RETIRED | done | `S-C48` closed the access-token window it left open | | S-C24 | Album-upgrade server halves (quiescence/drain/lineage) | server | S-C42 | M-L | RETIRED | done\* | the ceremony's wire vocabulary was `mls`-gated and therefore unreachable; the projection deliberately gets no lineage | -| S-C25 | Album provisioning + UUID album ids (unblocks push) | server | S-C29 | M | RETIRED | done\* | also lands the first real `WriteAuthority`; sharing widens it → `S-C4`/`S-C5` | +| S-C25 | Album provisioning + UUID album ids (unblocks push) | server | S-C29 | M | RETIRED | done\* | also lands the first real `WriteAuthority`; sharing widened it in `S-C51`; the `AlbumStore` Postgres adapter is still owed | | S-C26 | Retire the plaintext album name/description columns | server | S-C25 | S | RETIRED | done | the Kynos schema never declared them; a document tripwire keeps it that way | | S-C27 | Wire-contract types on plain serde behind an adapter | server | — | M | RETIRED | part 1 done | DTO move → Kynos rebuild; status gaps → `S-C28` | | S-C28 | Publish the statuses the server actually returns | server | S-C27 | S | RETIRED | done\* | auth surface closed; folds into each remaining port | @@ -266,7 +266,7 @@ row's remainder now lives. | S-C36 | Kynos's framework rejections carry no `error.*` code | server | S-C33 | M | RETIRED | done | a Capsule interceptor fills the member in; the upstream seam is still the better fix | | S-C37 | The asset index port, one sequence instead of two | server | S-C27, S-C29 | L | RETIRED | done | Postgres adapter landed under the row lock the design rests on; absorbs `S-C21`, unblocks `S-C22` | | S-C38 | Problem extensions are absent from the OpenAPI document | server | S-C34 | M | RETIRED | done\* | `code` is universal and derived; the sixteen other members ride a small table | -| S-C39 | Blob fetch has no read authority, so its `403` is unwritable | server | S-C10 | M | RETIRED | part | the authority lands and owner-scopes the path; the `403` needs a membership fact → `S-C51` | +| S-C39 | Blob fetch has no read authority, so its `403` is unwritable | server | S-C10 | M | RETIRED | done | the authority landed owner-scoped; the `403` landed with `S-C51`'s membership fact | | S-C40 | `awaiting-original` is not observable on the blob path | server | S-C10, S-C37 | M | RETIRED | done | the promise is the open upload session, so it needed no lifetime of its own | | S-C41 | The `deep` re-hash, with the limiter that makes it safe | server | S-C3, S-C32 | M | RETIRED | done\* | coalescing is deliberately absent, and the reason is in the note | | S-C42 | Nothing verifies the device directory's own signature | server | S-C9 | M | RETIRED | done | trust-on-first-publish anchor; unblocks `S-C23` | @@ -278,7 +278,7 @@ row's remainder now lives. | S-C48 | The bearer scheme never reads the session ledger | server | S-C23, S-C29 | M | RETIRED | done\* | fails closed as `401`; the honest `503` needs the seam `S-C36` wants | | S-C49 | Moderation's federated halves have no federation to hang on | server | S-C8, S-C32 | M | RETIRED | blocked | found by `S-C8`; report intake and the blocklist both need the federation layer | | S-C50 | The share-link privacy strip is specified where it cannot run | docs | S-C4 | S | ACTIVE | done | both docs now name the issuing client, with containment as the server's half | -| S-C51 | Server-side album membership, which two authorities are waiting on | server | S-C25, S-C39 | L | RETIRED | blocked | found by `S-C39`; the read `403` and the widening of album *write* access are one missing fact | +| S-C51 | Server-side album membership, which two authorities are waiting on | server | S-C25, S-C39 | L | RETIRED | done | the owner-signed roster is the fact; `PUT /v1/albums/{album_id}/roster`, the write widening, the blob `403` and `GET /v1/sync?album_id=` land together; shared bytes across owners → #462 | | S-C52 | The server keeps one manifest per asset, not the chain it is documented to hold | server | S-C16, S-C43, S-C45 | M | RETIRED | done | retention decided *for*, and scrub check 4 lands with it | | S-C53 | Account creation has no surface on the rebuilt server | server | S-C13 | M | RETIRED | done | registration lands; the unported operations are decided one by one on `S-C54`–`S-C58` | | S-C54 | The profile surface, and a password change that is not a reset | server | S-C53 | M | RETIRED | done | three operations where Salvo had one; the address becomes immutable and `/validate` and password reset are deleted rather than owed | @@ -2190,9 +2190,10 @@ working on a surface written after it. - **The name refusal is a `422`, not a silent drop.** The body is strict, so a `name` or `description` is refused — a client is told the server will not hold album titles rather than left to assume it did. `S-C26` retires the columns themselves. -- **Owed:** sharing widens "writable" from *owner* to *member*, which is `S-C4`/`S-C5`; until - then an album is writable only by the account it was provisioned to, which is the safe - direction. The Postgres adapter is owed with the rest. +- **Owed:** sharing widens "writable" from *owner* to *member* — landed with `S-C51`, not with + `S-C4`/`S-C5` (a share link is not a member): `album_write_access` is keyed on the caller and + answers a writer on the album's roster with the owner's namespace. The Postgres adapter for + `AlbumStore` is still owed with the rest. [`WriteAuthority`]: #s-c20--ground-invariant-7s-floor-in-the-device-directory @@ -2686,8 +2687,13 @@ design/moderation.md states the per-surface rule as *"takedown of known content re-reading a landed, tested contract on an inference is not this slice's to do. Recorded here for whoever owns that question. -- **Done when:** the account unshared from an album receives `403` — **not met**, and blocked on - `S-C51`. ✅ what did land: `another_accounts_live_blob_is_unknown_rather_than_served`, +- **Done when:** the account unshared from an album receives `403` — **met with `S-C51`** + (2026-09-03): `MembershipAuthority` replaces `OwnedAssetAuthority`, `BlobReadAccess::Revoked` + renders `403 error.blob.access_revoked` for an account the membership store holds a revoked + row for, and the authority is still asked first. ✅ `a_former_member_is_told_access_was_revoked`, + `a_former_member_gets_the_403_before_any_policy_refusal`, + `a_never_member_is_indistinguishable_from_an_unknown_address_body_and_headers`, plus what + landed with the `part`: `another_accounts_live_blob_is_unknown_rather_than_served`, `a_strangers_refusal_is_indistinguishable_from_an_unknown_address`, `a_stranger_cannot_tell_a_takedown_from_an_unknown_address`, and the authority's own unit cases. **Tier:** Unit + Integration. @@ -3878,8 +3884,8 @@ them was incidental: - **Gap** (found 2026-08-31 landing `S-C39`): the server holds no fact about who, other than the owner, may read or write an album. Two separate authorities are pinned to "owner only" by the same absence: - - `OwnedAssetAuthority` cannot render the `403` the download contract describes, because there - is no membership to withdraw; + - `OwnedAssetAuthority` (since replaced by `MembershipAuthority`) cannot render the `403` the + download contract describes, because there is no membership to withdraw; - `ProvisionedAuthority::album_write_access` has answered `Denied` for anything but the owner since `S-C25`, with a comment deferring the widening to `S-C4`/`S-C5` — which landed as **link** and **drop** capabilities and did not add it, correctly: a share link is not a @@ -3893,10 +3899,32 @@ them was incidental: end-to-end encryption exists to avoid. - **Blocked on:** the same signed-capability primitive federation needs, so it should be designed with `S-C49` rather than beside it. +- **Landed 2026-09-03 (#405).** The fact is a **full-roster attestation** the album owner signs + with a non-revoked device in their published device directory — + `capsule_core::crypto::membership::SignedAlbumRoster`, canonical CBOR, strictly monotonic + `roster_version`, non-decreasing `amk_epoch`, removal as absence at a higher version — published + at `PUT /v1/albums/{album_id}/roster` (invariant 33) and held by the `membership` port + (in-memory and Postgres, ordinal 5, one conformance suite). Removal is a *stored* fact: the row + is marked with the version and epoch at which the member vanished, which is what lets the blob + route disclose `403` to a former member and nothing to anyone else. It is a **transport** + control, never a confidentiality one; the server still cannot read the MLS group. + Widened on it: `WriteAuthority::album_write_access` is caller-keyed and admits a writer member + under the owner's namespace (upload, ops; adoption and finalization re-check); `MembershipAuthority` + serves either role and answers `Revoked` → `403 error.blob.access_revoked`; + `GET /v1/sync?album_id=` pages the owner's sequence filtered to the album for its members, with + the cursor bound to `(caller, album)`. Not here: the federation capability path (#406, which + stacks on `MembershipStore`, `CursorScope`, `album_feed_page` and `BlobReadAccess`); rosters + published by non-owner admins; bytes shared across unrelated owners, which `find_reference` still + decides from the first live row (#462). - **Done when:** a member of a shared album reads its blobs through `/v1/blob/{hash}` and writes to it through the upload path; a former member receives `403` on the first and a write refusal on the second; and a non-member remains unable to tell either from an address that does not - exist. **Tier:** Unit + Integration. + exist — **met**: `a_member_of_either_role_reads_the_owners_blobs`, + `a_writer_member_uploads_into_the_owners_album_and_pays_for_it`, + `a_former_member_is_told_access_was_revoked`, + `a_reader_a_former_member_and_a_stranger_get_the_one_album_refusal`, + `a_never_member_is_indistinguishable_from_an_unknown_address_body_and_headers`. + **Tier:** Unit + Integration. ### S-D1 — SDK upload client diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index 4c874840..104b58d6 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1819,6 +1819,11 @@ Upload only This album could not be registered because its identifier is malformed. This album isn\'t available on your account. + Only a device on the album owner\'s account can publish its roster. + Capsule couldn\'t read that album roster. + That album isn\'t yours, or doesn\'t exist. + The roster you sent is out of step with the one the server holds. + That roster\'s version is too far ahead of the one the server holds. Capsule couldn\'t set up that album. Please try again. This album is already being upgraded. Capsule couldn\'t read that album upgrade request. @@ -1843,6 +1848,7 @@ There is no two-factor setup waiting to be confirmed. Capsule couldn\'t reach your account just now. Please try again. An account with these details already exists. + You no longer have access to this album. That photo file is no longer available. That photo file isn\'t on the server. The original photo hasn\'t been uploaded from its device yet. @@ -1909,6 +1915,7 @@ Too many deep storage checks. Please wait and try again. The storage-verification request was malformed. Capsule couldn\'t check whether your photos are safely stored. Please try again. + You don\'t have access to that album. The sync session is out of date. Capsule will resync from the start. Please sign in again to continue syncing. Capsule could not reach the server to sync. It will try again. diff --git a/capsule-cli/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-core/src/crypto/membership.rs b/capsule-core/src/crypto/membership.rs new file mode 100644 index 00000000..3844c90d --- /dev/null +++ b/capsule-core/src/crypto/membership.rs @@ -0,0 +1,350 @@ +//! The album roster attestation — the one membership fact a **key-free server** can verify +//! (slice `S-C51`). +//! +//! # What the server cannot see, and what this gives it instead +//! +//! Membership of a shared album is decided inside the MLS group, and every control message +//! that adds or removes a member is AEAD-protected under a group key the server never holds. +//! `crypto::authority` can classify a commit chain as behind, ahead or forked from a server's +//! view of it, but it cannot tell the server *who is in the group*. So the server has no +//! roster — and without one it cannot answer "may this account read this album's blobs", which +//! is why the blob route and the album write routes have been owner-only. +//! +//! The [`SignedAlbumRoster`] is the album owner's **statement** of the roster, signed by one of +//! the owner account's devices. The server verifies it against the owner's published +//! [`DeviceDirectory`] — the same trust anchor `S-C42` established and the same check +//! [`SignedUpgradeIntent::verify`](crate::crypto::upgrade::SignedUpgradeIntent::verify) runs +//! for the upgrade ceremony — and then holds it as a *transport control*: who may fetch which +//! bytes. It is **not** a confidentiality control. A former member who kept the AMK for an +//! epoch can still decrypt what they already downloaded; what the roster does is stop the +//! server handing them anything further, exactly as design/federation.md says an unshare cuts +//! read access to the historical photos at the transport level. +//! +//! # A full document, versioned, and the owner is implicit +//! +//! The roster is the **whole** member list every time, with a strictly monotonic +//! [`AlbumRoster::roster_version`] — the same shape as the device directory's +//! `directory_version` (invariant 23). One monotonic field gives idempotency, replay-safety and +//! ordering at once, and it needs no per-grant ids and no separate revocation artefact: +//! removal is *absence* at a higher version. The owner account is never listed, because the +//! owner's access is the album record's `owner_id` fact and a roster that could omit the owner +//! would be a roster that could lock the owner out. +//! +//! # Why this lives here and not in the server crate +//! +//! A client signs it. The DSK that signs a roster is on a device, so the type must be +//! constructible and signable without the server crate — which is the rule `crypto::upgrade` +//! records for the upgrade intent, for the same reason: a structure defined at both ends is one +//! added field away from a signature that stops verifying. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::crypto::keys::{AmkVersion, DeviceDirectory, HybridSignature, HybridSigningKey}; + +/// What went wrong encoding or verifying an album roster. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MembershipError { + /// The roster could not be canonically encoded for signing. + #[error("the album roster could not be encoded: {0}")] + Encode(String), + /// The signature did not verify, or the attesting device is not a live device of the album + /// owner's account. + #[error("the album roster's attester signature did not verify: {0}")] + Attester(&'static str), +} + +/// What a member may do with the album's contents, as far as the server is concerned. +/// +/// Two values only. The finer MLS-side distinctions (admin, for one) never reach the server, +/// which needs exactly this: whether to serve bytes, and whether to accept them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemberRole { + /// May read the album's blobs and its sync feed. + Reader, + /// May read, and may add or change assets under the album owner's namespace. + Writer, +} + +/// One member of an album, as the roster names them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RosterMember { + /// The member's account. + pub user_id: Uuid, + /// What the member may do. + pub role: MemberRole, +} + +/// The signed-over content of a roster attestation. Every field is covered by the attesting +/// device's DSK hybrid signature in the enclosing [`SignedAlbumRoster`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AlbumRoster { + /// The album this roster is for. + pub album_id: Uuid, + /// Strictly monotonic per album. A server refuses a version at or below the one it holds + /// unless the bytes are identical (a replay), so a roster can neither be rolled back nor + /// silently replaced. + /// + /// A server also bounds it **above**, by a small step over the version it holds: the field + /// is an ordering, not a count, and a version nothing could ever exceed would freeze the + /// album's membership permanently. A client that is refused for it re-signs the same + /// document one above the version the refusal names. + pub roster_version: u64, + /// The AMK epoch the group is at after the commit this roster reflects. Non-decreasing + /// across versions; the server records the epoch at which a member was granted and the one + /// at which they vanished. + pub amk_epoch: AmkVersion, + /// The album owner's account. The server anchors on this account's published device + /// directory, and refuses a roster whose owner is not the album's. + pub attested_by_user: Uuid, + /// The owner-account device whose DSK signed this roster. Must be present and **not + /// revoked** in the owner's directory. + pub attested_by_device: Uuid, + /// RFC 3339 time the client produced the roster. Audit-only: the server orders by + /// `roster_version`, never by this. + pub attested_at: String, + /// Everyone other than the owner who may read the album, and what they may do. Absence at a + /// higher version *is* removal. + pub members: Vec, +} + +impl AlbumRoster { + /// The canonical-CBOR signing bytes the attesting device's DSK covers. + /// + /// # Errors + /// + /// Returns [`MembershipError::Encode`] if the roster cannot be canonically encoded. + pub fn signing_bytes(&self) -> Result, MembershipError> { + crate::cbor::to_canonical_vec(self).map_err(|e| MembershipError::Encode(e.to_string())) + } +} + +/// An [`AlbumRoster`] plus the attesting device's DSK **hybrid** signature over it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedAlbumRoster { + /// The attested roster. + pub roster: AlbumRoster, + /// The attesting DSK's hybrid signature over [`AlbumRoster::signing_bytes`]. + pub attester_sig: HybridSignature, +} + +impl SignedAlbumRoster { + /// Sign `roster` with the attesting device's DSK. + /// + /// The caller is responsible for `roster.attested_by_device` naming the device `dsk` + /// belongs to; [`verify`](Self::verify) is what checks it, on the other end. + /// + /// # Errors + /// + /// Returns [`MembershipError::Encode`] if the roster cannot be canonically encoded. + pub fn sign(roster: AlbumRoster, dsk: &HybridSigningKey) -> Result { + let attester_sig = dsk.sign(&roster.signing_bytes()?); + Ok(Self { + roster, + attester_sig, + }) + } + + /// Verify the attester's DSK hybrid signature (Ed25519 **and** ML-DSA) against the album + /// owner's published device directory. + /// + /// Stricter than the upgrade intent's check in one respect: a device the directory has + /// **revoked** may not attest a roster, whatever it signed before. The entry is retained so + /// that older manifests stay verifiable; it is not a licence to keep issuing new documents. + /// + /// # Errors + /// + /// Returns [`MembershipError::Attester`] when the directory names a different account, does + /// not hold the attesting device, holds it revoked, or the signature does not verify under + /// its DSK. + pub fn verify(&self, directory: &DeviceDirectory) -> Result<(), MembershipError> { + if directory.core.user_id != self.roster.attested_by_user { + return Err(MembershipError::Attester( + "the roster's attested_by_user is not this directory's account", + )); + } + let entry = + directory + .device(&self.roster.attested_by_device) + .ok_or(MembershipError::Attester( + "the attesting device is not in the directory", + ))?; + if entry.revoked_at.is_some() { + return Err(MembershipError::Attester("the attesting device is revoked")); + } + if !entry + .dsk_public + .verify(&self.roster.signing_bytes()?, &self.attester_sig) + { + return Err(MembershipError::Attester( + "the attester's DSK signature does not verify", + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::keys::{DeviceEntry, DirectoryCore}; + + const OWNER: Uuid = Uuid::from_u128(0xA11CE); + const DEVICE: Uuid = Uuid::from_u128(0xD1); + const ALBUM: Uuid = Uuid::from_u128(0xA1B); + const BOB: Uuid = Uuid::from_u128(0xB0B); + const CAROL: Uuid = Uuid::from_u128(0xCA501); + + fn ik() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[1; 32], &[2; 32]) + } + + fn dsk() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[3; 32], &[4; 32]) + } + + fn other_dsk() -> HybridSigningKey { + HybridSigningKey::from_seed_bytes(&[5; 32], &[6; 32]) + } + + fn directory_for(user_id: Uuid, revoked: bool) -> DeviceDirectory { + DirectoryCore { + user_id, + directory_version: 1, + updated_at: "2026-09-01T00:00:00Z".into(), + devices: vec![DeviceEntry { + device_id: DEVICE, + dsk_public: dsk().verifying_key(), + dek_public: None, + added_at: "2026-09-01T00:00:00Z".into(), + revoked_at: revoked.then(|| "2026-09-02T00:00:00Z".to_owned()), + }], + } + .sign(&ik()) + } + + fn roster() -> AlbumRoster { + AlbumRoster { + album_id: ALBUM, + roster_version: 1, + amk_epoch: AmkVersion(1), + attested_by_user: OWNER, + attested_by_device: DEVICE, + attested_at: "2026-09-02T00:00:00Z".into(), + members: vec![ + RosterMember { + user_id: BOB, + role: MemberRole::Writer, + }, + RosterMember { + user_id: CAROL, + role: MemberRole::Reader, + }, + ], + } + } + + #[test] + fn a_roster_signed_by_a_live_owner_device_verifies() { + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!(signed.verify(&directory_for(OWNER, false)), Ok(())); + } + + #[test] + fn the_signed_roster_round_trips_through_canonical_cbor() { + // The server stores the document verbatim and the SDK ships it base64-encoded, so the + // bytes must decode back to a value that still verifies. + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + let bytes = crate::cbor::to_canonical_vec(&signed).expect("encodes"); + let decoded: SignedAlbumRoster = crate::cbor::from_slice(&bytes).expect("decodes"); + assert_eq!(decoded, signed); + assert_eq!(decoded.verify(&directory_for(OWNER, false)), Ok(())); + // And the signing bytes are stable: the same roster encodes to the same bytes. + assert_eq!( + roster().signing_bytes().expect("encodes"), + decoded.roster.signing_bytes().expect("encodes") + ); + } + + #[test] + fn a_directory_of_another_account_is_refused() { + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(BOB, false)), + Err(MembershipError::Attester( + "the roster's attested_by_user is not this directory's account" + )) + ); + } + + #[test] + fn a_device_the_directory_does_not_hold_is_refused() { + let mut unknown = roster(); + unknown.attested_by_device = Uuid::from_u128(0xD2); + let signed = SignedAlbumRoster::sign(unknown, &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attesting device is not in the directory" + )) + ); + } + + #[test] + fn a_revoked_device_may_not_attest_a_roster() { + // Stricter than the upgrade intent: the entry is retained so old manifests verify, not + // so the device can keep issuing new documents. + let signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, true)), + Err(MembershipError::Attester("the attesting device is revoked")) + ); + } + + #[test] + fn a_tampered_member_role_does_not_verify() { + let mut signed = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + signed.roster.members[1].role = MemberRole::Writer; + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attester's DSK signature does not verify" + )) + ); + } + + #[test] + fn a_tampered_version_or_epoch_does_not_verify() { + let refused = Err(MembershipError::Attester( + "the attester's DSK signature does not verify", + )); + let mut bumped = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + bumped.roster.roster_version = 2; + assert_eq!(bumped.verify(&directory_for(OWNER, false)), refused); + + let mut rolled = SignedAlbumRoster::sign(roster(), &dsk()).expect("signs"); + rolled.roster.amk_epoch = AmkVersion(2); + assert_eq!(rolled.verify(&directory_for(OWNER, false)), refused); + } + + #[test] + fn a_signature_by_the_wrong_key_does_not_verify() { + // The right device id, the wrong DSK: what a member forging the owner's attestation + // looks like. + let signed = SignedAlbumRoster::sign(roster(), &other_dsk()).expect("signs"); + assert_eq!( + signed.verify(&directory_for(OWNER, false)), + Err(MembershipError::Attester( + "the attester's DSK signature does not verify" + )) + ); + } + + #[test] + fn roles_encode_as_their_snake_case_tokens() { + let bytes = crate::cbor::to_canonical_vec(&MemberRole::Reader).expect("encodes"); + let value: ciborium::Value = ciborium::from_reader(bytes.as_slice()).expect("decodes"); + assert_eq!(value, ciborium::Value::Text("reader".into())); + } +} diff --git a/capsule-core/src/crypto/mod.rs b/capsule-core/src/crypto/mod.rs index 5bb63611..39ba6f06 100644 --- a/capsule-core/src/crypto/mod.rs +++ b/capsule-core/src/crypto/mod.rs @@ -8,6 +8,7 @@ //! ```text //! hash · primitives · rng · kdf · pwkdf (foundation, no internal deps) //! └─ keys ─ encryption (key hierarchy + AEAD) +//! └─ keys ─ membership (the owner-signed album roster) //! └─ authority ─┐ //! └─ provenance ┴─ verify_asset (the single acknowledgement chokepoint) //! ``` @@ -21,6 +22,7 @@ pub mod encryption; pub mod hash; pub mod kdf; pub mod keys; +pub mod membership; pub mod primitives; pub mod provenance; pub mod pwkdf; diff --git a/capsule-core/src/crypto/provenance/manifest.rs b/capsule-core/src/crypto/provenance/manifest.rs index b859603a..46330d84 100644 --- a/capsule-core/src/crypto/provenance/manifest.rs +++ b/capsule-core/src/crypto/provenance/manifest.rs @@ -126,9 +126,26 @@ pub struct ManifestCore { /// `delete | derivative-* | trash-restore`. #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata_blob_hash: Option, - /// User who produced the asset. + /// The account whose device signed **this record**. + /// + /// Per-record, not per-asset: a `delete` written by a second device — or by a member of a + /// shared album — names that writer, not the account that created the asset. The asset's + /// original creator is recoverable from the `create` record at the head of the append-only + /// provenance chain, which is where it belongs. + /// + /// The pairing with [`Self::created_by_device`] is load-bearing rather than descriptive: + /// [`verify_asset`](crate::crypto::verify_asset::verify_asset) resolves the device *inside + /// this account's* published directory (step 6) and verifies [`AssetManifest::device_sig`] + /// under that entry's key (step 8), so a record naming anyone but its own signer cannot + /// verify. Album write authority is decided separately, by `write_sig` at step 10. pub created_by_user: Uuid, - /// Device that produced the asset (resolved in the device directory). + /// The device that signed **this record**, resolved in [`Self::created_by_user`]'s directory. + /// + /// Per-record for the same reason and with the same consequence: it must be the device whose + /// DSK produced [`AssetManifest::device_sig`], or step 8 of + /// [`verify_asset`](crate::crypto::verify_asset::verify_asset) rejects the manifest. The + /// server mirrors the resolvable half key-free as invariant 7 — the device must be in the + /// *calling* account's published directory, with `added_at` before the manifest's timestamp. pub created_by_device: Uuid, /// Producing client version string. pub client_version: String, diff --git a/capsule-core/src/lifecycle/provenance.rs b/capsule-core/src/lifecycle/provenance.rs index a71d6002..d5331d00 100644 --- a/capsule-core/src/lifecycle/provenance.rs +++ b/capsule-core/src/lifecycle/provenance.rs @@ -23,6 +23,27 @@ impl Workspace { /// fields. Used for metadata-update / delete / trash-restore. `metadata_blob_hash` is set /// explicitly per the presence-by-action rule (`Some` for a metadata-update that seals a /// fresh blob, `None` for delete / trash-restore) rather than inherited from `base`. + /// + /// # Who a continuation names, and why it cannot be the creator + /// + /// `created_by_user` / `created_by_device` name the **signer of this record**, re-minted per + /// write like `timestamp` and `client_version` — never inherited from `base`. + /// + /// That is not a preference, it is what + /// [`verify_asset`] requires: it resolves + /// `created_by_device` *inside `created_by_user`'s* published directory (step 6) and then + /// verifies `device_sig` under **that entry's** key (step 8). A record naming a device that + /// did not sign it fails step 8 and is unverifiable by every reader. Inheriting the pair + /// therefore broke the ordinary two-device case — device B deleting an asset created on + /// device A produced a manifest claiming A and signed by B — as well as every write by a + /// shared album's member. + /// + /// Album authority is a separate check and is unaffected: step 10 verifies `write_sig` + /// under the epoch's attested write-tier key, so naming the acting member as this record's + /// author does not weaken the owner's album. + /// + /// The asset's original creator stays recoverable where it always was — the `create` record + /// at the head of the provenance chain, which is append-only. fn sign_lifecycle( &self, album: &AlbumKeys, @@ -38,6 +59,11 @@ impl Workspace { retention_until, metadata_blob_hash, timestamp: now_rfc3339(), + // This record's signer, not the asset's creator — see the doc comment above. The + // same pair every create path writes (`import.rs`, `drops.rs`, `drop/mod.rs`), for + // the same reason: it is the device whose DSK signs the bytes below. + created_by_user: self.account.user_id, + created_by_device: self.account.device.device_id, // Each write records the exact client build that produced *this* record (S-D15), not // the creator's — so an edit by a different client identifies itself in the chain. client_version: self.client_version.clone(), @@ -210,7 +236,7 @@ mod tests { use super::super::fast_workspace; use super::*; - use crate::crypto::keys::Amk; + use crate::crypto::keys::{Amk, HybridSigningKey}; /// S-A3: the `Workspace` populates `metadata_blob_hash` per the sealing order, the sidecar /// binds to the manifest through the prior head, and a one-byte sidecar mutation quarantines. @@ -307,4 +333,151 @@ mod tests { ); assert!(del.structural_ok()); } + + /// Re-point `ws` at a different signing device — and optionally a different **account** — + /// publishing a directory that holds it. What a second phone, or a shared album's member, + /// looks like to everything below the signer. + fn become_device( + ws: &mut Workspace, + account: Option<(Uuid, HybridSigningKey)>, + device_id: Uuid, + dsk: HybridSigningKey, + ) { + use crate::crypto::keys::{DeviceEntry, DirectoryCore}; + + let entry = DeviceEntry { + device_id, + dsk_public: dsk.verifying_key(), + dek_public: None, + // Must precede any manifest it signs; the workspace stamps `now`. + added_at: "2020-01-01T00:00:00Z".into(), + revoked_at: None, + }; + ws.directory = match account { + // A second device of the *same* account: appended to the account's own directory, + // which is re-signed by the account IK at a higher version. + None => { + let mut core = ws.directory.core.clone(); + core.directory_version += 1; + core.devices.push(entry); + core.sign(&ws.account.user_ik) + } + // A different account entirely: its own directory, under its own IK. + Some((user_id, ref ik)) => { + let directory = DirectoryCore { + user_id, + directory_version: 1, + updated_at: now_rfc3339(), + devices: vec![entry], + } + .sign(ik); + ws.account.user_id = user_id; + directory + } + }; + ws.account.device.device_id = device_id; + ws.device_signer = Box::new(dsk); + } + + fn imported(lib: &TempDir, src: &TempDir) -> (Workspace, Uuid, Uuid) { + let img = src.path().join("photo.jpg"); + fs::write(&img, b"\xFF\xD8\xFF continuation-authorship bytes").unwrap(); + let mut ws = fast_workspace(lib.path()); + let album = ws.create_album("Trip").unwrap(); + let asset = ws.import_asset(album, &img).unwrap(); + (ws, album, asset) + } + + /// **A second device of the same account continues a chain, and the result verifies.** + /// + /// The case `sign_lifecycle` used to break outright: it inherited `created_by_user` and + /// `created_by_device` from the chain head while signing with the *current* device, so a + /// delete from device B claimed device A and failed `verify_asset` step 8 — the device + /// signature does not verify under the named entry's key. Ordinary two-device use, no + /// sharing required. + #[test] + fn a_continuation_from_a_second_device_names_it_and_verifies() { + let (lib, src) = (TempDir::new().unwrap(), TempDir::new().unwrap()); + let (mut ws, _album, asset) = imported(&lib, &src); + + let creator = ws.account.device.device_id; + let second = Uuid::from_u128(0xD2); + become_device( + &mut ws, + None, + second, + HybridSigningKey::from_seed_bytes(&[9; 32], &[10; 32]), + ); + + ws.soft_delete(&asset, 30).unwrap(); + + let st = ws.asset(&asset).unwrap(); + let head = &st.chain.records().last().unwrap().manifest; + assert_eq!(head.core.action, Action::Delete); + assert_eq!( + head.core.created_by_device, second, + "the continuation names the device that signed it" + ); + assert_ne!( + head.core.created_by_device, creator, + "and not the one that created the asset" + ); + assert_eq!( + ws.verify(&asset).unwrap(), + VerifyOutcome::Accept, + "which is the only reason it can verify at all" + ); + + // The creator is not lost — it is where the append-only chain keeps it. + assert_eq!( + st.chain.records()[0].manifest.core.created_by_device, + creator + ); + assert_eq!(st.chain.records()[0].manifest.core.action, Action::Create); + } + + /// **A member of a shared album continues the owner's chain under the member's own account**, + /// and it verifies against the *member's* directory. + /// + /// The write-tier signature is what carries album authority (step 10) and it is unaffected: + /// the member holds the epoch's write-tier key, which is what membership *is*. Naming the + /// acting member as the record's author therefore does not weaken the owner's album — it is + /// the only way the record can be verified by anyone. + #[test] + fn a_members_continuation_verifies_under_the_members_own_directory() { + let (lib, src) = (TempDir::new().unwrap(), TempDir::new().unwrap()); + let (mut ws, _album, asset) = imported(&lib, &src); + + let owner = ws.account.user_id; + let member = Uuid::from_u128(0xB0B); + become_device( + &mut ws, + Some(( + member, + HybridSigningKey::from_seed_bytes(&[11; 32], &[12; 32]), + )), + Uuid::from_u128(0xD3), + HybridSigningKey::from_seed_bytes(&[13; 32], &[14; 32]), + ); + + ws.soft_delete(&asset, 30).unwrap(); + + let st = ws.asset(&asset).unwrap(); + let head = &st.chain.records().last().unwrap().manifest; + assert_eq!( + head.core.created_by_user, member, + "a member's write is authored by the member" + ); + assert_ne!(head.core.created_by_user, owner); + assert_eq!( + ws.verify(&asset).unwrap(), + VerifyOutcome::Accept, + "verified under the member's directory, against the owner's album authority" + ); + assert_eq!( + st.chain.records()[0].manifest.core.created_by_user, + owner, + "and the album's asset is still the owner's creation" + ); + } } diff --git a/capsule-docs/src/content/docs/design/api-surfaces.md b/capsule-docs/src/content/docs/design/api-surfaces.md index b3d664da..b7bcc6c1 100644 --- a/capsule-docs/src/content/docs/design/api-surfaces.md +++ b/capsule-docs/src/content/docs/design/api-surfaces.md @@ -21,6 +21,7 @@ the gate that keeps it current — is [Developer Documentation](/design/develope | Authentication (sessions, TOTP, OIDC) | REST | `capsule-server::auth` | [Authentication](/design/authentication/) | | Resumable upload (`POST /v1/upload`, then `HEAD/PATCH /v1/upload/{id}`) | REST | `capsule-server::upload` | [Upload Protocol](/design/import/upload-protocol/) | | Lifecycle writes (`POST /v1/albums/{album_id}/ops`) | REST | `capsule-server::routes::ops` | [Authorization](/design/authorization/#the-lifecycle-write-surface) | +| Album roster publish (`PUT /v1/albums/{album_id}/roster`) | REST | `capsule-server::membership` | [Threat Model — Validation](/design/threat-model/validation/) (invariant 33) | | Blob fetch (`GET /v1/blob/{hash}`, HTTP `Range`) | REST | `capsule-server::blob` | [Download & Sync](/design/import/download-sync/) | | Sync feed (change discovery after a cursor) | REST | `capsule-server::sync` | [Download & Sync](/design/import/download-sync/) | | Federation pull | REST | `capsule-server::federation` | [Federation](/design/federation/) | @@ -123,12 +124,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/authorization.md b/capsule-docs/src/content/docs/design/authorization.md index 40ce4185..c97fb09d 100644 --- a/capsule-docs/src/content/docs/design/authorization.md +++ b/capsule-docs/src/content/docs/design/authorization.md @@ -48,6 +48,12 @@ The endpoint is deliberately singular — one closed enum, one gate, one transac The transport row lives in [API Surfaces](/design/api-surfaces/#surface--transport-map). Implementation is planned in `capsule-server::routes::ops` (slice `S-C16`, reusing the upload server's envelope gate). +## Album Membership on the Server + +Album sharing between accounts is an MLS group whose roster the server cannot read, by design. What the server holds instead is the album owner's **signed roster** — the whole member list, each with a role of `reader` or `writer`, under a strictly monotonic `roster_version` and the AMK epoch it reflects, signed by a non-revoked device in the owner's published device directory and published at `PUT /v1/albums/{album_id}/roster` (slice `S-C51`; [invariant 33](/design/threat-model/validation/#server-side-validation-invariants)). Only the owner account publishes; removal is a later roster that omits the member, and the server records the version and epoch at which they vanished rather than deleting the row. The version is bounded above as well as below — at most sixteen past the held one — because monotonicity alone would let a single publish latch the counter where nothing could supersede it and freeze the album's membership permanently; the refusal names the held version, and re-signing the same roster one above it says exactly the same thing. + +That stored fact widens two decisions that were owner-only until it existed. A **writer** on the current roster may write to the album through the upload path and `POST /v1/albums/{album_id}/ops`; the write is filed under the *owner's* namespace — the owner's feed is the one every member's devices read — and billed to the uploader. Any account on the roster, in either role, may fetch the album's blobs; a **former** member receives the `403` [Download & Sync](/design/import/download-sync/) describes; an account the roster never named gets the same `404` an unknown address gets. The roster is a transport control over who the server serves, never a confidentiality control: the server executes what the owner's signed statement permits, and, as below, authorizes nothing itself. + ## The Server Executes But Never Authorizes Per the principle of [trusting the server for storage, never for authorization](/design/cryptography/), the server **carries out** a remote delete or replace but is **never** the authority that permits it. A server-asserted lifecycle change with no valid write-tier signature is rejected by every client. This bounds the damage a compromised or buggy server can do: it can refuse to store data, but it cannot forge its destruction. diff --git a/capsule-docs/src/content/docs/design/import/download-sync.md b/capsule-docs/src/content/docs/design/import/download-sync.md index a6b2e314..4fa790f6 100644 --- a/capsule-docs/src/content/docs/design/import/download-sync.md +++ b/capsule-docs/src/content/docs/design/import/download-sync.md @@ -15,12 +15,15 @@ A client never polls assets individually. It holds a single opaque **sync cursor | Surface | Transport | Purpose | | --------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /v1/sync?cursor=…&page_size=…` | Kynos REST | Returns a page of asset changes (created, metadata-updated, deleted) after `cursor`, with a `next_cursor`. The feed is monotonic and resumable. | +| `GET /v1/sync?album_id=…&cursor=…` | Kynos REST | The same page for one **shared album**, read by its owner or by any account on its current roster (`S-C51`): the owner's sequence filtered to the album, so positions are per-album monotonic; the cursor is bound to `(caller, album)`. Anyone else — never a member, removed, or no such album — receives one `403 error.sync.album_access_denied`. The gaps between an album's positions reveal *how many* changes the owner made elsewhere, and nothing else; that volume-only metadata is accepted rather than paid for with a second, per-album numbering the anti-rewind mark would then have to reconcile. | | `GET /v1/blob/{hash}` | REST (HTTP `Range`) | Fetch a ciphertext blob by its content address; ranged for resumable and partial reads. | Each sync entry carries the asset's signed manifest as **the exact bytes the client uploaded** in its [provenance blob](/design/cryptography/provenance/#asset-manifest), the small encrypted **metadata blob**, and the asset's **blob manifest** — the content hashes of its original and derivative blobs — never original or derivative bytes. The manifest is passed through, not rebuilt: the server also holds an envelope projection of those same fields for its own key-free checks, but re-serializing *that* would hand the client a manifest detached from its signatures, which is a manifest no one can verify. Discovering a thousand new assets costs a few hundred kilobytes. The client decrypts each metadata blob, learns the asset's dimensions, capture date, and LQIP, and only *then* decides what else, if anything, to fetch. A deleted or modified asset arrives as a tombstone or an updated metadata reference; the client reconciles local state against it (see [Synchronization Scope](#synchronization-scope)). **Cursor authenticity.** The opaque sync cursor is **MAC'd by the server** (HMAC-SHA256 — a server-internal construction: the cursor is opaque to clients and never verified by them, so it sits outside the client-facing [primitives inventory](/design/cryptography/primitives/#primitives-inventory)) under a server-only key and verified on every `Sync` call (and [federation pull](/design/federation/#federation-reuses-existing-primitives)), so a client cannot forge or mutate a cursor and a cursor lifted from another context is rejected at the boundary. The MAC is the *authenticity* layer; the per-album monotonic `sync_seq` check below is the independent *anti-rewind* layer. They are separate on purpose: a malicious server can always hand back one of its own *older*, validly-MAC'd cursors, and only the client-held high-water mark defeats that. Together they close the [sync-cursor rewind class](/design/threat-model/scenarios/#damage-scenario--invariant-map). +**Operator note — deploying `S-C51` invalidates every cursor already issued.** The MAC now covers the *scope* a cursor was minted for (the caller, and the album when the page is an album's), so a cursor minted before that change verifies against different input and is refused as inauthentic. Clients recover by themselves — a refused cursor is a re-sync from zero, the same event a server key rotation is — but the recovery is one full feed read per client, so expect a burst of full-feed traffic on the first sync after the upgrade rather than the usual incremental pages. There is nothing for an operator to migrate: cursors are opaque, stateless and server-minted, and the version byte is deliberately unchanged so an old cursor fails as inauthentic rather than as malformed, which is the answer clients already handle. + **Sync feed validation.** Every entry in a `Sync` response carries a `protocol_version` (matching the album's pin) and a per-album monotonic `sync_seq` (a `u64`, strictly increasing per album). The client refuses to apply an entry whose `protocol_version` is above its max known (per the [tightened Postel's Law](/design/principles/#postels-law-asymmetric)) and refuses any page whose `sync_seq` regresses against what the client has already seen for that album — a regressing `sync_seq` indicates a malicious or buggy server attempting to rewind the client's view, and the client surfaces it rather than applying it. ## Stale-Revival Detection @@ -47,9 +50,9 @@ Because every blob is content-addressed, a fetch is skipped entirely when the bl **When an above-tier fetch cannot succeed.** A lazily-fetched representation may be temporarily or permanently unavailable. The client distinguishes the two: a **transient** failure (network drop, `5xx`) retries with backoff and resumes via `Range`; a **permanent** failure (`410 Gone`, a purged origin, or an unreachable [federated home server](/design/federation/#robustness-against-connectivity-loss)) **degrades gracefully** to the best representation already in hand. A **`403`** is neither: it signals an *authorization change*, not a durability loss — the client re-syncs its membership/capability state for the album before retrying, and only then degrades (the asset may have been unshared), so a revocation event is surfaced as such rather than masked as a missing file — preview → thumbnail → LQIP, down to the always-present LQIP — and surfaces a non-destructive "full resolution unavailable" state on the asset. It never thrashes the fetch, and it never removes the asset's metadata or local index entry over a missing derivative. The asset stays listed and re-fetches automatically once the representation becomes reachable again. -**What the home server can actually decide, as of `S-C39`.** `GET /v1/blob/{hash}` is **owner-scoped**: an account fetches the blobs of assets filed under it, and every other caller receives `404` — byte-identical to the answer for an address the server never heard of. That closes a real hole (previously any authenticated account could fetch any live ciphertext whose address it could name) and it fixes the `403`/`404` boundary at the only place that does not leak: a `403` confirms the address is referenced by *somebody*, so it is reserved for a caller the server can see once **had** access, and everyone else is told what an unknown address is told. +**What the home server decides, as of `S-C39` and `S-C51`.** `GET /v1/blob/{hash}` is **membership-scoped**: an account fetches the blobs of assets filed under it and of every album whose current roster names it, in either role; a **former** member — an account the roster once named and no longer does — receives the `403` above; and every other caller receives `404`, byte-identical to the answer for an address the server never heard of. `S-C39` closed a real hole (previously any authenticated account could fetch any live ciphertext whose address it could name) and fixed the `403`/`404` boundary at the only place that does not leak: a `403` confirms the address is referenced by *somebody*, so it is reserved for a caller the server can see once **had** access, and everyone else is told what an unknown address is told. -The `403` itself is therefore **not yet rendered on this path**, and the reason is a missing fact rather than missing code. Album sharing between accounts is an MLS group whose roster the server cannot read by design, and the surfaces that do let a non-owner reach ciphertext — `/s/{opaque_id}/blob/{hash}` and the drop paths — serve from their own capabilities and answer `404` when those are withdrawn. Server-side album membership is owed as `S-C51`, and it is the same fact that keeps album *write* access pinned to the owner. Until it lands, a client written against this contract sees the `403` arm only from surfaces that have a capability to revoke. +The fact behind the `403` is the album owner's **signed roster** (`PUT /v1/albums/{album_id}/roster`), verified against the owner's published device directory and stored as who is a member, since which roster version and AMK epoch, and at which version and epoch a member was removed. The server still cannot read the MLS group — the roster is the owner *telling* it, and it is a transport control over who is handed bytes, never a confidentiality control over who can read them: a former member who kept an epoch's key can still decrypt what they already fetched, which is why the client protocol pairs removal with an AMK epoch bump (the roster carries the new epoch; the server checks only that it does not regress). The surfaces that let a non-account reach ciphertext — `/s/{opaque_id}/blob/{hash}` and the drop paths — serve from their own capabilities and answer `404` when those are withdrawn; they do not route through membership. ## Resumption and Verification diff --git a/capsule-docs/src/content/docs/design/threat-model/validation.md b/capsule-docs/src/content/docs/design/threat-model/validation.md index 389c2e57..a62b55be 100644 --- a/capsule-docs/src/content/docs/design/threat-model/validation.md +++ b/capsule-docs/src/content/docs/design/threat-model/validation.md @@ -73,6 +73,10 @@ reintroducing the stale revival that 17 exists to catch, in the code enforcing i - **23.** A published `DeviceDirectory` has `directory_version` **strictly greater** than the version currently stored for that user, and the master signature covers it. A non-advancing or regressing publish is rejected (`409`). The signature is checked against the account's **identity anchor** — the identity public key its first published directory arrived with (in the required `X-Capsule-Identity-Key` header), immutable thereafter. A publish presenting a different key is rejected `403`: the document is well-formed and correctly signed, and signed by somebody else. Owner: [Cryptography — Device Directory](/design/cryptography/keys/#device-directory). - **24.** A federated **report** (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer's report rate budget; otherwise it is dropped before reaching the admin queue. Owner: [Moderation — Federated Reporting](/design/moderation/#federated-reporting). +### On `PUT /v1/albums/{album_id}/roster` (album roster publish) + +- **33.** The signed roster decodes as canonical CBOR, is at most 512 KiB, names the album in the path, lists no account twice and does not list the owner (otherwise `400` — every one of these is decidable from the request alone, so nothing store-held is disclosed); the caller is the album's owner (otherwise `404`, the album ceremonies' "not yours is not found"); its `attested_by_user` is the caller and its `attester_sig` verifies under a **non-revoked** device in the caller's published device directory (otherwise `403`); its `roster_version` is **strictly greater** than the version the server holds, or byte-identical to it (a replay), and its `amk_epoch` does not regress (otherwise `409` carrying `current_version`); and it is **at most sixteen above** the held version — an album with no roster reads as version `0` — because a version nothing could ever supersede would freeze the album's membership for good (otherwise `400 error.album.roster_version_leap`, carrying `current_version` and `max_version` so the owner re-signs the same roster one above what is held). The server stores the consequence — who is a member, with what role, since which version and epoch — and marks a member omitted from a later roster as revoked at that roster's version and epoch rather than deleting the row, so a former member's `403` on the blob route is a stored fact. Owner: [Authorization](/design/authorization/). + ### On any write whose bundle carries a metadata blob - **25.** The encrypted metadata blob in the bundle has a content hash equal to the manifest's `metadata_blob_hash`. The server holds no key, but it can compare the content address it stores against the value the signed manifest commits to, so a client cannot present the server a metadata blob different from the one its asset manifest is signed over. A mismatch is rejected (`400`) and no state is written. This applies on `POST /v1/upload` (the `create` bundle), at finalization, and on a non-upload `metadata-update`. Owner: [Metadata — Local and Server Metadata Equivalence](/design/metadata/#local-and-server-metadata-equivalence). @@ -163,6 +167,7 @@ Every write surface has a single idempotency key. Duplicates are no-ops; conflic | Share-link / upload-link creation | Client-supplied operation id (UUIDv7) | Retried create returns the already-minted link | | Share-link / upload-link revoke | `link_id` | Second revoke is a no-op | | Drop adoption (`POST /v1/drops/{drop_id}/adopt`) | `drop_id` — the atomic inbox→album promotion (invariant 32) | A retry after success finds the inbox row gone and returns the already-promoted asset | +| Album roster publish (`PUT /v1/albums/{album_id}/roster`) | `(album_id, roster_version)` (invariant 33) | Identical bytes: `200` with `replayed: true`, nothing written. Same version, different bytes: `409 error.album.roster_stale` | A write surface that does not appear here is, by default, **not** idempotent and must be designed before it ships. diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 318b9021..b1d6be1c 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1828,6 +1828,11 @@ "drop.upload_only_badge": "Upload only", "error.album.invalid_id": "This album could not be registered because its identifier is malformed.", "error.album.not_available": "This album isn't available on your account.", + "error.album.roster_attester": "Only a device on the album owner's account can publish its roster.", + "error.album.roster_malformed": "Capsule couldn't read that album roster.", + "error.album.roster_not_found": "That album isn't yours, or doesn't exist.", + "error.album.roster_stale": "The roster you sent is out of step with the one the server holds.", + "error.album.roster_version_leap": "That roster's version is too far ahead of the one the server holds.", "error.album.unavailable": "Capsule couldn't set up that album. Please try again.", "error.album.upgrade_in_flight": "This album is already being upgraded.", "error.album.upgrade_malformed": "Capsule couldn't read that album upgrade request.", @@ -1852,6 +1857,7 @@ "error.auth.totp_not_pending": "There is no two-factor setup waiting to be confirmed.", "error.auth.unavailable": "Capsule couldn't reach your account just now. Please try again.", "error.auth.user_already_exists": "An account with these details already exists.", + "error.blob.access_revoked": "You no longer have access to this album.", "error.blob.gone": "That photo file is no longer available.", "error.blob.not_found": "That photo file isn't on the server.", "error.blob.pending_upload": "The original photo hasn't been uploaded from its device yet.", @@ -1918,6 +1924,7 @@ "error.storage.deep_rate_limited": "Too many deep storage checks. Please wait and try again.", "error.storage.invalid_request": "The storage-verification request was malformed.", "error.storage.unavailable": "Capsule couldn't check whether your photos are safely stored. Please try again.", + "error.sync.album_access_denied": "You don't have access to that album.", "error.sync.cursor_invalid": "The sync session is out of date. Capsule will resync from the start.", "error.sync.unauthenticated": "Please sign in again to continue syncing.", "error.sync.unavailable": "Capsule could not reach the server to sync. It will try again.", diff --git a/capsule-i18n/src/generated.rs b/capsule-i18n/src/generated.rs index a71cd1ac..1352a76e 100644 --- a/capsule-i18n/src/generated.rs +++ b/capsule-i18n/src/generated.rs @@ -38,6 +38,21 @@ pub mod error_codes { /// `error.album.not_available` pub const ALBUM_NOT_AVAILABLE: &str = "error.album.not_available"; + /// `error.album.roster_attester` + pub const ALBUM_ROSTER_ATTESTER: &str = "error.album.roster_attester"; + + /// `error.album.roster_malformed` + pub const ALBUM_ROSTER_MALFORMED: &str = "error.album.roster_malformed"; + + /// `error.album.roster_not_found` + pub const ALBUM_ROSTER_NOT_FOUND: &str = "error.album.roster_not_found"; + + /// `error.album.roster_stale` + pub const ALBUM_ROSTER_STALE: &str = "error.album.roster_stale"; + + /// `error.album.roster_version_leap` + pub const ALBUM_ROSTER_VERSION_LEAP: &str = "error.album.roster_version_leap"; + /// `error.album.unavailable` pub const ALBUM_UNAVAILABLE: &str = "error.album.unavailable"; @@ -110,6 +125,9 @@ pub mod error_codes { /// `error.auth.user_already_exists` pub const AUTH_USER_ALREADY_EXISTS: &str = "error.auth.user_already_exists"; + /// `error.blob.access_revoked` + pub const BLOB_ACCESS_REVOKED: &str = "error.blob.access_revoked"; + /// `error.blob.gone` pub const BLOB_GONE: &str = "error.blob.gone"; @@ -308,6 +326,9 @@ pub mod error_codes { /// `error.storage.unavailable` pub const STORAGE_UNAVAILABLE: &str = "error.storage.unavailable"; + /// `error.sync.album_access_denied` + pub const SYNC_ALBUM_ACCESS_DENIED: &str = "error.sync.album_access_denied"; + /// `error.sync.cursor_invalid` pub const SYNC_CURSOR_INVALID: &str = "error.sync.cursor_invalid"; diff --git a/capsule-sdk/src/albums.rs b/capsule-sdk/src/albums.rs index 5e969203..f5d6322c 100644 --- a/capsule-sdk/src/albums.rs +++ b/capsule-sdk/src/albums.rs @@ -27,22 +27,27 @@ pub use crate::upload::StaticToken; // ─── Errors ─────────────────────────────────────────────────────────────────── -/// A failure provisioning an album. +/// A failure on the album surface: provisioning, or publishing a roster (`S-C51`). #[derive(Debug, thiserror::Error)] pub enum AlbumError { /// The HTTP request failed on the wire, or the session could not authorize it. - #[error("album provisioning transport: {0}")] + #[error("album request transport: {0}")] Transport(String), - /// The server refused the provisioning request. - #[error("album provisioning refused with status {status}")] + /// The server refused the request. + #[error("album request refused with status {status}")] Status { /// The HTTP status code. status: u16, /// The stable `error.*` code, when the server supplied one. code: Option, + /// On either roster-version refusal — the `409 error.album.roster_stale` that is behind + /// the server, and the `400 error.album.roster_version_leap` that is too far ahead of it + /// — the version the server holds, which is the one a caller re-signs above. Absent on + /// every other refusal. + current_version: Option, }, /// The response body was missing a field or otherwise unparsable. - #[error("malformed album provisioning response: {0}")] + #[error("malformed album response: {0}")] Malformed(String), } @@ -99,6 +104,9 @@ impl AlbumTransport { /// Build a transport over a fixed bearer token (tests; callers holding a live token). /// Same URL layout as [`Self::with_session`]. + /// + /// `http` **must** come from [`crate::net::http_builder`] or [`crate::net::http_client`]: a + /// client built any other way sends no protocol handshake, and every gated route refuses it. pub fn with_static_token( http: reqwest::Client, base_url: impl Into, @@ -126,8 +134,59 @@ impl AlbumTransport { .map_err(|e| AlbumError::Transport(e.to_string())), } } + + /// A `spargen`-generated client for the API root this transport's album endpoint hangs off, + /// carrying the same credential. + /// + /// The roster publish goes through this rather than through [`Self::send`]: everything that + /// parses or serializes in this repository is generated, and `publish_album_roster` is a + /// JSON operation the document fully describes — request body, success body, and the + /// `409`/`400` problem shapes with their extension members. Only `provision` still hand-writes + /// its DTOs, and only because `POST /v1/albums` predates this seam. + /// + /// The root is derived by trimming the endpoint's `/v1/albums` suffix, because the generated + /// operations carry their own absolute paths while this transport is constructed with the + /// album endpoint (`{origin}/v1/albums`) that `POST {base}` provisions against. + fn generated(&self) -> Result { + let root = self + .base_url + .strip_suffix("/v1/albums") + .unwrap_or(&self.base_url); + let (http, credential) = match &self.auth { + AlbumAuth::Session(session) => { + let session = session.clone(); + // The session's own pre-flight refresh and single-flight coalescing, consulted + // per request; the reactive `401` replay is the caller's, below. + let provider: crate::rest::TokenProvider = std::sync::Arc::new(move || { + let session = session.clone(); + Box::pin(async move { + session + .bearer() + .await + .map_err(|error| crate::rest::AuthError::new(error.to_string())) + }) + }); + ( + crate::net::http_client() + .map_err(|error| AlbumError::Transport(error.to_string()))?, + crate::rest::Credential::Provider(provider), + ) + } + AlbumAuth::Static { http, token } => ( + http.clone(), + crate::rest::Credential::Bearer(token.clone().into()), + ), + }; + Ok(crate::rest::Client::with_client(http, root) + .map_err(|error| AlbumError::Transport(error.to_string()))? + .with_credential(BEARER_SCHEME, credential)) + } } +/// The security-scheme key the document declares for the bearer JWT; the generated client +/// attaches the registered credential to every operation whose `security` names it. +const BEARER_SCHEME: &str = "bearer"; + // ─── Wire DTOs (mirror the server's transport JSON) ─────────────────────────── /// The `POST /v1/albums` request body. One field, deliberately: the server's body is strict, @@ -143,6 +202,8 @@ struct ProvisionAlbumResponseWire { created: bool, } +/// The one field `provision` reads off a refusal. The roster publish reads its problems through +/// the generated client's typed error instead, which is why nothing here describes extensions. #[derive(Deserialize)] struct ApiErrorWire { #[serde(default)] @@ -159,6 +220,24 @@ pub struct ProvisionedAlbum { pub created: bool, } +/// What the server holds for an album after a roster publish (`S-C51`). +/// +/// `replayed` is informational: the same bytes again are a success that wrote nothing, exactly +/// as re-provisioning is, so a client that lost an acknowledgement re-PUTs without branching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublishedRoster { + /// The album, echoed. + pub album_id: Uuid, + /// The roster version the server holds after this call. + pub roster_version: u64, + /// The AMK epoch that roster reflects. + pub amk_epoch: u64, + /// How many members it names, the owner excluded. + pub member_count: u64, + /// Whether this call replayed the roster already held. + pub replayed: bool, +} + // ─── Client ─────────────────────────────────────────────────────────────────── /// The album-provisioning client. @@ -204,6 +283,7 @@ impl AlbumClient { return Err(AlbumError::Status { status: status.as_u16(), code, + current_version: None, }); } @@ -224,6 +304,165 @@ impl AlbumClient { created: wire.created, }) } + + /// Publish `signed` as the roster of the album it names (`S-C51`). + /// + /// Orchestration only, and deliberately thin: the roster is signed in + /// `capsule_core::crypto::membership` by one of the owner's devices, base64-encoded, and + /// handed to the **generated** `publish_album_roster` operation, so every byte that is + /// parsed or serialized on this path comes from the committed OpenAPI document. Idempotent + /// under `(album_id, roster_version)`: the same bytes again succeed with `replayed`. + /// + /// Two refusals a caller acts on: a `409` (`error.album.roster_stale`) means the server holds + /// a roster this one does not supersede, and a `400 error.album.roster_version_leap` means + /// the version is too far *ahead* of the held one. Both carry + /// [`current_version`](AlbumError::Status), and the repair for both is the same — re-sign the + /// roster one above it. + /// + /// Under a session, a `401` is refreshed once and replayed, exactly as the sync feed does: + /// the credential provider's pre-flight refresh cannot cover a token revoked mid-flight. + /// + /// # Errors + /// + /// [`AlbumError::Transport`] when the request did not complete, [`AlbumError::Status`] with + /// the server's `error.*` code when it was refused, [`AlbumError::Malformed`] when the roster + /// could not be encoded or the response could not be read. + #[instrument(skip(self, signed), fields(album_id = %signed.roster.album_id, roster_version = signed.roster.roster_version))] + pub async fn publish_roster( + &self, + signed: &capsule_core::crypto::membership::SignedAlbumRoster, + ) -> Result { + use base64::Engine as _; + + let album_id = signed.roster.album_id; + let bytes = capsule_core::cbor::to_canonical_vec(signed) + .map_err(|e| AlbumError::Malformed(format!("roster encoding: {e}")))?; + let body = crate::rest::types::RosterRequest { + roster_cbor: base64::engine::general_purpose::STANDARD.encode(bytes), + }; + + let client = self.transport.generated()?; + let wire = match publish(&client, album_id, &body).await { + Ok(wire) => wire, + Err(error) if is_unauthenticated(&error) => match &self.transport.auth { + AlbumAuth::Session(session) => { + tracing::info!("the roster publish answered 401; refreshing once and retrying"); + session.refresh().await?; + publish(&client, album_id, &body) + .await + .map_err(publish_refusal)? + } + // A fixed token cannot be refreshed, so retrying would ask the same question + // twice. + AlbumAuth::Static { .. } => return Err(publish_refusal(error)), + }, + Err(error) => return Err(publish_refusal(error)), + }; + + let echoed = Uuid::parse_str(&wire.album_id) + .map_err(|e| AlbumError::Malformed(format!("response album_id: {e}")))?; + if echoed != album_id { + return Err(AlbumError::Malformed(format!( + "server echoed album {echoed}, not the requested {album_id}" + ))); + } + tracing::info!( + roster_version = wire.roster_version, + replayed = wire.replayed, + "album roster published" + ); + Ok(PublishedRoster { + album_id: echoed, + roster_version: counter(wire.roster_version, "roster_version")?, + amk_epoch: counter(wire.amk_epoch, "amk_epoch")?, + member_count: counter(wire.member_count, "member_count")?, + replayed: wire.replayed, + }) + } +} + +/// One call of the generated roster operation. +/// +/// The protocol date is a required parameter of every gated operation in the document, so the +/// generated signature asks for it; the value is this build's own, the same one the transport +/// sends as a default header. +async fn publish( + client: &crate::rest::Client, + album_id: Uuid, + body: &crate::rest::types::RosterRequest, +) -> Result< + crate::rest::types::RosterResponse, + crate::rest::Error, +> { + Ok(client + .publish_album_roster( + album_id.hyphenated().to_string(), + capsule_core::crypto::primitives::PROTOCOL_VERSION, + None, + body, + ) + .await? + .into_inner()) +} + +/// Whether the refusal was the credential's. +fn is_unauthenticated(error: &crate::rest::Error) -> bool { + matches!( + error, + crate::rest::Error::Api(response) + if matches!( + response.inner(), + crate::rest::PublishAlbumRosterError::Status401(_) + ) + ) +} + +/// Map the generated operation's typed error onto this module's. +/// +/// The `code` is what a caller switches on, and `current_version` is what the two version +/// refusals — the `409` that is behind and the `400` that is too far ahead — both carry so the +/// caller can re-sign one above what the server holds. +fn publish_refusal(error: crate::rest::Error) -> AlbumError { + use crate::rest::PublishAlbumRosterError as Refusal; + + let crate::rest::Error::Api(response) = error else { + return AlbumError::Transport(error.to_string()); + }; + let status = response.status().as_u16(); + let (code, current_version) = match response.into_inner() { + Refusal::Status400(problem) => ( + Some(problem.code.clone()), + problem + .current_version + .and_then(|held| u64::try_from(held).ok()), + ), + Refusal::Status409(problem) => ( + Some(problem.code.clone()), + problem + .current_version + .and_then(|held| u64::try_from(held).ok()), + ), + // The body-less refusal: a request past the transport's size backstop. + Refusal::Status413 => (None, None), + Refusal::Status401(problem) + | Refusal::Status403(problem) + | Refusal::Status404(problem) + | Refusal::Status415(problem) + | Refusal::Status422(problem) + | Refusal::Status426(problem) + | Refusal::Status500(problem) => (Some(problem.code.clone()), None), + }; + tracing::warn!(status, ?code, ?current_version, "roster publish refused"); + AlbumError::Status { + status, + code, + current_version, + } +} + +/// A counter the document types as a signed integer, as the SDK speaks it. +fn counter(value: i64, field: &str) -> Result { + u64::try_from(value).map_err(|_| AlbumError::Malformed(format!("{field}: {value} is negative"))) } #[cfg(test)] diff --git a/capsule-sdk/src/albums/tests.rs b/capsule-sdk/src/albums/tests.rs index 16bb318b..021a1c3b 100644 --- a/capsule-sdk/src/albums/tests.rs +++ b/capsule-sdk/src/albums/tests.rs @@ -19,6 +19,13 @@ //! | `a_malformed_id_carries_the_invalid_id_code` | the 400 path | //! | `an_echoed_mismatch_is_malformed` | the server cannot silently rebind another album | //! | `the_request_is_authorized` | the bearer rides every call | +//! | `publish_roster_sends_the_signed_bytes_verbatim` | the roster on the wire is the one the device signed (`S-C51`) | +//! | `a_published_roster_reports_what_the_server_holds` | the success mapping, replay included | +//! | `a_stale_roster_carries_the_distinct_code` | the `409` is switchable by code | +//! | `a_roster_echo_mismatch_is_malformed` | the server cannot silently answer for another album | +//! | `a_version_leap_carries_the_held_version_too` | the `400` too-far-ahead refusal is switchable and carries `current_version` | +//! | `the_widest_version_the_server_can_name_still_decodes` | the recovery hint survives at the top of the server's range, where an unbounded counter would not | +//! | `the_roster_publish_goes_through_the_generated_operation` | the path, method and body come from the committed contract, not from a hand-written request | use std::sync::{Arc, Mutex}; @@ -236,3 +243,261 @@ async fn the_request_is_authorized() { "every provisioning call rides the caller's bearer" ); } + +// ─── Roster publish (`S-C51`) ───────────────────────────────────────────────── + +/// A roster for [`album`] at `version`, signed by a fresh device key. +fn signed_roster(version: u64) -> capsule_core::crypto::membership::SignedAlbumRoster { + use capsule_core::crypto::keys::{AmkVersion, HybridSigningKey}; + use capsule_core::crypto::membership::{AlbumRoster, MemberRole, RosterMember}; + + let roster = AlbumRoster { + album_id: album(), + roster_version: version, + amk_epoch: AmkVersion(2), + attested_by_user: Uuid::from_u128(0xA11CE), + attested_by_device: Uuid::from_u128(0xD1), + attested_at: "2026-09-02T00:00:00Z".to_owned(), + members: vec![RosterMember { + user_id: Uuid::from_u128(0xB0B), + role: MemberRole::Writer, + }], + }; + capsule_core::crypto::membership::SignedAlbumRoster::sign( + roster, + &HybridSigningKey::from_seed_bytes(&[7; 32], &[8; 32]), + ) + .expect("a roster signs") +} + +/// The canonical success body the server sends for a roster publish. +fn held(id: Uuid, version: u64, replayed: bool) -> MockResponse { + MockResponse::new(200, "OK").json_body(format!( + r#"{{"album_id":"{id}","roster_version":{version},"amk_epoch":2,"member_count":1,"replayed":{replayed}}}"# + )) +} + +/// **The bytes on the wire are the bytes the device signed.** The client base64-encodes the +/// canonical CBOR and changes nothing: a re-serialization would be a roster whose signature no +/// longer verifies, and the server decides a replay on these exact bytes. +#[tokio::test] +async fn publish_roster_sends_the_signed_bytes_verbatim() { + use base64::Engine as _; + + let id = album(); + let signed = signed_roster(3); + let (server, seen) = recording(move |_| held(id, 3, false)).await; + // At the production layout — `{origin}/v1/albums` — so the path is the server's own. + let client = AlbumClient::new(AlbumTransport::with_static_token( + reqwest::Client::new(), + format!("{}/v1/albums", server.base_url().trim_end_matches('/')), + StaticToken("test-token".into()), + )); + client.publish_roster(&signed).await.expect("publish"); + + let requests = seen.lock().expect("recorded requests"); + assert_eq!(requests[0].method, "PUT"); + assert_eq!( + requests[0].path, + format!("/v1/albums/{}/roster", id.hyphenated()) + ); + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).expect("JSON"); + let object = body.as_object().expect("a JSON object"); + assert_eq!(object.keys().collect::>(), vec!["roster_cbor"]); + let bytes = base64::engine::general_purpose::STANDARD + .decode(body["roster_cbor"].as_str().expect("a string")) + .expect("standard base64"); + let expected = capsule_core::cbor::to_canonical_vec(&signed).expect("encodes"); + assert_eq!( + bytes, expected, + "the wire carries the canonical encoding of exactly what was signed" + ); + assert_eq!( + capsule_core::cbor::canonicalize(&bytes).expect("decodes"), + bytes, + "and those bytes are canonical, which is the form the server stores and replays on" + ); + assert_eq!( + body["roster_cbor"].as_str().expect("a string"), + base64::engine::general_purpose::STANDARD.encode(&expected), + "standard base64 with padding, the alphabet the server decodes" + ); + assert!( + requests[0].header("authorization").is_some(), + "the bearer rides the roster publish too" + ); +} + +#[tokio::test] +async fn a_published_roster_reports_what_the_server_holds() { + let id = album(); + let (server, _) = recording(move |_| held(id, 3, true)).await; + let result = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect("publish"); + assert_eq!( + result, + PublishedRoster { + album_id: id, + roster_version: 3, + amk_epoch: 2, + member_count: 1, + replayed: true, + } + ); +} + +/// The `409` is the one refusal a client acts on differently — re-sync and republish above the +/// version the server names — so its code must come through. +#[tokio::test] +async fn a_stale_roster_carries_the_distinct_code() { + let (server, _) = recording(|_| { + MockResponse::new(409, "Conflict").json_body( + r#"{"type":"about:blank","title":"Roster stale","status":409,"detail":"the server holds roster version 4, which this does not supersede","code":"error.album.roster_stale","current_version":4}"#.to_owned(), + ) + }) + .await; + let error = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect_err("a stale roster is refused"); + assert_eq!(error.error_code(), Some(error_codes::ALBUM_ROSTER_STALE)); + assert!( + matches!( + error, + AlbumError::Status { + status: 409, + current_version: Some(4), + .. + } + ), + "the held version rides the refusal, so the caller can republish above it: {error:?}" + ); +} + +/// A server answering for a different album than the one asked about is a malformed answer, +/// not a success with the wrong id in it. +#[tokio::test] +async fn a_roster_echo_mismatch_is_malformed() { + let other = Uuid::parse_str("0198f3c2-9c4a-7b3d-8f21-4d7c9a1b2eff").expect("a uuid"); + let (server, _) = recording(move |_| held(other, 3, false)).await; + let error = client_for(&server) + .publish_roster(&signed_roster(3)) + .await + .expect_err("a mismatched echo is refused"); + assert!(matches!(error, AlbumError::Malformed(_)), "{error:?}"); +} + +/// The other version refusal: a roster so far *ahead* of the held one that the server would be +/// latched if it took it. A `400` rather than the `409`, and it carries the held version for the +/// same reason — the caller re-signs one above it. +#[tokio::test] +async fn a_version_leap_carries_the_held_version_too() { + let (server, _) = recording(|_| { + MockResponse::new(400, "Bad Request").json_body( + r#"{"type":"about:blank","title":"Roster version leap","status":400,"detail":"roster version 9999 is past 17","code":"error.album.roster_version_leap","declared":9999,"current_version":1,"max_version":17}"#.to_owned(), + ) + }) + .await; + let error = client_for(&server) + .publish_roster(&signed_roster(9999)) + .await + .expect_err("a leap is refused"); + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_ROSTER_VERSION_LEAP) + ); + assert!( + matches!( + error, + AlbumError::Status { + status: 400, + current_version: Some(1), + .. + } + ), + "the held version rides this refusal too: {error:?}" + ); +} + +/// **The wire shape is the contract's, not this module's.** `publish_roster` is orchestration +/// over the generated `publish_album_roster` operation, so the method, the path and the required +/// protocol header come from `capsule-server/openapi.json` rather than from a hand-written +/// request this module could drift. +#[tokio::test] +async fn the_roster_publish_goes_through_the_generated_operation() { + let id = album(); + let (server, seen) = recording(move |_| held(id, 3, false)).await; + let client = AlbumClient::new(AlbumTransport::with_static_token( + reqwest::Client::new(), + format!("{}/v1/albums", server.base_url().trim_end_matches('/')), + StaticToken("test-token".into()), + )); + client + .publish_roster(&signed_roster(3)) + .await + .expect("publish"); + + let requests = seen.lock().expect("recorded requests"); + assert_eq!(requests[0].method, "PUT"); + assert_eq!( + requests[0].path, + format!("/v1/albums/{}/roster", id.hyphenated()) + ); + assert_eq!( + requests[0].header("x-capsule-protocol"), + Some(capsule_core::crypto::primitives::PROTOCOL_VERSION), + "the generated operation carries the protocol date the document declares required" + ); + assert_eq!( + requests[0].header("authorization"), + Some("Bearer test-token") + ); +} + +/// **The refusal has to survive at the top of the server's range.** +/// +/// spargen lowers every integer in the contract as `i64` and emits no `u64` at all, so a +/// counter above `i64::MAX` would not be an API error at all — the generated client would fail +/// to *decode* the body, the typed error would never be built, and `code` and `current_version` +/// would be replaced by an undifferentiated transport failure. The server bounds every counter +/// it can emit at `MAX_ROSTER_VERSION` (`i64::MAX`) precisely so this holds; the case pins the +/// boundary rather than a comfortable value in the middle of the range, and the declared +/// version — the one number a caller controls and the server therefore cannot bound — is not an +/// extension member at all. +#[tokio::test] +async fn the_widest_version_the_server_can_name_still_decodes() { + let ceiling = i64::MAX as u64; + let body = format!( + r#"{{"type":"about:blank","title":"Roster version leap","status":400,"detail":"roster version 18446744073709551615 is past {ceiling}, the highest this album will accept while it holds version {ceiling}","code":"error.album.roster_version_leap","current_version":{ceiling},"max_version":{ceiling}}}"# + ); + let (server, _) = + recording(move |_| MockResponse::new(400, "Bad Request").json_body(body.clone())).await; + + let error = client_for(&server) + .publish_roster(&signed_roster(u64::MAX)) + .await + .expect_err("a leap is refused"); + + assert_eq!( + error.error_code(), + Some(error_codes::ALBUM_ROSTER_VERSION_LEAP), + "the structured code survives at the boundary: {error:?}" + ); + assert!( + matches!( + error, + AlbumError::Status { + status: 400, + current_version: Some(held), + .. + } if held == ceiling + ), + "and so does the recovery hint: {error:?}" + ); + assert!( + !matches!(error, AlbumError::Transport(_)), + "a decode failure would have collapsed this into a transport error" + ); +} diff --git a/capsule-sdk/src/auth.rs b/capsule-sdk/src/auth.rs index 6c7db931..15b588bd 100644 --- a/capsule-sdk/src/auth.rs +++ b/capsule-sdk/src/auth.rs @@ -391,9 +391,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,6 +405,9 @@ 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, 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 fffa4840..c94a8ace 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -125,10 +125,19 @@ VALKEY_URL=redis://127.0.0.1:6379 # ── 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/migration/src/lib.rs b/capsule-server/migration/src/lib.rs index 24e0c4be..b14e9e03 100644 --- a/capsule-server/migration/src/lib.rs +++ b/capsule-server/migration/src/lib.rs @@ -1,9 +1,10 @@ //! The server's PostgreSQL schema, one migration per ordinal. //! -//! # What the four ordinals cover +//! # What the five ordinals cover //! -//! The durable ports issue #402 lands adapters for, and no others: the asset index, the account -//! cluster, the device-cohort map and the quota ledger. The remaining durable ports keep their +//! The four durable ports issue #402 lands adapters for — the asset index, the account +//! cluster, the device-cohort map and the quota ledger — plus album membership (`S-C51`, #405). +//! The remaining durable ports keep their //! in-memory adapters and gain ordinals with their adapters, so a migration never describes a //! table nothing reads. //! @@ -23,6 +24,7 @@ mod m20260902_000001_asset_index; mod m20260902_000002_accounts; mod m20260902_000003_cohorts; mod m20260902_000004_quota; +mod m20260902_000005_album_membership; /// The server's migrator. pub struct Migrator; @@ -35,6 +37,7 @@ impl MigratorTrait for Migrator { Box::new(m20260902_000002_accounts::Migration), Box::new(m20260902_000003_cohorts::Migration), Box::new(m20260902_000004_quota::Migration), + Box::new(m20260902_000005_album_membership::Migration), ] } } diff --git a/capsule-server/migration/src/m20260902_000005_album_membership.rs b/capsule-server/migration/src/m20260902_000005_album_membership.rs new file mode 100644 index 00000000..e0e7fd34 --- /dev/null +++ b/capsule-server/migration/src/m20260902_000005_album_membership.rs @@ -0,0 +1,138 @@ +//! Album membership (`S-C51`): the roster the owner attested, and who it makes a member. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub(crate) struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // One row per album: the roster the server currently accepts, with the signed document + // verbatim. A replay is decided on the document's bytes, and an operator can re-verify + // what was accepted against the owner's directory of the day. + manager + .create_table( + Table::create() + .table(AlbumRosters::Table) + .if_not_exists() + .col( + ColumnDef::new(AlbumRosters::AlbumId) + .text() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(AlbumRosters::RosterVersion) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::AmkEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::AttestedByDevice) + .text() + .not_null(), + ) + .col( + ColumnDef::new(AlbumRosters::ReceivedAt) + .big_integer() + .not_null(), + ) + .col(ColumnDef::new(AlbumRosters::Document).binary().not_null()) + .to_owned(), + ) + .await?; + + // One row per account that has ever been on one of the album's rosters. A member the + // owner removes keeps their row with the two `revoked_*` columns set: the blob route's + // `403` is reserved for a caller the server can see once *had* access, and a deleted row + // would make a former member indistinguishable from a stranger. + manager + .create_table( + Table::create() + .table(AlbumMembers::Table) + .if_not_exists() + .col(ColumnDef::new(AlbumMembers::AlbumId).text().not_null()) + .col(ColumnDef::new(AlbumMembers::UserId).text().not_null()) + .col(ColumnDef::new(AlbumMembers::Role).text().not_null()) + .col( + ColumnDef::new(AlbumMembers::SinceVersion) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumMembers::GrantedEpoch) + .big_integer() + .not_null(), + ) + .col( + ColumnDef::new(AlbumMembers::RevokedAtVersion) + .big_integer() + .null(), + ) + .col( + ColumnDef::new(AlbumMembers::RevokedEpoch) + .big_integer() + .null(), + ) + .primary_key( + Index::create() + .col(AlbumMembers::AlbumId) + .col(AlbumMembers::UserId), + ) + .to_owned(), + ) + .await?; + // "Which albums is this account on" — the sync side's question, and the one the + // primary key does not answer. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_album_members_user") + .table(AlbumMembers::Table) + .col(AlbumMembers::UserId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AlbumMembers::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(AlbumRosters::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum AlbumRosters { + Table, + AlbumId, + RosterVersion, + AmkEpoch, + AttestedByDevice, + ReceivedAt, + Document, +} + +#[derive(DeriveIden)] +enum AlbumMembers { + Table, + AlbumId, + UserId, + Role, + SinceVersion, + GrantedEpoch, + RevokedAtVersion, + RevokedEpoch, +} diff --git a/capsule-server/openapi.json b/capsule-server/openapi.json index 3e80f9f5..85b3d426 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,79 +6503,49 @@ } } } - }, - "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/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": "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": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "required": true - }, "responses": { "401": { "description": "Unauthorized", @@ -3535,6 +6557,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": { @@ -3547,6 +6593,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": { @@ -3555,18 +6627,70 @@ } } }, - "400": { - "description": "Bad Request", + "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/EnrollmentCodeResponse" } } } }, - "415": { - "description": "Unsupported media type", + "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": { @@ -3575,32 +6699,63 @@ } } }, - "204": { - "description": "the request succeeded and there is no content to send", + "413": { + "description": "the request body exceeds the configured limit", "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": { + "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": { @@ -3609,28 +6764,34 @@ } } }, - "409": { - "description": "Offset mismatch", - "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/OffsetMismatchProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "413": { - "description": "Chunk 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": { @@ -3647,58 +6808,84 @@ ] } }, - "/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", + "/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", "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.", + "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": [ - "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/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": { @@ -3707,63 +6894,32 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionsResponse" + "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}$" } - } - } - }, - "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}$" } - } - } - }, - "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", - "headers": { - "WWW-Authenticate": { - "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, "schema": { - "type": "string" - }, - "example": "Bearer" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -3774,18 +6930,34 @@ } } }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { + "422": { + "description": "Unprocessable Entity", + "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": { @@ -3796,93 +6968,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}$" } - } - } - }, - "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": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ChannelResponse" } } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "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" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OpRequest" - } - } - }, - "required": true - }, - "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": { @@ -3893,38 +7038,34 @@ } } }, - "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}$" } - } - } - }, - "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": { @@ -3933,38 +7074,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/OpResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "426": { - "description": "Upgrade required", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/ProtocolRangeProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "409": { - "description": "Stale revival", - "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/StaleRevivalProblem" + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -3974,103 +7111,62 @@ } }, "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", + "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]+$" } } } }, - "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}$" } - } - } - }, - "200": { - "description": "OK", - "content": { - "application/json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest 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-Min-Client-Build": { + "description": "The semver client build below which this server will 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": { @@ -4078,101 +7174,96 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] } - ] + } } }, - "/v1/blob/{hash}": { + "/v1/auth/devices/enroll/channel/{channel_id}": { "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", + "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": "hash", + "name": "channel_id", "in": "path", - "description": "The blob's ciphertext content address, lowercase hex.", + "description": "The handle a redeemed code returned.", "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.", + "name": "direction", + "in": "query", + "description": "`to_initiator` or `to_enrollee`.", + "required": true, "schema": { - "type": "string", - "pattern": "^bytes=(?:\\d+-\\d*|-\\d+)(?:\\s*,\\s*(?:\\d+-\\d*|-\\d+)){0,7}$" - }, - "example": "bytes=0-1023" + "type": "string" + } }, { - "name": "If-Range", + "name": "X-Capsule-Protocol", "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.", + "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": "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 } } ], "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": { @@ -4182,90 +7273,105 @@ } }, "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/DrainResponse" } } } }, - "206": { - "description": "the part the request asked for", + "404": { + "description": "Channel not found", "headers": { - "Accept-Ranges": { - "schema": { - "type": "string" - } - }, - "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}$" } }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "500": { + "description": "Internal server error", "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": { "schema": { @@ -4274,95 +7380,123 @@ } } }, - "409": { - "description": "Upload in progress", - "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}$" } - } - } - }, - "410": { - "description": "Gone", - "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" } - }, - "security": [ + } + }, + "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": [ { - "bearer": [] + "name": "channel_id", + "in": "path", + "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 + } } - ] - } - }, - "/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", + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StorageVerifyRequest" + "$ref": "#/components/schemas/RelayRequest" } } }, "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 +7507,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 +7543,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": { @@ -4391,63 +7577,61 @@ } } }, - "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/StorageVerifyResponse" + "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" - } - }, - "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": "Channel not found", "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,18 +7642,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": { @@ -4478,28 +7678,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/AssetReceiptsResponse" + "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", + "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": { @@ -4507,32 +7742,56 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } - }, - "security": [ + } + }, + "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": [ { - "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" - } + "name": "channel_id", + "in": "path", + "description": "The handle a redeemed code returned.", + "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", @@ -4544,6 +7803,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": { @@ -4556,6 +7839,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": { @@ -4566,6 +7875,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": { @@ -4574,8 +7909,63 @@ } } }, - "415": { - "description": "Unsupported Media Type", + "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": "Channel 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": { @@ -4584,8 +7974,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", + "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": { @@ -4594,18 +8010,63 @@ } } }, - "201": { - "description": "The share link is registered and servable", - "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/IssueShareResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will 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": { @@ -4613,9 +8074,6 @@ } } } - }, - "413": { - "description": "the request body exceeds the configured limit" } }, "security": [ @@ -4625,22 +8083,55 @@ ] } }, - "/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/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.", + "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/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionAlbumRequest" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -4652,6 +8143,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": { @@ -4664,29 +8179,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -4695,35 +8213,34 @@ } } }, - "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", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -4732,18 +8249,34 @@ } } }, - "200": { - "description": "OK", - "content": { - "application/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/SharedMetadataResponse" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will 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": { @@ -4752,18 +8285,34 @@ } } }, - "429": { - "description": "Too many requests", - "content": { - "application/problem+json": { + "422": { + "description": "Unprocessable Entity", + "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": { @@ -4772,148 +8321,272 @@ } } }, - "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", + "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", - "content": { - "application/octet-stream": { + "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", - "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]+$" } } - } - }, - "429": { - "description": "Too many requests", + }, "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/ProvisionAlbumResponse" } } } }, "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" - } - } - } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff 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": [] + } + ] + } }, - "/s/{opaque_id}/blob/{hash}": { + "/v1/albums/{album_id}/upgrade": { "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", + "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": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "hash", + "name": "album_id", "in": "path", - "description": "The blob's content address.", + "description": "The album's id.", "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 } } ], "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": { @@ -4922,101 +8595,142 @@ } } }, - "200": { - "description": "the whole representation", + "403": { + "description": "Forbidden", "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", + "400": { + "description": "Bad Request", "headers": { - "Accept-Ranges": { - "schema": { - "type": "string" - } - }, - "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}$" } }, - "Content-Range": { - "description": "The part of the representation enclosed, and its complete length, per RFC 9110 section 14.4.", + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", "required": true, - "content": { - "text/plain": { - "schema": { - "type": "string", - "pattern": "^bytes \\d+-\\d+/\\d+$" - } - } + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } }, "content": { - "application/octet-stream": { + "application/problem+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/CodedProblem" } } } }, - "304": { - "description": "the client's copy is current", + "200": { + "description": "OK", "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]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpgradePhaseResponse" } } } }, "404": { "description": "Not found", - "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]+$" } } - } - }, - "429": { - "description": "Too many requests", + }, "content": { "application/problem+json": { "schema": { @@ -5027,77 +8741,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" - } - } - } - }, - "/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" - } - }, - "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-Min-Client-Build": { + "description": "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": { "schema": { @@ -5106,38 +8775,34 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "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}$" } - } - } - }, - "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}$" } - } - } - }, - "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" } }, "security": [ @@ -5145,24 +8810,65 @@ "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", + }, + "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": "opaque_id", + "name": "album_id", "in": "path", - "description": "The opaque id.", + "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", @@ -5174,6 +8880,30 @@ "type": "string" }, "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } } }, "content": { @@ -5186,29 +8916,32 @@ }, "403": { "description": "Forbidden", - "content": { - "application/problem+json": { + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "204": { - "description": "the request succeeded and there is no content to send" - }, - "500": { - "description": "Internal server error", + }, "content": { "application/problem+json": { "schema": { @@ -5217,46 +8950,34 @@ } } }, - "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - }, - "/d/{opaque_id}": { - "post": { - "summary": "Open a drop session through a link.", - "description": "Invariants 26–30 in order: the link admits the file and reserves its caps in one store\noperation, then the owner's quota is charged, then the declaration is checked.", - "operationId": "create_drop", - "parameters": [ - { - "name": "opaque_id", - "in": "path", - "description": "The opaque id.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateDropRequest" - } - } - }, - "required": true - }, - "responses": { "400": { "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5266,7 +8987,33 @@ } }, "415": { - "description": "Unsupported Media Type", + "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": { @@ -5275,28 +9022,70 @@ } } }, - "422": { - "description": "Unprocessable Entity", - "content": { - "application/problem+json": { + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, "schema": { - "$ref": "#/components/schemas/CodedProblem" + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" } } - } - }, - "201": { - "description": "A drop session is open and accepting chunks", + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateDropResponse" + "$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": { @@ -5305,8 +9094,34 @@ } } }, - "403": { - "description": "Passphrase required", + "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": { @@ -5315,8 +9130,34 @@ } } }, - "409": { - "description": "Link capacity exhausted", + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5326,27 +9167,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 +9231,106 @@ } } } - } - } - }, - "/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", + }, + "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": "opaque_id", + "name": "album_id", "in": "path", - "description": "The opaque id of the link the session belongs to.", + "description": "The album's id.", "required": true, "schema": { "type": "string" } }, { - "name": "upload_id", - "in": "path", - "description": "The session id.", + "name": "intent_id", + "in": "query", + "description": "The ceremony to abort.", "required": true, "schema": { "type": "string" } }, { - "name": "X-Capsule-Offset", + "name": "X-Capsule-Protocol", "in": "header", - "description": "Where in the blob this chunk starts.", + "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-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": { - "schema": { - "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}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will 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,32 +9339,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}$" } - } - } - }, - "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]+$" } } - } - }, - "409": { - "description": "Chunk refused", + }, "content": { "application/problem+json": { "schema": { @@ -5473,49 +9375,106 @@ } } }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { + "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" } } } }, - "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", + "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/UpgradePhaseResponse" } } } }, - "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": { @@ -5524,18 +9483,70 @@ } } }, - "200": { - "description": "OK", + "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/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/InboxResponse" + "$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": { @@ -5545,7 +9556,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,27 +9628,58 @@ ] } }, - "/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}/roster": { + "put": { + "summary": "Publish the caller's roster for one of their albums.", + "operationId": "publish_album_roster", "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/json": { "schema": { - "$ref": "#/components/schemas/AdoptRequest" + "$ref": "#/components/schemas/RosterRequest" } } }, @@ -5592,6 +9696,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": { @@ -5604,6 +9732,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": { @@ -5614,46 +9768,176 @@ }, "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": { - "$ref": "#/components/schemas/CodedProblem" + "$ref": "#/components/schemas/RosterVersionLeapProblem" } } } }, - "200": { - "description": "OK", + "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" + "$ref": "#/components/schemas/RosterResponse" } } } }, "404": { "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5662,8 +9946,70 @@ } } }, + "409": { + "description": "Roster stale", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/RosterStaleProblem" + } + } + } + }, "500": { "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, "content": { "application/problem+json": { "schema": { @@ -5673,7 +10019,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": [ @@ -5683,22 +10091,55 @@ ] } }, - "/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", + "/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": "drop_id", - "in": "path", - "description": "The drop'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 } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadRequest" + } + } + }, + "required": true + }, "responses": { "401": { "description": "Unauthorized", @@ -5710,6 +10151,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 +10187,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 +10223,104 @@ }, "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]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } }, - "204": { - "description": "the request succeeded and there is no content to send" + "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" + } + } + } }, - "404": { - "description": "Not found", + "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": { @@ -5753,8 +10329,210 @@ } } }, - "500": { - "description": "Internal server error", + "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": { @@ -5764,1246 +10542,10710 @@ } }, "413": { - "description": "the request body exceeds the configured limit" - } - }, - "security": [ - { - "bearer": [] - } - ] - } - } - }, - "components": { - "schemas": { - "VersionResponse": { - "properties": { - "name": { - "type": "string", - "description": "The server package name." - }, - "version": { + "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": "album_id", + "in": "query", + "description": "One album's page rather than the caller's own feed (`S-C51`).\n\nFor the album's owner or any account on its current roster. Positions are the owner's\nsequence numbers filtered to the album, and the cursor is bound to `(caller, album)`, so\nit cannot be presented on the caller's own feed or on another album. Absent: the caller's\nown library, as before.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Protocol", + "in": "header", + "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + { + "name": "X-Capsule-Crypto-Suite", + "in": "header", + "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + }, + { + "name": "X-Capsule-Sidecar-Schema", + "in": "header", + "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.", + "required": false, + "schema": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0 + } + } + ], + "responses": { + "401": { + "description": "Unauthorized", + "headers": { + "WWW-Authenticate": { + "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.", + "required": true, + "schema": { + "type": "string" + }, + "example": "Bearer" + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "200": { + "description": "OK", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncPageResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ] + } + }, + "/v1/blob/{hash}": { + "get": { + "summary": "Fetch a ciphertext blob by its content address, ranged.", + "description": "Opaque octets: the server holds no key and this route never learns what it is serving. An\naccount fetches the blobs of its own assets and of the albums it is currently a member of; a\nformer member is told `403`, and everyone else is told what an unknown address is told —\nsee [`crate::serve`] for the boundary and its reasons.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).", + "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/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]+$" + } + } + } + } + } + } + }, + "/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/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]+$" + } + } + } + } + } + } + }, + "/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/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]+$" + } + } + } + } + } + } + }, + "/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/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" + } + } + } + } + } + } + }, + "/d/{opaque_id}/{upload_id}": { + "patch": { + "summary": "Append one chunk to a drop session.", + "description": "The link is the credential: possession of the opaque id, plus a session that belongs to it.\nEverything after that is [`crate::upload::chunk::append`] — the album path's own function.", + "operationId": "append_drop_chunk", + "parameters": [ + { + "name": "opaque_id", + "in": "path", + "description": "The opaque id of the link the session belongs to.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "upload_id", + "in": "path", + "description": "The session id.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Capsule-Offset", + "in": "header", + "description": "Where in the blob this chunk starts.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + }, + { + "name": "X-Capsule-Checksum", + "in": "header", + "description": "The chunk's SHA-256, bare lowercase hex. Required: the idempotency tuple is undefined\nwithout it.", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "Bad Request", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "415": { + "description": "Unsupported media type", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "204": { + "description": "the request succeeded and there is no content to send", + "headers": { + "X-Capsule-Offset": { + "description": "Where the session is now.", + "required": true, + "schema": { + "type": "integer", + "minimum": 0.0, + "format": "uint64" + } + }, + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + }, + "404": { + "description": "Not found", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "409": { + "description": "Chunk refused", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "500": { + "description": "Internal server error", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + }, + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/CodedProblem" + } + } + } + }, + "413": { + "description": "the request body exceeds the configured limit", + "headers": { + "X-Capsule-Protocol-Min": { + "description": "The oldest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Protocol-Max": { + "description": "The newest protocol version this server accepts.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + } + }, + "X-Capsule-Min-Client-Build": { + "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "RegisterRequest": { + "properties": { + "email": { + "type": "string", + "description": "The address the account is identified by." + }, + "password": { + "type": "string", + "description": "The password that will authenticate this account's **sessions**.\n\nNever the master key's input: the master key does not derive from it and is never visible\nto the credential verifier. Hashed by the registry adapter and never retained, logged, or\nechoed." + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "description": "The `POST /v1/auth/register` body.\n\nDeliberately the *smallest* thing that can create an account: an address and a password. No\ndisplay name, no profile, no invitation code — every one of those would be a field the server\nstores about a person, and this server's whole posture is that it stores as little as it can." + }, + "Problem": { + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "type", + "status" + ], + "title": "Problem Details", + "description": "An RFC 9457 problem detail." + }, + "TokenResponse": { + "properties": { + "access_token": { "type": "string", - "description": "The server package version." + "description": "The short-lived credential for ordinary requests." + }, + "refresh_token": { + "type": "string", + "description": "The long-lived credential that buys new pairs from `POST /v1/auth/refresh`." + }, + "token_type": { + "type": "string", + "description": "Always `Bearer`." + }, + "expires_by": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The **absolute** Unix-seconds instant `access_token` stops being honoured.\n\nAbsolute rather than a duration, which is what the field has always carried despite its\nname; the SDK depends on it." } }, "type": "object", "required": [ - "name", - "version" + "access_token", + "refresh_token", + "token_type", + "expires_by" ], - "description": "Identifies the running server.\n\nDeliberately incurious: a name and a version, no build host, no commit, no uptime, no\nfeature list. This endpoint is unauthenticated, so everything it returns is public, and a\nkey-free server has no reason to hand an anonymous caller a fingerprint of its deployment.\nExact client build identification runs the other way (`S-D15`) — clients tell the server\nwhat they are, not the reverse." + "description": "A freshly issued token pair.\n\nThe field names and `expires_by`'s meaning are a live client contract — `capsule-sdk`'s\n`TokenResponseBody` reads exactly these — so they are preserved verbatim from the Salvo\nsurface. `Debug` is hand-written; both tokens are bearer credentials.\n\n`Deserialize` is derived so the suite reads the pair back through the same type the server\nwrote — a test that pulled `access_token` out of a `serde_json::Value` would still pass if\nthe field were renamed on the way out." + }, + "LoginRequest": { + "properties": { + "email": { + "type": "string", + "description": "The account's email address." + }, + "password": { + "type": "string", + "description": "The account's password.\n\nVerified by the account directory and never retained, logged, or echoed." + }, + "cohort_hash": { + "type": [ + "string", + "null" + ], + "description": "An advisory device-cohort hash grouping one physical device's re-enrollments\n(slice `S-C13`).\n\nLegibility metadata only: no authorization path reads it, and an unusable value is\ndropped rather than refused — a sign-in must not fail over a field that gates nothing." + }, + "device_id": { + "type": [ + "string", + "null" + ], + "description": "The directory device the client claims to be (slice `S-N3`), as a UUID.\n\nClient-asserted and unverified. Dropped, not refused, when it is not a usable UUID, for\nthe same reason as `cohort_hash`." + } + }, + "type": "object", + "required": [ + "email", + "password" + ], + "description": "Credentials, plus the two advisory identifiers a client may volunteer.\n\n`Debug` is hand-written. A derived one would print the password into any log line, panic\nmessage or `tracing` field that formatted the request — which is the single worst thing this\nfile could do, and is one `#[derive(Debug)]` away at all times.\nNo `#[schema(min_length = ...)]` on either credential, deliberately. Kynos 0.1.0 publishes a\nstring constraint into the document but does not enforce it on the request path — an empty\npassword reaches the handler — so declaring one would put a promise in the contract that the\nserver does not keep, which is the exact class of drift this rebuild exists to remove. Length\nis a body-size concern and belongs to a limits middleware; it is recorded as owed rather than\nasserted here." + }, + "SecondFactorChallenge": { + "properties": { + "mfa_token": { + "type": "string", + "description": "The token to present alongside the code." + }, + "expires_by": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The **absolute** Unix-seconds instant the challenge stops being honoured.\n\nAbsolute rather than a duration, matching `TokenResponse::expires_by`, so a client has\none convention rather than two." + } + }, + "type": "object", + "required": [ + "mfa_token", + "expires_by" + ], + "description": "A half-finished sign-in.\n\n`Debug` is hand-written: the token is a credential, even though it authenticates nothing on\nits own." + }, + "RefreshRequest": { + "properties": { + "refresh_token": { + "type": "string", + "description": "The refresh token issued by a previous login or refresh.\n\nUnconstrained in the schema for the reason [`LoginRequest`] records: an empty one is a\ntoken that does not verify, which is a 401 the handler already answers correctly." + } + }, + "type": "object", + "required": [ + "refresh_token" + ], + "description": "The refresh token being exchanged for a new pair.\n\n`Debug` is hand-written, for the same reason as [`LoginRequest`]: this field is a live\ncredential." + }, + "RevokeChallengeResponse": { + "properties": { + "challenge": { + "type": "string", + "description": "The single-use token. Burned on the first attempt, successful or not." + }, + "expires_at": { + "type": "string", + "description": "When it stops being redeemable, RFC 3339." + } + }, + "type": "object", + "required": [ + "challenge", + "expires_at" + ], + "description": "The challenge a global sign-out is signed over." + }, + "RevokeAllRequest": { + "properties": { + "challenge": { + "type": "string", + "description": "The challenge that was issued." + }, + "proof": { + "type": "string", + "description": "The account identity key's hybrid signature over\n[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes),\ncanonical CBOR, base64." + } + }, + "type": "object", + "required": [ + "challenge", + "proof" + ], + "description": "A master-key proof over an issued challenge." + }, + "RevokeAllResponse": { + "properties": { + "revoked": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many sessions were closed — the caller's own among them.\n\nCounted from the records the store actually removed, never from a separately maintained\nindex. The Salvo implementation read a per-user set that `revoke_session` did not clean\nup, so this number inflated by one for every prior refresh; `S-C29` made the record and\nits listing entry one fact, so there is nothing left to disagree." + } + }, + "type": "object", + "required": [ + "revoked" + ], + "description": "What a global sign-out closed." + }, + "ReauthenticateRequest": { + "properties": { + "password": { + "type": "string", + "description": "The account's password." + } + }, + "type": "object", + "required": [ + "password" + ], + "description": "A password, re-presented on a session that already exists." + }, + "ReauthenticateResponse": { + "properties": { + "authenticated_at": { + "type": "string", + "description": "The moment the credential was accepted, RFC 3339.\n\nReturned so a client can decide locally whether a gated operation will be admitted,\nrather than discovering it from a `403` in the middle of a ceremony." + } + }, + "type": "object", + "required": [ + "authenticated_at" + ], + "description": "When the re-authenticated session's freshness window last opened." + }, + "PublishDirectoryResponse": { + "properties": { + "directory_version": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The version now stored, which equals the submitted one." + } + }, + "type": "object", + "required": [ + "directory_version" + ], + "description": "The accepted version, echoed so a client knows what is now in force." + }, + "StoreEscrowResponse": { + "properties": { + "stored_at": { + "type": "string", + "description": "When the server accepted it, RFC 3339.\n\nEchoed so a client can tell whether a cached copy is current — the stale-cache rule,\nwhich exists because a rotation from another device would otherwise manufacture false\nverification failures on this one." + }, + "replaced": { + "type": "boolean", + "description": "Whether this displaced an earlier escrow.\n\nA rotation and a first escrow are different events for a client: one completes account\nsetup, and the other means the previous recovery secret has stopped working." + } + }, + "type": "object", + "required": [ + "stored_at", + "replaced" + ], + "description": "What storing an escrow did." + }, + "UpdateProfileRequest": { + "properties": { + "display_name": { + "type": [ + "string", + "null" + ], + "description": "The display name to set, clear (`null`), or leave alone (absent)." + } + }, + "type": "object", + "description": "A partial edit of the caller's profile.\n\n`display_name` is a **doubly** optional field on the wire, and the two levels mean different\nthings: an absent key leaves the name alone, and an explicit `null` clears it. That is what\n`#[serde(default, deserialize_with = …)]` over an `Option>` buys, and it is\nthe whole reason this body is not `deny_unknown_fields`-plus-a-flat-option: a flat one cannot\ntell \"I did not mention the name\" from \"remove the name\", so every partial update would wipe\na field the caller never sent." + }, + "ProfileResponse": { + "properties": { + "user_id": { + "type": "string", + "description": "The account identifier every manifest and every session names." + }, + "email": { + "type": "string", + "description": "The address this account signs in with.\n\nRead-only on this surface. Changing it needs proof that the caller controls the new\naddress, and this server has no way to obtain one; see [`crate::auth::profile`]." + }, + "display_name": { + "type": [ + "string", + "null" + ], + "description": "The name the account chose to be shown as, if it chose one.\n\nAbsent rather than `null` when unset, so a client's \"has a name\" test is a key test." + }, + "created_at": { + "type": "string", + "description": "When the account was created, RFC 3339." + } + }, + "type": "object", + "required": [ + "user_id", + "email", + "created_at" + ], + "description": "An account's profile as it is served.\n\n`Deserialize` is derived so the suite reads it back through the same type the server wrote —\na test pulling `display_name` out of a `serde_json::Value` would still pass if the field were\nrenamed on the way out." }, - "RegisterRequest": { + "ChangePasswordRequest": { "properties": { - "email": { + "current_password": { "type": "string", - "description": "The address the account is identified by." + "description": "The password currently in use, which authorizes the change.\n\nVerified through the same directory method a sign-in uses, so a locked account is locked\nhere too." }, - "password": { + "new_password": { "type": "string", - "description": "The password that will authenticate this account's **sessions**.\n\nNever the master key's input: the master key does not derive from it and is never visible\nto the credential verifier. Hashed by the registry adapter and never retained, logged, or\nechoed." + "description": "The password to replace it with." } }, "type": "object", "required": [ - "email", - "password" + "current_password", + "new_password" ], - "description": "The `POST /v1/auth/register` body.\n\nDeliberately the *smallest* thing that can create an account: an address and a password. No\ndisplay name, no profile, no invitation code — every one of those would be a field the server\nstores about a person, and this server's whole posture is that it stores as little as it can." + "description": "The two passwords a rotation needs.\n\n`Debug` is hand-written for the reason `routes::auth`'s bodies are: a derived one would print\nboth credentials into any log line that formatted the request." }, - "Problem": { + "EnrollmentResponse": { "properties": { - "type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "status": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16" - }, - "detail": { - "type": "string" - }, - "instance": { - "type": "string" + "provisioning_uri": { + "type": "string", + "description": "The `otpauth://` URI an authenticator app scans.\n\nIt carries the shared secret, so it is a credential: served once, over the authenticated\nchannel, and never fetchable again. Losing it before confirming means enrolling again,\nwhich is why a *pending* enrollment is replaceable without ceremony." } }, - "additionalProperties": true, "type": "object", "required": [ - "type", - "status" + "provisioning_uri" ], - "title": "Problem Details", - "description": "An RFC 9457 problem detail." + "description": "A freshly issued, unconfirmed enrollment." }, - "TokenResponse": { + "CodeRequest": { "properties": { - "access_token": { - "type": "string", - "description": "The short-lived credential for ordinary requests." - }, - "refresh_token": { - "type": "string", - "description": "The long-lived credential that buys new pairs from `POST /v1/auth/refresh`." - }, - "token_type": { + "totp_code": { "type": "string", - "description": "Always `Bearer`." - }, - "expires_by": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The **absolute** Unix-seconds instant `access_token` stops being honoured.\n\nAbsolute rather than a duration, which is what the field has always carried despite its\nname; the SDK depends on it." + "description": "The code the authenticator app is showing." } }, "type": "object", "required": [ - "access_token", - "refresh_token", - "token_type", - "expires_by" + "totp_code" ], - "description": "A freshly issued token pair.\n\nThe field names and `expires_by`'s meaning are a live client contract — `capsule-sdk`'s\n`TokenResponseBody` reads exactly these — so they are preserved verbatim from the Salvo\nsurface. `Debug` is hand-written; both tokens are bearer credentials.\n\n`Deserialize` is derived so the suite reads the pair back through the same type the server\nwrote — a test that pulled `access_token` out of a `serde_json::Value` would still pass if\nthe field were renamed on the way out." + "description": "A six-digit code, and nothing else." }, - "LoginRequest": { + "VerifyLoginRequest": { "properties": { - "email": { + "mfa_token": { "type": "string", - "description": "The account's email address." + "description": "The challenge issued by `POST /v1/auth/login`." }, - "password": { + "totp_code": { "type": "string", - "description": "The account's password.\n\nVerified by the account directory and never retained, logged, or echoed." + "description": "The code the authenticator app is showing." }, "cohort_hash": { "type": [ "string", "null" ], - "description": "An advisory device-cohort hash grouping one physical device's re-enrollments\n(slice `S-C13`).\n\nLegibility metadata only: no authorization path reads it, and an unusable value is\ndropped rather than refused — a sign-in must not fail over a field that gates nothing." + "description": "An advisory device-cohort hash grouping one physical device's re-enrollments (`S-C13`)." }, "device_id": { "type": [ "string", "null" ], - "description": "The directory device the client claims to be (slice `S-N3`), as a UUID.\n\nClient-asserted and unverified. Dropped, not refused, when it is not a usable UUID, for\nthe same reason as `cohort_hash`." + "description": "The directory device the client claims to be (`S-N3`), as a UUID." } }, "type": "object", "required": [ - "email", - "password" + "mfa_token", + "totp_code" ], - "description": "Credentials, plus the two advisory identifiers a client may volunteer.\n\n`Debug` is hand-written. A derived one would print the password into any log line, panic\nmessage or `tracing` field that formatted the request — which is the single worst thing this\nfile could do, and is one `#[derive(Debug)]` away at all times.\nNo `#[schema(min_length = ...)]` on either credential, deliberately. Kynos 0.1.0 publishes a\nstring constraint into the document but does not enforce it on the request path — an empty\npassword reaches the handler — so declaring one would put a promise in the contract that the\nserver does not keep, which is the exact class of drift this rebuild exists to remove. Length\nis a body-size concern and belongs to a limits middleware; it is recorded as owed rather than\nasserted here." + "description": "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`)." }, - "SecondFactorChallenge": { + "EnrollmentCodeResponse": { "properties": { - "mfa_token": { + "code": { "type": "string", - "description": "The token to present alongside the code." + "description": "The full-entropy code the QR payload carries." }, - "expires_by": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "The **absolute** Unix-seconds instant the challenge stops being honoured.\n\nAbsolute rather than a duration, matching `TokenResponse::expires_by`, so a client has\none convention rather than two." + "text_fallback": { + "type": "string", + "description": "The shorter transcribable numeric fallback.\n\nDeliberately weaker than the QR payload and safe because it never stands alone:\nredemption is single-use and expires, and channel integrity rests on the safety-code\ncheck rather than on this value." + }, + "expires_at": { + "type": "string", + "description": "When both spellings stop being redeemable, RFC 3339." } }, "type": "object", "required": [ - "mfa_token", - "expires_by" + "code", + "text_fallback", + "expires_at" ], - "description": "A half-finished sign-in.\n\n`Debug` is hand-written: the token is a credential, even though it authenticates nothing on\nits own." + "description": "A freshly issued enrollment code." }, - "RefreshRequest": { + "RedeemRequest": { "properties": { - "refresh_token": { + "code": { "type": "string", - "description": "The refresh token issued by a previous login or refresh.\n\nUnconstrained in the schema for the reason [`LoginRequest`] records: an empty one is a\ntoken that does not verify, which is a 401 the handler already answers correctly." + "description": "Either spelling of the issued code." } }, "type": "object", "required": [ - "refresh_token" + "code" ], - "description": "The refresh token being exchanged for a new pair.\n\n`Debug` is hand-written, for the same reason as [`LoginRequest`]: this field is a live\ncredential." + "description": "The code a device presents." }, - "RevokeChallengeResponse": { + "ChannelResponse": { "properties": { - "challenge": { + "channel_id": { "type": "string", - "description": "The single-use token. Burned on the first attempt, successful or not." + "description": "The handle both devices relay through. Possession of it *is* the capability." }, "expires_at": { "type": "string", - "description": "When it stops being redeemable, RFC 3339." + "description": "When the channel closes on its own, RFC 3339." } }, "type": "object", "required": [ - "challenge", + "channel_id", "expires_at" ], - "description": "The challenge a global sign-out is signed over." + "description": "The channel a redeemed code opens." }, - "RevokeAllRequest": { + "RelayRequest": { "properties": { - "challenge": { + "direction": { "type": "string", - "description": "The challenge that was issued." + "description": "Which mailbox to append to: `to_initiator` or `to_enrollee`." }, - "proof": { + "payload": { "type": "string", - "description": "The account identity key's hybrid signature over\n[`revoke_all_signing_bytes`](capsule_core::crypto::revoke::revoke_all_signing_bytes),\ncanonical CBOR, base64." + "description": "The opaque payload. The server never inspects it." } }, "type": "object", "required": [ - "challenge", - "proof" + "direction", + "payload" ], - "description": "A master-key proof over an issued challenge." + "description": "One relayed payload." }, - "RevokeAllResponse": { + "ProvisionAlbumRequest": { "properties": { - "revoked": { + "album_id": { + "type": "string", + "description": "The client-derived album id, as a canonical lowercase hyphenated UUID." + } + }, + "type": "object", + "required": [ + "album_id" + ], + "description": "The provisioning request." + }, + "ProvisionAlbumResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "protocol_version": { + "type": "string", + "description": "The protocol date the album is pinned to — the server's, fixed at creation." + }, + "created": { + "type": "boolean", + "description": "Whether this call created the album. Advisory; both answers mean the same thing." + } + }, + "type": "object", + "required": [ + "album_id", + "protocol_version", + "created" + ], + "description": "What provisioning did." + }, + "UpgradePhaseResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "intent_id": { + "type": [ + "string", + "null" + ], + "description": "The ceremony in flight, or absent when the album is in normal operation.\n\nAbsent also covers *expired*: the deadline passing aborts the upgrade, so there is nothing\nleft to be in." + }, + "to_protocol_version": { + "type": [ + "string", + "null" + ], + "description": "The protocol version the fork will be pinned to, when a ceremony is in flight." + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "When the window closes, RFC 3339, on the **server's** clock." + }, + "in_flight": { "type": "integer", "minimum": 0.0, "format": "uint64", - "description": "How many sessions were closed — the caller's own among them.\n\nCounted from the records the store actually removed, never from a separately maintained\nindex. The Salvo implementation read a per-user set that `revoke_session` did not clean\nup, so this number inflated by one for every prior refresh; `S-C29` made the record and\nits listing entry one fact, so there is nothing left to disagree." + "description": "How many upload sessions are still in flight against this album.\n\nThe drain signal of versioning.md step 3: the proposer waits for zero. A count rather than\na listing, because the proposer needs to know *whether* to wait and has no business seeing\nother members' upload identifiers to find out." + } + }, + "type": "object", + "required": [ + "album_id", + "in_flight" + ], + "description": "The ceremony this album is in, as a client polls it." + }, + "RosterRequest": { + "properties": { + "roster_cbor": { + "type": "string", + "description": "The signed roster, as standard base64 of its canonical CBOR encoding." + } + }, + "type": "object", + "required": [ + "roster_cbor" + ], + "description": "The publish request." + }, + "RosterResponse": { + "properties": { + "album_id": { + "type": "string", + "description": "The album, echoed." + }, + "roster_version": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The roster version the server holds after this call." + }, + "amk_epoch": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The AMK epoch that roster reflects." + }, + "member_count": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many members the held roster names, the owner excluded." + }, + "replayed": { + "type": "boolean", + "description": "Whether this call was a replay of the roster already held. Advisory: both answers mean\n\"the server holds this roster\"." } }, "type": "object", "required": [ - "revoked" + "album_id", + "roster_version", + "amk_epoch", + "member_count", + "replayed" ], - "description": "What a global sign-out closed." + "description": "What the server now holds for the album." }, - "SessionView": { + "WireBlobRole": { + "type": "string", + "enum": [ + "original", + "derivative", + "metadata", + "provenance", + "backup" + ], + "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." + }, + "ManifestEnvelope": { "properties": { - "session_id": { + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite the blob was sealed under. Must equal the top-level declaration." + }, + "protocol_version": { "type": "string", - "description": "The session's identifier — what a revoke names." + "description": "The protocol date the manifest was written under (`YYYY-MM-DD`)." }, - "created_at": { + "album_id": { + "type": [ + "string", + "null" + ], + "description": "The album the asset belongs to. Must equal the top-level declaration." + }, + "file_id": { "type": "string", - "description": "When this session *record* was minted, RFC 3339.\n\nA refresh rotates the session, so after one this is the rotation time and not the\nsign-in. `authenticated_at` is the field that answers \"when did you last sign in\"." + "description": "The asset this blob belongs to — the same id across the bundle's members." }, - "authenticated_at": { + "amk_version": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The album-key epoch the manifest was written under." + }, + "ciphertext_hash": { "type": "string", - "description": "When the user last proved a credential on this session's lineage, RFC 3339.\n\nCarried forward across refreshes, so it is the one timestamp here that means what a\nuser reading a devices list expects \"signed in\" to mean. It is also what the\ncross-device add's freshness gate reads (`S-C7`), so a client can show why an add is\nabout to ask for a password again." + "description": "The ciphertext content hash, lowercase hex. Must equal the top-level `hash`.\n\n**This names the blob this session is uploading, not the manifest's own\n`ciphertext_hash`.** For the original the two coincide; for a metadata or provenance\nsession they do not, and the projection reuses the manifest's field name for a per-blob\ndeclaration. Invisible for a `create`, because the bundle is assembled in a pending row\nnobody can see and no member has to name another. It is not invisible for a `replace`,\nwhich is why [`Self::original_blob_hash`] exists (`S-C43`)." }, - "last_active_at": { + "plaintext_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The plaintext length the manifest commits to." + }, + "chunk_size": { + "type": "integer", + "maximum": 4294967295.0, + "minimum": 0.0, + "format": "uint32", + "description": "The STREAM plaintext chunk size." + }, + "key_mode": { "type": "string", - "description": "When it was last seen, RFC 3339.\n\nEqual to `created_at` until `S-C48` puts the session ledger on the request path. A\nclient must not label this \"last used\" before then." + "description": "`derived` or `wrapped`." }, - "user_agent": { + "metadata_blob_hash": { "type": [ "string", "null" ], - "description": "The `User-Agent` the opening ceremony carried, if any." + "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." }, - "ip_address": { + "original_blob_hash": { "type": [ "string", "null" ], - "description": "The address the opening ceremony came from, if any." + "description": "The content hash of the bundle's **original** blob, when the manifest commits to one\n(`S-C43`).\n\nThe manifest's own `ciphertext_hash`, under a name that cannot be confused with\n[`Self::ciphertext_hash`]'s per-session meaning. Optional on the wire and **required on a\n`replace`**: a replace re-points roles that already have bytes, so it has to be applied\nas one act, and the only member of the bundle that can carry the whole change is the\nmanifest — which therefore has to be able to name the original it commits to.\n\nA `create` may omit it. Its bundle is assembled incrementally in a row nobody can see,\nso no member needs to name another and requiring it would be a wire change for no gain." }, - "cohort_hash": { + "created_by_user": { + "type": "string", + "description": "The account that created the asset." + }, + "created_by_device": { + "type": "string", + "description": "The device that created it, as a UUID — invariant 7's subject." + }, + "client_version": { + "type": "string", + "description": "The client build that wrote the manifest." + }, + "timestamp": { + "type": "string", + "description": "The manifest's self-asserted RFC3339 timestamp — invariants 7 and 8's subject." + }, + "action": { + "type": "string", + "description": "The lifecycle action. `create` or `replace` on this surface — the two that move blob\nbytes — and see [`GateReject::ActionNotAllowed`] for the rest." + }, + "prior_provenance_hash": { "type": [ "string", "null" ], - "description": "The advisory cohort this session asserted, if any. Grouping only." + "description": "The provenance chain position this write continues from." }, - "device_id": { + "retention_until": { "type": [ "string", "null" ], - "description": "The directory device the client claimed to be (`S-N3`), if any.\n\nA different identifier space from `cohort_hash`: this names one directory device, the\ncohort groups re-enrollments of one physical device. Both are client-asserted; neither\ngates anything." - }, - "current": { - "type": "boolean", - "description": "Whether this is the session making the request.\n\nSo a client can label \"this device\" without comparing tokens it should not be handling,\nand so revoking the current session is a deliberate act rather than an accident." + "description": "The retention floor the manifest carries, when it carries one." } }, "type": "object", "required": [ - "session_id", - "created_at", - "authenticated_at", - "last_active_at", - "current" + "crypto_suite_id", + "protocol_version", + "file_id", + "amk_version", + "ciphertext_hash", + "plaintext_size", + "chunk_size", + "key_mode", + "created_by_user", + "created_by_device", + "client_version", + "timestamp", + "action" ], - "description": "One live session." + "description": "The server-visible mirror of the signed manifest's envelope fields, as declared at\n`POST /v1/upload`.\n\nStrict (`deny_unknown_fields`) like the rest of the transport JSON. The Postel asymmetry\nthe design draws — tolerant inside documents that outlive us, strict on the wire we own —\nputs unknown-key tolerance in the *signed CBOR interiors*, never in this JSON projection." }, - "CohortView": { + "CreateUploadRequest": { "properties": { - "cohort_hash": { - "type": "string", - "description": "The advisory hash." + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The ciphertext length in bytes. Immutable for the session's life." }, - "first_seen": { + "hash": { "type": "string", - "description": "The first time this account was seen under it, RFC 3339.\n\nWhat lets a client say *\"a device you've used before\"* about a session whose own\n`device_id` is new — which is the entire reason the map is durable." + "description": "The ciphertext content hash, lowercase hex; the digest length is the suite's." }, - "last_seen": { + "content_type": { "type": "string", - "description": "The most recent time, RFC 3339." - } - }, - "type": "object", - "required": [ - "cohort_hash", - "first_seen", - "last_seen" - ], - "description": "One cohort this account has been seen under." - }, - "DevicesResponse": { - "properties": { - "sessions": { - "items": { - "$ref": "#/components/schemas/SessionView" - }, - "type": "array", - "description": "Every live session, oldest first." + "description": "The media type, from the closed enum this protocol version fixes." }, - "cohorts": { - "items": { - "$ref": "#/components/schemas/CohortView" - }, - "type": "array", - "description": "Every cohort this account has ever been seen under, oldest first sighting first.\n\nServed **beside** the sessions rather than folded into them, because a cohort outlives\nthe sessions that carried it: a reinstall's new session groups with a cohort whose other\nsessions expired months ago, and a client that only had per-session cohorts could not\nsay \"you have used this device before\"." - } - }, - "type": "object", - "required": [ - "sessions", - "cohorts" - ], - "description": "The session ledger." - }, - "PublishDirectoryResponse": { - "properties": { - "directory_version": { + "crypto_suite_id": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "The version now stored, which equals the submitted one." - } - }, - "type": "object", - "required": [ - "directory_version" - ], - "description": "The accepted version, echoed so a client knows what is now in force." - }, - "StoreEscrowResponse": { - "properties": { - "stored_at": { - "type": "string", - "description": "When the server accepted it, RFC 3339.\n\nEchoed so a client can tell whether a cached copy is current — the stale-cache rule,\nwhich exists because a rotation from another device would otherwise manufacture false\nverification failures on this one." + "format": "uint16", + "description": "The crypto suite the blob was sealed under." }, - "replaced": { - "type": "boolean", - "description": "Whether this displaced an earlier escrow.\n\nA rotation and a first escrow are different events for a client: one completes account\nsetup, and the other means the previous recovery secret has stopped working." - } - }, - "type": "object", - "required": [ - "stored_at", - "replaced" - ], - "description": "What storing an escrow did." - }, - "ReauthenticateRequest": { - "properties": { - "password": { - "type": "string", - "description": "The account's password." - } - }, - "type": "object", - "required": [ - "password" - ], - "description": "A password, re-presented on a session that already exists." - }, - "ReauthenticateResponse": { - "properties": { - "authenticated_at": { - "type": "string", - "description": "The moment the credential was accepted, RFC 3339.\n\nReturned so a client can decide locally whether a gated operation will be admitted,\nrather than discovering it from a `403` in the middle of a ceremony." - } - }, - "type": "object", - "required": [ - "authenticated_at" - ], - "description": "When the re-authenticated session's freshness window last opened." - }, - "ProfileResponse": { - "properties": { - "user_id": { + "protocol_version": { "type": "string", - "description": "The account identifier every manifest and every session names." + "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." }, - "email": { - "type": "string", - "description": "The address this account signs in with.\n\nRead-only on this surface. Changing it needs proof that the caller controls the new\naddress, and this server has no way to obtain one; see [`crate::auth::profile`]." + "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." }, - "display_name": { + "album_id": { "type": [ "string", "null" ], - "description": "The name the account chose to be shown as, if it chose one.\n\nAbsent rather than `null` when unset, so a client's \"has a name\" test is a key test." + "description": "The album the asset is filed into.\n\nOptional on the wire because the contract reserves the shape for owner-scoped kinds and\nfor the album-upgrade ceremony; **required by this server**, which has no way to check\ninvariant 6 without one and refuses rather than skipping it." }, - "created_at": { - "type": "string", - "description": "When the account was created, RFC 3339." - } - }, - "type": "object", - "required": [ - "user_id", - "email", - "created_at" - ], - "description": "An account's profile as it is served.\n\n`Deserialize` is derived so the suite reads it back through the same type the server wrote —\na test pulling `display_name` out of a `serde_json::Value` would still pass if the field were\nrenamed on the way out." - }, - "UpdateProfileRequest": { - "properties": { - "display_name": { + "owner_id": { "type": [ "string", "null" ], - "description": "The display name to set, clear (`null`), or leave alone (absent)." - } - }, - "type": "object", - "description": "A partial edit of the caller's profile.\n\n`display_name` is a **doubly** optional field on the wire, and the two levels mean different\nthings: an absent key leaves the name alone, and an explicit `null` clears it. That is what\n`#[serde(default, deserialize_with = …)]` over an `Option>` buys, and it is\nthe whole reason this body is not `deny_unknown_fields`-plus-a-flat-option: a flat one cannot\ntell \"I did not mention the name\" from \"remove the name\", so every partial update would wipe\na field the caller never sent." - }, - "ChangePasswordRequest": { - "properties": { - "current_password": { - "type": "string", - "description": "The password currently in use, which authorizes the change.\n\nVerified through the same directory method a sign-in uses, so a locked account is locked\nhere too." + "description": "The owner the asset is filed under, when the client wants to say so.\n\nAdvisory, never decisive: the asset is filed under the **album's** owner, which the write\nauthority answers from the album record — the uploader when it is their album, the owner\nwhen the uploader is a writer on its roster (`S-C51`). A declared owner that is anyone\nelse, the uploading member included, is refused `error.upload.owner_not_permitted`." }, - "new_password": { - "type": "string", - "description": "The password to replace it with." + "intent_id": { + "type": [ + "string", + "null" + ], + "description": "The album-upgrade intent this write belongs to, when it belongs to one.\n\nCarried onto the session verbatim and read by nobody in this port; the ceremony that\ngives it meaning is `S-C24`." } }, "type": "object", "required": [ - "current_password", - "new_password" + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "blob_role", + "manifest_envelope" ], - "description": "The two passwords a rotation needs.\n\n`Debug` is hand-written for the reason `routes::auth`'s bodies are: a derived one would print\nboth credentials into any log line that formatted the request." + "description": "The body of `POST /v1/upload`.\n\nStrict (`deny_unknown_fields`): an unknown field is a client bug and is refused rather than\nignored. Plaintext metadata — a filename, a capture date, dimensions — is deliberately\nabsent: it rides the encrypted metadata blob and never the wire request." }, - "EnrollmentResponse": { + "CreateUploadResponse": { "properties": { - "provisioning_uri": { + "id": { "type": "string", - "description": "The `otpauth://` URI an authenticator app scans.\n\nIt carries the shared secret, so it is a credential: served once, over the authenticated\nchannel, and never fetchable again. Losing it before confirming means enrolling again,\nwhich is why a *pending* enrollment is replaceable without ceremony." - } - }, - "type": "object", - "required": [ - "provisioning_uri" - ], - "description": "A freshly issued, unconfirmed enrollment." - }, - "CodeRequest": { - "properties": { - "totp_code": { + "description": "The session's identifier." + }, + "upload_url": { "type": "string", - "description": "The code the authenticator app is showing." + "description": "Where to send chunks." + }, + "suggested_chunk_size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "A starting chunk size. A suggestion only — the client owns adaptation." } }, "type": "object", "required": [ - "totp_code" + "id", + "upload_url", + "suggested_chunk_size" ], - "description": "A six-digit code, and nothing else." + "description": "What a client needs to start sending bytes." }, - "VerifyLoginRequest": { + "OpRequest": { "properties": { - "mfa_token": { - "type": "string", - "description": "The challenge issued by `POST /v1/auth/login`." + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The server-visible projection of the signed manifest's fields, exactly as\n`POST /v1/upload` carries it. Its `album_id` must equal the path segment and its\n`action` must be one this surface accepts." }, - "totp_code": { + "manifest_cbor": { "type": "string", - "description": "The code the authenticator app is showing." - }, - "cohort_hash": { - "type": [ - "string", - "null" - ], - "description": "An advisory device-cohort hash grouping one physical device's re-enrollments (`S-C13`)." + "description": "The signed manifest itself, base64 of the canonical CBOR.\n\nStored verbatim as the asset's new provenance blob, so the feed serves the exact bytes\nthe client signed (`S-C30`) for a lifecycle write as it already does for an upload. The\nserver does not parse it: base64 is a transport encoding, and `decode(encode(b)) == b`." }, - "device_id": { + "metadata_blob": { "type": [ "string", "null" ], - "description": "The directory device the client claims to be (`S-N3`), as a UUID." + "description": "The encrypted metadata blob, base64, present exactly when the action carries one.\n\nIts content hash must equal the manifest's committed `metadata_blob_hash`\n(invariant 25). The server holds no key and never reads it." } }, "type": "object", "required": [ - "mfa_token", - "totp_code" + "manifest_envelope", + "manifest_cbor" ], - "description": "Completing a sign-in with a second factor.\n\nIt carries the same two advisory identifiers `LoginRequest` does, because *this* is the\nrequest that opens the session: without them a TOTP sign-in would land in the devices view as\nan unknown, ungrouped device (`S-N3`)." + "description": "The signed manifest bundle a lifecycle write carries." }, - "EnrollmentCodeResponse": { + "OpResponse": { "properties": { - "code": { + "asset_id": { "type": "string", - "description": "The full-entropy code the QR payload carries." + "description": "The asset the op chained onto." }, - "text_fallback": { - "type": "string", - "description": "The shorter transcribable numeric fallback.\n\nDeliberately weaker than the QR payload and safe because it never stands alone:\nredemption is single-use and expires, and channel integrity rests on the safety-code\ncheck rather than on this value." + "sync_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The feed position it occupies. On a replay, the position the *first* application took." }, - "expires_at": { + "action": { "type": "string", - "description": "When both spellings stop being redeemable, RFC 3339." + "description": "The action that was applied." + }, + "replayed": { + "type": "boolean", + "description": "Whether this response is a replay of an already-applied manifest.\n\nAdvisory, and deliberately not something a correct client needs: the other three fields\nare identical either way, which is what \"byte-identical prior response\" means." } }, "type": "object", "required": [ - "code", - "text_fallback", - "expires_at" + "asset_id", + "sync_seq", + "action", + "replayed" ], - "description": "A freshly issued enrollment code." + "description": "What a lifecycle write did." }, - "RedeemRequest": { + "AssetVerifyRequest": { "properties": { - "code": { + "asset_id": { "type": "string", - "description": "Either spelling of the issued code." + "description": "The asset." + }, + "blob_hashes": { + "items": { + "type": "string" + }, + "type": "array", + "description": "Every content address the client would be trusting the server with. The verdict is a\nconjunction over exactly these, so a client asks about what it is about to delete." } }, "type": "object", "required": [ - "code" + "asset_id", + "blob_hashes" ], - "description": "The code a device presents." + "description": "One asset to verify, with the exact copies the client is relying on." }, - "ChannelResponse": { + "StorageVerifyRequest": { "properties": { - "channel_id": { - "type": "string", - "description": "The handle both devices relay through. Possession of it *is* the capability." + "assets": { + "items": { + "$ref": "#/components/schemas/AssetVerifyRequest" + }, + "type": "array", + "description": "The assets to verify." }, - "expires_at": { - "type": "string", - "description": "When the channel closes on its own, RFC 3339." + "deep": { + "type": "boolean", + "description": "Also re-read and re-hash the bytes (`S-C41`).\n\nAbsent or `false` is the structural check: ask the index and the store whether the bytes\nare there. `true` additionally re-hashes them, which is the only way to catch silent\ncorruption — `stored` is a question about the filesystem, and a corrupt blob is still\nstored.\n\n**Rate-limited per account**, because a deep scan reads and hashes every declared blob\nand an unbounded one is an I/O-amplification attack costing the caller one small JSON\nbody. Past the budget the *structural* verdict still comes back and each blob's `deep`\nreads `rate_limited`: throwing away a good structural answer because the optional half\nwas throttled would make the limiter cost more than it saves." } }, "type": "object", "required": [ - "channel_id", - "expires_at" + "assets" ], - "description": "The channel a redeemed code opens." + "description": "The `POST /v1/storage/verify` body." }, - "RelayRequest": { + "BlobVerdictResponse": { "properties": { - "direction": { + "hash": { "type": "string", - "description": "Which mailbox to append to: `to_initiator` or `to_enrollee`." + "description": "The address, as the client declared it." }, - "payload": { + "role": { "type": "string", - "description": "The opaque payload. The server never inspects it." + "description": "The role the asset holds it under — `unknown` for a hash the asset does not hold." + }, + "stored": { + "type": "boolean", + "description": "The bytes are present at that address." + }, + "indexed": { + "type": "boolean", + "description": "A live asset of the caller's references the address." + }, + "retrievable": { + "type": "boolean", + "description": "Nothing is withholding it." + }, + "deep": { + "type": [ + "string", + "null" + ], + "description": "What a deep scan found: `intact`, `corrupt`, or `rate_limited` (`S-C41`).\n\n**Absent when no deep scan ran**, and the absence is load-bearing: it is the difference\nbetween \"we did not look at the bytes\" and \"we looked and they were fine\", and a client\ndeciding whether to release its only copy has to be able to tell those apart." } }, "type": "object", "required": [ - "direction", - "payload" + "hash", + "role", + "stored", + "indexed", + "retrievable" ], - "description": "One relayed payload." + "description": "One declared blob's verdict." }, - "DrainResponse": { + "StorageVerdictResponse": { "properties": { - "payloads": { + "asset_id": { + "type": "string", + "description": "The asset the client asked about." + }, + "durable": { + "type": "boolean", + "description": "Every declared blob is stored ∧ indexed ∧ retrievable. **This is the field that gates a\ndeletion**, so it is false whenever the server cannot say otherwise." + }, + "blobs": { "items": { - "type": "string" + "$ref": "#/components/schemas/BlobVerdictResponse" }, "type": "array", - "description": "The payloads in arrival order, removed by this call. Possibly empty." - } - }, - "type": "object", - "required": [ - "payloads" - ], - "description": "Everything pending in one mailbox." - }, - "ProvisionAlbumRequest": { - "properties": { - "album_id": { + "description": "One entry per declared hash, in declaration order and never shortened." + }, + "checked_at": { "type": "string", - "description": "The client-derived album id, as a canonical lowercase hyphenated UUID." + "description": "The server's own clock at verification, RFC 3339. Never the client's." } }, "type": "object", "required": [ - "album_id" + "asset_id", + "durable", + "blobs", + "checked_at" ], - "description": "The provisioning request." + "description": "One asset's verdict." }, - "ProvisionAlbumResponse": { + "StorageVerifyResponse": { "properties": { - "album_id": { - "type": "string", - "description": "The album, echoed." - }, - "protocol_version": { - "type": "string", - "description": "The protocol date the album is pinned to — the server's, fixed at creation." - }, - "created": { - "type": "boolean", - "description": "Whether this call created the album. Advisory; both answers mean the same thing." + "verdicts": { + "items": { + "$ref": "#/components/schemas/StorageVerdictResponse" + }, + "type": "array", + "description": "One verdict per requested asset, in request order." } }, "type": "object", "required": [ - "album_id", - "protocol_version", - "created" + "verdicts" ], - "description": "What provisioning did." + "description": "The `POST /v1/storage/verify` response." }, - "UpgradePhaseResponse": { + "IssueShareRequest": { "properties": { - "album_id": { + "opaque_id": { "type": "string", - "description": "The album, echoed." + "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." }, - "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." + "metadata_hash": { + "type": "string", + "description": "The metadata blob a viewer starts from. Must appear in `serves`." }, - "to_protocol_version": { + "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 protocol version the fork will be pinned to, when a ceremony is in flight." + "description": "The passphrase-wrapped scope material, base64, when the link is passphrase-protected.\n\nOpaque to this server. The passphrase never crosses the wire — unwrap is client-side." }, "expires_at": { "type": [ "string", "null" ], - "description": "When the window closes, RFC 3339, on the **server's** clock." - }, - "in_flight": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "How many upload sessions are still in flight against this album.\n\nThe drain signal of versioning.md step 3: the proposer waits for zero. A count rather than\na listing, because the proposer needs to know *whether* to wait and has no business seeing\nother members' upload identifiers to find out." + "description": "When the link stops being live, RFC 3339. Absent means no expiry." } }, "type": "object", "required": [ - "album_id", - "in_flight" + "opaque_id", + "metadata_hash", + "serves" ], - "description": "The ceremony this album is in, as a client polls it." + "description": "A link the owner's client has issued." }, - "QuotaResponse": { + "IssueShareResponse": { "properties": { - "used": { + "opaque_id": { + "type": "string", + "description": "The opaque id, echoed." + } + }, + "type": "object", + "required": [ + "opaque_id" + ], + "description": "Confirmation that a link is now servable." + }, + "ProvisionLinkRequest": { + "properties": { + "opaque_id": { + "type": "string", + "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." + }, + "drop_pubkey": { + "type": "string", + "description": "The Drop Key's public half, base64. Opaque here — the server never decapsulates." + }, + "crypto_suite_id": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "Bytes charged to the caller." + "format": "uint16", + "description": "The suite a drop must be sealed under." }, - "soft_limit": { + "expires_at": { + "type": [ + "string", + "null" + ], + "description": "When the link stops accepting drops, RFC 3339." + }, + "max_total_bytes": { "type": [ "integer", "null" ], "minimum": 0.0, "format": "uint64", - "description": "Where the warning starts, or absent on an unlimited deployment." + "description": "Cumulative bytes across every drop on this link." }, - "hard_limit": { + "max_file_count": { "type": [ "integer", "null" ], + "maximum": 4294967295.0, "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`." - } - }, - "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`." + "format": "uint32", + "description": "How many files the link may deposit." }, - "asset_id": { + "max_file_size": { "type": [ - "string", + "integer", "null" ], - "description": "The asset, when the action was about one rather than about the account." + "minimum": 0.0, + "format": "uint64", + "description": "The largest single file." }, - "at": { - "type": "string", - "description": "When it happened, RFC 3339." + "single_use": { + "type": "boolean", + "description": "Whether the link dies after its first successful drop." }, - "reason": { + "passphrase_verifier": { "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." + "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": [ - "action", - "at" + "opaque_id", + "drop_pubkey", + "crypto_suite_id" ], - "description": "One thing that was done to the account." + "description": "A link the owner is provisioning." }, - "ModerationRecordResponse": { + "ProvisionLinkResponse": { "properties": { - "standing": { + "opaque_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." - }, - "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." + "description": "The opaque id, echoed." } }, "type": "object", "required": [ - "standing", - "events" + "opaque_id" ], - "description": "The caller's moderation record." + "description": "Confirmation that a link is live." }, - "PublishedKeyResponse": { + "AdoptRequest": { "properties": { - "key_id": { + "album_id": { "type": "string", - "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." + "description": "The album to adopt into." }, - "public": { + "asset_id": { "type": "string", - "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." + "description": "The asset the drop becomes." }, - "algorithm": { - "type": "string", - "description": "The signature algorithm this key is used with." + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "The blob's declared size — the inbox row's, restated and checked against it." }, - "active_from": { + "hash": { "type": "string", - "description": "When it began signing, RFC 3339." + "description": "The ciphertext hash, which must name **this drop's** blob." }, - "active_to": { - "type": [ - "string", - "null" - ], - "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": { + "content_type": { "type": "string", - "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." + "description": "The declared content type." }, - "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": [ - "server_id", - "keys" - ], - "description": "The `.well-known/capsule/attestation-keys` record." - }, - "AuthEndpointsResponse": { - "properties": { - "login": { - "type": "string", - "description": "Where a session is opened." + "crypto_suite_id": { + "type": "integer", + "maximum": 65535.0, + "minimum": 0.0, + "format": "uint16", + "description": "The crypto suite." }, - "refresh": { + "protocol_version": { "type": "string", - "description": "Where an access token is rotated." + "description": "The protocol version the manifest is written against." }, - "logout": { + "key_mode": { "type": "string", - "description": "Where a session is ended." + "description": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." + }, + "manifest_envelope": { + "$ref": "#/components/schemas/ManifestEnvelope", + "description": "The signed manifest envelope, verbatim." } }, "type": "object", "required": [ - "login", - "refresh", - "logout" + "album_id", + "asset_id", + "size", + "hash", + "content_type", + "crypto_suite_id", + "protocol_version", + "key_mode", + "manifest_envelope" ], - "description": "The auth ceremony's endpoints." + "description": "The owner's signed `create` over a drop already in their inbox.\n\nThe same shape a `POST /v1/upload` create carries, minus everything about transferring bytes:\nthe blob is already committed, so there is no size to negotiate and no session to open. What\nremains is the manifest, which is the whole point — a drop becomes an asset only when the\n**owner** signs for it." }, - "ProtocolWindowResponse": { + "AdoptResponse": { "properties": { - "min": { - "type": "string", - "description": "The oldest version still accepted for writes." - }, - "max": { + "asset_id": { "type": "string", - "description": "The newest version this server speaks." + "description": "The asset the drop became." } }, "type": "object", "required": [ - "min", - "max" + "asset_id" ], - "description": "The accepted `protocol_version` range." + "description": "What adoption produced." }, - "DeprecationResponse": { + "SessionView": { "properties": { - "min_protocol_version": { + "session_id": { "type": "string", - "description": "The lowest `protocol_version` that remains accepted after the cutoff." + "description": "The session's identifier — what a revoke names." }, - "announced_at": { + "created_at": { "type": "string", - "description": "When the announcement was first published, RFC 3339." + "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\"." }, - "cutoff": { + "authenticated_at": { "type": "string", - "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." + "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." }, - "detail_url": { + "last_active_at": { + "type": "string", + "description": "When it was last seen, RFC 3339.\n\nEqual to `created_at` until `S-C48` puts the session ledger on the request path. A\nclient must not label this \"last used\" before then." + }, + "user_agent": { "type": [ "string", "null" ], - "description": "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": "The `User-Agent` the opening ceremony carried, if any." }, - "federation_url": { + "ip_address": { "type": [ "string", "null" ], - "description": "Where federated peers talk to this server. Absent when it does not federate." - }, - "protocol_version": { - "$ref": "#/components/schemas/ProtocolWindowResponse", - "description": "The `protocol_version` range accepted for writes today, both ends inclusive." + "description": "The address the opening ceremony came from, if any." }, - "signing_key": { - "type": "string", - "description": "The raw Ed25519 public key this server's tokens verify under, base64." + "cohort_hash": { + "type": [ + "string", + "null" + ], + "description": "The advisory cohort this session asserted, if any. Grouping only." }, - "signing_algorithm": { - "type": "string", - "description": "The signature algorithm that key is used with." + "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." }, - "deprecations": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Announced deprecation cutoffs, in announcement order. Empty when none is pending." - } - }, - "type": "object", - "required": [ - "server_id", - "api_base_url", - "auth", - "protocol_version", - "signing_key", - "signing_algorithm", - "deprecations" - ], - "description": "The `.well-known/capsule/server-info` record.\n\nServer-scoped facts only. The registry's rule — *never a user list* — is structural here:\nthis type holds no user-shaped field, so there is nothing for a future edit to leak through." - }, - "DeprecationsResponse": { - "properties": { - "announcements": { - "items": { - "$ref": "#/components/schemas/DeprecationResponse" - }, - "type": "array", - "description": "Every announced cutoff, in announcement order." + "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": [ - "announcements" + "session_id", + "created_at", + "authenticated_at", + "last_active_at", + "current" ], - "description": "The `.well-known/capsule/deprecation` record." + "description": "One live session." }, - "RevokedTokenResponse": { + "CohortView": { "properties": { - "jti": { + "cohort_hash": { "type": "string", - "description": "The token's `jti` claim." + "description": "The advisory hash." }, - "expires_at": { + "first_seen": { "type": "string", - "description": "The token's own `exp`, RFC 3339. After this the entry is pruned." + "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": { + "type": "string", + "description": "The most recent time, RFC 3339." } }, "type": "object", "required": [ - "jti", - "expires_at" + "cohort_hash", + "first_seen", + "last_seen" ], - "description": "One revoked capability token." + "description": "One cohort this account has been seen under." }, - "RevokedJtiResponse": { + "DevicesResponse": { "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." + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionView" + }, + "type": "array", + "description": "Every live session, oldest first." }, - "revoked": { + "cohorts": { "items": { - "$ref": "#/components/schemas/RevokedTokenResponse" + "$ref": "#/components/schemas/CohortView" }, "type": "array", - "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." + "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": [ - "generated_at", - "max_staleness_seconds", - "revoked" - ], - "description": "The `.well-known/capsule/revoked-jti` record." - }, - "WireBlobRole": { - "type": "string", - "enum": [ - "original", - "derivative", - "metadata", - "provenance", - "backup" + "sessions", + "cohorts" ], - "description": "A blob's role in its asset bundle, as the wire spells it.\n\nA wire type of its own rather than a serde derive on [`BlobRole`]: the state ports'\nrecords deliberately derive no serde traits, so that a record cannot be smuggled through a\nstore built for another. The mapping is one `match` in one direction." + "description": "The session ledger." }, - "ManifestEnvelope": { + "DrainResponse": { "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": { + "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", "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`." + "description": "Bytes charged to the caller." }, - "metadata_blob_hash": { + "soft_limit": { "type": [ - "string", + "integer", "null" ], - "description": "The content hash of the bundle's metadata blob, when the manifest commits to one." + "minimum": 0.0, + "format": "uint64", + "description": "Where the warning starts, or absent on an unlimited deployment." }, - "original_blob_hash": { + "hard_limit": { "type": [ - "string", + "integer", "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." + "minimum": 0.0, + "format": "uint64", + "description": "Where uploads stop, or absent on an unlimited deployment." }, - "action": { + "state": { "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." + "description": "The classified state: `ok`, `soft_warning`, `hard_exceeded`, `grace_expired`." } }, "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" + "used", + "state" ], - "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": "A user's quota snapshot." }, - "CreateUploadRequest": { + "ModerationEventResponse": { "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": { + "action": { "type": "string", - "description": "The protocol date (`YYYY-MM-DD`) this session is pinned to." - }, - "blob_role": { - "$ref": "#/components/schemas/WireBlobRole", - "description": "The blob's role in its bundle." - }, - "manifest_envelope": { - "$ref": "#/components/schemas/ManifestEnvelope", - "description": "The unencrypted manifest fields the server validates." + "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": { @@ -7056,84 +21298,29 @@ "id", "asset_id", "blob_role", - "status", - "received_bytes", - "total_size", - "created_at", - "last_progress_at" - ], - "description": "One in-flight upload, as the resumption listing serves it.\n\nDeliberately not the whole [`UploadSessionRecord`]. The manifest envelope, the expected hash\nand the crypto pin are finalization's inputs and are already the client's own — echoing them\nto every listing would put a signed document in a response nobody reads it from." - }, - "SessionsResponse": { - "properties": { - "sessions": { - "items": { - "$ref": "#/components/schemas/SessionSummary" - }, - "type": "array", - "description": "Every session the caller can resume, oldest first, ties broken by upload id." - } - }, - "type": "object", - "required": [ - "sessions" - ], - "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." + "status", + "received_bytes", + "total_size", + "created_at", + "last_progress_at" + ], + "description": "One in-flight upload, as the resumption listing serves it.\n\nDeliberately not the whole [`UploadSessionRecord`]. The manifest envelope, the expected hash\nand the crypto pin are finalization's inputs and are already the client's own — echoing them\nto every listing would put a signed document in a response nobody reads it from." + }, + "SessionsResponse": { + "properties": { + "sessions": { + "items": { + "$ref": "#/components/schemas/SessionSummary" + }, + "type": "array", + "description": "Every session the caller can resume, oldest first, ties broken by upload id." } }, "type": "object", "required": [ - "asset_id", - "sync_seq", - "action", - "replayed" + "sessions" ], - "description": "What a lifecycle write did." + "description": "The listing." }, "WireChangeKind": { "type": "string", @@ -7262,381 +21449,449 @@ ], "description": "A page of the feed." }, - "AssetVerifyRequest": { + "AssetReceipt": { "properties": { - "asset_id": { + "receipt_seq": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "Strictly monotonic per server. The chain position this receipt cannot be moved from." + }, + "server_id": { "type": "string", - "description": "The asset." + "description": "This server's canonical origin — what binds the receipt to one server." }, - "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." + "server_key_id": { + "type": "string", + "description": "The attestation key fingerprint that signed, hex. Survives rotation, which is why the\nkey is named rather than assumed." + }, + "prior_receipt_hash": { + "type": [ + "string", + "null" + ], + "description": "SHA-256 of the previous receipt in the server's log, hex. Absent for the first receipt\nthis server ever issued." + }, + "upload_id": { + "type": "string", + "description": "The upload session that produced custody." + }, + "blob_role": { + "type": "string", + "description": "`original`, `derivative`, `metadata` or `provenance`." + }, + "ciphertext_hash": { + "type": "string", + "description": "The server-recomputed ciphertext content address, hex." + }, + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "Ciphertext size in bytes." + }, + "envelope_hash": { + "type": [ + "string", + "null" + ], + "description": "SHA-256 of the asset's signed manifest, hex — present on the `provenance` receipt and\nabsent on every other, because the manifest commits to the rest." + }, + "received_at": { + "type": "string", + "description": "The server's trusted clock at the finalization commit, RFC 3339." + }, + "receipt_cbor": { + "type": "string", + "description": "The full signed receipt as canonical CBOR, base64.\n\n**This is the receipt.** Verify the hybrid signature over these bytes under the key\n`server_key_id` names, from `/.well-known/capsule/attestation-keys`; everything above is\na reading of them." } }, "type": "object", "required": [ - "asset_id", - "blob_hashes" + "receipt_seq", + "server_id", + "server_key_id", + "upload_id", + "blob_role", + "ciphertext_hash", + "size", + "received_at", + "receipt_cbor" ], - "description": "One asset to verify, with the exact copies the client is relying on." + "description": "One custody receipt, decoded, beside the bytes that were signed." }, - "StorageVerifyRequest": { + "AssetReceiptsResponse": { "properties": { - "assets": { + "asset_id": { + "type": "string", + "description": "The asset the chain belongs to, echoed so a client batching requests can tell the\nanswers apart." + }, + "receipts": { "items": { - "$ref": "#/components/schemas/AssetVerifyRequest" + "$ref": "#/components/schemas/AssetReceipt" }, "type": "array", - "description": "The assets to verify." - }, - "deep": { - "type": "boolean", - "description": "Also re-read and re-hash the bytes (`S-C41`).\n\nAbsent or `false` is the structural check: ask the index and the store whether the bytes\nare there. `true` additionally re-hashes them, which is the only way to catch silent\ncorruption — `stored` is a question about the filesystem, and a corrupt blob is still\nstored.\n\n**Rate-limited per account**, because a deep scan reads and hashes every declared blob\nand an unbounded one is an I/O-amplification attack costing the caller one small JSON\nbody. Past the budget the *structural* verdict still comes back and each blob's `deep`\nreads `rate_limited`: throwing away a good structural answer because the optional half\nwas throttled would make the limiter cost more than it saves." + "description": "Every receipt covering the asset, in `receipt_seq` order." } }, "type": "object", "required": [ - "assets" + "asset_id", + "receipts" ], - "description": "The `POST /v1/storage/verify` body." + "description": "The chain." }, - "BlobVerdictResponse": { + "InboxEntryResponse": { "properties": { - "hash": { + "drop_id": { "type": "string", - "description": "The address, as the client declared it." + "description": "The drop's identifier, which adoption and discard name." }, - "role": { + "opaque_id": { "type": "string", - "description": "The role the asset holds it under — `unknown` for a hash the asset does not hold." + "description": "The link it arrived through." }, - "stored": { - "type": "boolean", - "description": "The bytes are present at that address." + "ciphertext_hash": { + "type": "string", + "description": "The ciphertext's content address." }, - "indexed": { - "type": "boolean", - "description": "A live asset of the caller's references the address." + "size": { + "type": "integer", + "minimum": 0.0, + "format": "uint64", + "description": "How many bytes." }, - "retrievable": { - "type": "boolean", - "description": "Nothing is withholding it." + "content_type": { + "type": "string", + "description": "The guest's declared content type." }, - "deep": { + "kem_ct": { + "type": "string", + "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + }, + "suggested_filename": { "type": [ "string", "null" ], - "description": "What a deep scan found: `intact`, `corrupt`, or `rate_limited` (`S-C41`).\n\n**Absent when no deep scan ran**, and the absence is load-bearing: it is the difference\nbetween \"we did not look at the bytes\" and \"we looked and they were fine\", and a client\ndeciding whether to release its only copy has to be able to tell those apart." + "description": "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": [ - "hash", - "role", - "stored", - "indexed", - "retrievable" + "drop_id", + "opaque_id", + "ciphertext_hash", + "size", + "content_type", + "kem_ct", + "received_at", + "adopting" ], - "description": "One declared blob's verdict." + "description": "One drop waiting for the owner." }, - "StorageVerdictResponse": { + "InboxResponse": { "properties": { - "asset_id": { - "type": "string", - "description": "The asset the client asked about." - }, - "durable": { - "type": "boolean", - "description": "Every declared blob is stored ∧ indexed ∧ retrievable. **This is the field that gates a\ndeletion**, so it is false whenever the server cannot say otherwise." - }, - "blobs": { + "drops": { "items": { - "$ref": "#/components/schemas/BlobVerdictResponse" + "$ref": "#/components/schemas/InboxEntryResponse" }, "type": "array", - "description": "One entry per declared hash, in declaration order and never shortened." - }, - "checked_at": { - "type": "string", - "description": "The server's own clock at verification, RFC 3339. Never the client's." + "description": "Everything waiting, oldest first." } }, "type": "object", "required": [ - "asset_id", - "durable", - "blobs", - "checked_at" + "drops" ], - "description": "One asset's verdict." + "description": "The owner's pending drops." }, - "StorageVerifyResponse": { + "VersionResponse": { "properties": { - "verdicts": { - "items": { - "$ref": "#/components/schemas/StorageVerdictResponse" - }, - "type": "array", - "description": "One verdict per requested asset, in request order." + "name": { + "type": "string", + "description": "The server package name." + }, + "version": { + "type": "string", + "description": "The server package version." } }, "type": "object", "required": [ - "verdicts" + "name", + "version" ], - "description": "The `POST /v1/storage/verify` response." + "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." }, - "AssetReceipt": { + "PublishedKeyResponse": { "properties": { - "receipt_seq": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Strictly monotonic per server. The chain position this receipt cannot be moved from." - }, - "server_id": { - "type": "string", - "description": "This server's canonical origin — what binds the receipt to one server." - }, - "server_key_id": { - "type": "string", - "description": "The attestation key fingerprint that signed, hex. Survives rotation, which is why the\nkey is named rather than assumed." - }, - "prior_receipt_hash": { - "type": [ - "string", - "null" - ], - "description": "SHA-256 of the previous receipt in the server's log, hex. Absent for the first receipt\nthis server ever issued." - }, - "upload_id": { + "key_id": { "type": "string", - "description": "The upload session that produced custody." + "description": "The fingerprint a receipt's `server_key_id` selects on, lowercase hex." }, - "blob_role": { + "public": { "type": "string", - "description": "`original`, `derivative`, `metadata` or `provenance`." + "description": "The hybrid public key, base64 (Ed25519 ‖ ML-DSA-65)." }, - "ciphertext_hash": { + "algorithm": { "type": "string", - "description": "The server-recomputed ciphertext content address, hex." + "description": "The signature algorithm this key is used with." }, - "size": { - "type": "integer", - "minimum": 0.0, - "format": "uint64", - "description": "Ciphertext size in bytes." + "active_from": { + "type": "string", + "description": "When it began signing, RFC 3339." }, - "envelope_hash": { + "active_to": { "type": [ "string", "null" ], - "description": "SHA-256 of the asset's signed manifest, hex — present on the `provenance` receipt and\nabsent on every other, because the manifest commits to the rest." - }, - "received_at": { - "type": "string", - "description": "The server's trusted clock at the finalization commit, RFC 3339." - }, - "receipt_cbor": { - "type": "string", - "description": "The full signed receipt as canonical CBOR, base64.\n\n**This is the receipt.** Verify the hybrid signature over these bytes under the key\n`server_key_id` names, from `/.well-known/capsule/attestation-keys`; everything above is\na reading of them." + "description": "When it stopped, or absent while it is the active key." } }, "type": "object", "required": [ - "receipt_seq", - "server_id", - "server_key_id", - "upload_id", - "blob_role", - "ciphertext_hash", - "size", - "received_at", - "receipt_cbor" + "key_id", + "public", + "algorithm", + "active_from" ], - "description": "One custody receipt, decoded, beside the bytes that were signed." + "description": "One published attestation key." }, - "AssetReceiptsResponse": { + "AttestationKeysResponse": { "properties": { - "asset_id": { + "server_id": { "type": "string", - "description": "The asset the chain belongs to, echoed so a client batching requests can tell the\nanswers apart." + "description": "This server's canonical origin — the other half of the binding that refuses a\ncross-server replay." }, - "receipts": { + "keys": { "items": { - "$ref": "#/components/schemas/AssetReceipt" + "$ref": "#/components/schemas/PublishedKeyResponse" }, "type": "array", - "description": "Every receipt covering the asset, in `receipt_seq` order." + "description": "Every key this server has signed with, oldest first, the active one last." } }, "type": "object", "required": [ - "asset_id", - "receipts" + "server_id", + "keys" ], - "description": "The chain." + "description": "The `.well-known/capsule/attestation-keys` record." }, - "IssueShareRequest": { + "AuthEndpointsResponse": { "properties": { - "opaque_id": { + "login": { "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": "Where a session is opened." }, - "metadata_hash": { + "refresh": { "type": "string", - "description": "The metadata blob a viewer starts from. Must appear in `serves`." - }, - "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." + "description": "Where an access token is rotated." }, - "expires_at": { - "type": [ - "string", - "null" - ], - "description": "When the link stops being live, RFC 3339. Absent means no expiry." + "logout": { + "type": "string", + "description": "Where a session is ended." } }, "type": "object", "required": [ - "opaque_id", - "metadata_hash", - "serves" + "login", + "refresh", + "logout" ], - "description": "A link the owner's client has issued." + "description": "The auth ceremony's endpoints." }, - "IssueShareResponse": { + "ProtocolWindowResponse": { "properties": { - "opaque_id": { + "min": { "type": "string", - "description": "The opaque id, echoed." + "description": "The oldest version still accepted for writes." + }, + "max": { + "type": "string", + "description": "The newest version this server speaks." } }, "type": "object", "required": [ - "opaque_id" + "min", + "max" ], - "description": "Confirmation that a link is now servable." + "description": "The accepted `protocol_version` range." }, - "SharedMetadataResponse": { + "DeprecationResponse": { "properties": { - "metadata_hash": { + "min_protocol_version": { "type": "string", - "description": "The metadata blob's content address; fetch it from `/s/{opaque_id}/blob/{hash}`." + "description": "The lowest `protocol_version` that remains accepted after the cutoff." }, - "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." + "announced_at": { + "type": "string", + "description": "When the announcement was first published, RFC 3339." + }, + "cutoff": { + "type": "string", + "description": "When versions below `min_protocol_version` stop being accepted, RFC 3339." + }, + "detail_url": { + "type": [ + "string", + "null" + ], + "description": "Where a human reads what to do about it." } }, "type": "object", "required": [ - "metadata_hash", - "passphrase_protected" + "min_protocol_version", + "announced_at", + "cutoff" ], - "description": "What a viewer needs to start." + "description": "One announced deprecation cutoff." }, - "ProvisionLinkRequest": { + "ServerInfoResponse": { "properties": { - "opaque_id": { + "server_id": { "type": "string", - "description": "The 128-bit opaque id, 32 lowercase hex characters, from the client's CSPRNG." + "description": "This server's canonical origin." }, - "drop_pubkey": { + "api_base_url": { "type": "string", - "description": "The Drop Key's public half, base64. Opaque here — the server never decapsulates." + "description": "Where the versioned API lives." }, - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The suite a drop must be sealed under." + "auth": { + "$ref": "#/components/schemas/AuthEndpointsResponse", + "description": "Where a client performs the auth ceremony." }, - "expires_at": { + "federation_url": { "type": [ "string", "null" ], - "description": "When the link stops accepting drops, RFC 3339." + "description": "Where federated peers talk to this server. Absent when it does not federate." }, - "max_total_bytes": { - "type": [ - "integer", - "null" - ], - "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." + }, + "deprecations": { + "items": { + "$ref": "#/components/schemas/DeprecationResponse" + }, + "type": "array", + "description": "Announced deprecation cutoffs, in announcement order. Empty when none is pending." + } + }, + "type": "object", + "required": [ + "server_id", + "api_base_url", + "auth", + "protocol_version", + "signing_key", + "signing_algorithm", + "deprecations" + ], + "description": "The `.well-known/capsule/server-info` record.\n\nServer-scoped facts only. The registry's rule — *never a user list* — is structural here:\nthis type holds no user-shaped field, so there is nothing for a future edit to leak through." + }, + "DeprecationsResponse": { + "properties": { + "announcements": { + "items": { + "$ref": "#/components/schemas/DeprecationResponse" + }, + "type": "array", + "description": "Every announced cutoff, in announcement order." + } + }, + "type": "object", + "required": [ + "announcements" + ], + "description": "The `.well-known/capsule/deprecation` record." + }, + "RevokedTokenResponse": { + "properties": { + "jti": { + "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." }, - "single_use": { - "type": "boolean", - "description": "Whether the link dies after its first successful drop." + "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." }, - "passphrase_verifier": { - "type": [ - "string", - "null" - ], - "description": "An Argon2id **verifier**, base64, when the link is passphrase-gated.\n\nA verifier and never a passphrase: this is an abuse gate the server checks, which is why\nit is stored here at all — unlike a share link's passphrase, which protects decryption\nand which the server never sees in any form." + "revoked": { + "items": { + "$ref": "#/components/schemas/RevokedTokenResponse" + }, + "type": "array", + "description": "Every revoked `jti` not yet past its own expiry, soonest expiry first." } }, "type": "object", "required": [ - "opaque_id", - "drop_pubkey", - "crypto_suite_id" + "generated_at", + "max_staleness_seconds", + "revoked" ], - "description": "A link the owner is provisioning." + "description": "The `.well-known/capsule/revoked-jti` record." }, - "ProvisionLinkResponse": { + "SharedMetadataResponse": { "properties": { - "opaque_id": { + "metadata_hash": { "type": "string", - "description": "The opaque id, echoed." + "description": "The metadata blob's content address; fetch it from `/s/{opaque_id}/blob/{hash}`." + }, + "passphrase_protected": { + "type": "boolean", + "description": "Whether a passphrase is required before the scope material can be opened.\n\nThe one property of the link this path discloses, and it has to: a viewer cannot know to\nask for a passphrase otherwise. It says nothing about *what* the link points at." } }, "type": "object", "required": [ - "opaque_id" + "metadata_hash", + "passphrase_protected" ], - "description": "Confirmation that a link is live." + "description": "What a viewer needs to start." }, "CreateDropRequest": { "properties": { @@ -7702,151 +21957,85 @@ ], "description": "The session a guest uploads into." }, - "InboxEntryResponse": { + "CodedProblem": { "properties": { - "drop_id": { - "type": "string", - "description": "The drop's identifier, which adoption and discard name." - }, - "opaque_id": { - "type": "string", - "description": "The link it arrived through." + "type": { + "type": "string" }, - "ciphertext_hash": { - "type": "string", - "description": "The ciphertext's content address." + "title": { + "type": "string" }, - "size": { + "status": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "How many bytes." - }, - "content_type": { - "type": "string", - "description": "The guest's declared content type." + "format": "uint16" }, - "kem_ct": { - "type": "string", - "description": "`K` encapsulated to the link's Drop Key, base64. The owner decapsulates." + "detail": { + "type": "string" }, - "suggested_filename": { - "type": [ - "string", - "null" - ], - "description": "Guest-supplied and **unverified**.\n\nA guest chose this text. A client rendering it treats it as untrusted input — it is the\none field on this surface an anonymous party authored." + "instance": { + "type": "string" }, - "received_at": { + "code": { "type": "string", - "description": "When it landed, RFC 3339." - }, - "adopting": { - "type": "boolean", - "description": "Whether an adoption currently holds this row.\n\nSurfaced rather than hidden: a crash between claim and settle leaves a row here, and an\nowner who cannot see it cannot act on it." - } - }, - "type": "object", - "required": [ - "drop_id", - "opaque_id", - "ciphertext_hash", - "size", - "content_type", - "kem_ct", - "received_at", - "adopting" - ], - "description": "One drop waiting for the owner." - }, - "InboxResponse": { - "properties": { - "drops": { - "items": { - "$ref": "#/components/schemas/InboxEntryResponse" - }, - "type": "array", - "description": "Everything waiting, oldest first." + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." } }, + "additionalProperties": true, "type": "object", "required": [ - "drops" + "type", + "status", + "code" ], - "description": "The owner's pending drops." + "title": "CodedProblem", + "description": "An RFC 9457 problem detail." }, - "AdoptRequest": { + "ProtocolRangeProblem": { "properties": { - "album_id": { - "type": "string", - "description": "The album to adopt into." + "type": { + "type": "string" }, - "asset_id": { - "type": "string", - "description": "The asset the drop becomes." + "title": { + "type": "string" }, - "size": { + "status": { "type": "integer", + "maximum": 65535.0, "minimum": 0.0, - "format": "uint64", - "description": "The blob's declared size — the inbox row's, restated and checked against it." - }, - "hash": { - "type": "string", - "description": "The ciphertext hash, which must name **this drop's** blob." + "format": "uint16" }, - "content_type": { - "type": "string", - "description": "The declared content type." + "detail": { + "type": "string" }, - "crypto_suite_id": { - "type": "integer", - "maximum": 65535.0, - "minimum": 0.0, - "format": "uint16", - "description": "The crypto suite." + "instance": { + "type": "string" }, - "protocol_version": { + "code": { "type": "string", - "description": "The protocol version the manifest is written against." + "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "key_mode": { + "protocol_min": { "type": "string", - "description": "How the asset's key is carried. `derived` or `wrapped` (invariant 32)." + "description": "The oldest protocol date this server still speaks (`YYYY-MM-DD`)." }, - "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": { + "protocol_max": { "type": "string", - "description": "The asset the drop became." + "description": "The newest protocol date this server speaks (`YYYY-MM-DD`)." } }, + "additionalProperties": true, "type": "object", "required": [ - "asset_id" + "type", + "status", + "code" ], - "description": "What adoption produced." + "title": "ProtocolRangeProblem", + "description": "An RFC 9457 problem detail." }, - "CodedProblem": { + "DuplicateBlobProblem": { "properties": { "type": { "type": "string" @@ -7869,6 +22058,10 @@ "code": { "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." + }, + "existing_asset": { + "type": "string", + "description": "The asset already holding these exact bytes in the same album. Structured so a client merges rather than re-parsing a sentence (slice `S-C22`)." } }, "additionalProperties": true, @@ -7878,10 +22071,10 @@ "status", "code" ], - "title": "CodedProblem", + "title": "DuplicateBlobProblem", "description": "An RFC 9457 problem detail." }, - "ProtocolRangeProblem": { + "OffsetMismatchProblem": { "properties": { "type": { "type": "string" @@ -7905,13 +22098,9 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "protocol_min": { - "type": "string", - "description": "The oldest protocol date this server still speaks (`YYYY-MM-DD`)." - }, - "protocol_max": { - "type": "string", - "description": "The newest protocol date this server speaks (`YYYY-MM-DD`)." + "offset": { + "type": "integer", + "description": "The offset the server is actually at, so a client resumes from it instead of asking again." } }, "additionalProperties": true, @@ -7921,10 +22110,10 @@ "status", "code" ], - "title": "ProtocolRangeProblem", + "title": "OffsetMismatchProblem", "description": "An RFC 9457 problem detail." }, - "DuplicateBlobProblem": { + "DirectoryConflictProblem": { "properties": { "type": { "type": "string" @@ -7948,9 +22137,13 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "existing_asset": { - "type": "string", - "description": "The asset already holding these exact bytes in the same album. Structured so a client merges rather than re-parsing a sentence (slice `S-C22`)." + "submitted": { + "type": "integer", + "description": "The directory version the request carried." + }, + "stored": { + "type": "integer", + "description": "The version the server holds. A client re-signs above this one." } }, "additionalProperties": true, @@ -7960,10 +22153,10 @@ "status", "code" ], - "title": "DuplicateBlobProblem", + "title": "DirectoryConflictProblem", "description": "An RFC 9457 problem detail." }, - "OffsetMismatchProblem": { + "RosterVersionLeapProblem": { "properties": { "type": { "type": "string" @@ -7987,9 +22180,15 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "offset": { + "current_version": { "type": "integer", - "description": "The offset the server is actually at, so a client resumes from it instead of asking again." + "format": "uint64", + "description": "The roster version the server holds; `0` when it holds none." + }, + "max_version": { + "type": "integer", + "format": "uint64", + "description": "The highest version this album would have accepted. A client re-signs the same roster at `current_version + 1`; a version nothing could supersede would freeze the album's membership." } }, "additionalProperties": true, @@ -7999,10 +22198,10 @@ "status", "code" ], - "title": "OffsetMismatchProblem", + "title": "RosterVersionLeapProblem", "description": "An RFC 9457 problem detail." }, - "DirectoryConflictProblem": { + "RosterStaleProblem": { "properties": { "type": { "type": "string" @@ -8026,13 +22225,10 @@ "type": "string", "description": "The stable `error.*` catalog code. The client localizes this; `detail` stays English. Present on every problem this server renders." }, - "submitted": { - "type": "integer", - "description": "The directory version the request carried." - }, - "stored": { + "current_version": { "type": "integer", - "description": "The version the server holds. A client re-signs above this one." + "format": "uint64", + "description": "The roster version the server holds. A client re-syncs and republishes above it." } }, "additionalProperties": true, @@ -8042,7 +22238,7 @@ "status", "code" ], - "title": "DirectoryConflictProblem", + "title": "RosterStaleProblem", "description": "An RFC 9457 problem detail." }, "StaleRevivalProblem": { diff --git a/capsule-server/src/album/authority.rs b/capsule-server/src/album/authority.rs index c8721d51..b31df3ff 100644 --- a/capsule-server/src/album/authority.rs +++ b/capsule-server/src/album/authority.rs @@ -8,10 +8,12 @@ //! # Invariant 6, from the album store (`S-C25`) //! //! An album is writable by the account it was provisioned to, pinned to the protocol the server -//! spoke when it was created. Sharing widens that set — an album writable by a *member* rather -//! than only its owner — and that is `S-C4`/`S-C5`'s to add. Until then an unprovisioned or -//! somebody-else's album is [`AlbumWriteAccess::Denied`], which is the safe direction: a write -//! that should have been allowed is refused, never the reverse. +//! spoke when it was created — and, since `S-C51`, by a **writer** on its current roster +//! ([`crate::membership`]). Either way the write is filed under the *owner's* namespace, which is +//! the one every member's devices read. A reader, a former member, a stranger and an +//! unprovisioned id are all [`AlbumWriteAccess::Denied`], one answer, which is the safe direction: +//! a write that should have been allowed is refused, never the reverse, and the refusal says +//! nothing about which of the four it was. //! //! # Invariant 7, from the published device directory (`S-C20`) //! @@ -40,14 +42,16 @@ use uuid::Uuid; use super::AlbumStore; use crate::directory::DeviceDirectoryStore; -use crate::store::{AlbumId, OwnerId, UserId}; -use crate::upload::{AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority}; +use crate::membership::{MemberRole, Membership, MembershipStore}; +use crate::store::{AlbumId, UserId}; +use crate::upload::{AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority, WriteRole}; /// The write authority the server runs on. #[derive(Debug, Clone)] pub struct ProvisionedAuthority { albums: Arc, directories: Arc, + members: Arc, clock: Arc, } @@ -60,11 +64,13 @@ impl ProvisionedAuthority { pub fn new( albums: Arc, directories: Arc, + members: Arc, clock: Arc, ) -> Self { Self { albums, directories, + members, clock, } } @@ -78,33 +84,74 @@ fn unavailable(error: &crate::store::StoreError) -> AuthorityError { AuthorityError::unavailable(error.to_string()) } +impl ProvisionedAuthority { + /// The capacity `caller`'s roster seat gives them on `album`, if any. + /// + /// Only a *writer* member writes; a reader, a former member and a stranger are one `None`, + /// which the caller renders as the same `Denied` an unprovisioned album gets. + async fn member_role( + &self, + album: &AlbumId, + caller: &UserId, + ) -> Result, AuthorityError> { + let membership = self + .members + .membership(album, caller) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer"); + unavailable(&error) + })?; + Ok(match membership { + Membership::Member { + role: MemberRole::Writer, + .. + } => Some(WriteRole::Member), + Membership::Member { .. } | Membership::Revoked(_) | Membership::Never => None, + }) + } +} + impl WriteAuthority for ProvisionedAuthority { fn album_write_access<'a>( &'a self, - owner: &'a OwnerId, + caller: &'a UserId, album: &'a AlbumId, ) -> AuthorityFuture<'a, AlbumWriteAccess> { Box::pin(async move { - let record = self.albums.read(album).await.map_err(|error| { + let Some(record) = self.albums.read(album).await.map_err(|error| { tracing::error!(%error, %album, "the album store could not answer"); unavailable(&error) - })?; + })? + else { + // Unprovisioned. One answer with every other refusal: the id is client-derived + // and unguessable, and distinguishing would say whether it is taken. + return Ok(AlbumWriteAccess::Denied); + }; + + let role = if record.owner_id.as_str() == caller.as_str() { + WriteRole::Owner + } else { + // Somebody else's album: the roster decides (`S-C51`). + match self.member_role(album, caller).await? { + Some(role) => role, + None => return Ok(AlbumWriteAccess::Denied), + } + }; + let now = self.clock.now(); - Ok(match record { - Some(record) if &record.owner_id == owner => AlbumWriteAccess::Writable { - // `S-C24`: an expired ceremony is reported as none, because the deadline - // passing *is* the abort. Nothing has to run to clear it, which is what stops - // a proposer who vanished from freezing an album forever. - quiescing_under: record - .upgrade - .as_ref() - .filter(|quiescence| !quiescence.is_expired(now)) - .map(|quiescence| quiescence.intent.intent_id), - protocol_pin: record.protocol_version, - }, - // Unprovisioned, or somebody else's. One answer: the id is client-derived and - // unguessable, and distinguishing the two would say whether it is taken. - _ => AlbumWriteAccess::Denied, + Ok(AlbumWriteAccess::Writable { + owner_id: record.owner_id, + role, + // `S-C24`: an expired ceremony is reported as none, because the deadline + // passing *is* the abort. Nothing has to run to clear it, which is what stops + // a proposer who vanished from freezing an album forever. + quiescing_under: record + .upgrade + .as_ref() + .filter(|quiescence| !quiescence.is_expired(now)) + .map(|quiescence| quiescence.intent.intent_id), + protocol_pin: record.protocol_version, }) }) } diff --git a/capsule-server/src/album/tests.rs b/capsule-server/src/album/tests.rs index 33b2c3c0..afc4b179 100644 --- a/capsule-server/src/album/tests.rs +++ b/capsule-server/src/album/tests.rs @@ -6,7 +6,7 @@ use super::authority::ProvisionedAuthority; use super::*; use crate::directory::{DeviceDirectoryStore, InMemoryDeviceDirectory, PublishedDirectory}; use crate::store::UserId; -use crate::upload::{AlbumWriteAccess, WriteAuthority}; +use crate::upload::{AlbumWriteAccess, WriteAuthority, WriteRole}; /// The account every case provisions under. fn owner() -> OwnerId { @@ -14,6 +14,11 @@ fn owner() -> OwnerId { } /// A derived album id. +/// The owner, as the account that calls. +fn caller() -> UserId { + UserId::new(owner().as_str()) +} + fn album() -> AlbumId { AlbumId::new("0198f3c2-9c4a-7b3d-8f21-4d7c9a1b2e35") } @@ -124,6 +129,7 @@ fn authority( ProvisionedAuthority::new( albums, directories, + Arc::new(crate::membership::InMemoryMembership::new()), std::sync::Arc::new(crate::store::SystemClock), ) } @@ -136,10 +142,12 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { assert_eq!( authority - .album_write_access(&owner(), &album()) + .album_write_access(&caller(), &album()) .await .expect("the authority answers"), AlbumWriteAccess::Writable { + owner_id: owner(), + role: WriteRole::Owner, quiescing_under: None, protocol_pin: "2026-01-01".to_owned() }, @@ -147,7 +155,7 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { ); assert_eq!( authority - .album_write_access(&OwnerId::new("somebody-else"), &album()) + .album_write_access(&UserId::new("somebody-else"), &album()) .await .expect("the authority answers"), AlbumWriteAccess::Denied, @@ -155,7 +163,7 @@ async fn an_album_is_writable_by_the_account_it_was_provisioned_to() { assert_eq!( authority .album_write_access( - &owner(), + &caller(), &AlbumId::new("0198f3c2-0000-7b3d-8f21-4d7c9a1b2e35") ) .await @@ -259,3 +267,83 @@ async fn an_account_with_no_published_directory_has_no_floor() { the accounts most likely to be wrong about their devices" ); } + +/// A writer on the roster writes under the owner's namespace; everyone else is one `Denied`. +/// +/// The widening `S-C25` deferred, answered from the membership port (`S-C51`). A reader, a former +/// member and a stranger get the same answer an unprovisioned album gets, so the refusal says +/// nothing about the roster. +#[tokio::test] +async fn a_writer_member_writes_under_the_owner_and_nobody_else_writes_at_all() { + use crate::membership::{InMemoryMembership, MemberRole, MembershipStore as _, RosterRecord}; + + let albums = Arc::new(InMemoryAlbums::new()); + albums.provision(record(&owner())).await.expect("provision"); + let members = Arc::new(InMemoryMembership::new()); + let writer = UserId::new("member-writer"); + let reader = UserId::new("member-reader"); + let former = UserId::new("member-former"); + let roster = |version: u64, epoch: u64| RosterRecord { + album_id: album(), + roster_version: version, + amk_epoch: epoch, + attested_by_device: uuid::Uuid::from_u128(0xD1), + received_at: jiff::Timestamp::UNIX_EPOCH, + document: format!("v{version}").into_bytes(), + }; + members + .apply_roster( + roster(1, 1), + vec![ + (writer.clone(), MemberRole::Writer), + (reader.clone(), MemberRole::Reader), + (former.clone(), MemberRole::Writer), + ], + ) + .await + .expect("applied"); + members + .apply_roster( + roster(2, 2), + vec![ + (writer.clone(), MemberRole::Writer), + (reader.clone(), MemberRole::Reader), + ], + ) + .await + .expect("applied"); + let authority = ProvisionedAuthority::new( + albums, + Arc::new(InMemoryDeviceDirectory::new()), + members, + std::sync::Arc::new(crate::store::SystemClock), + ); + + assert_eq!( + authority + .album_write_access(&writer, &album()) + .await + .expect("the authority answers"), + AlbumWriteAccess::Writable { + owner_id: owner(), + role: WriteRole::Member, + quiescing_under: None, + protocol_pin: "2026-01-01".to_owned() + }, + "a writer member is filed under the owner, with the album's own pin" + ); + for (who, why) in [ + (reader, "a reader may read and not write"), + (former, "a former member is a stranger to the write path"), + (UserId::new("stranger"), "an account never on the roster"), + ] { + assert_eq!( + authority + .album_write_access(&who, &album()) + .await + .expect("the authority answers"), + AlbumWriteAccess::Denied, + "{why}" + ); + } +} diff --git a/capsule-server/src/app.rs b/capsule-server/src/app.rs index ecc54486..dbc6af14 100644 --- a/capsule-server/src/app.rs +++ b/capsule-server/src/app.rs @@ -37,6 +37,7 @@ use crate::discovery::DiscoveryContext; use crate::drop::DropContext; use crate::enrollment::EnrollmentContext; use crate::escrow::EscrowContext; +use crate::membership::MembershipContext; use crate::moderation::ModerationContext; use crate::quota::QuotaContext; use crate::serve::ServeContext; @@ -73,6 +74,8 @@ pub struct App { directories: DeviceDirectoryContext, /// The album-provisioning module's collaborators. albums: AlbumContext, + /// The album-membership module's collaborators (`S-C51`). + membership: MembershipContext, /// The quota module's collaborators. quota: QuotaContext, /// The custody-receipt module's collaborators. @@ -118,6 +121,8 @@ pub struct Modules { pub directories: DeviceDirectoryContext, /// The album-provisioning module's collaborators. pub albums: AlbumContext, + /// The album-membership module's collaborators (`S-C51`). + pub membership: MembershipContext, /// The quota module's collaborators. pub quota: QuotaContext, /// The custody-receipt module's collaborators. @@ -150,6 +155,7 @@ impl App { verify, directories, albums, + membership, quota, attestation, discovery, @@ -169,6 +175,7 @@ impl App { verify, directories, albums, + membership, quota, attestation, discovery, diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index 677bcd61..a1825fa9 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -17,8 +17,9 @@ //! //! The [`Backends::Durable`] arm now does its **Postgres half** (#402): it demands //! `DATABASE_URL`, opens the pool, refuses to continue against a database whose schema is not -//! the one this binary was built for, and builds the four durable adapters — the asset index, -//! the account store, the device-cohort map and the quota ledger. Then it still refuses, because +//! the one this binary was built for, and builds the durable adapters — the asset index, the +//! account store, the device-cohort map, the quota ledger and the membership store. Then it +//! still refuses, because //! the Valkey half does not exist: `AuthStateStore`, `UploadSessionStore`, the three ceremony //! stores and the rate-limit counters are #403's, and five other durable ports are #446's. //! @@ -72,6 +73,7 @@ use crate::escrow::{EscrowContext, InMemoryEscrow}; use crate::gc::CollectionContext; use crate::gc::memory::InMemoryCollection; use crate::index::memory::InMemoryAssetIndex; +use crate::membership::{InMemoryMembership, MembershipContext}; use crate::moderation::{InMemoryModeration, ModerationContext}; use crate::quota::{InMemoryQuota, QuotaContext, QuotaLimits}; use crate::scrub::ScrubContext; @@ -241,7 +243,7 @@ pub async fn assemble(config: &Config) -> Result { // the arm still refuses, because the ports it cannot fill are the ones a server // loses state without. See the module docs. // - // The connection is dropped rather than handed on. Constructing the four adapters + // The connection is dropped rather than handed on. Constructing the adapters // and throwing them away would be theatre in production code; that they *do* // compose out of exactly what this function has is asserted in // `tests::postgres_conformance` instead, which is where an assertion belongs. @@ -443,6 +445,7 @@ fn memory(config: &Config, stores: Stores) -> Result { )); let albums = Arc::new(InMemoryAlbums::new()); + let members = Arc::new(InMemoryMembership::new()); let directories = Arc::new(InMemoryDeviceDirectory::new()); // The production write authority (`S-C19`/`S-C20`), not a permissive double: it reads the // album's own pin and the account's published device directory, so invariants 6 and 7 mean @@ -450,6 +453,7 @@ fn memory(config: &Config, stores: Stores) -> Result { let authority = Arc::new(ProvisionedAuthority::new( albums.clone(), directories.clone(), + members.clone(), clock.clone(), )); let receipts = Arc::new(InMemoryReceipts::new()); @@ -500,23 +504,31 @@ fn memory(config: &Config, stores: Stores) -> Result { 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(), blobs.clone(), Arc::new(CursorCodec::new(&cursor_key)), + albums.clone(), + members.clone(), ), serve: ServeContext::new( index.clone(), blobs.clone(), marks.clone(), uploads.clone(), - crate::serve::owned_assets(), + crate::serve::membership_reads(members.clone()), ), verify: VerifyContext::new(index.clone(), blobs.clone(), marks.clone(), clock.clone()), directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), albums: AlbumContext::new(albums.clone(), clock.clone()), + membership: MembershipContext::new(members, clock.clone()), // Unlimited, which is what a self-hosted deployment runs. A configurable ceiling is a // quota policy this slice does not own; `QuotaLimits` already takes one. quota: QuotaContext::new(quotas.clone(), clock.clone(), QuotaLimits::unlimited()), @@ -605,6 +617,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() @@ -619,6 +635,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() @@ -727,6 +747,7 @@ mod tests { }; use crate::auth::{Credentials, PostgresAccounts}; use crate::index::postgres::PostgresAssetIndex; + use crate::membership::PostgresMembership; use crate::postgres::testing; use crate::quota::PostgresQuota; use crate::store::{PostgresCohorts, SystemClock}; @@ -792,7 +813,7 @@ mod tests { ); } - /// The four Postgres adapters compose out of exactly what the boot path has. + /// The five Postgres adapters compose out of exactly what the boot path has. /// /// Asserted here rather than by constructing them in `assemble` and throwing them away: /// production code that builds something it cannot use is theatre, and what #403 needs @@ -823,7 +844,9 @@ mod tests { let cohorts: Arc = Arc::new(PostgresCohorts::new(connection.clone())); let quotas: Arc = - Arc::new(PostgresQuota::new(connection)); + Arc::new(PostgresQuota::new(connection.clone())); + let members: Arc = + Arc::new(PostgresMembership::new(connection)); // Each one answers through its port, which is what makes this a boot check rather // than a compile check: the schema the migration applied is the schema the adapters @@ -848,6 +871,14 @@ mod tests { quotas.usage(&user).await.expect("the ledger answers").used, 0 ); + let album = crate::store::AlbumId::new("boot-probe-album"); + assert_eq!( + members + .membership(&album, &user) + .await + .expect("the membership store answers"), + crate::membership::Membership::Never + ); } } @@ -908,6 +939,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 @@ -920,6 +1007,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", @@ -933,6 +1024,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", @@ -1023,6 +1118,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", @@ -1033,6 +1132,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..4d8a1fce 100644 --- a/capsule-server/src/config.rs +++ b/capsule-server/src/config.rs @@ -120,6 +120,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); @@ -293,10 +313,12 @@ 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. @@ -438,12 +460,31 @@ impl Config { }); // ── 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 +492,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,6 +622,7 @@ impl Config { attestation_key_seed, protocol_min, protocol_max, + min_client_build, grace_window, lockout_window, lockout_attempts, @@ -751,9 +806,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 diff --git a/capsule-server/src/index/conformance.rs b/capsule-server/src/index/conformance.rs index edbd1ebd..ff87b514 100644 --- a/capsule-server/src/index/conformance.rs +++ b/capsule-server/src/index/conformance.rs @@ -1646,6 +1646,150 @@ pub async fn the_row_walk_orders_by_the_identifiers_own_bytes(index: &dyn AssetI ); } +/// An album page is the owner's sequence filtered to one album, with its own head (`S-C51`). +/// +/// Positions are the owner's numbers, so a member's per-album anti-rewind mark is the same value +/// the owner's feed carries; gaps are the other albums' entries. The head is the album's last +/// entry, not the owner's allocator: a member who has seen it is caught up whatever the owner +/// minted elsewhere since. +pub async fn an_album_page_is_the_owners_sequence_filtered_to_one_album(index: &dyn AssetIndex) { + let owner = OwnerId::new("albumpage-owner"); + let shared = AlbumId::new("albumpage-shared"); + let private = AlbumId::new("albumpage-private"); + let mut in_shared = Vec::new(); + for (n, album) in [(1_u32, &shared), (2, &private), (3, &shared), (4, &private)] { + let row = PendingAsset { + asset_id: AssetId::new(format!("albumpage-asset-{n}")), + owner_id: owner.clone(), + album_id: album.clone(), + protocol_version: "2026-01-01".to_owned(), + crypto_suite_id: 1, + created_at: Timestamp::UNIX_EPOCH, + }; + let asset = row.asset_id.clone(); + ok(index.reserve(row).await, "reserve a row"); + record( + index, + &asset, + blob(BlobRole::Provenance, &format!("albumpage-p{n}")), + ) + .await; + let seq = record( + index, + &asset, + blob(BlobRole::Metadata, &format!("albumpage-m{n}")), + ) + .await + .expect("landing the index tier publishes"); + if album == &shared { + in_shared.push(seq); + } + } + + let page = ok( + index.album_feed_page(&owner, &shared, 0, 10).await, + "page an album", + ); + assert_eq!( + page.iter().map(|entry| entry.sync_seq).collect::>(), + in_shared, + "the album page is the owner's sequence, filtered, in order" + ); + assert!(page.iter().all(|entry| entry.album_id == shared)); + assert_eq!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ), + in_shared[1], + "the head is the album's last entry, not the owner's allocator" + ); + assert!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ) < ok(index.head_seq(&owner).await, "read the owner's head"), + "the owner minted more in another album since" + ); + + // Resuming past the first shared entry yields exactly the second. + let resumed = ok( + index + .album_feed_page(&owner, &shared, in_shared[0], 10) + .await, + "resume an album page", + ); + assert_eq!(resumed.len(), 1); + assert_eq!(resumed[0].sync_seq, in_shared[1]); + // And a bounded page is bounded. + assert_eq!( + ok( + index.album_feed_page(&owner, &shared, 0, 1).await, + "page one" + ) + .len(), + 1 + ); + + // A row another account filed under the *same* album id is not this album's: the page is + // bound to the owner the album record names, which is what the index is keyed on. + let squatter = OwnerId::new("albumpage-squatter"); + let row = PendingAsset { + asset_id: AssetId::new("albumpage-asset-squat"), + owner_id: squatter.clone(), + album_id: shared.clone(), + protocol_version: "2026-01-01".to_owned(), + crypto_suite_id: 1, + created_at: Timestamp::UNIX_EPOCH, + }; + ok(index.reserve(row).await, "reserve a row"); + record( + index, + &AssetId::new("albumpage-asset-squat"), + blob(BlobRole::Provenance, "albumpage-ps"), + ) + .await; + record( + index, + &AssetId::new("albumpage-asset-squat"), + blob(BlobRole::Metadata, "albumpage-ms"), + ) + .await; + assert_eq!( + ok( + index.album_feed_page(&owner, &shared, 0, 10).await, + "page an album" + ) + .len(), + 2, + "another owner's row under the same album id is not on this owner's album page" + ); + assert_eq!( + ok( + index.album_head_seq(&owner, &shared).await, + "read an album head" + ), + in_shared[1] + ); + + // An album nothing was filed into: empty, head zero — not an error. + let unknown = AlbumId::new("albumpage-unknown"); + assert!( + ok( + index.album_feed_page(&owner, &unknown, 0, 10).await, + "page an unknown album" + ) + .is_empty() + ); + assert_eq!( + ok( + index.album_head_seq(&owner, &unknown).await, + "head of an unknown album" + ), + 0 + ); +} + pub async fn run_all(index: &dyn AssetIndex) { reserving_twice_joins_the_same_row(index).await; a_disagreeing_reservation_is_refused_without_disclosure(index).await; @@ -1658,6 +1802,7 @@ pub async fn run_all(index: &dyn AssetIndex) { the_change_kind_is_relative_to_the_reader(index).await; paging_is_ordered_bounded_and_resumable(index).await; every_minted_number_is_reachable(index).await; + an_album_page_is_the_owners_sequence_filtered_to_one_album(index).await; an_albums_numbers_are_monotonic_with_gaps(index).await; a_tombstone_reaches_every_reader(index).await; tombstoning_a_pending_row_publishes_nothing(index).await; diff --git a/capsule-server/src/index/memory.rs b/capsule-server/src/index/memory.rs index 98bc0d0b..924cf9d4 100644 --- a/capsule-server/src/index/memory.rs +++ b/capsule-server/src/index/memory.rs @@ -268,6 +268,7 @@ impl AssetIndex for InMemoryAssetIndex { let holds = |row: &&AssetRow| row.blobs.iter().any(|blob| &blob.address == address); let reference = |row: &AssetRow| super::BlobReference { asset_id: row.asset_id.clone(), + album_id: row.album_id.clone(), owner_id: row.owner_id.clone(), role: row .blobs @@ -541,4 +542,42 @@ impl AssetIndex for InMemoryAssetIndex { fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { Box::pin(async move { Ok(lock(&self.inner).minted.get(owner).copied().unwrap_or(0)) }) } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + let inner = lock(&self.inner); + let mut page: Vec = inner + .rows + .values() + .filter(|row| &row.owner_id == owner && &row.album_id == album) + .filter(|row| row.sync_seq.is_some_and(|seq| seq > after)) + .filter_map(|row| entry_for(row, after)) + .collect(); + page.sort_by_key(|entry| entry.sync_seq); + page.truncate(limit); + Ok(page) + }) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + Box::pin(async move { + Ok(lock(&self.inner) + .rows + .values() + .filter(|row| &row.owner_id == owner && &row.album_id == album) + .filter_map(|row| row.sync_seq) + .max() + .unwrap_or(0)) + }) + } } diff --git a/capsule-server/src/index/mod.rs b/capsule-server/src/index/mod.rs index 312352bb..5ed9c596 100644 --- a/capsule-server/src/index/mod.rs +++ b/capsule-server/src/index/mod.rs @@ -389,6 +389,11 @@ impl ChangeKind { pub struct BlobReference { /// The asset the reference belongs to. pub asset_id: AssetId, + /// The album that asset belongs to (`S-C51`). + /// + /// What the read authority asks the membership store about, from the same read that found + /// the reference, for the reason [`Self::owner_id`] rides here. + pub album_id: AlbumId, /// The account that asset is filed under (`S-C39`). /// /// The fact the read authority decides on. Carried on the reference for the same reason @@ -712,6 +717,31 @@ pub trait AssetIndex: std::fmt::Debug + Send + Sync { /// What lets a page report whether the client is caught up without asking for another page /// that would come back empty. fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64>; + + /// Up to `limit` feed entries in `album` after sequence number `after`, in sequence order + /// (`S-C51`). + /// + /// The **owner's** sequence, filtered to one album: positions are the same numbers the + /// owner's own feed carries, so they are per-album monotonic exactly as the client's + /// anti-rewind mark requires, and gaps are the other albums' entries. A member of the album + /// reads this page; the route decides who is one, and hands over the album's owner from + /// the album record so the query is bound to the rows that owner filed — the `(owner, + /// album)` pair is what the index is keyed on, and a row another account filed under the + /// same album id is not this album's. + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec>; + + /// The highest sequence number any entry in `album` carries, or `0` for none (`S-C51`). + /// + /// The album page's caught-up mark. Not the owner's allocator: a member who has seen the + /// album's last entry is caught up whatever the owner has minted in other albums since. + fn album_head_seq<'a>(&'a self, owner: &'a OwnerId, album: &'a AlbumId) + -> IndexFuture<'a, u64>; } /// The roles an asset may hold exactly one of. diff --git a/capsule-server/src/index/postgres.rs b/capsule-server/src/index/postgres.rs index 03396abd..03305311 100644 --- a/capsule-server/src/index/postgres.rs +++ b/capsule-server/src/index/postgres.rs @@ -775,6 +775,7 @@ impl AssetIndex for PostgresAssetIndex { load_collections(&snapshot, &mut row).await?; let reference = BlobReference { asset_id: row.asset_id.clone(), + album_id: row.album_id.clone(), owner_id: row.owner_id.clone(), role: row .blobs @@ -1192,6 +1193,77 @@ impl AssetIndex for PostgresAssetIndex { sequence_from(next) }) } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + Box::pin(async move { + // One snapshot, as the owner's page: see `feed_page`. Bound to the owner as well as + // the album so the `(owner_id, album_id)` index serves it. + let snapshot = begin_read_snapshot(&self.connection).await?; + let found = snapshot + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + format!( + "SELECT {ASSET_COLUMNS} FROM assets \ + WHERE owner_id = $1 AND album_id = $2 AND sync_seq > $3 \ + ORDER BY sync_seq LIMIT $4" + ), + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + Value::from(after as i64), + Value::from(limit as i64), + ], + )) + .await + .map_err(PORT.failing("reading an album's feed page"))?; + let mut rows = found + .iter() + .map(asset_without_collections) + .collect::, _>>()?; + load_all_collections(&snapshot, &mut rows).await?; + commit(snapshot).await?; + Ok(rows + .iter() + .filter_map(|row| entry_for(row, after)) + .collect()) + }) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + Box::pin(async move { + let found = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT COALESCE(MAX(sync_seq), 0)::bigint AS head FROM assets \ + WHERE owner_id = $1 AND album_id = $2", + [ + Value::from(owner.as_str().to_owned()), + Value::from(album.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("reading an album's head sequence number"))? + .ok_or_else(|| StoreError::Rejected { + store: PORT.store, + detail: "the album head query returned no row".to_owned(), + })?; + let head: i64 = found + .try_get("", "head") + .map_err(PORT.failing("reading an album's head sequence number"))?; + sequence_from(head) + }) + } } #[cfg(test)] diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index 3f8c00ca..e99fd094 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -28,7 +28,9 @@ //! //! 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`], [`membership`], [`moderation`], +//! [`negotiation`], [`quota`], +//! [`scrub`], [`serve`], //! [`share`], [`store`], //! [`sync`], [`upload`], //! [`verify`] — is framework-free and testable without a router, which is why the operator @@ -77,7 +79,9 @@ pub mod escrow; pub mod gc; pub mod index; pub mod limits; +pub mod membership; pub mod moderation; +pub mod negotiation; mod openapi; pub mod postgres; pub mod problem; @@ -95,6 +99,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; @@ -115,95 +120,131 @@ 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, and its second factor. + .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, + ]) + // The cross-device add: one code, one channel, and the writes into it. + .mount(kynos::routes![ + routes::enroll::issue_enrollment_code, + routes::enroll::redeem_enrollment_code, + routes::enroll::relay_enrollment_payload, + routes::enroll::close_enrollment_channel, + ]) + // The library's own writes: albums, upgrades, uploads, operations, verification. + .mount(kynos::routes![ + routes::albums::provision_album, + routes::upgrade::begin_album_upgrade, + routes::upgrade::abort_album_upgrade, + routes::roster::publish_album_roster, + routes::upload::create_upload, + routes::upload::append_chunk, + routes::upload::cancel_upload, + routes::ops::apply_op, + routes::storage::verify_storage, + ]) + // Share links and guest drops: the owner's writes on both. + .mount(kynos::routes![ + routes::share::issue_share, + routes::share::revoke_share, + routes::drop::provision_link, + routes::drop::revoke_link, + routes::drop::adopt_drop, + routes::drop::discard_drop, + ]), + ) + // 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, ]) } @@ -213,7 +254,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. /// @@ -265,5 +310,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/membership/conformance.rs b/capsule-server/src/membership/conformance.rs new file mode 100644 index 00000000..a789ac72 --- /dev/null +++ b/capsule-server/src/membership/conformance.rs @@ -0,0 +1,653 @@ +//! The one suite every [`MembershipStore`] adapter must pass. +//! +//! # The rules the suite exists to protect +//! +//! - **One critical section.** A roster at the held version with different bytes is `Stale`, +//! not applied; a single-process suite cannot exhibit the race itself, so it asserts the +//! consequence — the loser of a version tie changes nothing — and the structural guarantee +//! stays in the adapter (one mutex here, one transaction lock in Postgres). +//! - **Removal is a stored fact.** A member omitted from a later roster answers +//! [`Membership::Revoked`] with the version and epoch at which they vanished, never +//! [`Membership::Never`]. That is what the blob route's `403` is rendered from. +//! - **A refusal changes nothing.** `Stale`, `VersionLeap` and `EpochRegressed` leave both the +//! roster and every member row exactly as they were. +//! - **No publish can wedge the album.** A version far above the held one is refused, and the +//! next legitimate roster still applies — the counter is bounded above as well as below. +//! +//! # Reusing a harness +//! +//! Every case scopes its own album ids, so cases may share one store and [`run_all`] does. + +use jiff::{SignedDuration, Timestamp}; +use uuid::Uuid; + +use super::{MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord}; +use crate::store::{AlbumId, StoreError, UserId}; + +/// The store under test. +pub trait Harness: Send + Sync { + /// The membership store under test. + fn members(&self) -> &dyn MembershipStore; +} + +/// Unwrap a store result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming membership store must succeed at {doing}: {error}"), + } +} + +/// `case`'s own album. +fn album(case: &str) -> AlbumId { + AlbumId::new(format!("{case}-album")) +} + +/// `case`'s account `name`. +fn user(case: &str, name: &str) -> UserId { + UserId::new(format!("{case}-{name}")) +} + +/// A roster record for `case` at `version` and `epoch`, whose bytes differ per version and per +/// `variant` so a "same version, different bytes" case can be written. +fn roster(case: &str, version: u64, epoch: u64, variant: &str) -> RosterRecord { + RosterRecord { + album_id: album(case), + roster_version: version, + amk_epoch: epoch, + attested_by_device: Uuid::from_u128(0xD1), + received_at: Timestamp::UNIX_EPOCH + SignedDuration::from_secs(1_700_000_000), + document: format!("{case}/v{version}/e{epoch}/{variant}").into_bytes(), + } +} + +/// Apply `roster` naming `members`, expecting it to be accepted. +async fn apply( + h: &dyn Harness, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, +) -> RosterOutcome { + ok( + h.members().apply_roster(roster, members).await, + "apply a roster", + ) +} + +/// What the store says `user` is to `case`'s album. +async fn membership(h: &dyn Harness, case: &str, user: &UserId) -> Membership { + ok( + h.members().membership(&album(case), user).await, + "read a membership", + ) +} + +// =========================================================================================== +// Applying rosters +// =========================================================================================== + +/// The first roster is applied and its members are members, with the roster's epoch. +pub async fn the_first_roster_is_applied_and_its_members_are_members(h: &dyn Harness) { + let case = "first"; + let bob = user(case, "bob"); + let outcome = apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + let RosterOutcome::Applied(record) = outcome else { + panic!("the first roster must be applied, got {outcome:?}"); + }; + assert_eq!(record.roster_version, 1); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + let held = ok( + h.members().current_roster(&album(case)).await, + "read the current roster", + ) + .expect("a roster is held"); + assert_eq!( + held, record, + "current_roster returns what apply_roster returned" + ); +} + +/// The held record is the stored record, instant included. +/// +/// An adapter that keeps sub-microsecond precision in Rust and drops it in the column would hand +/// `apply_roster`'s caller a record the next `current_roster` does not produce. +pub async fn the_returned_record_is_the_record_the_next_read_produces(h: &dyn Harness) { + let case = "precise"; + let mut precise = roster(case, 1, 1, ""); + precise.received_at = + Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"); + let RosterOutcome::Applied(returned) = apply(h, precise, vec![]).await else { + panic!("applied"); + }; + let held = ok( + h.members().current_roster(&album(case)).await, + "read the current roster", + ) + .expect("a roster is held"); + assert_eq!(held.received_at, returned.received_at); + assert_eq!(held.document, returned.document); +} + +/// The same bytes again are a replay: the held record comes back and nothing changes. +pub async fn the_same_bytes_again_are_a_replay(h: &dyn Harness) { + let case = "replay"; + let bob = user(case, "bob"); + let first = roster(case, 1, 1, ""); + let RosterOutcome::Applied(record) = + apply(h, first.clone(), vec![(bob.clone(), MemberRole::Writer)]).await + else { + panic!("applied"); + }; + // The member list is deliberately *different* on the replay: a replay is decided on the + // document's bytes, which the route derived the list from, not on the list itself. + assert_eq!( + apply(h, first, vec![]).await, + RosterOutcome::Replayed(record), + "identical bytes at the held version are a replay" + ); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + }, + "a replay changes nothing" + ); +} + +/// A version at or below the held one with different bytes is stale, and changes nothing. +pub async fn a_stale_version_is_refused_and_changes_nothing(h: &dyn Harness) { + let case = "stale"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + + // Same version, different bytes: the loser of a concurrent publish. + assert_eq!( + apply( + h, + roster(case, 1, 1, "other"), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::Stale { current_version: 1 } + ); + // A lower version: a client that is behind. + assert_eq!( + apply( + h, + roster(case, 0, 1, ""), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::Stale { current_version: 1 } + ); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + // And the album is not left locked by the refusals: the next real version applies. An + // adapter whose early return leaked its critical section would hang here, visibly. + assert!(matches!( + apply(h, roster(case, 2, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + )); +} + +/// A version far above the held one is refused, and the album still takes its next roster. +/// +/// The wedge this exists to deny: accept one publish at the top of the counter and no later +/// roster can ever be strictly greater, so the album's membership is frozen for good. The case +/// therefore asserts both halves — the absurd version changes nothing, *and* the legitimate +/// successor still applies. +pub async fn a_version_leap_is_refused_and_the_album_still_takes_its_next_roster(h: &dyn Harness) { + let case = "leap"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + + assert_eq!( + apply( + h, + roster(case, u64::MAX, 1, ""), + vec![(carol.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::VersionLeap { + current_version: 1, + max_version: 1 + super::MAX_ROSTER_VERSION_STEP, + } + ); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + + // The whole point: the next legitimate roster is still accepted. + assert!(matches!( + apply( + h, + roster(case, 2, 1, ""), + vec![ + (bob, MemberRole::Writer), + (carol.clone(), MemberRole::Reader) + ] + ) + .await, + RosterOutcome::Applied(_) + )); + assert_eq!( + membership(h, case, &carol).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +/// A first roster is bounded too: an empty album is a held version of zero. +pub async fn the_version_window_binds_an_albums_first_roster(h: &dyn Harness) { + let case = "leap-first"; + let bob = user(case, "bob"); + assert_eq!( + apply( + h, + roster(case, u64::MAX, 1, ""), + vec![(bob.clone(), MemberRole::Writer)] + ) + .await, + RosterOutcome::VersionLeap { + current_version: 0, + max_version: super::MAX_ROSTER_VERSION_STEP, + } + ); + assert!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .is_none(), + "a refused first roster leaves the album with none" + ); + assert!(matches!( + apply(h, roster(case, 1, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + )); +} + +/// A newer version carrying a lower epoch is a regression, and changes nothing. +pub async fn an_epoch_regression_is_refused_and_changes_nothing(h: &dyn Harness) { + let case = "regress"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + assert_eq!( + apply(h, roster(case, 2, 0, ""), vec![]).await, + RosterOutcome::EpochRegressed { + current_version: 1, + stored: 1 + } + ); + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + }, + "the member was not revoked by a refused roster" + ); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ) + .expect("held") + .roster_version, + 1 + ); + assert!( + matches!( + apply(h, roster(case, 2, 1, ""), vec![(bob, MemberRole::Writer)]).await, + RosterOutcome::Applied(_) + ), + "the refusal released the album for the next version" + ); +} + +/// One application revokes, continues and admits at once. +/// +/// The case that runs the adapters' set arithmetic with more than one name on each side: the +/// omitted member is revoked, the continuing one keeps their grant, the new one gets a fresh +/// grant — in one operation, from one list. +pub async fn one_application_revokes_continues_and_admits(h: &dyn Harness) { + let case = "mixed"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + let dave = user(case, "dave"); + apply( + h, + roster(case, 1, 1, ""), + vec![ + (bob.clone(), MemberRole::Writer), + (carol.clone(), MemberRole::Reader), + ], + ) + .await; + apply( + h, + roster(case, 2, 2, ""), + vec![ + (carol.clone(), MemberRole::Writer), + (dave.clone(), MemberRole::Reader), + ], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + assert_eq!( + membership(h, case, &carol).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); + assert_eq!( + membership(h, case, &dave).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 2, + } + ); +} + +/// An account listed twice is taken once, the last entry winning. +/// +/// The route refuses such a document, so this is the port's promise rather than a wire case — +/// and it is asserted because an adapter that folded the list into one multi-row statement would +/// fail it loudly rather than diverge quietly. +pub async fn an_account_listed_twice_is_taken_once_last_entry_winning(h: &dyn Harness) { + let case = "twice"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![ + (bob.clone(), MemberRole::Writer), + (bob.clone(), MemberRole::Reader), + ], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +// =========================================================================================== +// Membership over time +// =========================================================================================== + +/// A member omitted from a later roster is revoked at that roster's version and epoch. +/// +/// The `403` case: the row is retained and marked, never deleted, or a former member would be +/// indistinguishable from a stranger. +pub async fn an_omitted_member_is_revoked_at_the_rosters_version_and_epoch(h: &dyn Harness) { + let case = "revoke"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply(h, roster(case, 2, 2, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + // And a *further* roster that still omits them does not move the revocation. + apply(h, roster(case, 3, 3, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }), + "the revocation records the first omission, not the latest roster" + ); +} + +/// A re-admitted member is a member again, with a fresh grant at the re-admitting epoch. +pub async fn a_re_admitted_member_gets_a_fresh_grant(h: &dyn Harness) { + let case = "readmit"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply(h, roster(case, 2, 2, ""), vec![]).await; + apply( + h, + roster(case, 3, 3, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 3, + } + ); +} + +/// A continuing member's role follows the roster and their grant does not move. +pub async fn a_continuing_members_role_changes_and_their_grant_does_not(h: &dyn Harness) { + let case = "continue"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply( + h, + roster(case, 2, 2, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + }, + "the grant is the epoch this continuous membership began at" + ); +} + +/// An account never listed is `Never`, and so is any account on an album with no roster. +pub async fn an_unlisted_account_is_never_a_member(h: &dyn Harness) { + let case = "never"; + let bob = user(case, "bob"); + let carol = user(case, "carol"); + assert_eq!(membership(h, case, &carol).await, Membership::Never); + assert_eq!( + ok( + h.members().current_roster(&album(case)).await, + "read the current roster" + ), + None + ); + apply(h, roster(case, 1, 1, ""), vec![(bob, MemberRole::Reader)]).await; + assert_eq!(membership(h, case, &carol).await, Membership::Never); +} + +/// Membership is per album: the same account on two albums has two independent answers. +pub async fn membership_does_not_leak_between_albums(h: &dyn Harness) { + let case = "isolated"; + let other = "isolated-other"; + let bob = user(case, "bob"); + apply( + h, + roster(case, 1, 1, ""), + vec![(bob.clone(), MemberRole::Writer)], + ) + .await; + apply( + h, + roster(other, 1, 1, ""), + vec![(bob.clone(), MemberRole::Reader)], + ) + .await; + // Revoking on one album leaves the other untouched. + apply(h, roster(case, 2, 2, ""), vec![]).await; + assert_eq!( + membership(h, case, &bob).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); + assert_eq!( + membership(h, other, &bob).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + } + ); +} + +// =========================================================================================== +// The whole suite +// =========================================================================================== + +/// Run every case above against one harness, in order. +pub async fn run_all(h: &dyn Harness) { + the_first_roster_is_applied_and_its_members_are_members(h).await; + the_returned_record_is_the_record_the_next_read_produces(h).await; + the_same_bytes_again_are_a_replay(h).await; + a_stale_version_is_refused_and_changes_nothing(h).await; + a_version_leap_is_refused_and_the_album_still_takes_its_next_roster(h).await; + the_version_window_binds_an_albums_first_roster(h).await; + an_epoch_regression_is_refused_and_changes_nothing(h).await; + one_application_revokes_continues_and_admits(h).await; + an_account_listed_twice_is_taken_once_last_entry_winning(h).await; + + an_omitted_member_is_revoked_at_the_rosters_version_and_epoch(h).await; + a_re_admitted_member_gets_a_fresh_grant(h).await; + a_continuing_members_role_changes_and_their_grant_does_not(h).await; + an_unlisted_account_is_never_a_member(h).await; + membership_does_not_leak_between_albums(h).await; +} + +#[cfg(test)] +mod tests { + use super::{Harness, run_all}; + use crate::membership::{InMemoryMembership, MembershipStore}; + + /// The deterministic store. + #[derive(Debug, Default)] + struct MemoryHarness { + members: InMemoryMembership, + } + + impl Harness for MemoryHarness { + fn members(&self) -> &dyn MembershipStore { + &self.members + } + } + + /// Declares one `#[tokio::test]` per conformance case, on a fresh store each. + macro_rules! conformance_cases { + ($($case:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $case() { + super::$case(&MemoryHarness::default()).await; + } + )+ + }; + } + + conformance_cases! { + the_first_roster_is_applied_and_its_members_are_members, + the_returned_record_is_the_record_the_next_read_produces, + the_same_bytes_again_are_a_replay, + a_stale_version_is_refused_and_changes_nothing, + a_version_leap_is_refused_and_the_album_still_takes_its_next_roster, + the_version_window_binds_an_albums_first_roster, + an_epoch_regression_is_refused_and_changes_nothing, + one_application_revokes_continues_and_admits, + an_account_listed_twice_is_taken_once_last_entry_winning, + an_omitted_member_is_revoked_at_the_rosters_version_and_epoch, + a_re_admitted_member_gets_a_fresh_grant, + a_continuing_members_role_changes_and_their_grant_does_not, + an_unlisted_account_is_never_a_member, + membership_does_not_leak_between_albums, + } + + /// The whole suite, in one pass on one store — the entry point the container case uses. + #[tokio::test] + async fn the_in_memory_store_conforms() { + run_all(&MemoryHarness::default()).await; + } +} diff --git a/capsule-server/src/membership/memory.rs b/capsule-server/src/membership/memory.rs new file mode 100644 index 00000000..91b7fefb --- /dev/null +++ b/capsule-server/src/membership/memory.rs @@ -0,0 +1,123 @@ +//! [`InMemoryMembership`] — the deterministic double. +//! +//! One mutex over both maps, which is what makes [`MembershipStore::apply_roster`] one critical +//! section: the version comparison and the replacement happen under the same lock. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +use super::{ + MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord, precheck, +}; +use crate::store::{AlbumId, StoreFuture, UserId}; + +/// The deterministic membership store. +#[derive(Debug, Default)] +pub struct InMemoryMembership { + inner: Mutex, +} + +/// One account's row for one album. +#[derive(Debug, Clone)] +struct MemberRow { + role: MemberRole, + granted_epoch: u64, + revoked: Option, +} + +#[derive(Debug, Default)] +struct Inner { + rosters: BTreeMap, + members: BTreeMap<(AlbumId, UserId), MemberRow>, +} + +impl InMemoryMembership { + /// An empty store. + pub fn new() -> Self { + Self::default() + } +} + +/// Take the lock, recovering from a poisoned mutex. +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +impl MembershipStore for InMemoryMembership { + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome> { + Box::pin(async move { + let mut inner = lock(&self.inner); + if let Some(outcome) = precheck(inner.rosters.get(&roster.album_id), &roster) { + return Ok(outcome); + } + let album = roster.album_id.clone(); + let listed: BTreeMap = members.into_iter().collect(); + + // Everyone live who is not on the new list vanished at this version and epoch. + for ((row_album, user), row) in &mut inner.members { + if row_album == &album && row.revoked.is_none() && !listed.contains_key(user) { + row.revoked = Some(Revocation { + at_version: roster.roster_version, + at_epoch: roster.amk_epoch, + }); + } + } + for (user, role) in listed { + let key = (album.clone(), user); + match inner.members.get_mut(&key) { + // Continuing: the role may change, the grant does not. + Some(row) if row.revoked.is_none() => row.role = role, + // New, or re-admitted: a fresh grant at this roster's epoch. + _ => { + inner.members.insert( + key, + MemberRow { + role, + granted_epoch: roster.amk_epoch, + revoked: None, + }, + ); + } + } + } + tracing::info!( + %album, + roster_version = roster.roster_version, + amk_epoch = roster.amk_epoch, + "an album roster was applied" + ); + inner.rosters.insert(album, roster.clone()); + Ok(RosterOutcome::Applied(roster)) + }) + } + + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership> { + Box::pin(async move { + let inner = lock(&self.inner); + Ok(match inner.members.get(&(album.clone(), user.clone())) { + None => Membership::Never, + Some(row) => match row.revoked { + Some(revocation) => Membership::Revoked(revocation), + None => Membership::Member { + role: row.role, + granted_epoch: row.granted_epoch, + }, + }, + }) + }) + } + + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.inner).rosters.get(album).cloned()) }) + } +} diff --git a/capsule-server/src/membership/mod.rs b/capsule-server/src/membership/mod.rs new file mode 100644 index 00000000..2942f984 --- /dev/null +++ b/capsule-server/src/membership/mod.rs @@ -0,0 +1,517 @@ +//! Album membership (`S-C51`): the one fact the key-free server holds about who may read and +//! write a shared album, and the port it is held behind. +//! +//! # What the server knows, and where it learned it +//! +//! The server cannot read the MLS roster — every membership change is AEAD-protected under a +//! group key it never holds — so what it knows is what the album owner **told** it: a +//! [`SignedAlbumRoster`](capsule_core::crypto::membership::SignedAlbumRoster), verified against +//! the owner's published device directory before it reaches this port (the roster route). The +//! port stores the *consequence* of that document — who is a member, with what role, since +//! which version and epoch — and never re-verifies it: the same rule `album/mod.rs` records for +//! a quiescence, that verification happens once at the write and a stored fact is read as a +//! fact. +//! +//! # Removal is a stored fact, not a deleted row +//! +//! A member who vanishes from a later roster is not deleted; the row is marked with the version +//! and epoch at which they vanished. That is what makes `403 error.blob.access_revoked` +//! renderable at all: `serve/authority.rs` reserves the `403` for a caller the server can see +//! once **had** access, and everyone else gets the unknown-address `404`. Delete the row and +//! the former member is indistinguishable from a stranger, and the authorization-change signal +//! design/import/download-sync.md requires is gone. +//! +//! # Removing a member reclaims nothing, and that is observable +//! +//! A writer member's upload is filed under the album **owner**'s namespace and charged to the +//! **uploader** (`routes/upload.rs`: `owner_id` is the namespace, `upload_user_id` is billed). +//! Removing that member from a later roster changes neither fact. The asset stays in the +//! owner's album, and its bytes stay against the removed member's quota — they are still stored, +//! so the ledger is not wrong, but the account they are charged to can no longer reach them: +//! a removed member may not write ops to that album, so they cannot delete their way back under +//! quota. The only thing that ever releases the attribution is the refcount collector +//! (`gc/mod.rs`, `QuotaStore::release_attribution`, `S-C44`), which runs when the last reference +//! to the bytes goes — i.e. only if the *owner* deletes the asset. +//! +//! This is recorded rather than repaired: reclaiming on removal is a protocol question (does the +//! owner inherit the bytes, does the member keep paying for what the owner still holds, is +//! removal a deletion at all?) that no design document in this tree answers, and inventing an +//! answer inside a storage port is how a quota becomes a way to delete somebody else's photos. +//! Tracked as issue #473. +//! +//! # One critical section +//! +//! [`MembershipStore::apply_roster`] compares versions and replaces the roster in **one** +//! operation, the way the device directory's `publish` does: two concurrent publishes cannot +//! both read "version 1 is current" and both write version 2. The in-memory adapter holds one +//! mutex; the Postgres adapter takes a per-album transaction lock. +//! +//! # The version is bounded above as well as below +//! +//! Monotonicity alone makes `roster_version` a one-way ratchet with no stop: a single publish at +//! the top of the counter can never be superseded, and the album's membership is frozen for +//! good. [`MAX_ROSTER_VERSION_STEP`] closes that — a roster is applied only inside a window +//! above the held version — and the refusal ([`RosterOutcome::VersionLeap`]) names the held +//! version, so the client re-signs at `held+1` and loses nothing: the roster is a full document, +//! so the version is only ever an ordering, never a count of anything. +//! +//! The window is clamped by [`MAX_ROSTER_VERSION`] as well as by the step, which is what keeps +//! the counter inside what a `BIGINT` holds and what the generated clients can decode, and what +//! makes the degenerate case at the top of the type unreachable instead of merely improbable. + +use std::fmt; + +pub use capsule_core::crypto::membership::MemberRole; +use jiff::Timestamp; +use uuid::Uuid; + +use crate::store::{AlbumId, StoreFuture, UserId}; + +pub mod conformance; +pub mod memory; +pub mod postgres; + +pub use self::memory::InMemoryMembership; +pub use self::postgres::PostgresMembership; + +/// The stable column token for a role, and its inverse. +/// +/// Here rather than on the core type because the token is a **storage** contract of this crate: +/// a row written as `writer` has to read back as `Writer` across every deploy, whatever the wire +/// spelling does. +pub fn role_token(role: MemberRole) -> &'static str { + match role { + MemberRole::Reader => "reader", + MemberRole::Writer => "writer", + } +} + +/// The role a stored token names, or `None` for a token no version of this server wrote. +pub fn role_from_token(token: &str) -> Option { + match token { + "reader" => Some(MemberRole::Reader), + "writer" => Some(MemberRole::Writer), + _ => None, + } +} + +/// How far above the held version a roster may declare itself, and still be applied. +/// +/// `roster_version` is the client's counter, and without a ceiling it is also a **latch**: one +/// publish at `u64::MAX` can never be superseded, because nothing can be strictly greater than +/// it, and the album's membership is frozen for good. That is a wedge no recovery path in this +/// design undoes — the store's comparison is the only ordering there is. +/// +/// So a version is accepted only in the window `held+1 ..= held+16` (`held` reads as `0` for an +/// album with no roster yet). Sixteen because the gap a *legitimate* client opens is the number +/// of membership changes it made while it could not reach the server — a roster is a full +/// document, so it publishes only its latest — and sixteen offline changes to one album's +/// membership is already far past what the design describes. A client that does exceed it is +/// not stuck: the refusal names the held version, and the roster it re-signs at `held+1` says +/// exactly the same thing, because absence at a higher version *is* removal. +pub const MAX_ROSTER_VERSION_STEP: u64 = 16; + +/// The widest `roster_version` any adapter will accept. +/// +/// Two independent reasons, and they agree on the same number. +/// +/// The durable adapter stores the counter in a `BIGINT`, so anything above `i64::MAX` is a +/// version Postgres cannot hold — `counter_to_column` refuses it. Deciding that at the port +/// instead means both adapters answer the same typed refusal rather than one answering a +/// storage failure, which is the divergence the container suite already caught once. +/// +/// And every integer this server puts in a problem body is lowered by spargen as `i64` +/// (it emits no `u64` anywhere, `format: uint64` notwithstanding), so a counter above +/// `i64::MAX` would be a number the generated client cannot decode — and a decode failure is +/// not a typed API error, so the `code` and the recovery hint would be lost. Bounding the +/// counter here makes "every version the server can hold or name is decodable" true by +/// construction rather than by argument. +/// +/// Reaching it legitimately would take ~9.2 × 10^18 publishes for one album. +pub const MAX_ROSTER_VERSION: u64 = i64::MAX as u64; + +/// The roster the server currently holds for an album. +#[derive(Clone, PartialEq, Eq)] +pub struct RosterRecord { + /// The album. + pub album_id: AlbumId, + /// Strictly monotonic per album; the idempotency key with `album_id`. + pub roster_version: u64, + /// The AMK epoch the roster reflects. Non-decreasing across versions. + pub amk_epoch: u64, + /// The owner-account device that signed it. + pub attested_by_device: Uuid, + /// When the server accepted it, on the server's clock. + pub received_at: Timestamp, + /// The signed document, verbatim canonical CBOR. Kept so a replay is decided on bytes and so + /// an operator can re-verify what was accepted. + pub document: Vec, +} + +impl fmt::Debug for RosterRecord { + /// The document is a few kilobytes of CBOR; a log line wants its length, not its bytes. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RosterRecord") + .field("album_id", &self.album_id) + .field("roster_version", &self.roster_version) + .field("amk_epoch", &self.amk_epoch) + .field("attested_by_device", &self.attested_by_device) + .field("received_at", &self.received_at) + .field("document_len", &self.document.len()) + .finish() + } +} + +/// The version and epoch at which a member vanished from the roster. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Revocation { + /// The first roster version that omitted them. + pub at_version: u64, + /// The AMK epoch that roster carried — the epoch the owner bumped to on removal. + pub at_epoch: u64, +} + +/// What the server knows about one account's relationship to one album. +/// +/// The owner is never a member here: the owner's access is the album record's own fact, and a +/// caller that needs both asks both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Membership { + /// Listed on the current roster. + Member { + /// What they may do. + role: MemberRole, + /// The epoch at which this continuous membership began. A re-admitted member gets the + /// epoch of the roster that re-admitted them, not their original one. + granted_epoch: u64, + }, + /// Once listed, since omitted. The `403` case. + Revoked(Revocation), + /// Never listed. Indistinguishable from a stranger, by design. + Never, +} + +/// What applying a roster did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RosterOutcome { + /// A newer roster replaced the held one (or there was none). + Applied(RosterRecord), + /// The same version with the same bytes: nothing changed, and the held record is returned. + Replayed(RosterRecord), + /// A version at or below the held one with different bytes. The client is behind. + Stale { + /// The version the server holds. + current_version: u64, + }, + /// A version more than [`MAX_ROSTER_VERSION_STEP`] above the held one. Not a roster that is + /// behind — one so far ahead that accepting it would put the counter out of reach of every + /// later publish. + VersionLeap { + /// The version the server holds (`0` when it holds no roster). + current_version: u64, + /// The highest version it would have accepted. + max_version: u64, + }, + /// A newer version that carried a lower AMK epoch than the held one. An epoch never goes + /// backwards, so this is a client that lost state, not a legitimate roster. + EpochRegressed { + /// The version the server holds — the same re-sync hint `Stale` carries, so a route + /// need not read the roster a second time to name it. + current_version: u64, + /// The epoch the server holds. + stored: u64, + }, +} + +/// Where membership is kept. +pub trait MembershipStore: fmt::Debug + Send + Sync { + /// Replace the album's roster with `roster` naming `members`, in one critical section. + /// + /// The version comparison and the replacement are one operation. On `Applied`: every live + /// member absent from `members` is marked revoked at the roster's version and epoch; every + /// listed member is upserted, keeping their `granted_epoch` if they were already live and + /// taking the roster's epoch if they are new or re-admitted. A user listed twice is taken + /// once, last entry winning; the route refuses such a document before it reaches here. + /// + /// `Stale`, `Replayed` and `EpochRegressed` change nothing. + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome>; + + /// What `user` is to `album`. + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership>; + + /// The roster the server holds for `album`, if any. + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option>; +} + +/// The membership module's collaborators. +#[derive(Debug, Clone)] +pub struct MembershipContext { + members: std::sync::Arc, + clock: std::sync::Arc, +} + +impl MembershipContext { + /// Assembles the module from its collaborators. + pub fn new( + members: std::sync::Arc, + clock: std::sync::Arc, + ) -> Self { + Self { members, clock } + } + + /// The store. + pub fn members(&self) -> &dyn MembershipStore { + self.members.as_ref() + } + + /// The clock a roster's `received_at` is stamped from. + pub fn clock(&self) -> &dyn crate::store::Clock { + self.clock.as_ref() + } +} + +/// Decide what `incoming` does to `held`, before any row is touched. +/// +/// Pure, so both adapters make the same decision and the rule is testable without a store. `None` +/// is "apply it"; `Some` is the outcome that ends the operation without a write. +pub(crate) fn precheck( + held: Option<&RosterRecord>, + incoming: &RosterRecord, +) -> Option { + // The ceiling first, and against a held version of `0` when there is no roster yet: a first + // publish at `u64::MAX` would wedge the album exactly as a later one would, and an album + // whose membership no publish can ever change is the one outcome this port must not be able + // to reach. `saturating_add` so the window itself cannot overflow into wrapping around. + let current_version = held.map_or(0, |held| held.roster_version); + // Clamped to the ceiling as well as to the step: `saturating_add` alone would let + // `max_version` sit above what an adapter can store and a client can decode, and — at the + // very top of the type — collapse to `max_version == current_version`, where every later + // publish is stale and the album is wedged after all. The clamp makes that unreachable + // rather than merely improbable: a version above the ceiling is never accepted, so a held + // version above it never exists. + let max_version = current_version + .saturating_add(MAX_ROSTER_VERSION_STEP) + .min(MAX_ROSTER_VERSION); + if incoming.roster_version > max_version { + return Some(RosterOutcome::VersionLeap { + current_version, + max_version, + }); + } + let held = held?; + if incoming.roster_version == held.roster_version { + return Some(if incoming.document == held.document { + RosterOutcome::Replayed(held.clone()) + } else { + RosterOutcome::Stale { + current_version: held.roster_version, + } + }); + } + if incoming.roster_version < held.roster_version { + return Some(RosterOutcome::Stale { + current_version: held.roster_version, + }); + } + if incoming.amk_epoch < held.amk_epoch { + return Some(RosterOutcome::EpochRegressed { + current_version: held.roster_version, + stored: held.amk_epoch, + }); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(version: u64, epoch: u64, document: &[u8]) -> RosterRecord { + RosterRecord { + album_id: AlbumId::new("album"), + roster_version: version, + amk_epoch: epoch, + attested_by_device: Uuid::from_u128(1), + received_at: Timestamp::UNIX_EPOCH, + document: document.to_vec(), + } + } + + #[test] + fn the_first_roster_is_always_applied() { + assert_eq!(precheck(None, &record(1, 1, b"a")), None); + // Even a version 0 or an epoch 0: monotonicity is against the *held* roster only. + assert_eq!(precheck(None, &record(0, 0, b"a")), None); + } + + #[test] + fn the_same_version_is_a_replay_on_identical_bytes_and_stale_otherwise() { + let held = record(1, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(1, 1, b"a")), + Some(RosterOutcome::Replayed(held.clone())) + ); + assert_eq!( + precheck(Some(&held), &record(1, 1, b"b")), + Some(RosterOutcome::Stale { current_version: 1 }) + ); + } + + #[test] + fn a_lower_version_is_stale_whatever_its_bytes_or_epoch() { + let held = record(2, 2, b"a"); + assert_eq!( + precheck(Some(&held), &record(1, 9, b"a")), + Some(RosterOutcome::Stale { current_version: 2 }) + ); + } + + #[test] + fn a_newer_version_with_a_lower_epoch_is_a_regression() { + let held = record(1, 3, b"a"); + assert_eq!( + precheck(Some(&held), &record(2, 2, b"b")), + Some(RosterOutcome::EpochRegressed { + current_version: 1, + stored: 3 + }) + ); + // Equal is fine: a roster may change without a key rotation. + assert_eq!(precheck(Some(&held), &record(2, 3, b"b")), None); + assert_eq!(precheck(Some(&held), &record(2, 4, b"b")), None); + } + + #[test] + fn a_version_past_the_window_is_a_leap_whatever_it_holds() { + // The wedge: one publish at the ceiling of the type, which nothing could ever supersede. + let held = record(2, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(u64::MAX, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: 2, + max_version: 2 + MAX_ROSTER_VERSION_STEP, + }) + ); + // The edge of the window is inside it; one past it is not. + assert_eq!( + precheck(Some(&held), &record(2 + MAX_ROSTER_VERSION_STEP, 1, b"b")), + None + ); + assert_eq!( + precheck(Some(&held), &record(3 + MAX_ROSTER_VERSION_STEP, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: 2, + max_version: 2 + MAX_ROSTER_VERSION_STEP, + }) + ); + } + + #[test] + fn the_window_binds_the_first_roster_too_against_a_held_version_of_zero() { + assert_eq!( + precheck(None, &record(MAX_ROSTER_VERSION_STEP, 0, b"a")), + None + ); + assert_eq!( + precheck(None, &record(u64::MAX, 0, b"a")), + Some(RosterOutcome::VersionLeap { + current_version: 0, + max_version: MAX_ROSTER_VERSION_STEP, + }) + ); + } + + #[test] + fn nothing_above_the_storable_ceiling_is_ever_accepted() { + // The ceiling is what a BIGINT holds and what a generated client can decode. It binds + // the first roster and every later one, and it is the reason a held version above it + // cannot exist. + assert_eq!( + precheck(None, &record(MAX_ROSTER_VERSION + 1, 0, b"a")), + Some(RosterOutcome::VersionLeap { + current_version: 0, + max_version: MAX_ROSTER_VERSION_STEP, + }) + ); + let held = record(MAX_ROSTER_VERSION - 1, 1, b"a"); + assert_eq!( + precheck(Some(&held), &record(MAX_ROSTER_VERSION, 1, b"b")), + None + ); + assert_eq!( + precheck(Some(&held), &record(MAX_ROSTER_VERSION + 1, 1, b"b")), + Some(RosterOutcome::VersionLeap { + current_version: MAX_ROSTER_VERSION - 1, + // Clamped: `held + 16` would be past what an adapter can store. + max_version: MAX_ROSTER_VERSION, + }) + ); + } + + #[test] + fn the_window_never_names_a_ceiling_a_client_could_not_decode() { + // Every `max_version` the route can render is inside the range the generated clients + // lower integers into (`i64`), whatever the held version is — including the values that + // cannot occur, so the property does not depend on the ceiling being enforced elsewhere. + for held_version in [0, 1, MAX_ROSTER_VERSION - 1, MAX_ROSTER_VERSION, u64::MAX] { + let held = record(held_version, 1, b"a"); + let Some(RosterOutcome::VersionLeap { + current_version, + max_version, + }) = precheck(Some(&held), &record(u64::MAX, 1, b"b")) + else { + continue; + }; + assert!(max_version <= MAX_ROSTER_VERSION, "{max_version}"); + assert!(i64::try_from(max_version).is_ok(), "{max_version}"); + assert_eq!(current_version, held_version); + } + } + + #[test] + fn a_held_version_at_the_top_of_the_type_is_unreachable_and_refuses_everything() { + // The degenerate case the clamp exists to make unreachable, pinned at the exact + // boundary rather than near it. A store that somehow held `u64::MAX` would refuse every + // publish — including `u64::MAX` itself, which is *not* treated as a replay, because the + // ceiling is decided before the version comparison. That state cannot arise: no roster + // above `MAX_ROSTER_VERSION` is ever applied (the case above), so no held record can + // carry one. The behaviour is stated here so it is a decision rather than an accident. + let held = record(u64::MAX, 1, b"a"); + let refusal = Some(RosterOutcome::VersionLeap { + current_version: u64::MAX, + max_version: MAX_ROSTER_VERSION, + }); + assert_eq!(precheck(Some(&held), &record(u64::MAX, 1, b"a")), refusal); + assert_eq!(precheck(Some(&held), &record(u64::MAX, 1, b"b")), refusal); + // And a version inside the storable range is stale against it, not a leap. + assert_eq!( + precheck(Some(&held), &record(5, 1, b"b")), + Some(RosterOutcome::Stale { + current_version: u64::MAX + }) + ); + } + + #[test] + fn the_role_tokens_round_trip_and_nothing_else_parses() { + for role in [MemberRole::Reader, MemberRole::Writer] { + assert_eq!(role_from_token(role_token(role)), Some(role)); + } + assert_eq!(role_from_token("admin"), None); + } + + #[test] + fn a_roster_records_debug_shows_the_documents_length_not_its_bytes() { + let rendered = format!("{:?}", record(1, 1, b"secret-bytes")); + assert!(rendered.contains("document_len: 12"), "{rendered}"); + assert!(!rendered.contains("secret"), "{rendered}"); + } +} diff --git a/capsule-server/src/membership/postgres.rs b/capsule-server/src/membership/postgres.rs new file mode 100644 index 00000000..73225ec4 --- /dev/null +++ b/capsule-server/src/membership/postgres.rs @@ -0,0 +1,359 @@ +//! [`PostgresMembership`] — the durable membership store (`S-C51`). +//! +//! # Two tables, one lock +//! +//! `album_rosters` holds the roster the server currently accepts for an album — one row per +//! album, the signed document verbatim — and `album_members` holds one row per account that +//! has ever been on one of that album's rosters. A revoked member keeps their row with +//! `revoked_at_version` and `revoked_epoch` set, because a deleted row would make the blob +//! route's `403` unrenderable (see the module docs). +//! +//! # Why the lock is an advisory lock and not `SELECT … FOR UPDATE` +//! +//! `apply_roster` has to be one critical section against a concurrent publish, and the row it +//! would lock does not exist yet for the album's **first** roster — two first publishes would +//! both read "no roster" and both upsert, and the loser would silently overwrite the winner's +//! row rather than answer `Stale`. A transaction-scoped advisory lock keyed on the album id +//! serialises both cases with one statement, is released by the commit or rollback, and needs +//! no row to exist. Every statement after it runs under the lock, so the read, the comparison +//! and the writes are one operation. +//! +//! # The advisory keyspace is shared, and that costs only serialization +//! +//! `pg_advisory_xact_lock(hashtext($1))` takes a **single-argument** advisory lock, whose key +//! space is the whole database's: `hashtext` is 32 bits, and any other advisory-lock user in +//! the same database — another Capsule adapter, an operator's migration script, an unrelated +//! application sharing the instance — can land on the same key for an entirely different +//! reason. What that costs is *serialization*, never correctness: a collision makes two +//! unrelated operations take turns. It cannot admit two concurrent publishes for one album, +//! because equal album ids always hash equal, which is the only direction this lock is relied +//! on for. If a deployment ever measures contention here, the repair is a two-argument +//! `pg_advisory_xact_lock(classid, objid)` with a class id reserved for this port — not a +//! wider lock and not a different concurrency story. + +use sea_orm::{ + ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement, + TransactionTrait, Value, +}; +use uuid::Uuid; + +use super::{ + MemberRole, Membership, MembershipStore, Revocation, RosterOutcome, RosterRecord, precheck, + role_from_token, role_token, +}; +use crate::postgres::error::Port; +use crate::postgres::time::{from_micros, stored, to_micros}; +use crate::store::{AlbumId, StoreError, StoreFuture, UserId}; + +/// Which port is speaking, for every error this adapter raises. +const PORT: Port = Port { + store: "membership", + record: "RosterRecord", +}; + +/// The durable membership store. +#[derive(Debug, Clone)] +pub struct PostgresMembership { + connection: DatabaseConnection, +} + +impl PostgresMembership { + /// A store over `connection`. + pub fn new(connection: DatabaseConnection) -> Self { + Self { connection } + } +} + +/// A version or epoch as the column holds it. +fn counter_to_column(value: u64) -> Result { + i64::try_from(value).map_err(|_| StoreError::Rejected { + store: PORT.store, + detail: format!("{value} is past what a BIGINT column holds"), + }) +} + +/// A version or epoch as the port speaks it. +fn counter_from(value: i64) -> Result { + u64::try_from(value).map_err(|_| PORT.undecodable(format!("{value} is not a counter"))) +} + +/// Begin a transaction, or say why not. +async fn begin(connection: &DatabaseConnection) -> Result { + connection + .begin() + .await + .map_err(PORT.failing("opening a transaction")) +} + +/// Commit, or say why not. +async fn commit(transaction: DatabaseTransaction) -> Result<(), StoreError> { + transaction + .commit() + .await + .map_err(PORT.failing("committing a transaction")) +} + +/// The roster row for `album`, read through `connection`. +async fn roster_of( + connection: &C, + album: &AlbumId, +) -> Result, StoreError> { + let Some(row) = connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT roster_version, amk_epoch, attested_by_device, received_at, document \ + FROM album_rosters WHERE album_id = $1", + [Value::from(album.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("reading an album's roster"))? + else { + return Ok(None); + }; + let failed = PORT.failing("reading an album's roster"); + let roster_version: i64 = row.try_get("", "roster_version").map_err(&failed)?; + let amk_epoch: i64 = row.try_get("", "amk_epoch").map_err(&failed)?; + let attested_by_device: String = row.try_get("", "attested_by_device").map_err(&failed)?; + let received_at: i64 = row.try_get("", "received_at").map_err(&failed)?; + let document: Vec = row.try_get("", "document").map_err(&failed)?; + Ok(Some(RosterRecord { + album_id: album.clone(), + roster_version: counter_from(roster_version)?, + amk_epoch: counter_from(amk_epoch)?, + attested_by_device: Uuid::parse_str(&attested_by_device) + .map_err(|_| PORT.undecodable(format!("`{attested_by_device}` is not a device id")))?, + received_at: from_micros(received_at).ok_or_else(|| { + PORT.undecodable(format!("{received_at}µs is not a representable instant")) + })?, + document, + })) +} + +impl MembershipStore for PostgresMembership { + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome> { + Box::pin(async move { + let transaction = begin(&self.connection).await?; + + // The critical section starts here: everything below runs under the album's lock, + // and the lock is released with the transaction. + // + // `hashtext` is 32 bits in a key space shared with every other advisory-lock user in + // this database, so an unrelated caller can collide with an album. The cost of a + // collision is serialization and nothing else — two unrelated operations take turns + // — because equal album ids always hash equal, which is the only guarantee this lock + // is asked for. See the module docs for the two-argument repair if it ever matters. + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_advisory_xact_lock(hashtext($1))", + [Value::from(roster.album_id.as_str().to_owned())], + )) + .await + .map_err(PORT.failing("locking an album's roster"))?; + + let held = roster_of(&transaction, &roster.album_id).await?; + if let Some(outcome) = precheck(held.as_ref(), &roster) { + // Nothing to write; the rollback releases the lock. + return Ok(outcome); + } + + // Column widths are decided **after** the port's own rule, never before it. A version + // past what a `BIGINT` holds is exactly the wedge `precheck`'s window refuses, and + // converting first would answer it as this adapter's storage failure — a `500` where + // the in-memory store answers a typed refusal, which is the divergence the shared + // conformance suite exists to catch. Anything that reaches here is inside the window + // above a version this column already held, so these conversions are a guard on an + // earlier check's promise rather than a decision. + let roster_version = counter_to_column(roster.roster_version)?; + let amk_epoch = counter_to_column(roster.amk_epoch)?; + + let roster = RosterRecord { + received_at: stored(roster.received_at), + ..roster + }; + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO album_rosters \ + (album_id, roster_version, amk_epoch, attested_by_device, received_at, \ + document) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + ON CONFLICT (album_id) DO UPDATE SET \ + roster_version = EXCLUDED.roster_version, \ + amk_epoch = EXCLUDED.amk_epoch, \ + attested_by_device = EXCLUDED.attested_by_device, \ + received_at = EXCLUDED.received_at, \ + document = EXCLUDED.document", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + Value::from(roster.attested_by_device.to_string()), + Value::from(to_micros(roster.received_at)), + Value::from(roster.document.clone()), + ], + )) + .await + .map_err(PORT.failing("replacing an album's roster"))?; + + // Everyone live who is not on the new list vanished at this version and epoch. The + // list is bound as a text array so one statement covers any roster size. + let listed: Vec = members + .iter() + .map(|(user, _)| user.as_str().to_owned()) + .collect(); + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "UPDATE album_members \ + SET revoked_at_version = $2, revoked_epoch = $3 \ + WHERE album_id = $1 AND revoked_at_version IS NULL \ + AND NOT (user_id = ANY($4))", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + Value::from(listed), + ], + )) + .await + .map_err(PORT.failing("revoking the members a roster omits"))?; + + // One statement per listed member, so a user listed twice is taken once, last entry + // winning, exactly as the in-memory store's map does. A continuing member keeps + // their grant and takes the new role; a new or re-admitted one gets a fresh grant. + for (user, role) in members { + transaction + .execute(Statement::from_sql_and_values( + DbBackend::Postgres, + "INSERT INTO album_members \ + (album_id, user_id, role, since_version, granted_epoch, \ + revoked_at_version, revoked_epoch) \ + VALUES ($1, $2, $3, $4, $5, NULL, NULL) \ + ON CONFLICT (album_id, user_id) DO UPDATE SET \ + role = EXCLUDED.role, \ + since_version = CASE \ + WHEN album_members.revoked_at_version IS NULL \ + THEN album_members.since_version ELSE EXCLUDED.since_version END, \ + granted_epoch = CASE \ + WHEN album_members.revoked_at_version IS NULL \ + THEN album_members.granted_epoch ELSE EXCLUDED.granted_epoch END, \ + revoked_at_version = NULL, \ + revoked_epoch = NULL", + [ + Value::from(roster.album_id.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + Value::from(role_token(role).to_owned()), + Value::from(roster_version), + Value::from(amk_epoch), + ], + )) + .await + .map_err(PORT.failing("recording a roster member"))?; + } + + commit(transaction).await?; + tracing::info!( + album = %roster.album_id, + roster_version = roster.roster_version, + amk_epoch = roster.amk_epoch, + "an album roster was applied" + ); + Ok(RosterOutcome::Applied(roster)) + }) + } + + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership> { + Box::pin(async move { + let Some(row) = self + .connection + .query_one(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT role, granted_epoch, revoked_at_version, revoked_epoch \ + FROM album_members WHERE album_id = $1 AND user_id = $2", + [ + Value::from(album.as_str().to_owned()), + Value::from(user.as_str().to_owned()), + ], + )) + .await + .map_err(PORT.failing("reading a membership"))? + else { + return Ok(Membership::Never); + }; + let failed = PORT.failing("reading a membership"); + let role: String = row.try_get("", "role").map_err(&failed)?; + // Validated on every row, revoked ones included: a stored fact is read as a fact, + // and a token no version of this server wrote is corruption whichever row holds it. + let role = role_from_token(&role) + .ok_or_else(|| PORT.undecodable(format!("`{role}` is not a member role")))?; + let granted_epoch: i64 = row.try_get("", "granted_epoch").map_err(&failed)?; + let revoked_at_version: Option = + row.try_get("", "revoked_at_version").map_err(&failed)?; + let revoked_epoch: Option = row.try_get("", "revoked_epoch").map_err(&failed)?; + Ok(match (revoked_at_version, revoked_epoch) { + (Some(at_version), Some(at_epoch)) => Membership::Revoked(Revocation { + at_version: counter_from(at_version)?, + at_epoch: counter_from(at_epoch)?, + }), + (None, None) => Membership::Member { + role, + granted_epoch: counter_from(granted_epoch)?, + }, + // The two revocation columns are written together; one without the other is a + // row this server did not write. + _ => { + return Err( + PORT.undecodable("a member row carries half a revocation".to_owned()) + ); + } + }) + }) + } + + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option> { + Box::pin(async move { roster_of(&self.connection, album).await }) + } +} + +#[cfg(test)] +mod tests { + /// The suite, against a real Postgres. + mod postgres_conformance { + use super::super::PostgresMembership; + use crate::membership::MembershipStore; + use crate::membership::conformance::{self, Harness}; + use crate::postgres::testing; + + /// A store over one container. + #[derive(Debug)] + struct PostgresHarness { + members: PostgresMembership, + } + + impl Harness for PostgresHarness { + fn members(&self) -> &dyn MembershipStore { + &self.members + } + } + + #[tokio::test] + async fn the_postgres_membership_store_conforms() { + let Some(database) = testing::start("the Postgres membership store").await else { + return; + }; + let harness = PostgresHarness { + members: PostgresMembership::new(database.connection().clone()), + }; + conformance::run_all(&harness).await; + } + } +} diff --git a/capsule-server/src/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..484c9dbe 100644 --- a/capsule-server/src/openapi/mod.rs +++ b/capsule-server/src/openapi/mod.rs @@ -46,8 +46,8 @@ //! new `#[problem(extension)]` field that is not `code` will not appear in the document until //! somebody adds a row, and nothing here fails when they forget. //! -//! Three things bound that. The table is small — nine rows against sixteen non-`code` fields -//! across six enums — and `every_row_names_a_response_that_exists` fails on a row that has gone +//! Three things bound that. The table is small — six rows against the non-`code` fields +//! across the rejection enums — and `every_row_names_a_response_that_exists` fails on a row that has gone //! stale, so it cannot rot in the other direction. The `code` member, which is the one the i18n //! contract turns on and 104 of the 120 extension fields on this surface, needs no table at all. //! And the real fix is upstream: `#[problem(extension)]` should carry a schema, which is the @@ -88,6 +88,22 @@ struct Member { name: &'static str, /// Its JSON Schema type. json_type: &'static str, + /// Its JSON Schema `format`, when the type alone would lose the Rust one. + /// + /// Kynos gives the *body* schemas their `format` from the Rust type; this table is + /// hand-written, so a member that is a `u64` on the wire has to say so here, or the + /// document describes it as a bare integer and the contract is less true than the code. + /// + /// **It buys truthfulness, not safety.** spargen lowers every integer in this document as + /// `i64` and emits no `u64` at all — `RosterResponse.roster_version` carries + /// `format: uint64` *and* `minimum: 0` and is still generated as `i64`. So a member whose + /// value can exceed `i64::MAX` breaks the generated client's *decode*, which is not a typed + /// API error: the `code` and every recovery hint are discarded and the caller cannot tell + /// the refusal from a network fault. The defence is therefore not this field but the + /// bound on the value: every integer member in this table is one the server cannot emit + /// above `i64::MAX` (see `MAX_ROSTER_VERSION` for the roster counters), and a number that + /// cannot be bounded belongs in the English `detail`, not in an extension. + format: Option<&'static str>, /// What it means, for the client that has to act on it. description: &'static str, /// Whether the member may be `null` — the shape a `Option` extension renders as. @@ -110,30 +126,6 @@ struct Extra { /// /// See the module docs for why this is a table and what bounds the risk of one. const EXTRAS: &[Extra] = &[ - Extra { - component: "ProtocolRangeProblem", - operation: "create_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "append_chunk", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "head_upload", - status: 426, - members: PROTOCOL_RANGE, - }, - Extra { - component: "ProtocolRangeProblem", - operation: "cancel_upload", - status: 426, - members: PROTOCOL_RANGE, - }, Extra { component: "ProtocolRangeProblem", operation: "album_lifecycle_op", @@ -147,6 +139,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "existing_asset", json_type: "string", + format: None, description: "The asset already holding these exact bytes in the same album. \ Structured so a client merges rather than re-parsing a sentence \ (slice `S-C22`).", @@ -160,6 +153,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "offset", json_type: "integer", + format: None, description: "The offset the server is actually at, so a client resumes from it \ instead of asking again.", nullable: false, @@ -173,17 +167,54 @@ const EXTRAS: &[Extra] = &[ Member { name: "submitted", json_type: "integer", + format: None, description: "The directory version the request carried.", nullable: false, }, Member { name: "stored", json_type: "integer", + format: None, description: "The version the server holds. A client re-signs above this one.", nullable: false, }, ], }, + Extra { + component: "RosterVersionLeapProblem", + operation: "publish_album_roster", + status: 400, + members: &[ + Member { + name: "current_version", + json_type: "integer", + format: Some("uint64"), + description: "The roster version the server holds; `0` when it holds none.", + nullable: false, + }, + Member { + name: "max_version", + json_type: "integer", + format: Some("uint64"), + description: "The highest version this album would have accepted. A client \ + re-signs the same roster at `current_version + 1`; a version \ + nothing could supersede would freeze the album's membership.", + nullable: false, + }, + ], + }, + Extra { + component: "RosterStaleProblem", + operation: "publish_album_roster", + status: 409, + members: &[Member { + name: "current_version", + json_type: "integer", + format: Some("uint64"), + description: "The roster version the server holds. A client re-syncs and republishes above it.", + nullable: false, + }], + }, Extra { component: "StaleRevivalProblem", operation: "album_lifecycle_op", @@ -191,6 +222,7 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "chain_head", json_type: "string", + format: None, description: "The manifest hash the asset's chain is actually at. Absent when the \ conflict is not a chain conflict, which is why it is nullable.", nullable: true, @@ -203,23 +235,33 @@ const EXTRAS: &[Extra] = &[ members: &[Member { name: "limit", json_type: "integer", + format: None, description: "The largest file this drop link accepts, in bytes.", nullable: false, }], }, ]; -/// The protocol window a `426` publishes, shared by every operation that pins one. +/// The protocol window a **body-level** `426` publishes as extension members. +/// +/// One row is left: `album_lifecycle_op` refuses a manifest envelope pinned outside the window +/// and still renders the range in the body. The four upload operations no longer do — since +/// issue #404 the window rides `X-Capsule-Protocol-Min`/`-Max` on every response, header-gated +/// and body-gated `426`s alike, which is where the SDK reads it; a second spelling in the body +/// is the drift the census exists to prevent. The remaining row goes when `routes/ops.rs` +/// drops its members. const PROTOCOL_RANGE: &[Member] = &[ Member { name: "protocol_min", json_type: "string", + format: None, description: "The oldest protocol date this server still speaks (`YYYY-MM-DD`).", nullable: false, }, Member { name: "protocol_max", json_type: "string", + format: None, description: "The newest protocol date this server speaks (`YYYY-MM-DD`).", nullable: false, }, @@ -308,6 +350,60 @@ fn fill_binary(schema: &mut Option) { ); } +/// Files the protocol window's three response headers under every response (issue #404). +/// +/// [`crate::negotiation::Negotiation`] attaches `X-Capsule-Protocol-Min`, `-Max` and +/// `X-Capsule-Min-Client-Build` to **every** response it forwards, and it forwards everything — +/// a short-circuit from an inner interceptor, an extractor's rejection, a handler's answer. +/// Kynos describes an interceptor's `Adds` at `StatusPattern::Success` only +/// (`kynos/src/middleware/erased.rs`), so without this the document would promise the headers +/// on a `200` and stay silent on the `426` where a client most needs them. +/// +/// The declarations come from [`crate::negotiation::response_header_declarations`] — the same +/// source the interceptor's own description uses — and a response that already declares a +/// header under one of these names is left exactly as the router emitted it, so this can never +/// overwrite what Kynos said. +pub(crate) fn describe_negotiation_headers(document: &mut Document) { + let declarations = crate::negotiation::response_header_declarations(); + for item in document.paths.items.values_mut() { + let slots: Vec<&mut Option>> = vec![ + &mut item.get, + &mut item.put, + &mut item.post, + &mut item.delete, + &mut item.options, + &mut item.head, + &mut item.patch, + &mut item.trace, + &mut item.query, + ]; + for operation in slots.into_iter().filter_map(|slot| slot.as_deref_mut()) { + let responses = operation + .responses + .responses + .values_mut() + .chain(operation.responses.default_response.iter_mut()); + for response in responses { + let kynos::openapi::RefOr::Item(response) = response else { + continue; + }; + for (name, header) in &declarations { + let declared = response + .headers + .keys() + .any(|existing| existing.eq_ignore_ascii_case(name)); + if !declared { + response.headers.insert( + (*name).to_owned(), + kynos::openapi::RefOr::Item(header.clone()), + ); + } + } + } + } + } +} + pub(crate) fn describe_problem_extensions(document: &mut Document) { let Some(base) = document.components.schemas.get(BASE).cloned() else { return; @@ -391,6 +487,7 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { crate::problem::CODE_MEMBER.to_owned(), member_schema( "string", + None, false, "The stable `error.*` catalog code. The client localizes this; `detail` stays \ English. Present on every problem this server renders.", @@ -407,7 +504,12 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { for member in members { object.properties.insert( member.name.to_owned(), - member_schema(member.json_type, member.nullable, member.description), + member_schema( + member.json_type, + member.format, + member.nullable, + member.description, + ), ); } @@ -415,17 +517,25 @@ fn extended(base: &Schema, title: &str, members: &[Member]) -> Schema { } /// One member's schema. -fn member_schema(json_type: &str, nullable: bool, description: &str) -> Schema { +fn member_schema( + json_type: &str, + format: Option<&str>, + nullable: bool, + description: &str, +) -> Schema { let types = if nullable { serde_json::json!([json_type, "null"]) } else { serde_json::json!(json_type) }; - serde_json::from_value(serde_json::json!({ + let mut schema = serde_json::json!({ "type": types, "description": description, - })) - .expect("a literal member schema is a schema") + }); + if let Some(format) = format { + schema["format"] = serde_json::json!(format); + } + serde_json::from_value(schema).expect("a literal member schema is a schema") } #[cfg(test)] diff --git a/capsule-server/src/postgres/mod.rs b/capsule-server/src/postgres/mod.rs index e21d83ea..36581b0e 100644 --- a/capsule-server/src/postgres/mod.rs +++ b/capsule-server/src/postgres/mod.rs @@ -5,12 +5,12 @@ //! //! Only the cross-cutting machinery lives in this module. Each adapter lives beside the port it //! implements — `index/postgres.rs`, `auth/accounts_postgres.rs`, `store/cohorts_postgres.rs`, -//! `quota/postgres.rs` — because design/module-map.md assigns contract ownership per *behaviour -//! module* rather than per backend, and the tree already does it that way for the blob store +//! `quota/postgres.rs`, `membership/postgres.rs` — because design/module-map.md assigns +//! contract ownership per *behaviour module* rather than per backend, and the tree already does it that way for the blob store //! (`blob/fs.rs`, `blob/memory.rs`). A `postgres/` tree holding every adapter would make one //! directory the co-owner of every durable contract in the crate. //! -//! Nothing forces the alternative either: no invariant in these four ports needs a transaction +//! Nothing forces the alternative either: no invariant in these ports needs a transaction //! spanning two of them. The sequence mint is inside `record_blob`, the attribution check is //! inside `QuotaStore::charge`, and the account row is one table. //! @@ -47,6 +47,7 @@ pub const EXPECTED_MIGRATIONS: &[&str] = &[ "m20260902_000002_accounts", "m20260902_000003_cohorts", "m20260902_000004_quota", + "m20260902_000005_album_membership", ]; /// The command an operator runs to apply them. diff --git a/capsule-server/src/routes/blob.rs b/capsule-server/src/routes/blob.rs index b525b05b..1170b886 100644 --- a/capsule-server/src/routes/blob.rs +++ b/capsule-server/src/routes/blob.rs @@ -93,6 +93,20 @@ pub enum BlobRejection { code: &'static str, }, + /// The caller was a member of the album that holds these bytes and has been removed from + /// its roster (`S-C51`). An authorization change, not a durability loss: the client re-syncs + /// its album membership before retrying, and only then degrades. + /// + /// Only a former member ever sees this. Everyone else gets [`Self::NotFound`], byte-identical + /// to an unknown address, because a `403` confirms the address is referenced by somebody. + #[error("you no longer have access to this album")] + #[problem(status = 403, title = "Access revoked")] + Forbidden { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// Referenced, but not retrievable per policy. Permanent. #[error("this blob is no longer available")] #[problem(status = 410, title = "Gone")] @@ -113,6 +127,13 @@ pub enum BlobRejection { } impl BlobRejection { + /// A former member of the album that holds the bytes (`S-C51`). + fn forbidden() -> Self { + Self::Forbidden { + code: error_codes::BLOB_ACCESS_REVOKED, + } + } + /// No live reference, or a malformed address. fn not_found() -> Self { Self::NotFound { @@ -144,10 +165,10 @@ impl BlobRejection { /// Fetch a ciphertext blob by its content address, ranged. /// -/// Opaque octets: the server holds no key and this route never learns what it is serving. Any -/// authenticated account may fetch any live address — see [`crate::serve`] for why that is a -/// capability model rather than a hole, and for the `403` the contract describes and nothing -/// implements. +/// Opaque octets: the server holds no key and this route never learns what it is serving. An +/// account fetches the blobs of its own assets and of the albums it is currently a member of; a +/// former member is told `403`, and everyone else is told what an unknown address is told — +/// see [`crate::serve`] for the boundary and its reasons. /// /// The one answer that *is* account-scoped is the transient `409`: it reports the caller's own /// in-flight upload and nobody else's (`S-C40`). @@ -174,6 +195,7 @@ pub async fn get_blob( ServeResolution::Serve { address, size } => (address, size), ServeResolution::AwaitingUpload { .. } => return Err(BlobRejection::pending()), ServeResolution::NotFound => return Err(BlobRejection::not_found()), + ServeResolution::Forbidden => return Err(BlobRejection::forbidden()), ServeResolution::Gone => return Err(BlobRejection::gone()), }; diff --git a/capsule-server/src/routes/drop.rs b/capsule-server/src/routes/drop.rs index c82381b5..e2ec461a 100644 --- a/capsule-server/src/routes/drop.rs +++ b/capsule-server/src/routes/drop.rs @@ -1080,10 +1080,17 @@ async fn adopt_claimed( let album = crate::store::AlbumId::new(&request.album_id); // Invariant 6, unchanged: adoption is a write into an album and needs the same capability - // any other write does. - let crate::upload::AlbumWriteAccess::Writable { protocol_pin, .. } = upload + // any other write does. And it stays a write into the link owner's **own** album: a drop is + // deposited with one account, and promoting it into an album that account merely writes to + // would file a guest's bytes under a third party. + let crate::upload::AlbumWriteAccess::Writable { + owner_id: filed_under, + role, + protocol_pin, + .. + } = upload .authority() - .album_write_access(&owner_id, &album) + .album_write_access(owner, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an adoption"); @@ -1094,6 +1101,12 @@ async fn adopt_claimed( "no write capability for that album", )); }; + if role != crate::upload::WriteRole::Owner || filed_under != owner_id { + tracing::info!(%owner, %album, "an adoption was refused: the album is not the link owner's own"); + return Err(AdoptRejection::refused( + "no write capability for that album", + )); + } // Invariant 7, unchanged. let device = crate::upload::envelope::created_by_device(&request.manifest_envelope) diff --git a/capsule-server/src/routes/mod.rs b/capsule-server/src/routes/mod.rs index 7ac91bdd..59b709ce 100644 --- a/capsule-server/src/routes/mod.rs +++ b/capsule-server/src/routes/mod.rs @@ -20,6 +20,7 @@ pub mod ops; pub mod profile; pub mod quota; pub mod receipts; +pub mod roster; pub mod sessions; pub mod share; pub mod storage; diff --git a/capsule-server/src/routes/ops.rs b/capsule-server/src/routes/ops.rs index d4394ec0..2c3c70b0 100644 --- a/capsule-server/src/routes/ops.rs +++ b/capsule-server/src/routes/ops.rs @@ -21,6 +21,48 @@ //! chain head and then both write would both pass a handler-side check and double-apply, which //! is the stale revival invariant 17 exists to catch, reintroduced by the code enforcing it. //! +//! # Who a lifecycle record names, and what the server checks about it +//! +//! Every action this surface admits is a **chain continuation**. The allow-list in +//! [`check_op`](crate::upload::envelope::check_op) is `delete | trash-restore | +//! metadata-update | derivative-add | derivative-replace` — the five that do not move blob +//! bytes — and none may carry a null `prior_provenance_hash`. The two that *do* move bytes, +//! `create` and `replace`, are `POST /v1/upload`'s by definition. +//! +//! **`created_by_user` and `created_by_device` name the signer of *this record*, not the asset's +//! creator**, on a continuation exactly as on a create. That is not a convention this server +//! picked: `capsule_core::crypto::verify_asset` resolves the device inside *that account's* +//! published directory (step 6) and verifies `device_sig` under that entry's key (step 8), so a +//! record naming anyone but its own signer cannot verify by any reader. Album write authority is +//! decided separately, by `write_sig` under the epoch's write-tier key at step 10 — which is why +//! a member writing under their own name does not weaken the owner's album. +//! +//! So on a shared album, a writer member's delete of the owner's asset is authored by the +//! **member**, and the asset's creator remains recoverable from the `create` record at the head +//! of the append-only chain. The client half of that is +//! `capsule_core::lifecycle::provenance::sign_lifecycle`, which re-mints both fields per write. +//! +//! What this surface checks, in the order it decides: +//! +//! 1. the **bearer token** — the caller is an authenticated account, and everything below is +//! about that account rather than about a field in the body; +//! 2. **standing** (`S-C8`) — a suspended account may not write, whoever the manifest names; +//! 3. **write capability** — [`WriteAuthority::album_write_access`] answers +//! owner-or-writer-member for *this caller* on *this album*, and a reader, a former member and +//! a stranger get one indistinguishable `403`; +//! 4. **invariant 7, both halves** — `created_by_user` must be the caller, and +//! `created_by_device` must be a device in that caller's **own** published directory with an +//! `added_at` preceding the manifest. The account half stops a member attributing a write to +//! another account; the device half is the one an attacker cannot satisfy by editing a field, +//! because a device id in your directory is not something another account can borrow. +//! +//! And what the server does **not** do, stated plainly so invariant 7 is not read as more than +//! it is: it holds no keys and never parses `manifest_cbor`, so it cannot check `device_sig` at +//! all. It checks that the *claimed* identity is the caller's and that the claimed device is +//! one the caller published — never that the signature over those bytes is real. Deciding +//! whether a stored record is authentic is a key-holder's job and stays one, in `verify_asset` +//! on the client. +//! //! # A rejection writes nothing a client can observe //! //! The bundle's blobs are stored *before* the index is asked to apply the op, so a refusal can @@ -50,6 +92,7 @@ //! | `200` | kept, and now **static**. The retired handler picked its status at run time with `StatusCode::from_u16(result.status)`, which is why salvo-oapi could describe no responses at all and spargen refused the operation outright — and the value was unconditionally `200` every time | //! | `400` (envelope, action, amk) | kept, each with its own `error.*` code | //! | `403 error.upload.album_access_denied` | kept, and it now also answers an asset that is not the caller's — one value, because the asset id is client-chosen | +//! | `403 error.moderation.account_suspended` | **added.** A suspension removes the ability to write and a lifecycle op is a write; `POST /v1/upload` refused one from the start and this surface did not, which stopped being merely inconsistent when `S-C51` widened it from the owner to every writer member | //! | `409 error.upload.stale_revival` | kept — invariant 17, the status this surface exists to be able to give | //! | `401` | kept, and now the framework's | //! | `500` | kept | @@ -64,7 +107,7 @@ use serde::{Deserialize, Serialize}; use crate::auth::AccessToken; use crate::blob::ContentAddress; use crate::index::{LifecycleOp, OpAction, OpOutcome}; -use crate::store::{AlbumId, AssetId, OwnerId}; +use crate::store::{AlbumId, AssetId}; use crate::upload::envelope::{GateContext, GateReject, ManifestEnvelope, check_op}; use crate::upload::{AlbumWriteAccess, UploadContext}; @@ -175,6 +218,20 @@ pub enum OpRejection { code: &'static str, }, + /// The account is suspended (`S-C8`). + /// + /// The same status, code and reasoning as `POST /v1/upload`'s: a suspension removes the + /// ability to *write*, and a lifecycle op is a write. Distinct from the quota `403` and from + /// the permission one because the three send a client to three different screens, which is + /// what design/moderation.md asks a structured code for. + #[error("this account is suspended and cannot write")] + #[problem(status = 403, title = "Account suspended")] + AccountSuspended { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// The account is past its grace window, and this write would grow stored metadata. /// /// Never returned for a `delete` or a `trash-restore`: a user must be able to delete their @@ -211,12 +268,12 @@ pub enum OpRejection { pub async fn apply_op( Inject(upload): Inject, Inject(quota): Inject, + Inject(moderation): Inject, Auth(credential): Auth, Path(path): Path, Json(request): Json, ) -> Result, OpRejection> { let caller = credential.user.clone(); - let owner = OwnerId::new(caller.as_str()); let album = AlbumId::new(&path.album_id); // The envelope must agree with the path it arrived on. A contradiction is a client bug the @@ -228,17 +285,60 @@ pub async fn apply_op( )); } - // Invariant 6, the half only the authority can answer. - let AlbumWriteAccess::Writable { protocol_pin, .. } = upload + // Invariant 7's account half. `created_by_user` names the account whose device signed this + // record — a per-record fact, not the asset's creator — so on this surface it is the caller, + // and a mismatch is a caller attributing a write to somebody else. See the module docs. + // + // A `400` and the envelope-mismatch code, like every other field that contradicts what the + // request itself establishes: the album id in the path, the metadata hash over the bytes in + // hand. The `403`s here are about *capability*; this is a contradiction. + if request.manifest_envelope.created_by_user != caller.as_str() { + tracing::info!( + %caller, %album, + "a lifecycle write was refused: created_by_user is not the caller" + ); + return Err(OpRejection::invalid( + error_codes::UPLOAD_ENVELOPE_MISMATCH, + "created_by_user is not the authenticated caller", + )); + } + + // Account standing (`S-C8`), on the seam `POST /v1/upload` uses and for the same reason: a + // suspension removes the ability to write, and a lifecycle op is a write — the only one that + // never moves blob bytes, which is exactly why it was easy to miss. Checked before the + // authority, before the quota and before anything is stored. + let standing = moderation + .store() + .standing(&crate::store::UserId::new(caller.as_str())) + .await + .map_err(|error| { + tracing::error!(%error, %caller, "the moderation store could not answer"); + OpRejection::unavailable() + })?; + if !standing.may_write() { + tracing::info!(%caller, "a lifecycle write was refused: the account is suspended"); + return Err(OpRejection::AccountSuspended { + code: error_codes::MODERATION_ACCOUNT_SUSPENDED, + }); + } + + // Invariant 6, the half only the authority can answer — and the namespace the op is filed + // under, which is the album owner's whoever the caller is (`S-C51`): the owner's feed is the + // one every member's devices read. + let AlbumWriteAccess::Writable { + owner_id: owner, + protocol_pin, + .. + } = upload .authority() - .album_write_access(&owner, &album) + .album_write_access(&caller, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an album"); OpRejection::unavailable() })? else { - tracing::info!(%owner, %album, "a lifecycle write was refused: no write capability"); + tracing::info!(%caller, %album, "a lifecycle write was refused: no write capability"); return Err(OpRejection::album_access_denied()); }; @@ -395,7 +495,7 @@ pub async fn apply_op( &format!("amk_version regresses against the album's recorded epoch {stored}"), )), OpOutcome::NotFound => { - tracing::info!(%owner, asset = %asset_id, "a lifecycle write was refused: not this caller's asset"); + tracing::info!(%owner, asset = %asset_id, "a lifecycle write was refused: not this album's asset"); Err(OpRejection::album_access_denied()) } } @@ -496,7 +596,7 @@ impl OpRejection { } } - /// The album is not writable, or the asset is not this caller's. + /// The album is not writable, or the asset is not this album's. fn album_access_denied() -> Self { Self::AlbumAccessDenied { code: error_codes::UPLOAD_ALBUM_ACCESS_DENIED, diff --git a/capsule-server/src/routes/roster.rs b/capsule-server/src/routes/roster.rs new file mode 100644 index 00000000..306a85c5 --- /dev/null +++ b/capsule-server/src/routes/roster.rs @@ -0,0 +1,434 @@ +//! `PUT /v1/albums/{album_id}/roster` — publishing an album's membership roster (slice `S-C51`). +//! +//! The one endpoint that tells the key-free server who may read and write a shared album. +//! [`crate::membership`] owns the port and the reasoning; this is the wire shape. +//! +//! ```text +//! PUT /v1/albums/{album_id}/roster { "roster_cbor": "" } +//! +//! 200 { "album_id": …, "roster_version": 3, "amk_epoch": 2, "member_count": 4, "replayed": false } +//! 400 error.album.roster_malformed +//! 400 error.album.roster_version_leap + current_version, max_version +//! 403 error.album.roster_attester +//! 404 error.album.roster_not_found +//! 409 error.album.roster_stale + current_version +//! 500 error.album.unavailable +//! ``` +//! +//! **Only the owner account publishes.** The trust anchor is the album owner's published device +//! directory — the same anchor the upgrade ceremony verifies its intent against — so the caller +//! must be the album's owner, the roster's `attested_by_user` must be the caller, and the +//! attesting device must be a live device in that directory. A member who tries, even one the +//! MLS group calls an admin, gets the album ceremonies' `404`: not yours is not found. +//! +//! **JSON with base64 CBOR, not `application/cbor`.** The signed bytes are canonical CBOR and +//! must reach the server verbatim, which a CBOR body would carry more directly — but spargen +//! cannot lower `application/cbor`, so a CBOR operation is one the generated SDK cannot call +//! and the client library would need a hand-written request for. Base64 inside a JSON field +//! keeps the bytes verbatim *and* the operation generated. +//! +//! **Removal is a new roster that omits the member.** There is no delete; the epoch bump that +//! accompanies an MLS `Remove` rides `amk_epoch`, and the store records the version and epoch at +//! which the member vanished — the stored fact a former member's blob-route `403` is rendered +//! from once that route consults membership. +//! +//! **Removal reclaims nothing, and this call is where an operator would expect it to.** What a +//! removed writer member uploaded stays in the owner's album and stays charged to the removed +//! member's quota; only the refcount collector releases an attribution, and only once the owner +//! deletes the asset. Whether that is right is a product question no design document answers +//! (issue #473); what this endpoint does is exactly what it says — it records who may read and +//! write, and nothing else. +//! +//! **Idempotent under `(album_id, roster_version)`.** The same bytes again are a `200` with +//! `replayed: true`; the same version with different bytes is the `409`. +//! +//! **The version is bounded above, too.** Strict monotonicity alone lets one publish latch the +//! counter at a value nothing can ever exceed, freezing the album's membership for good. A +//! version more than [`MAX_ROSTER_VERSION_STEP`](crate::membership::MAX_ROSTER_VERSION_STEP) +//! above the held one is refused with `error.album.roster_version_leap` and the held version, so +//! the owner re-signs at `current_version + 1` and says exactly the same thing. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use capsule_core::crypto::membership::SignedAlbumRoster; +use capsule_i18n::error_codes; +use kynos::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::album::AlbumContext; +use crate::auth::AccessToken; +use crate::directory::DeviceDirectoryContext; +use crate::membership::{MemberRole, MembershipContext, RosterOutcome, RosterRecord}; +use crate::routes::albums::AlbumsTag; +use crate::routes::upgrade::AlbumPath; +use crate::store::{AlbumId, UserId}; + +/// The largest signed roster this surface accepts, decoded. +/// +/// A roster member is a UUID and a role — well under a hundred bytes each in canonical CBOR — +/// so 512 KiB is several thousand members and still far below the transport backstop +/// (`S-C33`, 32 MiB), which is not a bound on a membership document. +pub const MAX_ROSTER_BYTES: usize = 512 * 1024; + +/// The publish request. +#[derive(Schema, Serialize, Deserialize, Debug, Clone)] +#[serde(deny_unknown_fields)] +pub struct RosterRequest { + /// The signed roster, as standard base64 of its canonical CBOR encoding. + pub roster_cbor: String, +} + +/// What the server now holds for the album. +#[derive(Schema, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct RosterResponse { + /// The album, echoed. + pub album_id: String, + /// The roster version the server holds after this call. + pub roster_version: u64, + /// The AMK epoch that roster reflects. + pub amk_epoch: u64, + /// How many members the held roster names, the owner excluded. + pub member_count: u64, + /// Whether this call was a replay of the roster already held. Advisory: both answers mean + /// "the server holds this roster". + pub replayed: bool, +} + +/// Why a roster was not published. +#[derive(Debug, thiserror::Error, ApiError)] +pub enum RosterRejection { + /// The body is not a signed roster this server can accept for this album. + #[error("{detail}")] + #[problem(status = 400, title = "Malformed roster")] + Malformed { + /// What was wrong, in English. + detail: String, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The roster is not attested by a live device in the album owner's published directory. + #[error("the roster's attester could not be verified")] + #[problem(status = 403, title = "Attester not authorized")] + AttesterNotAuthorized { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// No such album, or not this caller's. One answer for both. + #[error("no such album")] + #[problem(status = 404, title = "Not found")] + NotFound { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The roster's version is so far above the held one that accepting it would put the + /// counter out of reach of every later publish. + /// + /// A `400` rather than the `409` a stale roster gets, and the distinction is the one the + /// two statuses carry everywhere else on this surface: a `409` is a client that is + /// *behind* the server and must re-read, while this is a document the server would refuse + /// whatever it held — a version that does not follow from the one before it is a + /// structural fault in the document, in the same family as a roster naming the wrong + /// album. The held version rides it anyway, because the client's repair is to re-sign at + /// `current_version + 1`. + #[error( + "roster version {declared} is past {max_version}, the highest this album will accept \ + while it holds version {current_version}" + )] + #[problem(status = 400, title = "Roster version leap")] + VersionLeap { + /// The version the document declared. + /// + /// **Not** an extension member, deliberately: it is the only number on this response + /// the caller controls, and it is unbounded — a document may declare `u64::MAX`. Every + /// integer spargen lowers from this contract becomes an `i64` (it emits no `u64` at + /// all, `format: uint64` or not), so echoing an out-of-range one as a JSON number makes + /// the generated client fail to *decode* the refusal, which discards the `code` and the + /// recovery hint and leaves a caller unable to tell a refusal from a network fault. It + /// rides the English `detail` instead, where a human can read it and no decoder has to + /// parse it — and the caller already knows what it declared. + declared: u64, + /// The version the server holds; `0` when it holds no roster. Never above + /// [`MAX_ROSTER_VERSION`](crate::membership::MAX_ROSTER_VERSION), so it always decodes. + #[problem(extension)] + current_version: u64, + /// The highest version this album would have accepted. Bounded by the same ceiling. + #[problem(extension)] + max_version: u64, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// The server already holds a roster this one does not supersede. + #[error("the server holds roster version {current_version}, which this does not supersede")] + #[problem(status = 409, title = "Roster stale")] + Stale { + /// The version the server holds. + #[problem(extension)] + current_version: u64, + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + + /// A collaborator could not answer. + #[error("the roster could not be recorded")] + #[problem(status = 500, title = "Internal server error")] + Unavailable { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, +} + +impl RosterRejection { + /// The request was not a well-formed roster for this album. + fn malformed(detail: impl Into) -> Self { + Self::Malformed { + detail: detail.into(), + code: error_codes::ALBUM_ROSTER_MALFORMED, + } + } + + /// The attester did not verify. + fn attester() -> Self { + Self::AttesterNotAuthorized { + code: error_codes::ALBUM_ROSTER_ATTESTER, + } + } + + /// No such album, or not this caller's. + fn not_found() -> Self { + Self::NotFound { + code: error_codes::ALBUM_ROSTER_NOT_FOUND, + } + } + + /// A collaborator could not answer. + fn unavailable() -> Self { + Self::Unavailable { + code: error_codes::ALBUM_UNAVAILABLE, + } + } +} + +/// Decode and shape-check a request into the signed roster and its verbatim bytes. +/// +/// Everything here is decidable from the request alone: the encoding, the size, that the +/// document is for the album in the path, and that the member list is one the store can take +/// as a set. The account-level checks — owner, attester — need stores and follow. +fn decode( + request: &RosterRequest, + album: &AlbumId, +) -> Result<(SignedAlbumRoster, Vec), RosterRejection> { + // The cap is on the decoded document, and it is applied to the encoded string first so an + // oversized body is refused before it is decoded into a second buffer: base64 inflates by a + // third, so anything longer than this cannot decode to under the cap. + if request.roster_cbor.len() > MAX_ROSTER_BYTES / 3 * 4 + 4 { + return Err(RosterRejection::malformed(format!( + "a roster may be at most {MAX_ROSTER_BYTES} bytes" + ))); + } + let bytes = BASE64 + .decode(&request.roster_cbor) + .map_err(|_| RosterRejection::malformed("roster_cbor is not standard base64"))?; + if bytes.len() > MAX_ROSTER_BYTES { + return Err(RosterRejection::malformed(format!( + "a roster may be at most {MAX_ROSTER_BYTES} bytes" + ))); + } + let Ok(signed) = capsule_core::cbor::from_slice::(&bytes) else { + return Err(RosterRejection::malformed( + "roster_cbor is not a signed album roster", + )); + }; + // The bytes are stored verbatim and a replay is decided on them, so they must be the one + // canonical encoding: a non-canonical or trailing-garbage document would verify (the + // signature covers the re-encoded roster) and then make its own re-encoding a `409`. + if capsule_core::cbor::canonicalize(&bytes).ok().as_deref() != Some(bytes.as_slice()) { + return Err(RosterRejection::malformed( + "roster_cbor is not canonical CBOR", + )); + } + if signed.roster.album_id.to_string() != album.as_str() { + return Err(RosterRejection::malformed( + "the roster's album_id is not the album this request was addressed to", + )); + } + if signed + .roster + .members + .iter() + .any(|member| member.user_id == signed.roster.attested_by_user) + { + return Err(RosterRejection::malformed( + "the owner is not listed on their own roster", + )); + } + let mut seen = std::collections::BTreeSet::new(); + if !signed + .roster + .members + .iter() + .all(|member| seen.insert(member.user_id)) + { + return Err(RosterRejection::malformed( + "a roster lists each account at most once", + )); + } + Ok((signed, bytes)) +} + +/// Publish the caller's roster for one of their albums. +#[kynos::put( + "/v1/albums/{album_id}/roster", + operation_id = "publish_album_roster", + tag = AlbumsTag +)] +pub async fn publish_album_roster( + Inject(albums): Inject, + Inject(directories): Inject, + Inject(membership): Inject, + Auth(credential): Auth, + Path(path): Path, + Json(request): Json, +) -> Result, RosterRejection> { + let user = UserId::new(credential.user.as_str()); + let album = AlbumId::new(&path.album_id); + + let (signed, bytes) = decode(&request, &album)?; + + // The album must be the caller's. Before the attester check, and answered as not-found, so + // a member holding a valid roster for somebody else's album learns nothing about whether + // the owner's directory would have accepted it. + let record = albums.albums().read(&album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a roster publish"); + RosterRejection::unavailable() + })?; + match record { + Some(record) if record.owner_id.as_str() == user.as_str() => {} + _ => { + tracing::info!(%user, %album, "a roster was refused: no such album, or not the caller's"); + return Err(RosterRejection::not_found()); + } + } + if signed.roster.attested_by_user.to_string() != user.as_str() { + tracing::info!(%user, %album, "a roster was refused: attested_by_user is not the caller"); + return Err(RosterRejection::attester()); + } + + // The attester, against the owner's published directory (`S-C42`'s anchor), exactly as the + // upgrade ceremony verifies its proposer. Without this any holder of the owner's token could + // rewrite who may read the album by PUTting a struct. + let published = directories + .store() + .fetch(&user) + .await + .map_err(|error| { + tracing::error!(%error, %user, "the directory store could not answer a roster publish"); + RosterRejection::unavailable() + })? + .ok_or_else(|| { + tracing::info!(%user, "a roster was refused: no published device directory"); + RosterRejection::attester() + })?; + let Ok(directory) = capsule_core::cbor::from_slice::( + &published.document, + ) else { + // A document this server itself accepted and can no longer read. Its own inconsistency, + // answered as an outage rather than as the caller's fault. + tracing::error!(%user, "a stored device directory does not decode"); + return Err(RosterRejection::unavailable()); + }; + if let Err(error) = signed.verify(&directory) { + tracing::info!(%user, %album, %error, "a roster's attester did not verify"); + return Err(RosterRejection::attester()); + } + + let members: Vec<(UserId, MemberRole)> = signed + .roster + .members + .iter() + .map(|member| (UserId::new(member.user_id.to_string()), member.role)) + .collect(); + let outcome = membership + .members() + .apply_roster( + RosterRecord { + album_id: album.clone(), + roster_version: signed.roster.roster_version, + amk_epoch: u64::from(signed.roster.amk_epoch.0), + attested_by_device: signed.roster.attested_by_device, + received_at: membership.clock().now(), + document: bytes, + }, + members, + ) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not apply a roster"); + RosterRejection::unavailable() + })?; + + let member_count = u64::try_from(signed.roster.members.len()).unwrap_or(u64::MAX); + match outcome { + RosterOutcome::Applied(record) => Ok(Json(describe(&record, member_count, false))), + RosterOutcome::Replayed(record) => Ok(Json(describe(&record, member_count, true))), + RosterOutcome::Stale { current_version } => Err(RosterRejection::Stale { + current_version, + code: error_codes::ALBUM_ROSTER_STALE, + }), + RosterOutcome::VersionLeap { + current_version, + max_version, + } => { + tracing::info!( + %album, + declared = signed.roster.roster_version, + current_version, + max_version, + "a roster was refused: its version is past the window above the held one" + ); + Err(RosterRejection::VersionLeap { + declared: signed.roster.roster_version, + current_version, + max_version, + code: error_codes::ALBUM_ROSTER_VERSION_LEAP, + }) + } + RosterOutcome::EpochRegressed { + current_version, + stored, + } => { + tracing::info!( + %album, + stored_epoch = stored, + submitted_epoch = signed.roster.amk_epoch.0, + "a roster was refused: its AMK epoch regressed" + ); + // The held version, so the client's action is the same re-sync a stale version asks + // for: the roster it holds is not the one the server does. + Err(RosterRejection::Stale { + current_version, + code: error_codes::ALBUM_ROSTER_STALE, + }) + } + } +} + +/// The response an accepted roster renders. +fn describe(record: &RosterRecord, member_count: u64, replayed: bool) -> RosterResponse { + RosterResponse { + album_id: record.album_id.as_str().to_owned(), + roster_version: record.roster_version, + amk_epoch: record.amk_epoch, + member_count, + replayed, + } +} diff --git a/capsule-server/src/routes/sync.rs b/capsule-server/src/routes/sync.rs index 56b3485f..fb20ad34 100644 --- a/capsule-server/src/routes/sync.rs +++ b/capsule-server/src/routes/sync.rs @@ -41,9 +41,10 @@ use serde::{Deserialize, Serialize}; use crate::auth::AccessToken; use crate::blob::ContentAddress; use crate::index::{ChangeKind, FeedEntry}; +use crate::membership::Membership; use crate::routes::upload::WireBlobRole; -use crate::store::OwnerId; -use crate::sync::{CursorError, MAX_MANIFEST_BYTES, SyncContext, clamp_page_size}; +use crate::store::{AlbumId, OwnerId, UserId}; +use crate::sync::{CursorError, CursorScope, MAX_MANIFEST_BYTES, SyncContext, clamp_page_size}; /// The operation that tells a client what changed. #[derive(Tag)] @@ -71,6 +72,13 @@ pub struct SyncQuery { /// right to — a schema whose bounds depend on the server's pointer size is a schema no /// client can rely on. pub page_size: Option, + /// One album's page rather than the caller's own feed (`S-C51`). + /// + /// For the album's owner or any account on its current roster. Positions are the owner's + /// sequence numbers filtered to the album, and the cursor is bound to `(caller, album)`, so + /// it cannot be presented on the caller's own feed or on another album. Absent: the caller's + /// own library, as before. + pub album_id: Option, } /// What an entry is, relative to the client that asked for it. @@ -173,6 +181,17 @@ pub enum SyncRejection { code: &'static str, }, + /// The album is not the caller's and the caller is not on its roster (`S-C51`). + /// + /// One answer for unprovisioned, never-a-member and removed alike, as the write routes give. + #[error("no access to that album")] + #[problem(status = 403, title = "Album access denied")] + AlbumAccessDenied { + /// The stable catalog code. + #[problem(extension)] + code: &'static str, + }, + /// A collaborator could not answer. #[error("the sync feed could not be read")] #[problem(status = 500, title = "Internal server error")] @@ -184,6 +203,13 @@ pub enum SyncRejection { } impl SyncRejection { + /// The album page is not the caller's to read. + fn album_access_denied() -> Self { + Self::AlbumAccessDenied { + code: error_codes::SYNC_ALBUM_ACCESS_DENIED, + } + } + /// The one cursor rejection. fn cursor_invalid() -> Self { Self::CursorInvalid { @@ -214,14 +240,28 @@ pub async fn sync_feed( Auth(credential): Auth, Query(query): Query, ) -> Result, SyncRejection> { - // The feed is owner-scoped and the owner is the caller. There is no on-behalf read here for - // the same reason there is no on-behalf upload: the port that would verify the relationship - // does not exist, and inventing one at the read side would be the more dangerous half. + // The caller's own feed, or — with `album_id` — one album's page, which the caller reads as + // its owner or as a member of its current roster (`S-C51`). The relationship is decided + // first, and one refusal covers unprovisioned, not-a-member and removed alike. let owner = OwnerId::new(credential.user.as_str()); + let album = query.album_id.as_deref().map(AlbumId::new); + // The album's owner, from the album record: the page is bound to the rows that account + // filed, which is also what the index is keyed on. + let album = match album { + Some(album) => { + let filed_by = album_read_access(&sync, &credential.user, &album).await?; + Some((album, filed_by)) + } + None => None, + }; + let scope = match &album { + Some((album, _)) => CursorScope::album(&owner, album), + None => CursorScope::feed(&owner), + }; let after = sync .cursors() - .decode(&owner, query.cursor.as_deref()) + .decode(&scope, query.cursor.as_deref()) .map_err(|error| { // Logged at `info`, not `warn`: a cursor that stopped authenticating is the normal // consequence of a key rotation, and an operator who has just rotated should not be @@ -239,16 +279,24 @@ pub async fn sync_feed( .page_size .map(|size| usize::try_from(size).unwrap_or(usize::MAX)), ); - let rows = sync - .index() - .feed_page(&owner, after, limit) - .await - .map_err(|error| { - tracing::error!(%error, %owner, "the asset index could not serve a feed page"); - SyncRejection::unavailable() - })?; + let rows = match &album { + Some((album, filed_by)) => { + sync.index() + .album_feed_page(filed_by, album, after, limit) + .await + } + None => sync.index().feed_page(&owner, after, limit).await, + } + .map_err(|error| { + tracing::error!(%error, %owner, "the asset index could not serve a feed page"); + SyncRejection::unavailable() + })?; - let head = sync.index().head_seq(&owner).await.map_err(|error| { + let head = match &album { + Some((album, filed_by)) => sync.index().album_head_seq(filed_by, album).await, + None => sync.index().head_seq(&owner).await, + } + .map_err(|error| { tracing::error!(%error, %owner, "the asset index could not report its head"); SyncRejection::unavailable() })?; @@ -270,13 +318,54 @@ pub async fn sync_feed( Ok(Json(SyncPageResponse { entries, - next_cursor: sync.cursors().encode(&owner, position), + next_cursor: sync.cursors().encode(&scope, position), // Strictly greater: `position == head` is a caught-up client, and telling it otherwise // would make every idle client poll one extra time forever. has_more: head > position, })) } +/// Whether `caller` may read `album`'s page — its owner, or an account on its current roster — +/// answering the album's owner, whose rows the page is. +/// +/// One `403` for every refusal — unprovisioned, never a member, removed — matching the write +/// routes' uniform `album_access_denied`: the album id is client-derived and unguessable, and a +/// distinct answer per reason would say whether it is taken and whether the caller was ever on +/// it. (An unprovisioned id costs one store read and the other two cost two; with a UUIDv7 id +/// space that timing difference buys a guesser nothing, as it does not on the write path.) A +/// store that cannot answer is an outage, never a refusal. +async fn album_read_access( + sync: &SyncContext, + caller: &UserId, + album: &AlbumId, +) -> Result { + let record = sync.albums().read(album).await.map_err(|error| { + tracing::error!(%error, %album, "the album store could not answer a sync page"); + SyncRejection::unavailable() + })?; + let Some(record) = record else { + tracing::info!(%caller, %album, "an album page was refused: no such album"); + return Err(SyncRejection::album_access_denied()); + }; + if record.owner_id.as_str() == caller.as_str() { + return Ok(record.owner_id); + } + match sync + .members() + .membership(album, caller) + .await + .map_err(|error| { + tracing::error!(%error, %album, "the membership store could not answer a sync page"); + SyncRejection::unavailable() + })? { + Membership::Member { .. } => Ok(record.owner_id), + Membership::Revoked(_) | Membership::Never => { + tracing::info!(%caller, %album, "an album page was refused: not a member"); + Err(SyncRejection::album_access_denied()) + } + } +} + /// Render one index entry onto the wire, reading its manifest bytes. async fn render(sync: &SyncContext, entry: FeedEntry) -> SyncEntry { let deleted = entry.change == ChangeKind::Deleted; diff --git a/capsule-server/src/routes/upload.rs b/capsule-server/src/routes/upload.rs index cd13c4fe..c9dd2a0c 100644 --- a/capsule-server/src/routes/upload.rs +++ b/capsule-server/src/routes/upload.rs @@ -17,10 +17,10 @@ //! | create `200` (active session for the tuple) | **kept and now documented.** Salvo declared it `undocumented()`; it is [`CreateReply::Existing`], carrying `X-Capsule-Offset` so a resuming client needs no second round trip | //! | create `400` "Bad request" (untyped) | **deleted.** Never constructed with a code; the 400 a malformed body actually produces is the `Json` extractor's, which Kynos declares | //! | create `401` | kept, and now the framework's — `Auth` declares it and fills the `WWW-Authenticate` challenge | -//! | create `403` | kept — album access, device authorization, on-behalf refusal | +//! | create `403` | kept — album access, device authorization, a declared owner that is not the album's | //! | create `409 duplicate_blob` | **restored, with `S-C22`'s structured `existing_asset`.** It was deleted while this crate had no asset index, because it must name the existing asset and answering from blob presence alone would tell one account what another holds. `S-C37` answers it honestly and owner-scoped | //! | create `413` | kept — the declared size past the deployment ceiling | -//! | create `426` | kept — the protocol handshake, now with the accepted window as problem extensions | +//! | create `426` | kept — the manifest envelope's `protocol_version` pin, refused by the envelope gate. The *header* handshake is no longer this surface's: [`crate::negotiation::ProtocolGate`] answers it before the handler runs, and the accepted window rides `X-Capsule-Protocol-Min`/`-Max` on every response | //! | create `500` | kept — a collaborator that could not answer, with `error.upload.unavailable` | //! | chunk `204` | kept — with the authoritative `X-Capsule-Offset` | //! | chunk `400` | kept, and now *coded*: missing offset, missing checksum, checksum mismatch, empty chunk, misalignment, size exceeded, and the two finalization failures each carry their own `error.upload.*` | @@ -32,7 +32,7 @@ //! | chunk `409 finalize_in_progress` | **deleted.** Losing the finalize claim is a normal race and the chunk that triggered it was still accepted, so it answers `204`. Telling a client its accepted chunk failed was the Salvo behaviour and it was wrong | //! | chunk `500` | kept — storage inconsistency (the stage disagreeing with the counter) and collaborator failure | //! | head `200` | kept — [`HeadReply::Progress`], carrying offset, declared length and state on headers, with `Cache-Control: no-store`. A `Reply` rather than a `NoContent`, because `200 with headers` and `204` are different answers | -//! | head `400` / `401` / `403` / `404` / `426` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look | +//! | head `400` / `401` / `403` / `404` / `500` | kept; the `403` now covers the owner as well as the uploader, both of whom may look. The `400` is the handshake's, declared by the read gate rather than by this surface; the `426` is gone from `HEAD`, because a read is admitted at any protocol date (issue #404) | //! | head `409` | **deleted as unreachable.** `HEAD` reports a state, it does not require one. It would have been declared for free by sharing a rejection type with `DELETE`, which is why they are two types | //! | delete `204` | kept | //! | delete `409` | kept — finalization is not interruptible, and a terminal session has nothing left to cancel | @@ -43,15 +43,19 @@ //! Every status above is produced by a test in `tests/upload.rs`, because //! `assert_declared_responses_covered` fails on any the document promises and none produced. //! -//! # Two places the protocol asks for a header this surface cannot send +//! # One place the protocol asks for a header this surface cannot send //! //! A Kynos `ApiError` renders an RFC 9457 problem and has **no seam for a response header**, so -//! the two headers the protocol's census puts on *rejections* — `X-Capsule-Offset` on a `409` -//! and `X-Capsule-Protocol-Min`/`-Max` on a `426` — ride as problem **extension members** -//! instead. The data a client needs to recover is there and is machine-readable; the spelling -//! is not the one the census names. The alternative was to render those two rejections as -//! plain-JSON `Reply` variants, which would have cost them their `error.*` code — a worse -//! trade, since the code is what a client switches on. Recorded rather than hidden. +//! the `X-Capsule-Offset` the protocol's census puts on a `409` rides as a problem **extension +//! member** instead. The data a client needs to recover is there and is machine-readable; the +//! spelling is not the one the census names. The alternative was to render the rejection as a +//! plain-JSON `Reply` variant, which would have cost it its `error.*` code — a worse trade, +//! since the code is what a client switches on. Recorded rather than hidden. +//! +//! The `X-Capsule-Protocol-Min`/`-Max` pair used to be the second such place. It is not any +//! more: issue #404 moved the handshake onto [`crate::negotiation`], whose advertising +//! interceptor sits outside every rejection and stamps the window on all of them. The seam an +//! `ApiError` lacks, an `Interceptor` has. //! //! # `409 duplicate_blob` refuses, and nothing yet adopts //! @@ -183,10 +187,12 @@ pub struct CreateUploadRequest { /// for the album-upgrade ceremony; **required by this server**, which has no way to check /// invariant 6 without one and refuses rather than skipping it. pub album_id: Option, - /// The owner the asset is filed under, when it is not the uploader. + /// The owner the asset is filed under, when the client wants to say so. /// - /// Refused when it is anyone but the uploader: an on-behalf upload needs a verified - /// relationship, and the port that would answer for one does not exist here. + /// Advisory, never decisive: the asset is filed under the **album's** owner, which the write + /// authority answers from the album record — the uploader when it is their album, the owner + /// when the uploader is a writer on its roster (`S-C51`). A declared owner that is anyone + /// else, the uploading member included, is refused `error.upload.owner_not_permitted`. pub owner_id: Option, /// The album-upgrade intent this write belongs to, when it belongs to one. /// @@ -243,23 +249,13 @@ pub struct CreateHeaders { offset: Option, } -/// The `X-Capsule-Protocol` handshake header, on every upload request. -#[derive(HeaderParams)] -pub struct ProtocolHeader { - /// The protocol date the client speaks. - /// - /// Read as a string rather than a typed value so that a malformed one is *this* surface's - /// coded `400` rather than the framework's uncoded one. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, -} - /// The headers a chunk carries. +/// +/// The `X-Capsule-Protocol` handshake is not among them: [`crate::negotiation::ProtocolGate`] +/// reads and declares it for every operation on this surface, so a chunk handler only sees a +/// request the handshake already admitted. #[derive(HeaderParams)] pub struct ChunkHeaders { - /// The protocol date the client speaks. - #[header(rename = "X-Capsule-Protocol")] - protocol: Option, /// Where in the blob this chunk starts. #[header(rename = "X-Capsule-Offset")] offset: Option, @@ -347,19 +343,6 @@ pub enum CreateRejection { code: &'static str, }, - /// The request is not one this surface can read — a missing or unreadable handshake - /// header, most often. - #[error("the request is not a well-formed upload: {detail}")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// What was wrong, in English. Reaches the client as the problem's `detail`, via - /// `Display`, rather than as a second extension member saying the same thing. - detail: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The account is suspended (`S-C8`). /// /// Distinct from a quota refusal and from a permission one, deliberately: the three send a @@ -374,21 +357,16 @@ pub enum CreateRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the window this server accepts. + /// Invariant 1: the manifest envelope pins a `protocol_version` outside the window this + /// server accepts. /// - /// The accepted range rides as problem extensions rather than as the - /// `X-Capsule-Protocol-Min`/`-Max` headers the protocol's census names: a Kynos `ApiError` - /// has no seam for a response header, and a client that cannot read the window cannot show - /// the actionable "update to keep uploading". Recorded as a deviation rather than dropped. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] + /// The header handshake never reaches here — the gate answered it — so this is the + /// *body's* pin, which an album carries for life. The accepted window is not restated as + /// extension members: it rides `X-Capsule-Protocol-Min`/`-Max` on this response like every + /// other, which is where the SDK reads it. + #[error("the envelope pins a protocol version this server does not accept")] #[problem(status = 426, title = "Protocol version unsupported")] ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, /// The stable catalog code. #[problem(extension)] code: &'static str, @@ -471,9 +449,9 @@ pub enum CreateRejection { /// Why a chunk was not accepted, or the finalization it triggered did not commit. #[derive(Debug, thiserror::Error, ApiError)] pub enum ChunkRejection { - /// The request is not a well-formed chunk: a missing handshake header, a missing or - /// unreadable offset or checksum, an empty body, a misaligned chunk, a checksum that does - /// not match the bytes, or bytes past the declared size. + /// The request is not a well-formed chunk: a missing or unreadable offset or checksum, an + /// empty body, a misaligned chunk, a checksum that does not match the bytes, or bytes past + /// the declared size. #[error("{detail}")] #[problem(status = 400, title = "Invalid chunk")] Invalid { @@ -485,21 +463,6 @@ pub enum ChunkRejection { code: &'static str, }, - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// Only the uploader may append to a session. #[error("this session belongs to another uploader")] #[problem(status = 403, title = "Not the uploader")] @@ -618,30 +581,6 @@ pub enum ChunkRejection { /// afterwards. Two identical enums would be two places for the answers to drift apart. #[derive(Debug, thiserror::Error, ApiError)] pub enum SessionRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -679,30 +618,6 @@ pub enum SessionRejection { /// exact `S-C28` defect this rebuild removes. #[derive(Debug, thiserror::Error, ApiError)] pub enum CancelRejection { - /// The handshake header is missing or is not a protocol date. - #[error("X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request")] - #[problem(status = 400, title = "Malformed request")] - MalformedRequest { - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - - /// Invariant 1: the protocol version is outside the accepted window. - #[error("this server accepts protocol versions [{protocol_min}, {protocol_max}]")] - #[problem(status = 426, title = "Protocol version unsupported")] - ProtocolUnsupported { - /// The lowest version this server accepts. - #[problem(extension)] - protocol_min: String, - /// The highest version this server accepts. - #[problem(extension)] - protocol_max: String, - /// The stable catalog code. - #[problem(extension)] - code: &'static str, - }, - /// The caller is neither the session's uploader nor the owner it files under. #[error("this session belongs to another account")] #[problem(status = 403, title = "Not this caller's session")] @@ -763,16 +678,6 @@ impl CancelRejection { impl From for CancelRejection { fn from(rejection: SessionRejection) -> Self { match rejection { - SessionRejection::MalformedRequest { code } => Self::MalformedRequest { code }, - SessionRejection::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - } => Self::ProtocolUnsupported { - protocol_min, - protocol_max, - code, - }, SessionRejection::Forbidden { code } => Self::Forbidden { code }, SessionRejection::SessionNotFound { code } => Self::SessionNotFound { code }, SessionRejection::Unavailable { code } => Self::Unavailable { code }, @@ -781,12 +686,6 @@ impl From for CancelRejection { } impl SessionRejection { - fn malformed_request() -> Self { - Self::MalformedRequest { - code: error_codes::UPLOAD_MALFORMED_REQUEST, - } - } - fn forbidden() -> Self { Self::Forbidden { code: error_codes::UPLOAD_FORBIDDEN, @@ -822,11 +721,8 @@ pub async fn create_upload( Inject(quota): Inject, Inject(moderation): Inject, Auth(credential): Auth, - Headers(handshake): Headers, Json(request): Json, ) -> Result, CreateRejection> { - handshake_ok(upload.policy(), handshake.protocol.as_deref())?; - let uploader = credential.user.clone(); // Account standing (`S-C8`), checked before anything is reserved. A suspension removes the @@ -848,28 +744,31 @@ pub async fn create_upload( }); } - let owner = resolve_owner(&uploader, request.owner_id.as_deref())?; - // Invariant 6, first half: this surface has no way to check an album it was not given. let Some(album) = request.album_id.as_deref().map(AlbumId::new) else { return Err(CreateRejection::album_access_denied()); }; let AlbumWriteAccess::Writable { + owner_id: owner, + role, protocol_pin, quiescing_under, } = upload .authority() - .album_write_access(&owner, &album) + .album_write_access(&uploader, &album) .await .map_err(|error| { tracing::error!(%error, "the write authority could not answer for an album"); CreateRejection::unavailable() })? else { - tracing::info!(%owner, %album, "an upload was refused: no write capability"); + tracing::info!(%uploader, %album, "an upload was refused: no write capability"); return Err(CreateRejection::album_access_denied()); }; + tracing::debug!(%uploader, %owner, ?role, %album, "the album admits this upload"); + // The namespace is the authority's answer; a declared owner may only agree with it. + resolve_owner(&uploader, &owner, request.owner_id.as_deref())?; // Upgrade quiescence (`S-C24`, versioning.md step 2). An album whose members have stopped // writing and are draining accepts **only** the ceremony's own writes, so a stale client that @@ -891,6 +790,40 @@ pub async fn create_upload( }); } + // Invariant 7's account half — and it belongs on **this** surface only. + // + // This endpoint admits exactly the two actions that move blob bytes, `create` and `replace` + // (the dispatch below; a write that moves bytes is an upload by definition). Both mint + // freshly authored ciphertext, and every client path that builds one sets the author to the + // signing account: `lifecycle/import.rs`, `lifecycle/drops.rs` and `drop/mod.rs` all write + // `created_by_user: self.account.user_id` — the last of them for an *adopted* web-upload + // drop, which design/web-upload.md is explicit about ("set `created_by_user`/ + // `created_by_device` to the **adopter** (the cryptographic author)"). So on this surface + // the author is the caller, and a mismatch is a client contradicting itself. + // + // `POST /v1/albums/{album_id}/ops` deliberately does **not** make this comparison: every + // action it admits is a chain continuation whose author travels down from the creator, so + // the same check there would refuse a writer member's delete of the owner's asset. See that + // module's docs. + // + // Without this, a writer member could file a *new* asset into the owner's album attributed + // to a third account: the manifest is stored verbatim and served back as provenance, and + // nothing later re-derives who wrote it. A `400` with the envelope-mismatch code, like every + // other field that contradicts what the request itself establishes. + // + // `replace` has no client builder in this tree yet (no `Action::Replace` is constructed + // anywhere under `capsule-core/src/lifecycle/`), so whether a replace re-mints its + // attribution or inherits it is still open — #475. It is held to the same rule as `create` + // here because it authors new ciphertext under a fresh file key, which is what makes an + // author an author on this surface. + if request.manifest_envelope.created_by_user != uploader.as_str() { + tracing::info!(%uploader, "an upload was refused: created_by_user is not the caller"); + return Err(CreateRejection::Invalid { + detail: "created_by_user is not the authenticated caller".to_owned(), + code: error_codes::UPLOAD_ENVELOPE_MISMATCH, + }); + } + // Invariant 7: the device the manifest names must be in the uploader's published // directory, and the battery compares the moment it was admitted against the manifest. let device = crate::upload::envelope::created_by_device(&request.manifest_envelope) @@ -1012,7 +945,7 @@ pub async fn create_upload( })? { // A new bundle, or a sibling session of one already open. Both are the normal case. crate::index::Reservation::Created(_) | crate::index::Reservation::Joined(_) => {} - // The id names a row this caller does not own, or one filed under a different album or + // The id names a row filed under another album's owner, or under a different album or // pin. Answered as a plain refusal carrying nothing: the id is client-chosen, so a // guess costs the caller nothing and must buy them nothing. crate::index::Reservation::Conflict => { @@ -1109,8 +1042,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 +1159,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 +1186,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 +1232,8 @@ pub async fn cancel_upload( async fn session_for( upload: &UploadContext, path: &UploadPath, - presented: Option<&str>, caller: &UserId, ) -> Result { - match handshake(upload.policy(), presented) { - Handshake::Ok => {} - Handshake::Missing | Handshake::Malformed => { - return Err(SessionRejection::malformed_request()); - } - Handshake::OutOfRange => { - return Err(SessionRejection::ProtocolUnsupported { - protocol_min: upload.policy().protocol_min().to_owned(), - protocol_max: upload.policy().protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }); - } - } - let id = UploadId::new(path.id.clone()); let record = upload .sessions() @@ -1350,90 +1252,24 @@ async fn session_for( Ok(record) } -/// The handshake, for an operation that answers with [`CreateRejection`]. -fn handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), CreateRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is required on every upload request".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::Malformed => Err(CreateRejection::MalformedRequest { - detail: "X-Capsule-Protocol is not a YYYY-MM-DD date".to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(CreateRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// The handshake, for an operation that answers with [`ChunkRejection`]. -fn chunk_handshake_ok( - policy: &crate::upload::UploadPolicy, - presented: Option<&str>, -) -> Result<(), ChunkRejection> { - match handshake(policy, presented) { - Handshake::Ok => Ok(()), - Handshake::Missing | Handshake::Malformed => Err(ChunkRejection::Invalid { - detail: "X-Capsule-Protocol must be a YYYY-MM-DD date on every upload request" - .to_owned(), - code: error_codes::UPLOAD_MALFORMED_REQUEST, - }), - Handshake::OutOfRange => Err(ChunkRejection::ProtocolUnsupported { - protocol_min: policy.protocol_min().to_owned(), - protocol_max: policy.protocol_max().to_owned(), - code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, - }), - } -} - -/// What the handshake header said. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Handshake { - /// Present and inside the accepted window. - Ok, - /// Absent. - Missing, - /// Present but not a `YYYY-MM-DD` date. - Malformed, - /// A date outside the accepted window. - OutOfRange, -} - -/// The one-shot compatibility gate: a client either speaks a version this server accepts, or -/// it does not upload. There is no negotiation and no degrade. -fn handshake(policy: &crate::upload::UploadPolicy, presented: Option<&str>) -> Handshake { - let Some(version) = presented else { - return Handshake::Missing; - }; - match capsule_core::validation::protocol_gate( - version, - policy.protocol_min(), - policy.protocol_max(), - ) { - Ok(()) => Handshake::Ok, - Err(capsule_core::validation::HandshakeReject::ProtocolOutOfRange) => Handshake::OutOfRange, - Err(_) => Handshake::Malformed, - } -} - -/// The owner an upload is filed under. +/// Check a declared `owner_id` against the namespace the authority filed the write under. /// -/// An on-behalf upload needs a verified relationship between two accounts, and the port that -/// would answer for one is not part of this slice. So it is refused rather than assumed: a -/// server that cannot check a permission must not act as though it passed. -fn resolve_owner(uploader: &UserId, declared: Option<&str>) -> Result { +/// The owner is **not** taken from the request: it is the album's own, answered by the write +/// authority from the album record, and an uploader who is a writer member of somebody else's +/// album is filed under that owner whether or not they said so (`S-C51`). What the request may +/// do is *agree* — name the album owner, or, when the uploader is the owner, themselves. Naming +/// anyone else is refused: a member declaring their own account as owner would be asking for an +/// asset the owner's feed never carries, and a stranger's declaration is not a permission. +fn resolve_owner( + uploader: &UserId, + owner: &OwnerId, + declared: Option<&str>, +) -> Result<(), CreateRejection> { match declared { - None => Ok(OwnerId::new(uploader.as_str())), - Some(owner) if owner == uploader.as_str() => Ok(OwnerId::new(owner)), + None => Ok(()), + Some(named) if named == owner.as_str() => Ok(()), Some(_) => { - tracing::info!(%uploader, "an on-behalf upload was refused: no relationship port"); + tracing::info!(%uploader, %owner, "an upload was refused: the declared owner is not the album's"); Err(CreateRejection::owner_not_permitted()) } } @@ -1530,8 +1366,6 @@ impl CreateRejection { "protocol_version is not a YYYY-MM-DD date", ), GateReject::ProtocolOutOfRange => Self::ProtocolUnsupported { - protocol_min: String::new(), - protocol_max: String::new(), code: error_codes::PROTOCOL_VERSION_UNSUPPORTED, }, GateReject::UnknownCryptoSuite => invalid( @@ -1596,7 +1430,7 @@ impl CreateRejection { fn owner_not_permitted() -> Self { Self::Forbidden { - detail: "uploading on behalf of another owner is not permitted".to_owned(), + detail: "the declared owner is not the album's owner".to_owned(), code: error_codes::UPLOAD_OWNER_NOT_PERMITTED, } } diff --git a/capsule-server/src/serve/authority.rs b/capsule-server/src/serve/authority.rs index e116a536..afbb25a4 100644 --- a/capsule-server/src/serve/authority.rs +++ b/capsule-server/src/serve/authority.rs @@ -1,8 +1,9 @@ -//! [`ReadAuthority`] — who may fetch a blob, and the `403` the contract asks for (`S-C39`). +//! [`ReadAuthority`] — who may fetch a blob, and the `403` the contract asks for (`S-C39`, +//! `S-C51`). //! -//! # The hole this closes +//! # The hole `S-C39` closed //! -//! Before this, `GET /v1/blob/{hash}` authorized on "a valid access token" and nothing else, on +//! Before it, `GET /v1/blob/{hash}` authorized on "a valid access token" and nothing else, on //! both the Salvo surface and its Kynos port. **Any authenticated account could fetch any live //! ciphertext whose address it could name.** That was defended as a capability model — a content //! address is the hash of ciphertext, so producing one without holding the bytes is producing a @@ -11,14 +12,14 @@ //! backup, a log, a screenshot of a debug tool, and the capability is permanent because the //! address is. //! -//! So the serve path now asks a question, and the question has a port. +//! So the serve path asks a question, and the question has a port. //! //! # Three answers, and the middle one is the whole disclosure argument //! //! | Answer | Status | What it tells the caller | //! | --- | --- | --- | //! | [`BlobReadAccess::Granted`] | `200`/`206` | the bytes | -//! | *(no variant yet — `S-C51`)* | `403` | *"you had this and you do not now"* — re-sync membership, then degrade | +//! | [`BlobReadAccess::Revoked`] | `403` | *"you had this and you do not now"* — re-sync membership, then degrade | //! | [`BlobReadAccess::Unrelated`] | `404` | nothing. Byte-identical to an address the server never heard of | //! //! **A `403` is a disclosure and a `404` is not**, which is why the boundary is drawn where it @@ -28,41 +29,32 @@ //! authorization *change*, and a change presupposes a prior state: the caller has to be someone //! the server can see once had access. Everyone else is told what an unknown address is told. //! -//! # What the production authority can actually decide today, stated plainly +//! # Where the middle row's fact comes from (`S-C51`) //! -//! [`OwnedAssetAuthority`] grants a fetch to the account the referencing asset is filed under, -//! and answers [`BlobReadAccess::Unrelated`] to everyone else. That is the whole of it, and it -//! is the whole of it because **this server has no record of album membership**: +//! [`MembershipAuthority`] grants a fetch to the account the referencing asset is filed under +//! and to any account on the current roster of the album it belongs to, in either role — a +//! reader reads, that is what the role is for. An account the roster once carried and no longer +//! does is [`BlobReadAccess::Revoked`]: the membership store keeps the row and marks it, rather +//! than deleting it, precisely so this answer has a stored fact behind it. An account the roster +//! never named is [`BlobReadAccess::Unrelated`], indistinguishable from a stranger, because it +//! is one. //! -//! - Album sharing between accounts is an MLS group. The server holds no key and cannot read -//! the roster, by design — that is the product, not a limitation of this module. -//! - The share and drop surfaces that *do* let a non-owner reach ciphertext serve it on their -//! own routes, from their own capabilities: `/s/{id}/blob/{hash}` serves exactly the addresses -//! its link record enumerates, and a revoked link is a `404` there. Neither of them routes -//! through here. +//! The roster itself is the album owner's signed statement, verified against the owner's +//! published device directory before it is stored ([`crate::membership`]). This server still +//! cannot read the MLS group, and the roster does not change that: it is a **transport** +//! control over who is handed bytes, not a confidentiality control over who can read them. //! -//! So the middle row has **no production source**, and this enum therefore does not carry a -//! variant for it and the blob route does not declare the `403`. That is the `S-C28` rule -//! applied to a status the author would have liked to have: an enum arm nothing produces and a -//! status nothing can reach are the same defect, and writing the taxonomy into a doc comment is -//! the honest way to keep the design without shipping the dead code. +//! The membership question is asked from the reference the index returned, which carries the +//! asset's `album_id` and `owner_id` for exactly this reason: the decision comes from the same +//! read that found the reference, so there is no window in which ownership and the answer +//! disagree. It costs one membership lookup per fetch by a non-owner and none for the owner. //! -//! What changed is the **shape of the gap**. It was "there is no read authority", a hypothesis -//! about missing code — a plausible afternoon's work that would have been wrong. It is now -//! "there is no membership fact", a named thing the *write* path wants too: -//! `AlbumWriteAccess::Denied` has been unable to widen from owner to member since `S-C25` for -//! exactly the same reason. One fact unblocks both. Filed as `S-C51`. -//! -//! **The takedown `410` was deliberately left alone**, and it is worth saying why, because -//! owner-scoping dissolved the argument that put it there. `crate::serve` justifies collapsing a -//! serving hold into `410` partly on the grounds that a distinguishable answer would make the -//! path a moderation oracle *for an anonymous fetcher* — and after `S-C39` the only caller who -//! can reach a held asset's blob is the account that owns it, so there is no anonymous fetcher -//! left to protect from. A `403` would arguably serve that owner better: a takedown is -//! reversible, the bytes are untouched, and `410` tells a client to degrade permanently. It is -//! not changed here because design/moderation.md states the per-surface rule as *"takedown of -//! known content → `410`"* and changing a landed, tested contract on an inference is not this -//! slice's to do. Recorded on `S-C39` for whoever owns that question. +//! **The takedown `410` was deliberately left alone** by `S-C39`, and the reasoning still holds +//! with members in the picture: design/moderation.md states the per-surface rule as *"takedown +//! of known content → `410`"*, and changing a landed, tested contract on an inference is not +//! this module's to do. What `S-C51` adds is that a *former* member is answered `403` before any +//! `410` is reached, so the authority-first ordering `S-C39` established keeps every policy +//! refusal illegible to anyone who is not currently entitled to the bytes. //! //! [Download & Sync]: ../../../capsule-docs/src/content/docs/design/import/download-sync.md @@ -72,7 +64,8 @@ use std::pin::Pin; use std::sync::Arc; use crate::index::BlobReference; -use crate::store::OwnerId; +use crate::membership::{Membership, MembershipStore}; +use crate::store::{OwnerId, UserId}; /// The future a read-authority question returns. /// @@ -107,6 +100,11 @@ impl ReadAuthorityError { pub enum BlobReadAccess { /// Serve them. Granted, + /// The caller was on the album's roster and has been removed (`S-C51`). + /// + /// Rendered as `403`: the one answer that discloses the address is live, given only to an + /// account the server holds a revoked membership row for. + Revoked, /// The caller has no relationship to the asset the server can see. /// /// Rendered as `404`, byte-identical to an address nothing references — which is the point. @@ -117,8 +115,8 @@ pub enum BlobReadAccess { /// /// A port rather than a function on the serve context, for the reason /// [`WriteAuthority`](crate::upload::WriteAuthority) is one: the facts it decides from live in -/// stores that will grow (membership, federation), and a serving path that reached into them -/// directly would have to grow with them. +/// stores that will grow (federation next), and a serving path that reached into them directly +/// would have to grow with them. pub trait ReadAuthority: fmt::Debug + Send + Sync { /// May `caller` fetch the bytes `reference` names? /// @@ -132,22 +130,22 @@ pub trait ReadAuthority: fmt::Debug + Send + Sync { ) -> ReadAuthorityFuture<'a, BlobReadAccess>; } -/// The authority the server runs on: an account reads its own assets' blobs. -/// -/// Holds nothing. The fact it decides on travels on the reference, which is deliberate — the -/// alternative is a store lookup per fetch to learn something the index already read. -#[derive(Debug, Clone, Copy, Default)] -pub struct OwnedAssetAuthority; +/// The authority the server runs on: an account reads its own assets' blobs and the blobs of +/// every album it is currently a member of. +#[derive(Debug, Clone)] +pub struct MembershipAuthority { + members: Arc, +} -impl OwnedAssetAuthority { - /// The authority. +impl MembershipAuthority { + /// The authority over `members`. #[must_use] - pub fn new() -> Self { - Self + pub fn new(members: Arc) -> Self { + Self { members } } } -impl ReadAuthority for OwnedAssetAuthority { +impl ReadAuthority for MembershipAuthority { fn blob_read_access<'a>( &'a self, caller: &'a OwnerId, @@ -157,33 +155,61 @@ impl ReadAuthority for OwnedAssetAuthority { if &reference.owner_id == caller { return Ok(BlobReadAccess::Granted); } - // Not `Revoked`. The caller never had it, and saying otherwise would confirm the - // address is live — see the module docs on why the boundary is here. - tracing::info!( - asset = %reference.asset_id, - "a blob fetch named an address belonging to another account" - ); - Ok(BlobReadAccess::Unrelated) + // Somebody else's asset: the album's roster decides. The store is asked with the + // caller's account id, which is the same string the owner id is. + let user = UserId::new(caller.as_str()); + let membership = self + .members + .membership(&reference.album_id, &user) + .await + .map_err(|error| { + tracing::error!(%error, album = %reference.album_id, "the membership store could not answer a fetch"); + ReadAuthorityError::unavailable(error.to_string()) + })?; + Ok(match membership { + // Either role reads: that is what a reader is. + Membership::Member { .. } => BlobReadAccess::Granted, + Membership::Revoked(revocation) => { + tracing::info!( + asset = %reference.asset_id, + album = %reference.album_id, + at_version = revocation.at_version, + "a former member's blob fetch was refused" + ); + BlobReadAccess::Revoked + } + // Never a member. Not `Revoked`: the caller never had it, and saying otherwise + // would confirm the address is live — see the module docs on the boundary. + Membership::Never => { + tracing::info!( + asset = %reference.asset_id, + "a blob fetch named an address belonging to another account" + ); + BlobReadAccess::Unrelated + } + }) }) } } /// A convenience for wiring the production authority. #[must_use] -pub fn owned_assets() -> Arc { - Arc::new(OwnedAssetAuthority::new()) +pub fn membership_reads(members: Arc) -> Arc { + Arc::new(MembershipAuthority::new(members)) } #[cfg(test)] mod tests { use super::*; use crate::index::AssetState; - use crate::store::{AssetId, BlobRole}; + use crate::membership::{InMemoryMembership, MemberRole, RosterRecord}; + use crate::store::{AlbumId, AssetId, BlobRole}; - /// A reference to `owner`'s asset. + /// A reference to `owner`'s asset in the one album these cases share. fn reference(owner: &str) -> BlobReference { BlobReference { asset_id: AssetId::new("asset"), + album_id: AlbumId::new("album"), owner_id: OwnerId::new(owner), role: BlobRole::Original, state: AssetState::Visible, @@ -192,49 +218,135 @@ mod tests { } } + /// The album's roster at `version`, naming `members`. + async fn roster(store: &InMemoryMembership, version: u64, members: &[(&str, MemberRole)]) { + store + .apply_roster( + RosterRecord { + album_id: AlbumId::new("album"), + roster_version: version, + amk_epoch: version, + attested_by_device: uuid::Uuid::from_u128(0xD1), + received_at: jiff::Timestamp::UNIX_EPOCH, + document: format!("v{version}").into_bytes(), + }, + members + .iter() + .map(|(user, role)| (UserId::new(*user), *role)) + .collect(), + ) + .await + .expect("the store applies"); + } + + /// An authority over a store where `bob` is a reader, `carol` a writer and `dave` a former + /// member of alice's album. + async fn authority() -> MembershipAuthority { + let store = Arc::new(InMemoryMembership::new()); + roster( + &store, + 1, + &[ + ("bob", MemberRole::Reader), + ("carol", MemberRole::Writer), + ("dave", MemberRole::Writer), + ], + ) + .await; + roster( + &store, + 2, + &[("bob", MemberRole::Reader), ("carol", MemberRole::Writer)], + ) + .await; + MembershipAuthority::new(store) + } + + async fn decide( + authority: &MembershipAuthority, + caller: &str, + reference: &BlobReference, + ) -> BlobReadAccess { + authority + .blob_read_access(&OwnerId::new(caller), reference) + .await + .expect("the authority decides") + } + #[tokio::test] - async fn an_account_reads_its_own() { + async fn an_account_reads_its_own_without_asking_the_roster() { + // No roster at all: the owner's access is the album record's fact, not the roster's. + let authority = MembershipAuthority::new(Arc::new(InMemoryMembership::new())); assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("alice"), &reference("alice")) - .await - .expect("the authority decides"), + decide(&authority, "alice", &reference("alice")).await, BlobReadAccess::Granted ); } #[tokio::test] - async fn anybody_else_is_unrelated_rather_than_forbidden() { - // The disclosure boundary, at the unit that decides it. `Unrelated` becomes a `404` - // identical to an unknown address; a `403` here would confirm the reference exists. + async fn a_member_of_either_role_reads() { + let authority = authority().await; assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("mallory"), &reference("alice")) - .await - .expect("the authority decides"), + decide(&authority, "bob", &reference("alice")).await, + BlobReadAccess::Granted, + "a reader reads; that is what the role is for" + ); + assert_eq!( + decide(&authority, "carol", &reference("alice")).await, + BlobReadAccess::Granted + ); + } + + #[tokio::test] + async fn a_former_member_is_revoked_and_a_stranger_is_unrelated() { + // The disclosure boundary, at the unit that decides it. `Revoked` becomes the `403` the + // contract describes; `Unrelated` becomes a `404` identical to an unknown address. + let authority = authority().await; + assert_eq!( + decide(&authority, "dave", &reference("alice")).await, + BlobReadAccess::Revoked + ); + assert_eq!( + decide(&authority, "mallory", &reference("alice")).await, BlobReadAccess::Unrelated ); } /// State the caller cannot see does not change the answer. /// - /// A tombstoned or held asset of somebody else's is `Unrelated` exactly as a live one is — - /// the authority decides on ownership alone, so no lifecycle fact leaks through it. The - /// serving path relies on this by asking it **first**. + /// A tombstoned or held asset of somebody else's is `Unrelated` to a stranger and `Revoked` + /// to a former member exactly as a live one is — the authority decides on membership alone, + /// so no lifecycle fact leaks through it. The serving path relies on this by asking it + /// **first**. #[tokio::test] - async fn a_strangers_answer_does_not_vary_with_the_assets_state() { + async fn a_non_members_answer_does_not_vary_with_the_assets_state() { + let authority = authority().await; for state in [AssetState::Visible, AssetState::Tombstoned] { let mut reference = reference("alice"); reference.state = state; reference.hold = Some(crate::index::ServingHold::Takedown); assert_eq!( - OwnedAssetAuthority::new() - .blob_read_access(&OwnerId::new("mallory"), &reference) - .await - .expect("the authority decides"), + decide(&authority, "mallory", &reference).await, BlobReadAccess::Unrelated, "a stranger's refusal must not vary with facts about the owner's asset" ); + assert_eq!( + decide(&authority, "dave", &reference).await, + BlobReadAccess::Revoked, + "nor a former member's" + ); } } + + #[tokio::test] + async fn membership_is_asked_about_the_references_own_album() { + // The roster is per album: a member of *this* album is a stranger to another one. + let authority = authority().await; + let mut elsewhere = reference("alice"); + elsewhere.album_id = AlbumId::new("another-album"); + assert_eq!( + decide(&authority, "bob", &elsewhere).await, + BlobReadAccess::Unrelated + ); + } } diff --git a/capsule-server/src/serve/mod.rs b/capsule-server/src/serve/mod.rs index e2775557..2a2fac00 100644 --- a/capsule-server/src/serve/mod.rs +++ b/capsule-server/src/serve/mod.rs @@ -60,24 +60,26 @@ //! arbitrary strings. A reference is looked up **before** the store is touched, so a fetch for //! an address nothing references never reaches the bytes. Only then is presence asked about. //! -//! # What is disclosed, stated plainly (`S-C39`) +//! # What is disclosed, stated plainly (`S-C39`, `S-C51`) //! -//! **An account fetches its own assets' blobs and nothing else.** Until `S-C39` any -//! authenticated account could fetch any live address it could name, defended as a capability -//! model — a content address is the hash of ciphertext, so producing one without holding the -//! bytes is producing a preimage. The defence is not wrong and it is not the contract, and it -//! stacks badly besides: an address that leaks once is a permanent capability, because the -//! address never changes. +//! **An account fetches the blobs of its own assets and of the albums it is currently a member +//! of, and nothing else.** Until `S-C39` any authenticated account could fetch any live address +//! it could name, defended as a capability model — a content address is the hash of ciphertext, +//! so producing one without holding the bytes is producing a preimage. The defence is not wrong +//! and it is not the contract, and it stacks badly besides: an address that leaks once is a +//! permanent capability, because the address never changes. //! -//! The decision is [`ReadAuthority`]'s, and a stranger is told exactly what a caller naming an -//! unknown address is told. See [`crate::serve::authority`] for why the `403`/`404` boundary is -//! drawn there and not one step further out, and for why the `403` itself still has no -//! production source: it is no longer a missing authority, it is a missing **membership fact**, -//! and that fact is `S-C51`'s. +//! The decision is [`ReadAuthority`]'s, asked from the reference the index returned and asked +//! **first**. A former member of the album — an account the owner's roster once named and no +//! longer does — is answered [`ServeResolution::Forbidden`], the `403` the download contract +//! describes as an authorization change; everyone else with no relationship is told exactly +//! what a caller naming an unknown address is told. See [`crate::serve::authority`] for why the +//! `403`/`404` boundary is drawn there and not one step further out, and [`crate::membership`] +//! for where the fact behind the `403` comes from. //! -//! Non-owners are not locked out of shared content: `/s/{id}/blob/{hash}` serves exactly the -//! addresses a share link enumerates, and the drop surface serves its own. Neither routes -//! through here, which is what makes owner-scoping this path safe to do at all. +//! Non-accounts are not locked out of shared content either: `/s/{id}/blob/{hash}` serves +//! exactly the addresses a share link enumerates, and the drop surface serves its own. Neither +//! routes through here. //! //! # What is missing, and owned elsewhere //! @@ -102,8 +104,8 @@ use bytes::Bytes; pub mod authority; pub use self::authority::{ - BlobReadAccess, OwnedAssetAuthority, ReadAuthority, ReadAuthorityError, ReadAuthorityFuture, - owned_assets, + BlobReadAccess, MembershipAuthority, ReadAuthority, ReadAuthorityError, ReadAuthorityFuture, + membership_reads, }; use crate::blob::{BlobError, BlobStore, ContentAddress}; use crate::index::{AssetIndex, AssetState}; @@ -198,6 +200,12 @@ pub enum ServeResolution { /// No live reference names the address, it is not an address at all, or the caller has no /// relationship to the asset that holds it. NotFound, + /// The caller once had access to the album that holds it and does not now (`S-C51`). + /// + /// The one refusal that discloses the address is live, and it discloses it only to an + /// account the server holds a revoked membership row for. Decided **before** every policy + /// refusal below, so a former member learns nothing about holds or deletions either. + Forbidden, /// Referenced but not retrievable per policy: a deleted asset, or a dangling reference. Gone, } @@ -271,6 +279,7 @@ pub async fn resolve( ServeUnavailable("the read authority could not decide".to_owned()) })? { BlobReadAccess::Granted => {} + BlobReadAccess::Revoked => return Ok(ServeResolution::Forbidden), BlobReadAccess::Unrelated => return Ok(ServeResolution::NotFound), } diff --git a/capsule-server/src/sync/cursor.rs b/capsule-server/src/sync/cursor.rs index 350b4762..4a99c9b3 100644 --- a/capsule-server/src/sync/cursor.rs +++ b/capsule-server/src/sync/cursor.rs @@ -27,6 +27,18 @@ //! identifier out of a token clients hand around, and still makes a foreign cursor fail //! verification rather than decode into a position. //! +//! # Scope (`S-C51`) +//! +//! A cursor is issued for one of two shapes — the caller's own feed, or one album's page read +//! by the caller as its owner or a member — and both carry the owner's sequence numbers, so a +//! cursor that crossed between them would skip unseen entries exactly as a foreign one would. +//! The shape is therefore MAC input too: the tag is taken over +//! `payload || len(caller) as u32 BE || caller || 0x00`, or `… || 0x01 || album` for an album +//! page. The caller is length-prefixed because a variable-length field follows it. The version +//! byte was **not** bumped: the wire layout below is unchanged, and a cursor minted before the +//! scope entered the MAC fails as `NotAuthentic` — a one-time full resync, the same event a key +//! rotation is, and indistinguishable from it to a client. +//! //! # Layout //! //! `version(1) || position(8, big-endian u64) || hmac_sha256(32)`, base64url without padding on @@ -37,7 +49,7 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::hmac; -use crate::store::OwnerId; +use crate::store::{AlbumId, OwnerId}; /// Cursor wire-format version. Bumped only on an incompatible layout change; an unknown /// version is [`CursorError::Malformed`], never a best-effort parse. @@ -55,6 +67,9 @@ const CURSOR_LEN: usize = PAYLOAD_LEN + TAG_LEN; /// The server-only MAC key length. pub const CURSOR_KEY_LEN: usize = 32; +/// A generous guess at the album half of a MAC input: a scope byte and a hyphenated UUID. +const SCOPE_ESTIMATE: usize = 1 + 36; + /// Why a cursor was not accepted. /// /// Every variant maps to the same client-facing rejection — `error.sync.cursor_invalid` — and @@ -70,6 +85,39 @@ pub enum CursorError { NotAuthentic, } +/// What a cursor is issued for: a caller's own feed, or one album's page (`S-C51`). +/// +/// Part of the MAC input, so a cursor minted for the album page cannot be presented on the +/// owner feed or on another album's page: positions are the owner's sequence numbers in both +/// shapes, and a cursor that crossed between them would skip a member's unseen entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorScope<'a> { + /// The account the cursor was issued to. + pub caller: &'a OwnerId, + /// The album whose page it resumes, or `None` for the caller's own feed. + pub album: Option<&'a AlbumId>, +} + +impl<'a> CursorScope<'a> { + /// `caller`'s own feed. + #[must_use] + pub fn feed(caller: &'a OwnerId) -> Self { + Self { + caller, + album: None, + } + } + + /// `album`'s page, as read by `caller`. + #[must_use] + pub fn album(caller: &'a OwnerId, album: &'a AlbumId) -> Self { + Self { + caller, + album: Some(album), + } + } +} + /// Mints and verifies opaque sync cursors under a server-only key. /// /// `Debug` is hand-written: the derive would print the key material behind [`hmac::Key`]. @@ -97,29 +145,43 @@ impl CursorCodec { } } - /// The bytes the tag is taken over: the payload, then the owner it is issued to. + /// The bytes the tag is taken over: the payload, the length-prefixed caller, then the + /// scope byte and the album when there is one. /// - /// A length prefix would matter if a second variable-length field were ever appended; there - /// is not one, and the fixed-width payload comes *first*, so no two `(position, owner)` - /// pairs share a MAC input. - fn signed_bytes(payload: &[u8], owner: &OwnerId) -> Vec { - let mut bytes = Vec::with_capacity(payload.len() + owner.as_str().len()); + /// The caller is length-prefixed because a second variable-length field now follows it: + /// without the prefix `("ab", album "c")` and `("a", album "bc")` would share a MAC input. + /// The scope byte keeps a feed cursor and an album cursor for one caller apart. + fn signed_bytes(payload: &[u8], scope: &CursorScope<'_>) -> Vec { + let caller = scope.caller.as_str().as_bytes(); + let mut bytes = Vec::with_capacity(payload.len() + 4 + caller.len() + SCOPE_ESTIMATE); bytes.extend_from_slice(payload); - bytes.extend_from_slice(owner.as_str().as_bytes()); + bytes.extend_from_slice( + &u32::try_from(caller.len()) + .unwrap_or(u32::MAX) + .to_be_bytes(), + ); + bytes.extend_from_slice(caller); + match scope.album { + None => bytes.push(0), + Some(album) => { + bytes.push(1); + bytes.extend_from_slice(album.as_str().as_bytes()); + } + } bytes } - /// Mint the cursor that resumes `owner`'s feed after `position`. - pub fn encode(&self, owner: &OwnerId, position: u64) -> String { + /// Mint the cursor that resumes `scope` after `position`. + pub fn encode(&self, scope: &CursorScope<'_>, position: u64) -> String { let mut payload = Vec::with_capacity(CURSOR_LEN); payload.push(CURSOR_VERSION); payload.extend_from_slice(&position.to_be_bytes()); - let tag = hmac::sign(&self.key, &Self::signed_bytes(&payload, owner)); + let tag = hmac::sign(&self.key, &Self::signed_bytes(&payload, scope)); payload.extend_from_slice(tag.as_ref()); URL_SAFE_NO_PAD.encode(&payload) } - /// Recover the position a cursor names, for `owner`. + /// Recover the position a cursor names, for `scope`. /// /// An **absent or empty** cursor is the first-sync sentinel and decodes to `0`, which is /// why sequence numbers start at 1: "I have seen nothing" and "resume after 0" are the same @@ -127,8 +189,12 @@ impl CursorCodec { /// /// # Errors /// - /// [`CursorError`] when the cursor is not well-formed or does not authenticate for `owner`. - pub fn decode(&self, owner: &OwnerId, cursor: Option<&str>) -> Result { + /// [`CursorError`] when the cursor is not well-formed or does not authenticate for `scope`. + pub fn decode( + &self, + scope: &CursorScope<'_>, + cursor: Option<&str>, + ) -> Result { let Some(cursor) = cursor.filter(|value| !value.is_empty()) else { return Ok(0); }; @@ -144,7 +210,7 @@ impl CursorCodec { } // Verified *before* the position is read, so a tampered position is never briefly a // value this function has computed with. - hmac::verify(&self.key, &Self::signed_bytes(payload, owner), tag) + hmac::verify(&self.key, &Self::signed_bytes(payload, scope), tag) .map_err(|_| CursorError::NotAuthentic)?; let position = u64::from_be_bytes( payload[1..PAYLOAD_LEN] @@ -168,8 +234,11 @@ mod tests { let codec = codec(1); let owner = OwnerId::new("owner-1"); for position in [0_u64, 1, 42, u64::MAX] { - let cursor = codec.encode(&owner, position); - assert_eq!(codec.decode(&owner, Some(&cursor)), Ok(position)); + let cursor = codec.encode(&CursorScope::feed(&owner), position); + assert_eq!( + codec.decode(&CursorScope::feed(&owner), Some(&cursor)), + Ok(position) + ); } } @@ -177,8 +246,8 @@ mod tests { fn no_cursor_is_the_first_sync_sentinel() { let codec = codec(1); let owner = OwnerId::new("owner-1"); - assert_eq!(codec.decode(&owner, None), Ok(0)); - assert_eq!(codec.decode(&owner, Some("")), Ok(0)); + assert_eq!(codec.decode(&CursorScope::feed(&owner), None), Ok(0)); + assert_eq!(codec.decode(&CursorScope::feed(&owner), Some("")), Ok(0)); } #[test] @@ -186,9 +255,9 @@ mod tests { let codec = codec(1); let mine = OwnerId::new("owner-1"); let theirs = OwnerId::new("owner-2"); - let cursor = codec.encode(&theirs, 500); + let cursor = codec.encode(&CursorScope::feed(&theirs), 500); assert_eq!( - codec.decode(&mine, Some(&cursor)), + codec.decode(&CursorScope::feed(&mine), Some(&cursor)), Err(CursorError::NotAuthentic), "a cursor lifted from another library authenticated, and per-owner sequence \ numbers make that a way to skip your own unseen entries" @@ -198,9 +267,9 @@ mod tests { #[test] fn another_servers_cursor_does_not_authenticate() { let owner = OwnerId::new("owner-1"); - let cursor = codec(2).encode(&owner, 7); + let cursor = codec(2).encode(&CursorScope::feed(&owner), 7); assert_eq!( - codec(1).decode(&owner, Some(&cursor)), + codec(1).decode(&CursorScope::feed(&owner), Some(&cursor)), Err(CursorError::NotAuthentic) ); } @@ -209,7 +278,7 @@ mod tests { fn every_mutation_of_a_cursor_is_refused() { let codec = codec(1); let owner = OwnerId::new("owner-1"); - let cursor = codec.encode(&owner, 9); + let cursor = codec.encode(&CursorScope::feed(&owner), 9); let raw = URL_SAFE_NO_PAD .decode(&cursor) .expect("the codec emits base64url"); @@ -219,22 +288,67 @@ mod tests { tampered[byte] ^= 0x01; let encoded = URL_SAFE_NO_PAD.encode(&tampered); assert!( - codec.decode(&owner, Some(&encoded)).is_err(), + codec + .decode(&CursorScope::feed(&owner), Some(&encoded)) + .is_err(), "flipping a bit of byte {byte} produced a cursor the server accepted" ); } } + #[test] + fn an_album_cursor_and_a_feed_cursor_do_not_cross() { + // Both carry the owner's sequence numbers, so a cursor that crossed between the two + // shapes would skip a member's unseen entries. The scope is in the MAC input. + let codec = codec(1); + let owner = OwnerId::new("owner-1"); + let album = AlbumId::new("album-1"); + let other = AlbumId::new("album-2"); + let on_album = codec.encode(&CursorScope::album(&owner, &album), 5); + assert_eq!( + codec.decode(&CursorScope::album(&owner, &album), Some(&on_album)), + Ok(5) + ); + assert_eq!( + codec.decode(&CursorScope::feed(&owner), Some(&on_album)), + Err(CursorError::NotAuthentic) + ); + assert_eq!( + codec.decode(&CursorScope::album(&owner, &other), Some(&on_album)), + Err(CursorError::NotAuthentic) + ); + let on_feed = codec.encode(&CursorScope::feed(&owner), 5); + assert_eq!( + codec.decode(&CursorScope::album(&owner, &album), Some(&on_feed)), + Err(CursorError::NotAuthentic) + ); + // And the framing keeps `(caller, album)` pairs apart however the bytes split. + let ab = codec.encode( + &CursorScope::album(&OwnerId::new("ab"), &AlbumId::new("c")), + 1, + ); + assert_eq!( + codec.decode( + &CursorScope::album(&OwnerId::new("a"), &AlbumId::new("bc")), + Some(&ab) + ), + Err(CursorError::NotAuthentic) + ); + } + #[test] fn a_cursor_of_the_wrong_shape_is_malformed_not_unauthentic() { let codec = codec(1); let owner = OwnerId::new("owner-1"); assert_eq!( - codec.decode(&owner, Some("not base64!!")), + codec.decode(&CursorScope::feed(&owner), Some("not base64!!")), Err(CursorError::Malformed) ); assert_eq!( - codec.decode(&owner, Some(&URL_SAFE_NO_PAD.encode([0_u8; 8]))), + codec.decode( + &CursorScope::feed(&owner), + Some(&URL_SAFE_NO_PAD.encode([0_u8; 8])) + ), Err(CursorError::Malformed) ); @@ -243,7 +357,10 @@ mod tests { let mut future = vec![CURSOR_VERSION + 1]; future.extend_from_slice(&[0_u8; CURSOR_LEN - 1]); assert_eq!( - codec.decode(&owner, Some(&URL_SAFE_NO_PAD.encode(&future))), + codec.decode( + &CursorScope::feed(&owner), + Some(&URL_SAFE_NO_PAD.encode(&future)) + ), Err(CursorError::Malformed) ); } diff --git a/capsule-server/src/sync/mod.rs b/capsule-server/src/sync/mod.rs index ff965698..5571b0d0 100644 --- a/capsule-server/src/sync/mod.rs +++ b/capsule-server/src/sync/mod.rs @@ -36,7 +36,7 @@ pub mod cursor; use std::sync::Arc; -pub use self::cursor::{CURSOR_KEY_LEN, CursorCodec, CursorError}; +pub use self::cursor::{CURSOR_KEY_LEN, CursorCodec, CursorError, CursorScope}; use crate::blob::BlobStore; use crate::index::AssetIndex; @@ -67,6 +67,8 @@ pub struct SyncContext { index: Arc, blobs: Arc, cursors: Arc, + albums: Arc, + members: Arc, } impl SyncContext { @@ -75,14 +77,28 @@ impl SyncContext { index: Arc, blobs: Arc, cursors: Arc, + albums: Arc, + members: Arc, ) -> Self { Self { index, blobs, cursors, + albums, + members, } } + /// The albums, for deciding whose album a member's page is (`S-C51`). + pub fn albums(&self) -> &dyn crate::album::AlbumStore { + self.albums.as_ref() + } + + /// The roster, for deciding whether the caller may read an album's page (`S-C51`). + pub fn members(&self) -> &dyn crate::membership::MembershipStore { + self.members.as_ref() + } + /// The asset index (`S-C37`) — the only source of positions and entries. pub fn index(&self) -> &dyn AssetIndex { self.index.as_ref() diff --git a/capsule-server/src/upload/authority.rs b/capsule-server/src/upload/authority.rs index 9da9222e..5a8a1da6 100644 --- a/capsule-server/src/upload/authority.rs +++ b/capsule-server/src/upload/authority.rs @@ -72,15 +72,36 @@ impl AuthorityError { } } -/// Whether an owner may add a blob to an album, and under which protocol pin. +/// In what capacity a caller may write to an album. /// -/// A missing album and a forbidden one are **one variant**, deliberately: the error taxonomy -/// answers both with `403 error.upload.album_access_denied`, and telling a caller which -/// applied would turn the endpoint into an oracle for album ids it cannot otherwise see. +/// Two values, because the server decides two things from it and no more: under whose namespace +/// the write is filed (always the owner's) and whether the caller is that owner. The finer +/// MLS-side distinctions never reach the server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteRole { + /// The account the album was provisioned to. + Owner, + /// A writer on the album's current roster (`S-C51`). + Member, +} + +/// Whether a caller may add a blob to an album, under whose namespace, and under which protocol +/// pin. +/// +/// A missing album, somebody else's album, a reader's membership and a revoked one are **one +/// variant**, deliberately: the error taxonomy answers all of them with +/// `403 error.upload.album_access_denied`, and telling a caller which applied would turn the +/// endpoint into an oracle for album ids and rosters it cannot otherwise see. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AlbumWriteAccess { - /// The album exists, the owner may write to it, and this is its immutable protocol pin. + /// The album exists, the caller may write to it, and this is its immutable protocol pin. Writable { + /// The account the album is filed under — the namespace every write lands in, whoever + /// makes it. A member's asset is the owner's asset: the owner's feed is the one every + /// device of every member reads, so filing elsewhere would make the write invisible. + owner_id: OwnerId, + /// Whether the caller is that owner or a writer member. + role: WriteRole, /// The album's pinned protocol version (`YYYY-MM-DD`), set when it was provisioned. protocol_pin: String, /// The upgrade ceremony the album is quiescing under, if any (`S-C24`). @@ -93,16 +114,21 @@ pub enum AlbumWriteAccess { /// none by design. quiescing_under: Option, }, - /// The album does not exist, or the owner may not write to it. + /// The album does not exist, or the caller may not write to it. Denied, } /// The durable facts invariants 6 and 7 are decided against. pub trait WriteAuthority: fmt::Debug + Send + Sync { - /// Whether `owner` may add a blob to `album`, and the album's protocol pin. + /// Whether `caller` may add a blob to `album`, under whose namespace, and the album's + /// protocol pin. + /// + /// Keyed on the **caller**, not on a declared owner: the authority is what knows whose album + /// it is, and a route that asked "may this owner write" with an owner the caller named would + /// be checking the caller's own claim. fn album_write_access<'a>( &'a self, - owner: &'a OwnerId, + caller: &'a UserId, album: &'a AlbumId, ) -> AuthorityFuture<'a, AlbumWriteAccess>; diff --git a/capsule-server/src/upload/finalize.rs b/capsule-server/src/upload/finalize.rs index ef42116a..771fc29a 100644 --- a/capsule-server/src/upload/finalize.rs +++ b/capsule-server/src/upload/finalize.rs @@ -609,12 +609,19 @@ async fn revalidate( )); }; + // Asked for the **uploader**, as creation asked: a member whose write access was withdrawn + // between creation and finalization is refused here, exactly as a closed album is. let access = context .authority() - .album_write_access(&record.owner_id, album) + .album_write_access(&record.upload_user_id, album) .await .map_err(|error| FinalizeFailure::Unavailable(error.to_string()))?; - let AlbumWriteAccess::Writable { protocol_pin, .. } = access else { + let AlbumWriteAccess::Writable { + owner_id, + protocol_pin, + .. + } = access + else { // The album closed, or write capability was withdrawn, since creation. The taxonomy // answers a finalization-time envelope failure with one code, so this is not a second // `album_access_denied`. @@ -622,6 +629,14 @@ async fn revalidate( GateReject::AlbumPinMismatch, )); }; + if owner_id != record.owner_id { + // The album changed hands since the session was opened. Not a state this server can + // produce today, and refused rather than re-filed because the session's namespace is + // what its reserved asset row was minted under. + return Err(FinalizeFailure::EnvelopeRejected( + GateReject::AlbumPinMismatch, + )); + } let device = super::envelope::created_by_device(&envelope).map_err(FinalizeFailure::EnvelopeRejected)?; diff --git a/capsule-server/src/upload/mod.rs b/capsule-server/src/upload/mod.rs index 408d0e07..c7c01bb3 100644 --- a/capsule-server/src/upload/mod.rs +++ b/capsule-server/src/upload/mod.rs @@ -54,7 +54,9 @@ pub mod visibility; use std::sync::Arc; -pub use self::authority::{AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority}; +pub use self::authority::{ + AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority, WriteRole, +}; pub use self::envelope::{DeclaredBlob, GateContext, GateReject, ManifestEnvelope}; pub use self::policy::UploadPolicy; use crate::blob::BlobStore; 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/albums.rs b/capsule-server/tests/albums.rs index 47732037..4bc3ae8e 100644 --- a/capsule-server/tests/albums.rs +++ b/capsule-server/tests/albums.rs @@ -11,10 +11,10 @@ mod support; use capsule_server::album::authority::ProvisionedAuthority; use capsule_server::album::{AlbumStore, ProvisionOutcome}; use capsule_server::store::AlbumId; -use capsule_server::upload::{AlbumWriteAccess, WriteAuthority}; +use capsule_server::upload::{AlbumWriteAccess, WriteAuthority, WriteRole}; use kynos::http::StatusCode; use serde_json::{Value, json}; -use support::{Fixture, PROTOCOL_VERSION, owner}; +use support::{Fixture, PROTOCOL_VERSION, owner, user}; /// A canonical derived album id. const DERIVED: &str = "0198f3c2-9c4a-7b3d-8f21-4d7c9a1b2e35"; @@ -88,12 +88,13 @@ async fn provisioning_makes_an_album_writable() { let authority = ProvisionedAuthority::new( fixture.albums.clone(), fixture.directories.clone(), + fixture.members.clone(), fixture.clock.clone(), ); assert_eq!( authority - .album_write_access(&owner(), &album) + .album_write_access(&user(), &album) .await .expect("the authority answers"), AlbumWriteAccess::Denied, @@ -110,10 +111,12 @@ async fn provisioning_makes_an_album_writable() { assert_eq!( authority - .album_write_access(&owner(), &album) + .album_write_access(&user(), &album) .await .expect("the authority answers"), AlbumWriteAccess::Writable { + owner_id: owner(), + role: WriteRole::Owner, quiescing_under: None, protocol_pin: PROTOCOL_VERSION.to_owned() }, diff --git a/capsule-server/tests/blob.rs b/capsule-server/tests/blob.rs index 54520b2d..6a895df7 100644 --- a/capsule-server/tests/blob.rs +++ b/capsule-server/tests/blob.rs @@ -12,7 +12,8 @@ mod support; use capsule_server::blob::{BlobStore, ContentAddress}; use capsule_server::gc::CollectionStore; use capsule_server::index::{AssetIndex, BlobRecord, HoldOutcome, PendingAsset, ServingHold}; -use capsule_server::store::{AssetId, BlobRole}; +use capsule_server::membership::{MemberRole, MembershipStore as _, RosterRecord}; +use capsule_server::store::{AssetId, BlobRole, UserId}; use jiff::Timestamp; use kynos::http::StatusCode; use support::{Fixture, PROTOCOL_VERSION, album, owner, payload}; @@ -824,7 +825,7 @@ async fn another_accounts_live_blob_is_unknown_rather_than_served() { /// differed from the unknown-address answer — would confirm that the address is referenced by /// *somebody*, which is an existence oracle over content addresses handed to anyone who can name /// one. The `403` the contract describes is reserved for a caller the server can see once *had* -/// access, and no such caller exists yet — see `S-C51`. +/// access — a former member, whose row the membership store keeps (`S-C51`). #[tokio::test] async fn a_strangers_refusal_is_indistinguishable_from_an_unknown_address() { let fixture = Fixture::working(); @@ -906,3 +907,196 @@ async fn a_stranger_cannot_tell_a_takedown_from_an_unknown_address() { .await .assert_status(StatusCode::NOT_FOUND); } + +// =========================================================================================== +// Membership (`S-C51`) +// =========================================================================================== + +/// A second account, on the seeded album's roster in whatever state a case puts it. +const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0"; + +/// Publish the seeded album's roster at `version`, naming `members`. +async fn roster(fixture: &Fixture, version: u64, members: &[(&str, MemberRole)]) { + fixture + .members + .apply_roster( + RosterRecord { + album_id: album(), + roster_version: version, + amk_epoch: version, + attested_by_device: support::device(), + received_at: Timestamp::UNIX_EPOCH, + document: format!("blob-test-v{version}").into_bytes(), + }, + members + .iter() + .map(|(user, role)| (UserId::new(*user), *role)) + .collect(), + ) + .await + .expect("the store applies"); +} + +/// Fetch `address` as `bearer`, asking for a problem body. +async fn fetch(fixture: &Fixture, bearer: &str, address: &str) -> kynos::test::TestResponse { + fixture + .client + .get(&format!("/v1/blob/{address}")) + .header("authorization", bearer) + .header("accept", "application/problem+json") + .send() + .await +} + +#[tokio::test] +async fn a_member_of_either_role_reads_the_owners_blobs() { + let fixture = Fixture::working(); + let bytes = ciphertext(); + let address = published_original(&fixture, "shared", &bytes).await; + let bob = fixture.other_bearer(BOB).await; + + for role in [MemberRole::Reader, MemberRole::Writer] { + roster( + &fixture, + u64::from(role == MemberRole::Writer) + 1, + &[(BOB, role)], + ) + .await; + let response = fetch(&fixture, &bob, address.as_str()).await; + response.assert_status(StatusCode::OK); + assert_eq!( + response.bytes().as_ref(), + bytes.as_slice(), + "{role:?} reads the bytes" + ); + fixture + .client + .get(&format!("/v1/blob/{address}")) + .header("authorization", &bob) + .header("range", "bytes=0-1023") + .send() + .await + .assert_status(StatusCode::PARTIAL_CONTENT); + } +} + +#[tokio::test] +async fn a_former_member_is_told_access_was_revoked() { + // The `403` the download contract describes, rendered at last: an authorization change, not + // a durability loss, so the client re-syncs its membership before it degrades. + let fixture = Fixture::working(); + let address = published_original(&fixture, "unshared", &ciphertext()).await; + let bob = fixture.other_bearer(BOB).await; + roster(&fixture, 1, &[(BOB, MemberRole::Writer)]).await; + fetch(&fixture, &bob, address.as_str()) + .await + .assert_status(StatusCode::OK); + + roster(&fixture, 2, &[]).await; + let refused = fetch(&fixture, &bob, address.as_str()).await; + refused.assert_status(StatusCode::FORBIDDEN); + let problem: serde_json::Value = refused.json(); + assert_eq!(problem["code"], "error.blob.access_revoked"); + + // Re-admitted: the bytes again. + roster(&fixture, 3, &[(BOB, MemberRole::Reader)]).await; + fetch(&fixture, &bob, address.as_str()) + .await + .assert_status(StatusCode::OK); +} + +#[tokio::test] +async fn a_former_member_gets_the_403_before_any_policy_refusal() { + // Authority first, as `S-C39` fixed it: a former member learns nothing about takedowns or + // deletions either. The `403` is theirs whatever the asset's state. + let fixture = Fixture::working(); + let address = published_original(&fixture, "held-from-former", &ciphertext()).await; + let bob = fixture.other_bearer(BOB).await; + roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await; + roster(&fixture, 2, &[]).await; + assert_eq!( + fixture + .index + .set_hold( + &AssetId::new("held-from-former"), + Some(ServingHold::Takedown) + ) + .await + .expect("the index holds"), + HoldOutcome::Applied + ); + + fetch(&fixture, &bob, address.as_str()) + .await + .assert_status(StatusCode::FORBIDDEN); + // The owner is told the truth, as before. + fetch(&fixture, &bearer(&fixture).await, address.as_str()) + .await + .assert_status(StatusCode::GONE); +} + +#[tokio::test] +async fn a_never_member_is_indistinguishable_from_an_unknown_address_body_and_headers() { + // The full disclosure property with a roster in play: an account the roster never named — + // even while *other* accounts are on it — gets the unknown-address answer byte for byte, + // headers included (the `date` header aside, which is the clock's). + let fixture = Fixture::working(); + let address = published_original(&fixture, "never-shared", &ciphertext()).await; + roster(&fixture, 1, &[(BOB, MemberRole::Writer)]).await; + let carol = fixture + .other_bearer("01937b7c-0000-7000-8000-0000000000c0") + .await; + + let refused = fetch(&fixture, &carol, address.as_str()).await; + refused.assert_status(StatusCode::NOT_FOUND); + let unknown = fetch(&fixture, &carol, &support::checksum(b"never existed")).await; + unknown.assert_status(StatusCode::NOT_FOUND); + + assert_eq!(refused.bytes(), unknown.bytes()); + // Presence first, so the equalities below cannot pass on two absent headers. (The in-process + // client does not materialise `content-length`, so the media type is the one header a problem + // body is guaranteed to carry here.) + assert!( + !refused.headers("content-type").is_empty(), + "a problem response carries `content-type`" + ); + // Every header a problem response carries, `date` aside (which is the clock's). The test + // client exposes headers by name, so the set is spelled out; a new response header joins it. + for name in [ + "content-type", + "content-length", + "cache-control", + "vary", + "www-authenticate", + "x-capsule-protocol-min", + "x-capsule-protocol-max", + ] { + assert_eq!( + refused.headers(name), + unknown.headers(name), + "the `{name}` header differs between a never-member's refusal and an unknown address" + ); + } +} + +#[tokio::test] +async fn a_membership_store_that_cannot_answer_is_an_outage_never_a_refusal() { + // An outage must not look like a revocation — the client actions are opposite — nor like + // an unknown address. + let fixture = Fixture::working(); + let address = published_original(&fixture, "outage", &ciphertext()).await; + let bob = fixture.other_bearer(BOB).await; + roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await; + + fixture.members.set_unavailable(true); + let failed = fetch(&fixture, &bob, address.as_str()).await; + failed.assert_status(StatusCode::INTERNAL_SERVER_ERROR); + let problem: serde_json::Value = failed.json(); + assert_eq!(problem["code"], "error.blob.unavailable"); + fixture.members.set_unavailable(false); + + // The owner never asks the roster, so the outage does not touch them. + fetch(&fixture, &bearer(&fixture).await, address.as_str()) + .await + .assert_status(StatusCode::OK); +} diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs index 90f0434c..1ca8afe6 100644 --- a/capsule-server/tests/conformance.rs +++ b/capsule-server/tests/conformance.rs @@ -121,6 +121,7 @@ async fn every_declared_response_is_exercised() { ("POST", "/v1/albums/anything/upgrade"), ("GET", "/v1/albums/anything/upgrade"), ("DELETE", "/v1/albums/anything/upgrade"), + ("PUT", "/v1/albums/anything/roster"), ("GET", "/v1/quota"), ("GET", "/v1/upload/sessions"), ("GET", "/v1/assets/anything/receipts"), @@ -170,12 +171,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 +474,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 +528,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 +537,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::OK); client + .raw() .head(&session) .header("authorization", &bearer) .send() @@ -507,13 +566,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 +673,7 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::UPGRADE_REQUIRED); client + .raw() .delete(&session) .header("authorization", &bearer) .send() @@ -738,6 +800,15 @@ async fn every_declared_response_is_exercised() { .await .assert_status(StatusCode::FORBIDDEN); + // 403: an album page the caller may not read (`S-C51`) — here an album nobody provisioned, + // which is one answer with not-a-member and removed. + client + .get("/v1/sync?album_id=018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff") + .header("authorization", &bearer) + .send() + .await + .assert_status(StatusCode::FORBIDDEN); + // 400: the one cursor rejection. Malformed and foreign are deliberately the same answer. client .get("/v1/sync?cursor=not-a-cursor") @@ -842,6 +913,39 @@ async fn every_declared_response_is_exercised() { .expect("the index records"); } + // 403: a former member of the album (`S-C51`). The stranger account above was on the + // roster at version 1 and is not at version 2, so the server holds a revoked row for it — + // the one fact the `403` may be rendered from. Applied through the store: the roster route + // and its verification are `tests/roster.rs`'s, and this walk is about which statuses exist. + { + use capsule_server::membership::{MemberRole, MembershipStore as _, RosterRecord}; + let roster = |version: u64| RosterRecord { + album_id: support::album(), + roster_version: version, + amk_epoch: version, + attested_by_device: support::device(), + received_at: jiff::Timestamp::UNIX_EPOCH, + document: format!("walk-v{version}").into_bytes(), + }; + let former = capsule_server::store::UserId::new("01937b7c-0000-7000-8000-0000000000ff"); + fixture + .members + .apply_roster(roster(1), vec![(former, MemberRole::Reader)]) + .await + .expect("the store applies"); + fixture + .members + .apply_roster(roster(2), vec![]) + .await + .expect("the store applies"); + } + client + .get(&format!("/v1/blob/{address}")) + .header("authorization", &stranger) + .send() + .await + .assert_status(StatusCode::FORBIDDEN); + // 404: a well-formed address nothing references. client .get(&format!("/v1/blob/{}", checksum(b"nothing holds these"))) @@ -1380,6 +1484,17 @@ async fn every_declared_response_is_exercised() { )) .await; + // ── PUT /v1/albums/{album_id}/roster (`S-C51`) ───────────────────────────────────────── + Box::pin(roster_block( + client, + &fixture, + &bearer, + &rotated.refresh_token, + DERIVED, + &account_ik, + )) + .await; + // ── GET /v1/quota ────────────────────────────────────────────────────────────────────── client .get("/v1/quota") @@ -2496,6 +2611,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 +3083,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 +3361,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 +3606,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, @@ -3565,12 +4061,158 @@ async fn drops_block( .assert_status(StatusCode::NO_CONTENT); } +/// Every answer the roster publish can give (`S-C51`). +/// +/// Its own function for the reason the upgrade block is one: the walk's generator has to fit in +/// the test thread's stack. +async fn roster_block( + client: &support::Client, + fixture: &Fixture, + bearer: &str, + refresh_token: &str, + album: &str, + account_ik: &capsule_core::crypto::keys::HybridSigningKey, +) { + use capsule_server::membership::MemberRole; + + let path = format!("/v1/albums/{album}/roster"); + let album_id = capsule_server::store::AlbumId::new(album); + let dsk = support::identity_key(); + let member = "01937b7c-0000-7000-8000-0000000000b0"; + let roster = |version: u64| { + json!({ + "roster_cbor": support::signed_roster( + &dsk, support::device(), &album_id, version, 1, &[(member, MemberRole::Writer)], + ) + }) + }; + + // 401 and 403 are the scheme's. + client + .put(&path) + .json(&roster(1)) + .send() + .await + .assert_status(StatusCode::UNAUTHORIZED); + client + .put(&path) + .header("authorization", &format!("Bearer {refresh_token}")) + .json(&roster(1)) + .send() + .await + .assert_status(StatusCode::FORBIDDEN); + + // 415 and 422 are the `Json` extractor's; 400 is the surface's own floor. + client + .put(&path) + .header("authorization", bearer) + .body("text/plain", "{}") + .send() + .await + .assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + client + .put(&path) + .header("authorization", bearer) + .json(&json!({ "roster_cbor": 42 })) + .send() + .await + .assert_status(StatusCode::UNPROCESSABLE_ENTITY); + client + .put(&path) + .header("authorization", bearer) + .json(&json!({ "roster_cbor": "not base64!" })) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST); + + // 404: an album nobody provisioned, answered before any attester question. + let unprovisioned = "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff"; + client + .put(&format!("/v1/albums/{unprovisioned}/roster")) + .header("authorization", bearer) + .json(&json!({ + "roster_cbor": support::signed_roster( + &dsk, + support::device(), + &capsule_server::store::AlbumId::new(unprovisioned), + 1, + 1, + &[], + ) + })) + .send() + .await + .assert_status(StatusCode::NOT_FOUND); + + // 403: the directory the upgrade block published holds another key for this device, so a + // roster signed by *this* one does not verify — the forged-attester case. + client + .put(&path) + .header("authorization", bearer) + .json(&roster(1)) + .send() + .await + .assert_status(StatusCode::FORBIDDEN); + + // Re-anchor the device on this block's key, one version above the upgrade block's `9`: + // invariant 23 makes the version strictly monotonic, so this block is coupled to that one + // and a bump there is a `409` here. + client + .post("/v1/auth/devices/directory") + .header("authorization", bearer) + .header( + "x-capsule-identity-key", + &support::identity_header(account_ik), + ) + .body( + "application/cbor", + support::signed_directory_with_device( + account_ik, + 10, + support::device(), + &dsk, + "1970-01-01T00:00:00Z", + ), + ) + .send() + .await + .assert_status(StatusCode::OK); + + // 500 from the membership store. + fixture.members.set_unavailable(true); + client + .put(&path) + .header("authorization", bearer) + .json(&roster(1)) + .send() + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR); + fixture.members.set_unavailable(false); + + // 200, then the 409 a roster that does not supersede it gets. + client + .put(&path) + .header("authorization", bearer) + .header("accept", "application/json") + .json(&roster(1)) + .send() + .await + .assert_status(StatusCode::OK); + client + .put(&path) + .header("authorization", bearer) + .json(&roster(0)) + .send() + .await + .assert_status(StatusCode::CONFLICT); +} + /// Every answer the three upgrade-ceremony operations can give (`S-C24`). /// /// 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, diff --git a/capsule-server/tests/ops.rs b/capsule-server/tests/ops.rs index 2601a049..04a7391e 100644 --- a/capsule-server/tests/ops.rs +++ b/capsule-server/tests/ops.rs @@ -578,3 +578,311 @@ async fn a_lifecycle_write_requires_a_credential() { .await .assert_status(StatusCode::UNAUTHORIZED); } + +// =========================================================================================== +// Member writes (`S-C51`) +// =========================================================================================== + +/// A second account, on the seeded album's roster in whatever role a case puts it. +const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0"; + +fn bobs_device() -> uuid::Uuid { + uuid::Uuid::parse_str("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eb0").expect("a uuid") +} + +/// A fixture with the owner's asset published, Bob's device known, and Bob seated as `role`. +async fn with_bob(role: Option) -> (Fixture, String) { + let fixture = Fixture::working(); + publish(&fixture).await; + let bob = capsule_server::store::UserId::new(BOB); + fixture + .authority + .add_device(&bob, bobs_device(), fixture.clock.now()); + if let Some(role) = role { + fixture.authority.share(&album(), &bob, role); + } + let bearer = fixture.other_bearer(BOB).await; + (fixture, bearer) +} + +/// The delete bundle a writer member's device really produces, continuing the owner's chain. +/// +/// **Both identity fields are Bob's**, and that is what `capsule_core` emits: a lifecycle record +/// names the account and device that *signed it*, re-minted per write by +/// `lifecycle::provenance::sign_lifecycle`, never inherited from the chain head. It has to be — +/// `verify_asset` resolves `created_by_device` inside `created_by_user`'s directory (step 6) and +/// verifies `device_sig` under that entry (step 8), so a record naming anyone but its signer +/// cannot verify. The owner's album is protected by `write_sig` at step 10 instead, which is why +/// a member writing under their own name takes nothing from the owner. +/// +/// The asset stays the owner's: it is filed under the owner's namespace, it lands on the owner's +/// feed, and the `create` record at the head of the chain still names the owner as creator. Only +/// *this record* is Bob's, because Bob wrote it. +fn bobs_delete(fixture: &Fixture) -> Value { + let mut body = bundle( + fixture, + "delete", + "bob-deletes", + Some(&created_head()), + None, + ); + body["manifest_envelope"]["created_by_user"] = BOB.into(); + body["manifest_envelope"]["created_by_device"] = bobs_device().to_string().into(); + body +} + +#[tokio::test] +async fn a_writer_members_op_is_filed_under_the_owner_and_reaches_the_owners_feed() { + use capsule_server::membership::MemberRole; + + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let owner_bearer = token(&fixture).await; + let before = feed(&fixture, &owner_bearer).await; + let body = apply(&fixture, &bob, &bobs_delete(&fixture), StatusCode::OK).await; + assert_eq!(body["action"], "delete"); + + // The owner's feed — the one every member's devices read — advanced with the tombstone. + let owners = feed(&fixture, &owner_bearer).await; + assert_ne!(owners, before, "the member's op advanced the owner's feed"); + let entry = &owners["entries"][0]; + assert_eq!(entry["asset_id"], ASSET); + assert_eq!(entry["change"], "deleted"); + assert_eq!( + entry["sync_seq"], body["sync_seq"], + "the feed position is the one the op was answered with" + ); + // And Bob's own feed does not: the op was not filed under the member. + let bobs = feed(&fixture, &bob).await; + assert_eq!(bobs["entries"].as_array().map(Vec::len), Some(0)); +} + +#[tokio::test] +async fn a_reader_a_former_member_and_a_stranger_cannot_apply_an_op() { + use capsule_server::membership::MemberRole; + + let (fixture, bob) = with_bob(Some(MemberRole::Reader)).await; + let reader = apply( + &fixture, + &bob, + &bobs_delete(&fixture), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(reader["code"], "error.upload.album_access_denied"); + + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + fixture + .authority + .unshare(&album(), &capsule_server::store::UserId::new(BOB)); + let former = apply( + &fixture, + &bob, + &bobs_delete(&fixture), + StatusCode::FORBIDDEN, + ) + .await; + + let (fixture, bob) = with_bob(None).await; + let owner_bearer = token(&fixture).await; + let before = feed(&fixture, &owner_bearer).await; + let stranger = apply( + &fixture, + &bob, + &bobs_delete(&fixture), + StatusCode::FORBIDDEN, + ) + .await; + + assert_eq!(reader, former); + assert_eq!(former, stranger); + // Nothing reached the owner's feed. + assert_eq!(feed(&fixture, &owner_bearer).await, before); +} + +#[tokio::test] +async fn a_members_op_is_checked_against_the_members_own_directory() { + use capsule_server::membership::MemberRole; + + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let mut body = bobs_delete(&fixture); + body["manifest_envelope"]["created_by_device"] = device().to_string().into(); + let problem = apply(&fixture, &bob, &body, StatusCode::BAD_REQUEST).await; + assert_eq!(problem["code"], "error.upload.device_not_authorized"); +} + +/// **A suspended account may not write, and a lifecycle op is a write.** `POST /v1/upload` +/// refused a suspended uploader from the start; this surface did not, which stopped being merely +/// inconsistent once `S-C51` widened it from the owner to every writer member. Both principals +/// are asserted, because the refusal has to be about *standing* and not about ownership. +#[tokio::test] +async fn a_suspended_account_may_not_apply_a_lifecycle_op() { + use capsule_server::membership::MemberRole; + use capsule_server::moderation::{ + ModerationAction, ModerationEvent, ModerationStore as _, Standing, + }; + use capsule_server::store::UserId; + + // The album's own owner, suspended. + let fixture = Fixture::working(); + let bearer = token(&fixture).await; + publish(&fixture).await; + let since = fixture.clock.now(); + fixture + .moderation + .apply( + ModerationEvent { + user_id: UserId::new(user().as_str()), + action: ModerationAction::Suspended, + asset_id: None, + at: since, + reason: Some("verified report".to_owned()), + }, + Some(Standing::Suspended { since }), + ) + .await + .expect("the moderation store applies"); + + let before = feed(&fixture, &bearer).await; + let problem = apply( + &fixture, + &bearer, + &bundle(&fixture, "delete", "suspended", Some(&created_head()), None), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(problem["code"], "error.moderation.account_suspended"); + assert_eq!( + feed(&fixture, &bearer).await, + before, + "a refused write reaches no feed" + ); + + // And a writer member, suspended: the standing is the account's, not the album's. + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let since = fixture.clock.now(); + fixture + .moderation + .apply( + ModerationEvent { + user_id: UserId::new(BOB), + action: ModerationAction::Suspended, + asset_id: None, + at: since, + reason: None, + }, + Some(Standing::Suspended { since }), + ) + .await + .expect("the moderation store applies"); + let members = apply( + &fixture, + &bob, + &bobs_delete(&fixture), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(members["code"], "error.moderation.account_suspended"); +} + +/// Fail closed: a moderation store that cannot answer "is this account suspended" must not be +/// read as "no", or an outage becomes a window in which every suspension is lifted. +#[tokio::test] +async fn an_unreachable_moderation_store_refuses_the_lifecycle_op() { + let fixture = Fixture::working(); + let bearer = token(&fixture).await; + publish(&fixture).await; + fixture.moderation.set_unavailable(true); + + apply( + &fixture, + &bearer, + &bundle( + &fixture, + "delete", + "unreachable", + Some(&created_head()), + None, + ), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; +} + +/// **The owner creates, a writer member deletes, and the write is accepted** — authored by the +/// member, filed under the owner, landing on the owner's feed. +/// +/// The case the membership widening exists for, and the one that pins the settled rule after +/// three passes over it. A lifecycle record names its own signer, so the member's delete is +/// authored by the member; the *asset* stays the owner's, which is what the namespace and the +/// feed assert here. The `create` record at the head of the chain is where the creator lives. +/// +/// The second arm is the other half of invariant 7: swap in the owner's device — an account Bob +/// cannot publish devices for — and the write is refused, because the device a manifest names +/// must live in the caller's own directory. +#[tokio::test] +async fn a_member_continues_the_owners_chain_under_their_own_authorship() { + use capsule_server::membership::MemberRole; + + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let owner_bearer = token(&fixture).await; + let before = feed(&fixture, &owner_bearer).await; + + let body = bobs_delete(&fixture); + assert_eq!( + body["manifest_envelope"]["created_by_user"], + Value::from(BOB), + "the record names the account that signed it" + ); + assert_ne!( + body["manifest_envelope"]["created_by_user"], + Value::from(user().as_str()), + "which is not the album's owner, and that is the point of the case" + ); + + let applied = apply(&fixture, &bob, &body, StatusCode::OK).await; + assert_eq!(applied["action"], "delete"); + + // The asset is still the owner's: the member's write lands on the owner's feed. + let owners = feed(&fixture, &owner_bearer).await; + assert_ne!(owners, before, "the member's op advanced the owner's feed"); + assert_eq!(owners["entries"][0]["asset_id"], ASSET); + assert_eq!(owners["entries"][0]["change"], "deleted"); + assert_eq!(owners["entries"][0]["sync_seq"], applied["sync_seq"]); + + // Invariant 7's device half: a device the caller has not published is refused. + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let mut owners_device = bobs_delete(&fixture); + owners_device["manifest_envelope"]["created_by_device"] = device().to_string().into(); + let problem = apply(&fixture, &bob, &owners_device, StatusCode::BAD_REQUEST).await; + assert_eq!(problem["code"], "error.upload.device_not_authorized"); +} + +/// **A write may not be attributed to another account.** Invariant 7's account half, and the +/// refusal the write widening made necessary: before `S-C51` only the album owner could reach +/// this surface and the field could only plausibly be their own. +#[tokio::test] +async fn an_op_attributed_to_another_account_is_refused() { + use capsule_server::membership::MemberRole; + + let (fixture, bob) = with_bob(Some(MemberRole::Writer)).await; + let owner_bearer = token(&fixture).await; + let before = feed(&fixture, &owner_bearer).await; + + // The member attributing their delete to the album's owner. + let mut forged = bobs_delete(&fixture); + forged["manifest_envelope"]["created_by_user"] = user().as_str().into(); + let problem = apply(&fixture, &bob, &forged, StatusCode::BAD_REQUEST).await; + assert_eq!(problem["code"], "error.upload.envelope_mismatch"); + assert_eq!( + feed(&fixture, &owner_bearer).await, + before, + "and nothing was written" + ); + + // The owner attributing theirs to the member: the rule is "the author is the caller", not + // "the author is the owner". + let mut theirs = bundle(&fixture, "delete", "d-owner", Some(&created_head()), None); + theirs["manifest_envelope"]["created_by_user"] = BOB.into(); + let mine = apply(&fixture, &owner_bearer, &theirs, StatusCode::BAD_REQUEST).await; + assert_eq!(mine["code"], "error.upload.envelope_mismatch"); +} diff --git a/capsule-server/tests/roster.rs b/capsule-server/tests/roster.rs new file mode 100644 index 00000000..c4270c97 --- /dev/null +++ b/capsule-server/tests/roster.rs @@ -0,0 +1,580 @@ +//! `PUT /v1/albums/{album_id}/roster` — publishing an album's membership roster (slice `S-C51`). +//! +//! The one way the key-free server learns who may read and write a shared album. What these +//! cases pin is the trust anchor and the disclosure boundary: only the album owner's account +//! publishes, only a live device in the owner's published directory attests, a member who tries +//! learns nothing, and a roster that does not supersede the held one changes nothing. + +mod support; + +use capsule_core::crypto::keys::HybridSigningKey; +use capsule_server::membership::{MemberRole, Membership, MembershipStore as _, Revocation}; +use capsule_server::routes::roster::MAX_ROSTER_BYTES; +use capsule_server::store::{AlbumId, UserId}; +use kynos::http::StatusCode; +use serde_json::{Value, json}; +use support::{ + Fixture, album, device, identity_header, identity_key, second_album, + signed_directory_with_device, signed_roster, +}; + +/// The member every roster here names. +const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0"; + +/// Publish a directory holding the seeded device with `dsk` as its signing key. +async fn anchor(fixture: &Fixture, bearer: &str, ik: &HybridSigningKey, dsk: &HybridSigningKey) { + fixture + .client + .post("/v1/auth/devices/directory") + .header("authorization", bearer) + .header("x-capsule-identity-key", &identity_header(ik)) + .body( + "application/cbor", + signed_directory_with_device(ik, 1, device(), dsk, "1970-01-01T00:00:00Z"), + ) + .send() + .await + .assert_status(StatusCode::OK); +} + +/// Provision `id` for the seeded account. +async fn provision(fixture: &Fixture, bearer: &str, id: &AlbumId) { + fixture + .client + .post("/v1/albums") + .header("authorization", bearer) + .header("accept", "application/json") + .json(&json!({ "album_id": id.as_str() })) + .send() + .await + .assert_status(StatusCode::CREATED); +} + +/// A fixture with the seeded account anchored on `dsk` and the seeded album provisioned. +async fn ready(dsk: &HybridSigningKey) -> (Fixture, String) { + let fixture = Fixture::working(); + let bearer = fixture.bearer().await; + anchor(&fixture, &bearer, &identity_key(), dsk).await; + provision(&fixture, &bearer, &album()).await; + (fixture, bearer) +} + +/// PUT `roster_cbor` for `id` as `bearer`. +async fn publish( + fixture: &Fixture, + bearer: &str, + id: &AlbumId, + roster_cbor: &str, +) -> kynos::test::TestResponse { + fixture + .client + .put(&format!("/v1/albums/{id}/roster")) + .header("authorization", bearer) + .header("accept", "application/json") + .json(&json!({ "roster_cbor": roster_cbor })) + .send() + .await +} + +/// What the store says `user` is to the seeded album. +async fn membership_of(fixture: &Fixture, user: &str) -> Membership { + fixture + .members + .membership(&album(), &UserId::new(user)) + .await + .expect("the store answers") +} + +/// Assert every JSON number in `problem` is inside the range a spargen-generated client decodes. +/// +/// The generator lowers every `integer` in the contract as `i64` — it emits no `u64` at all, +/// `format: uint64` or not — so a member above `i64::MAX` is a member no generated client can +/// read. This walks the whole body rather than the members a case happens to name, so a future +/// extension that forgets the rule fails here. +#[track_caller] +fn assert_decodable(problem: &Value) { + for (name, value) in problem.as_object().expect("a problem object") { + if let Some(number) = value.as_u64() { + assert!( + i64::try_from(number).is_ok(), + "`{name}` is {number}, past what a generated client decodes" + ); + } + } +} + +// =========================================================================================== + +#[tokio::test] +async fn the_owner_publishes_a_roster_and_its_members_become_members() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + + let body: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK) + .json(); + assert_eq!(body["album_id"], album().as_str()); + assert_eq!(body["roster_version"], 1); + assert_eq!(body["amk_epoch"], 1); + assert_eq!(body["member_count"], 1); + assert_eq!(body["replayed"], false); + assert_eq!( + membership_of(&fixture, BOB).await, + Membership::Member { + role: MemberRole::Writer, + granted_epoch: 1, + } + ); +} + +#[tokio::test] +async fn the_same_roster_again_is_a_replay_and_a_lower_version_is_stale() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + let first = signed_roster(&dsk, device(), &album(), 2, 1, &[(BOB, MemberRole::Reader)]); + publish(&fixture, &bearer, &album(), &first) + .await + .assert_status(StatusCode::OK); + + // Identical bytes: idempotent under `(album_id, roster_version)`. + let replayed: Value = publish(&fixture, &bearer, &album(), &first) + .await + .assert_status(StatusCode::OK) + .json(); + assert_eq!(replayed["replayed"], true); + assert_eq!(replayed["roster_version"], 2); + + // Same version, different bytes — the loser of a concurrent publish — and a lower version. + for stale in [ + signed_roster(&dsk, device(), &album(), 2, 1, &[]), + signed_roster(&dsk, device(), &album(), 1, 1, &[]), + ] { + let problem: Value = publish(&fixture, &bearer, &album(), &stale) + .await + .assert_status(StatusCode::CONFLICT) + .json(); + assert_eq!(problem["code"], "error.album.roster_stale"); + assert_eq!(problem["current_version"], 2); + } + assert_eq!( + membership_of(&fixture, BOB).await, + Membership::Member { + role: MemberRole::Reader, + granted_epoch: 1, + }, + "a refused roster changes nothing" + ); +} + +#[tokio::test] +async fn an_epoch_that_regresses_is_stale_too() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 3, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK); + + let problem: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 2, 2, &[]), + ) + .await + .assert_status(StatusCode::CONFLICT) + .json(); + assert_eq!(problem["code"], "error.album.roster_stale"); + assert_eq!( + problem["current_version"], 1, + "the held version, so the client's action is the same re-sync a stale version asks for" + ); + assert!(matches!( + membership_of(&fixture, BOB).await, + Membership::Member { .. } + )); +} + +/// **An absurd version cannot wedge the album.** `roster_version` is the client's own counter +/// and the server's only ordering, so a publish at the top of it would be a roster nothing could +/// ever supersede — membership frozen for good, with no recovery path in the design. The window +/// above the held version is what denies it, and the case asserts the half that matters: after +/// the refusal, the next legitimate roster is still accepted and still changes membership. +#[tokio::test] +async fn a_version_far_above_the_held_one_is_refused_and_the_next_roster_still_applies() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK); + + let problem: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), u64::MAX, 1, &[]), + ) + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(problem["code"], "error.album.roster_version_leap"); + assert_eq!(problem["current_version"], 1); + assert_eq!( + problem["max_version"], + 1 + capsule_server::membership::MAX_ROSTER_VERSION_STEP, + "the refusal names the ceiling, so the client knows what it may re-sign at" + ); + + // **Every number in this refusal must survive a generated client.** spargen lowers every + // integer in the contract as `i64` and emits no `u64` at all, so an out-of-range member + // would make the SDK fail to *decode* the problem — and a decode failure is not a typed API + // error, so the `code` and the recovery hint would be lost and the caller could not tell + // this refusal from a network fault. The declared version, the one number here the caller + // controls, rides the English `detail` for exactly that reason. + assert_decodable(&problem); + assert!( + problem["detail"] + .as_str() + .expect("a detail string") + .contains(&u64::MAX.to_string()), + "the declared version is still legible to a human: {}", + problem["detail"] + ); + assert!( + problem.get("declared").is_none(), + "and it is not an extension member: {problem}" + ); + + // Nothing was written, and — the point of the bound — the album is not wedged. + assert!(matches!( + membership_of(&fixture, BOB).await, + Membership::Member { .. } + )); + let body: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 2, 2, &[]), + ) + .await + .assert_status(StatusCode::OK) + .json(); + assert_eq!(body["roster_version"], 2); + assert_eq!( + membership_of(&fixture, BOB).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }), + "the legitimate roster after a leap is applied in full" + ); +} + +/// The window binds an album's **first** roster too: an album with no roster reads as version 0, +/// so a first publish cannot latch the counter either. +#[tokio::test] +async fn a_first_roster_far_above_zero_is_refused() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + + let problem: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster( + &dsk, + device(), + &album(), + u64::from(u32::MAX), + 1, + &[(BOB, MemberRole::Writer)], + ), + ) + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(problem["code"], "error.album.roster_version_leap"); + assert_eq!(problem["current_version"], 0); + assert_eq!(membership_of(&fixture, BOB).await, Membership::Never); + + publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK); +} + +#[tokio::test] +async fn a_member_omitted_from_a_later_roster_is_revoked_at_its_version_and_epoch() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK); + + // Removal is a new roster that omits the member; the MLS `Remove`'s epoch bump rides along. + let body: Value = publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 2, 2, &[]), + ) + .await + .assert_status(StatusCode::OK) + .json(); + assert_eq!(body["member_count"], 0); + assert_eq!( + membership_of(&fixture, BOB).await, + Membership::Revoked(Revocation { + at_version: 2, + at_epoch: 2, + }) + ); +} + +#[tokio::test] +async fn a_roster_not_attested_by_a_live_owner_device_is_refused() { + let dsk = identity_key(); + let fixture = Fixture::working(); + let bearer = fixture.bearer().await; + provision(&fixture, &bearer, &album()).await; + let roster = signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]); + + // No published directory: nothing can vouch for the attester. + let problem: Value = publish(&fixture, &bearer, &album(), &roster) + .await + .assert_status(StatusCode::FORBIDDEN) + .json(); + assert_eq!(problem["code"], "error.album.roster_attester"); + + // A directory that holds the device under a *different* key: the signature does not verify. + anchor(&fixture, &bearer, &identity_key(), &identity_key()).await; + let problem: Value = publish(&fixture, &bearer, &album(), &roster) + .await + .assert_status(StatusCode::FORBIDDEN) + .json(); + assert_eq!(problem["code"], "error.album.roster_attester"); + assert_eq!(membership_of(&fixture, BOB).await, Membership::Never); +} + +#[tokio::test] +async fn a_roster_attested_for_another_account_is_refused_even_by_the_owner() { + // The owner's token, the owner's album, the owner's device key — but the document says it + // was attested by somebody else. The anchor is the *caller's* directory, so the document + // must name the caller. + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + let forged = { + use capsule_core::crypto::keys::AmkVersion; + use capsule_core::crypto::membership::AlbumRoster; + AlbumRoster { + album_id: uuid::Uuid::parse_str(album().as_str()).expect("a uuid"), + roster_version: 1, + amk_epoch: AmkVersion(1), + attested_by_user: uuid::Uuid::parse_str(BOB).expect("a uuid"), + attested_by_device: device(), + attested_at: "2026-09-02T00:00:00Z".to_owned(), + members: vec![], + } + }; + let signed = + capsule_core::crypto::membership::SignedAlbumRoster::sign(forged, &dsk).expect("signs"); + let encoded = { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .encode(capsule_core::cbor::to_canonical_vec(&signed).expect("encodes")) + }; + let problem: Value = publish(&fixture, &bearer, &album(), &encoded) + .await + .assert_status(StatusCode::FORBIDDEN) + .json(); + assert_eq!(problem["code"], "error.album.roster_attester"); +} + +#[tokio::test] +async fn a_member_cannot_publish_the_owners_roster_and_learns_nothing_trying() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + publish( + &fixture, + &bearer, + &album(), + &signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]), + ) + .await + .assert_status(StatusCode::OK); + + // Bob is a writer member, and gets the album ceremonies' answer: not yours is not found — + // the same body an album nobody provisioned gets. + let bob = fixture.other_bearer(BOB).await; + let roster = signed_roster(&dsk, device(), &album(), 2, 1, &[]); + let as_member: Value = publish(&fixture, &bob, &album(), &roster) + .await + .assert_status(StatusCode::NOT_FOUND) + .json(); + assert_eq!(as_member["code"], "error.album.roster_not_found"); + let unprovisioned: Value = publish( + &fixture, + &bob, + &second_album(), + &signed_roster(&dsk, device(), &second_album(), 2, 1, &[]), + ) + .await + .assert_status(StatusCode::NOT_FOUND) + .json(); + assert_eq!( + as_member, unprovisioned, + "one answer for not-yours and not-there" + ); + assert!(matches!( + membership_of(&fixture, BOB).await, + Membership::Member { .. } + )); +} + +#[tokio::test] +async fn a_malformed_roster_is_refused_before_anything_is_read() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + + let malformed = [ + // Not base64 at all. + "not base64!".to_owned(), + // Base64 of something that is not a signed roster. + { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(b"not a roster") + }, + // Past the size ceiling, refused before decoding. + { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(vec![0u8; MAX_ROSTER_BYTES + 1]) + }, + // A valid signed roster with a trailing byte: verifiable, but not the canonical bytes. + { + use base64::Engine as _; + let mut bytes = base64::engine::general_purpose::STANDARD + .decode(signed_roster(&dsk, device(), &album(), 1, 1, &[])) + .expect("the helper emits base64"); + bytes.push(0); + base64::engine::general_purpose::STANDARD.encode(bytes) + }, + // For a different album than the path. + signed_roster(&dsk, device(), &second_album(), 1, 1, &[]), + // Lists the owner. + signed_roster( + &dsk, + device(), + &album(), + 1, + 1, + &[(support::user().as_str(), MemberRole::Reader)], + ), + // Lists an account twice. + signed_roster( + &dsk, + device(), + &album(), + 1, + 1, + &[(BOB, MemberRole::Reader), (BOB, MemberRole::Writer)], + ), + ]; + for roster in malformed { + let problem: Value = publish(&fixture, &bearer, &album(), &roster) + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(problem["code"], "error.album.roster_malformed", "{problem}"); + } + assert!( + fixture + .members + .current_roster(&album()) + .await + .expect("the store answers") + .is_none(), + "nothing was written" + ); +} + +#[tokio::test] +async fn a_store_that_cannot_answer_is_an_outage_never_a_refusal() { + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + let roster = signed_roster(&dsk, device(), &album(), 1, 1, &[(BOB, MemberRole::Writer)]); + + for (down, up) in [ + ( + Box::new(|| fixture.members.set_unavailable(true)) as Box, + Box::new(|| fixture.members.set_unavailable(false)) as Box, + ), + ( + Box::new(|| fixture.albums.set_unavailable(true)), + Box::new(|| fixture.albums.set_unavailable(false)), + ), + ( + Box::new(|| fixture.directories.set_unavailable(true)), + Box::new(|| fixture.directories.set_unavailable(false)), + ), + ] { + down(); + let problem: Value = publish(&fixture, &bearer, &album(), &roster) + .await + .assert_status(StatusCode::INTERNAL_SERVER_ERROR) + .json(); + assert_eq!(problem["code"], "error.album.unavailable"); + up(); + } + assert_eq!(membership_of(&fixture, BOB).await, Membership::Never); +} + +#[tokio::test] +async fn a_publish_without_the_protocol_handshake_is_refused_by_the_gate() { + // The roster route is a write inside the gated group: a client that does not say which + // protocol it speaks is turned away before the body is read. + let dsk = identity_key(); + let (fixture, bearer) = ready(&dsk).await; + fixture + .client + .raw() + .put(&format!("/v1/albums/{}/roster", album())) + .header("authorization", &bearer) + .json(&json!({ + "roster_cbor": signed_roster(&dsk, device(), &album(), 1, 1, &[]), + })) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST); + assert!( + fixture + .members + .current_roster(&album()) + .await + .expect("the store answers") + .is_none() + ); +} diff --git a/capsule-server/tests/support/fault.rs b/capsule-server/tests/support/fault.rs index 08c25d00..35bc9a47 100644 --- a/capsule-server/tests/support/fault.rs +++ b/capsule-server/tests/support/fault.rs @@ -166,4 +166,22 @@ impl AssetIndex for CrashBeforeCommit { fn head_seq<'a>(&'a self, owner: &'a OwnerId) -> IndexFuture<'a, u64> { self.inner.head_seq(owner) } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + self.inner.album_feed_page(owner, album, after, limit) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + self.inner.album_head_seq(owner, album) + } } diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index 64a2b0d4..cd9f25f2 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -67,6 +67,10 @@ use capsule_server::index::{ AssetIndex, AssetRow, BlobOutcome, BlobRecord, FeedEntry, HoldOutcome, IndexFuture, LifecycleOp, OpOutcome, PendingAsset, Reservation, ServingHold, }; +use capsule_server::membership::{ + InMemoryMembership, MemberRole, Membership, MembershipContext, MembershipStore, RosterOutcome, + RosterRecord, +}; use capsule_server::moderation::{ InMemoryModeration, ModerationContext, ModerationEvent, ModerationStore, Standing, }; @@ -91,7 +95,7 @@ use capsule_server::sync::{CURSOR_KEY_LEN, CursorCodec, SyncContext}; use capsule_server::upload::authority::{ AlbumWriteAccess, AuthorityError, AuthorityFuture, WriteAuthority, }; -use capsule_server::upload::{UploadContext, UploadPolicy}; +use capsule_server::upload::{UploadContext, UploadPolicy, WriteRole}; use capsule_server::verify::VerifyContext; use jiff::{SignedDuration, Timestamp}; use kynos::test::{TestClient, TestRequest}; @@ -1718,7 +1722,10 @@ impl BlobStore for SwallowingBlobs { /// would, rather than by flipping a flag the port does not have. #[derive(Debug, Default)] pub(crate) struct TestAuthority { - albums: Mutex>, + /// Each album's owner and protocol pin. + albums: Mutex>, + /// Each `(album, member)`'s role on the roster (`S-C51`). + shares: Mutex>, upgrades: Mutex>, devices: Mutex>, unavailable: AtomicBool, @@ -1733,12 +1740,25 @@ impl TestAuthority { /// Record `album` as writable by `owner`, pinned to `protocol_pin`. pub(crate) fn allow_album(&self, owner: &OwnerId, album: &AlbumId, protocol_pin: &str) { self.albums().insert( - (owner.as_str().to_owned(), album.as_str().to_owned()), - protocol_pin.to_owned(), + album.as_str().to_owned(), + (owner.as_str().to_owned(), protocol_pin.to_owned()), + ); + } + + /// Put `member` on `album`'s roster with `role` (`S-C51`). + pub(crate) fn share(&self, album: &AlbumId, member: &UserId, role: MemberRole) { + self.shares().insert( + (album.as_str().to_owned(), member.as_str().to_owned()), + role, ); } - /// Forget an album, as a closed or unshared one would be. + /// Take `member` off `album`'s roster. + pub(crate) fn unshare(&self, album: &AlbumId, member: &UserId) { + self.shares() + .remove(&(album.as_str().to_owned(), member.as_str().to_owned())); + } + /// Put an album into upgrade quiescence under `intent` (`S-C24`). /// /// The double carries the fact the production authority reads off the album record, so a @@ -1763,9 +1783,21 @@ impl TestAuthority { .copied() } + /// Forget an album, as a closed one would be. + /// + /// `owner` is asserted rather than looked up: the map is album-keyed, and a case that closes + /// the wrong owner's album would otherwise pass vacuously. pub(crate) fn close_album(&self, owner: &OwnerId, album: &AlbumId) { - self.albums() - .remove(&(owner.as_str().to_owned(), album.as_str().to_owned())); + let mut albums = self.albums(); + assert!( + albums + .get(album.as_str()) + .is_some_and(|(held, _)| held == owner.as_str()), + "close_album: {album} is not {owner}'s" + ); + { + albums.remove(album.as_str()); + } } /// Record `device` as entering `user`'s directory at `added_at`. @@ -1784,10 +1816,14 @@ impl TestAuthority { self.unavailable.store(unavailable, Ordering::SeqCst); } - fn albums(&self) -> MutexGuard<'_, BTreeMap<(String, String), String>> { + fn albums(&self) -> MutexGuard<'_, BTreeMap> { self.albums.lock().unwrap_or_else(PoisonError::into_inner) } + fn shares(&self) -> MutexGuard<'_, BTreeMap<(String, String), MemberRole>> { + self.shares.lock().unwrap_or_else(PoisonError::into_inner) + } + fn devices(&self) -> MutexGuard<'_, BTreeMap<(String, Uuid), Timestamp>> { self.devices.lock().unwrap_or_else(PoisonError::into_inner) } @@ -1800,20 +1836,34 @@ impl TestAuthority { impl WriteAuthority for TestAuthority { fn album_write_access<'a>( &'a self, - owner: &'a OwnerId, + caller: &'a UserId, album: &'a AlbumId, ) -> AuthorityFuture<'a, AlbumWriteAccess> { Box::pin(async move { if self.is_down() { return Err(AuthorityError::unavailable(REFUSAL)); } - Ok(self - .albums() - .get(&(owner.as_str().to_owned(), album.as_str().to_owned())) - .map_or(AlbumWriteAccess::Denied, |pin| AlbumWriteAccess::Writable { - protocol_pin: pin.clone(), - quiescing_under: self.quiescing_under(owner, album), - })) + let Some((owner, pin)) = self.albums().get(album.as_str()).cloned() else { + return Ok(AlbumWriteAccess::Denied); + }; + let role = if owner == caller.as_str() { + WriteRole::Owner + } else { + match self + .shares() + .get(&(album.as_str().to_owned(), caller.as_str().to_owned())) + { + Some(MemberRole::Writer) => WriteRole::Member, + _ => return Ok(AlbumWriteAccess::Denied), + } + }; + let owner_id = OwnerId::new(owner); + Ok(AlbumWriteAccess::Writable { + protocol_pin: pin, + quiescing_under: self.quiescing_under(&owner_id, album), + owner_id, + role, + }) }) } @@ -1923,6 +1973,67 @@ impl QuotaStore for SwitchableQuota { } } +/// A membership store that can be made to fail on demand. +#[derive(Debug, Default)] +pub(crate) struct SwitchableMembership { + inner: InMemoryMembership, + unavailable: AtomicBool, +} + +impl SwitchableMembership { + /// A working store. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Make every subsequent operation fail, or stop. + pub(crate) fn set_unavailable(&self, unavailable: bool) { + self.unavailable.store(unavailable, Ordering::SeqCst); + } + + fn refuse() -> Result { + Err(StoreError::Unavailable { + store: "membership", + detail: REFUSAL.to_owned(), + }) + } + + fn is_down(&self) -> bool { + self.unavailable.load(Ordering::SeqCst) + } +} + +impl MembershipStore for SwitchableMembership { + fn apply_roster( + &self, + roster: RosterRecord, + members: Vec<(UserId, MemberRole)>, + ) -> StoreFuture<'_, RosterOutcome> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.apply_roster(roster, members) + } + + fn membership<'a>( + &'a self, + album: &'a AlbumId, + user: &'a UserId, + ) -> StoreFuture<'a, Membership> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.membership(album, user) + } + + fn current_roster<'a>(&'a self, album: &'a AlbumId) -> StoreFuture<'a, Option> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.current_roster(album) + } +} + /// An album store that can be made to fail on demand. #[derive(Debug, Default)] pub(crate) struct SwitchableAlbums { @@ -2249,6 +2360,100 @@ impl AssetIndex for SwitchableIndex { } self.inner.head_seq(owner) } + + fn album_feed_page<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + after: u64, + limit: usize, + ) -> IndexFuture<'a, Vec> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.album_feed_page(owner, album, after, limit) + } + + fn album_head_seq<'a>( + &'a self, + owner: &'a OwnerId, + album: &'a AlbumId, + ) -> IndexFuture<'a, u64> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.album_head_seq(owner, album) + } +} + +/// 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. @@ -2256,8 +2461,8 @@ impl AssetIndex for SwitchableIndex { /// 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. @@ -2290,6 +2495,8 @@ pub(crate) struct Fixture { pub(crate) directories: Arc, /// The albums the server has provisioned. pub(crate) albums: Arc, + /// The album rosters the server holds, and who they make a member (`S-C51`). + pub(crate) members: Arc, /// The quota ledger the server charges against. pub(crate) quotas: Arc, /// The collector's marks, which is where `retrievable` diverges from `stored`. @@ -2358,6 +2565,7 @@ impl Fixture { let cursors = Arc::new(CursorCodec::new(&CURSOR_KEY)); let directories = Arc::new(SwitchableDirectories::new()); let albums = Arc::new(SwitchableAlbums::new()); + let members = Arc::new(SwitchableMembership::new()); let quotas = Arc::new(SwitchableQuota::new()); let marks = Arc::new(InMemoryCollection::new()); let receipts = Arc::new(InMemoryReceipts::new()); @@ -2406,13 +2614,19 @@ impl Fixture { clock.clone(), UploadPolicy::default(), ), - sync: SyncContext::new(index_fault.clone(), blobs.clone(), cursors.clone()), + sync: SyncContext::new( + index_fault.clone(), + blobs.clone(), + cursors.clone(), + albums.clone(), + members.clone(), + ), serve: ServeContext::new( index_fault.clone(), blobs.clone(), marks.clone(), uploads.clone(), - capsule_server::serve::owned_assets(), + capsule_server::serve::membership_reads(members.clone()), ), verify: VerifyContext::new( index_fault.clone(), @@ -2422,6 +2636,7 @@ impl Fixture { ), directories: DeviceDirectoryContext::new(directories.clone(), clock.clone()), albums: AlbumContext::new(albums.clone(), clock.clone()), + membership: MembershipContext::new(members.clone(), clock.clone()), quota: QuotaContext::new(quotas.clone(), clock.clone(), quota_limits), attestation: AttestationContext::new( receipts.clone(), @@ -2448,9 +2663,9 @@ impl Fixture { }); Self { - client: TestClient::new( + client: Client::new(TestClient::new( capsule_server::service(app.clone()).expect("the router builds"), - ), + )), app, sessions, accounts, @@ -2464,6 +2679,7 @@ impl Fixture { cursors, directories, albums, + members, quotas, marks, receipts, @@ -2510,6 +2726,8 @@ impl Fixture { let blobs = Arc::new(SwallowingBlobs::new()); let index = Arc::new(SwitchableIndex::new()); + let members = Arc::new(InMemoryMembership::new()); + let albums = Arc::new(SwitchableAlbums::new()); let tokens = Arc::new(signer(clock.clone())); let app = App::new(Modules { auth: AuthContext::new(AuthCollaborators { @@ -2535,13 +2753,15 @@ impl Fixture { index.clone(), blobs.clone(), Arc::new(CursorCodec::new(&CURSOR_KEY)), + albums.clone(), + members.clone(), ), serve: ServeContext::new( index.clone(), blobs.clone(), Arc::new(InMemoryCollection::new()), Arc::new(SwitchableUploads::new(clock.clone())), - capsule_server::serve::owned_assets(), + capsule_server::serve::membership_reads(members.clone()), ), verify: VerifyContext::new( index, @@ -2553,7 +2773,8 @@ impl Fixture { Arc::new(SwitchableDirectories::new()), clock.clone(), ), - albums: AlbumContext::new(Arc::new(SwitchableAlbums::new()), clock.clone()), + albums: AlbumContext::new(albums.clone(), clock.clone()), + membership: MembershipContext::new(members, clock.clone()), quota: QuotaContext::new( Arc::new(SwitchableQuota::new()), clock.clone(), @@ -2671,7 +2892,6 @@ impl Fixture { .client .post("/v1/upload") .header("authorization", bearer) - .header("x-capsule-protocol", PROTOCOL_VERSION) .json(request) .send() .await @@ -2682,8 +2902,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, @@ -2694,7 +2914,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()) @@ -2903,6 +3122,44 @@ pub(crate) fn signed_directory_with_device( capsule_core::cbor::to_canonical_vec(&directory).expect("a directory serializes") } +/// A signed album roster, base64-encoded as `PUT /v1/albums/{album_id}/roster` carries it +/// (`S-C51`). +/// +/// Attested by the seeded account through `capsule_core::crypto::membership` — the same types +/// the server verifies with — so a fixture cannot pass while the two ends disagree about what +/// was signed. +pub(crate) fn signed_roster( + dsk: &HybridSigningKey, + device_id: Uuid, + album: &AlbumId, + roster_version: u64, + amk_epoch: u32, + members: &[(&str, MemberRole)], +) -> String { + use capsule_core::crypto::keys::AmkVersion; + use capsule_core::crypto::membership::{AlbumRoster, RosterMember, SignedAlbumRoster}; + + let roster = AlbumRoster { + album_id: Uuid::parse_str(album.as_str()).expect("an album id is a uuid"), + roster_version, + amk_epoch: AmkVersion(amk_epoch), + attested_by_user: Uuid::parse_str(user().as_str()) + .expect("the seeded account id is a uuid"), + attested_by_device: device_id, + attested_at: "2026-09-02T00:00:00Z".to_owned(), + members: members + .iter() + .map(|(user_id, role)| RosterMember { + user_id: Uuid::parse_str(user_id).expect("a member id is a uuid"), + role: *role, + }) + .collect(), + }; + let signed = SignedAlbumRoster::sign(roster, dsk).expect("a roster signs"); + base64::engine::general_purpose::STANDARD + .encode(capsule_core::cbor::to_canonical_vec(&signed).expect("a signed roster serializes")) +} + /// A signed upgrade intent, as the proposing admin device's client would produce it (`S-C24`). /// /// Built through `capsule_core::crypto::upgrade` — the *same* types the server verifies with — diff --git a/capsule-server/tests/sync.rs b/capsule-server/tests/sync.rs index 07e1426f..1ca8ac45 100644 --- a/capsule-server/tests/sync.rs +++ b/capsule-server/tests/sync.rs @@ -10,12 +10,13 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use capsule_server::blob::{BlobStore, ContentAddress}; use capsule_server::index::{AssetIndex, BlobRecord, PendingAsset}; -use capsule_server::store::{AssetId, BlobRole}; -use capsule_server::sync::{CURSOR_KEY_LEN, CursorCodec}; +use capsule_server::membership::{MemberRole, MembershipStore as _, RosterRecord}; +use capsule_server::store::{AlbumId, AssetId, BlobRole, OwnerId, UserId}; +use capsule_server::sync::{CURSOR_KEY_LEN, CursorCodec, CursorScope}; use jiff::Timestamp; use kynos::http::StatusCode; use serde_json::Value; -use support::{CURSOR_KEY, Fixture, PROTOCOL_VERSION, album, owner}; +use support::{CURSOR_KEY, Fixture, PROTOCOL_VERSION, album, owner, second_album}; /// The bytes a manifest for `asset` is made of. /// @@ -383,7 +384,9 @@ async fn another_owners_cursor_is_refused_even_under_the_right_key() { let codec = CursorCodec::new(&CURSOR_KEY); let foreign = codec.encode( - &capsule_server::store::OwnerId::new("01937b7c-0000-7000-8000-0000000000ff"), + &CursorScope::feed(&capsule_server::store::OwnerId::new( + "01937b7c-0000-7000-8000-0000000000ff", + )), 0, ); @@ -404,7 +407,8 @@ async fn another_servers_cursor_is_refused() { let bearer = bearer(&fixture).await; publish(&fixture, "keyed").await; - let elsewhere = CursorCodec::new(&[0x11; CURSOR_KEY_LEN]).encode(&owner(), 0); + let elsewhere = + CursorCodec::new(&[0x11; CURSOR_KEY_LEN]).encode(&CursorScope::feed(&owner()), 0); let body = page( &fixture, &bearer, @@ -578,3 +582,286 @@ async fn upload(fixture: &Fixture, bearer: &str, role: &str, marker: u8) -> Cont } ContentAddress::parse(&support::checksum(&bytes)).expect("a content address") } + +// =========================================================================================== +// Album pages for members (`S-C51`) +// =========================================================================================== + +/// A second account, on the seeded album's roster in whatever state a case puts it. +const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0"; + +/// Publish `asset` into `into`, returning the sequence number publication minted. +async fn publish_into(fixture: &Fixture, asset: &str, into: &AlbumId) -> u64 { + let id = AssetId::new(asset); + fixture + .index + .reserve(PendingAsset { + asset_id: id.clone(), + owner_id: owner(), + album_id: into.clone(), + protocol_version: PROTOCOL_VERSION.to_owned(), + crypto_suite_id: 1, + created_at: Timestamp::UNIX_EPOCH, + }) + .await + .expect("the index reserves"); + let provenance = store_blob(fixture, &manifest_bytes(asset)).await; + record(fixture, &id, BlobRole::Provenance, &provenance, 0).await; + let metadata = store_blob(fixture, format!("metadata-{asset}").as_bytes()).await; + record(fixture, &id, BlobRole::Metadata, &metadata, 0) + .await + .expect("landing the index tier publishes the asset") +} + +/// Provision the seeded album to the seeded account, so the page has an owner to ask about. +async fn provision(fixture: &Fixture, bearer: &str) { + fixture + .client + .post("/v1/albums") + .header("authorization", bearer) + .header("accept", "application/json") + .json(&serde_json::json!({ "album_id": album().as_str() })) + .send() + .await + .assert_status(StatusCode::CREATED); +} + +/// The seeded album's roster at `version`, naming `members`. +async fn roster(fixture: &Fixture, version: u64, members: &[(&str, MemberRole)]) { + fixture + .members + .apply_roster( + RosterRecord { + album_id: album(), + roster_version: version, + amk_epoch: version, + attested_by_device: support::device(), + received_at: Timestamp::UNIX_EPOCH, + document: format!("sync-test-v{version}").into_bytes(), + }, + members + .iter() + .map(|(user, role)| (UserId::new(*user), *role)) + .collect(), + ) + .await + .expect("the store applies"); +} + +/// Ask for a page and its raw response, for cases comparing bodies. +async fn page_raw(fixture: &Fixture, bearer: &str, query: &str) -> kynos::test::TestResponse { + fixture + .client + .get(&format!("/v1/sync?{query}")) + .header("authorization", bearer) + .header("accept", "application/json") + .send() + .await +} + +#[tokio::test] +async fn a_member_reads_the_albums_page_over_the_owners_sequence() { + let fixture = Fixture::working(); + let owner_bearer = bearer(&fixture).await; + provision(&fixture, &owner_bearer).await; + let first = publish_into(&fixture, "shared-1", &album()).await; + publish_into(&fixture, "private-1", &second_album()).await; + let third = publish_into(&fixture, "shared-2", &album()).await; + // After the album's last entry, so the album head and the owner's allocator differ. + publish_into(&fixture, "private-2", &second_album()).await; + roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await; + let bob = fixture.other_bearer(BOB).await; + + let body = page( + &fixture, + &bob, + &format!("album_id={}", album()), + StatusCode::OK, + ) + .await; + let entries = body["entries"].as_array().expect("an array"); + assert_eq!( + entries + .iter() + .map(|entry| entry["sync_seq"].as_u64().expect("a position")) + .collect::>(), + vec![first, third], + "the owner's sequence, filtered to the album, in order" + ); + assert!( + entries + .iter() + .all(|entry| entry["album_id"] == album().as_str()), + "nothing from the owner's other albums" + ); + assert_eq!(body["has_more"], false); + // The cursor is bound to (bob, album): it decodes for that scope and names the last entry. + let cursor = body["next_cursor"].as_str().expect("a cursor"); + assert_eq!( + fixture.cursors.decode( + &CursorScope::album(&OwnerId::new(BOB), &album()), + Some(cursor) + ), + Ok(third) + ); + + // Bob's own feed is still empty: the member's read did not move anything into his library. + let own = page(&fixture, &bob, "", StatusCode::OK).await; + assert!(own["entries"].as_array().expect("an array").is_empty()); +} + +#[tokio::test] +async fn an_album_cursor_is_refused_on_the_callers_own_feed() { + // Both shapes carry the owner's sequence numbers, so a cursor that crossed between them + // would skip a member's unseen entries. + let fixture = Fixture::working(); + let owner_bearer = bearer(&fixture).await; + provision(&fixture, &owner_bearer).await; + publish_into(&fixture, "shared-1", &album()).await; + roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await; + let bob = fixture.other_bearer(BOB).await; + + let on_album = page( + &fixture, + &bob, + &format!("album_id={}", album()), + StatusCode::OK, + ) + .await; + let cursor = on_album["next_cursor"] + .as_str() + .expect("a cursor") + .to_owned(); + let body = page( + &fixture, + &bob, + &format!("cursor={cursor}"), + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!(body["code"], "error.sync.cursor_invalid"); + // And the owner's own feed cursor is not an album cursor either. + let own = page(&fixture, &owner_bearer, "", StatusCode::OK).await; + let own_cursor = own["next_cursor"].as_str().expect("a cursor").to_owned(); + let body = page( + &fixture, + &owner_bearer, + &format!("album_id={}&cursor={own_cursor}", album()), + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!(body["code"], "error.sync.cursor_invalid"); +} + +#[tokio::test] +async fn a_non_member_a_former_member_and_an_unknown_album_get_one_refusal() { + let fixture = Fixture::working(); + let owner_bearer = bearer(&fixture).await; + provision(&fixture, &owner_bearer).await; + publish_into(&fixture, "shared-1", &album()).await; + roster(&fixture, 1, &[(BOB, MemberRole::Writer)]).await; + let bob = fixture.other_bearer(BOB).await; + + // Never a member of the *other* album (which is not even provisioned). + let unknown = page_raw(&fixture, &bob, &format!("album_id={}", second_album())).await; + unknown.assert_status(StatusCode::FORBIDDEN); + let unknown: Value = unknown.json(); + assert_eq!(unknown["code"], "error.sync.album_access_denied"); + + // Removed from the roster. + roster(&fixture, 2, &[]).await; + let former = page_raw(&fixture, &bob, &format!("album_id={}", album())).await; + former.assert_status(StatusCode::FORBIDDEN); + let former: Value = former.json(); + + // A stranger to a provisioned, shared album. + let carol = fixture + .other_bearer("01937b7c-0000-7000-8000-0000000000c0") + .await; + roster(&fixture, 3, &[(BOB, MemberRole::Writer)]).await; + let stranger = page_raw(&fixture, &carol, &format!("album_id={}", album())).await; + stranger.assert_status(StatusCode::FORBIDDEN); + let stranger: Value = stranger.json(); + // And re-admitted Bob reads again. + page( + &fixture, + &bob, + &format!("album_id={}", album()), + StatusCode::OK, + ) + .await; + + assert_eq!(unknown, former, "one body for every refusal"); + assert_eq!(former, stranger, "one body for every refusal"); +} + +#[tokio::test] +async fn the_owner_reads_an_album_page_too_and_it_pages() { + let fixture = Fixture::working(); + let owner_bearer = bearer(&fixture).await; + provision(&fixture, &owner_bearer).await; + let first = publish_into(&fixture, "shared-1", &album()).await; + publish_into(&fixture, "private-1", &second_album()).await; + let third = publish_into(&fixture, "shared-2", &album()).await; + // After the album's last entry: the owner's allocator is now past the album's head, which + // is what makes the final `has_more` a statement about the album and not the allocator. + publish_into(&fixture, "private-2", &second_album()).await; + + let one = page( + &fixture, + &owner_bearer, + &format!("album_id={}&page_size=1", album()), + StatusCode::OK, + ) + .await; + assert_eq!(one["entries"][0]["sync_seq"], first); + assert_eq!( + one["has_more"], true, + "the album's head is past this position" + ); + let cursor = one["next_cursor"].as_str().expect("a cursor").to_owned(); + let two = page( + &fixture, + &owner_bearer, + &format!("album_id={}&page_size=1&cursor={cursor}", album()), + StatusCode::OK, + ) + .await; + assert_eq!(two["entries"][0]["sync_seq"], third); + assert_eq!( + two["has_more"], false, + "the head is the album's last entry, not the owner's allocator, which minted more in \ + another album" + ); +} + +#[tokio::test] +async fn a_store_that_cannot_answer_the_album_question_is_an_outage() { + let fixture = Fixture::working(); + let owner_bearer = bearer(&fixture).await; + provision(&fixture, &owner_bearer).await; + roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await; + let bob = fixture.other_bearer(BOB).await; + + fixture.members.set_unavailable(true); + let body = page( + &fixture, + &bob, + &format!("album_id={}", album()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; + assert_eq!(body["code"], "error.sync.unavailable"); + fixture.members.set_unavailable(false); + + fixture.albums.set_unavailable(true); + let body = page( + &fixture, + &owner_bearer, + &format!("album_id={}", album()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .await; + assert_eq!(body["code"], "error.sync.unavailable"); + fixture.albums.set_unavailable(false); +} diff --git a/capsule-server/tests/upload.rs b/capsule-server/tests/upload.rs index 2d43cbe2..35091726 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"); } // =========================================================================================== @@ -875,6 +908,40 @@ async fn a_closed_album_stops_a_transfer_that_was_already_in_flight() { ); } +#[tokio::test] +async fn unsharing_a_member_stops_a_transfer_that_was_already_in_flight() { + // Finalization re-asks the authority for the *uploader*, so a writer removed from the roster + // between the first chunk and the last is refused where a closed album is refused. + use capsule_server::membership::MemberRole; + + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + let (first, second, whole) = blob(); + let id = fixture + .open_session_with(&bobs_request(&fixture, &whole), &bearer) + .await; + + fixture + .chunk(&id, 0, &first, &bearer) + .send() + .await + .assert_status(StatusCode::NO_CONTENT); + fixture + .authority + .unshare(&album(), &capsule_server::store::UserId::new(BOB)); + + let response = fixture.chunk(&id, 4096, &second, &bearer).send().await; + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!( + code(&response.json::()), + "error.upload.envelope_rejected" + ); + assert_eq!( + fixture.blobs.blob_count_for_test().await, + 0, + "a write refused at finalization commits nothing" + ); +} + #[tokio::test] async fn losing_the_finalize_claim_is_a_race_rather_than_a_failure() { let fixture = Fixture::working(); @@ -1271,3 +1338,224 @@ async fn finalization_crash_between_rename_and_commit_leaves_no_dangling_referen "the bytes are unchanged: an identical ciphertext is one object", ); } + +// =========================================================================================== +// Member writes (`S-C51`) +// =========================================================================================== + +/// A second account, on the seeded album's roster in whatever role a case puts it. +const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0"; + +/// Bob's own device, in Bob's own directory: invariant 7 is answered from the *uploader's* +/// directory, whoever the album belongs to. +fn bobs_device() -> uuid::Uuid { + uuid::Uuid::parse_str("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eb0").expect("a uuid") +} + +/// A create request for `bytes`, as Bob's device would sign it. +fn bobs_request(fixture: &Fixture, bytes: &[u8]) -> serde_json::Value { + let mut body = create_request(&fixture.clock, bytes, "original"); + body["manifest_envelope"]["created_by_user"] = BOB.into(); + body["manifest_envelope"]["created_by_device"] = bobs_device().to_string().into(); + body +} + +/// A fixture where Bob has a device and, when `role` is given, a seat on the seeded album. +async fn with_bob(role: Option) -> (Fixture, String) { + let fixture = Fixture::working(); + let bob = capsule_server::store::UserId::new(BOB); + fixture.authority.add_device( + &bob, + bobs_device(), + capsule_server::store::Clock::now(&*fixture.clock), + ); + if let Some(role) = role { + fixture.authority.share(&album(), &bob, role); + } + let bearer = fixture.other_bearer(BOB).await; + (fixture, bearer) +} + +#[tokio::test] +async fn a_writer_member_uploads_into_the_owners_album_and_pays_for_it() { + use capsule_server::membership::MemberRole; + use capsule_server::quota::QuotaStore as _; + + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + let bytes = payload(b'm', 8192); + + // No declared owner: the session is filed under the album's owner, because that is whose + // feed every member's devices read — and it is billed to the uploader, who spent the bytes. + let body: serde_json::Value = fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&bobs_request(&fixture, &bytes)) + .send() + .await + .assert_status(StatusCode::CREATED) + .json(); + let record = fixture + .uploads + .read_for_test(body["id"].as_str().expect("a session id")) + .await + .expect("the session is in the store"); + assert_eq!(record.owner_id, owner(), "filed under the album owner"); + assert_eq!( + record.upload_user_id, + capsule_server::store::UserId::new(BOB), + "uploaded by the member" + ); + assert_eq!( + fixture + .quotas + .usage(&capsule_server::store::UserId::new(BOB)) + .await + .expect("the ledger answers") + .used, + bytes.len() as u64, + "the uploader pays" + ); + assert_eq!( + fixture + .quotas + .usage(&support::user()) + .await + .expect("the ledger answers") + .used, + 0, + "and the owner does not" + ); + + // Declaring the album owner explicitly agrees with the authority and is accepted. + let mut agreeing = bobs_request(&fixture, &payload(b'n', 8192)); + agreeing["owner_id"] = owner().as_str().into(); + fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&agreeing) + .send() + .await + .assert_status(StatusCode::CREATED); +} + +#[tokio::test] +async fn a_member_may_not_declare_anyone_but_the_album_owner_as_owner() { + use capsule_server::membership::MemberRole; + + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + let bytes = payload(b'o', 8192); + // Themselves included: a member's asset under the member's own namespace would be one the + // album owner's feed never carries. + for declared in [BOB, "01937b7c-0000-7000-8000-0000000000c0"] { + let mut body = bobs_request(&fixture, &bytes); + body["owner_id"] = declared.into(); + let problem: serde_json::Value = fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&body) + .send() + .await + .assert_status(StatusCode::FORBIDDEN) + .json(); + assert_eq!(problem["code"], "error.upload.owner_not_permitted"); + } +} + +/// Bob's create, refused with the album's one `403`. +async fn refused(fixture: &Fixture, bearer: &str) -> serde_json::Value { + fixture + .client + .post("/v1/upload") + .header("authorization", bearer) + .json(&bobs_request(fixture, &payload(b'r', 8192))) + .send() + .await + .assert_status(StatusCode::FORBIDDEN) + .json() +} + +#[tokio::test] +async fn a_reader_a_former_member_and_a_stranger_get_the_one_album_refusal() { + use capsule_server::membership::MemberRole; + + // A reader: may fetch, may not add. + let (fixture, bearer) = with_bob(Some(MemberRole::Reader)).await; + let reader = refused(&fixture, &bearer).await; + assert_eq!(reader["code"], "error.upload.album_access_denied"); + + // A former writer: once on the roster, since removed. Same answer. + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + fixture + .authority + .unshare(&album(), &capsule_server::store::UserId::new(BOB)); + let former = refused(&fixture, &bearer).await; + + // A stranger whose envelope is the same well-formed one the writer case above is admitted + // with: the authority refuses before the device or the battery is consulted, so a good + // envelope from a non-member buys nothing. + let (fixture, bearer) = with_bob(None).await; + let stranger = refused(&fixture, &bearer).await; + + assert_eq!(reader, former, "one body for every refusal"); + assert_eq!(former, stranger, "one body for every refusal"); +} + +#[tokio::test] +async fn a_members_upload_is_still_checked_against_the_members_own_directory() { + use capsule_server::membership::MemberRole; + + // Invariant 7 does not move with the namespace: Bob writes under the owner's album, but the + // device on the envelope has to be in *Bob's* directory — naming the owner's device is a + // device Bob has not published. + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + let mut body = bobs_request(&fixture, &payload(b'd', 8192)); + body["manifest_envelope"]["created_by_device"] = device().to_string().into(); + let problem: serde_json::Value = fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&body) + .send() + .await + .assert_status(StatusCode::FORBIDDEN) + .json(); + assert_eq!(problem["code"], "error.upload.device_not_authorized"); +} + +/// **An upload is attributed to the account that made it.** The manifest envelope's +/// `created_by_user` is stored and served back as the asset's provenance and nothing later +/// re-derives it, so a writer member could otherwise file an asset into the owner's album under +/// a third account's name. Invariant 7's device half is already bound to the *uploader's* own +/// directory; this is the account half of the same rule. +#[tokio::test] +async fn an_upload_attributed_to_another_account_is_refused() { + use capsule_server::membership::MemberRole; + + let (fixture, bearer) = with_bob(Some(MemberRole::Writer)).await; + + let mut forged = bobs_request(&fixture, &payload(b'x', 8192)); + forged["manifest_envelope"]["created_by_user"] = support::user().as_str().into(); + let problem: serde_json::Value = fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&forged) + .send() + .await + .assert_status(StatusCode::BAD_REQUEST) + .json(); + assert_eq!(problem["code"], "error.upload.envelope_mismatch"); + + // The matching arm: Bob's own create, under Bob's own name, still opens a session. + fixture + .client + .post("/v1/upload") + .header("authorization", &bearer) + .json(&bobs_request(&fixture, &payload(b'y', 8192))) + .send() + .await + .assert_status(StatusCode::CREATED); +} diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index d16f811c..fa5994c3 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -36895,6 +36895,56 @@ } } }, + "error.album.roster_attester": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only a device on the album owner's account can publish its roster." + } + } + } + }, + "error.album.roster_malformed": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capsule couldn't read that album roster." + } + } + } + }, + "error.album.roster_not_found": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That album isn't yours, or doesn't exist." + } + } + } + }, + "error.album.roster_stale": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The roster you sent is out of step with the one the server holds." + } + } + } + }, + "error.album.roster_version_leap": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That roster's version is too far ahead of the one the server holds." + } + } + } + }, "error.album.unavailable": { "localizations": { "en": { @@ -37999,6 +38049,16 @@ } } }, + "error.blob.access_revoked": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You no longer have access to this album." + } + } + } + }, "error.blob.gone": { "localizations": { "ar": { @@ -41611,6 +41671,16 @@ } } }, + "error.sync.album_access_denied": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You don't have access to that album." + } + } + } + }, "error.sync.cursor_invalid": { "localizations": { "ar": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 318b9021..b1d6be1c 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1828,6 +1828,11 @@ "drop.upload_only_badge": "Upload only", "error.album.invalid_id": "This album could not be registered because its identifier is malformed.", "error.album.not_available": "This album isn't available on your account.", + "error.album.roster_attester": "Only a device on the album owner's account can publish its roster.", + "error.album.roster_malformed": "Capsule couldn't read that album roster.", + "error.album.roster_not_found": "That album isn't yours, or doesn't exist.", + "error.album.roster_stale": "The roster you sent is out of step with the one the server holds.", + "error.album.roster_version_leap": "That roster's version is too far ahead of the one the server holds.", "error.album.unavailable": "Capsule couldn't set up that album. Please try again.", "error.album.upgrade_in_flight": "This album is already being upgraded.", "error.album.upgrade_malformed": "Capsule couldn't read that album upgrade request.", @@ -1852,6 +1857,7 @@ "error.auth.totp_not_pending": "There is no two-factor setup waiting to be confirmed.", "error.auth.unavailable": "Capsule couldn't reach your account just now. Please try again.", "error.auth.user_already_exists": "An account with these details already exists.", + "error.blob.access_revoked": "You no longer have access to this album.", "error.blob.gone": "That photo file is no longer available.", "error.blob.not_found": "That photo file isn't on the server.", "error.blob.pending_upload": "The original photo hasn't been uploaded from its device yet.", @@ -1918,6 +1924,7 @@ "error.storage.deep_rate_limited": "Too many deep storage checks. Please wait and try again.", "error.storage.invalid_request": "The storage-verification request was malformed.", "error.storage.unavailable": "Capsule couldn't check whether your photos are safely stored. Please try again.", + "error.sync.album_access_denied": "You don't have access to that album.", "error.sync.cursor_invalid": "The sync session is out of date. Capsule will resync from the start.", "error.sync.unauthenticated": "Please sign in again to continue syncing.", "error.sync.unavailable": "Capsule could not reach the server to sync. It will try again.", diff --git a/capsule-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..b5204b8f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7315,6 +7315,26 @@ "message": "This album isn't available on your account.", "context": "HTTP 403 on POST /v1/albums (album provisioning, S-C25): the submitted album id cannot be bound to the caller. Deliberately identical whatever the reason, so the endpoint never reveals whether another account already holds that id." }, + "error.album.roster_attester": { + "message": "Only a device on the album owner's account can publish its roster.", + "context": "HTTP 403 on PUT /v1/albums/{album_id}/roster (slice S-C51): the roster's attester signature does not verify against the owner's published device directory, the attesting device is revoked, no directory is published, or attested_by_user is not the caller." + }, + "error.album.roster_malformed": { + "message": "Capsule couldn't read that album roster.", + "context": "HTTP 400 on PUT /v1/albums/{album_id}/roster (slice S-C51): the body's roster_cbor is not base64 of a signed album roster, is past the 512 KiB ceiling, names a different album than the path, lists the owner or lists an account twice." + }, + "error.album.roster_not_found": { + "message": "That album isn't yours, or doesn't exist.", + "context": "HTTP 404 on PUT /v1/albums/{album_id}/roster (slice S-C51): no such album, or one owned by a different account. One answer for both — a member, even an MLS admin, cannot publish the owner's roster and learns nothing by trying." + }, + "error.album.roster_stale": { + "message": "The roster you sent is out of step with the one the server holds.", + "context": "HTTP 409 on PUT /v1/albums/{album_id}/roster (slice S-C51): the roster_version is at or below the one the server holds with different bytes, or a newer version carried a lower AMK epoch. Carries current_version so the client can re-sync and republish." + }, + "error.album.roster_version_leap": { + "message": "That roster's version is too far ahead of the one the server holds.", + "context": "HTTP 400 on PUT /v1/albums/{album_id}/roster (slice S-C51): the roster_version is more than the accepted step above the held one, which is refused because a version nothing can supersede would freeze the album's membership for good. Carries declared, current_version and max_version; the client re-signs the same roster at current_version + 1." + }, "error.album.unavailable": { "message": "Capsule couldn't set up that album. Please try again.", "context": "HTTP 500 on POST /v1/albums (slice S-C25): the album store could not answer, so nothing was provisioned. Retryable, and it says nothing about whether the id is available." @@ -7411,6 +7431,10 @@ "message": "An account with these details already exists.", "context": "High-level error for HTTP 409 on registration. Server sends code `error.auth.user_already_exists`; clients localize this message." }, + "error.blob.access_revoked": { + "message": "You no longer have access to this album.", + "context": "HTTP 403 on GET /v1/blob/{hash} (slice S-C51): the caller was once a member of the album that holds these bytes and has since been removed from its roster. Only a former member ever sees this; everyone else gets the same 404 as an unknown address. The client re-syncs the album list and degrades." + }, "error.blob.gone": { "message": "That photo file is no longer available.", "context": "HTTP 410 on GET /v1/blob/{hash} (slice S-C10): the address is referenced but not retrievable per policy — the asset was deleted, or the reference is dangling. Permanent: the client degrades to a representation it already holds rather than retrying. Distinct from the transient error.blob.pending_upload." @@ -7675,6 +7699,10 @@ "message": "Capsule couldn't check whether your photos are safely stored. Please try again.", "context": "HTTP 500 on POST /v1/storage/verify (slice S-C3): the asset index or the blob store could not answer, so no durability verdict was reached. Deliberately not a durable=false verdict — a client told \"not durable\" keeps its local copy and is safe, so conflating an outage with a real finding would train users to ignore the state that matters. Retryable." }, + "error.sync.album_access_denied": { + "message": "You don't have access to that album.", + "context": "HTTP 403 on GET /v1/sync?album_id= (slice S-C51): the album is not the caller's and the caller is not on its roster — never a member, removed, or no such album. One answer for all three, matching the write routes' uniform 403." + }, "error.sync.cursor_invalid": { "message": "The sync session is out of date. Capsule will resync from the start.", "context": "HTTP 400 on GET /v1/sync (threat-model invariant 22): the opaque sync cursor failed its server MAC — forged, mutated, issued to another owner, or minted under a rotated key. The client discards it and resyncs from an empty cursor."