diff --git a/SLICES.md b/SLICES.md
index ca3059ea..5eddddc5 100644
--- a/SLICES.md
+++ b/SLICES.md
@@ -321,10 +321,10 @@ row's remainder now lives.
| S-D29 | Local alert surface (`capsule-core::notify` + native delivery) | sdk/clients | S-Z11 | M | ACTIVE | ready | |
| S-D20 | CLI truthfulness pass (status/register/endpoints/flags) | sdk/clients | — | M | MIXED | done | |
| S-E1 | Share-link end-to-end serving | fed/sharing | S-C4 | M | MIXED | done\* | live-browser smoke → `S-Q5`; seeds → gates |
-| S-E2 | Federation capabilities + pulls | fed/sharing | S-C2, S-A3 | L | RETIRED | ready | capability gate on the live read method → `S-E5` |
+| S-E2 | Federation capabilities + pulls | fed/sharing | S-C2, S-A3 | L | RETIRED | part | the serving half ships: mint/refresh/revoke, the capability arm on `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, per-peer events budget, Postgres ordinal 6; the receiving half (egress worker, re-validation, rejected-hash table) → #476 |
| S-E3 | LAN peering | fed/sharing | S-D2, S-C7 | L | RETIRED | ready | live mDNS → post-v1 (peering.md note) |
| S-E4 | Aggregated federated albums (album-group view) | fed/sharing | S-E2, S-D2 | L | MIXED | done | cover override rides post-v1 settings doc |
-| S-E5 | Federation capability gate on the REST sync surface | fed/sharing | — | M-L | RETIRED | ready | |
+| S-E5 | Federation capability gate on the REST sync surface | fed/sharing | — | M-L | RETIRED | done | one `bearer` component, two principals; the peer arm is bound to the capability's album and its member's granted epoch |
| S-F1 | uniffi consolidation (0.29 catalog vs 0.31 core) | platform/FFI | — | M | ACTIVE | done | |
| S-F2 | Secure Enclave / StrongBox hybrid composition | platform/FFI | S-A4, S-F1 | L | ACTIVE | done\* | Kotlin run → owed-CI |
| S-F3 | Xcode/Gradle binding wiring + on-device CI | platform/FFI | S-F2 | L | ACTIVE | done\* | first CI runs + device lanes → owed-CI |
@@ -3838,6 +3838,23 @@ them was incidental:
discovery and pinning, signed report intake with `S-C32`'s rate limit, the blocklist and its
enforcement point, and whatever admin authentication the above needs.
- **Blocked on:** the federation layer (`S-E2`'s territory) and `S-C32`. **Tier:** Unit + Smoke.
+- **Status note (2026-09-09): both halves ship, one question stays open.** The
+ federation-capability layer landed with `S-E2`, and both of this slice's blocked deliverables
+ followed. **Report intake** is `POST /v1/federation/reports`: the report carries its own
+ Ed25519 signature over the canonical CBOR of its other fields, verified against the peer's
+ key, and the signature is verified **before** the `(reporting_server, reported_user)` budget
+ is charged, so a third party spoofing `reporting_server` cannot spend a real peer's allowance.
+ A report nobody signed for is dropped and never queued; an accepted one writes a row an
+ operator reads through `ModerationStore::pending_reports` and changes nothing about the
+ reported account. **The blocklist** is `blocked_at` on the peer row and is consulted at mint,
+ at every presentation, at refresh and at intake; blocking also cuts and publishes every live
+ grant the peer holds.
+- **Still owed here.** Peer keys are **operator-pinned**: this server has no outbound HTTP
+ client, so nothing fetches or TOFU-pins another server's `server-info`, and the operator
+ command that would do the pinning cannot be written until the durable boot arm exists (`serve
+ --memory` forgets what it pinned). The **admin authentication model** this slice names first
+ is untouched: `pending_reports` is the queue, and reading it over HTTP is what waits.
+ Blocklist *exchange* stays v2 by the contract. Filed as #476.
### S-C50 — the share-link privacy strip is specified where it cannot run
@@ -4522,7 +4539,21 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr
bullets pass; E2E case 4 lives. **Tier:** Unit + Smoke + E2E case 4.
- **Landed in retired code:** capabilities, budgets, and revocation state ship on the
Salvo server. **Re-scoped onto Kynos.**
-- **Owed:** capability gate on the live method → `S-E5`.
+- **Status note (2026-09-09).** The **serving half** ships on Kynos. `capsule-server::federation`
+ mints an EdDSA-JWT capability under the server's own operational key (the one `server-info`
+ publishes), records it, refreshes it idempotently on `(peer, jti)`, and revokes it — and the
+ store **is** the revocation list, so `/.well-known/capsule/revoked-jti` and "is this `jti`
+ revoked" have one answer. The pull is the existing reads: `GET /v1/sync?album_id=` and
+ `GET /v1/blob/{hash}` take the capability on the same `bearer` component a session token
+ rides (`S-E5`). Scope is enforced against the blob's server-visible role, the per-peer
+ events-per-hour budget rides `CounterStore`, and both stores have in-memory and Postgres
+ adapters passing one conformance suite (migration ordinal 6). `capsule-sdk::federation`
+ orchestrates a pull over generated calls only.
+- **Owed:** the **receiving** half — the egress worker that fetches on a schedule, invariant-20
+ re-validation of what it pulls, per-`(receiving_user, source_peer)` quota, the breadcrumb
+ index and the soft-fail rejected-hash table — plus bytes/hour and CPU/hour budgets, the error
+ budget, the circuit breaker and the probation tier, which need a weighted counter this port
+ does not have. Filed as #476. `error.federation.circuit_open` stays unused until it lands.
### S-E3 — LAN peering
@@ -4576,6 +4607,17 @@ Kynos server, which cannot be written until `S-C53` gives the server a way to cr
- **Note:** the verifier itself (`federation::pull::authorize`) is `ACTIVE` core and does
not change — this slice is purely about giving it a production caller on the new
transport.
+- **Status note (2026-09-09): done.** The verifier was rebuilt on Kynos rather than called
+ from the retired tree, because the retired one has no store behind it. `federation::scheme`
+ registers a second security scheme under the **same** `bearer` component name and description
+ as the session scheme — one entry in the document, one credential key in the generated SDK —
+ and hands the handler a `Principal::{Session, Peer}`. The authenticator asks the session
+ module first and only then the capability codec, so every existing bearer path is byte-for-byte
+ what it was; a capability that verifies must also be one this server **recorded**. Coded
+ refusals are the route's, from the admitted credential: revoked, wrong album, insufficient
+ scope, blocked peer, over budget. Peer identity is grounded in `federation_peers`, closing
+ `S-C8`'s note. E2E case 4's server half runs in `capsule-server/tests/federation.rs`, and its
+ SDK-over-a-socket half in `capsule-server/tests/sdk_client.rs`.
## Lane F — platform / FFI
diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml
index 104b58d6..34187966 100644
--- a/capsule-android/src/androidMain/res/values/strings.xml
+++ b/capsule-android/src/androidMain/res/values/strings.xml
@@ -1881,15 +1881,22 @@
The recovery backup could not be saved.
No recovery backup is saved for this account.
Capsule couldn\'t reach the recovery backup. Please try again.
+ That album couldn\'t be found.
This access grant is for a different album.
This shared album\'s access has expired.
This shared album\'s access could not be verified.
+ That sharing request isn\'t valid.
Access to this shared album has been revoked.
This source is temporarily backed off after repeated errors.
+ That person isn\'t on this album\'s member list.
+ This server doesn\'t share albums with other servers.
+ That server isn\'t one this server knows.
This source has reached its request limit. Please wait and try again.
Capsule couldn\'t read the revocation list. Please try again.
This access grant does not cover the requested content.
+ Capsule couldn\'t reach the federation records. Please try again.
Your account is suspended. You can\'t upload or share until it\'s reinstated.
+ That report isn\'t valid.
Too many reports from this source. Please wait and try again.
The moderation report could not be verified.
This server is blocked from federating with us.
diff --git a/capsule-docs/planned-modules.txt b/capsule-docs/planned-modules.txt
index d78427f0..f5c907d9 100644
--- a/capsule-docs/planned-modules.txt
+++ b/capsule-docs/planned-modules.txt
@@ -15,4 +15,3 @@
capsule-core::media The Capsule-side owner of decode, metadata extraction and derivative generation, which will consume Rawshift once Rawshift stabilizes. Rawshift is a pinned submodule today and is not a workspace dependency, so nothing consumes it and this module has no body to write yet. Lane B in SLICES.md.
capsule-core::notify Alert classes and their trigger predicates, so every platform evaluates one shared decision function rather than reimplementing the taxonomy. Contract: design/notifications.md. Tier 0 has no server half, so this is client-only work.
capsule-core::import::camera The PTP/IP tethered-camera source adapter (S-B9). Post-v1; the contract exists so the adapter seam is fixed before anything implements it.
-capsule-server::federation Server-to-server federation pull. The whole surface is post-v1 — `capsule-server` has no federation route, no capability-token verifier and no per-peer budget enforcement.
diff --git a/capsule-docs/src/content/docs/design/api-surfaces.md b/capsule-docs/src/content/docs/design/api-surfaces.md
index b7bcc6c1..6000c84d 100644
--- a/capsule-docs/src/content/docs/design/api-surfaces.md
+++ b/capsule-docs/src/content/docs/design/api-surfaces.md
@@ -24,7 +24,8 @@ the gate that keeps it current — is [Developer Documentation](/design/develope
| Album roster publish (`PUT /v1/albums/{album_id}/roster`) | REST | `capsule-server::membership` | [Threat Model — Validation](/design/threat-model/validation/) (invariant 33) |
| Blob fetch (`GET /v1/blob/{hash}`, HTTP `Range`) | REST | `capsule-server::blob` | [Download & Sync](/design/import/download-sync/) |
| Sync feed (change discovery after a cursor) | REST | `capsule-server::sync` | [Download & Sync](/design/import/download-sync/) |
-| Federation pull | REST | `capsule-server::federation` | [Federation](/design/federation/) |
+| Federation capability lifecycle (`POST /v1/albums/{album_id}/capabilities`, `DELETE /v1/albums/{album_id}/capabilities/{jti}`, `POST /v1/federation/capabilities/refresh`) and signed report intake (`POST /v1/federation/reports`) | REST | `capsule-server::federation` | [Federation](/design/federation/) |
+| Federation **pull** — no route of its own: a peer reads `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` with a capability in the `bearer` slot | REST | `capsule-server::federation` (the credential and its admission) over `::sync` / `::serve` | [Federation](/design/federation/) |
| Share serving (`/s/{opaque_id}`) | REST | `capsule-server::share` | [Share Links](/design/share-links/) |
| Guest drops (`POST /d/{opaque_id}`, inbox, adoption) | REST | `capsule-server::drop` | [Web Upload](/design/web-upload/) |
| Storage verification (`POST /v1/storage/verify`) | REST | `capsule-server::verify` | [Storage Verification](/design/import/storage-verification/) |
@@ -167,7 +168,13 @@ gate by accident.
Credentials use `Authorization: Bearer`. Session access tokens and federation capabilities are
different token types verified by their owning modules, even though both use the standard HTTP
-carriage.
+carriage. **The document carries one `bearer` component for both.** The two read primitives a peer
+pulls through — `GET /v1/sync` and `GET /v1/blob/{hash}` — register a second Kynos security scheme
+under the same component name and a byte-identical description, so every operation's `security` is
+the one requirement it always was and the generated client attaches either token type under the one
+credential key it knows. A second key would have split one carriage into two for a difference the
+wire does not have. `capsule-server/tests/conformance.rs` pins the component set at exactly one
+entry.
## Rejection Mapping
diff --git a/capsule-docs/src/content/docs/design/federation.md b/capsule-docs/src/content/docs/design/federation.md
index 712d6ca7..83c761dc 100644
--- a/capsule-docs/src/content/docs/design/federation.md
+++ b/capsule-docs/src/content/docs/design/federation.md
@@ -6,7 +6,7 @@ status: draft
Federation lets an album owned on one Capsule server be shared with users whose accounts live on another. This document covers **server-to-server** federation only; direct device-to-device sync for a single user is [Peering](/design/peering/).
-Federation reuses the planned Kynos REST read primitives — `/sync`, `/blob/{hash}`, and the standard manifest envelope. The only new things federation introduces are a **capability token** (the contract that gates which peers may fetch what) and a **per-peer compartmentalization layer**. Capability issuance, verification, the pull path, and per-peer rate budgeting will live in `capsule-server::federation`.
+Federation reuses the Kynos REST read primitives — `/sync`, `/blob/{hash}`, and the standard manifest envelope. The only new things federation introduces are a **capability token** (the contract that gates which peers may fetch what) and a **per-peer compartmentalization layer**. Capability issuance, verification, the capability arm of the read path, and per-peer rate budgeting live in `capsule-server::federation`.
## Threat Model
@@ -110,10 +110,31 @@ Signed under the home server's signing key — classical Ed25519 only, per the [
1. **Issuance.** A user on `home.tld` shares an album with `alice@other.tld`. `home.tld` mints a capability token for `other.tld` and delivers it as part of the share-invite message to Alice's client. Alice's client posts the token to `other.tld`; `other.tld` caches it server-side and uses it on every subsequent pull.
2. **Verification.** Capsule (the verifier, `home.tld` in this case) verifies the token offline against its own published signing key — no third-party PKI, no network call to a notary except for key rotation (see [Server Identity and Key Rotation](#server-identity-and-key-rotation)).
-3. **Refresh.** A token nearing `exp` is replaced by `other.tld` requesting a new one on Alice's behalf; the request is itself authenticated by the previous token. Idempotency keyed by `(peer_id, jti)` per [Threat Model — Idempotency Invariants](/design/threat-model/validation/#idempotency-invariants).
+3. **Refresh.** A token nearing `exp` is replaced by `other.tld` requesting a new one on Alice's behalf; the request is itself authenticated by the previous token. Idempotency keyed by `(peer_id, jti)` per [Threat Model — Idempotency Invariants](/design/threat-model/validation/#idempotency-invariants). **A refresh may never outlive the grant.** "Same peer, same album, same member" does not bound a chain of refreshes: a successor with a fresh `exp` is a grant that outlives the lifetime its owner chose, so a sixty-second capability would become an indefinite one after a single hop and revoking a `jti` the owner never saw would be the only remaining control. The issuing server therefore fixes an **absolute deadline** at the original mint, keeps it on its own record — never as a claim, because the token format is normative and parsed by every peer — and copies it unchanged into every successor. **Renewability is asked for, not assumed:** the default deadline is the first token's own `exp`, so a grant is single-shot unless the owner said otherwise, and each successor is minted for `min(default TTL, deadline − now)` so the last token of a grant ends at the deadline rather than past it. A refresh past it, or of a grant that was never renewable, is `403 error.federation.capability_expired` — the end of the sharing relationship rather than of one token, and a peer's cue to ask the album's owner rather than this server. The refresh also re-checks that the member the grant was minted for is still on the album's roster at the epoch it was granted at; otherwise it is `409 error.federation.member_not_on_roster`, because a successor for a membership that has ended is a token that can never be used.
4. **Revocation.** Revocation is a short TTL (`exp ≤ 24h`) plus a published **revocation list** at `/.well-known/capsule/revoked-jti`. Peers fetch and cache the list with a **maximum staleness of 15 minutes**. A peer holding a revoked-but-not-yet-expired token will still be honored for up to 15 minutes after revocation — this is the deliberate trade-off between revocation latency and revocation-list polling overhead. **List unavailability fails closed:** a verifier that relies on a *cached* copy of an issuer's revocation list and cannot refresh it must reject, past the 15-minute bound, any token whose `jti` it can no longer confirm against a current list — it never honors tokens indefinitely on a stale list. The `exp ≤ 24h` ceiling caps the worst case regardless, but the explicit rule means revocation cannot be outlived by making the list unreachable. (A server verifying its *own* tokens checks its own always-fresh list and is never stale.) Revoked `jti`s are **pruned** from the published list once their `exp` passes — an expired token is rejected unconditionally anyway — so the list stays bounded by at most 24 hours of revocations.
5. **Expiry.** A token past `exp` is rejected unconditionally; the verifier returns `401` and the peer must obtain a fresh token before continuing.
+### What a scope hides, and what it does not
+
+`read-derivative-only` is a **transport** control, exactly as the capability itself is: it decides which bytes this server hands over, not which identifiers a peer learns. A peer holding one receives every asset's full sync entry — including the `role`, `hash` and `size` of the original it may not fetch — and is refused only at `GET /v1/blob/{hash}`, with `403 error.federation.scope_insufficient`.
+
+That is deliberate, and the alternative is theatre. Every sync entry carries the asset's **signed manifest, as the exact bytes the client uploaded**, and the manifest's `ciphertext_hash` *is* the original's content address. A peer must receive that manifest to verify anything at all, and the server cannot rewrite it — a re-serialized manifest is one detached from its signatures, which is a manifest nobody can verify ([Download & Sync](/design/import/download-sync/#discovering-what-changed)). So filtering the entry's `blobs` array would remove an address that is still present, unfilterable, two fields away, while making the feed inconsistent with the manifest beside it. A control that hides nothing and costs consistency is worse than an honest boundary.
+
+What a derivative-only grant therefore does **not** promise: that the peer cannot learn an original exists, or its address, or its size. What it does promise, and enforces: the bytes are never served. Anyone needing the stronger property wants a separate album, not a narrower scope — the confidentiality boundary in Capsule is the MLS album key, and it always was.
+
+A **backup** is outside every scope and is answered `404`, not `403`: it is the owner's own durability artefact rather than part of what was shared, and the feed never names one, so a peer holds no fact about it that a `403` would be acknowledging.
+
+### Status note (2026-09-09)
+
+The **serving** half of everything above ships (`S-E2`, `S-E5`, `S-C49`).
+
+- `POST /v1/albums/{album_id}/capabilities` mints for the album's owner, over the same Ed25519 key `server-info` publishes; `POST /v1/federation/capabilities/refresh` presents the previous token and is idempotent on `(peer, jti)` because the store links a predecessor to its successor and re-signs the stored grant byte-for-byte; `DELETE /v1/albums/{album_id}/capabilities/{jti}` revokes. Revoking is deliberately **not** gated on the deployment federating: turning federation off must not remove an operator's ability to cut a grant already out there.
+- The **capability store is the revocation list**. Revoking an issued capability sets its `revoked_at` and publishes its `jti` in one transaction, so "is this `jti` revoked" has one answer. Publishing a member's roster removal cuts every grant made for that member; blocking a peer cuts every grant it holds. An epoch bump that keeps the member cuts nothing — the member still holds their keys — and a takedown cuts nothing either, because it is a per-asset serving hold.
+- The **pull is the read path**: `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` accept the capability on the same `bearer` component a session access token rides. Scope is enforced against each blob's server-visible role, and the grant is bound server-side to the epoch its member's membership was granted at, so a member removed and re-admitted later cannot reuse an older token.
+- **Signed report intake** (`POST /v1/federation/reports`) and the **server-level blocklist** are built; peer keys are **operator-pinned** rather than TOFU-fetched, because this server has no outbound HTTP client. **Neither is reachable in a real deployment yet**, and for one reason: nothing can pin or block a peer. `boot::assemble` refuses the durable backend outright until [#403](https://github.com/Capsulsaurus/Capsule/issues/403) lands its adapters, so an operator command would only run against `--memory` and forget what it pinned; it is owed with [#476](https://github.com/Capsulsaurus/Capsule/issues/476). Until then intake answers `403 error.federation.peer_unknown` to every peer and the blocklist is empty. The route stays mounted so a peer has a published contract to implement against — what is missing is the command, not the surface.
+
+What is **owed** (issue #476): the egress pull worker and everything downstream of it — invariant-20 re-validation of what is pulled, the per-`(receiving_user, source_peer)` quota, the breadcrumb index, the soft-fail rejected-hash table, and accepting hints. Of the per-peer budgets below, only **events/hour** ships: bytes/hour and CPU/hour, the error budget, the circuit breaker and the probation tier need a weighted counter the counter port does not have, and nothing measures per-request CPU. `error.federation.circuit_open` is in the catalog and unused until they land.
+
This capability is a **transport-scoped control, not a confidentiality control**: it gates *who may fetch at all* (rate-limiting, anti-enumeration, clean revocation of a sharing relationship), nothing more. Confidentiality is already enforced by [MLS album membership](/design/cryptography/mls/) — without the album master key, fetched bytes are unreadable.
## Validation at the Boundary
diff --git a/capsule-docs/src/content/docs/design/import/download-sync.md b/capsule-docs/src/content/docs/design/import/download-sync.md
index 4fa790f6..9a42e436 100644
--- a/capsule-docs/src/content/docs/design/import/download-sync.md
+++ b/capsule-docs/src/content/docs/design/import/download-sync.md
@@ -15,7 +15,7 @@ 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/sync?album_id=…&cursor=…` | Kynos REST | The same page for one **shared album**, read by its owner, by any account on its current roster (`S-C51`), or by a **federated peer** presenting a capability whose audience is that album (`S-E5`): the owner's sequence filtered to the album, so positions are per-album monotonic; the cursor is bound to `(caller, album)`, or to `(peer, album)` for a peer, which is its own scope so the two can never cross. Anyone else — never a member, removed, or no such album — receives one `403 error.sync.album_access_denied`; a peer that names a different album, or none, gets `403 error.federation.audience_mismatch`, because a peer has no feed of its own. The gaps between an album's positions reveal *how many* changes the owner made elsewhere, and nothing else; that volume-only metadata is accepted rather than paid for with a second, per-album numbering the anti-rewind mark would then have to reconcile. |
| `GET /v1/blob/{hash}` | REST (HTTP `Range`) | Fetch a ciphertext blob by its content address; ranged for resumable and partial reads. |
Each sync entry carries the asset's signed manifest as **the exact bytes the client uploaded** in its [provenance blob](/design/cryptography/provenance/#asset-manifest), the small encrypted **metadata blob**, and the asset's **blob manifest** — the content hashes of its original and derivative blobs — never original or derivative bytes. The manifest is passed through, not rebuilt: the server also holds an envelope projection of those same fields for its own key-free checks, but re-serializing *that* would hand the client a manifest detached from its signatures, which is a manifest no one can verify. Discovering a thousand new assets costs a few hundred kilobytes. The client decrypts each metadata blob, learns the asset's dimensions, capture date, and LQIP, and only *then* decides what else, if anything, to fetch. A deleted or modified asset arrives as a tombstone or an updated metadata reference; the client reconciles local state against it (see [Synchronization Scope](#synchronization-scope)).
diff --git a/capsule-docs/src/content/docs/design/moderation.md b/capsule-docs/src/content/docs/design/moderation.md
index f47f2775..c51a6fd8 100644
--- a/capsule-docs/src/content/docs/design/moderation.md
+++ b/capsule-docs/src/content/docs/design/moderation.md
@@ -25,14 +25,16 @@ The actual policy surfaces that need design:
A report against `alice@other.tld`'s asset is routed to her home server's administrators, since they are the only party that can act on her account. Three mechanics are fixed:
- **Authentication.** A federated report MUST be signed by the reporting server's [signing key](/design/federation/#server-identity-and-key-rotation) and is verified before it reaches the admin queue; an unsigned or invalid-signature report is dropped, never surfaced. This makes every report attributable — a server that submits false reports is itself identifiable and blockable.
-- **Rate-limiting.** Reports are bounded per `(reporting_server, reported_user)`; exceeding the limit applies backpressure rather than amplifying. Together with signing, this defeats the false-flag / mass-report abuse vector (a flood of forged or spoofed reports against one user).
+- **Rate-limiting.** Reports are bounded per `(reporting_server, reported_user)`; exceeding the limit applies backpressure rather than amplifying. A receiving server also bounds a peer's reports **across all accounts** — `reported_user` is a string the peer chooses, so a per-account limit alone is one a peer refreshes by naming a different account — and bounds attempts per *claimed* origin before it looks the peer up at all, which is the only place a bound can sit on an unauthenticated route.
+- **Unknown accounts are accepted and dropped.** A report naming an account the receiving server does not host is answered exactly as an accepted one is, and filed nowhere: an unresolvable report is a permanent orphan in an operator's queue, and a *distinct* refusal would turn intake into an account-enumeration surface for any peer holding a pinned key — which is not the same as being trusted with enumeration, since a peer key can be compromised and a peer may be adversarial toward its own users while remaining a legitimate partner. The receiving server logs it for its operator; the rate limits above still apply, so sweeping identifiers costs the peer its allowance. Together with signing, this defeats the false-flag / mass-report abuse vector (a flood of forged or spoofed reports against one user).
+- **Wire form and what is signed.** The report is JSON with seven members — `reporting_server`, `reported_user`, `asset_hash`, `album_id`, `reason` (optional), `reported_at` (RFC 3339), and `signature` — and the signature is Ed25519 over the **canonical CBOR of the other six**. One normalization rule, and only one: each of the six has surrounding whitespace trimmed and is otherwise signed exactly as sent. In particular `reporting_server` is signed as the peer wrote it — the receiving server folds case and strips a trailing dot to *look the peer up*, and that canonical form is not what the signature covers — and `reported_at` is signed as its RFC 3339 text, not as a re-rendered instant. A receiving server keeps the signed bytes verbatim beside the row so the signature can be re-verified later; rebuilding them from stored, normalized fields would produce different bytes and an unattributable report.
- **Content.** A report carries the alleged asset's **content hash and album pointer — never plaintext or decryption material**. This is the privacy-preserving, operable middle: the home-server admin can locate the asset and, *if* they already hold album access, fetch and view it to act; an admin without album access sees only opaque identifiers, exactly as the E2EE model requires. A report never widens who can read content.
### Blocklists
Server-level blocklists, plus per-user blocks that federate:
-- **Server-level blocklist.** A server admin publishes a list of peer servers that this server refuses to accept federated requests from. Operates at the [federation capability](/design/federation/#federation-capabilities) layer.
+- **Server-level blocklist.** A server admin publishes a list of peer servers that this server refuses to accept federated requests from. Operates at the [federation capability](/design/federation/#federation-capabilities) layer. **Status: enforced, not yet writable.** `blocked_at` is a column on the peer row and every federation boundary consults it — mint, capability presentation, refresh, report intake — but nothing in production can set it: the durable backend refuses to assemble until [#403](https://github.com/Capsulsaurus/Capsule/issues/403) lands its adapters, so an operator command that pinned or blocked a peer could only run against `--memory`, which forgets the moment it exits. The command is owed with [#476](https://github.com/Capsulsaurus/Capsule/issues/476). Until then this list is empty in every real deployment, and this document's own rule cuts both ways: a blocklist nothing consults reads as protection, and so does one nothing can write.
- **Per-user block.** A user can block another user; the block is enforced by the blocker's home server — the blocked user is removed from albums shared with the blocker and cannot share new albums with them. Removal is an ordinary MLS `Remove` + AMK epoch bump applied at the blocked user's next sync; the prior epochs' keys they already hold are not retroactively clawed back (consistent with [removal semantics](/design/cryptography/mls/#remove-user-charlie)). A per-user block is **scoped to that user**: it does **not** propagate as a server-wide federation block, so one user (or a coordinated group) cannot weaponize blocks to sever an entire peer server from the federation. Each home server enforces only its own users' blocks. (The MLS `Remove` this rides has landed — `OpenMlsAuthority::block_user`, slice `S-X4`; see the [MLS status note](/design/cryptography/mls/). It removes every device the blocked user holds in one commit, so the epoch bumps once.)
- **Blocklist exchange (v2).** A peer-level mechanism for sharing *server-level* blocklists across federated servers (so a malicious server isn't pure whack-a-mole) is **deferred to v2**, but its shape is fixed now: signed, versioned blocklist documents an admin **opts into** consuming from peers they already trust — never auto-applied, and deliberately distinct from per-user blocks (which never propagate). v1 ships only the manual server-level blocklist above.
@@ -58,7 +60,7 @@ When a moderation action requires the *home server* to stop serving a specific a
- Federated peers fetching the asset receive `410 Gone`. (This deliberately diverges from the [share-link and drop serve paths](/design/share-links/#security-contract), which return an indistinguishable `404` — those must not confirm a capability URL ever existed, while a takedown *intends* to signal removal of content whose existence the peer already knows. The per-surface rule: capability-URL serving → `404`; takedown of known content → `410`.)
- The asset's underlying blob is **not** deleted — the user owns the data, and a takedown is a serving constraint, not a destruction; the user can still restore from their own backup. A takedown is therefore **reversible by default** (an admin can lift it). A **legal-hold** variant marks the asset indefinitely unservable where law requires it — lifted only when the legal obligation ends, not at admin discretion — but even then never destroys the user's bytes: the constraint is on the *home server's serving*, not on the data the user holds.
- **Storage verification tells the truth about a held asset**: every blob reports `stored` and **not** `retrievable`. That surface exists to answer one question — may a client release its only local copy? — and a server that called held bytes durable would be answering it wrong in the one direction that loses a photo. The honest pair is *we have your bytes, and we will not serve them*.
-- **Status note.** Suspension enforcement and the user's own moderation record are **served today** by the Kynos surface, with slice `S-C8`: a suspended account's upload session creation is refused with a structured code, and `GET /v1/moderation/record` is where the user reads what was done and why. Moderation *actions* deliberately have **no wire surface** — this doc names an admin throughout and specifies no way for one to authenticate, so the actions sit behind an operator-driven port, the same shape the garbage collector and the integrity scrub already use. Federated report intake and the server-level blocklist both need the federation-capability layer and are owed with `S-C49`.
+- **Status note.** Suspension enforcement and the user's own moderation record are **served today** by the Kynos surface, with slice `S-C8`: a suspended account's upload session creation is refused with a structured code, and `GET /v1/moderation/record` is where the user reads what was done and why. Moderation *actions* deliberately have **no wire surface** — this doc names an admin throughout and specifies no way for one to authenticate, so the actions sit behind an operator-driven port, the same shape the garbage collector and the integrity scrub already use. **Federated report intake and the server-level blocklist are built as of `S-C49`** (2026-09-09), on the federation-capability layer `S-E2` landed — and **neither is reachable in a real deployment yet**, for one shared reason: peer keys are operator-pinned, and no operator command can pin one until the durable backend assembles ([#403](https://github.com/Capsulsaurus/Capsule/issues/403), then [#476](https://github.com/Capsulsaurus/Capsule/issues/476)). Intake therefore answers `403 error.federation.peer_unknown` to every peer today, and the blocklist is empty. The route stays mounted and its contract published, because a peer implementing against this document needs it and the operator command is what is missing rather than the surface. What is built: `POST /v1/federation/reports` verifies the reporting server's Ed25519 signature against the key an operator pinned for it *before* charging the `(reporting_server, reported_user)` budget — so a third party spoofing `reporting_server` cannot spend a real peer's allowance — drops anything unsigned or unattributable, and files an accepted report for an operator to read without touching the reported account's standing. The blocklist is a column on the peer row, consulted at mint, at every capability presentation, at refresh and at intake; blocking also cuts and publishes every live grant the peer holds. Two things stay owed beyond the operator command (issue #476): nothing *fetches* a peer key — TOFU at intake is rejected on its own merits, because the first report from an unknown server is the wrong moment to decide whether to trust it — and the **admin** surface that would read the report queue still waits on the admin authentication model this doc leaves open.
- The takedown emits a **server-visible moderation provenance record** the user sees in their audit log — what was taken down, when, and (where policy permits) why — honoring the "[No silent operations](#what-moderation-cannot-do-structural)" rule. A user whose asset stops serving is never left to guess why, and the moderation action is itself auditable after the fact.
## Federation Boundary
diff --git a/capsule-docs/src/content/docs/design/module-map.md b/capsule-docs/src/content/docs/design/module-map.md
index 5e452908..b538f7fa 100644
--- a/capsule-docs/src/content/docs/design/module-map.md
+++ b/capsule-docs/src/content/docs/design/module-map.md
@@ -132,7 +132,9 @@ covers (`rg "E2E case N"`), and slices in the repo-root `SLICES.md` reference th
3. **Sync feed pickup.** Upload from device A → device B's feed advances → device B fetches the
metadata blob and, per scope, the original.
4. **Federation cross-server pull.** Alice on `home.tld` shares to Bob on `other.tld` → capability
- token → Bob's server pulls metadata and blobs → Bob's client renders.
+ token → Bob's server pulls metadata and blobs → Bob's client renders. (The **server half** and
+ the SDK's pull over a socket land with `S-E2`/`S-E5`: `capsule-server/tests/federation.rs` and
+ `capsule-server/tests/sdk_client.rs`. Bob's client rendering is `capsule-e2e`'s.)
5. **LAN peering A→B.** Two devices on one LAN; discovery → TLS handshake → delta-scoped artifact →
restore on the receiver → byte-equal libraries.
6. **Backup → restore on a fresh device.** Export a full backup → bootstrap a new device via
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 a62b55be..fdabcc33 100644
--- a/capsule-docs/src/content/docs/design/threat-model/validation.md
+++ b/capsule-docs/src/content/docs/design/threat-model/validation.md
@@ -63,15 +63,15 @@ reintroducing the stale revival that 17 exists to catch, in the code enforcing i
### On federation pull (server-to-server)
-- **19.** Capability token verifies under home server's signing key; `exp` in future; `jti` not in revocation list (cached ≤ 15 min). Otherwise `401` / `403`.
-- **20.** All checks (1)–(18) re-applied — federation does not unlock looser rules.
-- **21.** Per-peer rate budgets unbroken (events/hour, bytes/hour, CPU/hour). Otherwise `429`.
+- **19.** Capability token verifies under home server's signing key; `exp` in future; `jti` not in revocation list (cached ≤ 15 min). Otherwise `401` / `403`. **Enforced** (`S-E5`) on `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`. The structural failures — does not verify, expired, not this server's — are the framework's uncoded `401`, because the authenticator has no seam for a coded body; everything a peer can act on is the route's coded `403` from an admitted credential: `error.federation.capability_revoked`, `error.federation.audience_mismatch`, `error.federation.scope_insufficient`, `error.moderation.server_blocked`. A capability that verifies must also be one this server **recorded**, and the record binds the grant to the epoch its member's membership was granted at.
+- **20.** All checks (1)–(18) re-applied — federation does not unlock looser rules. **Not yet reachable:** this server never *receives* a federated write, because nothing on it pulls from a peer (issue #476). The invariant binds the egress worker when it lands.
+- **21.** Per-peer rate budgets unbroken (events/hour, bytes/hour, CPU/hour). Otherwise `429`. **Events/hour is enforced** through `CounterStore`, keyed on the peer's origin rather than on the capability — a budget per token would be one a peer widens by asking for more tokens — and answered as `429 error.federation.rate_budget_exceeded`. **Bytes/hour and CPU/hour are not**: both need a weighted counter the port does not have, and nothing measures per-request CPU (issue #476).
### On the sync feed, directory publish, and federated reports
- **22.** The `sync_cursor` carries a server MAC under a server-only key; a forged or mutated cursor is rejected (`400`). This is the authenticity layer; the client independently enforces per-album `sync_seq` monotonicity (client-side invariants below). Owner: [Import — Download & Sync](/design/import/download-sync/#discovering-what-changed).
- **23.** A published `DeviceDirectory` has `directory_version` **strictly greater** than the version currently stored for that user, and the master signature covers it. A non-advancing or regressing publish is rejected (`409`). The signature is checked against the account's **identity anchor** — the identity public key its first published directory arrived with (in the required `X-Capsule-Identity-Key` header), immutable thereafter. A publish presenting a different key is rejected `403`: the document is well-formed and correctly signed, and signed by somebody else. Owner: [Cryptography — Device Directory](/design/cryptography/keys/#device-directory).
-- **24.** A federated **report** (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer's report rate budget; otherwise it is dropped before reaching the admin queue. Owner: [Moderation — Federated Reporting](/design/moderation/#federated-reporting).
+- **24.** A federated **report** (an out-of-band moderation message, not a state write) carries a valid signature from the reporting server and is within that peer's report rate budget; otherwise it is dropped before reaching the admin queue. **Enforced** (`S-C49`) at `POST /v1/federation/reports`: the signature is Ed25519 over the canonical CBOR of every other field, verified against the peer's operator-pinned key, and it is checked **before** the `(reporting_server, reported_user)` budget is charged so a spoofed `reporting_server` cannot spend a real peer's allowance. A report from a server nobody pinned is `403` — intake is not the moment a peer becomes trusted. Owner: [Moderation — Federated Reporting](/design/moderation/#federated-reporting).
### On `PUT /v1/albums/{album_id}/roster` (album roster publish)
diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json
index b1d6be1c..d1f58ee8 100644
--- a/capsule-i18n/src/bundles/en.json
+++ b/capsule-i18n/src/bundles/en.json
@@ -1890,15 +1890,22 @@
"error.escrow.malformed": "The recovery backup could not be saved.",
"error.escrow.not_stored": "No recovery backup is saved for this account.",
"error.escrow.unavailable": "Capsule couldn't reach the recovery backup. Please try again.",
+ "error.federation.album_not_found": "That album couldn't be found.",
"error.federation.audience_mismatch": "This access grant is for a different album.",
"error.federation.capability_expired": "This shared album's access has expired.",
"error.federation.capability_invalid": "This shared album's access could not be verified.",
+ "error.federation.capability_malformed": "That sharing request isn't valid.",
"error.federation.capability_revoked": "Access to this shared album has been revoked.",
"error.federation.circuit_open": "This source is temporarily backed off after repeated errors.",
+ "error.federation.member_not_on_roster": "That person isn't on this album's member list.",
+ "error.federation.not_configured": "This server doesn't share albums with other servers.",
+ "error.federation.peer_unknown": "That server isn't one this server knows.",
"error.federation.rate_budget_exceeded": "This source has reached its request limit. Please wait and try again.",
"error.federation.revocations_unavailable": "Capsule couldn't read the revocation list. Please try again.",
"error.federation.scope_insufficient": "This access grant does not cover the requested content.",
+ "error.federation.unavailable": "Capsule couldn't reach the federation records. Please try again.",
"error.moderation.account_suspended": "Your account is suspended. You can't upload or share until it's reinstated.",
+ "error.moderation.report_malformed": "That report isn't valid.",
"error.moderation.report_rate_limited": "Too many reports from this source. Please wait and try again.",
"error.moderation.report_unsigned": "The moderation report could not be verified.",
"error.moderation.server_blocked": "This server is blocked from federating with us.",
diff --git a/capsule-i18n/src/generated.rs b/capsule-i18n/src/generated.rs
index 1352a76e..55bdc608 100644
--- a/capsule-i18n/src/generated.rs
+++ b/capsule-i18n/src/generated.rs
@@ -224,6 +224,9 @@ pub mod error_codes {
/// `error.escrow.unavailable`
pub const ESCROW_UNAVAILABLE: &str = "error.escrow.unavailable";
+ /// `error.federation.album_not_found`
+ pub const FEDERATION_ALBUM_NOT_FOUND: &str = "error.federation.album_not_found";
+
/// `error.federation.audience_mismatch`
pub const FEDERATION_AUDIENCE_MISMATCH: &str = "error.federation.audience_mismatch";
@@ -233,12 +236,24 @@ pub mod error_codes {
/// `error.federation.capability_invalid`
pub const FEDERATION_CAPABILITY_INVALID: &str = "error.federation.capability_invalid";
+ /// `error.federation.capability_malformed`
+ pub const FEDERATION_CAPABILITY_MALFORMED: &str = "error.federation.capability_malformed";
+
/// `error.federation.capability_revoked`
pub const FEDERATION_CAPABILITY_REVOKED: &str = "error.federation.capability_revoked";
/// `error.federation.circuit_open`
pub const FEDERATION_CIRCUIT_OPEN: &str = "error.federation.circuit_open";
+ /// `error.federation.member_not_on_roster`
+ pub const FEDERATION_MEMBER_NOT_ON_ROSTER: &str = "error.federation.member_not_on_roster";
+
+ /// `error.federation.not_configured`
+ pub const FEDERATION_NOT_CONFIGURED: &str = "error.federation.not_configured";
+
+ /// `error.federation.peer_unknown`
+ pub const FEDERATION_PEER_UNKNOWN: &str = "error.federation.peer_unknown";
+
/// `error.federation.rate_budget_exceeded`
pub const FEDERATION_RATE_BUDGET_EXCEEDED: &str = "error.federation.rate_budget_exceeded";
@@ -248,9 +263,15 @@ pub mod error_codes {
/// `error.federation.scope_insufficient`
pub const FEDERATION_SCOPE_INSUFFICIENT: &str = "error.federation.scope_insufficient";
+ /// `error.federation.unavailable`
+ pub const FEDERATION_UNAVAILABLE: &str = "error.federation.unavailable";
+
/// `error.moderation.account_suspended`
pub const MODERATION_ACCOUNT_SUSPENDED: &str = "error.moderation.account_suspended";
+ /// `error.moderation.report_malformed`
+ pub const MODERATION_REPORT_MALFORMED: &str = "error.moderation.report_malformed";
+
/// `error.moderation.report_rate_limited`
pub const MODERATION_REPORT_RATE_LIMITED: &str = "error.moderation.report_rate_limited";
diff --git a/capsule-sdk/src/federation.rs b/capsule-sdk/src/federation.rs
new file mode 100644
index 00000000..3edd416c
--- /dev/null
+++ b/capsule-sdk/src/federation.rs
@@ -0,0 +1,537 @@
+//! [`FederationPull`] — a peer server pulling one shared album from its home server (`S-E2`).
+//!
+//! # There is no federation protocol to speak
+//!
+//! design/federation.md introduces **no new data protocol**: a peer pulls through exactly the
+//! primitives a client pulls through, `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, with a
+//! capability token in the `Authorization: Bearer` slot instead of a session access token. So
+//! this module is *orchestration over generated calls* and contains no parser: the page comes
+//! back through [`SyncConsumer::pull_album`], the bytes through the generated `get_blob` fed
+//! into the same self-verifying [`RangedFetcher`](crate::fetch::RangedFetcher) every download
+//! uses, and the lifecycle calls are the generated `refresh_capability` and `revoked_jti`.
+//!
+//! # The pull is gated on a fresh revocation list, and fails closed
+//!
+//! A capability is a bearer token: the only thing that can take one back before it expires is
+//! the home server's published list at `/.well-known/capsule/revoked-jti`. So a puller does not
+//! present a token it has not recently checked. [`FederationPull`] refreshes its snapshot when
+//! the one it holds is older than the list's own `max_staleness_seconds`, refuses to pull when
+//! the snapshot cannot be refreshed past that bound ([`FederationError::ListStale`]), and
+//! refuses immediately when the token's `jti` is on the list
+//! ([`FederationError::Revoked`]) — the same fail-closed rule the server-side verifier applies.
+//!
+//! Nothing here caches a decision it could re-derive: the snapshot is the list as published,
+//! and the answer is recomputed from it on every call.
+//!
+//! # Refresh replaces the credential in place
+//!
+//! `POST /v1/federation/capabilities/refresh` presents the *previous* capability and answers the
+//! successor; the predecessor is revoked as the successor is issued, so a puller that kept using
+//! it would refuse itself on the next poll. [`FederationPull::refresh`] therefore swaps the held
+//! token, its `jti` and both clients over together, under one lock.
+
+use std::sync::{Arc, RwLock};
+use std::time::{Duration, Instant};
+
+use tracing::instrument;
+
+use crate::fetch::{BlobSource, RangeOutcome};
+use crate::sync::{SyncConsumer, SyncCursor, SyncError, SyncPage};
+use crate::{net, rest};
+
+/// The scheme key the generated client attaches a bearer under.
+///
+/// The same one a session token rides: the server registers one `bearer` component for both
+/// token types, which is what keeps a capability presentable through the generated client at all.
+const BEARER_SCHEME: &str = "bearer";
+
+/// Why a federated pull could not proceed.
+#[derive(Debug, thiserror::Error)]
+pub enum FederationError {
+ /// The capability's `jti` is on the home server's published revocation list.
+ ///
+ /// Terminal for this token. The peer asks the album's owner for a fresh grant, or stops.
+ #[error("this capability has been revoked by its issuer")]
+ Revoked,
+ /// The revocation list could not be refreshed inside its own staleness bound.
+ ///
+ /// **Not** a reason to keep pulling: a list a peer cannot refresh is a list that may have
+ /// revoked this token, and honouring the token anyway is exactly the failure the bound
+ /// exists to prevent.
+ #[error("the revocation list is staler than its own bound allows")]
+ ListStale,
+ /// The home server refused, or could not be reached.
+ #[error("the home server answered {code}: {detail}")]
+ Refused {
+ /// The stable `error.*` code, where the answer carried one.
+ code: String,
+ /// The server's own description.
+ detail: String,
+ },
+ /// The transport failed, or a URL could not be built.
+ #[error("the pull could not be transported: {0}")]
+ Transport(String),
+ /// The feed answered, and the page did not survive validation.
+ #[error(transparent)]
+ Feed(#[from] SyncError),
+}
+
+/// The revocation list as the home server last published it.
+#[derive(Debug, Clone)]
+pub struct RevocationSnapshot {
+ /// Every `jti` the issuer currently refuses.
+ pub revoked: Vec,
+ /// How long the issuer says a copy of this list may be relied on.
+ pub max_staleness: Duration,
+ /// When this peer fetched it, on the local monotonic clock.
+ ///
+ /// The *local* clock and not the list's `generated_at`: a peer deciding freshness from a
+ /// timestamp the issuer wrote would be trusting the party whose revocations it is checking
+ /// to be honest about their age.
+ fetched_at: Instant,
+}
+
+impl RevocationSnapshot {
+ /// Whether this copy is still inside the issuer's own bound at `now`.
+ #[must_use]
+ pub fn is_fresh(&self, now: Instant) -> bool {
+ now.duration_since(self.fetched_at) <= self.max_staleness
+ }
+
+ /// Whether the issuer currently refuses `jti`.
+ #[must_use]
+ pub fn refuses(&self, jti: &str) -> bool {
+ self.revoked.iter().any(|revoked| revoked == jti)
+ }
+}
+
+/// What the puller currently holds: the credential, and the clients built over it.
+struct Held {
+ token: String,
+ jti: String,
+ sync: SyncConsumer,
+ blobs: CapabilityBlobSource,
+ client: rest::Client,
+}
+
+/// A peer server pulling one album it holds a capability for.
+pub struct FederationPull {
+ base_url: String,
+ album_id: String,
+ held: RwLock,
+ snapshot: RwLock>,
+}
+
+impl FederationPull {
+ /// A puller against `base_url`, presenting `token` for `album_id`.
+ ///
+ /// `jti` is the token's own identifier as the home server minted it — the key the revocation
+ /// list is checked against. It is passed in rather than parsed out of the token, because
+ /// this module holds no JWT parser and a client that read its own credential's claims would
+ /// be trusting a value it never verified.
+ ///
+ /// # Errors
+ ///
+ /// [`FederationError::Transport`] when `base_url` is not a URL a client can hang paths off.
+ pub fn new(
+ base_url: &str,
+ album_id: impl Into,
+ token: impl Into,
+ jti: impl Into,
+ ) -> Result {
+ let token = token.into();
+ let jti = jti.into();
+ Ok(Self {
+ base_url: base_url.to_owned(),
+ album_id: album_id.into(),
+ held: RwLock::new(Held::build(base_url, token, jti)?),
+ snapshot: RwLock::new(None),
+ })
+ }
+
+ /// The `jti` of the capability currently held.
+ #[must_use]
+ pub fn jti(&self) -> String {
+ read(&self.held).jti.clone()
+ }
+
+ /// The token currently held, for a caller that persists it across restarts.
+ #[must_use]
+ pub fn token(&self) -> String {
+ read(&self.held).token.clone()
+ }
+
+ /// Fetch the issuer's revocation list and keep it as this puller's snapshot.
+ ///
+ /// # Errors
+ ///
+ /// [`FederationError::Refused`] or [`FederationError::Transport`]; the snapshot is left
+ /// as it was, and the next [`Self::admit`] will refuse once the old one goes stale.
+ #[instrument(skip(self))]
+ pub async fn poll_revocations(&self) -> Result {
+ let client = read(&self.held).client.clone();
+ let list = client
+ .revoked_jti()
+ .await
+ .map_err(|error| {
+ // The list declares one coded refusal, so the generated error is a newtype
+ // over the problem rather than an enum of statuses.
+ let code = match &error {
+ rest::Error::Api(response) => Some(response.inner().0.code.clone()),
+ _ => None,
+ };
+ refusal("the revocation list", code, &error.to_string())
+ })?
+ .into_inner();
+ // Fail **closed** on a bound this client cannot read: a negative or absurd
+ // `max_staleness_seconds` means the snapshot is stale the instant it is taken, so the
+ // next `admit` re-polls rather than honouring the list forever. `u64::MAX` here would
+ // have read as fail-open the day the schema widened.
+ let max_staleness =
+ Duration::from_secs(u64::try_from(list.max_staleness_seconds).unwrap_or(0));
+ let snapshot = RevocationSnapshot {
+ revoked: list
+ .revoked
+ .into_iter()
+ .map(|token| token.jti.clone())
+ .collect(),
+ max_staleness,
+ fetched_at: Instant::now(),
+ };
+ tracing::debug!(
+ revoked = snapshot.revoked.len(),
+ max_staleness = ?snapshot.max_staleness,
+ "refreshed the issuer's revocation list"
+ );
+ *write(&self.snapshot) = Some(snapshot.clone());
+ Ok(snapshot)
+ }
+
+ /// Refuse unless the held capability is admissible right now.
+ ///
+ /// Refreshes the snapshot when the held one is past the issuer's own bound. Every pull goes
+ /// through here, so a revoked grant stops the pull rather than being discovered one refusal
+ /// at a time.
+ ///
+ /// # Errors
+ ///
+ /// [`FederationError::Revoked`] when the issuer refuses this `jti`;
+ /// [`FederationError::ListStale`] when the list could not be refreshed inside its bound.
+ pub async fn admit(&self) -> Result<(), FederationError> {
+ let fresh = read(&self.snapshot)
+ .as_ref()
+ .filter(|snapshot| snapshot.is_fresh(Instant::now()))
+ .cloned();
+ let snapshot = match fresh {
+ Some(snapshot) => snapshot,
+ None => self.poll_revocations().await.map_err(|error| {
+ tracing::warn!(%error, "the revocation list could not be refreshed; refusing to pull");
+ FederationError::ListStale
+ })?,
+ };
+ if snapshot.refuses(&read(&self.held).jti) {
+ tracing::info!("the issuer has revoked this capability; the pull stops");
+ return Err(FederationError::Revoked);
+ }
+ Ok(())
+ }
+
+ /// Pull one page of the album this capability covers.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::admit`], plus whatever the feed answered.
+ #[instrument(skip(self, cursor), fields(album = %self.album_id))]
+ pub async fn page(
+ &self,
+ cursor: &SyncCursor,
+ page_size: u32,
+ ) -> Result {
+ self.admit().await?;
+ let sync = read(&self.held).sync.clone();
+ Ok(sync.pull_album(cursor, page_size, &self.album_id).await?)
+ }
+
+ /// Fetch one blob the page named, verified against its own address.
+ ///
+ /// The bytes are self-verifying: [`crate::fetch::fetch_blob`] hashes what arrives and
+ /// refuses anything that is not the address asked for, so a home server cannot substitute
+ /// content for a peer any more than it can for its own client.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::admit`], plus [`FederationError::Refused`] carrying the fetch's own reason —
+ /// `error.federation.scope_insufficient` for a blob outside the grant's scope among them.
+ #[instrument(skip(self), fields(album = %self.album_id))]
+ pub async fn blob(&self, hash: &str, expected_len: u64) -> Result, FederationError> {
+ self.admit().await?;
+ let blobs = read(&self.held).blobs.clone();
+ crate::fetch::fetch_blob(&blobs, hash, expected_len)
+ .await
+ .map_err(|error| FederationError::Refused {
+ // The stable code the server sent, not a prose rendering of it: the doc above
+ // promises `error.federation.scope_insufficient` here, and a caller that had to
+ // string-match a message to find it would be matching on a message.
+ code: error.error_code().unwrap_or_default().to_owned(),
+ detail: error.to_string(),
+ })
+ }
+
+ /// Exchange the held capability for its successor, and pull with that from now on.
+ ///
+ /// Idempotent at the server: a replay of the same predecessor answers the same successor,
+ /// so a puller that crashed between the call and persisting the answer gets the same token
+ /// back rather than a second grant.
+ ///
+ /// # Errors
+ ///
+ /// [`FederationError::Refused`] when the issuer will not continue the grant — a revoked
+ /// predecessor, a blocked peer, a spent budget — or [`FederationError::Transport`].
+ #[instrument(skip(self))]
+ pub async fn refresh(&self) -> Result {
+ let client = read(&self.held).client.clone();
+ let refreshed = client
+ .refresh_capability(capsule_core::crypto::primitives::PROTOCOL_VERSION, None)
+ .await
+ .map_err(|error| match error {
+ rest::Error::Api(response) => match response.into_inner() {
+ rest::RefreshCapabilityError::Status403(problem)
+ | rest::RefreshCapabilityError::Status429(problem)
+ | rest::RefreshCapabilityError::Status400(problem)
+ | rest::RefreshCapabilityError::Status500(problem) => {
+ FederationError::Refused {
+ code: problem.code.clone(),
+ detail: problem.detail.clone().unwrap_or_default(),
+ }
+ }
+ other => FederationError::Refused {
+ code: String::new(),
+ detail: other.to_string(),
+ },
+ },
+ other => FederationError::Transport(other.to_string()),
+ })?
+ .into_inner();
+
+ let held = Held::build(
+ &self.base_url,
+ refreshed.token.clone(),
+ refreshed.jti.clone(),
+ )?;
+ // The predecessor is revoked the moment the successor is issued, so the swap has to be
+ // one act: a puller holding one client on the old token and another on the new would
+ // refuse itself on whichever request lost the race.
+ *write(&self.held) = held;
+ // The list a moment ago did not carry the predecessor; it does now. Dropped rather than
+ // patched, so the next pull re-reads it from the issuer.
+ *write(&self.snapshot) = None;
+ tracing::info!(jti = %refreshed.jti, replayed = refreshed.replayed, "refreshed the capability");
+ Ok(refreshed.token)
+ }
+}
+
+impl std::fmt::Debug for FederationPull {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ // Never the token: a `Debug` that printed a live credential is how one reaches a log.
+ formatter
+ .debug_struct("FederationPull")
+ .field("base_url", &self.base_url)
+ .field("album_id", &self.album_id)
+ .field("jti", &read(&self.held).jti)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Held {
+ fn build(base_url: &str, token: String, jti: String) -> Result {
+ Ok(Self {
+ sync: SyncConsumer::with_static_token(base_url, token.clone())
+ .map_err(|error| FederationError::Transport(error.to_string()))?,
+ blobs: CapabilityBlobSource::new(base_url, token.clone())?,
+ client: client_for(base_url, &token)?,
+ token,
+ jti,
+ })
+ }
+}
+
+/// A generated client for `base_url` carrying `token` under the bearer scheme.
+fn client_for(base_url: &str, token: &str) -> Result {
+ let http = net::http_client().map_err(|error| FederationError::Transport(error.to_string()))?;
+ Ok(rest::Client::with_client(http, base_url)
+ .map_err(|error| FederationError::Transport(error.to_string()))?
+ .with_credential(
+ BEARER_SCHEME,
+ rest::Credential::Bearer(token.to_owned().into()),
+ ))
+}
+
+/// A refusal from the home server, carrying the stable code when the answer had one.
+fn refusal(doing: &str, code: Option, detail: &str) -> FederationError {
+ tracing::info!(%doing, ?code, %detail, "the home server refused a federated call");
+ FederationError::Refused {
+ code: code.unwrap_or_default(),
+ detail: detail.to_owned(),
+ }
+}
+
+/// Read a lock, recovering from a poisoned one.
+fn read(lock: &RwLock) -> std::sync::RwLockReadGuard<'_, T> {
+ lock.read()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+/// Write a lock, recovering from a poisoned one.
+fn write(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> {
+ lock.write()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+/// A [`BlobSource`] over the **generated** `get_blob`, presenting a capability.
+///
+/// Not [`HttpBlobSource`](crate::fetch::HttpBlobSource): that one is raw `reqwest` over an
+/// `S-D7` session, and a peer has no session. Everything that parses or serializes here is
+/// generated — the `Range` parameter, the byte body and every declared refusal — and what is
+/// hand-written is the mapping from a status to the fetcher's own outcome.
+#[derive(Clone)]
+pub struct CapabilityBlobSource {
+ client: Arc,
+}
+
+impl CapabilityBlobSource {
+ /// A source against `base_url`, presenting `token`.
+ ///
+ /// # Errors
+ ///
+ /// [`FederationError::Transport`] when `base_url` is not a URL a client can hang paths off.
+ pub fn new(base_url: &str, token: impl Into) -> Result {
+ Ok(Self {
+ client: Arc::new(client_for(base_url, &token.into())?),
+ })
+ }
+}
+
+impl std::fmt::Debug for CapabilityBlobSource {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter.write_str("CapabilityBlobSource")
+ }
+}
+
+impl BlobSource for CapabilityBlobSource {
+ async fn get_range(&self, hash: &str, start: u64, max_len: Option) -> RangeOutcome {
+ // A zero-length window would be malformed; the fetcher never asks for one when bytes
+ // remain, so it is read as the open-ended remainder.
+ let range = match max_len {
+ Some(len) if len > 0 => format!("bytes={start}-{}", start + len - 1),
+ _ => format!("bytes={start}-"),
+ };
+ let params = rest::GetBlobParams {
+ range: Some(range),
+ ..rest::GetBlobParams::default()
+ };
+ match self
+ .client
+ .get_blob(
+ hash.to_owned(),
+ capsule_core::crypto::primitives::PROTOCOL_VERSION,
+ params,
+ )
+ .await
+ {
+ Ok(response) => {
+ let bytes = match response.into_inner() {
+ rest::GetBlobResponse::Status200(bytes)
+ | rest::GetBlobResponse::Status206(bytes) => bytes,
+ };
+ RangeOutcome::Complete {
+ bytes: bytes.to_vec(),
+ }
+ }
+ Err(rest::Error::Api(response)) => {
+ let (status, code) = describe(response.into_inner());
+ RangeOutcome::Status { status, code }
+ }
+ Err(error) => {
+ tracing::debug!(%error, "a federated blob range request failed in transport");
+ RangeOutcome::Status {
+ status: 0,
+ code: None,
+ }
+ }
+ }
+ }
+}
+
+/// The status and the stable code a declared blob refusal carries.
+///
+/// Exhaustive over the generated enum on purpose: a status the contract adds later is a compile
+/// error here rather than a silent `0` the fetcher would read as a transport failure.
+fn describe(error: rest::GetBlobError) -> (u16, Option) {
+ let coded =
+ |status: u16, problem: Box| (status, Some(problem.code.clone()));
+ match error {
+ rest::GetBlobError::Status304 => (304, None),
+ rest::GetBlobError::Status400(problem) => coded(400, problem),
+ rest::GetBlobError::Status401(problem) => coded(401, problem),
+ rest::GetBlobError::Status403(problem) => coded(403, problem),
+ rest::GetBlobError::Status404(problem) => coded(404, problem),
+ rest::GetBlobError::Status409(problem) => coded(409, problem),
+ rest::GetBlobError::Status410(problem) => coded(410, problem),
+ rest::GetBlobError::Status413 => (413, None),
+ rest::GetBlobError::Status429(problem) => coded(429, problem),
+ rest::GetBlobError::Status500(problem) => coded(500, problem),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn snapshot(revoked: &[&str], max_staleness: Duration) -> RevocationSnapshot {
+ RevocationSnapshot {
+ revoked: revoked.iter().map(|jti| (*jti).to_owned()).collect(),
+ max_staleness,
+ fetched_at: Instant::now(),
+ }
+ }
+
+ #[test]
+ fn a_snapshot_refuses_the_jtis_it_carries_and_no_others() {
+ let held = snapshot(&["one", "two"], Duration::from_mins(15));
+ assert!(held.refuses("one"));
+ assert!(held.refuses("two"));
+ assert!(!held.refuses("three"));
+ }
+
+ #[test]
+ fn a_snapshot_is_fresh_only_inside_the_issuers_own_bound() {
+ // The bound is the issuer's, carried on the list itself, and it is measured on the
+ // peer's own clock — the party being checked does not get to say how old its list is.
+ let held = snapshot(&[], Duration::from_mins(15));
+ assert!(held.is_fresh(held.fetched_at));
+ assert!(held.is_fresh(held.fetched_at + Duration::from_mins(15)));
+ assert!(!held.is_fresh(held.fetched_at + Duration::from_mins(15) + Duration::from_secs(1)));
+
+ // A bound of zero is a list that is stale the instant after it is read, which is what a
+ // server publishing `max_staleness_seconds: 0` is asking for.
+ let strict = snapshot(&[], Duration::ZERO);
+ assert!(!strict.is_fresh(strict.fetched_at + Duration::from_millis(1)));
+ }
+
+ #[test]
+ fn the_debug_rendering_never_carries_the_token() {
+ let pull = FederationPull::new(
+ "https://home.test/v1",
+ "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60",
+ "a.very.secret.token",
+ "01937b7c-0000-7000-8000-0000000000aa",
+ )
+ .expect("the base url is a url");
+ let rendered = format!("{pull:?}");
+ assert!(!rendered.contains("a.very.secret.token"), "{rendered}");
+ assert!(
+ rendered.contains("01937b7c-0000-7000-8000-0000000000aa"),
+ "{rendered}"
+ );
+ }
+}
diff --git a/capsule-sdk/src/fetch.rs b/capsule-sdk/src/fetch.rs
index 829e946e..d47d8968 100644
--- a/capsule-sdk/src/fetch.rs
+++ b/capsule-sdk/src/fetch.rs
@@ -223,8 +223,17 @@ pub enum FetchError {
Gone,
/// `403` — an authorization change, not a durability loss. Re-sync membership
/// then retry; only then degrade (the asset may have been unshared).
+ ///
+ /// Carries the stable code, because the `403`s on this route no longer say one thing: an
+ /// account's `error.blob.access_revoked` means re-sync membership, while a federated
+ /// peer's `error.federation.scope_insufficient` means the grant never covered this blob and
+ /// re-syncing anything will not change that. A caller that could not tell them apart would
+ /// retry the second forever.
#[error("blob authorization changed")]
- AuthorizationChanged,
+ AuthorizationChanged {
+ /// Stable `error.*` code, when the refusal carried one.
+ code: Option,
+ },
/// `error.blob.pending_upload` — the original has not landed yet
/// (`awaiting-original`); show the badge, never a failure, and re-fetch when
/// the feed flips `original_held`. Explicitly distinct from `410 Gone`.
@@ -259,7 +268,7 @@ impl FetchError {
pub fn error_code(&self) -> Option<&str> {
match self {
Self::PendingUpload => Some(error_codes::BLOB_PENDING_UPLOAD),
- Self::Rejected { code, .. } => code.as_deref(),
+ Self::AuthorizationChanged { code } | Self::Rejected { code, .. } => code.as_deref(),
_ => None,
}
}
@@ -278,7 +287,7 @@ fn classify_status(status: u16, code: Option) -> FetchError {
return FetchError::PendingUpload;
}
match status {
- 403 => FetchError::AuthorizationChanged,
+ 403 => FetchError::AuthorizationChanged { code },
404 | 410 => FetchError::Gone,
0 => FetchError::Transient("transport error".to_string()),
s if (500..600).contains(&s) => FetchError::Transient(format!("server status {s}")),
@@ -554,7 +563,7 @@ where
representation: desired,
bytes,
},
- Err(FetchError::AuthorizationChanged) => {
+ Err(FetchError::AuthorizationChanged { .. }) => {
tracing::info!("403 on fetch — re-syncing album membership before retrying");
on_authorization_change().await;
match fetch_representation(source, asset, desired).await {
diff --git a/capsule-sdk/src/lib.rs b/capsule-sdk/src/lib.rs
index be738cd2..7a9cd7e6 100644
--- a/capsule-sdk/src/lib.rs
+++ b/capsule-sdk/src/lib.rs
@@ -18,6 +18,7 @@ pub mod auth;
pub mod client;
pub mod cohort;
pub mod directory;
+pub mod federation;
pub mod fetch;
pub mod net;
pub mod peering;
diff --git a/capsule-sdk/src/sync.rs b/capsule-sdk/src/sync.rs
index eb0feb79..013c9e80 100644
--- a/capsule-sdk/src/sync.rs
+++ b/capsule-sdk/src/sync.rs
@@ -470,15 +470,28 @@ impl SyncConsumer {
/// retries, then a visible failure — no configuration hot-loops.
#[instrument(skip(self, cursor), fields(page_size, entries))]
pub async fn pull(&self, cursor: &SyncCursor, page_size: u32) -> Result {
+ self.pull_scoped(cursor, page_size, None).await
+ }
+
+ /// The body both [`Self::pull`] and [`Self::pull_album`] run: one album or the whole feed.
+ async fn pull_scoped(
+ &self,
+ cursor: &SyncCursor,
+ page_size: u32,
+ album_id: Option<&str>,
+ ) -> Result {
let mut engine: RetryEngine = RetryClass::Interactive.engine();
let response = loop {
- match self.call(cursor, page_size).await {
+ match self.call(cursor, page_size, album_id).await {
Ok(page) => break page,
Err(error) if is_unauthenticated(&error) => match &self.auth {
SyncAuth::Session(session) => {
tracing::info!("the feed answered 401; refreshing once and retrying");
session.refresh().await?;
- break self.call(cursor, page_size).await.map_err(map_error)?;
+ break self
+ .call(cursor, page_size, album_id)
+ .await
+ .map_err(map_error)?;
}
SyncAuth::Static => return Err(map_error(error)),
},
@@ -500,6 +513,28 @@ impl SyncConsumer {
Ok(page)
}
+ /// Pull one page of **one album** after `cursor`.
+ ///
+ /// The album arm of the same operation (`S-C51`, `S-E5`): an account reads it as the album's
+ /// owner or a member of its current roster, and a federated peer reads it under a capability
+ /// whose audience is that album. Same retry and same refresh-once behaviour as
+ /// [`Self::pull`]; the only difference is the parameter, because the *server* is where the
+ /// two arms differ and the client has one feed.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::pull`], plus the album refusals the server renders — a peer's revoked or
+ /// out-of-audience capability among them.
+ #[instrument(skip(self, cursor), fields(page_size, entries))]
+ pub async fn pull_album(
+ &self,
+ cursor: &SyncCursor,
+ page_size: u32,
+ album_id: &str,
+ ) -> Result {
+ self.pull_scoped(cursor, page_size, Some(album_id)).await
+ }
+
/// Pull the next page for `state` (using its stored cursor), validate and apply it, and
/// return it. The one call that ties the opaque-cursor round-trip to the anti-rewind layer.
#[instrument(skip(self, state), fields(page_size))]
@@ -518,8 +553,11 @@ impl SyncConsumer {
&self,
cursor: &SyncCursor,
page_size: u32,
+ album_id: Option<&str>,
) -> Result> {
let params = rest::SyncFeedParams {
+ // Absent is the caller's own feed; present is one album's page.
+ album_id: album_id.map(str::to_owned),
// The cursor is round-tripped verbatim. Empty means "from the beginning", which the
// server spells as an absent parameter rather than an empty one.
cursor: cursor
@@ -601,9 +639,13 @@ fn map_error(error: rest::Error) -> SyncError {
// The 400 includes the protocol gate's malformed-handshake answer (issue #404).
// There is no 426 to map: the feed is a read, and a read is admitted at any
// grammatical protocol date — the window rides the response headers instead.
+ // The 429 is a federated peer's events budget (`S-E5`): a capability puller
+ // over its hour. Rejected rather than retried, because the window is an hour
+ // and the interactive retry class would give up long before it turned.
rest::SyncFeedError::Status400(problem)
| rest::SyncFeedError::Status401(problem)
| rest::SyncFeedError::Status403(problem)
+ | rest::SyncFeedError::Status429(problem)
| rest::SyncFeedError::Status500(problem) => (
Some(problem.code.clone()),
problem.detail.clone().unwrap_or_default(),
diff --git a/capsule-server/.env.example b/capsule-server/.env.example
index c94a8ace..49ef106b 100644
--- a/capsule-server/.env.example
+++ b/capsule-server/.env.example
@@ -56,6 +56,14 @@ SERVER_DOMAIN=localhost
# Default: http://{SERVER_DOMAIN}:{SERVER_PORT}/v1
# API_BASE_URL=https://api.capsule.example/v1
+# Where federated peers pull from, published as `server-info.federation_url`. Federation reuses
+# the versioned API itself — `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` under a capability
+# bearer — so the value is this deployment's API base URL. **Unset means this server does not
+# federate**: the record publishes no endpoint, and minting, refreshing or revoking a capability
+# and federated report intake all refuse with `error.federation.not_configured`. A capability
+# minted while it was set still verifies; configuration does not un-mint a token.
+# FEDERATION_URL=https://api.capsule.example/v1
+
# TLS is **not** terminated here. `design/cryptography/failure-modes.md` puts HTTPS on the
# ingress or reverse proxy; there is no certificate setting and Kynos's `tls` feature is off.
diff --git a/capsule-server/migration/src/lib.rs b/capsule-server/migration/src/lib.rs
index b14e9e03..6059f67a 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 five ordinals cover
+//! # What the six ordinals cover
//!
//! The four durable ports issue #402 lands adapters for — the asset index, the account
-//! cluster, the device-cohort map and the quota ledger — plus album membership (`S-C51`, #405).
+//! cluster, the device-cohort map and the quota ledger — plus album membership (`S-C51`, #405)
+//! and federation's capabilities, revocation list and peers (`S-E2`, #406).
//! The remaining durable ports keep their
//! in-memory adapters and gain ordinals with their adapters, so a migration never describes a
//! table nothing reads.
@@ -25,6 +26,7 @@ mod m20260902_000002_accounts;
mod m20260902_000003_cohorts;
mod m20260902_000004_quota;
mod m20260902_000005_album_membership;
+mod m20260902_000006_federation;
/// The server's migrator.
pub struct Migrator;
@@ -38,6 +40,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260902_000003_cohorts::Migration),
Box::new(m20260902_000004_quota::Migration),
Box::new(m20260902_000005_album_membership::Migration),
+ Box::new(m20260902_000006_federation::Migration),
]
}
}
diff --git a/capsule-server/migration/src/m20260902_000006_federation.rs b/capsule-server/migration/src/m20260902_000006_federation.rs
new file mode 100644
index 00000000..d5e029c7
--- /dev/null
+++ b/capsule-server/migration/src/m20260902_000006_federation.rs
@@ -0,0 +1,242 @@
+//! Federation (`S-E2`, `S-E5`): the capabilities this server issued, the revocation list it
+//! publishes, and the peers an operator has pinned or blocked.
+
+use sea_orm_migration::prelude::*;
+
+#[derive(DeriveMigrationName)]
+pub(crate) struct Migration;
+
+#[async_trait::async_trait]
+impl MigrationTrait for Migration {
+ async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ // One row per capability this server minted. The record carries what the token does not:
+ // the roster member the grant was made for and the epoch their membership was granted
+ // at, which is what a presentation re-checks so a member removed and re-admitted later
+ // cannot reuse an older grant. `refreshed_to` links a predecessor to its successor and
+ // is what makes a replayed refresh idempotent without an idempotency table.
+ manager
+ .create_table(
+ Table::create()
+ .table(FederationCapabilities::Table)
+ .if_not_exists()
+ .col(
+ ColumnDef::new(FederationCapabilities::Jti)
+ .text()
+ .not_null()
+ .primary_key(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::AlbumId)
+ .text()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::PeerId)
+ .text()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::MemberId)
+ .text()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::Scope)
+ .text()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::GrantedEpoch)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::MinProtocolVersion)
+ .text()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::IssuedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::ExpiresAt)
+ .big_integer()
+ .not_null(),
+ )
+ // The absolute deadline of the whole grant, fixed at the original mint and
+ // copied unchanged into every successor. `expires_at` is one token's life
+ // and a refresh replaces it; this is the column a refresh cannot move, and
+ // it is what keeps a chain of refreshes from outliving the lifetime the
+ // album's owner chose. Equal to `expires_at` for a grant nobody made
+ // renewable, which is the default.
+ .col(
+ ColumnDef::new(FederationCapabilities::NotAfter)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::RevokedAt)
+ .big_integer()
+ .null(),
+ )
+ .col(
+ ColumnDef::new(FederationCapabilities::RefreshedTo)
+ .text()
+ .null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ // "Every live grant over this album" — what a roster change consults — and "every live
+ // grant this peer holds" — what a block cascades over. Neither is the primary key's
+ // question.
+ manager
+ .create_index(
+ Index::create()
+ .if_not_exists()
+ .name("idx_federation_capabilities_album")
+ .table(FederationCapabilities::Table)
+ .col(FederationCapabilities::AlbumId)
+ .to_owned(),
+ )
+ .await?;
+ manager
+ .create_index(
+ Index::create()
+ .if_not_exists()
+ .name("idx_federation_capabilities_peer")
+ .table(FederationCapabilities::Table)
+ .col(FederationCapabilities::PeerId)
+ .to_owned(),
+ )
+ .await?;
+
+ // The published list, and its own table rather than a view over the one above: a
+ // revocation is also accepted for a `jti` no record backs — an operator cutting a token
+ // named in a peer's report, or one that predates this store — and the list must carry
+ // it either way. `expires_at` is what the list is pruned by, so an entry never outlives
+ // the token it is about.
+ manager
+ .create_table(
+ Table::create()
+ .table(FederationRevokedJti::Table)
+ .if_not_exists()
+ .col(
+ ColumnDef::new(FederationRevokedJti::Jti)
+ .text()
+ .not_null()
+ .primary_key(),
+ )
+ .col(
+ ColumnDef::new(FederationRevokedJti::ExpiresAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationRevokedJti::RevokedAt)
+ .big_integer()
+ .not_null(),
+ )
+ .to_owned(),
+ )
+ .await?;
+ // The published record is read in expiry order and pruned by it on every read.
+ manager
+ .create_index(
+ Index::create()
+ .if_not_exists()
+ .name("idx_federation_revoked_jti_expiry")
+ .table(FederationRevokedJti::Table)
+ .col(FederationRevokedJti::ExpiresAt)
+ .to_owned(),
+ )
+ .await?;
+
+ // The peers this server knows. `signing_key` is nullable because a block may name a
+ // server nobody ever pinned — an operator blocking a server they never wanted to hear
+ // from is legitimate — and `blocked_at` is the blocklist itself, a column rather than a
+ // table because the blocklist operates at the federation-capability layer.
+ manager
+ .create_table(
+ Table::create()
+ .table(FederationPeers::Table)
+ .if_not_exists()
+ .col(
+ ColumnDef::new(FederationPeers::ServerId)
+ .text()
+ .not_null()
+ .primary_key(),
+ )
+ .col(ColumnDef::new(FederationPeers::SigningKey).binary().null())
+ .col(
+ ColumnDef::new(FederationPeers::FirstSeenAt)
+ .big_integer()
+ .not_null(),
+ )
+ .col(
+ ColumnDef::new(FederationPeers::BlockedAt)
+ .big_integer()
+ .null(),
+ )
+ .col(ColumnDef::new(FederationPeers::Note).text().null())
+ .to_owned(),
+ )
+ .await?;
+
+ Ok(())
+ }
+
+ async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
+ manager
+ .drop_table(Table::drop().table(FederationPeers::Table).to_owned())
+ .await?;
+ manager
+ .drop_table(Table::drop().table(FederationRevokedJti::Table).to_owned())
+ .await?;
+ manager
+ .drop_table(
+ Table::drop()
+ .table(FederationCapabilities::Table)
+ .to_owned(),
+ )
+ .await?;
+ Ok(())
+ }
+}
+
+#[derive(DeriveIden)]
+enum FederationCapabilities {
+ Table,
+ Jti,
+ AlbumId,
+ PeerId,
+ MemberId,
+ Scope,
+ GrantedEpoch,
+ MinProtocolVersion,
+ IssuedAt,
+ ExpiresAt,
+ NotAfter,
+ RevokedAt,
+ RefreshedTo,
+}
+
+#[derive(DeriveIden)]
+enum FederationRevokedJti {
+ Table,
+ Jti,
+ ExpiresAt,
+ RevokedAt,
+}
+
+#[derive(DeriveIden)]
+enum FederationPeers {
+ Table,
+ ServerId,
+ SigningKey,
+ FirstSeenAt,
+ BlockedAt,
+ Note,
+}
diff --git a/capsule-server/openapi.json b/capsule-server/openapi.json
index 85b3d426..db67e552 100644
--- a/capsule-server/openapi.json
+++ b/capsule-server/openapi.json
@@ -14737,6 +14737,1597 @@
]
}
},
+ "/v1/albums/{album_id}/capabilities": {
+ "post": {
+ "summary": "Mint a capability letting one peer server pull one album.",
+ "description": "The token is in the response and nowhere else: this server keeps the record, never the\ncredential.",
+ "operationId": "issue_capability",
+ "parameters": [
+ {
+ "name": "album_id",
+ "in": "path",
+ "description": "The album's id.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "X-Capsule-Protocol",
+ "in": "header",
+ "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ {
+ "name": "X-Capsule-Crypto-Suite",
+ "in": "header",
+ "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ },
+ {
+ "name": "X-Capsule-Sidecar-Schema",
+ "in": "header",
+ "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MintCapabilityRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "401": {
+ "description": "Unauthorized",
+ "headers": {
+ "WWW-Authenticate": {
+ "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "Bearer"
+ },
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Unsupported Media Type",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "201": {
+ "description": "The capability was minted",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MintedCapabilityResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Member not on roster",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "the request body exceeds the configured limit",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ }
+ },
+ "426": {
+ "description": "Protocol version unsupported",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ]
+ }
+ },
+ "/v1/albums/{album_id}/capabilities/{jti}": {
+ "delete": {
+ "summary": "Revoke one capability of one album.",
+ "description": "Idempotent, and silent about what it did: a `jti` that is not a live capability of this\nalbum — never issued, already revoked, or another album's — is the same `204` a revocation\nis, so the operation is not a probe over identifiers.",
+ "operationId": "revoke_capability",
+ "parameters": [
+ {
+ "name": "album_id",
+ "in": "path",
+ "description": "The album's id.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "jti",
+ "in": "path",
+ "description": "The capability's `jti`.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "X-Capsule-Protocol",
+ "in": "header",
+ "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ {
+ "name": "X-Capsule-Crypto-Suite",
+ "in": "header",
+ "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ },
+ {
+ "name": "X-Capsule-Sidecar-Schema",
+ "in": "header",
+ "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ }
+ ],
+ "responses": {
+ "401": {
+ "description": "Unauthorized",
+ "headers": {
+ "WWW-Authenticate": {
+ "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "Bearer"
+ },
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "204": {
+ "description": "the request succeeded and there is no content to send",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "the request body exceeds the configured limit",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ }
+ },
+ "426": {
+ "description": "Protocol version unsupported",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ]
+ }
+ },
+ "/v1/federation/capabilities/refresh": {
+ "post": {
+ "summary": "Exchange a capability for its successor.",
+ "description": "The credential **is** the capability being refreshed; a session token has nothing to refresh\nhere and is refused.",
+ "operationId": "refresh_capability",
+ "parameters": [
+ {
+ "name": "X-Capsule-Protocol",
+ "in": "header",
+ "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ {
+ "name": "X-Capsule-Crypto-Suite",
+ "in": "header",
+ "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ },
+ {
+ "name": "X-Capsule-Sidecar-Schema",
+ "in": "header",
+ "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ }
+ ],
+ "responses": {
+ "401": {
+ "description": "Unauthorized",
+ "headers": {
+ "WWW-Authenticate": {
+ "description": "The challenge the client must answer, per RFC 9110 section 11.6.1.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "example": "Bearer"
+ },
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "200": {
+ "description": "OK",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RefreshedCapabilityResponse"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Member not on roster",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Rate budget exceeded",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "the request body exceeds the configured limit",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ }
+ },
+ "426": {
+ "description": "Protocol version unsupported",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Malformed handshake",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ }
+ ]
+ }
+ },
+ "/v1/federation/reports": {
+ "post": {
+ "summary": "File a signed moderation report from a peer server.",
+ "description": "# Its only reachable answer today is `403`\n\nA report is verified against the peer's **operator-pinned** key, and nothing can pin one:\n[`boot::assemble`](crate::boot::assemble) refuses the durable backend until #403 lands its\nadapters, so an operator command that pinned a peer could only run against `serve --memory`\nand would forget the moment it exited. The command is owed with #476. Until it lands this\noperation answers `403 error.federation.peer_unknown` to every real peer.\n\nIt is mounted anyway, deliberately: a peer implementing against the published contract needs\nthe operation to exist and to answer honestly, and what is missing is the command, not the\nsurface. What is *not* acceptable is a route that reads as protection it cannot provide —\nhence this paragraph, and the matching status notes in design/moderation.md and\ndesign/federation.md.\n\n# No bearer, and why that is not \"unauthenticated\"\n\nThe reporting peer holds no capability here — it is reporting *this* server's content, not\npulling it — so there is nothing to present. What it does hold is a key an operator has\n**pinned**, and the report carries its own Ed25519 signature over the canonical CBOR of every\nother field. A report from a server nobody has pinned is `403`: intake is not the moment a\npeer becomes trusted (design/federation.md's TOFU is explicitly not done here).\n\n# The order the checks run in\n\nBounds, then how much may be asked for at all, then who is speaking, then whether they are\nwelcome, then whether they really said it, then whose account it is, then whether they have\nsaid it too often.\n\nEvery field is length-capped first, before a store is read or a byte is keyed on. Then\n[`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) — keyed on the\n*claimed* origin, so it bounds one origin looping rather than a caller cycling origins, which\nis the most this server can do without a trusted client address. Everything after it is a\nstore read and an Ed25519 verification, and this is the only place a bound on that work can\nsit.\n\nThe **policy** budgets are charged last, after the signature verifies, so a third party\nspoofing `reporting_server` cannot spend a real peer's allowance. Two of them: the contract's\nper-`(server, account)` limit, and a per-peer ceiling that ignores the account, because\n`reported_user` is a string the peer chooses and a peer cycling accounts would otherwise mint\nitself a fresh allowance each time.\n\nWhat is *not* bounded is bytes parsed per request: a per-operation body cap cannot be\nexpressed against this framework, and the reason is recorded on\n[`MAX_FEDERATION_BODY_BYTES`](crate::limits::MAX_FEDERATION_BODY_BYTES) (issue #478).\n\n# What accepting one does\n\nIt writes a row an operator will read ([`ModerationStore::pending_reports`]) and **nothing\nelse**. A peer's report is an input to a decision, never a decision: no standing changes, no\nserving hold appears, and the reported account sees nothing — because nothing has been done\nto them.\n\n# `202` whether or not the account exists\n\nA report naming an account this server does not host is **accepted on the wire and dropped**,\nwith a `warn` for the operator. It is not filed: an unresolvable report is a permanent orphan\nrow that nobody can act on, which is the reason the check exists at all.\n\nThe answer is deliberately the same one a filed report gets. An earlier version refused with a\ndistinct coded `404`, and that manufactured an account-enumeration oracle out of a check that\ndid not need one: a pinned peer could walk identifiers and read existence off the status line.\n\"Pinned\" is not \"trusted with enumeration\" — a peer key can be compromised, and a peer can be\nadversarial toward its own users while remaining an operator's legitimate partner — and this\ncodebase treats exists-versus-does-not as a first-order defect nearly everywhere else\n([`crate::routes::enroll`]'s indistinguishable code refusal, the album ceremonies' \"not yours\nis not found\", [`crate::serve::authority`]'s `404`/`403` boundary).\n\nProbing is not free even so: every budget above is charged before this point is reached, so a\npeer sweeping identifiers spends its allowance doing it and an operator sees the `warn`.",
+ "operationId": "submit_federated_report",
+ "parameters": [
+ {
+ "name": "X-Capsule-Protocol",
+ "in": "header",
+ "description": "The `YYYY-MM-DD` protocol version this request is written against. Outside the server's `[X-Capsule-Protocol-Min, X-Capsule-Protocol-Max]` window the request is refused with `426`.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ {
+ "name": "X-Capsule-Crypto-Suite",
+ "in": "header",
+ "description": "The crypto suite id from the primitives inventory. Sent on writes; a suite this server does not implement is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ },
+ {
+ "name": "X-Capsule-Sidecar-Schema",
+ "in": "header",
+ "description": "The sidecar schema version declared at `sidecar_schema` field 0. Sent on metadata updates; a schema newer than this server indexes is refused with `400`.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 65535.0,
+ "minimum": 0.0
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/FederatedReportRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "400": {
+ "description": "Bad Request",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Unsupported Media Type",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Unprocessable Entity",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "202": {
+ "description": "The report was accepted for review",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/FederatedReportResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Report unsigned",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Peer unknown",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Report rate limited",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "the request body exceeds the configured limit",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ }
+ },
+ "426": {
+ "description": "Protocol version unsupported",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/v1/auth/devices": {
"get": {
"summary": "List the caller's live sessions and the cohorts they group under.",
@@ -16696,6 +18287,42 @@
}
}
},
+ "429": {
+ "description": "Rate budget exceeded",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"headers": {
@@ -16772,7 +18399,7 @@
"/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`).",
+ "description": "Opaque octets: the server holds no key and this route never learns what it is serving. An\naccount fetches the blobs of its own assets and of the albums it is currently a member of; a\nformer member is told `403`, and everyone else is told what an unknown address is told —\nsee [`crate::serve`] for the boundary and its reasons.\n\nThe one answer that *is* account-scoped is the transient `409`: it reports the caller's own\nin-flight upload and nobody else's (`S-C40`).\n\nA federated peer fetches here with a capability instead of a session token (`S-E5`), through\nthe same resolution and the same authority.",
"operationId": "get_blob",
"parameters": [
{
@@ -17231,6 +18858,42 @@
}
}
},
+ "429": {
+ "description": "Rate budget exceeded",
+ "headers": {
+ "X-Capsule-Protocol-Min": {
+ "description": "The oldest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Protocol-Max": {
+ "description": "The newest protocol version this server accepts.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
+ }
+ },
+ "X-Capsule-Min-Client-Build": {
+ "description": "The semver client build below which this server will stop answering. Advisory: `0.0.0` when no cutoff has been announced.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
+ }
+ }
+ },
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "#/components/schemas/CodedProblem"
+ }
+ }
+ }
+ },
"500": {
"description": "Internal server error",
"headers": {
@@ -21025,6 +22688,212 @@
],
"description": "What adoption produced."
},
+ "WireScope": {
+ "type": "string",
+ "enum": [
+ "read",
+ "read-derivative-only"
+ ],
+ "description": "What a capability permits, on the wire.\n\nA mirror of [`Scope`] rather than the type itself, for the reason\n[`WireBlobRole`](crate::routes::upload::WireBlobRole) is one: the domain enum is not a schema\ntype, and the wire spelling is a contract that should not move when an internal name does."
+ },
+ "MintCapabilityRequest": {
+ "properties": {
+ "peer": {
+ "type": "string",
+ "description": "The peer server the grant is for, as its own `server-info` names it (`other.tld`)."
+ },
+ "member": {
+ "type": "string",
+ "description": "The roster member whose access the grant carries, as the owner listed them."
+ },
+ "scope": {
+ "$ref": "#/components/schemas/WireScope",
+ "description": "What the grant permits."
+ },
+ "ttl_seconds": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 0.0,
+ "format": "uint64",
+ "description": "How long **one token** should live, in seconds. Clamped to the 24-hour ceiling; absent is\nsix hours."
+ },
+ "renewable_until": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The absolute deadline the whole grant dies at, RFC 3339 — and the only thing that makes\nit **renewable**.\n\nAbsent, the default, is a grant that cannot be refreshed at all: it lives exactly\n`ttl_seconds` and then the owner mints again if they still mean to share. Present, it\nmust be in the future and at most ninety days out."
+ }
+ },
+ "type": "object",
+ "required": [
+ "peer",
+ "member",
+ "scope"
+ ],
+ "description": "The mint request."
+ },
+ "MintedCapabilityResponse": {
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The signed capability, to be carried as `Authorization: Bearer`."
+ },
+ "jti": {
+ "type": "string",
+ "description": "Its identifier, and the key it is revoked by."
+ },
+ "album_id": {
+ "type": "string",
+ "description": "The album it scopes to."
+ },
+ "peer": {
+ "type": "string",
+ "description": "The peer it was minted for."
+ },
+ "member": {
+ "type": "string",
+ "description": "The roster member whose access it carries."
+ },
+ "scope": {
+ "$ref": "#/components/schemas/WireScope",
+ "description": "What it permits."
+ },
+ "issued_at": {
+ "type": "string",
+ "description": "When it was minted, RFC 3339."
+ },
+ "expires_at": {
+ "type": "string",
+ "description": "When **this token** stops being honoured, RFC 3339."
+ },
+ "not_after": {
+ "type": "string",
+ "description": "When the **whole grant** dies, RFC 3339. Equal to `expires_at` when it is not renewable."
+ },
+ "renewable": {
+ "type": "boolean",
+ "description": "Whether a refresh may issue a successor from this grant.\n\nStated plainly rather than left to be inferred from the two timestamps above: how long\nan owner is sharing for is the decision this response reports back to them."
+ },
+ "min_protocol_version": {
+ "type": "string",
+ "description": "The album's pinned protocol date, which the peer must speak to pull."
+ }
+ },
+ "type": "object",
+ "required": [
+ "token",
+ "jti",
+ "album_id",
+ "peer",
+ "member",
+ "scope",
+ "issued_at",
+ "expires_at",
+ "not_after",
+ "renewable",
+ "min_protocol_version"
+ ],
+ "description": "A freshly minted capability.\n\nThe token is returned **once**. Nothing on this server can produce it again — a stored grant\nre-signs byte-for-byte, but only the refresh operation does that, and only for its holder."
+ },
+ "RefreshedCapabilityResponse": {
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "The successor token."
+ },
+ "jti": {
+ "type": "string",
+ "description": "Its identifier."
+ },
+ "expires_at": {
+ "type": "string",
+ "description": "When this token stops being honoured, RFC 3339."
+ },
+ "not_after": {
+ "type": "string",
+ "description": "When the whole grant dies, RFC 3339 — unchanged by this or any refresh."
+ },
+ "replayed": {
+ "type": "boolean",
+ "description": "Whether this call issued the successor, or answered one an earlier call already issued.\n\nAdvisory. A peer never branches on it: both answers mean \"here is the token to keep\npulling with\"."
+ }
+ },
+ "type": "object",
+ "required": [
+ "token",
+ "jti",
+ "expires_at",
+ "not_after",
+ "replayed"
+ ],
+ "description": "A refreshed capability."
+ },
+ "FederatedReportRequest": {
+ "properties": {
+ "reporting_server": {
+ "type": "string",
+ "description": "The peer filing the report, as its own `server-info` names it."
+ },
+ "reported_user": {
+ "type": "string",
+ "description": "The account on this server the report is about."
+ },
+ "asset_hash": {
+ "type": "string",
+ "description": "The content address of the asset complained about."
+ },
+ "album_id": {
+ "type": "string",
+ "description": "The album it was pulled from."
+ },
+ "reason": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "A short reason, where the peer gives one."
+ },
+ "reported_at": {
+ "type": "string",
+ "description": "When the peer says it was reported, RFC 3339."
+ },
+ "signature": {
+ "type": "string",
+ "description": "The peer's Ed25519 signature over the canonical CBOR of the fields above, base64."
+ }
+ },
+ "type": "object",
+ "required": [
+ "reporting_server",
+ "reported_user",
+ "asset_hash",
+ "album_id",
+ "reported_at",
+ "signature"
+ ],
+ "description": "A moderation report one peer server files against an account on this one.\n\nEvery field except `signature` is covered by the signature, in canonical CBOR — see\n[`ReportClaim`](crate::federation::ReportClaim)."
+ },
+ "FederatedReportResponse": {
+ "properties": {
+ "report_id": {
+ "type": "string",
+ "description": "This server's identifier for the report."
+ },
+ "received_at": {
+ "type": "string",
+ "description": "When this server accepted it, RFC 3339."
+ }
+ },
+ "type": "object",
+ "required": [
+ "report_id",
+ "received_at"
+ ],
+ "description": "An accepted report.\n\nThe identifier is this server's, so an operator and the reporting peer can talk about one\nreport. Nothing about the reported account is echoed — accepting a report says nothing about\nwhether it is true, and a body that reported on the account's standing would say it does."
+ },
"SessionView": {
"properties": {
"session_id": {
diff --git a/capsule-server/src/app.rs b/capsule-server/src/app.rs
index dbc6af14..eb2b246d 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::federation::{FederationContext, ReadBearer};
use crate::membership::MembershipContext;
use crate::moderation::ModerationContext;
use crate::quota::QuotaContext;
@@ -84,6 +85,8 @@ pub struct App {
discovery: DiscoveryContext,
/// The master-key escrow's collaborators.
escrow: EscrowContext,
+ /// The federation module's collaborators (`S-E2`, `S-E5`).
+ federation: FederationContext,
/// The cross-device add's collaborators.
enrollment: EnrollmentContext,
/// The moderation record's collaborators.
@@ -131,6 +134,8 @@ pub struct Modules {
pub discovery: DiscoveryContext,
/// The master-key escrow's collaborators.
pub escrow: EscrowContext,
+ /// The federation module's collaborators (`S-E2`, `S-E5`).
+ pub federation: FederationContext,
/// The cross-device add's collaborators.
pub enrollment: EnrollmentContext,
/// The moderation record's collaborators.
@@ -144,6 +149,11 @@ pub struct Modules {
}
impl App {
+ /// The authentication module, for the read scheme that delegates to it first.
+ pub(crate) fn auth(&self) -> &AuthContext {
+ &self.auth
+ }
+
/// Assembles the application from its modules.
pub fn new(modules: Modules) -> Self {
let Modules {
@@ -160,6 +170,7 @@ impl App {
attestation,
discovery,
escrow,
+ federation,
enrollment,
moderation,
share,
@@ -180,6 +191,7 @@ impl App {
attestation,
discovery,
escrow,
+ federation,
enrollment,
moderation,
share,
@@ -201,3 +213,16 @@ impl Authenticates for App {
&self.auth
}
}
+
+/// The federation module verifies the bearer the two read primitives accept two principals on.
+///
+/// Its authenticator asks [`AuthContext`] first and the capability codec second, so a session
+/// token on `GET /v1/sync` or `GET /v1/blob/{hash}` is admitted exactly as it is everywhere
+/// else (`S-E5`).
+impl Authenticates for App {
+ type Authenticator = FederationContext;
+
+ fn authenticator(&self) -> &Self::Authenticator {
+ &self.federation
+ }
+}
diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs
index a1825fa9..099280c7 100644
--- a/capsule-server/src/boot.rs
+++ b/capsule-server/src/boot.rs
@@ -65,11 +65,14 @@ use crate::blob::FilesystemBlobStore;
use crate::config::{Backends, Config};
use crate::counter::{CounterContext, InMemoryCounters};
use crate::directory::{DeviceDirectoryContext, InMemoryDeviceDirectory};
-use crate::discovery::revocation::InMemoryRevocations;
use crate::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo};
use crate::drop::{DropContext, InMemoryDrops};
use crate::enrollment::EnrollmentContext;
use crate::escrow::{EscrowContext, InMemoryEscrow};
+use crate::federation::{
+ CapabilityCodec, FederationCollaborators, FederationContext, InMemoryCapabilities,
+ InMemoryPeers,
+};
use crate::gc::CollectionContext;
use crate::gc::memory::InMemoryCollection;
use crate::index::memory::InMemoryAssetIndex;
@@ -470,7 +473,22 @@ fn memory(config: &Config, stores: Stores) -> Result {
capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed),
));
- let server_info = Arc::new(ServerInfo::new(
+ // The capability codec signs with the **same** key: a peer verifies a capability against
+ // the key `server-info` publishes, and that key is read out of the session signer. Built
+ // from the same bytes rather than handed the signer, so the two stay one key by
+ // construction; `tests::the_capability_codec_signs_under_the_published_key` asserts it.
+ let capabilities = Arc::new(
+ CapabilityCodec::from_pkcs8(der.expose(), config.server_domain.clone(), clock.clone())
+ .map_err(|error| BootError::SigningKey {
+ detail: error.detail,
+ })?,
+ );
+ // The capability store **is** the revocation list `revoked-jti` serves: one object, handed
+ // to discovery as the list and to federation as the store (design/federation.md).
+ let issued = Arc::new(InMemoryCapabilities::new(clock.clone()));
+ let peers = Arc::new(InMemoryPeers::new());
+
+ let mut server_info = ServerInfo::new(
config.server_domain.clone(),
config.api_base_url.clone(),
ProtocolWindow {
@@ -478,7 +496,11 @@ fn memory(config: &Config, stores: Stores) -> Result {
max: config.protocol_max.clone(),
},
tokens.public_key().to_vec(),
- ));
+ );
+ if let Some(url) = &config.federation_url {
+ server_info = server_info.with_federation(url.clone());
+ }
+ let server_info = Arc::new(server_info);
let app = App::new(Modules {
auth: AuthContext::new(AuthCollaborators {
@@ -540,11 +562,15 @@ fn memory(config: &Config, stores: Stores) -> Result {
// Publishing a rotation history is `ATTESTATION_KEY_HISTORY`'s job and nobody's yet.
Timestamp::UNIX_EPOCH,
),
- discovery: DiscoveryContext::new(
- server_info,
- Arc::new(InMemoryRevocations::new(clock.clone())),
- ),
+ discovery: DiscoveryContext::new(server_info, issued.clone()),
escrow: EscrowContext::new(Arc::new(InMemoryEscrow::new()), clock.clone()),
+ federation: FederationContext::new(FederationCollaborators {
+ codec: capabilities,
+ capabilities: issued,
+ peers,
+ clock: clock.clone(),
+ federation_url: config.federation_url.clone(),
+ }),
enrollment: EnrollmentContext::new(
Arc::new(InMemoryEnrollments::with_default_ttl(clock.clone())),
Arc::new(InMemoryChannels::with_default_ttl(clock.clone())),
@@ -746,6 +772,7 @@ mod tests {
BTreeMap, BootError, Config, Demands, Overrides, assemble, durable_environment,
};
use crate::auth::{Credentials, PostgresAccounts};
+ use crate::federation::postgres::{PostgresCapabilities, PostgresPeers};
use crate::index::postgres::PostgresAssetIndex;
use crate::membership::PostgresMembership;
use crate::postgres::testing;
@@ -813,7 +840,7 @@ mod tests {
);
}
- /// The five Postgres adapters compose out of exactly what the boot path has.
+ /// The seven Postgres adapters compose out of exactly what the boot path has.
///
/// Asserted here rather than by constructing them in `assemble` and throwing them away:
/// production code that builds something it cannot use is theatre, and what #403 needs
@@ -846,7 +873,12 @@ mod tests {
let quotas: Arc =
Arc::new(PostgresQuota::new(connection.clone()));
let members: Arc =
- Arc::new(PostgresMembership::new(connection));
+ Arc::new(PostgresMembership::new(connection.clone()));
+ let capabilities: Arc = Arc::new(
+ PostgresCapabilities::new(connection.clone(), Arc::new(SystemClock)),
+ );
+ let peers: Arc =
+ Arc::new(PostgresPeers::new(connection));
// Each one answers through its port, which is what makes this a boot check rather
// than a compile check: the schema the migration applied is the schema the adapters
@@ -879,6 +911,20 @@ mod tests {
.expect("the membership store answers"),
crate::membership::Membership::Never
);
+ assert!(
+ capabilities
+ .find("boot-probe-jti")
+ .await
+ .expect("the capability store answers")
+ .is_none()
+ );
+ assert!(
+ peers
+ .read(&crate::federation::PeerId::new("boot-probe.test"))
+ .await
+ .expect("the peer store answers")
+ .is_none()
+ );
}
}
@@ -920,6 +966,67 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn the_capability_codec_signs_under_the_published_key() {
+ // A peer verifies a capability against `server-info`'s `signing_key`. The codec is
+ // built from the same DER as the session signer, so the two are one key — asserted
+ // through the surface and through the codec, rather than assumed from the wiring.
+ let root = tempfile::tempdir().expect("a scratch directory");
+ let config = memory_config(root.path());
+ let assembled = assemble(&config).await.expect("it assembles");
+ let client = kynos::test::TestClient::new(assembled.service().expect("the router builds"));
+ let body: serde_json::Value = client
+ .get("/.well-known/capsule/server-info")
+ .header("accept", "application/json")
+ .send()
+ .await
+ .assert_status(kynos::http::StatusCode::OK)
+ .json();
+ let published = body["signing_key"].as_str().expect("it is published");
+ let codec = crate::federation::CapabilityCodec::from_pkcs8(
+ config
+ .signing_key_der
+ .as_ref()
+ .expect("the key is configured")
+ .expose(),
+ config.server_domain.clone(),
+ std::sync::Arc::new(crate::store::SystemClock),
+ )
+ .expect("the key parses");
+ assert_eq!(
+ published,
+ base64::Engine::encode(
+ &base64::engine::general_purpose::STANDARD,
+ codec.public_key()
+ )
+ );
+ assert_eq!(codec.server_id(), config.server_domain);
+ assert!(
+ body.get("federation_url").is_none(),
+ "a deployment without FEDERATION_URL publishes no federation endpoint"
+ );
+ }
+
+ #[tokio::test]
+ async fn federation_url_is_published_when_configured() {
+ // Opt-in by one variable, and the record is the only way a peer learns it.
+ let root = tempfile::tempdir().expect("a scratch directory");
+ let config = memory_config_with(
+ root.path(),
+ &[("FEDERATION_URL", "https://capsule.example/v1")],
+ );
+ let assembled = assemble(&config).await.expect("it assembles");
+ let client = kynos::test::TestClient::new(assembled.service().expect("the router builds"));
+ let body: serde_json::Value = client
+ .get("/.well-known/capsule/server-info")
+ .header("accept", "application/json")
+ .send()
+ .await
+ .assert_status(kynos::http::StatusCode::OK)
+ .json();
+ assert_eq!(body["federation_url"], "https://capsule.example/v1");
+ }
+
#[tokio::test]
async fn the_published_protocol_window_is_the_configured_one() {
let root = tempfile::tempdir().expect("a scratch directory");
diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs
index 4d8a1fce..b7ac5f09 100644
--- a/capsule-server/src/config.rs
+++ b/capsule-server/src/config.rs
@@ -301,6 +301,12 @@ pub struct Config {
pub server_domain: String,
/// The absolute base URL clients reach the versioned API at.
pub api_base_url: String,
+ /// Where federated peers reach this server, when it federates at all (`FEDERATION_URL`).
+ ///
+ /// `None` is a deployment that does not federate: `server-info` publishes no
+ /// `federation_url`, and the capability lifecycle writes refuse with
+ /// `error.federation.not_configured`.
+ pub federation_url: Option,
/// The filesystem tree ciphertext blobs are written to. There is no object store.
pub blob_root: Option,
/// The Postgres URL, once an adapter reads it (#402).
@@ -396,6 +402,10 @@ impl Config {
let api_base_url = env
.var("API_BASE_URL")
.unwrap_or_else(|| format!("http://{server_domain}:{}/v1", listen.port()));
+ // Opt-in, and the value is the URL peers pull from: the federation surface is the
+ // versioned API itself (design/federation.md, "no new data protocol"), so a deployment
+ // that federates publishes its API base here.
+ let federation_url = env.var("FEDERATION_URL");
// ── Storage ─────────────────────────────────────────────────────────────────────
// `UPLOAD_DIR` is the name the retired deployment used, accepted so an operator's
@@ -614,6 +624,7 @@ impl Config {
listen,
server_domain,
api_base_url,
+ federation_url,
blob_root,
database_url,
valkey_url,
diff --git a/capsule-server/src/counter/budgets.rs b/capsule-server/src/counter/budgets.rs
index 2c53cd5f..7f81acaa 100644
--- a/capsule-server/src/counter/budgets.rs
+++ b/capsule-server/src/counter/budgets.rs
@@ -66,3 +66,35 @@ pub const SECOND_FACTOR: Budget = Budget::new(5, SignedDuration::from_mins(5));
/// re-hashes every declared blob, so an unbounded one is an I/O-amplification attack costing the
/// attacker one small JSON body.
pub const DEEP_VERIFY: Budget = Budget::new(4, SignedDuration::from_hours(1));
+
+/// Requests from one federated peer, across the sync and blob reads (invariant 21).
+///
+/// Ten thousand an hour — the retired server's established-tier default. A peer pulling a
+/// shared album makes one sync page and a few blob fetches per asset; ten thousand is an
+/// evening of photos from one household of peers, and a hostile peer enumerating addresses
+/// gets fewer than three a second. A fixed window, so a peer that spends it waits for the
+/// hour to turn rather than trickling back in.
+pub const PEER_REQUESTS: Budget = Budget::new(10_000, SignedDuration::from_hours(1));
+
+/// Federated moderation reports from one peer against one account (invariant 24).
+///
+/// Twenty an hour per `(reporting_server, reported_user)`. A real report is one message; a
+/// flood against one user is the false-flag vector the contract names, and backpressure at
+/// twenty bounds it without silencing a peer that has two things to say.
+pub const FEDERATED_REPORTS: Budget = Budget::new(20, SignedDuration::from_hours(1));
+
+/// Federated moderation reports from one peer against **every** account (`S-C49`).
+///
+/// Two hundred an hour. Ten times the per-account allowance, so a peer with a genuinely bad hour
+/// — a spam wave it is reporting honestly — is not silenced, while a peer cycling `reported_user`
+/// to mint itself a fresh per-account budget each time runs into a ceiling that does not care
+/// which account it named.
+pub const PEER_REPORTS: Budget = Budget::new(200, SignedDuration::from_hours(1));
+
+/// Attempts at `POST /v1/federation/reports` from one **claimed** origin (`S-C49`).
+///
+/// Three hundred an hour, charged before the peer is looked up. Deliberately above
+/// [`PEER_REPORTS`], because it is not a policy on reporting — it is the bound on how much work
+/// an anonymous caller can ask for on the server's one unauthenticated write, and a real peer
+/// must never meet it before meeting the budget that *is* the policy.
+pub const FEDERATED_INTAKE: Budget = Budget::new(300, SignedDuration::from_hours(1));
diff --git a/capsule-server/src/counter/mod.rs b/capsule-server/src/counter/mod.rs
index be6601dd..e1c213ce 100644
--- a/capsule-server/src/counter/mod.rs
+++ b/capsule-server/src/counter/mod.rs
@@ -83,6 +83,33 @@ pub enum CounterKey {
/// missed — recorded here rather than replaced by an email-keyed limiter, which would bound
/// repeated probes against one address while doing nothing about a sweep across many.
RegistrationSource(String),
+ /// Requests from one federated peer server, across the sync and blob reads (`S-E2`,
+ /// invariant 21).
+ ///
+ /// Keyed on the peer's origin, never on the capability: a peer holding ten capabilities is
+ /// one blast-radius boundary, and a budget per token would be a budget a peer widens by
+ /// asking for more tokens. Events per hour only; bytes and CPU per hour need a weighted
+ /// counter this port does not have and are post-v1.
+ PeerRequests(String),
+ /// Federated moderation reports from one peer against one account (`S-C49`, invariant
+ /// 24), keyed on `"{reporting_server}:{reported_user}"` as the contract bounds them.
+ FederatedReports(String),
+ /// Federated moderation reports from one peer against **every** account (`S-C49`).
+ ///
+ /// The ceiling the per-account budget cannot provide: `reported_user` is a string a peer
+ /// chooses, so a peer cycling accounts gets a fresh per-account allowance each time, and
+ /// only a key that ignores the account bounds the peer's total volume.
+ PeerReports(String),
+ /// Attempts at `POST /v1/federation/reports`, keyed on the **claimed** reporting origin.
+ ///
+ /// Charged before the peer is looked up, which is the only place a bound can sit on this
+ /// route: it is the server's one unauthenticated write, and everything after it — a store
+ /// read and an Ed25519 verification — is work an anonymous caller would otherwise get for
+ /// free. The key is attacker-chosen and that is stated rather than papered over: it bounds
+ /// one claimed origin looping, not a caller cycling origins, and this server has no trusted
+ /// client address to key on instead (see
+ /// [`CounterKey::RegistrationSource`], which waits on the same missing fact).
+ FederatedIntake(String),
}
impl CounterKey {
@@ -98,6 +125,10 @@ impl CounterKey {
Self::DeepVerify(_) => "deep_verify",
Self::SecondFactor(_) => "second_factor",
Self::RegistrationSource(_) => "registration_source",
+ Self::PeerRequests(_) => "peer_requests",
+ Self::FederatedReports(_) => "federated_reports",
+ Self::PeerReports(_) => "peer_reports",
+ Self::FederatedIntake(_) => "federated_intake",
}
}
}
diff --git a/capsule-server/src/discovery/revocation.rs b/capsule-server/src/discovery/revocation.rs
index 02ccc6f0..108b7bbf 100644
--- a/capsule-server/src/discovery/revocation.rs
+++ b/capsule-server/src/discovery/revocation.rs
@@ -25,15 +25,22 @@
//! somebody has to enforce. [`RevocationList::revoke`] refuses an entry whose expiry is beyond
//! the ceiling, which is what keeps that reasoning true: one accepted long-lived entry and the
//! list grows without bound while the peer-side staleness math silently stops applying.
+//!
+//! # Where the list lives now
+//!
+//! The port is implemented by the federation capability store
+//! ([`crate::federation::CapabilityStore`]), because once this server *issues* capabilities the
+//! record of one and the fact of its revocation are one row, and a standalone list would be a
+//! second answer to "is this `jti` revoked". The deterministic adapter is
+//! [`crate::federation::InMemoryCapabilities`]; the conformance suite that pins the pruning,
+//! ordering and ceiling rules is `federation::conformance`.
-use std::collections::BTreeMap;
use std::fmt;
use std::pin::Pin;
-use std::sync::{Arc, Mutex};
use jiff::{SignedDuration, Timestamp};
-use crate::store::{Clock, StoreError, StoreFuture};
+use crate::store::{StoreError, StoreFuture};
/// The ceiling design/federation.md puts on a capability token's lifetime.
pub const MAX_TOKEN_TTL: SignedDuration = SignedDuration::from_hours(24);
@@ -122,82 +129,6 @@ pub trait RevocationList: fmt::Debug + Send + Sync {
fn published(&self) -> StoreFuture<'_, PublishedRevocations>;
}
-/// The deterministic in-memory adapter.
-#[derive(Debug)]
-pub struct InMemoryRevocations {
- entries: Mutex>,
- clock: Arc,
-}
-
-impl InMemoryRevocations {
- /// An empty list reading `clock` for pruning and for `generated_at`.
- pub fn new(clock: Arc) -> Self {
- Self {
- entries: Mutex::new(BTreeMap::new()),
- clock,
- }
- }
-}
-
-impl RevocationList for InMemoryRevocations {
- fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> {
- Box::pin(async move {
- let now = self.clock.now();
- let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL);
- if token.expires_at > ceiling {
- tracing::warn!(
- jti = %token.jti,
- expires_at = %token.expires_at,
- "a revocation was refused: its expiry is beyond the capability TTL ceiling"
- );
- return Err(RevocationError::BeyondTtlCeiling {
- expires_at: token.expires_at,
- ceiling: MAX_TOKEN_TTL,
- }
- .into());
- }
-
- let mut entries = self
- .entries
- .lock()
- .expect("the revocation list is not poisoned");
- entries.insert(token.jti.clone(), token.expires_at);
- tracing::info!(
- jti = %token.jti,
- expires_at = %token.expires_at,
- published = entries.len(),
- "a federation capability token was revoked"
- );
- Ok(())
- })
- }
-
- fn published(&self) -> StoreFuture<'_, PublishedRevocations> {
- Box::pin(async move {
- let now = self.clock.now();
- let mut entries = self
- .entries
- .lock()
- .expect("the revocation list is not poisoned");
- // Pruned on read *and* retained pruned, so a list nobody fetches does not grow
- // forever holding entries that already mean nothing.
- entries.retain(|_, expires_at| *expires_at > now);
- let mut revoked: Vec = entries
- .iter()
- .map(|(jti, expires_at)| RevokedToken {
- jti: jti.clone(),
- expires_at: *expires_at,
- })
- .collect();
- revoked.sort_by_key(|token| (token.expires_at, token.jti.clone()));
- Ok(PublishedRevocations {
- generated_at: now,
- revoked,
- })
- })
- }
-}
-
/// What a verifier concluded about one `jti`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RevocationVerdict {
diff --git a/capsule-server/src/discovery/tests.rs b/capsule-server/src/discovery/tests.rs
index 540f84f4..e3b7c3b9 100644
--- a/capsule-server/src/discovery/tests.rs
+++ b/capsule-server/src/discovery/tests.rs
@@ -1,19 +1,15 @@
//! The registry's remaining records, and the rule a peer reads them by.
-use std::sync::Arc;
-
use jiff::{SignedDuration, Timestamp};
use super::revocation::{
- InMemoryRevocations, MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationList,
- RevocationVerdict, RevokeError, RevokedToken, check_revocation,
+ MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationVerdict, RevokedToken,
+ check_revocation,
};
use super::{
AnnouncementError, DEFAULT_ANNOUNCEMENT_WINDOW, DeprecationAnnouncement, ProtocolWindow,
ServerInfo,
};
-use crate::store::Clock;
-use crate::store::memory::ManualClock;
fn window() -> ProtocolWindow {
ProtocolWindow {
@@ -127,90 +123,6 @@ fn a_cutoff_in_the_past_is_refused_as_such() {
assert!(matches!(error, AnnouncementError::AlreadyPassed { .. }));
}
-#[tokio::test]
-async fn a_revocation_beyond_the_ttl_ceiling_is_refused() {
- // The published list is bounded *because* a capability token cannot outlive 24 hours. One
- // accepted long-lived entry and the list grows without bound while the peer-side staleness
- // math silently stops applying — so the ceiling is the port's invariant, not a convention.
- let clock = Arc::new(ManualClock::default());
- let list = InMemoryRevocations::new(clock.clone());
-
- let error = list
- .revoke(RevokedToken {
- jti: "beyond".to_owned(),
- expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(25)),
- })
- .await
- .expect_err("an entry past the ceiling is refused");
-
- assert!(matches!(error, RevokeError::Refused(_)));
- let published = list.published().await.expect("the list reads back");
- assert!(published.revoked.is_empty());
-}
-
-#[tokio::test]
-async fn revoking_the_same_token_twice_is_one_entry() {
- let clock = Arc::new(ManualClock::default());
- let list = InMemoryRevocations::new(clock.clone());
- let entry = RevokedToken {
- jti: "repeated".to_owned(),
- expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)),
- };
-
- list.revoke(entry.clone()).await.expect("first revocation");
- list.revoke(entry).await.expect("a retry is not a new fact");
-
- let published = list.published().await.expect("the list reads back");
- assert_eq!(published.revoked.len(), 1);
-}
-
-#[tokio::test]
-async fn an_entry_is_pruned_once_the_token_it_names_has_expired() {
- // An expired token is rejected whether or not it appears here, so the entry carries no
- // information — and dropping it is what keeps the list bounded by 24 hours of revocations.
- let clock = Arc::new(ManualClock::default());
- let list = InMemoryRevocations::new(clock.clone());
- list.revoke(RevokedToken {
- jti: "short".to_owned(),
- expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)),
- })
- .await
- .expect("revocation recorded");
-
- assert_eq!(
- list.published().await.expect("reads back").revoked.len(),
- 1,
- "live while the token it names could still be presented"
- );
-
- clock.advance(SignedDuration::from_hours(2));
- let published = list.published().await.expect("reads back");
- assert!(published.revoked.is_empty());
- assert_eq!(published.generated_at, clock.now());
-}
-
-#[tokio::test]
-async fn the_published_list_orders_by_expiry() {
- let clock = Arc::new(ManualClock::default());
- let list = InMemoryRevocations::new(clock.clone());
- for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] {
- list.revoke(RevokedToken {
- jti: jti.to_owned(),
- expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(hours)),
- })
- .await
- .expect("revocation recorded");
- }
-
- let published = list.published().await.expect("reads back");
- let order: Vec<&str> = published
- .revoked
- .iter()
- .map(|token| token.jti.as_str())
- .collect();
- assert_eq!(order, ["sooner", "middle", "later"]);
-}
-
#[test]
fn a_listed_token_is_refused() {
let now = at(0);
diff --git a/capsule-server/src/federation/capability.rs b/capsule-server/src/federation/capability.rs
new file mode 100644
index 00000000..71e6dde7
--- /dev/null
+++ b/capsule-server/src/federation/capability.rs
@@ -0,0 +1,811 @@
+//! The federation capability token, and the codec that mints and reads it.
+//!
+//! # The format is the contract
+//!
+//! design/federation.md makes the claim set normative — it is what every federated peer parses
+//! and what this server signs — so the shape here is that table verbatim and nothing more:
+//!
+//! ```text
+//! { "iss": , "sub": , "aud": "urn:capsule:album:",
+//! "scope": "read" | "read-derivative-only",
+//! "iat": , "exp": , "nbf": ,
+//! "jti": , "min_protocol_version": }
+//! ```
+//!
+//! Three deviations from RFC 7519 defaults, each the design's and each enforced here rather
+//! than left to a peer's discretion:
+//!
+//! - **`aud` names the album, never the recipient.** The recipient is `sub`. A verifier that
+//! matched `aud` against itself would accept every capability for every album, so
+//! `jsonwebtoken`'s audience check is off and [`CapabilityCodec::verify`] hands the album back
+//! for the *route* to match against the album being pulled.
+//! - **The three instants are RFC 3339 strings**, not numeric dates, so the library's own
+//! `exp`/`nbf` checks — against the system clock, with sixty seconds of leeway — are off and
+//! every temporal decision is made here against the injected [`Clock`]. The same rule
+//! [`crate::auth::tokens`] applies to session tokens, for the same reason: a deadline a test
+//! cannot walk over is a deadline nobody tests.
+//! - **`exp` is never more than 24 hours after `iat`.** Minting clamps; verification refuses a
+//! wider window even under a valid signature, because the published revocation list is
+//! bounded *by* that ceiling and one long-lived token would quietly break the bound.
+//!
+//! # One key, two token types
+//!
+//! The codec signs with the **same** Ed25519 key `SessionTokens` does — the operational key
+//! `server-info` publishes — and the two token types cannot be confused with each other: a
+//! session token carries `iss = "capsule-api"` and a required `kind`, a capability carries
+//! `iss = ` and no `kind`, so each verifier finds the other's tokens unreadable by
+//! construction.
+//!
+//! # Whole seconds, deliberately
+//!
+//! Every instant a capability carries is truncated to the second at mint. That is what lets a
+//! grant be **re-signed byte-for-byte** from its stored record ([`CapabilityCodec::sign`]):
+//! the refresh operation is idempotent on `(peer, jti)` and must answer a replay with the same
+//! successor token, and a store that keeps microseconds cannot reproduce a nanosecond string.
+//! Ed25519 signatures are deterministic, so the same claims sign to the same bytes.
+
+use std::fmt;
+use std::sync::Arc;
+
+use jiff::{SignedDuration, Timestamp};
+use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
+use serde::{Deserialize, Serialize};
+
+use super::PeerId;
+use crate::auth::tokens::SigningKeyError;
+use crate::discovery::revocation::MAX_TOKEN_TTL;
+use crate::store::{AlbumId, BlobRole, Clock};
+
+/// The URN prefix an album-scoped `aud` claim carries.
+pub const ALBUM_URN_PREFIX: &str = "urn:capsule:album:";
+
+/// The `aud` claim for `album`.
+#[must_use]
+pub fn album_urn(album: &AlbumId) -> String {
+ format!("{ALBUM_URN_PREFIX}{}", album.as_str())
+}
+
+/// The album an `aud` claim names, or `None` for a claim that is not an album URN.
+///
+/// The suffix must be a UUID, because an album id is one: a URN over any other text is not a
+/// claim this server ever minted.
+#[must_use]
+pub fn album_from_urn(aud: &str) -> Option {
+ let id = aud.strip_prefix(ALBUM_URN_PREFIX)?;
+ uuid::Uuid::parse_str(id).ok().map(|_| AlbumId::new(id))
+}
+
+/// What a capability grants over an album's blobs.
+///
+/// Enforced structurally against each blob's server-visible **role**, which is on its index row
+/// and named by its signed envelope: a derivative-only capability is refused an `original` at
+/// `GET /v1/blob/{hash}` whatever the peer claims to be fetching.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Scope {
+ /// Everything a member reads: originals, derivatives, metadata, provenance.
+ Read,
+ /// Thumbnails and previews only — never originals.
+ ReadDerivativeOnly,
+}
+
+impl Scope {
+ /// The stable token this scope travels under, on the wire and in a column.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Read => "read",
+ Self::ReadDerivativeOnly => "read-derivative-only",
+ }
+ }
+
+ /// The scope a stored token names, or `None` for a token no version of this server wrote.
+ pub fn from_token(token: &str) -> Option {
+ match token {
+ "read" => Some(Self::Read),
+ "read-derivative-only" => Some(Self::ReadDerivativeOnly),
+ _ => None,
+ }
+ }
+
+ /// Whether a blob of `role` may be fetched under this scope.
+ ///
+ /// A backup is refused under both: a peer pulls an album's assets, and a backup copy is the
+ /// owner's own durability artefact rather than part of what was shared.
+ pub fn permits(self, role: BlobRole) -> bool {
+ match role {
+ BlobRole::Backup => false,
+ BlobRole::Original => matches!(self, Self::Read),
+ BlobRole::Derivative | BlobRole::Metadata | BlobRole::Provenance => true,
+ }
+ }
+}
+
+impl fmt::Display for Scope {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+/// The claims a capability carries. Serialized as the JWT payload, verbatim from the design.
+///
+/// `deny_unknown_fields` because the set is closed: a claim the contract does not name is a
+/// token this server did not mint, whatever its signature says.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Claims {
+ iss: String,
+ sub: String,
+ aud: String,
+ scope: Scope,
+ iat: String,
+ exp: String,
+ nbf: String,
+ jti: String,
+ min_protocol_version: String,
+}
+
+/// What a capability turned out to grant, once it verified.
+///
+/// Carries no raw token: everything downstream needs is here, and handing on the credential
+/// itself is how one ends up in a log. `aud` has been parsed into the album it names, so a
+/// route matches an [`AlbumId`] against an [`AlbumId`] rather than re-parsing a URN.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapabilityGrant {
+ /// The peer server the grant was issued to (`sub`).
+ pub peer: PeerId,
+ /// The album it scopes to (`aud`).
+ pub album: AlbumId,
+ /// What it permits.
+ pub scope: Scope,
+ /// The revocation key (`jti`).
+ pub jti: String,
+ /// When it was issued; also its `nbf`.
+ pub issued_at: Timestamp,
+ /// When it stops being honoured.
+ pub expires_at: Timestamp,
+ /// The album's pinned protocol date, which the peer selects its parser from.
+ pub min_protocol_version: String,
+}
+
+/// Why a presented capability was not honoured.
+///
+/// Deliberately carries no fragment of the token. The variants exist so the unit suite can
+/// assert *which* mutation was refused; on the wire every one of them but [`Self::Expired`]
+/// collapses into the framework's uncoded `401`, as the session scheme's do.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum CapabilityError {
+ /// The token did not verify: a bad signature, a malformed payload, or a missing claim.
+ #[error("the capability could not be read")]
+ Unreadable,
+ /// The token verified and was issued by some other server.
+ #[error("the capability was issued by another server")]
+ WrongIssuer,
+ /// A claim is present and is not the shape the contract fixes.
+ #[error("the capability's {claim} claim is malformed")]
+ Malformed {
+ /// The claim that did not parse.
+ claim: &'static str,
+ },
+ /// `exp` is more than the ceiling after `iat`.
+ #[error("the capability's lifetime exceeds the {MAX_TOKEN_TTL} ceiling")]
+ BeyondTtlCeiling,
+ /// `nbf` is in the future on this server's clock.
+ #[error("the capability is not valid yet")]
+ NotYetValid,
+ /// `exp` has passed.
+ #[error("the capability has expired")]
+ Expired,
+}
+
+/// The claims could not be signed. The server's fault, never the caller's.
+#[derive(Debug, thiserror::Error)]
+#[error("the capability could not be signed: {detail}")]
+pub struct MintError {
+ /// The signer's own description of the failure.
+ pub detail: String,
+}
+
+/// What a mint asks for.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MintRequest {
+ /// The peer server the grant is for.
+ pub peer: PeerId,
+ /// The album it scopes to.
+ pub album: AlbumId,
+ /// What it permits.
+ pub scope: Scope,
+ /// The album's pinned protocol date.
+ pub min_protocol_version: String,
+ /// The requested lifetime. Clamped into `1s ..= MAX_TOKEN_TTL`, never refused: a
+ /// capability that expired before it was issued would be one the codec signs and cannot
+ /// read.
+ pub ttl: SignedDuration,
+}
+
+/// A freshly minted capability.
+///
+/// `Debug` is hand-written: the token is a bearer credential and a derived impl would publish
+/// it to any `tracing` field that formatted the struct.
+#[derive(Clone, PartialEq, Eq)]
+pub struct Minted {
+ /// The signed token, to hand to the peer.
+ pub token: String,
+ /// What it grants, for the issuer's own record.
+ pub grant: CapabilityGrant,
+}
+
+impl fmt::Debug for Minted {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("Minted")
+ .field("token", &"")
+ .field("grant", &self.grant)
+ .finish()
+ }
+}
+
+/// Mints and reads capabilities under this server's operational key.
+///
+/// `Debug` is hand-written and prints no key material.
+pub struct CapabilityCodec {
+ signing: EncodingKey,
+ verifying: DecodingKey,
+ public_key: Vec,
+ validation: Validation,
+ server_id: String,
+ clock: Arc,
+}
+
+impl fmt::Debug for CapabilityCodec {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("CapabilityCodec")
+ .field("server_id", &self.server_id)
+ .field("keys", &"")
+ .finish_non_exhaustive()
+ }
+}
+
+impl CapabilityCodec {
+ /// A codec over the operator's PKCS#8 Ed25519 key, issuing as `server_id`.
+ ///
+ /// The **same** bytes `SessionTokens::from_pkcs8` takes, so the public half this derives is
+ /// the one `server-info` publishes and the one a peer verifies against — `boot` asserts the
+ /// two agree. `from_pkcs8_maybe_unchecked` for the reason the session signer uses it: a v1
+ /// PKCS#8 document, which is what `openssl genpkey` writes, lacks the public half that is
+ /// being derived here anyway.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`SigningKeyError`] if `pkcs8_der` is not a readable Ed25519 private key.
+ pub fn from_pkcs8(
+ pkcs8_der: &[u8],
+ server_id: impl Into,
+ clock: Arc,
+ ) -> Result {
+ use ring::signature::KeyPair as _;
+
+ let pair = ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8_der).map_err(
+ |error| SigningKeyError {
+ detail: error.to_string(),
+ },
+ )?;
+ let public_key = pair.public_key().as_ref().to_vec();
+
+ // Everything temporal is decided here against `clock`, and `aud` is matched by the
+ // route against the album: the library checks the signature and the algorithm and that
+ // the three identity claims are present, and nothing else.
+ let mut validation = Validation::new(Algorithm::EdDSA);
+ validation.set_required_spec_claims(&["iss", "sub", "aud"]);
+ validation.validate_exp = false;
+ validation.validate_nbf = false;
+ validation.validate_aud = false;
+
+ Ok(Self {
+ signing: EncodingKey::from_ed_der(pkcs8_der),
+ verifying: DecodingKey::from_ed_der(&public_key),
+ public_key,
+ validation,
+ server_id: server_id.into(),
+ clock,
+ })
+ }
+
+ /// The issuer every capability from this codec carries.
+ pub fn server_id(&self) -> &str {
+ &self.server_id
+ }
+
+ /// The raw Ed25519 public key capabilities verify under. Thirty-two bytes, no encoding.
+ pub fn public_key(&self) -> &[u8] {
+ &self.public_key
+ }
+
+ /// Mint a capability for `request`, at the clock's now.
+ ///
+ /// `iat = nbf = now`, `exp = now + min(ttl, ceiling)`, a fresh UUIDv7 `jti`, all instants
+ /// at whole seconds (see the module docs).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MintError`] if the claims cannot be signed.
+ pub fn mint(&self, request: &MintRequest) -> Result {
+ let now = whole_seconds(self.clock.now());
+ let ttl = request
+ .ttl
+ .clamp(SignedDuration::from_secs(1), MAX_TOKEN_TTL);
+ let grant = CapabilityGrant {
+ peer: request.peer.clone(),
+ album: request.album.clone(),
+ scope: request.scope,
+ jti: uuid::Uuid::now_v7().to_string(),
+ issued_at: now,
+ expires_at: whole_seconds(crate::store::deadline(now, ttl)),
+ min_protocol_version: request.min_protocol_version.clone(),
+ };
+ let token = self.sign(&grant)?;
+ tracing::info!(
+ peer = %grant.peer,
+ album = %grant.album,
+ scope = %grant.scope,
+ jti = %grant.jti,
+ expires_at = %grant.expires_at,
+ "minted a federation capability"
+ );
+ Ok(Minted { token, grant })
+ }
+
+ /// Sign `grant` exactly as it was first minted.
+ ///
+ /// What answers a replayed refresh with the same successor: the grant is rebuilt from its
+ /// stored record and re-signed, and because every instant is at whole seconds and Ed25519
+ /// is deterministic, the bytes are the bytes the peer already holds.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`MintError`] if the claims cannot be signed.
+ pub fn sign(&self, grant: &CapabilityGrant) -> Result {
+ let claims = Claims {
+ iss: self.server_id.clone(),
+ sub: grant.peer.as_str().to_owned(),
+ aud: album_urn(&grant.album),
+ scope: grant.scope,
+ iat: grant.issued_at.to_string(),
+ exp: grant.expires_at.to_string(),
+ nbf: grant.issued_at.to_string(),
+ jti: grant.jti.clone(),
+ min_protocol_version: grant.min_protocol_version.clone(),
+ };
+ jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &self.signing).map_err(
+ |error| MintError {
+ detail: error.to_string(),
+ },
+ )
+ }
+
+ /// Read a presented capability.
+ ///
+ /// Signature and issuer first, then the shape of every claim, then the window against the
+ /// ceiling, then the clock. The order reports the most specific true reason without ever
+ /// computing with a claim that has not yet been checked.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`CapabilityError`] for every way a token can fail; none carries any of it.
+ pub fn verify(&self, presented: &str) -> Result {
+ let claims = jsonwebtoken::decode::(presented, &self.verifying, &self.validation)
+ .map_err(|error| {
+ // The *kind* names which check failed and never any part of the credential.
+ tracing::debug!(reason = ?error.kind(), "a presented capability did not verify");
+ CapabilityError::Unreadable
+ })?
+ .claims;
+
+ if claims.iss != self.server_id {
+ tracing::debug!("a presented capability names another issuer");
+ return Err(CapabilityError::WrongIssuer);
+ }
+ if claims.sub.is_empty() {
+ return Err(CapabilityError::Malformed { claim: "sub" });
+ }
+ // A UUIDv7, as the table says and as this server mints: any other `jti` is a token
+ // this server did not issue, whatever key it verifies under.
+ if !uuid::Uuid::parse_str(&claims.jti).is_ok_and(|id| id.get_version_num() == 7) {
+ return Err(CapabilityError::Malformed { claim: "jti" });
+ }
+ if claims
+ .min_protocol_version
+ .parse::()
+ .is_err()
+ {
+ return Err(CapabilityError::Malformed {
+ claim: "min_protocol_version",
+ });
+ }
+ let album =
+ album_from_urn(&claims.aud).ok_or(CapabilityError::Malformed { claim: "aud" })?;
+ let issued_at = instant(&claims.iat, "iat")?;
+ let expires_at = instant(&claims.exp, "exp")?;
+ let not_before = instant(&claims.nbf, "nbf")?;
+ if expires_at <= issued_at {
+ return Err(CapabilityError::Malformed { claim: "exp" });
+ }
+ if expires_at.duration_since(issued_at) > MAX_TOKEN_TTL {
+ tracing::debug!(jti = %claims.jti, "a presented capability outlives the ceiling");
+ return Err(CapabilityError::BeyondTtlCeiling);
+ }
+
+ let now = self.clock.now();
+ if now < not_before {
+ tracing::debug!(jti = %claims.jti, "a presented capability is not valid yet");
+ return Err(CapabilityError::NotYetValid);
+ }
+ if expires_at <= now {
+ tracing::debug!(jti = %claims.jti, "a presented capability has expired");
+ return Err(CapabilityError::Expired);
+ }
+
+ Ok(CapabilityGrant {
+ peer: PeerId::new(claims.sub),
+ album,
+ scope: claims.scope,
+ jti: claims.jti,
+ issued_at,
+ expires_at,
+ min_protocol_version: claims.min_protocol_version,
+ })
+ }
+}
+
+/// `at` with its sub-second part dropped.
+fn whole_seconds(at: Timestamp) -> Timestamp {
+ Timestamp::from_second(at.as_second()).unwrap_or(at)
+}
+
+/// An RFC 3339 claim as an instant.
+fn instant(text: &str, claim: &'static str) -> Result {
+ text.parse::()
+ .map_err(|_| CapabilityError::Malformed { claim })
+}
+
+#[cfg(test)]
+mod tests {
+ use base64::Engine as _;
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+ use serde_json::{Value, json};
+
+ use super::*;
+ use crate::store::memory::ManualClock;
+
+ const SERVER: &str = "home.test";
+
+ fn der() -> Vec {
+ ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
+ .expect("the platform generates a key")
+ .as_ref()
+ .to_vec()
+ }
+
+ fn codec(clock: Arc) -> CapabilityCodec {
+ CapabilityCodec::from_pkcs8(&der(), SERVER, clock).expect("a fresh key parses")
+ }
+
+ fn request() -> MintRequest {
+ MintRequest {
+ peer: PeerId::new("other.test"),
+ album: AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0"),
+ scope: Scope::ReadDerivativeOnly,
+ min_protocol_version: "2026-06-01".to_owned(),
+ ttl: SignedDuration::from_hours(6),
+ }
+ }
+
+ /// The payload of `token`, as JSON.
+ fn payload(token: &str) -> Value {
+ let segment = token.split('.').nth(1).expect("a JWT has three segments");
+ serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("base64url"))
+ .expect("the payload is JSON")
+ }
+
+ /// `token` with its payload replaced by `edit(payload)`, re-signed under `key`.
+ fn resigned(token: &str, key: &[u8], edit: impl FnOnce(&mut Value)) -> String {
+ let mut claims = payload(token);
+ edit(&mut claims);
+ jsonwebtoken::encode(
+ &Header::new(Algorithm::EdDSA),
+ &claims,
+ &EncodingKey::from_ed_der(key),
+ )
+ .expect("the edited claims sign")
+ }
+
+ #[test]
+ fn a_minted_capability_verifies_and_carries_exactly_the_contracts_claims() {
+ let clock = Arc::new(ManualClock::default());
+ let codec = codec(clock);
+ let minted = codec.mint(&request()).expect("it mints");
+
+ let claims = payload(&minted.token);
+ let keys: Vec<&str> = claims
+ .as_object()
+ .expect("an object")
+ .keys()
+ .map(String::as_str)
+ .collect();
+ assert_eq!(
+ keys,
+ [
+ "aud",
+ "exp",
+ "iat",
+ "iss",
+ "jti",
+ "min_protocol_version",
+ "nbf",
+ "scope",
+ "sub"
+ ],
+ "the claim set is the design's table and nothing else"
+ );
+ assert_eq!(claims["iss"], SERVER);
+ assert_eq!(claims["sub"], "other.test");
+ assert_eq!(
+ claims["aud"],
+ "urn:capsule:album:01937b7c-0000-7000-8000-00000000a1b0"
+ );
+ assert_eq!(claims["scope"], "read-derivative-only");
+ assert_eq!(claims["iat"], "1970-01-01T00:00:00Z");
+ assert_eq!(claims["nbf"], "1970-01-01T00:00:00Z");
+ assert_eq!(claims["exp"], "1970-01-01T06:00:00Z");
+ assert_eq!(
+ uuid::Uuid::parse_str(claims["jti"].as_str().expect("a string"))
+ .expect("a uuid")
+ .get_version_num(),
+ 7
+ );
+
+ let grant = codec.verify(&minted.token).expect("it verifies");
+ assert_eq!(grant, minted.grant);
+ }
+
+ #[test]
+ fn a_grant_re_signs_to_the_same_bytes() {
+ // What refresh idempotency rests on: the record can reproduce the token.
+ let codec = codec(Arc::new(ManualClock::default()));
+ let minted = codec.mint(&request()).expect("it mints");
+ assert_eq!(codec.sign(&minted.grant).expect("it signs"), minted.token);
+ }
+
+ #[test]
+ fn the_lifetime_is_clamped_to_the_ceiling_and_the_instants_are_whole_seconds() {
+ let clock = Arc::new(ManualClock::new(
+ Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"),
+ ));
+ let codec = codec(clock);
+ let minted = codec
+ .mint(&MintRequest {
+ ttl: SignedDuration::from_hours(48),
+ ..request()
+ })
+ .expect("it mints");
+ assert_eq!(
+ minted.grant.issued_at,
+ Timestamp::from_second(1_700_000_000).expect("an instant")
+ );
+ assert_eq!(
+ minted
+ .grant
+ .expires_at
+ .duration_since(minted.grant.issued_at),
+ MAX_TOKEN_TTL
+ );
+ assert!(codec.verify(&minted.token).is_ok());
+ }
+
+ #[test]
+ fn every_mutation_of_a_claim_is_refused_with_its_reason() {
+ // The federation doc's own unit bullet: mutate each claim and assert the reason.
+ let clock = Arc::new(ManualClock::default());
+ let key = der();
+ let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses");
+ let minted = codec.mint(&request()).expect("it mints");
+ let token = &minted.token;
+
+ // A lifetime that is not positive is clamped up rather than signed unreadable.
+ let instant = codec
+ .mint(&MintRequest {
+ ttl: SignedDuration::from_secs(-5),
+ ..request()
+ })
+ .expect("it mints");
+ assert_eq!(
+ instant
+ .grant
+ .expires_at
+ .duration_since(instant.grant.issued_at),
+ SignedDuration::from_secs(1)
+ );
+ assert!(codec.verify(&instant.token).is_ok());
+
+ // The signature: any other key, or a flipped payload byte under the right key.
+ let forged = resigned(token, &der(), |_| {});
+ assert_eq!(codec.verify(&forged), Err(CapabilityError::Unreadable));
+ let mut tampered = token.clone();
+ let payload_start = tampered.find('.').expect("a dot") + 1;
+ let byte = tampered.as_bytes()[payload_start];
+ tampered.replace_range(
+ payload_start..=payload_start,
+ if byte == b'A' { "B" } else { "A" },
+ );
+ assert_eq!(codec.verify(&tampered), Err(CapabilityError::Unreadable));
+
+ // Each claim, under the real key.
+ type Edit = Box;
+ let cases: [(&str, Edit, CapabilityError); 16] = [
+ (
+ "iss",
+ Box::new(|c| c["iss"] = json!("elsewhere.test")),
+ CapabilityError::WrongIssuer,
+ ),
+ (
+ "sub",
+ Box::new(|c| c["sub"] = json!("")),
+ CapabilityError::Malformed { claim: "sub" },
+ ),
+ (
+ "aud",
+ Box::new(|c| c["aud"] = json!("urn:capsule:user:someone")),
+ CapabilityError::Malformed { claim: "aud" },
+ ),
+ (
+ "scope",
+ Box::new(|c| c["scope"] = json!("write")),
+ CapabilityError::Unreadable,
+ ),
+ (
+ "iat",
+ Box::new(|c| c["iat"] = json!(0)),
+ CapabilityError::Unreadable,
+ ),
+ (
+ "exp",
+ Box::new(|c| c["exp"] = json!("tomorrow")),
+ CapabilityError::Malformed { claim: "exp" },
+ ),
+ (
+ "exp before iat",
+ Box::new(|c| c["exp"] = json!("1969-12-31T23:00:00Z")),
+ CapabilityError::Malformed { claim: "exp" },
+ ),
+ (
+ "exp beyond the ceiling",
+ Box::new(|c| c["exp"] = json!("1970-01-02T00:00:01Z")),
+ CapabilityError::BeyondTtlCeiling,
+ ),
+ (
+ "nbf",
+ Box::new(|c| c["nbf"] = json!("1970-01-01T01:00:00Z")),
+ CapabilityError::NotYetValid,
+ ),
+ (
+ "jti",
+ Box::new(|c| c["jti"] = json!("")),
+ CapabilityError::Malformed { claim: "jti" },
+ ),
+ (
+ "jti that is not a UUIDv7",
+ Box::new(|c| c["jti"] = json!("2b6ed3a6-4c7e-4f3a-9d3c-1f1f1f1f1f1f")),
+ CapabilityError::Malformed { claim: "jti" },
+ ),
+ (
+ "aud whose suffix is not an album id",
+ Box::new(|c| c["aud"] = json!("urn:capsule:album:not-an-id")),
+ CapabilityError::Malformed { claim: "aud" },
+ ),
+ (
+ "min_protocol_version",
+ Box::new(|c| c["min_protocol_version"] = json!("soon")),
+ CapabilityError::Malformed {
+ claim: "min_protocol_version",
+ },
+ ),
+ (
+ "an extra claim",
+ Box::new(|c| c["kind"] = json!("access")),
+ CapabilityError::Unreadable,
+ ),
+ (
+ "a missing claim",
+ Box::new(|c| {
+ c.as_object_mut().expect("an object").remove("jti");
+ }),
+ CapabilityError::Unreadable,
+ ),
+ (
+ "a session token's shape",
+ Box::new(|c| {
+ c["iss"] = json!("capsule-api");
+ c["kind"] = json!("access");
+ }),
+ CapabilityError::Unreadable,
+ ),
+ ];
+ for (claim, edit, expected) in cases {
+ let mutated = resigned(token, &key, edit);
+ assert_eq!(
+ codec.verify(&mutated),
+ Err(expected),
+ "mutating {claim} was not refused as expected"
+ );
+ }
+
+ // And expiry, on the clock rather than by editing a claim.
+ clock.advance(SignedDuration::from_hours(6));
+ assert_eq!(codec.verify(token), Err(CapabilityError::Expired));
+ }
+
+ #[test]
+ fn a_session_token_is_unreadable_to_the_capability_codec_and_vice_versa() {
+ // The same key signs both, and the two verifiers still cannot be confused: a session
+ // token carries `iss = capsule-api` and a `kind`, a capability neither.
+ let clock = Arc::new(ManualClock::default());
+ let key = der();
+ let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses");
+ let sessions = crate::auth::SessionTokens::from_pkcs8(&key, clock).expect("parses");
+ assert_eq!(codec.public_key(), sessions.public_key());
+
+ let issued = sessions
+ .issue(
+ &crate::store::UserId::new("user"),
+ &crate::store::SessionId::new("session"),
+ SignedDuration::from_hours(1),
+ )
+ .expect("it issues");
+ assert_eq!(
+ codec.verify(&issued.access_token),
+ Err(CapabilityError::Unreadable),
+ "a session token has no aud, which the capability codec requires"
+ );
+
+ let minted = codec.mint(&request()).expect("it mints");
+ assert!(matches!(
+ sessions.verify(&minted.token, crate::auth::TokenKind::Access),
+ Err(crate::auth::TokenError::Unreadable)
+ ));
+ }
+
+ #[test]
+ fn scope_is_decided_by_the_blobs_role() {
+ for role in [
+ BlobRole::Derivative,
+ BlobRole::Metadata,
+ BlobRole::Provenance,
+ ] {
+ assert!(Scope::Read.permits(role));
+ assert!(Scope::ReadDerivativeOnly.permits(role));
+ }
+ assert!(Scope::Read.permits(BlobRole::Original));
+ assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Original));
+ assert!(!Scope::Read.permits(BlobRole::Backup));
+ assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Backup));
+ for scope in [Scope::Read, Scope::ReadDerivativeOnly] {
+ assert_eq!(Scope::from_token(scope.as_str()), Some(scope));
+ }
+ assert_eq!(Scope::from_token("write"), None);
+ }
+
+ #[test]
+ fn the_album_urn_round_trips_and_nothing_else_parses() {
+ let album = AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0");
+ assert_eq!(album_from_urn(&album_urn(&album)), Some(album));
+ assert_eq!(album_from_urn("urn:capsule:album:"), None);
+ assert_eq!(album_from_urn("home.test"), None);
+ }
+
+ #[test]
+ fn nothing_prints_a_token_or_a_key() {
+ let codec = codec(Arc::new(ManualClock::default()));
+ let minted = codec.mint(&request()).expect("it mints");
+ let rendered = format!("{minted:?} {codec:?}");
+ assert!(!rendered.contains(&minted.token), "{rendered}");
+ assert!(rendered.contains(""), "{rendered}");
+ }
+}
diff --git a/capsule-server/src/federation/conformance.rs b/capsule-server/src/federation/conformance.rs
new file mode 100644
index 00000000..fa085d7a
--- /dev/null
+++ b/capsule-server/src/federation/conformance.rs
@@ -0,0 +1,800 @@
+//! The one suite every [`CapabilityStore`] and [`PeerStore`] adapter must pass.
+//!
+//! # The rules the suite exists to protect
+//!
+//! - **The store is the revocation list.** Revoking an issued capability publishes its `jti`;
+//! revoking a `jti` nothing backs still publishes it; and what is published is pruned past
+//! the token's own expiry and bounded by the TTL ceiling — the rules the standalone list
+//! carried before this store replaced it.
+//! - **Refresh is one operation and is idempotent.** The successor is recorded, the
+//! predecessor is linked and revoked, and a replay answers with the same successor.
+//! - **A refusal changes nothing.** `AlreadyRevoked`, `AlreadyRefreshed`, `AlreadyBlocked`
+//! leave every row as it was.
+//!
+//! # Reusing a harness
+//!
+//! Every case scopes its own identifiers, so cases may share one store and [`run_all`] does.
+//! The clock is the harness's own [`ManualClock`], because pruning is decided on the adapter's
+//! clock rather than on an argument.
+
+use jiff::SignedDuration;
+
+use super::PeerId;
+use super::capability::Scope;
+use super::peers::{BlockOutcome, PeerStore, UnblockOutcome};
+use super::store::{
+ CapabilityFilter, CapabilityRecord, CapabilityStore, MAX_GRANT_LIFETIME, RefreshOutcome,
+ RevokeOutcome,
+};
+use crate::discovery::revocation::{RevokeError, RevokedToken};
+use crate::store::memory::ManualClock;
+use crate::store::{AlbumId, Clock as _, StoreError, UserId};
+
+/// The stores under test.
+pub trait Harness: Send + Sync {
+ /// The capability store under test.
+ fn capabilities(&self) -> &dyn CapabilityStore;
+ /// The peer store under test.
+ fn peers(&self) -> &dyn PeerStore;
+ /// The clock both adapters read.
+ fn clock(&self) -> &ManualClock;
+}
+
+/// Unwrap a store result, failing with the operation that was expected to work.
+#[track_caller]
+fn ok(result: Result, doing: &str) -> T {
+ match result {
+ Ok(value) => value,
+ Err(error) => panic!("a conforming federation store must succeed at {doing}: {error}"),
+ }
+}
+
+/// A capability for `case`, minted at the clock's now and good for `hours`.
+///
+/// Not renewable: `not_after` equals `expires_at`, which is the default an owner gets when they
+/// do not ask for renewal. The cases that are about the deadline set it themselves.
+fn record(h: &dyn Harness, case: &str, jti: &str, hours: i64) -> CapabilityRecord {
+ renewable_record(h, case, jti, hours, hours)
+}
+
+/// A capability for `case`, good for `hours` and renewable until `grant_hours` from now.
+fn renewable_record(
+ h: &dyn Harness,
+ case: &str,
+ jti: &str,
+ hours: i64,
+ grant_hours: i64,
+) -> CapabilityRecord {
+ let now = h.clock().now();
+ CapabilityRecord {
+ jti: format!("{case}-{jti}"),
+ album_id: AlbumId::new(format!("{case}-album")),
+ peer_id: PeerId::new(format!("{case}.peer.test")),
+ member: UserId::new(format!("{case}-member")),
+ scope: Scope::Read,
+ granted_epoch: 3,
+ min_protocol_version: "2026-06-01".to_owned(),
+ issued_at: now,
+ expires_at: crate::store::deadline(now, SignedDuration::from_hours(hours)),
+ not_after: crate::store::deadline(now, SignedDuration::from_hours(grant_hours)),
+ revoked_at: None,
+ refreshed_to: None,
+ }
+}
+
+async fn issue(h: &dyn Harness, record: CapabilityRecord) {
+ ok(h.capabilities().issue(record).await, "record a capability");
+}
+
+async fn find(h: &dyn Harness, jti: &str) -> Option {
+ ok(h.capabilities().find(jti).await, "find a capability")
+}
+
+async fn published(h: &dyn Harness) -> Vec {
+ ok(h.capabilities().published().await, "read the list")
+ .revoked
+ .into_iter()
+ .map(|token| token.jti)
+ .collect()
+}
+
+// ===========================================================================================
+// Capabilities
+// ===========================================================================================
+
+/// An issued capability reads back whole, and an unknown `jti` is `None`.
+pub async fn an_issued_capability_reads_back_and_an_unknown_jti_is_none(h: &dyn Harness) {
+ let case = "readback";
+ let record = record(h, case, "one", 6);
+ issue(h, record.clone()).await;
+ assert_eq!(find(h, &record.jti).await, Some(record.clone()));
+ assert!(record.is_live(h.clock().now()));
+ assert_eq!(find(h, "readback-never").await, None);
+}
+
+/// A second record under one `jti` is refused as a rejection, and the first stands.
+pub async fn a_duplicate_jti_is_rejected_and_the_first_record_stands(h: &dyn Harness) {
+ let case = "duplicate";
+ let first = record(h, case, "one", 6);
+ issue(h, first.clone()).await;
+ let error = h
+ .capabilities()
+ .issue(CapabilityRecord {
+ scope: Scope::ReadDerivativeOnly,
+ ..first.clone()
+ })
+ .await
+ .expect_err("a jti is minted once");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ assert_eq!(find(h, &first.jti).await, Some(first));
+}
+
+/// `live` answers by album and by peer, and leaves out the revoked and the expired.
+pub async fn live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h: &dyn Harness) {
+ let case = "live";
+ let now = h.clock().now();
+ let a = record(h, case, "a", 6);
+ let b = CapabilityRecord {
+ peer_id: PeerId::new("other-live.peer.test"),
+ ..record(h, case, "b", 6)
+ };
+ let revoked = record(h, case, "revoked", 6);
+ let expiring = record(h, case, "expiring", 1);
+ for record in [&a, &b, &revoked, &expiring] {
+ issue(h, record.clone()).await;
+ }
+ assert_eq!(
+ h.capabilities()
+ .revoke_issued(&revoked.jti, now)
+ .await
+ .expect("revokes"),
+ RevokeOutcome::Revoked
+ );
+
+ let later = crate::store::deadline(now, SignedDuration::from_hours(2));
+ let mut by_album: Vec = ok(
+ h.capabilities()
+ .live(&CapabilityFilter::Album(a.album_id.clone()), later)
+ .await,
+ "list by album",
+ )
+ .into_iter()
+ .map(|record| record.jti)
+ .collect();
+ by_album.sort();
+ assert_eq!(by_album, vec![a.jti.clone(), b.jti.clone()]);
+
+ let by_peer: Vec = ok(
+ h.capabilities()
+ .live(&CapabilityFilter::Peer(b.peer_id.clone()), later)
+ .await,
+ "list by peer",
+ )
+ .into_iter()
+ .map(|record| record.jti)
+ .collect();
+ assert_eq!(by_peer, vec![b.jti]);
+}
+
+/// Revoking an issued capability sets `revoked_at`, publishes its `jti`, and is idempotent.
+pub async fn revoking_an_issued_capability_publishes_it_once(h: &dyn Harness) {
+ let case = "revoke";
+ let now = h.clock().now();
+ let record = record(h, case, "one", 6);
+ issue(h, record.clone()).await;
+
+ assert_eq!(
+ h.capabilities()
+ .revoke_issued(&record.jti, now)
+ .await
+ .expect("revokes"),
+ RevokeOutcome::Revoked
+ );
+ let stored = find(h, &record.jti).await.expect("still recorded");
+ assert_eq!(stored.revoked_at, Some(now));
+ assert!(!stored.is_live(now));
+ assert!(published(h).await.contains(&record.jti));
+
+ assert_eq!(
+ h.capabilities()
+ .revoke_issued(&record.jti, now)
+ .await
+ .expect("answers"),
+ RevokeOutcome::AlreadyRevoked
+ );
+ assert_eq!(
+ find(h, &record.jti)
+ .await
+ .expect("still recorded")
+ .revoked_at,
+ Some(now),
+ "a retry does not move the instant"
+ );
+ assert_eq!(
+ h.capabilities()
+ .revoke_issued("revoke-never", now)
+ .await
+ .expect("answers"),
+ RevokeOutcome::Unknown
+ );
+ assert_eq!(
+ published(h)
+ .await
+ .iter()
+ .filter(|jti| *jti == &record.jti)
+ .count(),
+ 1,
+ "one entry however many times it is revoked"
+ );
+}
+
+/// A `jti` nothing backs is still published, and one past the ceiling is refused.
+pub async fn a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h: &dyn Harness) {
+ let now = h.clock().now();
+ h.capabilities()
+ .revoke(RevokedToken {
+ jti: "foreign-one".to_owned(),
+ expires_at: crate::store::deadline(now, SignedDuration::from_hours(2)),
+ })
+ .await
+ .expect("a foreign jti is a fact the list carries");
+ assert!(published(h).await.contains(&"foreign-one".to_owned()));
+ assert_eq!(
+ find(h, "foreign-one").await,
+ None,
+ "no record is invented for it"
+ );
+
+ let error = h
+ .capabilities()
+ .revoke(RevokedToken {
+ jti: "foreign-beyond".to_owned(),
+ expires_at: crate::store::deadline(now, SignedDuration::from_hours(25)),
+ })
+ .await
+ .expect_err("an entry past the ceiling is refused");
+ assert!(matches!(error, RevokeError::Refused(_)), "{error:?}");
+ assert!(!published(h).await.contains(&"foreign-beyond".to_owned()));
+}
+
+/// Revoking a `jti` through the list also revokes the record behind it, and once only.
+pub async fn the_list_and_the_record_are_one_fact(h: &dyn Harness) {
+ let case = "onefact";
+ let now = h.clock().now();
+ let record = record(h, case, "one", 6);
+ issue(h, record.clone()).await;
+ let entry = RevokedToken {
+ jti: record.jti.clone(),
+ expires_at: record.expires_at,
+ };
+ h.capabilities()
+ .revoke(entry.clone())
+ .await
+ .expect("first revocation");
+ h.capabilities()
+ .revoke(entry)
+ .await
+ .expect("a retry is not a new fact");
+ assert_eq!(
+ find(h, &record.jti).await.expect("recorded").revoked_at,
+ Some(now)
+ );
+ assert_eq!(
+ published(h)
+ .await
+ .iter()
+ .filter(|jti| *jti == &record.jti)
+ .count(),
+ 1
+ );
+}
+
+/// A list-side revocation of an issued `jti` is published under the record's own expiry.
+///
+/// A shorter expiry from the caller would prune the entry while the token still verifies —
+/// a peer's cached list would drop it and honour a revoked token until its real `exp`.
+pub async fn a_list_side_revocation_keeps_the_records_expiry(h: &dyn Harness) {
+ let case = "keepexp";
+ let now = h.clock().now();
+ let record = record(h, case, "one", 6);
+ issue(h, record.clone()).await;
+ h.capabilities()
+ .revoke(RevokedToken {
+ jti: record.jti.clone(),
+ expires_at: crate::store::deadline(now, SignedDuration::from_mins(1)),
+ })
+ .await
+ .expect("revokes");
+ let entry = ok(h.capabilities().published().await, "read the list")
+ .revoked
+ .into_iter()
+ .find(|token| token.jti == record.jti)
+ .expect("published");
+ assert_eq!(entry.expires_at, record.expires_at);
+ assert_eq!(
+ find(h, &record.jti).await.expect("recorded").revoked_at,
+ Some(now)
+ );
+}
+
+/// A record that would outlive the ceiling is refused by the store, at issue and at refresh.
+pub async fn a_record_past_the_ceiling_is_refused(h: &dyn Harness) {
+ let case = "ceiling";
+ let now = h.clock().now();
+ let long = record(h, case, "long", 25);
+ let error = h
+ .capabilities()
+ .issue(long.clone())
+ .await
+ .expect_err("the list is bounded by the ceiling, so the store holds it too");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ assert_eq!(find(h, &long.jti).await, None);
+
+ let old = record(h, case, "old", 6);
+ issue(h, old.clone()).await;
+ let error = h
+ .capabilities()
+ .refresh(&old.jti, record(h, case, "long-successor", 25), now)
+ .await
+ .expect_err("a successor is held to the same ceiling");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ let old = find(h, &old.jti).await.expect("recorded");
+ assert_eq!(old.refreshed_to, None, "a refusal changes nothing");
+ assert_eq!(old.revoked_at, None);
+}
+
+/// A successor for another peer, album or member is refused; the link cannot widen a grant.
+pub async fn a_successor_must_carry_the_predecessors_peer_album_and_member(h: &dyn Harness) {
+ let case = "widen";
+ let now = h.clock().now();
+ let old = record(h, case, "old", 6);
+ issue(h, old.clone()).await;
+ for (name, successor) in [
+ (
+ "peer",
+ CapabilityRecord {
+ peer_id: PeerId::new("widen-other.peer.test"),
+ ..record(h, case, "peer", 6)
+ },
+ ),
+ (
+ "album",
+ CapabilityRecord {
+ album_id: AlbumId::new("widen-other-album"),
+ ..record(h, case, "album", 6)
+ },
+ ),
+ (
+ "member",
+ CapabilityRecord {
+ member: UserId::new("widen-other-member"),
+ ..record(h, case, "member", 6)
+ },
+ ),
+ ] {
+ let error = h
+ .capabilities()
+ .refresh(&old.jti, successor.clone(), now)
+ .await
+ .expect_err("a successor naming another peer, album or member is a rejection");
+ assert!(
+ matches!(error, StoreError::Rejected { .. }),
+ "{name}: {error:?}"
+ );
+ assert_eq!(
+ find(h, &successor.jti).await,
+ None,
+ "{name}: a refusal records nothing"
+ );
+ }
+ let old = find(h, &old.jti).await.expect("recorded");
+ assert_eq!(old.refreshed_to, None);
+ assert_eq!(old.revoked_at, None);
+}
+
+/// A successor may not move the grant's absolute deadline, in either direction.
+///
+/// The rule that makes "a refresh cannot extend a grant" a property of the *store* rather than
+/// of the one route that computes a successor's TTL. Without it a peer holding a deliberately
+/// short grant refreshes into an indefinite one, and every adapter would have to be trusted to
+/// have re-derived the same check.
+pub async fn a_successor_may_not_move_the_grants_deadline(h: &dyn Harness) {
+ let case = "deadline";
+ let now = h.clock().now();
+ // Renewable for a week; each token lives six hours.
+ let old = renewable_record(h, case, "old", 6, 24 * 7);
+ issue(h, old.clone()).await;
+
+ for (name, not_after) in [
+ (
+ "longer",
+ crate::store::deadline(now, SignedDuration::from_hours(24 * 30)),
+ ),
+ (
+ "shorter",
+ crate::store::deadline(now, SignedDuration::from_hours(12)),
+ ),
+ ] {
+ let successor = CapabilityRecord {
+ not_after,
+ ..renewable_record(h, case, name, 6, 24 * 7)
+ };
+ let error = h
+ .capabilities()
+ .refresh(&old.jti, successor.clone(), now)
+ .await
+ .expect_err("a successor moving the deadline is a rejection");
+ assert!(
+ matches!(error, StoreError::Rejected { .. }),
+ "{name}: {error:?}"
+ );
+ assert_eq!(
+ find(h, &successor.jti).await,
+ None,
+ "{name}: a refusal records nothing"
+ );
+ }
+
+ // And a token that would run past the deadline is refused at issue, before any refresh.
+ let overhanging = CapabilityRecord {
+ expires_at: crate::store::deadline(now, SignedDuration::from_hours(12)),
+ not_after: crate::store::deadline(now, SignedDuration::from_hours(6)),
+ ..record(h, case, "overhanging", 6)
+ };
+ let error = h
+ .capabilities()
+ .issue(overhanging.clone())
+ .await
+ .expect_err("a token outliving its grant is a rejection");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ assert_eq!(find(h, &overhanging.jti).await, None);
+
+ // The one successor that *is* admissible carries the deadline unchanged.
+ let successor = renewable_record(h, case, "ok", 6, 24 * 7);
+ match ok(
+ h.capabilities()
+ .refresh(&old.jti, successor.clone(), now)
+ .await,
+ "refresh a renewable grant",
+ ) {
+ RefreshOutcome::Issued(issued) => assert_eq!(issued.not_after, old.not_after),
+ other => panic!("a live renewable predecessor must refresh, got {other:?}"),
+ }
+}
+
+/// A grant whose deadline runs past the ninety-day ceiling is refused by the **store**, not only
+/// by the route that parses `renewable_until`.
+///
+/// Issued here **directly through the port**, bypassing `mint_capability` entirely, because that
+/// is the whole property: the route is the only caller of [`CapabilityStore::issue`] today and
+/// #476's operator tooling is exactly the second one. A ceiling enforced by whichever caller
+/// remembers it is a ceiling the next caller does not have.
+///
+/// Distinct from the two cases beside it: [`a_record_past_the_ceiling_is_refused`] bounds one
+/// *token*'s life against `MAX_TOKEN_TTL`, and
+/// [`a_successor_may_not_move_the_grants_deadline`] bounds a successor against its predecessor.
+/// Neither says anything about how far out the original deadline may be.
+pub async fn a_grant_past_the_lifetime_ceiling_is_refused_by_the_store(h: &dyn Harness) {
+ let case = "lifetime";
+ let now = h.clock().now();
+
+ // Ninety days and an hour: a mistyped year is the input this exists for, and one hour past
+ // is the boundary that proves the comparison is the ceiling and not a rounding of it.
+ let mut past = record(h, case, "past", 6);
+ past.not_after =
+ crate::store::deadline(now, MAX_GRANT_LIFETIME + SignedDuration::from_hours(1));
+ let error = h
+ .capabilities()
+ .issue(past.clone())
+ .await
+ .expect_err("a grant past the lifetime ceiling is a rejection");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ assert_eq!(
+ find(h, &past.jti).await,
+ None,
+ "a refused grant records nothing"
+ );
+
+ // Exactly at the ceiling is admissible: the bound is inclusive, and a deployment sharing for
+ // precisely ninety days is not the mistake being guarded against.
+ let mut edge = record(h, case, "edge", 6);
+ edge.not_after = crate::store::deadline(now, MAX_GRANT_LIFETIME);
+ issue(h, edge.clone()).await;
+ assert_eq!(
+ find(h, &edge.jti).await.expect("recorded").not_after,
+ edge.not_after
+ );
+
+ // And a refresh cannot be used to walk past it either: the successor carries the deadline
+ // unchanged, so the ceiling is fixed at the original mint rather than re-measured per token.
+ let successor = CapabilityRecord {
+ not_after: edge.not_after,
+ ..record(h, case, "successor", 6)
+ };
+ match ok(
+ h.capabilities().refresh(&edge.jti, successor, now).await,
+ "refresh a grant sitting at the ceiling",
+ ) {
+ RefreshOutcome::Issued(issued) => assert_eq!(issued.not_after, edge.not_after),
+ other => panic!("a live grant at the ceiling must refresh, got {other:?}"),
+ }
+}
+
+/// Every adapter accepts the widest `granted_epoch` the port's type allows, and refuses the
+/// first one it does not — identically.
+///
+/// The suite fixes `granted_epoch` at 3 everywhere else, which is exactly the shape of divergence
+/// this file exists to catch and cannot: the port says `u64` and the Postgres column is a
+/// `BIGINT`, so an epoch above `i64::MAX` is representable to a caller and not to one adapter.
+/// The rule is that it is **refused**, not silently narrowed — a grant recorded under a different
+/// epoch than the one asked for is a grant that admits the wrong membership — and that both
+/// adapters draw the line in the same place.
+pub async fn the_widest_epoch_every_adapter_accepts_is_the_same_one(h: &dyn Harness) {
+ let case = "epoch";
+ let widest = u64::try_from(i64::MAX).expect("i64::MAX is a u64");
+
+ // The widest value that round-trips, stored and read back unchanged.
+ let mut held = record(h, case, "widest", 6);
+ held.granted_epoch = widest;
+ issue(h, held.clone()).await;
+ assert_eq!(
+ find(h, &held.jti).await.expect("recorded").granted_epoch,
+ widest,
+ "the widest epoch must survive the round trip unchanged"
+ );
+
+ // Zero is the other end, and is a legitimate epoch rather than a missing one.
+ let mut zero = record(h, case, "zero", 6);
+ zero.granted_epoch = 0;
+ issue(h, zero.clone()).await;
+ assert_eq!(find(h, &zero.jti).await.expect("recorded").granted_epoch, 0);
+
+ // One past it is refused, and refused *before* anything is written.
+ let mut past = record(h, case, "past", 6);
+ past.granted_epoch = widest + 1;
+ let error = h
+ .capabilities()
+ .issue(past.clone())
+ .await
+ .expect_err("an epoch past the port's width is a rejection, never a truncation");
+ assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}");
+ assert_eq!(
+ find(h, &past.jti).await,
+ None,
+ "a refused epoch records nothing"
+ );
+
+ // And `live` still finds the widest one, so the refusal did not disturb the rows beside it.
+ let filter = CapabilityFilter::Album(held.album_id.clone());
+ let found = ok(
+ h.capabilities().live(&filter, h.clock().now()).await,
+ "list live capabilities",
+ );
+ assert!(found.iter().any(|row| row.jti == held.jti));
+}
+
+/// An entry leaves the list once the token it names has expired, and the list orders by expiry.
+pub async fn the_published_list_prunes_expired_entries_and_orders_by_expiry(h: &dyn Harness) {
+ let case = "prune";
+ let now = h.clock().now();
+ for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] {
+ let record = record(h, case, jti, hours);
+ issue(h, record.clone()).await;
+ h.capabilities()
+ .revoke_issued(&record.jti, now)
+ .await
+ .expect("revokes");
+ }
+ let listed: Vec = published(h)
+ .await
+ .into_iter()
+ .filter(|jti| jti.starts_with("prune-"))
+ .collect();
+ assert_eq!(listed, ["prune-sooner", "prune-middle", "prune-later"]);
+
+ h.clock().advance(SignedDuration::from_hours(3));
+ let list = ok(h.capabilities().published().await, "read the list");
+ assert_eq!(list.generated_at, h.clock().now());
+ let listed: Vec = list
+ .revoked
+ .into_iter()
+ .map(|token| token.jti)
+ .filter(|jti| jti.starts_with("prune-"))
+ .collect();
+ assert_eq!(
+ listed,
+ ["prune-middle", "prune-later"],
+ "an expired token is refused whether or not it is listed, so its entry carries nothing"
+ );
+}
+
+/// A refresh issues the successor, links and revokes the predecessor, and replays.
+pub async fn a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h: &dyn Harness) {
+ let case = "refresh";
+ let now = h.clock().now();
+ let old = record(h, case, "old", 6);
+ issue(h, old.clone()).await;
+ let new = record(h, case, "new", 6);
+
+ let outcome = h
+ .capabilities()
+ .refresh(&old.jti, new.clone(), now)
+ .await
+ .expect("refreshes");
+ assert_eq!(outcome, RefreshOutcome::Issued(new.clone()));
+ let stored_old = find(h, &old.jti).await.expect("recorded");
+ assert_eq!(stored_old.refreshed_to, Some(new.jti.clone()));
+ assert_eq!(stored_old.revoked_at, Some(now));
+ assert!(published(h).await.contains(&old.jti));
+ assert_eq!(find(h, &new.jti).await, Some(new.clone()));
+
+ // The replay: a different successor is offered and the first one is answered.
+ let another = record(h, case, "another", 6);
+ let replay = h
+ .capabilities()
+ .refresh(&old.jti, another.clone(), now)
+ .await
+ .expect("answers");
+ assert_eq!(replay, RefreshOutcome::AlreadyRefreshed(new));
+ assert_eq!(
+ find(h, &another.jti).await,
+ None,
+ "nothing was recorded for the replay"
+ );
+}
+
+/// A revoked predecessor cannot be refreshed, and an unknown one is unknown.
+pub async fn a_revoked_or_unknown_predecessor_is_not_refreshed(h: &dyn Harness) {
+ let case = "norefresh";
+ let now = h.clock().now();
+ let revoked = record(h, case, "revoked", 6);
+ issue(h, revoked.clone()).await;
+ h.capabilities()
+ .revoke_issued(&revoked.jti, now)
+ .await
+ .expect("revokes");
+ let successor = record(h, case, "successor", 6);
+ assert_eq!(
+ h.capabilities()
+ .refresh(&revoked.jti, successor.clone(), now)
+ .await
+ .expect("answers"),
+ RefreshOutcome::Revoked
+ );
+ assert_eq!(
+ h.capabilities()
+ .refresh("norefresh-never", successor.clone(), now)
+ .await
+ .expect("answers"),
+ RefreshOutcome::Unknown
+ );
+ assert_eq!(
+ find(h, &successor.jti).await,
+ None,
+ "a refusal records nothing"
+ );
+}
+
+// ===========================================================================================
+// Peers
+// ===========================================================================================
+
+/// An unknown peer is `None`; a pinned one reads back with its key and is not blocked.
+pub async fn a_pinned_peer_reads_back_and_an_unknown_one_is_none(h: &dyn Harness) {
+ let peer = PeerId::new("pin.peer.test");
+ let now = h.clock().now();
+ assert_eq!(ok(h.peers().read(&peer).await, "read a peer"), None);
+ ok(h.peers().pin(&peer, [7; 32], now).await, "pin a peer");
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned");
+ assert_eq!(record.server_id, peer);
+ assert_eq!(record.signing_key, Some([7; 32]));
+ assert_eq!(record.first_seen_at, now);
+ assert!(!record.is_blocked());
+ assert_eq!(record.note, None);
+
+ // A re-pin rotates the key and keeps the first-seen instant.
+ h.clock().advance(SignedDuration::from_hours(1));
+ ok(
+ h.peers().pin(&peer, [8; 32], h.clock().now()).await,
+ "re-pin a peer",
+ );
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned");
+ assert_eq!(record.signing_key, Some([8; 32]));
+ assert_eq!(record.first_seen_at, now);
+}
+
+/// A block on a never-pinned peer creates a keyless row; a second block changes nothing.
+pub async fn a_block_needs_no_key_and_is_idempotent(h: &dyn Harness) {
+ let peer = PeerId::new("block.peer.test");
+ let now = h.clock().now();
+ assert_eq!(
+ ok(
+ h.peers().block(&peer, now, Some("spam".to_owned())).await,
+ "block a peer"
+ ),
+ BlockOutcome::Blocked
+ );
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded");
+ assert!(record.is_blocked());
+ assert_eq!(record.blocked_at, Some(now));
+ assert_eq!(record.signing_key, None);
+ assert_eq!(record.note.as_deref(), Some("spam"));
+
+ let later = crate::store::deadline(now, SignedDuration::from_hours(1));
+ assert_eq!(
+ ok(
+ h.peers()
+ .block(&peer, later, Some("again".to_owned()))
+ .await,
+ "block a peer again"
+ ),
+ BlockOutcome::AlreadyBlocked
+ );
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded");
+ assert_eq!(
+ record.blocked_at,
+ Some(now),
+ "a retry does not move the instant"
+ );
+ assert_eq!(record.note.as_deref(), Some("spam"));
+}
+
+/// Pinning keeps a block, and unblocking keeps the key.
+pub async fn a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h: &dyn Harness) {
+ let peer = PeerId::new("keep.peer.test");
+ let now = h.clock().now();
+ ok(h.peers().block(&peer, now, None).await, "block a peer");
+ ok(
+ h.peers().pin(&peer, [9; 32], now).await,
+ "pin a blocked peer",
+ );
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded");
+ assert!(
+ record.is_blocked(),
+ "pinning a key is not an opinion about talking to its owner"
+ );
+ assert_eq!(record.signing_key, Some([9; 32]));
+
+ assert_eq!(
+ ok(h.peers().unblock(&peer).await, "unblock a peer"),
+ UnblockOutcome::Unblocked
+ );
+ let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded");
+ assert!(!record.is_blocked());
+ assert_eq!(record.signing_key, Some([9; 32]));
+ assert_eq!(
+ ok(h.peers().unblock(&peer).await, "unblock a peer again"),
+ UnblockOutcome::NotBlocked
+ );
+ assert_eq!(
+ ok(
+ h.peers()
+ .unblock(&PeerId::new("keep-never.peer.test"))
+ .await,
+ "unblock an unknown peer"
+ ),
+ UnblockOutcome::NotBlocked
+ );
+}
+
+/// Every case, against one harness.
+pub async fn run_all(h: &dyn Harness) {
+ an_issued_capability_reads_back_and_an_unknown_jti_is_none(h).await;
+ a_duplicate_jti_is_rejected_and_the_first_record_stands(h).await;
+ live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h).await;
+ revoking_an_issued_capability_publishes_it_once(h).await;
+ a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h).await;
+ the_list_and_the_record_are_one_fact(h).await;
+ a_list_side_revocation_keeps_the_records_expiry(h).await;
+ a_record_past_the_ceiling_is_refused(h).await;
+ a_successor_must_carry_the_predecessors_peer_album_and_member(h).await;
+ a_successor_may_not_move_the_grants_deadline(h).await;
+ the_widest_epoch_every_adapter_accepts_is_the_same_one(h).await;
+ a_grant_past_the_lifetime_ceiling_is_refused_by_the_store(h).await;
+ the_published_list_prunes_expired_entries_and_orders_by_expiry(h).await;
+ a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h).await;
+ a_revoked_or_unknown_predecessor_is_not_refreshed(h).await;
+ a_pinned_peer_reads_back_and_an_unknown_one_is_none(h).await;
+ a_block_needs_no_key_and_is_idempotent(h).await;
+ a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h).await;
+}
diff --git a/capsule-server/src/federation/memory.rs b/capsule-server/src/federation/memory.rs
new file mode 100644
index 00000000..3d10d37d
--- /dev/null
+++ b/capsule-server/src/federation/memory.rs
@@ -0,0 +1,383 @@
+//! The deterministic doubles: [`InMemoryCapabilities`] and [`InMemoryPeers`].
+//!
+//! One mutex each, which is what makes every multi-step operation — revoke-and-publish,
+//! refresh — one critical section, exactly as the Postgres adapter's transaction is.
+
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+use jiff::Timestamp;
+
+use super::PeerId;
+use super::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome};
+use super::store::{
+ CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome,
+};
+use crate::discovery::revocation::{
+ MAX_TOKEN_TTL, PublishedRevocations, RevocationError, RevocationList, RevokeFuture,
+ RevokedToken,
+};
+use crate::store::{Clock, StoreError, StoreFuture};
+
+/// Take the lock, recovering from a poisoned mutex.
+fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> {
+ mutex
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+}
+
+/// The deterministic capability store, and the revocation list it publishes.
+#[derive(Debug)]
+pub struct InMemoryCapabilities {
+ inner: Mutex,
+ clock: Arc,
+}
+
+#[derive(Debug, Default)]
+struct Inner {
+ /// Every capability issued here, by `jti`.
+ records: BTreeMap,
+ /// Every published revocation, by `jti`, with the token's own expiry for pruning.
+ published: BTreeMap,
+}
+
+impl Inner {
+ /// Revoke `jti` at `at` if a record backs it, and publish it either way.
+ ///
+ /// The one place both halves happen, so no path can do one without the other. The expiry
+ /// the entry is published under is the **record's** when there is one — a caller's shorter
+ /// `expires_at` would prune the entry while the token still verifies, which is a peer
+ /// honouring a revoked token — and an entry already published is never shortened.
+ fn revoke(&mut self, jti: &str, expires_at: Timestamp, at: Timestamp) {
+ let mut expires_at = expires_at;
+ if let Some(record) = self.records.get_mut(jti) {
+ if record.revoked_at.is_none() {
+ record.revoked_at = Some(at);
+ }
+ expires_at = record.expires_at;
+ }
+ let entry = self.published.entry(jti.to_owned()).or_insert(expires_at);
+ *entry = (*entry).max(expires_at);
+ }
+
+ /// Refuse a record whose lifetime the published list could not stay bounded under.
+ fn admissible(record: &CapabilityRecord) -> Result<(), StoreError> {
+ super::store::admissible(record)
+ }
+}
+
+impl InMemoryCapabilities {
+ /// An empty store reading `clock` for pruning and for `generated_at`.
+ pub fn new(clock: Arc) -> Self {
+ Self {
+ inner: Mutex::new(Inner::default()),
+ clock,
+ }
+ }
+}
+
+impl RevocationList for InMemoryCapabilities {
+ fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> {
+ Box::pin(async move {
+ let now = self.clock.now();
+ let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL);
+ if token.expires_at > ceiling {
+ tracing::warn!(
+ jti = %token.jti,
+ expires_at = %token.expires_at,
+ "a revocation was refused: its expiry is beyond the capability TTL ceiling"
+ );
+ return Err(RevocationError::BeyondTtlCeiling {
+ expires_at: token.expires_at,
+ ceiling: MAX_TOKEN_TTL,
+ }
+ .into());
+ }
+ let mut inner = lock(&self.inner);
+ inner.revoke(&token.jti, token.expires_at, now);
+ tracing::info!(
+ jti = %token.jti,
+ expires_at = %token.expires_at,
+ published = inner.published.len(),
+ "a federation capability token was revoked"
+ );
+ Ok(())
+ })
+ }
+
+ fn published(&self) -> StoreFuture<'_, PublishedRevocations> {
+ Box::pin(async move {
+ let now = self.clock.now();
+ let mut inner = lock(&self.inner);
+ // Pruned on read *and* retained pruned, so a list nobody fetches does not grow
+ // forever holding entries that already mean nothing.
+ inner.published.retain(|_, expires_at| *expires_at > now);
+ let mut revoked: Vec = inner
+ .published
+ .iter()
+ .map(|(jti, expires_at)| RevokedToken {
+ jti: jti.clone(),
+ expires_at: *expires_at,
+ })
+ .collect();
+ revoked.sort_by_key(|token| (token.expires_at, token.jti.clone()));
+ Ok(PublishedRevocations {
+ generated_at: now,
+ revoked,
+ })
+ })
+ }
+}
+
+impl CapabilityStore for InMemoryCapabilities {
+ fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> {
+ Box::pin(async move {
+ Inner::admissible(&record)?;
+ let mut inner = lock(&self.inner);
+ if inner.records.contains_key(&record.jti) {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!("a capability with jti {} is already recorded", record.jti),
+ });
+ }
+ tracing::info!(
+ jti = %record.jti,
+ peer = %record.peer_id,
+ album = %record.album_id,
+ member = %record.member,
+ scope = %record.scope,
+ granted_epoch = record.granted_epoch,
+ expires_at = %record.expires_at,
+ "a federation capability was recorded"
+ );
+ inner.records.insert(record.jti.clone(), record);
+ Ok(())
+ })
+ }
+
+ fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> {
+ Box::pin(async move { Ok(lock(&self.inner).records.get(jti).cloned()) })
+ }
+
+ fn live<'a>(
+ &'a self,
+ filter: &'a CapabilityFilter,
+ now: Timestamp,
+ ) -> StoreFuture<'a, Vec> {
+ Box::pin(async move {
+ Ok(lock(&self.inner)
+ .records
+ .values()
+ .filter(|record| record.is_live(now))
+ .filter(|record| match filter {
+ CapabilityFilter::Album(album) => &record.album_id == album,
+ CapabilityFilter::Peer(peer) => &record.peer_id == peer,
+ })
+ .cloned()
+ .collect())
+ })
+ }
+
+ fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> {
+ Box::pin(async move {
+ let mut inner = lock(&self.inner);
+ let Some(record) = inner.records.get(jti) else {
+ return Ok(RevokeOutcome::Unknown);
+ };
+ if record.revoked_at.is_some() {
+ return Ok(RevokeOutcome::AlreadyRevoked);
+ }
+ let expires_at = record.expires_at;
+ inner.revoke(jti, expires_at, at);
+ tracing::info!(%jti, published = inner.published.len(), "an issued capability was revoked");
+ Ok(RevokeOutcome::Revoked)
+ })
+ }
+
+ fn refresh<'a>(
+ &'a self,
+ predecessor: &'a str,
+ successor: CapabilityRecord,
+ at: Timestamp,
+ ) -> StoreFuture<'a, RefreshOutcome> {
+ Box::pin(async move {
+ Inner::admissible(&successor)?;
+ let mut inner = lock(&self.inner);
+ let Some(old) = inner.records.get(predecessor) else {
+ return Ok(RefreshOutcome::Unknown);
+ };
+ super::store::continues(predecessor, old, &successor)?;
+ if let Some(next) = &old.refreshed_to {
+ let existing =
+ inner
+ .records
+ .get(next)
+ .cloned()
+ .ok_or_else(|| StoreError::Corrupt {
+ store: "capabilities",
+ record: "CapabilityRecord",
+ detail: format!(
+ "{predecessor} was refreshed to {next}, which is not recorded"
+ ),
+ })?;
+ return Ok(RefreshOutcome::AlreadyRefreshed(existing));
+ }
+ if old.revoked_at.is_some() {
+ return Ok(RefreshOutcome::Revoked);
+ }
+ if inner.records.contains_key(&successor.jti) {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "a capability with jti {} is already recorded",
+ successor.jti
+ ),
+ });
+ }
+ let old_expires_at = old.expires_at;
+ inner.revoke(predecessor, old_expires_at, at);
+ if let Some(old) = inner.records.get_mut(predecessor) {
+ old.refreshed_to = Some(successor.jti.clone());
+ }
+ tracing::info!(
+ predecessor = %predecessor,
+ successor = %successor.jti,
+ peer = %successor.peer_id,
+ "a federation capability was refreshed"
+ );
+ inner
+ .records
+ .insert(successor.jti.clone(), successor.clone());
+ Ok(RefreshOutcome::Issued(successor))
+ })
+ }
+}
+
+/// The deterministic peer store.
+#[derive(Debug, Default)]
+pub struct InMemoryPeers {
+ peers: Mutex>,
+}
+
+impl InMemoryPeers {
+ /// An empty store: no peer pinned, no peer blocked.
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+impl PeerStore for InMemoryPeers {
+ fn pin<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ signing_key: [u8; 32],
+ at: Timestamp,
+ ) -> StoreFuture<'a, ()> {
+ Box::pin(async move {
+ let mut peers = lock(&self.peers);
+ match peers.get_mut(peer) {
+ Some(record) => record.signing_key = Some(signing_key),
+ None => {
+ peers.insert(
+ peer.clone(),
+ PeerRecord {
+ server_id: peer.clone(),
+ signing_key: Some(signing_key),
+ first_seen_at: at,
+ blocked_at: None,
+ note: None,
+ },
+ );
+ }
+ }
+ tracing::info!(%peer, "a peer's signing key was pinned");
+ Ok(())
+ })
+ }
+
+ fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option> {
+ Box::pin(async move { Ok(lock(&self.peers).get(peer).cloned()) })
+ }
+
+ fn block<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ at: Timestamp,
+ note: Option,
+ ) -> StoreFuture<'a, BlockOutcome> {
+ Box::pin(async move {
+ let mut peers = lock(&self.peers);
+ let record = peers.entry(peer.clone()).or_insert_with(|| PeerRecord {
+ server_id: peer.clone(),
+ signing_key: None,
+ first_seen_at: at,
+ blocked_at: None,
+ note: None,
+ });
+ if record.blocked_at.is_some() {
+ return Ok(BlockOutcome::AlreadyBlocked);
+ }
+ record.blocked_at = Some(at);
+ record.note = note;
+ tracing::warn!(%peer, "a peer server was blocked");
+ Ok(BlockOutcome::Blocked)
+ })
+ }
+
+ fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> {
+ Box::pin(async move {
+ let mut peers = lock(&self.peers);
+ match peers.get_mut(peer) {
+ Some(record) if record.blocked_at.is_some() => {
+ record.blocked_at = None;
+ record.note = None;
+ tracing::info!(%peer, "a peer server was unblocked");
+ Ok(UnblockOutcome::Unblocked)
+ }
+ _ => Ok(UnblockOutcome::NotBlocked),
+ }
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use super::super::conformance::{self, Harness};
+ use super::{InMemoryCapabilities, InMemoryPeers};
+ use crate::federation::{CapabilityStore, PeerStore};
+ use crate::store::memory::ManualClock;
+
+ #[derive(Debug)]
+ struct MemoryHarness {
+ clock: Arc,
+ capabilities: InMemoryCapabilities,
+ peers: InMemoryPeers,
+ }
+
+ impl Harness for MemoryHarness {
+ fn capabilities(&self) -> &dyn CapabilityStore {
+ &self.capabilities
+ }
+
+ fn peers(&self) -> &dyn PeerStore {
+ &self.peers
+ }
+
+ fn clock(&self) -> &ManualClock {
+ &self.clock
+ }
+ }
+
+ #[tokio::test]
+ async fn the_in_memory_stores_conform() {
+ let clock = Arc::new(ManualClock::default());
+ let harness = MemoryHarness {
+ capabilities: InMemoryCapabilities::new(clock.clone()),
+ peers: InMemoryPeers::new(),
+ clock,
+ };
+ conformance::run_all(&harness).await;
+ }
+}
diff --git a/capsule-server/src/federation/mod.rs b/capsule-server/src/federation/mod.rs
new file mode 100644
index 00000000..93516a14
--- /dev/null
+++ b/capsule-server/src/federation/mod.rs
@@ -0,0 +1,587 @@
+//! Server-to-server federation (`S-E2`, `S-E5`, `S-C49`): the capability that gates which peer
+//! may pull which album, the store it is issued from and revoked into, and the peers this
+//! server knows.
+//!
+//! # No new data protocol
+//!
+//! design/federation.md is explicit: a peer fetches *exactly* the primitives a client fetches —
+//! `GET /v1/sync?album_id=…` and `GET /v1/blob/{hash}` — and what federation adds is the
+//! **capability token** those two reads accept in the `Authorization: Bearer` slot, plus the
+//! per-peer budget behind it. So there is no `/v1/federation/pull` here and never will be: the
+//! pull path is the read path, and this module is the credential, the lifecycle around it
+//! (mint, refresh, revoke) and the moderation halves that hang on it (signed report intake, the
+//! server-level blocklist).
+//!
+//! # What lives where
+//!
+//! - [`capability`] — the EdDSA-JWT and the codec that mints and reads it, over the **same**
+//! Ed25519 key the session tokens are signed with, which is the key `server-info` publishes.
+//! - [`store`] — [`CapabilityStore`], the record of every capability this server issued. It
+//! **is** the revocation list: the adapters implement
+//! [`RevocationList`](crate::discovery::revocation::RevocationList) and
+//! `/.well-known/capsule/revoked-jti` reads them, so "is this `jti` revoked" has one answer.
+//! - [`peers`] — [`PeerStore`], the peers whose signing keys an operator has pinned and the
+//! blocklist, which is a column on the same row.
+//! - [`memory`] — the deterministic doubles; [`conformance`] — the suite every adapter passes.
+//!
+//! # A peer is not an account
+//!
+//! A [`PeerId`] is a server's canonical origin (`other.tld`), never a user id, and the types
+//! keep them apart everywhere the two could be confused: the sync cursor's scope byte, the
+//! blob authority's principal, the counter key. Nothing here holds a user list, and nothing
+//! published here names a user — the registry's no-enumeration rule holds at this layer too.
+
+use std::fmt;
+use std::sync::Arc;
+
+pub mod capability;
+pub mod conformance;
+pub mod memory;
+pub mod peers;
+pub mod postgres;
+pub mod report;
+pub mod scheme;
+pub mod store;
+
+pub use self::capability::{
+ ALBUM_URN_PREFIX, CapabilityCodec, CapabilityError, CapabilityGrant, MintError, MintRequest,
+ Minted, Scope, album_from_urn, album_urn,
+};
+pub use self::memory::{InMemoryCapabilities, InMemoryPeers};
+pub use self::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome};
+pub use self::report::{ReportClaim, ReportError, verify_signed_report};
+pub use self::scheme::{Principal, ReadBearer, VerifiedCapability};
+pub use self::store::{
+ CapabilityFilter, CapabilityRecord, CapabilityStore, MAX_GRANT_LIFETIME, RefreshOutcome,
+ RevokeOutcome,
+};
+use crate::counter::{CounterContext, CounterKey, budgets};
+use crate::store::Clock;
+
+/// A peer server's identity: its canonical origin, as its own `server-info` publishes it.
+///
+/// Its own type rather than a `UserId` or a bare string so a peer can never be handed to a port
+/// that expects an account, and so the log field that names one reads as what it is.
+///
+/// Canonical: a host name is case-insensitive and a trailing dot names the same host, so both
+/// are folded at construction. A block on `other.test` therefore covers a capability minted for
+/// `Other.Test.`, and two records can never name one peer twice.
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct PeerId(String);
+
+impl PeerId {
+ /// Wraps an origin, folded to its canonical form.
+ pub fn new(value: impl Into) -> Self {
+ let value: String = value.into();
+ Self(value.trim().trim_end_matches('.').to_ascii_lowercase())
+ }
+
+ /// The origin as text.
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Display for PeerId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl fmt::Debug for PeerId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "PeerId({:?})", self.0)
+ }
+}
+
+/// What the federation module is assembled from.
+///
+/// Named rather than positional, for the reason [`crate::app::Modules`] is: a constructor that
+/// lengthens with every collaborator is one that is eventually got wrong positionally.
+#[derive(Debug)]
+pub struct FederationCollaborators {
+ /// Mints and reads capability tokens.
+ pub codec: Arc,
+ /// Every capability this server issued, and the revocation list it publishes.
+ pub capabilities: Arc,
+ /// The peers this server has pinned or blocked.
+ pub peers: Arc,
+ /// The clock every record and every deadline is stamped from.
+ pub clock: Arc,
+ /// Where peers reach this server, when it federates at all.
+ ///
+ /// `None` is a deployment that does not federate: the lifecycle writes refuse with
+ /// `error.federation.not_configured`, while a capability minted earlier still verifies —
+ /// a token is not un-minted by a configuration change.
+ pub federation_url: Option,
+}
+
+/// The federation module's collaborators.
+#[derive(Debug, Clone)]
+pub struct FederationContext {
+ codec: Arc,
+ capabilities: Arc,
+ peers: Arc,
+ clock: Arc,
+ federation_url: Option,
+}
+
+impl FederationContext {
+ /// Assembles the module.
+ pub fn new(collaborators: FederationCollaborators) -> Self {
+ let FederationCollaborators {
+ codec,
+ capabilities,
+ peers,
+ clock,
+ federation_url,
+ } = collaborators;
+ Self {
+ codec,
+ capabilities,
+ peers,
+ clock,
+ federation_url,
+ }
+ }
+
+ /// The codec capabilities are minted with and read by.
+ pub fn codec(&self) -> &CapabilityCodec {
+ &self.codec
+ }
+
+ /// Every capability this server issued.
+ pub fn capabilities(&self) -> &dyn CapabilityStore {
+ self.capabilities.as_ref()
+ }
+
+ /// The peers this server knows.
+ pub fn peers(&self) -> &dyn PeerStore {
+ self.peers.as_ref()
+ }
+
+ /// The clock.
+ pub fn clock(&self) -> &dyn Clock {
+ self.clock.as_ref()
+ }
+
+ /// Where peers reach this server, if it federates.
+ pub fn federation_url(&self) -> Option<&str> {
+ self.federation_url.as_deref()
+ }
+
+ /// Whether this deployment federates at all.
+ ///
+ /// The gate on every lifecycle write. Reads are not gated on it: a capability that was
+ /// minted while federation was on still verifies, and refusing it would cut a peer off
+ /// without a revocation anybody can see.
+ pub fn is_configured(&self) -> bool {
+ self.federation_url.is_some()
+ }
+}
+
+/// Why an admitted capability is refused by a route.
+///
+/// Every variant is a *coded* answer the route renders — the authenticator has no seam for one
+/// (see [`scheme`]). The order [`admit`] decides them in is the order a client should learn them:
+/// a revoked grant is refused before anything is charged to the peer's budget, and a blocked
+/// peer is refused before it is either.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Refusal {
+ /// The capability's `jti` is revoked. `403 error.federation.capability_revoked`.
+ Revoked,
+ /// The peer is on this server's blocklist. `403 error.moderation.server_blocked`.
+ PeerBlocked,
+ /// The peer's events-per-hour budget is spent. `429 error.federation.rate_budget_exceeded`.
+ RateLimited {
+ /// When the window resets.
+ retry_after: jiff::Timestamp,
+ },
+ /// A collaborator could not answer, so nothing was decided. `500 error.federation.unavailable`.
+ ///
+ /// Never an admission: a limiter that fails open is a limiter an attacker turns off by
+ /// loading the counter store.
+ Unavailable,
+}
+
+/// What a capability is being presented for.
+///
+/// Only the liveness rule differs, and it differs for one reason: a predecessor that was revoked
+/// **because it was refreshed** is exactly what a replayed refresh looks like, and refusing it
+/// would make the idempotency the contract promises unreachable. Every other revocation refuses
+/// both.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Presentation {
+ /// A read: a sync page or a blob fetch. The grant must be live.
+ Read,
+ /// A refresh, presenting the predecessor. A predecessor already linked to a successor is
+ /// admitted so the replay can be answered with that successor — whose own liveness the
+ /// route then asks about, because a block cascades over it too.
+ Refresh,
+}
+
+/// Decide whether `capability` may be presented at all, and charge the peer's budget if so.
+///
+/// The three questions every federated request asks before it looks at what is being asked for:
+/// is the grant still live, is the peer still welcome, and is the peer within budget. Asked
+/// here once so the sync, blob and refresh routes cannot ask them in different orders.
+///
+/// # Errors
+///
+/// Returns the [`Refusal`] the route renders.
+pub async fn admit(
+ federation: &FederationContext,
+ counters: &CounterContext,
+ capability: &VerifiedCapability,
+ presentation: Presentation,
+) -> Result<(), Refusal> {
+ let peer = &capability.record.peer_id;
+ let live = match presentation {
+ Presentation::Read => capability.record.is_live(federation.clock().now()),
+ Presentation::Refresh => {
+ capability.record.refreshed_to.is_some()
+ || capability.record.is_live(federation.clock().now())
+ }
+ };
+ if !live {
+ tracing::info!(
+ %peer,
+ jti = %capability.record.jti,
+ "a revoked capability was presented"
+ );
+ return Err(Refusal::Revoked);
+ }
+
+ let blocked = federation
+ .peers()
+ .read(peer)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %peer, "the peer store could not answer an admission");
+ Refusal::Unavailable
+ })?
+ .is_some_and(|record| record.is_blocked());
+ if blocked {
+ tracing::info!(%peer, "a blocked peer presented a capability");
+ return Err(Refusal::PeerBlocked);
+ }
+
+ let key = CounterKey::PeerRequests(peer.as_str().to_owned());
+ match counters
+ .hit(&key, budgets::PEER_REQUESTS)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %peer, "the per-peer counter could not be reached");
+ Refusal::Unavailable
+ })? {
+ crate::counter::Verdict::Admitted { .. } => Ok(()),
+ crate::counter::Verdict::Limited { retry_after } => {
+ tracing::info!(%peer, %retry_after, "a peer's events budget is spent");
+ Err(Refusal::RateLimited { retry_after })
+ }
+ }
+}
+
+/// Revoke every live capability over `album` whose member is not on the roster `listed` names.
+///
+/// The one automatic revocation write (`S-E5`). A roster is the album owner's statement of who
+/// may read it; a capability minted for a member the owner has just removed is a grant the
+/// owner has withdrawn, and the peer holding it learns so from
+/// `/.well-known/capsule/revoked-jti` rather than from a refusal it cannot explain.
+///
+/// **An epoch bump alone revokes nothing.** A member still on the roster still holds their keys
+/// and the server has nothing to cut; the grant's own epoch binding, re-checked at every
+/// presentation, is what handles a member who *left and came back*.
+///
+/// **A takedown revokes nothing either.** A moderation hold is a per-asset serving constraint
+/// answering `410`, not a statement about who may read the album (design/moderation.md).
+///
+/// # Errors
+///
+/// Returns the store error. The caller — the roster route — logs it and still answers the
+/// roster's own success: the roster is the fact, and a capability whose member has gone is
+/// refused at its next presentation anyway, because membership is re-checked there. The gap is
+/// bounded by the token's TTL and closes at the next revocation write.
+pub async fn on_roster_applied(
+ federation: &FederationContext,
+ album: &crate::store::AlbumId,
+ listed: &[crate::store::UserId],
+) -> Result {
+ let now = federation.clock().now();
+ let live = federation
+ .capabilities()
+ .live(&CapabilityFilter::Album(album.clone()), now)
+ .await?;
+ let mut revoked = 0;
+ for record in live {
+ if listed.contains(&record.member) {
+ continue;
+ }
+ federation
+ .capabilities()
+ .revoke_issued(&record.jti, now)
+ .await?;
+ revoked += 1;
+ tracing::info!(
+ %album,
+ peer = %record.peer_id,
+ member = %record.member,
+ jti = %record.jti,
+ "a roster change revoked a federation capability"
+ );
+ }
+ if revoked > 0 {
+ tracing::info!(%album, revoked, "a roster change cut federated grants");
+ }
+ Ok(revoked)
+}
+
+/// Revoke every live capability held by `peer`, because it has just been blocked.
+///
+/// A block is already consulted at every federation boundary, so this cuts nothing the
+/// blocklist would have let through. What it adds is **publication**: the peer's `jti`s go onto
+/// `/.well-known/capsule/revoked-jti`, so a blocked peer learns its grants are gone from the
+/// record every peer polls rather than only from a refusal, and an operator who later unblocks
+/// the peer does not silently restore access the block was meant to end.
+///
+/// # It is owed a caller
+///
+/// **Nothing in production calls this.** A block is written by an operator command, and there is
+/// none: `boot::assemble` refuses the durable backend until #403 lands its adapters, so a
+/// command that blocked a peer could only run against `serve --memory` and would forget the
+/// moment it exited. The command is owed with #476, and this function is what it will call.
+///
+/// Said here the way [`crate::boot`] names what #403 owes, so a reader does not take the cascade
+/// for something that happens automatically. What *is* automatic is the refusal: `blocked_at` is
+/// consulted at mint, at every presentation, at refresh and at report intake, so a block
+/// enforces itself the moment it is written. This adds only **publication** — the peer's `jti`s
+/// reach the list every peer polls — which is why it is a separate step at all.
+///
+/// # Errors
+///
+/// Returns the store error. The caller — an operator command — reports it; the block itself has
+/// already been written, and a block whose cascade failed still refuses every request.
+pub async fn on_peer_blocked(
+ federation: &FederationContext,
+ peer: &PeerId,
+) -> Result {
+ let now = federation.clock().now();
+ let live = federation
+ .capabilities()
+ .live(&CapabilityFilter::Peer(peer.clone()), now)
+ .await?;
+ let mut revoked = 0;
+ for record in live {
+ federation
+ .capabilities()
+ .revoke_issued(&record.jti, now)
+ .await?;
+ revoked += 1;
+ }
+ tracing::info!(%peer, revoked, "a peer was blocked and its grants were cut");
+ Ok(revoked)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use jiff::{SignedDuration, Timestamp};
+
+ use super::*;
+ use crate::counter::{Budget, CounterStore, InMemoryCounters, Verdict};
+ use crate::store::memory::ManualClock;
+ use crate::store::{AlbumId, StoreError, StoreFuture, UserId};
+
+ #[test]
+ fn a_peer_id_is_canonical() {
+ assert_eq!(PeerId::new("Other.Test."), PeerId::new("other.test"));
+ assert_eq!(PeerId::new(" other.test ").as_str(), "other.test");
+ assert_ne!(PeerId::new("other.test"), PeerId::new("another.test"));
+ }
+
+ /// A counter that cannot be reached.
+ #[derive(Debug)]
+ struct DownCounters;
+
+ fn down() -> StoreFuture<'static, T> {
+ Box::pin(async {
+ Err(StoreError::Unavailable {
+ store: "counters",
+ detail: "down".to_owned(),
+ })
+ })
+ }
+
+ impl CounterStore for DownCounters {
+ fn hit<'a>(
+ &'a self,
+ _: &'a CounterKey,
+ _: Budget,
+ _: Timestamp,
+ ) -> StoreFuture<'a, Verdict> {
+ down()
+ }
+
+ fn peek<'a>(
+ &'a self,
+ _: &'a CounterKey,
+ _: Budget,
+ _: Timestamp,
+ ) -> StoreFuture<'a, Verdict> {
+ down()
+ }
+
+ fn reset<'a>(&'a self, _: &'a CounterKey) -> StoreFuture<'a, ()> {
+ down()
+ }
+ }
+
+ /// A peer store that cannot be reached.
+ #[derive(Debug)]
+ struct DownPeers;
+
+ impl PeerStore for DownPeers {
+ fn pin<'a>(&'a self, _: &'a PeerId, _: [u8; 32], _: Timestamp) -> StoreFuture<'a, ()> {
+ down()
+ }
+
+ fn read<'a>(&'a self, _: &'a PeerId) -> StoreFuture<'a, Option> {
+ down()
+ }
+
+ fn block<'a>(
+ &'a self,
+ _: &'a PeerId,
+ _: Timestamp,
+ _: Option,
+ ) -> StoreFuture<'a, BlockOutcome> {
+ down()
+ }
+
+ fn unblock<'a>(&'a self, _: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> {
+ down()
+ }
+ }
+
+ fn context(peers: Arc, clock: Arc) -> FederationContext {
+ let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
+ .expect("a key generates");
+ FederationContext::new(FederationCollaborators {
+ codec: Arc::new(
+ CapabilityCodec::from_pkcs8(der.as_ref(), "home.test", clock.clone())
+ .expect("parses"),
+ ),
+ capabilities: Arc::new(InMemoryCapabilities::new(clock.clone())),
+ peers,
+ clock,
+ federation_url: None,
+ })
+ }
+
+ fn verified(clock: &ManualClock) -> VerifiedCapability {
+ let now = clock.now();
+ let record = CapabilityRecord {
+ jti: "01937b7c-0000-7000-8000-0000000000aa".to_owned(),
+ album_id: AlbumId::new("album"),
+ peer_id: PeerId::new("other.test"),
+ member: UserId::new("bob"),
+ scope: Scope::Read,
+ granted_epoch: 1,
+ min_protocol_version: "2026-06-01".to_owned(),
+ issued_at: now,
+ expires_at: crate::store::deadline(now, SignedDuration::from_hours(1)),
+ not_after: crate::store::deadline(now, SignedDuration::from_hours(1)),
+ revoked_at: None,
+ refreshed_to: None,
+ };
+ VerifiedCapability {
+ grant: record.grant(),
+ record,
+ }
+ }
+
+ #[tokio::test]
+ async fn a_predecessor_revoked_by_its_own_refresh_is_admitted_only_to_be_refreshed() {
+ // What makes a replayed refresh answerable: the predecessor is revoked the moment its
+ // successor is issued, and a rule that refused every revoked grant would make the
+ // idempotency the contract promises unreachable. A read is still refused.
+ let clock = Arc::new(ManualClock::default());
+ let mut capability = verified(&clock);
+ capability.record.revoked_at = Some(clock.now());
+ capability.record.refreshed_to = Some("01937b7c-0000-7000-8000-0000000000bb".to_owned());
+ let federation = context(Arc::new(InMemoryPeers::new()), clock.clone());
+ let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock);
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Refresh).await,
+ Ok(())
+ );
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Read).await,
+ Err(Refusal::Revoked)
+ );
+
+ // A grant revoked without a successor is refused on both.
+ capability.record.refreshed_to = None;
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Refresh).await,
+ Err(Refusal::Revoked)
+ );
+ }
+
+ #[tokio::test]
+ async fn a_store_that_cannot_answer_an_admission_is_an_outage_never_an_admission() {
+ // The fail-closed rule at the seam every federated read passes through: a peer store
+ // or a counter that cannot be reached decides nothing, and "nothing" is a refusal.
+ let clock = Arc::new(ManualClock::default());
+ let capability = verified(&clock);
+
+ let federation = context(Arc::new(DownPeers), clock.clone());
+ let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock.clone());
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Read).await,
+ Err(Refusal::Unavailable)
+ );
+
+ let federation = context(Arc::new(InMemoryPeers::new()), clock.clone());
+ let counters = CounterContext::new(Arc::new(DownCounters), clock.clone());
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Read).await,
+ Err(Refusal::Unavailable)
+ );
+
+ let counters = CounterContext::new(Arc::new(InMemoryCounters::new()), clock);
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Read).await,
+ Ok(())
+ );
+ }
+
+ #[tokio::test]
+ async fn a_revoked_capability_is_refused_before_the_peer_is_charged() {
+ let clock = Arc::new(ManualClock::default());
+ let mut capability = verified(&clock);
+ capability.record.revoked_at = Some(clock.now());
+ let federation = context(Arc::new(InMemoryPeers::new()), clock.clone());
+ let store = Arc::new(InMemoryCounters::new());
+ let counters = CounterContext::new(store.clone(), clock.clone());
+ assert_eq!(
+ admit(&federation, &counters, &capability, Presentation::Read).await,
+ Err(Refusal::Revoked)
+ );
+ assert_eq!(
+ store
+ .peek(
+ &CounterKey::PeerRequests("other.test".to_owned()),
+ budgets::PEER_REQUESTS,
+ clock.now(),
+ )
+ .await
+ .expect("answers"),
+ Verdict::Admitted {
+ remaining: budgets::PEER_REQUESTS.limit
+ },
+ "a revoked grant costs the peer nothing"
+ );
+ }
+}
diff --git a/capsule-server/src/federation/peers.rs b/capsule-server/src/federation/peers.rs
new file mode 100644
index 00000000..32b0fef4
--- /dev/null
+++ b/capsule-server/src/federation/peers.rs
@@ -0,0 +1,99 @@
+//! [`PeerStore`] — the peer servers this one knows: their pinned signing keys, and the
+//! server-level blocklist.
+//!
+//! # Operator-pinned, not fetched
+//!
+//! design/federation.md describes peers caching each other's keys TOFU-style with a perspective
+//! check on rotation. This server has no outbound HTTP client at all — nothing in
+//! `capsule-server` reaches out to another server — so in v1 a peer's key arrives the way a
+//! deployment's own key does: an operator puts it there. That is stated rather than worked
+//! around because the alternative, fetching `server-info` at report intake, would make the
+//! first federated report from a new peer the thing that decides whether it is trusted.
+//!
+//! **Minting needs no peer key.** A capability is signed with this server's own key, and the
+//! peer verifies it against `server-info`. The pinned key serves exactly one thing: verifying
+//! the signature on a federated moderation report.
+//!
+//! # The blocklist is a column
+//!
+//! design/moderation.md's server-level blocklist "operates at the federation capability layer",
+//! and here it is a row's `blocked_at`. Blocking a peer nobody has pinned is legitimate — an
+//! operator blocks a server they never wanted to hear from — so a block creates the row without
+//! a key. Every federation boundary consults it: mint, presentation, refresh, report intake.
+
+use std::fmt;
+
+use jiff::Timestamp;
+
+use super::PeerId;
+use crate::store::StoreFuture;
+
+/// What this server knows about one peer.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PeerRecord {
+ /// The peer's canonical origin.
+ pub server_id: PeerId,
+ /// Its operational Ed25519 public key, if an operator has pinned one.
+ pub signing_key: Option<[u8; 32]>,
+ /// When this server first recorded the peer, by a pin or by a block.
+ pub first_seen_at: Timestamp,
+ /// When it was blocked, while it is.
+ pub blocked_at: Option,
+ /// The operator's note on the block, if they left one.
+ pub note: Option,
+}
+
+impl PeerRecord {
+ /// Whether federated requests from this peer are refused.
+ pub fn is_blocked(&self) -> bool {
+ self.blocked_at.is_some()
+ }
+}
+
+/// What blocking did.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BlockOutcome {
+ /// The peer is now blocked.
+ Blocked,
+ /// It already was. A retry is not a new fact.
+ AlreadyBlocked,
+}
+
+/// What unblocking did.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum UnblockOutcome {
+ /// The peer is no longer blocked.
+ Unblocked,
+ /// It was not blocked, or was never recorded.
+ NotBlocked,
+}
+
+/// Where peers are kept.
+pub trait PeerStore: fmt::Debug + Send + Sync {
+ /// Pin `signing_key` as `peer`'s operational key, at `at`.
+ ///
+ /// Replaces a key already pinned: rotation is an operator act here. A block already on
+ /// the row is kept — pinning a key is not an opinion about whether to talk to its owner.
+ fn pin<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ signing_key: [u8; 32],
+ at: Timestamp,
+ ) -> StoreFuture<'a, ()>;
+
+ /// What is known about `peer`, if anything.
+ fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option>;
+
+ /// Refuse federated requests from `peer` from `at`, with `note` for the operator's record.
+ ///
+ /// Creates the row if the peer was never pinned. Idempotent.
+ fn block<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ at: Timestamp,
+ note: Option,
+ ) -> StoreFuture<'a, BlockOutcome>;
+
+ /// Lift a block on `peer`. The pinned key, if any, is kept.
+ fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome>;
+}
diff --git a/capsule-server/src/federation/postgres.rs b/capsule-server/src/federation/postgres.rs
new file mode 100644
index 00000000..71f76dd8
--- /dev/null
+++ b/capsule-server/src/federation/postgres.rs
@@ -0,0 +1,657 @@
+//! [`PostgresCapabilities`] and [`PostgresPeers`] — the durable federation stores (`S-E2`).
+//!
+//! # Two tables behind one port, written in one transaction
+//!
+//! `federation_capabilities` holds every capability this server minted; `federation_revoked_jti`
+//! is the list `/.well-known/capsule/revoked-jti` publishes. They are one port because "is this
+//! `jti` revoked" must have one answer: revoking an issued capability sets its `revoked_at`
+//! **and** publishes its `jti`, and doing one without the other is what a transaction is for.
+//!
+//! They are two *tables* because a revocation is also accepted for a `jti` no record backs — an
+//! operator cutting a token named in a peer's report, or one that predates this store — so the
+//! list cannot be a view over the records.
+//!
+//! # Why the lock is an advisory lock
+//!
+//! `revoke_issued` and `refresh` each read a record, decide, and write two tables. The row they
+//! would `SELECT … FOR UPDATE` exists for `revoke_issued` but not for the successor `refresh`
+//! inserts, and two concurrent refreshes of one predecessor must not both issue. A
+//! transaction-scoped advisory lock keyed on the predecessor's `jti` serialises both, is
+//! released by the commit or the rollback, and needs no row to exist — the same choice
+//! [`PostgresMembership`](crate::membership::postgres::PostgresMembership) makes for the same
+//! reason.
+//!
+//! # Pruning on read
+//!
+//! `published` deletes every entry past its expiry before it reads, exactly as the in-memory
+//! adapter retains, so a list nobody fetches does not grow forever holding entries that already
+//! mean nothing. The delete is the read's own statement rather than a background job: the record
+//! is fetched often and pruned rarely, and a sweeper would be a second thing to run.
+
+use jiff::Timestamp;
+use sea_orm::{
+ ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, Statement,
+ TransactionTrait, Value,
+};
+
+use super::PeerId;
+use super::capability::Scope;
+use super::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome};
+use super::store::{
+ CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome,
+};
+use crate::discovery::revocation::{
+ MAX_TOKEN_TTL, PublishedRevocations, RevocationError, RevocationList, RevokeFuture,
+ RevokedToken,
+};
+use crate::federation::store::admissible;
+use crate::postgres::error::Port;
+use crate::postgres::time::{from_micros, to_micros};
+use crate::store::{AlbumId, Clock, StoreError, StoreFuture, UserId};
+
+/// Which port is speaking, for every error the capability adapter raises.
+const CAPABILITIES: Port = Port {
+ store: "capabilities",
+ record: "CapabilityRecord",
+};
+
+/// Which port is speaking, for every error the peer adapter raises.
+const PEERS: Port = Port {
+ store: "peers",
+ record: "PeerRecord",
+};
+
+/// The columns every capability read selects, in the order [`record_from`] decodes them.
+const CAPABILITY_COLUMNS: &str = "jti, album_id, peer_id, member_id, scope, granted_epoch, \
+ min_protocol_version, issued_at, expires_at, not_after, \
+ revoked_at, refreshed_to";
+
+/// An epoch as the column holds it.
+fn epoch_to_column(value: u64) -> Result {
+ i64::try_from(value).map_err(|_| StoreError::Rejected {
+ store: CAPABILITIES.store,
+ detail: format!("{value} is past what a BIGINT column holds"),
+ })
+}
+
+/// An instant as the port speaks it.
+fn instant(port: Port, micros: i64) -> Result {
+ from_micros(micros)
+ .ok_or_else(|| port.undecodable(format!("{micros}µs is not a representable instant")))
+}
+
+/// Decode one row of [`CAPABILITY_COLUMNS`].
+fn record_from(row: &sea_orm::QueryResult) -> Result {
+ let failed = CAPABILITIES.failing("reading a capability");
+ let jti: String = row.try_get("", "jti").map_err(&failed)?;
+ let album_id: String = row.try_get("", "album_id").map_err(&failed)?;
+ let peer_id: String = row.try_get("", "peer_id").map_err(&failed)?;
+ let member_id: String = row.try_get("", "member_id").map_err(&failed)?;
+ let scope: String = row.try_get("", "scope").map_err(&failed)?;
+ let granted_epoch: i64 = row.try_get("", "granted_epoch").map_err(&failed)?;
+ let min_protocol_version: String = row.try_get("", "min_protocol_version").map_err(&failed)?;
+ let issued_at: i64 = row.try_get("", "issued_at").map_err(&failed)?;
+ let expires_at: i64 = row.try_get("", "expires_at").map_err(&failed)?;
+ let not_after: i64 = row.try_get("", "not_after").map_err(&failed)?;
+ let revoked_at: Option = row.try_get("", "revoked_at").map_err(&failed)?;
+ let refreshed_to: Option = row.try_get("", "refreshed_to").map_err(&failed)?;
+ Ok(CapabilityRecord {
+ jti,
+ album_id: AlbumId::new(album_id),
+ peer_id: PeerId::new(peer_id),
+ member: UserId::new(member_id),
+ scope: Scope::from_token(&scope)
+ .ok_or_else(|| CAPABILITIES.undecodable(format!("`{scope}` is not a scope")))?,
+ granted_epoch: u64::try_from(granted_epoch)
+ .map_err(|_| CAPABILITIES.undecodable(format!("{granted_epoch} is not an epoch")))?,
+ min_protocol_version,
+ issued_at: instant(CAPABILITIES, issued_at)?,
+ expires_at: instant(CAPABILITIES, expires_at)?,
+ not_after: instant(CAPABILITIES, not_after)?,
+ revoked_at: revoked_at
+ .map(|micros| instant(CAPABILITIES, micros))
+ .transpose()?,
+ refreshed_to,
+ })
+}
+
+/// Begin a transaction, or say why not.
+async fn begin(
+ connection: &DatabaseConnection,
+ port: Port,
+) -> Result {
+ connection
+ .begin()
+ .await
+ .map_err(port.failing("opening a transaction"))
+}
+
+/// Commit, or say why not.
+async fn commit(transaction: DatabaseTransaction, port: Port) -> Result<(), StoreError> {
+ transaction
+ .commit()
+ .await
+ .map_err(port.failing("committing a transaction"))
+}
+
+/// Take the transaction-scoped lock that serialises everything keyed on `key`.
+async fn lock(transaction: &DatabaseTransaction, key: &str, port: Port) -> Result<(), StoreError> {
+ transaction
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "SELECT pg_advisory_xact_lock(hashtext($1))",
+ [Value::from(key.to_owned())],
+ ))
+ .await
+ .map(|_| ())
+ .map_err(port.failing("taking the federation lock"))
+}
+
+/// Read one capability under `connection`.
+async fn record_of(
+ connection: &C,
+ jti: &str,
+) -> Result, StoreError> {
+ let row = connection
+ .query_one(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ format!("SELECT {CAPABILITY_COLUMNS} FROM federation_capabilities WHERE jti = $1"),
+ [Value::from(jti.to_owned())],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("reading a capability"))?;
+ row.as_ref().map(record_from).transpose()
+}
+
+/// Insert `record`, refusing a `jti` already recorded.
+async fn insert(
+ connection: &C,
+ record: &CapabilityRecord,
+) -> Result<(), StoreError> {
+ let inserted = connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "INSERT INTO federation_capabilities \
+ (jti, album_id, peer_id, member_id, scope, granted_epoch, min_protocol_version, \
+ issued_at, expires_at, not_after, revoked_at, refreshed_to) \
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NULL, NULL) \
+ ON CONFLICT (jti) DO NOTHING",
+ [
+ Value::from(record.jti.clone()),
+ Value::from(record.album_id.as_str().to_owned()),
+ Value::from(record.peer_id.as_str().to_owned()),
+ Value::from(record.member.as_str().to_owned()),
+ Value::from(record.scope.as_str().to_owned()),
+ Value::from(epoch_to_column(record.granted_epoch)?),
+ Value::from(record.min_protocol_version.clone()),
+ Value::from(to_micros(record.issued_at)),
+ Value::from(to_micros(record.expires_at)),
+ Value::from(to_micros(record.not_after)),
+ ],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("recording a capability"))?;
+ if inserted.rows_affected() == 0 {
+ return Err(StoreError::Rejected {
+ store: CAPABILITIES.store,
+ detail: format!("a capability with jti {} is already recorded", record.jti),
+ });
+ }
+ Ok(())
+}
+
+/// Publish `jti` under `expires_at`, never shortening an entry already published.
+async fn publish(
+ connection: &C,
+ jti: &str,
+ expires_at: Timestamp,
+ at: Timestamp,
+) -> Result<(), StoreError> {
+ connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "INSERT INTO federation_revoked_jti (jti, expires_at, revoked_at) \
+ VALUES ($1, $2, $3) \
+ ON CONFLICT (jti) DO UPDATE SET \
+ expires_at = GREATEST(federation_revoked_jti.expires_at, EXCLUDED.expires_at)",
+ [
+ Value::from(jti.to_owned()),
+ Value::from(to_micros(expires_at)),
+ Value::from(to_micros(at)),
+ ],
+ ))
+ .await
+ .map(|_| ())
+ .map_err(CAPABILITIES.failing("publishing a revocation"))?;
+ Ok(())
+}
+
+/// Mark `jti` revoked at `at` if it is not already, and publish it under the record's own expiry.
+///
+/// The two halves in one call, so no path can do one without the other.
+async fn revoke_and_publish(
+ connection: &C,
+ jti: &str,
+ expires_at: Timestamp,
+ at: Timestamp,
+) -> Result<(), StoreError> {
+ connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "UPDATE federation_capabilities SET revoked_at = $2 \
+ WHERE jti = $1 AND revoked_at IS NULL",
+ [Value::from(jti.to_owned()), Value::from(to_micros(at))],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("revoking a capability"))?;
+ publish(connection, jti, expires_at, at).await
+}
+
+/// The durable capability store, and the revocation list it publishes.
+#[derive(Debug, Clone)]
+pub struct PostgresCapabilities {
+ connection: DatabaseConnection,
+ clock: std::sync::Arc,
+}
+
+impl PostgresCapabilities {
+ /// A store over `connection`, reading `clock` for pruning and for `generated_at`.
+ pub fn new(connection: DatabaseConnection, clock: std::sync::Arc) -> Self {
+ Self { connection, clock }
+ }
+}
+
+impl RevocationList for PostgresCapabilities {
+ fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> {
+ Box::pin(async move {
+ let now = self.clock.now();
+ let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL);
+ if token.expires_at > ceiling {
+ tracing::warn!(
+ jti = %token.jti,
+ expires_at = %token.expires_at,
+ "a revocation was refused: its expiry is beyond the capability TTL ceiling"
+ );
+ return Err(RevocationError::BeyondTtlCeiling {
+ expires_at: token.expires_at,
+ ceiling: MAX_TOKEN_TTL,
+ }
+ .into());
+ }
+ let transaction = begin(&self.connection, CAPABILITIES).await?;
+ lock(&transaction, &token.jti, CAPABILITIES).await?;
+ // The expiry an entry is published under is the **record's** when one backs it: a
+ // caller's shorter `expires_at` would prune the entry while the token still
+ // verifies, which is a peer honouring a revoked token.
+ let expires_at = record_of(&transaction, &token.jti)
+ .await?
+ .map_or(token.expires_at, |record| record.expires_at);
+ revoke_and_publish(&transaction, &token.jti, expires_at, now).await?;
+ commit(transaction, CAPABILITIES).await?;
+ tracing::info!(
+ jti = %token.jti,
+ expires_at = %token.expires_at,
+ "a federation capability token was revoked"
+ );
+ Ok(())
+ })
+ }
+
+ fn published(&self) -> StoreFuture<'_, PublishedRevocations> {
+ Box::pin(async move {
+ let now = self.clock.now();
+ let transaction = begin(&self.connection, CAPABILITIES).await?;
+ transaction
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "DELETE FROM federation_revoked_jti WHERE expires_at <= $1",
+ [Value::from(to_micros(now))],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("pruning the revocation list"))?;
+ let rows = transaction
+ .query_all(Statement::from_string(
+ DbBackend::Postgres,
+ "SELECT jti, expires_at FROM federation_revoked_jti \
+ ORDER BY expires_at ASC, jti ASC",
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("reading the revocation list"))?;
+ commit(transaction, CAPABILITIES).await?;
+
+ let failed = CAPABILITIES.failing("reading the revocation list");
+ let mut revoked = Vec::with_capacity(rows.len());
+ for row in &rows {
+ let jti: String = row.try_get("", "jti").map_err(&failed)?;
+ let expires_at: i64 = row.try_get("", "expires_at").map_err(&failed)?;
+ revoked.push(RevokedToken {
+ jti,
+ expires_at: instant(CAPABILITIES, expires_at)?,
+ });
+ }
+ Ok(PublishedRevocations {
+ generated_at: now,
+ revoked,
+ })
+ })
+ }
+}
+
+impl CapabilityStore for PostgresCapabilities {
+ fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> {
+ Box::pin(async move {
+ admissible(&record)?;
+ insert(&self.connection, &record).await?;
+ tracing::info!(
+ jti = %record.jti,
+ peer = %record.peer_id,
+ album = %record.album_id,
+ member = %record.member,
+ scope = %record.scope,
+ granted_epoch = record.granted_epoch,
+ expires_at = %record.expires_at,
+ "a federation capability was recorded"
+ );
+ Ok(())
+ })
+ }
+
+ fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> {
+ Box::pin(async move { record_of(&self.connection, jti).await })
+ }
+
+ fn live<'a>(
+ &'a self,
+ filter: &'a CapabilityFilter,
+ now: Timestamp,
+ ) -> StoreFuture<'a, Vec> {
+ Box::pin(async move {
+ let (column, key) = match filter {
+ CapabilityFilter::Album(album) => ("album_id", album.as_str().to_owned()),
+ CapabilityFilter::Peer(peer) => ("peer_id", peer.as_str().to_owned()),
+ };
+ let rows = self
+ .connection
+ .query_all(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ format!(
+ "SELECT {CAPABILITY_COLUMNS} FROM federation_capabilities \
+ WHERE {column} = $1 AND revoked_at IS NULL AND expires_at > $2 \
+ ORDER BY jti ASC"
+ ),
+ [Value::from(key), Value::from(to_micros(now))],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("listing live capabilities"))?;
+ rows.iter().map(record_from).collect()
+ })
+ }
+
+ fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> {
+ Box::pin(async move {
+ let transaction = begin(&self.connection, CAPABILITIES).await?;
+ lock(&transaction, jti, CAPABILITIES).await?;
+ let Some(record) = record_of(&transaction, jti).await? else {
+ return Ok(RevokeOutcome::Unknown);
+ };
+ if record.revoked_at.is_some() {
+ return Ok(RevokeOutcome::AlreadyRevoked);
+ }
+ revoke_and_publish(&transaction, jti, record.expires_at, at).await?;
+ commit(transaction, CAPABILITIES).await?;
+ tracing::info!(%jti, "an issued capability was revoked");
+ Ok(RevokeOutcome::Revoked)
+ })
+ }
+
+ fn refresh<'a>(
+ &'a self,
+ predecessor: &'a str,
+ successor: CapabilityRecord,
+ at: Timestamp,
+ ) -> StoreFuture<'a, RefreshOutcome> {
+ Box::pin(async move {
+ admissible(&successor)?;
+ let transaction = begin(&self.connection, CAPABILITIES).await?;
+ lock(&transaction, predecessor, CAPABILITIES).await?;
+ let Some(old) = record_of(&transaction, predecessor).await? else {
+ return Ok(RefreshOutcome::Unknown);
+ };
+ super::store::continues(predecessor, &old, &successor)?;
+ if let Some(next) = &old.refreshed_to {
+ let existing =
+ record_of(&transaction, next)
+ .await?
+ .ok_or_else(|| StoreError::Corrupt {
+ store: CAPABILITIES.store,
+ record: CAPABILITIES.record,
+ detail: format!(
+ "{predecessor} was refreshed to {next}, which is not recorded"
+ ),
+ })?;
+ return Ok(RefreshOutcome::AlreadyRefreshed(existing));
+ }
+ if old.revoked_at.is_some() {
+ return Ok(RefreshOutcome::Revoked);
+ }
+ insert(&transaction, &successor).await?;
+ revoke_and_publish(&transaction, predecessor, old.expires_at, at).await?;
+ transaction
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "UPDATE federation_capabilities SET refreshed_to = $2 WHERE jti = $1",
+ [
+ Value::from(predecessor.to_owned()),
+ Value::from(successor.jti.clone()),
+ ],
+ ))
+ .await
+ .map_err(CAPABILITIES.failing("linking a refreshed capability"))?;
+ commit(transaction, CAPABILITIES).await?;
+ tracing::info!(
+ predecessor = %predecessor,
+ successor = %successor.jti,
+ peer = %successor.peer_id,
+ "a federation capability was refreshed"
+ );
+ Ok(RefreshOutcome::Issued(successor))
+ })
+ }
+}
+
+/// The durable peer store.
+#[derive(Debug, Clone)]
+pub struct PostgresPeers {
+ connection: DatabaseConnection,
+}
+
+impl PostgresPeers {
+ /// A store over `connection`.
+ pub fn new(connection: DatabaseConnection) -> Self {
+ Self { connection }
+ }
+
+ /// Read one peer under `connection`.
+ async fn read_under(
+ connection: &C,
+ peer: &PeerId,
+ ) -> Result, StoreError> {
+ let Some(row) = connection
+ .query_one(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "SELECT server_id, signing_key, first_seen_at, blocked_at, note \
+ FROM federation_peers WHERE server_id = $1",
+ [Value::from(peer.as_str().to_owned())],
+ ))
+ .await
+ .map_err(PEERS.failing("reading a peer"))?
+ else {
+ return Ok(None);
+ };
+ let failed = PEERS.failing("reading a peer");
+ let server_id: String = row.try_get("", "server_id").map_err(&failed)?;
+ let signing_key: Option> = row.try_get("", "signing_key").map_err(&failed)?;
+ let first_seen_at: i64 = row.try_get("", "first_seen_at").map_err(&failed)?;
+ let blocked_at: Option = row.try_get("", "blocked_at").map_err(&failed)?;
+ let note: Option = row.try_get("", "note").map_err(&failed)?;
+ let signing_key = signing_key
+ .map(|bytes| {
+ <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| {
+ PEERS.undecodable(format!(
+ "{server_id}'s signing key is {} bytes, not thirty-two",
+ bytes.len()
+ ))
+ })
+ })
+ .transpose()?;
+ Ok(Some(PeerRecord {
+ server_id: PeerId::new(server_id),
+ signing_key,
+ first_seen_at: instant(PEERS, first_seen_at)?,
+ blocked_at: blocked_at
+ .map(|micros| instant(PEERS, micros))
+ .transpose()?,
+ note,
+ }))
+ }
+}
+
+impl PeerStore for PostgresPeers {
+ fn pin<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ signing_key: [u8; 32],
+ at: Timestamp,
+ ) -> StoreFuture<'a, ()> {
+ Box::pin(async move {
+ // A block already on the row is kept: pinning a key is not an opinion about whether
+ // to talk to its owner.
+ self.connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "INSERT INTO federation_peers (server_id, signing_key, first_seen_at) \
+ VALUES ($1, $2, $3) \
+ ON CONFLICT (server_id) DO UPDATE SET signing_key = EXCLUDED.signing_key",
+ [
+ Value::from(peer.as_str().to_owned()),
+ Value::from(signing_key.to_vec()),
+ Value::from(to_micros(at)),
+ ],
+ ))
+ .await
+ .map_err(PEERS.failing("pinning a peer's key"))?;
+ tracing::info!(%peer, "a peer's signing key was pinned");
+ Ok(())
+ })
+ }
+
+ fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option> {
+ Box::pin(async move { Self::read_under(&self.connection, peer).await })
+ }
+
+ fn block<'a>(
+ &'a self,
+ peer: &'a PeerId,
+ at: Timestamp,
+ note: Option,
+ ) -> StoreFuture<'a, BlockOutcome> {
+ Box::pin(async move {
+ // Creates the row if the peer was never pinned: blocking a server nobody wanted to
+ // hear from is legitimate, and it must not need a key first.
+ let updated = self
+ .connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "INSERT INTO federation_peers (server_id, first_seen_at, blocked_at, note) \
+ VALUES ($1, $2, $2, $3) \
+ ON CONFLICT (server_id) DO UPDATE SET blocked_at = $2, note = $3 \
+ WHERE federation_peers.blocked_at IS NULL",
+ [
+ Value::from(peer.as_str().to_owned()),
+ Value::from(to_micros(at)),
+ Value::from(note),
+ ],
+ ))
+ .await
+ .map_err(PEERS.failing("blocking a peer"))?;
+ if updated.rows_affected() == 0 {
+ return Ok(BlockOutcome::AlreadyBlocked);
+ }
+ tracing::warn!(%peer, "a peer server was blocked");
+ Ok(BlockOutcome::Blocked)
+ })
+ }
+
+ fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> {
+ Box::pin(async move {
+ let updated = self
+ .connection
+ .execute(Statement::from_sql_and_values(
+ DbBackend::Postgres,
+ "UPDATE federation_peers SET blocked_at = NULL, note = NULL \
+ WHERE server_id = $1 AND blocked_at IS NOT NULL",
+ [Value::from(peer.as_str().to_owned())],
+ ))
+ .await
+ .map_err(PEERS.failing("unblocking a peer"))?;
+ if updated.rows_affected() == 0 {
+ return Ok(UnblockOutcome::NotBlocked);
+ }
+ tracing::info!(%peer, "a peer server was unblocked");
+ Ok(UnblockOutcome::Unblocked)
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ /// The suite, against a real Postgres.
+ mod postgres_conformance {
+ use std::sync::Arc;
+
+ use super::super::{PostgresCapabilities, PostgresPeers};
+ use crate::federation::conformance::{self, Harness};
+ use crate::federation::{CapabilityStore, PeerStore};
+ use crate::postgres::testing;
+ use crate::store::memory::ManualClock;
+
+ /// Both stores over one container.
+ #[derive(Debug)]
+ struct PostgresHarness {
+ clock: Arc,
+ capabilities: PostgresCapabilities,
+ peers: PostgresPeers,
+ }
+
+ impl Harness for PostgresHarness {
+ fn capabilities(&self) -> &dyn CapabilityStore {
+ &self.capabilities
+ }
+
+ fn peers(&self) -> &dyn PeerStore {
+ &self.peers
+ }
+
+ fn clock(&self) -> &ManualClock {
+ &self.clock
+ }
+ }
+
+ #[tokio::test]
+ async fn the_postgres_federation_stores_conform() {
+ let Some(database) = testing::start("the Postgres federation stores").await else {
+ return;
+ };
+ let clock = Arc::new(ManualClock::default());
+ let harness = PostgresHarness {
+ capabilities: PostgresCapabilities::new(
+ database.connection().clone(),
+ clock.clone(),
+ ),
+ peers: PostgresPeers::new(database.connection().clone()),
+ clock,
+ };
+ conformance::run_all(&harness).await;
+ }
+ }
+}
diff --git a/capsule-server/src/federation/report.rs b/capsule-server/src/federation/report.rs
new file mode 100644
index 00000000..6675929a
--- /dev/null
+++ b/capsule-server/src/federation/report.rs
@@ -0,0 +1,205 @@
+//! The signed federated moderation report, and what verifies one (`S-C49`).
+//!
+//! # Why a report is signed rather than authenticated
+//!
+//! A peer filing a report holds no capability on this server — it is reporting *this* server's
+//! content, not pulling it — so there is no bearer to present and nothing to check a bearer
+//! against. What there is, is the peer's operational Ed25519 key, which an operator has pinned
+//! ([`PeerStore::pin`](super::PeerStore::pin)). So the report carries its own signature and the
+//! route verifies it against that pinned key: intake is unauthenticated in the HTTP sense and
+//! attributed in every sense that matters.
+//!
+//! That is also why intake is not TOFU. Fetching a peer's key at the moment it first files would
+//! make the first report from a new server the thing that decides whether to trust that server.
+//!
+//! # What is signed
+//!
+//! The canonical CBOR of [`ReportClaim`] — every field of the report except the signature — so
+//! the bytes a peer signs are reproducible from the body this server received and nothing about
+//! JSON key order or number formatting can change them. Canonical CBOR is the same encoding
+//! every other signed document in Capsule uses, and `capsule-core` owns it.
+//!
+//! Replay is bounded by the `(reporting_server, reported_user)` budget rather than by a nonce:
+//! a replayed report is a duplicate row in an operator's queue, not an action, and a nonce table
+//! would be a second store for a threat whose worst outcome is a duplicate.
+
+use serde::{Deserialize, Serialize};
+
+/// The report's signed payload: every field except the signature.
+///
+/// Canonical CBOR sorts a map's keys, so the encoding depends on the field *names* and their
+/// values and not on the order they are declared in here — which is what lets a peer implement
+/// the format from the design doc rather than from this file. Renaming a field, adding one, or
+/// changing one's type is still a breaking change to every peer, and
+/// `tests::the_signing_bytes_are_stable` pins the encoding so it fails here rather than in the
+/// field.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ReportClaim {
+ /// The peer filing the report, as its own `server-info` names it.
+ pub reporting_server: String,
+ /// The account on the receiving server the report is about.
+ pub reported_user: String,
+ /// The content address of the asset complained about.
+ pub asset_hash: String,
+ /// The album it was pulled from.
+ pub album_id: String,
+ /// A short reason, where the peer gives one.
+ pub reason: Option,
+ /// When the peer says it was reported, RFC 3339.
+ pub reported_at: String,
+}
+
+/// Why a report was not accepted.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
+pub enum ReportError {
+ /// The claim could not be encoded, which can only be this server's own fault.
+ #[error("the report's signing bytes could not be produced")]
+ Unencodable,
+ /// The signature does not verify under the peer's pinned key.
+ #[error("the report's signature does not verify")]
+ NotAuthentic,
+}
+
+impl ReportClaim {
+ /// The exact bytes a peer signs.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReportError::Unencodable`] if the claim does not serialize, which nothing a
+ /// peer can send causes: every field is a string.
+ pub fn signing_bytes(&self) -> Result, ReportError> {
+ capsule_core::cbor::to_canonical_vec(self).map_err(|error| {
+ tracing::error!(%error, "a federated report's claim did not serialize");
+ ReportError::Unencodable
+ })
+ }
+
+ /// Whether `signature` is this claim's, under `key`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`ReportError::NotAuthentic`] when it is not. No part of the signature or the key
+ /// is logged: which check failed is the whole of what is safe to say.
+ pub fn verify(&self, signature: &[u8], key: &[u8; 32]) -> Result<(), ReportError> {
+ let bytes = self.signing_bytes()?;
+ verify_signed_report(&bytes, signature, key).inspect_err(|_| {
+ tracing::info!(
+ from = %self.reporting_server,
+ "a federated report's signature did not verify under the pinned key"
+ );
+ })
+ }
+}
+
+/// Whether `signature` covers `signed` under `key`.
+///
+/// The re-verification an operator does against a filed report, months after intake: it takes
+/// the two byte strings the row carries and the peer's pinned key, and nothing that was derived
+/// or normalized. Deliberately *not* a method on [`ReportClaim`] — reconstructing a claim from a
+/// stored row is exactly the mistake this exists to make unnecessary.
+///
+/// # Errors
+///
+/// Returns [`ReportError::NotAuthentic`] when it does not.
+pub fn verify_signed_report(
+ signed: &[u8],
+ signature: &[u8],
+ key: &[u8; 32],
+) -> Result<(), ReportError> {
+ ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key.as_slice())
+ .verify(signed, signature)
+ .map_err(|_| ReportError::NotAuthentic)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn claim() -> ReportClaim {
+ ReportClaim {
+ reporting_server: "other.test".to_owned(),
+ reported_user: "01937b7c-0000-7000-8000-0000000000b0".to_owned(),
+ asset_hash: "a".repeat(64),
+ album_id: "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5e60".to_owned(),
+ reason: Some("csam".to_owned()),
+ reported_at: "2026-09-02T00:00:00Z".to_owned(),
+ }
+ }
+
+ /// A key pair, and the raw thirty-two public bytes an operator pins.
+ fn keypair() -> (ring::signature::Ed25519KeyPair, [u8; 32]) {
+ use ring::signature::KeyPair as _;
+ let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
+ .expect("a key generates");
+ let pair = ring::signature::Ed25519KeyPair::from_pkcs8(der.as_ref()).expect("it parses");
+ let public: [u8; 32] = pair.public_key().as_ref().try_into().expect("32 bytes");
+ (pair, public)
+ }
+
+ #[test]
+ fn a_report_verifies_under_the_key_that_signed_it_and_no_other() {
+ let (pair, public) = keypair();
+ let claim = claim();
+ let signature = pair.sign(&claim.signing_bytes().expect("it encodes"));
+ assert_eq!(claim.verify(signature.as_ref(), &public), Ok(()));
+
+ let (_, other) = keypair();
+ assert_eq!(
+ claim.verify(signature.as_ref(), &other),
+ Err(ReportError::NotAuthentic)
+ );
+ }
+
+ #[test]
+ fn every_field_is_covered_by_the_signature() {
+ // The whole point of signing the claim rather than a digest of part of it: a peer
+ // cannot have its signature over one report re-used to file a different one.
+ let (pair, public) = keypair();
+ let original = claim();
+ let signature = pair.sign(&original.signing_bytes().expect("it encodes"));
+
+ // Plain function pointers rather than boxed closures: none of them captures, and the
+ // list is the point — one entry per field of the claim, so a field added without a
+ // mutation here is a field this case silently stops covering.
+ let mutations: [fn(&mut ReportClaim); 7] = [
+ |claim| claim.reporting_server = "third.test".to_owned(),
+ |claim| claim.reported_user = "01937b7c-0000-7000-8000-0000000000cc".to_owned(),
+ |claim| claim.asset_hash = "b".repeat(64),
+ |claim| claim.album_id = "018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff".to_owned(),
+ |claim| claim.reason = Some("spam".to_owned()),
+ |claim| claim.reason = None,
+ |claim| claim.reported_at = "2026-09-03T00:00:00Z".to_owned(),
+ ];
+ for (index, mutate) in mutations.iter().enumerate() {
+ let mut mutated = original.clone();
+ mutate(&mut mutated);
+ assert_eq!(
+ mutated.verify(signature.as_ref(), &public),
+ Err(ReportError::NotAuthentic),
+ "mutation {index} was not covered by the signature"
+ );
+ }
+ }
+
+ #[test]
+ fn the_signing_bytes_are_stable() {
+ // Pinned as a literal: this encoding is the contract every peer signs against, and a
+ // field reordering or an encoder change would break every peer at once. It must fail
+ // here rather than in the field.
+ let bytes = claim().signing_bytes().expect("it encodes");
+ assert_eq!(
+ hex_of(&bytes),
+ "a666726561736f6e646373616d68616c62756d5f6964782430313866336631652d346237612d376339642d386532662d3161326233633464356536306a61737365745f686173687840616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616b7265706f727465645f617474323032362d30392d30325430303a30303a30305a6d7265706f727465645f75736572782430313933376237632d303030302d373030302d383030302d303030303030303030306230707265706f7274696e675f7365727665726a6f746865722e74657374",
+ "if this changed, every peer's signature changed with it"
+ );
+ }
+
+ fn hex_of(bytes: &[u8]) -> String {
+ use std::fmt::Write as _;
+
+ bytes.iter().fold(String::new(), |mut hex, byte| {
+ let _ = write!(hex, "{byte:02x}");
+ hex
+ })
+ }
+}
diff --git a/capsule-server/src/federation/scheme.rs b/capsule-server/src/federation/scheme.rs
new file mode 100644
index 00000000..1cde3e96
--- /dev/null
+++ b/capsule-server/src/federation/scheme.rs
@@ -0,0 +1,182 @@
+//! [`ReadBearer`] — the one bearer carriage the two read primitives accept two principals on.
+//!
+//! # One component key, two principals
+//!
+//! design/api-surfaces.md: "Session access tokens and federation capabilities are different token
+//! types verified by their owning modules, even though both use the standard HTTP carriage."
+//! Kynos registers a scheme under its component name, and `GET /v1/sync` and
+//! `GET /v1/blob/{hash}` must keep declaring the same `bearer` requirement every other operation
+//! does — the generated SDK client attaches its credential by that key, and a second key would
+//! split one carriage into two in the document for a difference the wire does not have. So this
+//! scheme registers under **the same name, with a byte-identical description**, as
+//! [`AccessToken`]; Kynos accepts a duplicate registration exactly when the two descriptions are
+//! equal, and `tests::the_two_schemes_describe_one_component` pins that they are.
+//!
+//! # Session first, capability second
+//!
+//! The authenticator asks the session module first, exactly as `Auth` would — the
+//! ledger check of `S-C48` included — and only on *unauthenticated* tries the capability codec.
+//! A session token that is live and the wrong kind stays `403`: a refresh token is an
+//! insufficient credential on this operation whichever module reads it. A capability that
+//! verifies must also be one this server **recorded**: an unknown `jti` under a valid signature
+//! is a token this server did not issue, and the record is what carries the member and epoch the
+//! route checks membership against.
+//!
+//! # What this authenticator refuses, and what it deliberately does not
+//!
+//! Only the structural refusals — does not verify, expired, unknown — are decided here, and they
+//! render as the framework's uncoded `401`, the recorded limitation of
+//! [`crate::auth::scheme`]. Everything a client can act on with a code — revoked, wrong album,
+//! insufficient scope, over budget, blocked peer — is decided by the **route** from the admitted
+//! [`VerifiedCapability`] through [`admit`](super::admit), the same way `Membership::Revoked`
+//! becomes a route's `403`. The credential never carries the raw token.
+
+use kynos::error::rejection::AuthRejection;
+use kynos::prelude::*;
+use kynos::security::Authenticator;
+use kynos::security::carrier::BearerToken;
+
+use super::FederationContext;
+use super::capability::CapabilityGrant;
+use super::store::CapabilityRecord;
+use crate::app::App;
+use crate::auth::{AccessToken, AuthContext, AuthenticatedSession};
+
+/// A capability that verified and that this server recorded.
+///
+/// The grant is what the token said; the record is what the server knows about it — the member
+/// it was minted for, the epoch their membership was granted at, whether it has been revoked.
+/// A route decides from both.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VerifiedCapability {
+ /// The token's claims, verified.
+ pub grant: CapabilityGrant,
+ /// The issued record the `jti` names.
+ pub record: CapabilityRecord,
+}
+
+/// Who a read is being served to.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Principal {
+ /// An account, through a session access token.
+ Session(AuthenticatedSession),
+ /// A peer server, through a federation capability.
+ ///
+ /// Boxed: the grant and its record are several hundred bytes against a session's tens, and
+ /// every session-authenticated read would otherwise carry the difference.
+ Peer(Box),
+}
+
+/// The bearer carriage on the two read primitives a peer may pull through.
+///
+/// Registered under the same component as [`AccessToken`], with the same description: one
+/// `bearer` in the document, one credential key in the SDK. The handler receives a
+/// [`Principal`] and never the token.
+#[derive(SecurityScheme)]
+#[security(bearer(format = "JWT"))]
+#[security(
+ name = "bearer",
+ credential = Principal,
+ description = "A short-lived Capsule access token, issued by `POST /v1/auth/login` and \
+ rotated by `POST /v1/auth/refresh`."
+)]
+pub struct ReadBearer;
+
+impl Authenticator for FederationContext {
+ async fn authenticate(
+ &self,
+ presented: BearerToken,
+ context: &App,
+ ) -> Result {
+ // The session module first, and its answer is final unless it is "not a session": a
+ // live refresh token is `403` here as everywhere, and a session it admits is admitted.
+ match >::authenticate(
+ context.auth(),
+ presented.clone(),
+ context,
+ )
+ .await
+ {
+ Ok(session) => return Ok(Principal::Session(session)),
+ // `AuthRejection` is non-exhaustive; anything that is not "insufficient" is "not a
+ // session", which is the one answer that opens the capability path.
+ Err(AuthRejection::Forbidden) => return Err(AuthRejection::Forbidden),
+ Err(_) => {}
+ }
+
+ let grant = self.codec().verify(presented.as_str()).map_err(|reason| {
+ // Which check failed is safe to log — it names no part of the credential — and it
+ // is the only thing that makes "my capability is refused" actionable.
+ tracing::debug!(%reason, "a request presented a credential that is neither a session nor a capability");
+ AuthRejection::unauthenticated()
+ })?;
+
+ let record = match self.capabilities().find(&grant.jti).await {
+ Ok(Some(record)) => record,
+ Ok(None) => {
+ tracing::info!(
+ peer = %grant.peer,
+ jti = %grant.jti,
+ "a capability verified under this server's key but was never issued here"
+ );
+ return Err(AuthRejection::unauthenticated());
+ }
+ // Fail closed, as the session ledger does. `401` is the only refusal this trait can
+ // render; the route-level `admit` renders the honest `500` for the same outage.
+ Err(error) => {
+ tracing::error!(
+ %error,
+ jti = %grant.jti,
+ "the capability store could not be read, so the request was refused closed"
+ );
+ return Err(AuthRejection::unauthenticated());
+ }
+ };
+ if record.grant() != grant {
+ // The token and the record disagree about what was granted. Nothing this server
+ // wrote can produce that, so it is refused rather than reconciled.
+ tracing::error!(jti = %grant.jti, "a capability's claims do not match its record");
+ return Err(AuthRejection::unauthenticated());
+ }
+
+ tracing::trace!(
+ peer = %grant.peer,
+ album = %grant.album,
+ jti = %grant.jti,
+ "a request presented a recorded federation capability"
+ );
+ Ok(Principal::Peer(Box::new(VerifiedCapability {
+ grant,
+ record,
+ })))
+ }
+
+ async fn authorize(
+ &self,
+ _credential: &Principal,
+ _scopes: &'static [&'static str],
+ _context: &App,
+ ) -> Result<(), AuthRejection> {
+ // Neither token type carries OAuth-style scopes; a capability's `scope` is decided
+ // against a blob's role by the route. Exists because the trait requires it.
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use kynos::security::SecurityScheme as _;
+
+ use super::ReadBearer;
+ use crate::auth::AccessToken;
+
+ #[test]
+ fn the_two_schemes_describe_one_component() {
+ // What keeps `components.securitySchemes` at one `bearer` entry and every operation's
+ // `security` unchanged: Kynos accepts a second registration under a name exactly when
+ // its description is byte-identical to the first.
+ assert_eq!(ReadBearer::NAME, AccessToken::NAME);
+ assert_eq!(ReadBearer::describe(), AccessToken::describe());
+ assert_eq!(ReadBearer::challenge(), AccessToken::challenge());
+ }
+}
diff --git a/capsule-server/src/federation/store.rs b/capsule-server/src/federation/store.rs
new file mode 100644
index 00000000..5318ccff
--- /dev/null
+++ b/capsule-server/src/federation/store.rs
@@ -0,0 +1,315 @@
+//! [`CapabilityStore`] — every capability this server issued, and the revocation list it
+//! publishes.
+//!
+//! # The store is the revocation list
+//!
+//! `/.well-known/capsule/revoked-jti` was served from a standalone list before federation had a
+//! minting side (`S-C18`). Once a capability is a stored record, "is this `jti` revoked" has a
+//! second possible answer — the record's `revoked_at` — and two answers to that question is the
+//! one shape revocation cannot afford. So every adapter here **is** a
+//! [`RevocationList`]: revoking an issued capability sets its `revoked_at` and publishes its
+//! `jti` in one critical section, and the standalone in-memory list is gone.
+//!
+//! [`RevocationList::revoke`] still accepts a `jti` this server never issued, and still
+//! publishes it: an operator revoking a token by hand from a peer's report, or a record that
+//! predates the store, is a fact the list must carry whether or not a row backs it.
+//!
+//! # What the record binds that the token does not
+//!
+//! The token names the peer, the album and the scope. The record adds the **member** the
+//! capability was minted for and the **epoch** their membership was granted at
+//! ([`CapabilityRecord::granted_epoch`]), so presentation can ask whether that member is still
+//! on the roster at that epoch: a member removed and re-admitted later gets a fresh grant, and
+//! the old capability — minted for a membership that ended — is refused without anyone having
+//! revoked it. The token format is normative and parsed by every peer, which is why the epoch is
+//! a stored fact rather than a claim.
+//!
+//! # Refresh is one operation
+//!
+//! [`CapabilityStore::refresh`] issues the successor, marks the predecessor as refreshed *to*
+//! it, and revokes the predecessor, in one critical section. Idempotency keyed by
+//! `(peer, jti)` — threat-model/validation.md — falls out of the `refreshed_to` link: a replay
+//! finds the predecessor already refreshed and answers with the same successor, and two
+//! concurrent refreshes of one token cannot both issue. The `peer` half of the key is the
+//! credential's: only the holder of the predecessor can present it, and the store refuses a
+//! successor that names another peer, album or member than the predecessor did, so the link
+//! can never widen what was granted. A successor answered to a replay may itself have been
+//! revoked since (a block cascades over every live capability of a peer); the route re-checks
+//! [`CapabilityRecord::is_live`] before re-signing it.
+
+use std::fmt;
+
+use jiff::{SignedDuration, Timestamp};
+
+use super::PeerId;
+use super::capability::{CapabilityGrant, Scope};
+use crate::discovery::revocation::{MAX_TOKEN_TTL, RevocationList};
+use crate::store::{AlbumId, StoreError, StoreFuture, UserId};
+
+/// One capability this server issued.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct CapabilityRecord {
+ /// The token's `jti`, and the revocation key.
+ pub jti: String,
+ /// The album it scopes to.
+ pub album_id: AlbumId,
+ /// The peer server it was issued to.
+ pub peer_id: PeerId,
+ /// The roster member whose access it carries, as the owner listed them.
+ pub member: UserId,
+ /// What it permits.
+ pub scope: Scope,
+ /// The epoch the member's membership was granted at when this was minted.
+ pub granted_epoch: u64,
+ /// The album's pinned protocol date, carried so the grant can be re-signed.
+ pub min_protocol_version: String,
+ /// When it was minted; also its `nbf`.
+ pub issued_at: Timestamp,
+ /// When it stops being honoured.
+ pub expires_at: Timestamp,
+ /// The absolute deadline the **whole grant** dies at, chosen at the original mint.
+ ///
+ /// The token's own `expires_at` is at most 24 h out and a refresh replaces it; this is the
+ /// thing a refresh cannot move. Equal to `expires_at` for a grant the owner did not make
+ /// renewable, which is the default — see [`CapabilityRecord::may_refresh_at`].
+ pub not_after: Timestamp,
+ /// When it was revoked, if it has been.
+ pub revoked_at: Option,
+ /// The `jti` of the successor a refresh issued, if one has.
+ pub refreshed_to: Option,
+}
+
+impl CapabilityRecord {
+ /// Whether the capability may still be presented at `now`: unrevoked and unexpired.
+ pub fn is_live(&self, now: Timestamp) -> bool {
+ self.revoked_at.is_none() && self.expires_at > now
+ }
+
+ /// Whether a successor may still be issued from this grant at `now`.
+ ///
+ /// The absolute deadline, and the whole of what stops a refresh chain. Without it a peer
+ /// holding a deliberate sixty-second grant refreshes inside the minute to the default TTL
+ /// and again forever, and the owner's chosen lifetime is advisory for exactly one hop.
+ ///
+ /// Two conditions, and the first is the default: the owner must have made the grant
+ /// renewable at all, and the deadline must not have passed. The last token of a renewable
+ /// grant is minted for exactly what is left, so it satisfies neither and answers the same
+ /// "this grant is over" as one that was never renewable — which is the honest answer in
+ /// both cases, because in both there is no successor left to have.
+ pub fn may_refresh_at(&self, now: Timestamp) -> bool {
+ self.is_renewable() && self.not_after > now
+ }
+
+ /// Whether the owner made this grant renewable at all.
+ ///
+ /// `false` is the default: renewability is asked for at mint, never assumed.
+ pub fn is_renewable(&self) -> bool {
+ self.not_after > self.expires_at
+ }
+
+ /// The grant this record describes, which the codec re-signs byte-for-byte.
+ pub fn grant(&self) -> CapabilityGrant {
+ CapabilityGrant {
+ peer: self.peer_id.clone(),
+ album: self.album_id.clone(),
+ scope: self.scope,
+ jti: self.jti.clone(),
+ issued_at: self.issued_at,
+ expires_at: self.expires_at,
+ min_protocol_version: self.min_protocol_version.clone(),
+ }
+ }
+}
+
+/// The furthest out a grant's absolute deadline may sit from the mint that fixed it.
+///
+/// Ninety days. Not a security boundary — the owner chose the date and can revoke it — but a
+/// mistyped year is the one input on this surface whose blast radius is measured in years, and a
+/// cap turns that into a refusal the client sees rather than a grant nobody remembers making.
+///
+/// Here rather than on the route that parses `renewable_until`, and that is the whole point: the
+/// route is the only caller of [`CapabilityStore::issue`] **today**, and #476's operator tooling
+/// is exactly the second one. A ceiling enforced by whichever caller happens to remember it is a
+/// ceiling the next caller does not have.
+pub const MAX_GRANT_LIFETIME: SignedDuration = SignedDuration::from_hours(24 * 90);
+
+/// Refuse a record whose lifetime the published list could not stay bounded under.
+///
+/// Shared by every adapter rather than re-derived in each: the 24 h ceiling and the absolute
+/// deadline are properties of the *record*, and an adapter that checked them differently would
+/// be an adapter that accepted a grant another one refuses.
+///
+/// # Errors
+///
+/// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) when the token's own
+/// window is past [`MAX_TOKEN_TTL`], when it runs past the grant's absolute deadline, when that
+/// deadline is further out than [`MAX_GRANT_LIFETIME`], or when its `granted_epoch` is wider
+/// than every adapter can hold.
+pub fn admissible(record: &CapabilityRecord) -> Result<(), StoreError> {
+ // The port says `u64` and the durable column is a `BIGINT`, so an epoch above `i64::MAX` is
+ // representable to a caller and not to one adapter. Refused here, for every adapter at once,
+ // rather than narrowed: a grant recorded under a different epoch than the one asked for is a
+ // grant that admits the wrong membership, and an in-memory store that accepted what Postgres
+ // refuses is the divergence class the conformance suite exists to catch.
+ if i64::try_from(record.granted_epoch).is_err() {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "capability {}'s granted epoch {} is wider than a stored epoch",
+ record.jti, record.granted_epoch
+ ),
+ });
+ }
+ if record.expires_at.duration_since(record.issued_at) > MAX_TOKEN_TTL {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "capability {} would live past the {MAX_TOKEN_TTL} ceiling",
+ record.jti
+ ),
+ });
+ }
+ if record.expires_at > record.not_after {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "capability {} expires at {}, past its grant's deadline of {}",
+ record.jti, record.expires_at, record.not_after
+ ),
+ });
+ }
+ // The ceiling on the deadline itself. The route that parses `renewable_until` refuses one
+ // further out than this, and that is not enough: a grant is only as bounded as its *least*
+ // careful caller, and the whole reason these checks live in the store is that an adapter —
+ // or a second caller — cannot re-derive them differently.
+ if record.not_after.duration_since(record.issued_at) > MAX_GRANT_LIFETIME {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "capability {}'s grant would run to {}, past the {MAX_GRANT_LIFETIME} ceiling \
+ from its mint at {}",
+ record.jti, record.not_after, record.issued_at
+ ),
+ });
+ }
+ Ok(())
+}
+
+/// Refuse a successor that does not continue exactly what its predecessor granted.
+///
+/// The four things a refresh may never move: the peer, the album, the member, and the absolute
+/// deadline. The first three keep a refresh from *widening* a grant; the fourth keeps it from
+/// *outliving* one, which is the same defect one dimension along.
+///
+/// # Errors
+///
+/// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) naming which of them
+/// moved. Every one is a bug in the caller, never a peer's request.
+pub fn continues(
+ predecessor: &str,
+ old: &CapabilityRecord,
+ successor: &CapabilityRecord,
+) -> Result<(), StoreError> {
+ if successor.peer_id != old.peer_id
+ || successor.album_id != old.album_id
+ || successor.member != old.member
+ {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!("a successor of {predecessor} must carry its peer, album and member"),
+ });
+ }
+ if successor.not_after != old.not_after {
+ return Err(StoreError::Rejected {
+ store: "capabilities",
+ detail: format!(
+ "a successor of {predecessor} must carry its deadline of {}, not {}",
+ old.not_after, successor.not_after
+ ),
+ });
+ }
+ Ok(())
+}
+
+/// Which live capabilities a caller wants.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum CapabilityFilter {
+ /// Every live capability over one album — what a roster change consults.
+ Album(AlbumId),
+ /// Every live capability held by one peer — what a block cascades over.
+ Peer(PeerId),
+}
+
+/// What revoking an issued capability did.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RevokeOutcome {
+ /// It was live and is now revoked and published.
+ Revoked,
+ /// It was already revoked. A retry is not a new fact.
+ AlreadyRevoked,
+ /// No capability with that `jti` was ever issued here.
+ Unknown,
+}
+
+/// What a refresh did.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RefreshOutcome {
+ /// The successor was issued and the predecessor revoked and linked to it.
+ Issued(CapabilityRecord),
+ /// The predecessor had already been refreshed; this is the successor it links to.
+ AlreadyRefreshed(CapabilityRecord),
+ /// The predecessor was revoked without a successor, so there is nothing to continue.
+ Revoked,
+ /// No capability with the predecessor's `jti` was ever issued here.
+ Unknown,
+}
+
+/// Where issued capabilities live, and the revocation list they feed.
+pub trait CapabilityStore: RevocationList + fmt::Debug + Send + Sync {
+ /// Record a freshly minted capability.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if a capability with
+ /// the same `jti` is already recorded — a `jti` is a fresh UUIDv7 per mint, so a collision is
+ /// a bug rather than a retry — or if the record would live past the TTL ceiling, which the
+ /// published list is bounded by. The codec clamps at mint, so the second is a bug too.
+ fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()>;
+
+ /// The capability `jti` names, revoked or not.
+ fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option>;
+
+ /// Every capability matching `filter` that is live at `now`.
+ fn live<'a>(
+ &'a self,
+ filter: &'a CapabilityFilter,
+ now: Timestamp,
+ ) -> StoreFuture<'a, Vec>;
+
+ /// Revoke the capability `jti` names at `at`, and publish its `jti`, in one operation.
+ ///
+ /// Idempotent: a second call answers [`RevokeOutcome::AlreadyRevoked`] and changes nothing.
+ fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome>;
+
+ /// Issue `successor` in place of the capability `predecessor` names, at `at`.
+ ///
+ /// One critical section: the successor is recorded, the predecessor's `refreshed_to` is set
+ /// to it, and the predecessor is revoked and published. A predecessor that has already been
+ /// refreshed answers [`RefreshOutcome::AlreadyRefreshed`] with the successor it links to and
+ /// records nothing — which is the idempotency the contract promises.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if `successor` names
+ /// a different peer, album or member than the predecessor, **carries a different
+ /// `not_after` or one its own `expires_at` runs past**, or would live past the ceiling, or
+ /// reuses a recorded `jti`. Every one is a bug in the caller, never a peer's request — and
+ /// the `not_after` rule is what makes "a refresh cannot extend a grant" structural rather
+ /// than a property of the one route that happens to compute the successor's TTL.
+ fn refresh<'a>(
+ &'a self,
+ predecessor: &'a str,
+ successor: CapabilityRecord,
+ at: Timestamp,
+ ) -> StoreFuture<'a, RefreshOutcome>;
+}
diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs
index e99fd094..b60aa7bf 100644
--- a/capsule-server/src/lib.rs
+++ b/capsule-server/src/lib.rs
@@ -28,7 +28,8 @@
//!
//! Each module owns one port and, where it has one, the surface over it. [`routes`] is the only
//! module that knows about HTTP: everything under it — [`album`], [`directory`], [`discovery`],
-//! [`enrollment`], [`escrow`], [`gc`], [`index`], [`membership`], [`moderation`],
+//! [`enrollment`], [`escrow`], [`federation`], [`gc`], [`index`], [`membership`],
+//! [`moderation`],
//! [`negotiation`], [`quota`],
//! [`scrub`], [`serve`],
//! [`share`], [`store`],
@@ -76,6 +77,7 @@ pub mod discovery;
pub mod drop;
pub mod enrollment;
pub mod escrow;
+pub mod federation;
pub mod gc;
pub mod index;
pub mod limits;
@@ -202,6 +204,23 @@ pub fn router() -> ServerRouter {
routes::drop::discard_drop,
]),
)
+ // Federation's lifecycle: minting, revoking and refreshing the capability a peer pulls
+ // with, and the signed report intake. The pull itself is `sync_feed` and `get_blob` in
+ // the read group below — federation adds no new data protocol (design/federation.md).
+ //
+ // Its own group rather than a fifth `mount` on the one above, because these four are one
+ // surface and the sixteen-operation tuple ceiling is close. A **tighter body cap** was
+ // tried here and cannot be expressed: see [`crate::limits::MAX_FEDERATION_BODY_BYTES`].
+ .group(
+ Group::::new("/")
+ .intercept(negotiation::ProtocolGate::new())
+ .mount(kynos::routes![
+ routes::federation::issue_capability,
+ routes::federation::revoke_capability,
+ routes::federation::refresh_capability,
+ routes::federation::submit_federated_report,
+ ]),
+ )
// The reads: every gated `GET` and `HEAD`. Held to the handshake's grammar, admitted at
// any protocol date, and declaring the `400` alone.
.group(
diff --git a/capsule-server/src/limits.rs b/capsule-server/src/limits.rs
index 30482d07..c2fb920c 100644
--- a/capsule-server/src/limits.rs
+++ b/capsule-server/src/limits.rs
@@ -65,6 +65,38 @@ pub fn body_size() -> BodySize {
BodySize::new(MAX_REQUEST_BODY_BYTES)
}
+/// The cap a federation write's body *would* have: **16 KiB** (`S-E2`, `S-C49`).
+///
+/// Declared and **not enforced**, which is the opposite of this module's usual rule, so it says
+/// why rather than sitting here looking like a control.
+///
+/// The transport backstop above is sized for a 16 MiB upload chunk. A federation write is
+/// nothing like one — the largest legitimate body on that surface is a signed moderation report,
+/// six short strings and a base64 signature, under a kilobyte in practice — and it matters
+/// because `POST /v1/federation/reports` is this server's only **unauthenticated** write: an
+/// anonymous caller can hand it a body two thousand times larger than any real report and have
+/// it parsed before anything looks at who is speaking.
+///
+/// Mounting a second [`BodySize`] on the federation group is the obvious fix and Kynos refuses
+/// it at compile time, correctly:
+///
+/// ```text
+/// evaluation panicked: two interceptors covering this route answer with the same status;
+/// a consumer could not tell which one replied
+/// ```
+///
+/// Both answer `413`, and an operation cannot declare two of them. The alternative — moving
+/// `BodySize` off the router and onto every group — would take `413` off the ten operations
+/// mounted outside every group, which `tests/conformance.rs` pins as declared on *every*
+/// operation, and that contract is `S-C33`'s rather than this lane's to change. Filed as #478.
+///
+/// What bounds the route meanwhile is not nothing, and is not this: every field is length-capped
+/// before any store is read ([`crate::routes::federation`]), and
+/// [`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) is charged
+/// before the peer lookup and the signature check. What remains unbounded is bytes *parsed* per
+/// request, which needs either a per-operation limit Kynos does not express or `S-C33` revisited.
+pub const MAX_FEDERATION_BODY_BYTES: u64 = 16 * 1024;
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/capsule-server/src/moderation/mod.rs b/capsule-server/src/moderation/mod.rs
index 8f1d54ba..5ec577aa 100644
--- a/capsule-server/src/moderation/mod.rs
+++ b/capsule-server/src/moderation/mod.rs
@@ -23,22 +23,32 @@
//! forget: a takedown that failed to record itself is exactly the silent operation the rule
//! forbids, and it would fail silently in the direction that hides it.
//!
-//! # What is not here, and why
+//! # The federated half (`S-C49`)
//!
-//! - **Federated report intake** needs a peer's signing key to verify against, and federation
-//! has no surface on this port. Its rate limit needs `S-C32`'s counter besides.
-//! - **The server-level blocklist** operates at the federation-capability layer, which likewise
-//! does not exist here.
+//! Both halves design/moderation.md names are now here. **Federated report intake** is
+//! [`ModerationStore::file_report`], written by `POST /v1/federation/reports` once the report's
+//! Ed25519 signature verifies against the reporting peer's operator-pinned key and the
+//! `(reporting_server, reported_user)` budget admits it; [`ModerationStore::pending_reports`] is
+//! how an operator reads the queue. The content is a hash and an album pointer and nothing else,
+//! because a report must not become a channel for a peer to say things about a user.
//!
-//! Both are `S-C8` deliverables and both are recorded as owed rather than stubbed, because a
-//! blocklist nothing consults is worse than an absent one: it reads as protection.
+//! **The server-level blocklist** is not here and is not meant to be: it operates at the
+//! federation-capability layer, so it is a column on [`crate::federation::PeerRecord`] and is
+//! consulted at mint, at every presentation, at refresh and at intake. Per-user blocks are
+//! MLS-side and never propagate.
+//!
+//! # What is still not here, and why
+//!
+//! **An admin surface.** design/moderation.md names an admin queue and an admin who acts on it,
+//! and specifies no way for that admin to authenticate. [`ModerationStore::pending_reports`] is
+//! the queue; reading it over HTTP is what waits for an admin authentication model.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use jiff::Timestamp;
-use crate::store::{AssetId, StoreFuture, UserId};
+use crate::store::{AlbumId, AssetId, StoreFuture, UserId};
/// Whether an account may act.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -117,6 +127,57 @@ pub struct ModerationEvent {
pub reason: Option,
}
+/// A moderation report one peer server filed against an account on this one (`S-C49`).
+///
+/// # The content is a pointer, not a complaint
+///
+/// design/moderation.md fixes what a federated report may carry: the reported user, the asset's
+/// **content hash** and the album it is in, and a short reason. No text about the person, no
+/// evidence blob, no copy of anything. A report is a request that this server's operator look at
+/// something it already holds — everything else would make the intake a channel for a peer to
+/// publish claims about a user into this server's storage.
+///
+/// # Re-verifiable, which means the *signed bytes* are what is kept
+///
+/// The signature is kept so an operator can re-verify it long after the fact, and so a key
+/// rotation cannot silently turn an accepted report into an unattributable one. That is only
+/// true if what is stored is what was signed — and the fields below are not: `reporting_server`
+/// is the canonical [`PeerId`](crate::federation::PeerId) form (case-folded, trailing dot
+/// stripped) rather than the string the peer sent, and `reported_at` is a parsed instant rather
+/// than the RFC 3339 text. Re-encoding those back into a claim would produce different bytes and
+/// a signature that no longer verifies.
+///
+/// So [`FederatedReport::signed`] holds the exact canonical-CBOR bytes the signature covers, and
+/// every field below is **derived from them** at intake rather than assembled beside them. An
+/// operator re-verifies with `signed` and the peer's pinned key and needs nothing else;
+/// `moderation::tests` pins the round trip.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FederatedReport {
+ /// This server's identifier for the report, a UUIDv7.
+ pub report_id: String,
+ /// The peer that filed it, as its own `server-info` names it.
+ pub reporting_server: String,
+ /// The account on **this** server the report is about.
+ pub reported_user: UserId,
+ /// The content address of the asset complained about.
+ pub asset_hash: String,
+ /// The album it was pulled from.
+ pub album_id: AlbumId,
+ /// The peer's short reason, where it gave one.
+ pub reason: Option,
+ /// When the peer says it was reported.
+ pub reported_at: Timestamp,
+ /// When this server accepted it. The only timestamp this server vouches for.
+ pub received_at: Timestamp,
+ /// The peer's Ed25519 signature over [`Self::signed`].
+ pub signature: Vec,
+ /// The exact canonical-CBOR bytes the signature covers.
+ ///
+ /// Stored verbatim, never rebuilt: every other field on this record is derived from these
+ /// bytes, and re-encoding a normalized field would produce a report nobody can attribute.
+ pub signed: Vec,
+}
+
/// The account-standing and moderation-record port.
pub trait ModerationStore: std::fmt::Debug + Send + Sync {
/// Apply `event` and move `standing` to match, as one operation.
@@ -134,8 +195,41 @@ pub trait ModerationStore: std::fmt::Debug + Send + Sync {
/// The order is part of the contract: this is a user-visible surface, and a reader following
/// what happened to their account needs it in the order it happened.
fn events_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec>;
+
+ /// Record a federated report (`S-C49`).
+ ///
+ /// Writes nothing about the reported account's standing: a peer's report is an *input* to a
+ /// decision, never a decision. Filing one has no effect a user can observe, which is why it
+ /// is not a [`ModerationEvent`] — the no-silent-operations rule is about actions taken
+ /// against a user, and nothing has been taken.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if a report with the
+ /// same `report_id` is already recorded. The id is a fresh UUIDv7 per accepted report, so a
+ /// collision is a bug rather than a retry.
+ fn file_report(&self, report: FederatedReport) -> StoreFuture<'_, ()>;
+
+ /// Every federated report on file, oldest first.
+ ///
+ /// "Pending" is the whole set until an admin surface exists to work through it — there is no
+ /// authentication model for the admin who would resolve one (see the module docs), so a
+ /// resolved state would be a column nothing could ever set.
+ fn pending_reports(&self) -> StoreFuture<'_, Vec>;
}
+/// The most federated reports the in-memory adapter keeps.
+///
+/// Reports arrive on an unauthenticated route from parties an operator pinned, and an in-memory
+/// map with no eviction is process memory that never returns — a `--memory` deployment left
+/// running would grow until it did not. Ten thousand is far above any real queue an operator
+/// works by hand and far below anything that matters to a process.
+///
+/// **Eviction is oldest-first and loud.** A dropped report is a moderation input nobody will
+/// ever see, so it is a `warn`, not a silent trim; the durable adapter (#476) is where a queue
+/// that must not lose anything belongs.
+pub const MAX_IN_MEMORY_REPORTS: usize = 10_000;
+
/// A deterministic in-memory adapter.
#[derive(Debug, Default)]
pub struct InMemoryModeration {
@@ -146,6 +240,8 @@ pub struct InMemoryModeration {
struct Inner {
standing: BTreeMap,
events: BTreeMap>,
+ /// Keyed by `report_id`, which is a UUIDv7 — so iteration order is arrival order.
+ reports: BTreeMap,
}
impl InMemoryModeration {
@@ -191,6 +287,44 @@ impl ModerationStore for InMemoryModeration {
})
}
+ fn file_report(&self, report: FederatedReport) -> StoreFuture<'_, ()> {
+ Box::pin(async move {
+ let mut inner = lock(&self.inner);
+ if inner.reports.contains_key(&report.report_id) {
+ return Err(crate::store::StoreError::Rejected {
+ store: "moderation",
+ detail: format!("report {} is already on file", report.report_id),
+ });
+ }
+ tracing::info!(
+ report = %report.report_id,
+ from = %report.reporting_server,
+ about = %report.reported_user,
+ album = %report.album_id,
+ "a federated moderation report was filed"
+ );
+ inner.reports.insert(report.report_id.clone(), report);
+ // The `report_id` is a UUIDv7, so the map's own order is arrival order and the first
+ // key is the oldest report.
+ while inner.reports.len() > MAX_IN_MEMORY_REPORTS {
+ let Some(oldest) = inner.reports.keys().next().cloned() else {
+ break;
+ };
+ tracing::warn!(
+ report = %oldest,
+ kept = MAX_IN_MEMORY_REPORTS,
+ "the in-memory report queue is full; the oldest report was dropped"
+ );
+ inner.reports.remove(&oldest);
+ }
+ Ok(())
+ })
+ }
+
+ fn pending_reports(&self) -> StoreFuture<'_, Vec> {
+ Box::pin(async move { Ok(lock(&self.inner).reports.values().cloned().collect()) })
+ }
+
fn events_for_user<'a>(&'a self, user: &'a UserId) -> StoreFuture<'a, Vec> {
Box::pin(async move {
Ok(lock(&self.inner)
diff --git a/capsule-server/src/postgres/mod.rs b/capsule-server/src/postgres/mod.rs
index 36581b0e..53abac52 100644
--- a/capsule-server/src/postgres/mod.rs
+++ b/capsule-server/src/postgres/mod.rs
@@ -48,6 +48,7 @@ pub const EXPECTED_MIGRATIONS: &[&str] = &[
"m20260902_000003_cohorts",
"m20260902_000004_quota",
"m20260902_000005_album_membership",
+ "m20260902_000006_federation",
];
/// 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 1170b886..d6151d7a 100644
--- a/capsule-server/src/routes/blob.rs
+++ b/capsule-server/src/routes/blob.rs
@@ -31,6 +31,18 @@
//! | `401` | kept, and now the framework's, with the `WWW-Authenticate` challenge |
//! | `500` | kept, with `error.blob.unavailable` |
//!
+//! # A peer fetches here too (`S-E5`)
+//!
+//! `Authorization: Bearer` carries a session token **or** a federation capability, on the same
+//! component and through the same scheme the feed uses. A peer is admitted first
+//! ([`crate::federation::admit`]) — revoked grant, blocked peer, spent events budget — and then
+//! resolves through exactly the path an account does, with two differences the principal owns:
+//! there is no transient `409` for a peer (it reports the caller's *own* device), and the
+//! authority may answer `403 error.federation.scope_insufficient` when the grant's scope does
+//! not cover the blob's role. The `403` a peer gets for a revoked grant carries
+//! `error.federation.capability_revoked`, not the account's `error.blob.access_revoked`: the
+//! two say different things about what to do next.
+//!
//! [Encryption — ranged reads]: ../../../capsule-docs/src/content/docs/design/cryptography/encryption.md
use capsule_i18n::error_codes;
@@ -39,8 +51,11 @@ use kynos::http::etag::ETag;
use kynos::prelude::*;
use kynos::response::range::served::{Conditions, Delivery, Served};
-use crate::auth::AccessToken;
-use crate::serve::{BlobSource, ServeContext, ServeResolution};
+use crate::counter::CounterContext;
+use crate::federation::{
+ self, FederationContext, Principal, ReadBearer, Refusal, VerifiedCapability,
+};
+use crate::serve::{BlobSource, ReadPrincipal, ServeContext, ServeResolution};
/// The media surface: fetching the opaque ciphertext a sync entry named.
#[derive(Tag)]
@@ -50,6 +65,14 @@ use crate::serve::{BlobSource, ServeContext, ServeResolution};
)]
pub struct MediaTag;
+/// Who is fetching, owning the credential the borrowed [`ReadPrincipal`] points into.
+enum Reader {
+ /// An account, through a session access token.
+ Account(crate::store::OwnerId),
+ /// A peer server, through an admitted federation capability (`S-E5`).
+ Peer(Box),
+}
+
/// The content address in the path.
#[derive(PathParams, Schema)]
pub struct BlobPath {
@@ -116,6 +139,51 @@ pub enum BlobRejection {
code: &'static str,
},
+ /// A peer's capability has been revoked (`S-E5`).
+ ///
+ /// The federated counterpart of [`Self::Forbidden`], and a different code because the
+ /// action is different: an account re-syncs its album membership, while a peer asks the
+ /// home server for a fresh grant or stops pulling.
+ #[error("this capability has been revoked")]
+ #[problem(status = 403, title = "Capability revoked")]
+ CapabilityRevoked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer is on this server's blocklist (`S-C49`).
+ #[error("this server is blocked")]
+ #[problem(status = 403, title = "Server blocked")]
+ PeerBlocked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer is entitled to the album but its grant does not cover this blob's role
+ /// (`S-E5`).
+ ///
+ /// A `read-derivative-only` capability asking for an `original`, or any capability asking
+ /// for a `backup`. `403` rather than `404` because the peer already knows the asset is
+ /// there — the feed told it — and a `404` would send it hunting an address that exists.
+ #[error("this capability does not cover this blob")]
+ #[problem(status = 403, title = "Scope insufficient")]
+ ScopeInsufficient {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer's events-per-hour budget is spent (invariant 21).
+ #[error("this peer has reached its request budget")]
+ #[problem(status = 429, title = "Rate budget exceeded")]
+ RateLimited {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
/// A collaborator could not answer, so nothing was decided.
#[error("the blob could not be served")]
#[problem(status = 500, title = "Internal server error")]
@@ -126,6 +194,25 @@ pub enum BlobRejection {
},
}
+impl From for BlobRejection {
+ fn from(refusal: Refusal) -> Self {
+ match refusal {
+ Refusal::Revoked => Self::CapabilityRevoked {
+ code: error_codes::FEDERATION_CAPABILITY_REVOKED,
+ },
+ Refusal::PeerBlocked => Self::PeerBlocked {
+ code: error_codes::MODERATION_SERVER_BLOCKED,
+ },
+ Refusal::RateLimited { .. } => Self::RateLimited {
+ code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED,
+ },
+ Refusal::Unavailable => Self::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ },
+ }
+ }
+}
+
impl BlobRejection {
/// A former member of the album that holds the bytes (`S-C51`).
fn forbidden() -> Self {
@@ -161,6 +248,13 @@ impl BlobRejection {
code: error_codes::BLOB_UNAVAILABLE,
}
}
+
+ /// A peer's grant does not cover this blob's role (`S-E5`).
+ fn scope_insufficient() -> Self {
+ Self::ScopeInsufficient {
+ code: error_codes::FEDERATION_SCOPE_INSUFFICIENT,
+ }
+ }
}
/// Fetch a ciphertext blob by its content address, ranged.
@@ -172,22 +266,44 @@ impl BlobRejection {
///
/// The one answer that *is* account-scoped is the transient `409`: it reports the caller's own
/// in-flight upload and nobody else's (`S-C40`).
+///
+/// A federated peer fetches here with a capability instead of a session token (`S-E5`), through
+/// the same resolution and the same authority.
#[kynos::get("/v1/blob/{hash}", operation_id = "get_blob", tag = MediaTag)]
pub async fn get_blob(
Inject(serve): Inject,
- Auth(credential): Auth,
+ Inject(federation): Inject,
+ Inject(counters): Inject,
+ Auth(principal): Auth,
Path(path): Path,
conditions: Conditions,
) -> Result, BlobRejection> {
- // The caller files under itself. Nothing about *reading* is scoped by it today — any
- // authenticated account may fetch any live address, see [`crate::serve`] — but the
- // transient `409` is, and `S-C39` is where the read authority that would scope the rest
- // arrives.
- let owner = crate::store::OwnerId::new(credential.user.as_str());
- let resolution = crate::serve::resolve(&serve, &owner, &path.hash)
+ // Who is asking, in the shape the read authority decides from. A peer is *admitted* before
+ // anything is resolved — a revoked grant, a blocked peer or a spent budget is refused
+ // without the index being touched — and only then does it become a principal.
+ let reader: Reader = match principal {
+ Principal::Session(credential) => {
+ Reader::Account(crate::store::OwnerId::new(credential.user.as_str()))
+ }
+ Principal::Peer(capability) => {
+ federation::admit(
+ &federation,
+ &counters,
+ &capability,
+ federation::Presentation::Read,
+ )
+ .await?;
+ Reader::Peer(capability)
+ }
+ };
+ let principal = match &reader {
+ Reader::Account(owner) => ReadPrincipal::Account(owner),
+ Reader::Peer(capability) => ReadPrincipal::Peer(capability),
+ };
+ let resolution = crate::serve::resolve(&serve, principal, &path.hash)
.await
.map_err(|error| {
- tracing::error!(%error, user = %credential.user, "a blob fetch could not be resolved");
+ tracing::error!(%error, reader = %principal, "a blob fetch could not be resolved");
BlobRejection::unavailable()
})?;
@@ -195,7 +311,16 @@ pub async fn get_blob(
ServeResolution::Serve { address, size } => (address, size),
ServeResolution::AwaitingUpload { .. } => return Err(BlobRejection::pending()),
ServeResolution::NotFound => return Err(BlobRejection::not_found()),
- ServeResolution::Forbidden => return Err(BlobRejection::forbidden()),
+ // The `403` says the same thing to both readers and says it differently, because what
+ // the reader does next differs: an account re-syncs its membership, a peer asks its
+ // home server for a fresh grant.
+ ServeResolution::Forbidden => {
+ return Err(match &reader {
+ Reader::Account(_) => BlobRejection::forbidden(),
+ Reader::Peer(_) => BlobRejection::from(Refusal::Revoked),
+ });
+ }
+ ServeResolution::ScopeInsufficient => return Err(BlobRejection::scope_insufficient()),
ServeResolution::Gone => return Err(BlobRejection::gone()),
};
diff --git a/capsule-server/src/routes/federation.rs b/capsule-server/src/routes/federation.rs
new file mode 100644
index 00000000..3d414e31
--- /dev/null
+++ b/capsule-server/src/routes/federation.rs
@@ -0,0 +1,1339 @@
+//! The federation capability's lifecycle: minting, revoking and refreshing (`S-E2`, `S-C49`).
+//!
+//! Not the pull path. design/federation.md is explicit that federation adds **no new data
+//! protocol** — a peer pulls through `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}`, which
+//! is [`crate::routes::sync`] and [`crate::routes::blob`]. What is here is the credential those
+//! two reads accept and the three operations that manage it.
+//!
+//! ```text
+//! POST /v1/albums/{album_id}/capabilities
+//! { peer, member, scope, ttl_seconds?, renewable_until? }
+//! 201 { token, jti, album_id, peer, member, scope, issued_at, expires_at, not_after,
+//! renewable, min_protocol_version }
+//! 400 error.federation.capability_malformed
+//! 403 error.federation.not_configured | error.moderation.server_blocked
+//! 404 error.federation.album_not_found
+//! 409 error.federation.member_not_on_roster
+//! 500 error.federation.unavailable
+//!
+//! DELETE /v1/albums/{album_id}/capabilities/{jti}
+//! 204 (idempotent)
+//! 404 error.federation.album_not_found
+//! 500 error.federation.unavailable
+//!
+//! POST /v1/federation/reports (the report's own signature is the credential)
+//! 202 { report_id, received_at }
+//! 400 error.moderation.report_malformed
+//! 401 error.moderation.report_unsigned
+//! 403 error.federation.peer_unknown | error.moderation.server_blocked
+//! | error.federation.not_configured
+//! 429 error.moderation.report_rate_limited
+//! 500 error.moderation.unavailable
+//!
+//! POST /v1/federation/capabilities/refresh (the capability itself is the credential)
+//! 200 { token, jti, expires_at, replayed }
+//! 403 error.federation.capability_invalid | error.federation.capability_revoked
+//! | error.federation.capability_expired | error.moderation.server_blocked
+//! | error.federation.not_configured
+//! 409 error.federation.member_not_on_roster
+//! 429 error.federation.rate_budget_exceeded
+//! 500 error.federation.unavailable
+//! ```
+//!
+//! # Who mints, and against what
+//!
+//! **The album's owner, through its own client.** design/federation.md puts minting on the home
+//! server at the moment the owner shares, so the credential is `Auth` and the album
+//! must be the caller's — answered `404` when it is not, the album ceremonies' "not yours is not
+//! found", so a member holding somebody else's album id learns nothing.
+//!
+//! The mint needs no key of the peer's: the token is signed with **this** server's operational
+//! Ed25519 key, the one `/.well-known/capsule/server-info` publishes, and the peer verifies it
+//! against that. A pinned peer key is needed only to verify a signed moderation report.
+//!
+//! Two facts are checked before anything is signed. The peer must not be **blocked** — the
+//! server-level blocklist operates at exactly this layer (design/moderation.md) — and the member
+//! must be on the album's **current roster**, whose `granted_epoch` is copied into the record.
+//! That epoch is the server-side half of the grant: at every presentation the member's current
+//! epoch must still equal it, so a member removed and re-admitted later gets a fresh membership
+//! and the old capability dies without anyone revoking it. It is a stored fact rather than a
+//! claim because the token format is normative and parsed by every peer.
+//!
+//! # Revoking is not gated on the deployment federating
+//!
+//! Minting and refreshing refuse `403 error.federation.not_configured` when `FEDERATION_URL` is
+//! unset: a server that does not federate does not hand out new grants. **Revoking is not**, and
+//! deliberately: turning federation off must never be the thing that takes away an operator's
+//! ability to cut a grant that is already out there. Nor does an unset `FEDERATION_URL` stop an
+//! already-minted capability verifying — a token is not un-minted by a configuration change, and
+//! silently refusing one would cut a peer off with no revocation anybody can see.
+//!
+//! # Renewability is asked for, never assumed
+//!
+//! A refresh mints a **successor**, and a successor with a fresh TTL is a grant that outlives the
+//! lifetime its owner chose unless something stops it. Nothing about "same peer, same album, same
+//! member" does: an owner who mints a deliberate sixty-second capability would get a peer that
+//! refreshes inside the minute and chains forever, leaving `ttl_seconds` advisory for exactly one
+//! hop and revocation of a `jti` the owner never saw as the only remaining control.
+//!
+//! So the record carries an **absolute deadline**, [`CapabilityRecord::not_after`], fixed at the
+//! original mint and copied unchanged into every successor — the store refuses one that carries a
+//! different deadline, so this is structural rather than a property of the route that happens to
+//! compute the TTL. The default is `not_after == expires_at`: **a grant is not renewable unless
+//! the owner said so**, by naming `renewable_until` at mint. Each successor is minted for
+//! `min(DEFAULT_TTL, not_after − now)`, so the last token of a grant is short rather than
+//! overhanging, and a refresh past the deadline is `403 error.federation.capability_expired`.
+//!
+//! The mint response states both `not_after` and a plain `renewable` flag, because an owner
+//! deciding how long to share for should not have to infer it from two timestamps.
+//!
+//! # Refresh, and why it is idempotent by construction
+//!
+//! The **previous capability** is the credential (design/federation.md: "refresh authenticated
+//! by the previous token"), so this operation takes `Auth` and refuses a session
+//! principal: an account has nothing to refresh here. The store issues the successor, links the
+//! predecessor to it and revokes the predecessor in one critical section, so a replayed refresh
+//! finds the link and is answered with **the same token** — the grant is re-signed from its
+//! stored record, and because every instant is at whole seconds and Ed25519 is deterministic the
+//! bytes are the bytes the peer already holds. That is the `(peer, jti)` idempotency
+//! threat-model/validation.md asks for, without an idempotency table.
+//!
+//! A successor answered to a replay may itself have been revoked since — a block cascades over
+//! every live capability of a peer — so its liveness is re-checked before it is re-signed.
+
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use capsule_i18n::error_codes;
+use jiff::SignedDuration;
+use kynos::prelude::*;
+use kynos::response::status::NoContent;
+use kynos::security::auth::Auth;
+use serde::{Deserialize, Serialize};
+
+use crate::album::AlbumContext;
+use crate::auth::AccessToken;
+use crate::counter::{CounterContext, CounterKey, budgets};
+use crate::federation::{
+ self, CapabilityRecord, FederationContext, MAX_GRANT_LIFETIME, MintRequest, PeerId,
+ Presentation, Principal, ReadBearer, Refusal, ReportClaim, Scope,
+};
+use crate::membership::{Membership, MembershipContext};
+use crate::moderation::{FederatedReport, ModerationContext};
+use crate::store::{AlbumId, UserId};
+
+/// The federation surface: the capability a peer server pulls a shared album with.
+#[derive(Tag)]
+#[tag(
+ name = "federation",
+ description = "Minting, revoking and refreshing the capability a peer server pulls with."
+)]
+pub struct FederationTag;
+
+/// The default life of a minted capability when the caller names none.
+///
+/// Six hours: long enough that a peer pulling an evening's photos never refreshes mid-pull,
+/// short enough that a grant nobody revokes is not a day-long hole. The ceiling is the
+/// contract's 24 hours and the codec clamps to it whatever is asked for.
+pub const DEFAULT_TTL: SignedDuration = SignedDuration::from_hours(6);
+
+/// What a capability permits, on the wire.
+///
+/// A mirror of [`Scope`] rather than the type itself, for the reason
+/// [`WireBlobRole`](crate::routes::upload::WireBlobRole) is one: the domain enum is not a schema
+/// type, and the wire spelling is a contract that should not move when an internal name does.
+#[derive(Schema, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum WireScope {
+ /// Everything a member reads: originals, derivatives, metadata, provenance.
+ Read,
+ /// Thumbnails and previews only — never originals.
+ ReadDerivativeOnly,
+}
+
+impl From for Scope {
+ fn from(scope: WireScope) -> Self {
+ match scope {
+ WireScope::Read => Self::Read,
+ WireScope::ReadDerivativeOnly => Self::ReadDerivativeOnly,
+ }
+ }
+}
+
+impl From for WireScope {
+ fn from(scope: Scope) -> Self {
+ match scope {
+ Scope::Read => Self::Read,
+ Scope::ReadDerivativeOnly => Self::ReadDerivativeOnly,
+ }
+ }
+}
+
+/// The mint request.
+#[derive(Schema, Serialize, Deserialize, Debug, Clone)]
+pub struct MintCapabilityRequest {
+ /// The peer server the grant is for, as its own `server-info` names it (`other.tld`).
+ pub peer: String,
+ /// The roster member whose access the grant carries, as the owner listed them.
+ pub member: String,
+ /// What the grant permits.
+ pub scope: WireScope,
+ /// How long **one token** should live, in seconds. Clamped to the 24-hour ceiling; absent is
+ /// six hours.
+ pub ttl_seconds: Option,
+ /// The absolute deadline the whole grant dies at, RFC 3339 — and the only thing that makes
+ /// it **renewable**.
+ ///
+ /// Absent, the default, is a grant that cannot be refreshed at all: it lives exactly
+ /// `ttl_seconds` and then the owner mints again if they still mean to share. Present, it
+ /// must be in the future and at most ninety days out.
+ pub renewable_until: Option,
+}
+
+/// A freshly minted capability.
+///
+/// The token is returned **once**. Nothing on this server can produce it again — a stored grant
+/// re-signs byte-for-byte, but only the refresh operation does that, and only for its holder.
+#[derive(Schema, Serialize, Deserialize, Debug, Clone)]
+pub struct MintedCapabilityResponse {
+ /// The signed capability, to be carried as `Authorization: Bearer`.
+ pub token: String,
+ /// Its identifier, and the key it is revoked by.
+ pub jti: String,
+ /// The album it scopes to.
+ pub album_id: String,
+ /// The peer it was minted for.
+ pub peer: String,
+ /// The roster member whose access it carries.
+ pub member: String,
+ /// What it permits.
+ pub scope: WireScope,
+ /// When it was minted, RFC 3339.
+ pub issued_at: String,
+ /// When **this token** stops being honoured, RFC 3339.
+ pub expires_at: String,
+ /// When the **whole grant** dies, RFC 3339. Equal to `expires_at` when it is not renewable.
+ pub not_after: String,
+ /// Whether a refresh may issue a successor from this grant.
+ ///
+ /// Stated plainly rather than left to be inferred from the two timestamps above: how long
+ /// an owner is sharing for is the decision this response reports back to them.
+ pub renewable: bool,
+ /// The album's pinned protocol date, which the peer must speak to pull.
+ pub min_protocol_version: String,
+}
+
+/// A refreshed capability.
+#[derive(Schema, Serialize, Deserialize, Debug, Clone)]
+pub struct RefreshedCapabilityResponse {
+ /// The successor token.
+ pub token: String,
+ /// Its identifier.
+ pub jti: String,
+ /// When this token stops being honoured, RFC 3339.
+ pub expires_at: String,
+ /// When the whole grant dies, RFC 3339 — unchanged by this or any refresh.
+ pub not_after: String,
+ /// Whether this call issued the successor, or answered one an earlier call already issued.
+ ///
+ /// Advisory. A peer never branches on it: both answers mean "here is the token to keep
+ /// pulling with".
+ pub replayed: bool,
+}
+
+/// The album a capability is minted over.
+#[derive(PathParams, Schema)]
+pub struct CapabilitiesPath {
+ /// The album's id.
+ pub album_id: String,
+}
+
+/// One capability of one album.
+#[derive(PathParams, Schema)]
+pub struct CapabilityPath {
+ /// The album's id.
+ pub album_id: String,
+ /// The capability's `jti`.
+ pub jti: String,
+}
+
+/// Why a capability was not minted.
+#[derive(Debug, thiserror::Error, ApiError)]
+pub enum MintRejection {
+ /// A field of the body is not what it must be.
+ #[error("the capability request is malformed")]
+ #[problem(status = 400, title = "Malformed request")]
+ Malformed {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// This deployment does not federate.
+ #[error("this server does not federate")]
+ #[problem(status = 403, title = "Federation not configured")]
+ NotConfigured {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer is on this server's blocklist.
+ #[error("this server is blocked")]
+ #[problem(status = 403, title = "Server blocked")]
+ PeerBlocked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// No such album, or one owned by a different account. One answer for both.
+ #[error("no such album")]
+ #[problem(status = 404, title = "Not found")]
+ NotFound {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The member named is not on the album's current roster.
+ #[error("that member is not on this album's roster")]
+ #[problem(status = 409, title = "Member not on roster")]
+ NotOnRoster {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// A collaborator could not answer, so nothing was minted.
+ #[error("the capability could not be minted")]
+ #[problem(status = 500, title = "Internal server error")]
+ Unavailable {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+}
+
+impl MintRejection {
+ /// A collaborator could not answer.
+ fn unavailable() -> Self {
+ Self::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ }
+
+ /// No such album, or not the caller's.
+ fn not_found() -> Self {
+ Self::NotFound {
+ code: error_codes::FEDERATION_ALBUM_NOT_FOUND,
+ }
+ }
+}
+
+/// Why a capability was not revoked.
+///
+/// No "unknown capability" answer: revoking is idempotent and a `jti` this album does not hold
+/// is a `204` like any other, so the operation cannot be used to probe which `jti`s exist.
+#[derive(Debug, thiserror::Error, ApiError)]
+pub enum RevokeRejection {
+ /// No such album, or one owned by a different account.
+ #[error("no such album")]
+ #[problem(status = 404, title = "Not found")]
+ NotFound {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// A collaborator could not answer, so nothing was revoked.
+ #[error("the capability could not be revoked")]
+ #[problem(status = 500, title = "Internal server error")]
+ Unavailable {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+}
+
+/// Why a capability was not refreshed.
+#[derive(Debug, thiserror::Error, ApiError)]
+pub enum RefreshRejection {
+ /// The credential is not a capability, or names a grant that cannot be continued.
+ #[error("this credential cannot be refreshed")]
+ #[problem(status = 403, title = "Capability invalid")]
+ NotRefreshable {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The capability, or the successor a replay names, has been revoked.
+ #[error("this capability has been revoked")]
+ #[problem(status = 403, title = "Capability revoked")]
+ Revoked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer is on this server's blocklist.
+ #[error("this server is blocked")]
+ #[problem(status = 403, title = "Server blocked")]
+ PeerBlocked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The grant's absolute deadline has passed, or the owner never made it renewable.
+ ///
+ /// The end of the sharing relationship rather than of one token: no successor will ever be
+ /// issued from it, and the peer's next move is to ask the album's owner, not this server.
+ #[error("this grant cannot be renewed any further")]
+ #[problem(status = 403, title = "Capability expired")]
+ GrantExpired {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The member the grant was minted for is no longer on the album's roster at its epoch.
+ #[error("that member is not on this album's roster")]
+ #[problem(status = 409, title = "Member not on roster")]
+ NotOnRoster {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// This deployment does not federate.
+ #[error("this server does not federate")]
+ #[problem(status = 403, title = "Federation not configured")]
+ NotConfigured {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer's events-per-hour budget is spent.
+ #[error("this peer has reached its request budget")]
+ #[problem(status = 429, title = "Rate budget exceeded")]
+ RateLimited {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// A collaborator could not answer, so nothing was refreshed.
+ #[error("the capability could not be refreshed")]
+ #[problem(status = 500, title = "Internal server error")]
+ Unavailable {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+}
+
+impl From for RefreshRejection {
+ fn from(refusal: Refusal) -> Self {
+ match refusal {
+ Refusal::Revoked => Self::Revoked {
+ code: error_codes::FEDERATION_CAPABILITY_REVOKED,
+ },
+ Refusal::PeerBlocked => Self::PeerBlocked {
+ code: error_codes::MODERATION_SERVER_BLOCKED,
+ },
+ Refusal::RateLimited { .. } => Self::RateLimited {
+ code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED,
+ },
+ Refusal::Unavailable => Self::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ },
+ }
+ }
+}
+
+// ===========================================================================================
+// Operations
+// ===========================================================================================
+
+/// Mint a capability letting one peer server pull one album.
+///
+/// The token is in the response and nowhere else: this server keeps the record, never the
+/// credential.
+#[kynos::post(
+ "/v1/albums/{album_id}/capabilities",
+ operation_id = "issue_capability",
+ tag = FederationTag
+)]
+pub async fn issue_capability(
+ Inject(federation): Inject,
+ Inject(albums): Inject,
+ Inject(membership): Inject,
+ Auth(credential): Auth,
+ Path(path): Path,
+ Json(request): Json,
+) -> Result {
+ if !federation.is_configured() {
+ tracing::info!("a capability was refused: this deployment does not federate");
+ return Err(MintRejection::NotConfigured {
+ code: error_codes::FEDERATION_NOT_CONFIGURED,
+ });
+ }
+ let peer = PeerId::new(request.peer.trim());
+ // A peer id is an origin, and an empty or whitespace one is a client bug rather than an
+ // unknown server. Checked before the store so a blank never becomes a row.
+ if peer.as_str().is_empty() || request.member.trim().is_empty() {
+ return Err(MintRejection::Malformed {
+ code: error_codes::FEDERATION_CAPABILITY_MALFORMED,
+ });
+ }
+ let member = UserId::new(request.member.trim());
+ let album = AlbumId::new(&path.album_id);
+
+ // The album must be the caller's, and "not yours" is "not found" — the same answer the
+ // roster and upgrade ceremonies give, so an album id somebody else holds discloses nothing.
+ let record = albums.albums().read(&album).await.map_err(|error| {
+ tracing::error!(%error, %album, "the album store could not answer a capability mint");
+ MintRejection::unavailable()
+ })?;
+ let Some(record) = record.filter(|record| record.owner_id.as_str() == credential.user.as_str())
+ else {
+ tracing::info!(user = %credential.user, %album, "a mint was refused: no such album, or not the caller's");
+ return Err(MintRejection::not_found());
+ };
+
+ // The blocklist, at the layer design/moderation.md puts it: a blocked peer is refused a new
+ // grant as surely as it is refused a presentation.
+ let blocked = federation
+ .peers()
+ .read(&peer)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %peer, "the peer store could not answer a capability mint");
+ MintRejection::unavailable()
+ })?
+ .is_some_and(|record| record.is_blocked());
+ if blocked {
+ tracing::info!(%peer, %album, "a mint was refused: the peer is blocked");
+ return Err(MintRejection::PeerBlocked {
+ code: error_codes::MODERATION_SERVER_BLOCKED,
+ });
+ }
+
+ // The membership, and the epoch it was granted at — the server-side half of the grant.
+ let membership = membership
+ .members()
+ .membership(&album, &member)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %album, "the membership store could not answer a capability mint");
+ MintRejection::unavailable()
+ })?;
+ let Membership::Member { granted_epoch, .. } = membership else {
+ tracing::info!(%album, %member, ?membership, "a mint was refused: the member is not on the roster");
+ return Err(MintRejection::NotOnRoster {
+ code: error_codes::FEDERATION_MEMBER_NOT_ON_ROSTER,
+ });
+ };
+
+ let scope = Scope::from(request.scope);
+ let ttl = request
+ .ttl_seconds
+ .and_then(|seconds| i64::try_from(seconds).ok())
+ .map_or(DEFAULT_TTL, SignedDuration::from_secs);
+
+ // The absolute deadline, and the only way a grant becomes renewable at all. Parsed before
+ // anything is signed, so a malformed date costs a `400` rather than a recorded grant.
+ let now = federation.clock().now();
+ let renewable_until = match request.renewable_until.as_deref() {
+ None => None,
+ Some(text) => {
+ let Ok(until) = text.parse::() else {
+ tracing::info!(%album, "a mint named an unreadable renewable_until");
+ return Err(MintRejection::Malformed {
+ code: error_codes::FEDERATION_CAPABILITY_MALFORMED,
+ });
+ };
+ if until <= now || until > crate::store::deadline(now, MAX_GRANT_LIFETIME) {
+ tracing::info!(%album, %until, "a mint named a deadline outside the permitted window");
+ return Err(MintRejection::Malformed {
+ code: error_codes::FEDERATION_CAPABILITY_MALFORMED,
+ });
+ }
+ Some(until)
+ }
+ };
+ let minted = federation
+ .codec()
+ .mint(&MintRequest {
+ peer: peer.clone(),
+ album: album.clone(),
+ scope,
+ // The album's pin, not the server's: a peer must speak what the album speaks.
+ min_protocol_version: record.protocol_version.clone(),
+ ttl,
+ })
+ .map_err(|error| {
+ tracing::error!(%error, %album, "a capability could not be signed");
+ MintRejection::unavailable()
+ })?;
+
+ // A deadline earlier than the token's own expiry would be a grant that dies before its first
+ // token does, which is not a thing an owner can mean; the token wins and the grant is simply
+ // not renewable.
+ let not_after = renewable_until.map_or(minted.grant.expires_at, |until| {
+ until.max(minted.grant.expires_at)
+ });
+ federation
+ .capabilities()
+ .issue(CapabilityRecord {
+ jti: minted.grant.jti.clone(),
+ album_id: album.clone(),
+ peer_id: peer.clone(),
+ member: member.clone(),
+ scope,
+ granted_epoch,
+ min_protocol_version: minted.grant.min_protocol_version.clone(),
+ issued_at: minted.grant.issued_at,
+ expires_at: minted.grant.expires_at,
+ not_after,
+ revoked_at: None,
+ refreshed_to: None,
+ })
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %album, jti = %minted.grant.jti, "a minted capability could not be recorded");
+ MintRejection::unavailable()
+ })?;
+
+ Ok(MintReply::Created(MintedCapabilityResponse {
+ token: minted.token,
+ jti: minted.grant.jti,
+ album_id: album.as_str().to_owned(),
+ peer: peer.as_str().to_owned(),
+ member: member.as_str().to_owned(),
+ scope: scope.into(),
+ issued_at: minted.grant.issued_at.to_string(),
+ expires_at: minted.grant.expires_at.to_string(),
+ not_after: not_after.to_string(),
+ renewable: not_after > minted.grant.expires_at,
+ min_protocol_version: minted.grant.min_protocol_version,
+ }))
+}
+
+/// The one way minting succeeds.
+///
+/// A `Reply` rather than a bare `Json` because a mint **creates** a grant, and `201` is what
+/// says so; Kynos's `Created` wants a `Location` and there is no URL for a capability — the
+/// server never serves one back.
+#[derive(Reply)]
+pub enum MintReply {
+ /// The grant was minted and recorded.
+ #[reply(status = 201, description = "The capability was minted")]
+ Created(MintedCapabilityResponse),
+}
+
+/// Revoke one capability of one album.
+///
+/// Idempotent, and silent about what it did: a `jti` that is not a live capability of this
+/// album — never issued, already revoked, or another album's — is the same `204` a revocation
+/// is, so the operation is not a probe over identifiers.
+#[kynos::delete(
+ "/v1/albums/{album_id}/capabilities/{jti}",
+ operation_id = "revoke_capability",
+ tag = FederationTag
+)]
+pub async fn revoke_capability(
+ Inject(federation): Inject,
+ Inject(albums): Inject,
+ Auth(credential): Auth,
+ Path(path): Path,
+) -> Result {
+ let album = AlbumId::new(&path.album_id);
+ let record = albums.albums().read(&album).await.map_err(|error| {
+ tracing::error!(%error, %album, "the album store could not answer a capability revoke");
+ RevokeRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+ if record.is_none_or(|record| record.owner_id.as_str() != credential.user.as_str()) {
+ tracing::info!(user = %credential.user, %album, "a revoke was refused: no such album, or not the caller's");
+ return Err(RevokeRejection::NotFound {
+ code: error_codes::FEDERATION_ALBUM_NOT_FOUND,
+ });
+ }
+
+ // The grant must be this album's. Without the check an owner could revoke a grant over
+ // somebody else's album by naming its `jti`.
+ let held = federation
+ .capabilities()
+ .find(&path.jti)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %album, "the capability store could not be read for a revoke");
+ RevokeRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+ match held {
+ Some(held) if held.album_id == album => {
+ let outcome = federation
+ .capabilities()
+ .revoke_issued(&path.jti, federation.clock().now())
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %album, jti = %path.jti, "a capability could not be revoked");
+ RevokeRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+ tracing::info!(%album, jti = %path.jti, ?outcome, "an owner revoked a federation capability");
+ }
+ _ => {
+ tracing::debug!(%album, jti = %path.jti, "a revoke named no live capability of this album");
+ }
+ }
+ Ok(NoContent)
+}
+
+/// Exchange a capability for its successor.
+///
+/// The credential **is** the capability being refreshed; a session token has nothing to refresh
+/// here and is refused.
+#[kynos::post(
+ "/v1/federation/capabilities/refresh",
+ operation_id = "refresh_capability",
+ tag = FederationTag
+)]
+pub async fn refresh_capability(
+ Inject(federation): Inject,
+ Inject(membership): Inject,
+ Inject(counters): Inject,
+ Auth(principal): Auth,
+) -> Result, RefreshRejection> {
+ let Principal::Peer(capability) = principal else {
+ tracing::info!("a session token was presented on the capability refresh");
+ return Err(RefreshRejection::NotRefreshable {
+ code: error_codes::FEDERATION_CAPABILITY_INVALID,
+ });
+ };
+ // The same admission every federated read passes: live grant, unblocked peer, budget.
+ federation::admit(&federation, &counters, &capability, Presentation::Refresh).await?;
+ if !federation.is_configured() {
+ // A grant minted while this server federated still *verifies* — a token is not
+ // un-minted by a configuration change — but it is not continued.
+ tracing::info!(peer = %capability.record.peer_id, "a refresh was refused: this deployment does not federate");
+ return Err(RefreshRejection::NotConfigured {
+ code: error_codes::FEDERATION_NOT_CONFIGURED,
+ });
+ }
+
+ let predecessor = &capability.record;
+ let now = federation.clock().now();
+
+ // The absolute deadline the original mint fixed. A grant the owner did not make renewable
+ // has `not_after == expires_at` and fails here on its own first refresh, which is the
+ // point: renewability is asked for, not assumed.
+ if !predecessor.may_refresh_at(now) {
+ tracing::info!(
+ peer = %predecessor.peer_id,
+ jti = %predecessor.jti,
+ not_after = %predecessor.not_after,
+ renewable = predecessor.is_renewable(),
+ "a refresh was refused: the grant's deadline has passed"
+ );
+ return Err(RefreshRejection::GrantExpired {
+ code: error_codes::FEDERATION_CAPABILITY_EXPIRED,
+ });
+ }
+
+ // The membership the grant was minted for, re-asked. The read path checks this too, so
+ // nothing is *exposed* by skipping it — but a server that kept minting successors for a
+ // membership that has ended would be issuing tokens that can never be used, and writing a
+ // row for each.
+ match membership
+ .members()
+ .membership(&predecessor.album_id, &predecessor.member)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, album = %predecessor.album_id, "the membership store could not answer a refresh");
+ RefreshRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })? {
+ Membership::Member { granted_epoch, .. } if granted_epoch == predecessor.granted_epoch => {}
+ membership => {
+ tracing::info!(
+ peer = %predecessor.peer_id,
+ member = %predecessor.member,
+ album = %predecessor.album_id,
+ ?membership,
+ granted_epoch = predecessor.granted_epoch,
+ "a refresh was refused: its member is not on the roster at the granted epoch"
+ );
+ return Err(RefreshRejection::NotOnRoster {
+ code: error_codes::FEDERATION_MEMBER_NOT_ON_ROSTER,
+ });
+ }
+ }
+
+ // The successor carries everything the predecessor granted, unchanged: the store refuses a
+ // successor that names another peer, album, member **or deadline**, so a refresh can neither
+ // widen a grant nor outlive one. Its TTL is whatever is left of the grant, capped at the
+ // default — so the last token of a grant is short rather than overhanging its deadline.
+ let remaining = predecessor.not_after.duration_since(now);
+ let minted = federation
+ .codec()
+ .mint(&MintRequest {
+ peer: predecessor.peer_id.clone(),
+ album: predecessor.album_id.clone(),
+ scope: predecessor.scope,
+ min_protocol_version: predecessor.min_protocol_version.clone(),
+ ttl: DEFAULT_TTL.min(remaining),
+ })
+ .map_err(|error| {
+ tracing::error!(%error, "a successor capability could not be signed");
+ RefreshRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+ let successor = CapabilityRecord {
+ jti: minted.grant.jti.clone(),
+ album_id: predecessor.album_id.clone(),
+ peer_id: predecessor.peer_id.clone(),
+ member: predecessor.member.clone(),
+ scope: predecessor.scope,
+ granted_epoch: predecessor.granted_epoch,
+ min_protocol_version: predecessor.min_protocol_version.clone(),
+ issued_at: minted.grant.issued_at,
+ expires_at: minted.grant.expires_at,
+ not_after: predecessor.not_after,
+ revoked_at: None,
+ refreshed_to: None,
+ };
+
+ let outcome = federation
+ .capabilities()
+ .refresh(&predecessor.jti, successor, now)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, jti = %predecessor.jti, "a capability could not be refreshed");
+ RefreshRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+
+ let (record, token, replayed) = match outcome {
+ crate::federation::RefreshOutcome::Issued(record) => (record, minted.token, false),
+ crate::federation::RefreshOutcome::AlreadyRefreshed(record) => {
+ // The successor an earlier call issued. It may have been revoked since — a block
+ // cascades over every live grant of a peer — so its liveness is asked again before
+ // it is handed back.
+ if !record.is_live(now) {
+ tracing::info!(jti = %record.jti, "a replayed refresh named a successor that is no longer live");
+ return Err(RefreshRejection::Revoked {
+ code: error_codes::FEDERATION_CAPABILITY_REVOKED,
+ });
+ }
+ let token = federation.codec().sign(&record.grant()).map_err(|error| {
+ tracing::error!(%error, jti = %record.jti, "a stored grant could not be re-signed");
+ RefreshRejection::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ }
+ })?;
+ (record, token, true)
+ }
+ // The predecessor was revoked without a successor, or was never issued here. Neither is
+ // continuable, and both are what a revoked grant is told.
+ outcome => {
+ tracing::info!(jti = %predecessor.jti, ?outcome, "a refresh named a grant that cannot be continued");
+ return Err(RefreshRejection::Revoked {
+ code: error_codes::FEDERATION_CAPABILITY_REVOKED,
+ });
+ }
+ };
+
+ tracing::info!(
+ peer = %record.peer_id,
+ album = %record.album_id,
+ predecessor = %predecessor.jti,
+ successor = %record.jti,
+ replayed,
+ "a federation capability was refreshed"
+ );
+ Ok(Json(RefreshedCapabilityResponse {
+ token,
+ jti: record.jti,
+ expires_at: record.expires_at.to_string(),
+ not_after: record.not_after.to_string(),
+ replayed,
+ }))
+}
+
+// ===========================================================================================
+// Federated moderation report intake (S-C49)
+// ===========================================================================================
+
+/// The most bytes any one field of a federated report may carry.
+///
+/// Every one of them ends up in a store row, a log line or a counter key, and none of them has a
+/// natural bound from the type system: `reported_user`, `asset_hash` and `album_id` are strings a
+/// peer chooses. The caps are generous against the real values — a DNS name is at most 253 bytes,
+/// a UUID is 36, a SHA-256 hex digest is 64 — and their point is that *some* bound exists before
+/// anything is stored or keyed on.
+mod report_bounds {
+ /// A peer's origin: RFC 1035's ceiling on a domain name.
+ pub(super) const ORIGIN: usize = 253;
+ /// An account or album identifier: a UUID with room to spare.
+ pub(super) const IDENTIFIER: usize = 64;
+ /// A content address: a SHA-256 digest as lowercase hex.
+ pub(super) const HASH: usize = 64;
+ /// The peer's short reason. A sentence, not a case file — the contract's "short reason".
+ pub(super) const REASON: usize = 256;
+ /// An RFC 3339 instant, with room for any offset spelling.
+ pub(super) const INSTANT: usize = 64;
+ /// A base64 Ed25519 signature is 88 bytes; this leaves room for padding variants.
+ pub(super) const SIGNATURE: usize = 128;
+}
+
+/// A moderation report one peer server files against an account on this one.
+///
+/// Every field except `signature` is covered by the signature, in canonical CBOR — see
+/// [`ReportClaim`](crate::federation::ReportClaim).
+#[derive(Schema, Serialize, Deserialize, Debug, Clone)]
+pub struct FederatedReportRequest {
+ /// The peer filing the report, as its own `server-info` names it.
+ pub reporting_server: String,
+ /// The account on this server the report is about.
+ pub reported_user: String,
+ /// The content address of the asset complained about.
+ pub asset_hash: String,
+ /// The album it was pulled from.
+ pub album_id: String,
+ /// A short reason, where the peer gives one.
+ pub reason: Option,
+ /// When the peer says it was reported, RFC 3339.
+ pub reported_at: String,
+ /// The peer's Ed25519 signature over the canonical CBOR of the fields above, base64.
+ pub signature: String,
+}
+
+/// An accepted report.
+///
+/// The identifier is this server's, so an operator and the reporting peer can talk about one
+/// report. Nothing about the reported account is echoed — accepting a report says nothing about
+/// whether it is true, and a body that reported on the account's standing would say it does.
+#[derive(Schema, Serialize, Deserialize, Debug, Clone)]
+pub struct FederatedReportResponse {
+ /// This server's identifier for the report.
+ pub report_id: String,
+ /// When this server accepted it, RFC 3339.
+ pub received_at: String,
+}
+
+/// Why a report was not accepted.
+#[derive(Debug, thiserror::Error, ApiError)]
+pub enum ReportRejection {
+ /// A field of the body is not what it must be.
+ #[error("the report is malformed")]
+ #[problem(status = 400, title = "Malformed report")]
+ Malformed {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The signature does not verify under the peer's pinned key.
+ #[error("the report's signature could not be verified")]
+ #[problem(status = 401, title = "Report unsigned")]
+ Unsigned {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// No operator has pinned a key for the reporting server.
+ #[error("this server is not one we know")]
+ #[problem(status = 403, title = "Peer unknown")]
+ PeerUnknown {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The reporting server is on this server's blocklist.
+ #[error("this server is blocked")]
+ #[problem(status = 403, title = "Server blocked")]
+ PeerBlocked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// This deployment does not federate.
+ #[error("this server does not federate")]
+ #[problem(status = 403, title = "Federation not configured")]
+ NotConfigured {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// Too many reports from this peer about this account.
+ #[error("too many reports from this server about this account")]
+ #[problem(status = 429, title = "Report rate limited")]
+ RateLimited {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// A collaborator could not answer, so nothing was filed.
+ #[error("the report could not be filed")]
+ #[problem(status = 500, title = "Internal server error")]
+ Unavailable {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+}
+
+impl ReportRejection {
+ /// A field of the body is not what it must be.
+ fn malformed() -> Self {
+ Self::Malformed {
+ code: error_codes::MODERATION_REPORT_MALFORMED,
+ }
+ }
+
+ /// A collaborator could not answer.
+ fn unavailable() -> Self {
+ Self::Unavailable {
+ code: error_codes::MODERATION_UNAVAILABLE,
+ }
+ }
+}
+
+/// File a signed moderation report from a peer server.
+///
+/// # Its only reachable answer today is `403`
+///
+/// A report is verified against the peer's **operator-pinned** key, and nothing can pin one:
+/// [`boot::assemble`](crate::boot::assemble) refuses the durable backend until #403 lands its
+/// adapters, so an operator command that pinned a peer could only run against `serve --memory`
+/// and would forget the moment it exited. The command is owed with #476. Until it lands this
+/// operation answers `403 error.federation.peer_unknown` to every real peer.
+///
+/// It is mounted anyway, deliberately: a peer implementing against the published contract needs
+/// the operation to exist and to answer honestly, and what is missing is the command, not the
+/// surface. What is *not* acceptable is a route that reads as protection it cannot provide —
+/// hence this paragraph, and the matching status notes in design/moderation.md and
+/// design/federation.md.
+///
+/// # No bearer, and why that is not "unauthenticated"
+///
+/// The reporting peer holds no capability here — it is reporting *this* server's content, not
+/// pulling it — so there is nothing to present. What it does hold is a key an operator has
+/// **pinned**, and the report carries its own Ed25519 signature over the canonical CBOR of every
+/// other field. A report from a server nobody has pinned is `403`: intake is not the moment a
+/// peer becomes trusted (design/federation.md's TOFU is explicitly not done here).
+///
+/// # The order the checks run in
+///
+/// Bounds, then how much may be asked for at all, then who is speaking, then whether they are
+/// welcome, then whether they really said it, then whose account it is, then whether they have
+/// said it too often.
+///
+/// Every field is length-capped first, before a store is read or a byte is keyed on. Then
+/// [`CounterKey::FederatedIntake`](crate::counter::CounterKey::FederatedIntake) — keyed on the
+/// *claimed* origin, so it bounds one origin looping rather than a caller cycling origins, which
+/// is the most this server can do without a trusted client address. Everything after it is a
+/// store read and an Ed25519 verification, and this is the only place a bound on that work can
+/// sit.
+///
+/// The **policy** budgets are charged last, after the signature verifies, so a third party
+/// spoofing `reporting_server` cannot spend a real peer's allowance. Two of them: the contract's
+/// per-`(server, account)` limit, and a per-peer ceiling that ignores the account, because
+/// `reported_user` is a string the peer chooses and a peer cycling accounts would otherwise mint
+/// itself a fresh allowance each time.
+///
+/// What is *not* bounded is bytes parsed per request: a per-operation body cap cannot be
+/// expressed against this framework, and the reason is recorded on
+/// [`MAX_FEDERATION_BODY_BYTES`](crate::limits::MAX_FEDERATION_BODY_BYTES) (issue #478).
+///
+/// # What accepting one does
+///
+/// It writes a row an operator will read ([`ModerationStore::pending_reports`]) and **nothing
+/// else**. A peer's report is an input to a decision, never a decision: no standing changes, no
+/// serving hold appears, and the reported account sees nothing — because nothing has been done
+/// to them.
+///
+/// # `202` whether or not the account exists
+///
+/// A report naming an account this server does not host is **accepted on the wire and dropped**,
+/// with a `warn` for the operator. It is not filed: an unresolvable report is a permanent orphan
+/// row that nobody can act on, which is the reason the check exists at all.
+///
+/// The answer is deliberately the same one a filed report gets. An earlier version refused with a
+/// distinct coded `404`, and that manufactured an account-enumeration oracle out of a check that
+/// did not need one: a pinned peer could walk identifiers and read existence off the status line.
+/// "Pinned" is not "trusted with enumeration" — a peer key can be compromised, and a peer can be
+/// adversarial toward its own users while remaining an operator's legitimate partner — and this
+/// codebase treats exists-versus-does-not as a first-order defect nearly everywhere else
+/// ([`crate::routes::enroll`]'s indistinguishable code refusal, the album ceremonies' "not yours
+/// is not found", [`crate::serve::authority`]'s `404`/`403` boundary).
+///
+/// Probing is not free even so: every budget above is charged before this point is reached, so a
+/// peer sweeping identifiers spends its allowance doing it and an operator sees the `warn`.
+#[kynos::post(
+ "/v1/federation/reports",
+ operation_id = "submit_federated_report",
+ tag = FederationTag
+)]
+pub async fn submit_federated_report(
+ Inject(federation): Inject,
+ Inject(moderation): Inject,
+ Inject(auth): Inject,
+ Inject(counters): Inject,
+ Json(request): Json,
+) -> Result {
+ if !federation.is_configured() {
+ tracing::info!("a federated report was refused: this deployment does not federate");
+ return Err(ReportRejection::NotConfigured {
+ code: error_codes::FEDERATION_NOT_CONFIGURED,
+ });
+ }
+ // Structural bounds first, on every field, before a store is touched or a byte is keyed on.
+ // Each of these ends up in a row, a log line or a counter key, and none of them is bounded
+ // by anything but this: the body cap the federation group mounts stops a caller sending
+ // megabytes, and this stops one field of a legal body being all of them.
+ for (name, value, cap) in [
+ (
+ "reporting_server",
+ request.reporting_server.trim(),
+ report_bounds::ORIGIN,
+ ),
+ (
+ "reported_user",
+ request.reported_user.trim(),
+ report_bounds::IDENTIFIER,
+ ),
+ ("asset_hash", request.asset_hash.trim(), report_bounds::HASH),
+ (
+ "album_id",
+ request.album_id.trim(),
+ report_bounds::IDENTIFIER,
+ ),
+ (
+ "reported_at",
+ request.reported_at.trim(),
+ report_bounds::INSTANT,
+ ),
+ (
+ "signature",
+ request.signature.trim(),
+ report_bounds::SIGNATURE,
+ ),
+ (
+ "reason",
+ request.reason.as_deref().unwrap_or("x").trim(),
+ report_bounds::REASON,
+ ),
+ ] {
+ if value.is_empty() || value.len() > cap {
+ tracing::info!(
+ field = name,
+ length = value.len(),
+ "a federated report's field is out of bounds"
+ );
+ return Err(ReportRejection::malformed());
+ }
+ }
+ let peer = PeerId::new(request.reporting_server.trim());
+ if peer.as_str().is_empty() {
+ return Err(ReportRejection::malformed());
+ }
+ let Ok(reported_at) = request.reported_at.parse::() else {
+ tracing::info!(%peer, "a federated report carried an unreadable reported_at");
+ return Err(ReportRejection::malformed());
+ };
+ let Ok(signature) = BASE64.decode(request.signature.as_bytes()) else {
+ tracing::info!(%peer, "a federated report's signature is not base64");
+ return Err(ReportRejection::malformed());
+ };
+
+ // The bound on how much work an anonymous caller may ask for, charged **before** the peer
+ // is looked up — everything past this line is a store read and an Ed25519 verification. The
+ // key is the *claimed* origin, which is attacker-chosen: it bounds one origin looping and
+ // not a caller cycling origins, because this server has no trusted client address to key on
+ // instead. Stated here rather than left to look like more than it is.
+ charge(
+ &counters,
+ &CounterKey::FederatedIntake(peer.as_str().to_owned()),
+ budgets::FEDERATED_INTAKE,
+ )
+ .await?;
+
+ // Who is speaking. A peer nobody pinned, and a peer pinned without a key, are the same
+ // answer: there is nothing to verify against, so nothing is verified.
+ let record = federation.peers().read(&peer).await.map_err(|error| {
+ tracing::error!(%error, %peer, "the peer store could not answer a report intake");
+ ReportRejection::unavailable()
+ })?;
+ let Some(record) = record else {
+ tracing::info!(%peer, "a report was refused: the peer is unknown");
+ return Err(ReportRejection::PeerUnknown {
+ code: error_codes::FEDERATION_PEER_UNKNOWN,
+ });
+ };
+ if record.is_blocked() {
+ tracing::info!(%peer, "a report was refused: the peer is blocked");
+ return Err(ReportRejection::PeerBlocked {
+ code: error_codes::MODERATION_SERVER_BLOCKED,
+ });
+ }
+ let Some(key) = record.signing_key else {
+ tracing::info!(%peer, "a report was refused: the peer has no pinned key");
+ return Err(ReportRejection::PeerUnknown {
+ code: error_codes::FEDERATION_PEER_UNKNOWN,
+ });
+ };
+
+ // The claim is built from the body's fields with **surrounding whitespace trimmed and
+ // nothing else** — the one normalization rule, written into design/federation.md so a peer
+ // implementing from the doc signs the bytes this verifies. In particular the peer's own
+ // `reporting_server` string is signed as sent, not folded to the canonical `PeerId` form
+ // used for the lookup, and `reported_at` is the RFC 3339 text and not a re-rendered instant.
+ let claim = ReportClaim {
+ reporting_server: request.reporting_server.trim().to_owned(),
+ reported_user: request.reported_user.trim().to_owned(),
+ asset_hash: request.asset_hash.trim().to_owned(),
+ album_id: request.album_id.trim().to_owned(),
+ reason: request.reason.clone(),
+ reported_at: request.reported_at.trim().to_owned(),
+ };
+ // Kept verbatim: these are the bytes the signature covers, and the only thing an operator
+ // re-verifying months later can use. Every stored field is derived from this claim.
+ let signed = claim
+ .signing_bytes()
+ .map_err(|_| ReportRejection::unavailable())?;
+ claim
+ .verify(&signature, &key)
+ .map_err(|error| match error {
+ crate::federation::ReportError::NotAuthentic => ReportRejection::Unsigned {
+ code: error_codes::MODERATION_REPORT_UNSIGNED,
+ },
+ crate::federation::ReportError::Unencodable => ReportRejection::unavailable(),
+ })?;
+
+ // Does the account exist here? The answer decides whether a row is written and **never what
+ // the peer is told** — see the module docs. A report naming an account this server does not
+ // host is accepted on the wire and dropped with a `warn`, which is the pattern
+ // [`crate::routes::enroll`] uses for unknown-versus-spent-versus-expired codes: log so an
+ // operator can act, never tell the asker.
+ let reported_user = UserId::new(&claim.reported_user);
+ let hosted = crate::auth::AccountProfiles::read(auth.profiles(), &reported_user)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %peer, "the account directory could not answer a report intake");
+ ReportRejection::unavailable()
+ })?
+ .is_some();
+
+ // Only now are the *policy* budgets charged: a spoofed `reporting_server` must not be able
+ // to spend a real peer's allowance. Two of them — the contract bounds reports per
+ // `(server, account)`, and a peer cycling accounts would mint itself a fresh allowance each
+ // time, so a ceiling that ignores the account is what actually bounds the peer.
+ charge(
+ &counters,
+ &CounterKey::PeerReports(peer.as_str().to_owned()),
+ budgets::PEER_REPORTS,
+ )
+ .await?;
+ charge(
+ &counters,
+ &CounterKey::FederatedReports(format!("{peer}:{}", claim.reported_user)),
+ budgets::FEDERATED_REPORTS,
+ )
+ .await?;
+
+ let received_at = federation.clock().now();
+ if !hosted {
+ // Logged at `warn` rather than `info`: a peer repeatedly reporting accounts this server
+ // does not host is either misrouting or probing, and both are things an operator wants
+ // to see. The budgets above were charged either way, so probing is not free.
+ tracing::warn!(
+ %peer,
+ "a federated report named an account this server does not host; accepted and dropped"
+ );
+ return Ok(ReportReply::Accepted(FederatedReportResponse {
+ // A fresh identifier, as an accepted report gets. It names nothing this server
+ // stored, and that is the point: the answer must not vary with what exists.
+ report_id: uuid::Uuid::now_v7().to_string(),
+ received_at: received_at.to_string(),
+ }));
+ }
+
+ let report = FederatedReport {
+ report_id: uuid::Uuid::now_v7().to_string(),
+ reporting_server: peer.as_str().to_owned(),
+ reported_user,
+ asset_hash: claim.asset_hash.clone(),
+ album_id: AlbumId::new(&claim.album_id),
+ reason: claim.reason.clone(),
+ reported_at,
+ received_at,
+ signature,
+ signed,
+ };
+ let report_id = report.report_id.clone();
+ moderation
+ .store()
+ .file_report(report)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %peer, "a federated report could not be filed");
+ ReportRejection::unavailable()
+ })?;
+
+ Ok(ReportReply::Accepted(FederatedReportResponse {
+ report_id,
+ received_at: received_at.to_string(),
+ }))
+}
+
+/// Charge `budget` under `key`, rendering the refusals this route gives.
+///
+/// One helper for three budgets so they cannot answer differently: a spent budget is `429` with
+/// the contract's code, and a counter that cannot be reached is `500` and never an admission —
+/// a limiter that failed open would be one an attacker turns off by loading the counter store.
+async fn charge(
+ counters: &CounterContext,
+ key: &CounterKey,
+ budget: crate::counter::Budget,
+) -> Result<(), ReportRejection> {
+ match counters.hit(key, budget).await.map_err(|error| {
+ tracing::error!(%error, kind = key.as_str(), "a report counter could not be reached");
+ ReportRejection::unavailable()
+ })? {
+ crate::counter::Verdict::Admitted { .. } => Ok(()),
+ crate::counter::Verdict::Limited { retry_after } => {
+ tracing::info!(kind = key.as_str(), %retry_after, "a federated report budget is spent");
+ Err(ReportRejection::RateLimited {
+ code: error_codes::MODERATION_REPORT_RATE_LIMITED,
+ })
+ }
+ }
+}
+
+/// The one way intake succeeds.
+///
+/// `202`, never `201`: this server has accepted the report for an operator to look at, and has
+/// created nothing the reporting peer can address. A `200` would read as "handled".
+#[derive(Reply)]
+pub enum ReportReply {
+ /// The report was filed for an operator to read.
+ #[reply(status = 202, description = "The report was accepted for review")]
+ Accepted(FederatedReportResponse),
+}
diff --git a/capsule-server/src/routes/mod.rs b/capsule-server/src/routes/mod.rs
index 59b709ce..467d0af1 100644
--- a/capsule-server/src/routes/mod.rs
+++ b/capsule-server/src/routes/mod.rs
@@ -15,6 +15,7 @@ pub mod directory;
pub mod drop;
pub mod enroll;
pub mod escrow;
+pub mod federation;
pub mod moderation;
pub mod ops;
pub mod profile;
diff --git a/capsule-server/src/routes/roster.rs b/capsule-server/src/routes/roster.rs
index 306a85c5..291e55af 100644
--- a/capsule-server/src/routes/roster.rs
+++ b/capsule-server/src/routes/roster.rs
@@ -58,6 +58,7 @@ use serde::{Deserialize, Serialize};
use crate::album::AlbumContext;
use crate::auth::AccessToken;
use crate::directory::DeviceDirectoryContext;
+use crate::federation::{self, FederationContext};
use crate::membership::{MemberRole, MembershipContext, RosterOutcome, RosterRecord};
use crate::routes::albums::AlbumsTag;
use crate::routes::upgrade::AlbumPath;
@@ -295,6 +296,7 @@ pub async fn publish_album_roster(
Inject(albums): Inject,
Inject(directories): Inject,
Inject(membership): Inject,
+ Inject(federation): Inject,
Auth(credential): Auth,
Path(path): Path,
Json(request): Json,
@@ -378,7 +380,28 @@ pub async fn publish_album_roster(
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::Applied(record) => {
+ // A roster the owner just narrowed is a set of federated grants the owner just
+ // withdrew (`S-E5`). Published to `/.well-known/capsule/revoked-jti` here so a peer
+ // learns from the list rather than from a refusal it cannot explain — and logged
+ // rather than surfaced, because the roster is the fact this operation answers for
+ // and a capability whose member has gone is refused at its next presentation
+ // regardless, membership being re-checked there.
+ let listed: Vec = signed
+ .roster
+ .members
+ .iter()
+ .map(|member| UserId::new(member.user_id.to_string()))
+ .collect();
+ if let Err(error) = federation::on_roster_applied(&federation, &album, &listed).await {
+ tracing::error!(
+ %error,
+ %album,
+ "a roster was applied but its federated grants could not be revoked"
+ );
+ }
+ Ok(Json(describe(&record, member_count, false)))
+ }
RosterOutcome::Replayed(record) => Ok(Json(describe(&record, member_count, true))),
RosterOutcome::Stale { current_version } => Err(RosterRejection::Stale {
current_version,
diff --git a/capsule-server/src/routes/sync.rs b/capsule-server/src/routes/sync.rs
index fb20ad34..9b9f378f 100644
--- a/capsule-server/src/routes/sync.rs
+++ b/capsule-server/src/routes/sync.rs
@@ -14,6 +14,16 @@
//! | `INTERNAL` | `500`, and now *coded* — `error.sync.unavailable`, a key this slice added because the retired feed had none and a client could not tell a broken server from a broken cursor |
//! | page size out of range | **not a rejection.** Clamped; see [`crate::sync::clamp_page_size`] |
//!
+//! # A peer reads the same page (`S-E5`)
+//!
+//! `Authorization: Bearer` also carries a federation capability. The album arm is then bound to
+//! the capability's album — a peer has no "own feed", so `album_id` absent or different is
+//! `403 error.federation.audience_mismatch` — and the member the capability was minted for must
+//! still be on the roster at the epoch it was granted, which is the same
+//! `403 error.sync.album_access_denied` an account's arm gives. Before any of that the
+//! capability is *admitted* ([`federation::admit`]): revoked `403`, blocked peer `403`, over
+//! budget `429`. The cursor is bound to `(peer, album)`, its own scope.
+//!
//! # What a tombstone discloses
//!
//! A `deleted` entry carries no manifest, no metadata reference and no blob list. The row still
@@ -38,8 +48,11 @@ use kynos::prelude::*;
use kynos::security::auth::Auth;
use serde::{Deserialize, Serialize};
-use crate::auth::AccessToken;
use crate::blob::ContentAddress;
+use crate::counter::CounterContext;
+use crate::federation::{
+ self, FederationContext, Principal, ReadBearer, Refusal, VerifiedCapability,
+};
use crate::index::{ChangeKind, FeedEntry};
use crate::membership::Membership;
use crate::routes::upload::WireBlobRole;
@@ -192,6 +205,42 @@ pub enum SyncRejection {
code: &'static str,
},
+ /// The capability is revoked (`S-E5`).
+ #[error("this capability has been revoked")]
+ #[problem(status = 403, title = "Capability revoked")]
+ CapabilityRevoked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The capability is for another album, or no album was named (`S-E5`).
+ #[error("this capability is for a different album")]
+ #[problem(status = 403, title = "Audience mismatch")]
+ AudienceMismatch {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer is on this server's blocklist (`S-C49`).
+ #[error("this server is blocked")]
+ #[problem(status = 403, title = "Server blocked")]
+ PeerBlocked {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
+ /// The peer's events-per-hour budget is spent (invariant 21).
+ #[error("this peer has reached its request budget")]
+ #[problem(status = 429, title = "Rate budget exceeded")]
+ RateLimited {
+ /// The stable catalog code.
+ #[problem(extension)]
+ code: &'static str,
+ },
+
/// A collaborator could not answer.
#[error("the sync feed could not be read")]
#[problem(status = 500, title = "Internal server error")]
@@ -223,6 +272,41 @@ impl SyncRejection {
code: error_codes::SYNC_UNAVAILABLE,
}
}
+
+ /// The capability names another album, or none was asked for.
+ fn audience_mismatch() -> Self {
+ Self::AudienceMismatch {
+ code: error_codes::FEDERATION_AUDIENCE_MISMATCH,
+ }
+ }
+}
+
+impl From for SyncRejection {
+ fn from(refusal: Refusal) -> Self {
+ match refusal {
+ Refusal::Revoked => Self::CapabilityRevoked {
+ code: error_codes::FEDERATION_CAPABILITY_REVOKED,
+ },
+ Refusal::PeerBlocked => Self::PeerBlocked {
+ code: error_codes::MODERATION_SERVER_BLOCKED,
+ },
+ Refusal::RateLimited { .. } => Self::RateLimited {
+ code: error_codes::FEDERATION_RATE_BUDGET_EXCEEDED,
+ },
+ Refusal::Unavailable => Self::Unavailable {
+ code: error_codes::FEDERATION_UNAVAILABLE,
+ },
+ }
+ }
+}
+
+/// Who is reading, once the credential has been decided.
+///
+/// The account's id or the peer's origin, each in its own type: the cursor scope and the log
+/// field both need to know which, and a string would let the two be confused.
+enum Reader {
+ Account(OwnerId),
+ Peer(Box),
}
// ===========================================================================================
@@ -237,26 +321,68 @@ impl SyncRejection {
#[kynos::get("/v1/sync", operation_id = "sync_feed", tag = SyncTag)]
pub async fn sync_feed(
Inject(sync): Inject,
- Auth(credential): Auth,
+ Inject(federation): Inject,
+ Inject(counters): Inject,
+ Auth(principal): Auth,
Query(query): Query,
) -> Result, SyncRejection> {
- // The 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))
+ let requested = query.album_id.as_deref().map(AlbumId::new);
+
+ // Who is asking, and which page they may have. An account reads its own feed, or — with
+ // `album_id` — one album's page as its owner or a member of its current roster (`S-C51`).
+ // A peer reads exactly the album its capability names, as the member it was minted for
+ // (`S-E5`). Each relationship is decided first, and one refusal covers every way it fails.
+ let (reader, album) = match principal {
+ Principal::Session(credential) => {
+ let album = match requested {
+ Some(album) => {
+ let filed_by = album_read_access(&sync, &credential.user, &album).await?;
+ Some((album, filed_by))
+ }
+ None => None,
+ };
+ (
+ Reader::Account(OwnerId::new(credential.user.as_str())),
+ album,
+ )
}
- None => None,
+ Principal::Peer(capability) => {
+ federation::admit(
+ &federation,
+ &counters,
+ &capability,
+ federation::Presentation::Read,
+ )
+ .await?;
+ let album = match requested {
+ Some(album) if album == capability.record.album_id => album,
+ _ => {
+ tracing::info!(
+ peer = %capability.record.peer_id,
+ jti = %capability.record.jti,
+ "a peer asked for a page its capability does not cover"
+ );
+ return Err(SyncRejection::audience_mismatch());
+ }
+ };
+ let filed_by = peer_album_access(&sync, &capability, &album).await?;
+ (Reader::Peer(capability), Some((album, filed_by)))
+ }
+ };
+ let scope = match (&reader, &album) {
+ (Reader::Account(owner), Some((album, _))) => CursorScope::album(owner, album),
+ (Reader::Account(owner), None) => CursorScope::feed(owner),
+ (Reader::Peer(capability), Some((album, _))) => {
+ CursorScope::peer(&capability.record.peer_id, album)
+ }
+ // A peer always has an album by the time it is here; the arm above returned otherwise.
+ (Reader::Peer(_), None) => return Err(SyncRejection::audience_mismatch()),
};
- let scope = match &album {
- Some((album, _)) => CursorScope::album(&owner, album),
- None => CursorScope::feed(&owner),
+ // The account whose rows are paged: the caller's own, or the album owner's.
+ let owner = match (&reader, &album) {
+ (_, Some((_, filed_by))) => filed_by.clone(),
+ (Reader::Account(owner), None) => owner.clone(),
+ (Reader::Peer(_), None) => return Err(SyncRejection::audience_mismatch()),
};
let after = sync
@@ -309,6 +435,10 @@ pub async fn sync_feed(
tracing::debug!(
%owner,
+ peer = match &reader {
+ Reader::Peer(capability) => Some(capability.record.peer_id.as_str()),
+ Reader::Account(_) => None,
+ },
after,
limit,
served = entries.len(),
@@ -325,6 +455,53 @@ pub async fn sync_feed(
}))
}
+/// Whether the member `capability` was minted for is still on `album`'s roster at the epoch the
+/// grant was made at — answering the album's owner, whose rows the page is (`S-E5`).
+///
+/// The epoch is the server-side half of the grant: a member removed and re-admitted at a later
+/// epoch gets a fresh membership, and a capability minted for the earlier one is refused without
+/// anyone having revoked it. One `403` for every failure, as the account arm gives — the album
+/// id is the capability's own, so the answer discloses nothing a peer does not hold already.
+async fn peer_album_access(
+ sync: &SyncContext,
+ capability: &VerifiedCapability,
+ album: &AlbumId,
+) -> Result {
+ let record = sync.albums().read(album).await.map_err(|error| {
+ tracing::error!(%error, %album, "the album store could not answer a peer's sync page");
+ SyncRejection::unavailable()
+ })?;
+ let Some(record) = record else {
+ tracing::info!(peer = %capability.record.peer_id, %album, "a peer's page was refused: no such album");
+ return Err(SyncRejection::album_access_denied());
+ };
+ match sync
+ .members()
+ .membership(album, &capability.record.member)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, %album, "the membership store could not answer a peer's sync page");
+ SyncRejection::unavailable()
+ })? {
+ Membership::Member { granted_epoch, .. }
+ if granted_epoch == capability.record.granted_epoch =>
+ {
+ Ok(record.owner_id)
+ }
+ membership => {
+ tracing::info!(
+ peer = %capability.record.peer_id,
+ member = %capability.record.member,
+ %album,
+ ?membership,
+ granted_epoch = capability.record.granted_epoch,
+ "a peer's page was refused: its member is not on the roster at the granted epoch"
+ );
+ Err(SyncRejection::album_access_denied())
+ }
+ }
+}
+
/// Whether `caller` may read `album`'s page — its owner, or an account on its current roster —
/// answering the album's owner, whose rows the page is.
///
diff --git a/capsule-server/src/serve/authority.rs b/capsule-server/src/serve/authority.rs
index afbb25a4..8e431064 100644
--- a/capsule-server/src/serve/authority.rs
+++ b/capsule-server/src/serve/authority.rs
@@ -21,6 +21,7 @@
//! | [`BlobReadAccess::Granted`] | `200`/`206` | the bytes |
//! | [`BlobReadAccess::Revoked`] | `403` | *"you had this and you do not now"* — re-sync membership, then degrade |
//! | [`BlobReadAccess::Unrelated`] | `404` | nothing. Byte-identical to an address the server never heard of |
+//! | [`BlobReadAccess::ScopeInsufficient`] | `403` | *"this grant does not cover originals"* — a peer only (`S-E5`) |
//!
//! **A `403` is a disclosure and a `404` is not**, which is why the boundary is drawn where it
//! is. Answering `403` to a caller with no relationship to an asset would confirm that the
@@ -39,6 +40,27 @@
//! never named is [`BlobReadAccess::Unrelated`], indistinguishable from a stranger, because it
//! is one.
//!
+//! # And where a peer's fact comes from (`S-E5`)
+//!
+//! A federated peer is not an account, so it is not asked the account's question. Its
+//! relationship to an asset is the **capability** this server minted: one album, one roster
+//! member, one epoch, one scope. So [`ReadPrincipal::Peer`] is decided as — is this blob in the
+//! album the capability names (anything else is a stranger's, `404`), is that member still on
+//! the roster at the epoch the grant was made at (removed, or re-admitted later, is `403`
+//! [`BlobReadAccess::Revoked`]: the peer held the grant, so the change is a disclosure it is
+//! owed), was that member ever on it at all (`404`), and finally does the grant's scope cover
+//! this blob's **role** — a `read-derivative-only` capability is refused an `original` with
+//! [`BlobReadAccess::ScopeInsufficient`]. A **backup** is refused under every scope and is
+//! refused as [`BlobReadAccess::Unrelated`] rather than as a scope failure: it is the owner's own
+//! durability artefact rather than part of what was shared, the feed never names one, and the
+//! `403`'s justification — the peer already knows the asset is there — does not hold for a blob
+//! it was never told about.
+//!
+//! Whether the grant is still *live* — unrevoked, unexpired — is not asked here: it has no
+//! clock, and the route admits the capability through
+//! [`federation::admit`](crate::federation::admit) before it resolves anything. What is asked
+//! here is only what the stores know.
+//!
//! The 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**
@@ -63,6 +85,7 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
+use crate::federation::VerifiedCapability;
use crate::index::BlobReference;
use crate::membership::{Membership, MembershipStore};
use crate::store::{OwnerId, UserId};
@@ -109,6 +132,53 @@ pub enum BlobReadAccess {
///
/// Rendered as `404`, byte-identical to an address nothing references — which is the point.
Unrelated,
+ /// The caller is entitled to the album, but its grant does not cover this blob's role
+ /// (`S-E5`).
+ ///
+ /// Only a peer under a capability ever sees this: an account's membership carries no scope.
+ /// Rendered as `403 error.federation.scope_insufficient` rather than `404`, because the peer
+ /// already knows the album holds the asset — the feed told it — and a `404` would send it
+ /// looking for an address that is there.
+ ScopeInsufficient,
+}
+
+/// Who a blob is being served to (`S-C39`, `S-E5`).
+///
+/// An account and a peer are decided from the same stores, but they are not the same reader:
+/// an account's relationship to an asset is its own membership, while a peer's is the
+/// membership of the roster member its capability was minted for, inside the one album that
+/// capability names. A bare identifier would have let either be read as the other, and a peer
+/// origin and an account id can spell the same string.
+#[derive(Debug, Clone, Copy)]
+pub enum ReadPrincipal<'a> {
+ /// An account, through a session access token.
+ Account(&'a OwnerId),
+ /// A peer server, through a federation capability (`S-E5`).
+ Peer(&'a VerifiedCapability),
+}
+
+impl<'a> ReadPrincipal<'a> {
+ /// The account whose own in-flight uploads may answer a fetch (`S-C40`), or `None`.
+ ///
+ /// A peer has none. The transient `409` reports the caller's *own device* still sending
+ /// exactly these bytes; a peer has no device here, so it is told what an unreferenced
+ /// address tells everyone and waits for the feed's `original_held` to flip instead.
+ #[must_use]
+ pub fn own_account(self) -> Option<&'a OwnerId> {
+ match self {
+ Self::Account(owner) => Some(owner),
+ Self::Peer(_) => None,
+ }
+ }
+}
+
+impl fmt::Display for ReadPrincipal<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Account(owner) => write!(f, "account {owner}"),
+ Self::Peer(capability) => write!(f, "peer {}", capability.record.peer_id),
+ }
+ }
}
/// Who may read a blob.
@@ -118,20 +188,20 @@ pub enum BlobReadAccess {
/// stores that will grow (federation next), and a serving path that reached into them directly
/// would have to grow with them.
pub trait ReadAuthority: fmt::Debug + Send + Sync {
- /// May `caller` fetch the bytes `reference` names?
+ /// May `principal` fetch the bytes `reference` names?
///
/// Takes the whole reference rather than an asset id so the decision comes from the same
/// read that found it. An authority that re-looked-up the asset would open a window in
/// which the two reads disagree, and would cost a round trip to do it.
fn blob_read_access<'a>(
&'a self,
- caller: &'a OwnerId,
+ principal: ReadPrincipal<'a>,
reference: &'a BlobReference,
) -> ReadAuthorityFuture<'a, BlobReadAccess>;
}
/// The authority the server runs on: an account reads its own assets' blobs and the blobs of
-/// every album it is currently a member of.
+/// every album it is currently a member of, and a peer reads what its capability names.
#[derive(Debug, Clone)]
pub struct MembershipAuthority {
members: Arc,
@@ -145,13 +215,14 @@ impl MembershipAuthority {
}
}
-impl ReadAuthority for MembershipAuthority {
- fn blob_read_access<'a>(
- &'a self,
- caller: &'a OwnerId,
- reference: &'a BlobReference,
- ) -> ReadAuthorityFuture<'a, BlobReadAccess> {
- Box::pin(async move {
+impl MembershipAuthority {
+ /// What an account may read: its own assets, and the albums it is on the roster of.
+ async fn account_access(
+ &self,
+ caller: &OwnerId,
+ reference: &BlobReference,
+ ) -> Result {
+ {
if &reference.owner_id == caller {
return Ok(BlobReadAccess::Granted);
}
@@ -188,6 +259,107 @@ impl ReadAuthority for MembershipAuthority {
BlobReadAccess::Unrelated
}
})
+ }
+ }
+
+ /// What a peer may read: the album its capability names, as the member it was minted for,
+ /// within the scope it was granted (`S-E5`).
+ async fn peer_access(
+ &self,
+ capability: &VerifiedCapability,
+ reference: &BlobReference,
+ ) -> Result {
+ let record = &capability.record;
+ // A capability covers exactly one album. A blob in any other is answered as a
+ // stranger's: the peer holds no fact about that album and must not acquire one here,
+ // and `404` is byte-identical to an address nothing references.
+ if reference.album_id != record.album_id {
+ tracing::info!(
+ peer = %record.peer_id,
+ asset = %reference.asset_id,
+ "a peer named an address outside its capability's album"
+ );
+ return Ok(BlobReadAccess::Unrelated);
+ }
+ let membership = self
+ .members
+ .membership(&reference.album_id, &record.member)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, album = %reference.album_id, "the membership store could not answer a peer's fetch");
+ ReadAuthorityError::unavailable(error.to_string())
+ })?;
+ match membership {
+ Membership::Member { granted_epoch, .. } if granted_epoch == record.granted_epoch => {
+ // Entitled to the album. The last question is the grant's own: a scope is
+ // enforced against the blob's server-visible **role**, so a derivative-only
+ // capability cannot fetch an original whatever the peer says it is fetching.
+ if record.scope.permits(reference.role) {
+ return Ok(BlobReadAccess::Granted);
+ }
+ // A **backup** is not part of what was shared at all — it is the owner's own
+ // durability artefact — so no capability over the album covers it and a peer has
+ // no relationship to it to be told about. `404`, as a stranger gets, and *not*
+ // the `403` below: that answer's whole justification is that the feed already
+ // told the peer the asset is there, which is true of an original under a
+ // derivative-only grant and false of a backup, which the feed never names.
+ if reference.role == crate::store::BlobRole::Backup {
+ tracing::info!(
+ peer = %record.peer_id,
+ asset = %reference.asset_id,
+ "a peer named a backup, which no capability covers"
+ );
+ return Ok(BlobReadAccess::Unrelated);
+ }
+ tracing::info!(
+ peer = %record.peer_id,
+ asset = %reference.asset_id,
+ role = reference.role.as_str(),
+ scope = record.scope.as_str(),
+ "a peer's capability does not cover this blob's role"
+ );
+ Ok(BlobReadAccess::ScopeInsufficient)
+ }
+ // The member was never on this roster at all. Not the peer's business that the
+ // album exists, so it is told what a stranger is told.
+ Membership::Never => {
+ tracing::info!(
+ peer = %record.peer_id,
+ member = %record.member,
+ album = %reference.album_id,
+ "a peer's capability names a member the roster never carried"
+ );
+ Ok(BlobReadAccess::Unrelated)
+ }
+ // Removed, or re-admitted at a later epoch: either way the membership this grant
+ // was minted for has ended. The peer held it, so the change is a disclosure it is
+ // owed — the same `403` a former member gets.
+ membership => {
+ tracing::info!(
+ peer = %record.peer_id,
+ member = %record.member,
+ album = %reference.album_id,
+ ?membership,
+ granted_epoch = record.granted_epoch,
+ "a peer's capability outlived the membership it was minted for"
+ );
+ Ok(BlobReadAccess::Revoked)
+ }
+ }
+ }
+}
+
+impl ReadAuthority for MembershipAuthority {
+ fn blob_read_access<'a>(
+ &'a self,
+ principal: ReadPrincipal<'a>,
+ reference: &'a BlobReference,
+ ) -> ReadAuthorityFuture<'a, BlobReadAccess> {
+ Box::pin(async move {
+ match principal {
+ ReadPrincipal::Account(caller) => self.account_access(caller, reference).await,
+ ReadPrincipal::Peer(capability) => self.peer_access(capability, reference).await,
+ }
})
}
}
@@ -201,6 +373,7 @@ pub fn membership_reads(members: Arc) -> Arc MembershipAuthority {
let store = Arc::new(InMemoryMembership::new());
roster(
@@ -250,6 +429,7 @@ mod tests {
("bob", MemberRole::Reader),
("carol", MemberRole::Writer),
("dave", MemberRole::Writer),
+ ("erin", MemberRole::Reader),
],
)
.await;
@@ -259,6 +439,16 @@ mod tests {
&[("bob", MemberRole::Reader), ("carol", MemberRole::Writer)],
)
.await;
+ roster(
+ &store,
+ 3,
+ &[
+ ("bob", MemberRole::Reader),
+ ("carol", MemberRole::Writer),
+ ("erin", MemberRole::Reader),
+ ],
+ )
+ .await;
MembershipAuthority::new(store)
}
@@ -267,8 +457,45 @@ mod tests {
caller: &str,
reference: &BlobReference,
) -> BlobReadAccess {
+ let owner = OwnerId::new(caller);
authority
- .blob_read_access(&OwnerId::new(caller), reference)
+ .blob_read_access(ReadPrincipal::Account(&owner), reference)
+ .await
+ .expect("the authority decides")
+ }
+
+ /// A capability over the shared album, minted for `member` at `granted_epoch` with `scope`.
+ ///
+ /// Built as the store holds one rather than through the codec: what this unit decides from
+ /// is the *record*, and the token behind it is `federation::capability`'s subject.
+ fn capability(member: &str, granted_epoch: u64, scope: Scope) -> VerifiedCapability {
+ let record = CapabilityRecord {
+ jti: "01937b7c-0000-7000-8000-0000000000aa".to_owned(),
+ album_id: AlbumId::new("album"),
+ peer_id: PeerId::new("other.test"),
+ member: UserId::new(member),
+ scope,
+ granted_epoch,
+ min_protocol_version: "2026-06-01".to_owned(),
+ issued_at: jiff::Timestamp::UNIX_EPOCH,
+ expires_at: jiff::Timestamp::UNIX_EPOCH + jiff::SignedDuration::from_hours(6),
+ not_after: jiff::Timestamp::UNIX_EPOCH + jiff::SignedDuration::from_hours(6),
+ revoked_at: None,
+ refreshed_to: None,
+ };
+ VerifiedCapability {
+ grant: record.grant(),
+ record,
+ }
+ }
+
+ async fn decide_peer(
+ authority: &MembershipAuthority,
+ capability: &VerifiedCapability,
+ reference: &BlobReference,
+ ) -> BlobReadAccess {
+ authority
+ .blob_read_access(ReadPrincipal::Peer(capability), reference)
.await
.expect("the authority decides")
}
@@ -338,6 +565,159 @@ mod tests {
}
}
+ #[tokio::test]
+ async fn a_peer_reads_the_album_its_capability_names_as_the_member_it_was_minted_for() {
+ // Bob is on the roster at epoch 2, which is what the grant is bound to.
+ let authority = authority().await;
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("bob", 1, Scope::Read),
+ &reference("alice")
+ )
+ .await,
+ BlobReadAccess::Granted
+ );
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("erin", 3, Scope::Read),
+ &reference("alice")
+ )
+ .await,
+ BlobReadAccess::Granted,
+ "the epoch a re-admission was granted at"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_peer_outside_its_capabilitys_album_is_a_stranger() {
+ // Not `Revoked`: the peer has no relationship to another album, and a `403` would tell
+ // it the address is referenced by somebody.
+ let authority = authority().await;
+ let mut elsewhere = reference("alice");
+ elsewhere.album_id = AlbumId::new("another-album");
+ assert_eq!(
+ decide_peer(&authority, &capability("bob", 1, Scope::Read), &elsewhere).await,
+ BlobReadAccess::Unrelated
+ );
+ // And a member the roster never carried is a stranger inside the album too.
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("mallory", 1, Scope::Read),
+ &reference("alice")
+ )
+ .await,
+ BlobReadAccess::Unrelated
+ );
+ }
+
+ #[tokio::test]
+ async fn a_peers_grant_does_not_outlive_the_membership_it_was_minted_for() {
+ // Dave was removed at version 2 and never came back. Erin was removed at 2 and
+ // re-admitted at 3, so a grant naming epoch 1 covers a membership that ended even
+ // though she is on the roster right now. Both are the `403` a former member gets,
+ // because the peer held the grant and the change is a disclosure it is owed.
+ let authority = authority().await;
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("dave", 1, Scope::Read),
+ &reference("alice")
+ )
+ .await,
+ BlobReadAccess::Revoked
+ );
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("erin", 1, Scope::Read),
+ &reference("alice")
+ )
+ .await,
+ BlobReadAccess::Revoked,
+ "re-admission at a later epoch does not revive an older grant"
+ );
+ }
+
+ #[tokio::test]
+ async fn a_derivative_only_grant_is_refused_an_original_and_every_grant_a_backup() {
+ // The scope is enforced against the blob's server-visible role, never against what the
+ // peer says it is fetching.
+ let authority = authority().await;
+ let mut original = reference("alice");
+ original.role = BlobRole::Original;
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("bob", 1, Scope::ReadDerivativeOnly),
+ &original
+ )
+ .await,
+ BlobReadAccess::ScopeInsufficient
+ );
+ assert_eq!(
+ decide_peer(&authority, &capability("bob", 1, Scope::Read), &original).await,
+ BlobReadAccess::Granted
+ );
+
+ for role in [
+ BlobRole::Derivative,
+ BlobRole::Metadata,
+ BlobRole::Provenance,
+ ] {
+ let mut derived = reference("alice");
+ derived.role = role;
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("bob", 1, Scope::ReadDerivativeOnly),
+ &derived
+ )
+ .await,
+ BlobReadAccess::Granted,
+ "{role:?} is what a derivative-only grant is for"
+ );
+ }
+
+ // A backup is refused as a *stranger's* blob, not as a scope failure: the feed never
+ // names one, so the peer holds no fact about it and the `403`'s premise does not apply.
+ let mut backup = reference("alice");
+ backup.role = BlobRole::Backup;
+ for scope in [Scope::Read, Scope::ReadDerivativeOnly] {
+ assert_eq!(
+ decide_peer(&authority, &capability("bob", 1, scope), &backup).await,
+ BlobReadAccess::Unrelated,
+ "a backup is the owner's durability artefact, not part of what was shared"
+ );
+ }
+ }
+
+ /// A peer's refusal does not vary with the asset's state either.
+ #[tokio::test]
+ async fn a_peers_answer_does_not_vary_with_the_assets_state() {
+ let authority = authority().await;
+ for state in [AssetState::Visible, AssetState::Tombstoned] {
+ let mut reference = reference("alice");
+ reference.state = state;
+ reference.hold = Some(crate::index::ServingHold::Takedown);
+ assert_eq!(
+ decide_peer(
+ &authority,
+ &capability("mallory", 1, Scope::Read),
+ &reference
+ )
+ .await,
+ BlobReadAccess::Unrelated
+ );
+ assert_eq!(
+ decide_peer(&authority, &capability("dave", 1, Scope::Read), &reference).await,
+ BlobReadAccess::Revoked
+ );
+ }
+ }
+
#[tokio::test]
async fn membership_is_asked_about_the_references_own_album() {
// The roster is per album: a member of *this* album is a stranger to another one.
diff --git a/capsule-server/src/serve/mod.rs b/capsule-server/src/serve/mod.rs
index 2a2fac00..9217f2e2 100644
--- a/capsule-server/src/serve/mod.rs
+++ b/capsule-server/src/serve/mod.rs
@@ -13,6 +13,7 @@
//! | [`ServeResolution::AwaitingUpload`] | `409` | nothing references the address **yet** — the caller's own device has an upload of exactly these bytes in flight. **Transient**, so the client waits |
//! | [`ServeResolution::NotFound`] | `404` | no live reference names the address, or it is malformed |
//! | [`ServeResolution::Gone`] | `410` | referenced but not retrievable per policy — **permanent**, so the client degrades to a lower representation |
+//! | [`ServeResolution::ScopeInsufficient`] | `403` | a peer's capability does not cover this blob's role (`S-E5`) |
//!
//! Three distinct facts collapse into that one `410` — a deleted asset, a blob awaiting
//! collection, and a moderation hold — and they collapse deliberately. The client's action is
@@ -77,6 +78,18 @@
//! `403`/`404` boundary is drawn there and not one step further out, and [`crate::membership`]
//! for where the fact behind the `403` comes from.
//!
+//! # A peer reads through the same path (`S-E5`)
+//!
+//! A federated peer presenting a capability on `GET /v1/blob/{hash}` resolves here too, as
+//! [`ReadPrincipal::Peer`], and every rule above holds unchanged: the authority is asked first,
+//! so a takedown `410` is still only legible to a reader entitled to the bytes. Two things
+//! differ, and both are the principal's rather than the path's. The transient `409` is not
+//! offered to a peer — it reports the *caller's own device* still sending the bytes, and a peer
+//! has no device here — so a peer gets the `404` an unreferenced address gets and waits for the
+//! feed's `original_held` to flip. And the authority may answer a fourth way,
+//! [`ServeResolution::ScopeInsufficient`], when the grant's scope does not cover the blob's
+//! role.
+//!
//! Non-accounts are not locked out of shared content either: `/s/{id}/blob/{hash}` serves
//! exactly the addresses a share link enumerates, and the drop surface serves its own. Neither
//! routes through here.
@@ -105,7 +118,7 @@ pub mod authority;
pub use self::authority::{
BlobReadAccess, MembershipAuthority, ReadAuthority, ReadAuthorityError, ReadAuthorityFuture,
- membership_reads,
+ ReadPrincipal, membership_reads,
};
use crate::blob::{BlobError, BlobStore, ContentAddress};
use crate::index::{AssetIndex, AssetState};
@@ -206,6 +219,13 @@ pub enum ServeResolution {
/// account the server holds a revoked membership row for. Decided **before** every policy
/// refusal below, so a former member learns nothing about holds or deletions either.
Forbidden,
+ /// The reader is entitled to the album but its grant does not cover this blob's role
+ /// (`S-E5`).
+ ///
+ /// Only a peer under a capability reaches it. Decided in the same place as
+ /// [`Self::Forbidden`] and for the same reason: it is an authorization answer, and it is
+ /// given only to a reader that already knows the asset is there.
+ ScopeInsufficient,
/// Referenced but not retrievable per policy: a deleted asset, or a dangling reference.
Gone,
}
@@ -221,10 +241,10 @@ pub struct ServeUnavailable(String);
///
/// Returns [`ServeUnavailable`] when the index or the blob store could not answer — never for a
/// blob that is simply absent, which is a decision rather than a failure.
-#[tracing::instrument(skip(context), fields(hash = %hash, owner = %owner))]
+#[tracing::instrument(skip(context), fields(hash = %hash, reader = %principal))]
pub async fn resolve(
context: &ServeContext,
- owner: &crate::store::OwnerId,
+ principal: ReadPrincipal<'_>,
hash: &str,
) -> Result {
// A string that is not a content address can address no committed blob. Answered as
@@ -247,15 +267,18 @@ pub async fn resolve(
// Nothing references it. Before answering "unknown", ask whether the caller's own
// account is in the middle of putting it there (`S-C40`) — the difference between
// "never heard of it" and "your other device is still sending it" is the difference
- // between a client degrading permanently and a client waiting.
- if let Some(upload) = context
- .uploads()
- .pending_for_address(owner, hash)
- .await
- .map_err(|error| {
- tracing::error!(%error, "the upload sessions could not be asked about an address");
- ServeUnavailable("the upload sessions could not answer".to_owned())
- })?
+ // between a client degrading permanently and a client waiting. Asked only for an
+ // account: a peer has no upload here, and offering it the answer would report on
+ // somebody else's transfer.
+ if let Some(owner) = principal.own_account()
+ && let Some(upload) = context
+ .uploads()
+ .pending_for_address(owner, hash)
+ .await
+ .map_err(|error| {
+ tracing::error!(%error, "the upload sessions could not be asked about an address");
+ ServeUnavailable("the upload sessions could not answer".to_owned())
+ })?
{
tracing::debug!(%upload, "the address is not referenced yet: an upload is in flight");
return Ok(ServeResolution::AwaitingUpload { upload });
@@ -272,7 +295,7 @@ pub async fn resolve(
// deletions.
match context
.authority()
- .blob_read_access(owner, &reference)
+ .blob_read_access(principal, &reference)
.await
.map_err(|error| {
tracing::error!(%error, "the read authority could not decide a blob fetch");
@@ -281,6 +304,7 @@ pub async fn resolve(
BlobReadAccess::Granted => {}
BlobReadAccess::Revoked => return Ok(ServeResolution::Forbidden),
BlobReadAccess::Unrelated => return Ok(ServeResolution::NotFound),
+ BlobReadAccess::ScopeInsufficient => return Ok(ServeResolution::ScopeInsufficient),
}
// Moderation takedown (`S-C17`). First among the refusals and **before any read**: a held
diff --git a/capsule-server/src/sync/cursor.rs b/capsule-server/src/sync/cursor.rs
index 4a99c9b3..0314e074 100644
--- a/capsule-server/src/sync/cursor.rs
+++ b/capsule-server/src/sync/cursor.rs
@@ -27,17 +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`)
+//! # Scope (`S-C51`, `S-E5`)
//!
-//! 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.
+//! A cursor is issued for one of three shapes — the caller's own feed, one album's page read by
+//! the caller as its owner or a member, or one album's page pulled by a federated peer — and all
+//! three 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(reader) as u32 BE || reader || 0x00`, `… || 0x01 || album` for
+//! an album page, or `… || 0x02 || album` for a peer's page, where the reader is the account or
+//! the peer server. The reader 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
//!
@@ -49,6 +50,7 @@ use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::hmac;
+use crate::federation::PeerId;
use crate::store::{AlbumId, OwnerId};
/// Cursor wire-format version. Bumped only on an incompatible layout change; an unknown
@@ -85,35 +87,62 @@ pub enum CursorError {
NotAuthentic,
}
-/// What a cursor is issued for: a caller's own feed, or one album's page (`S-C51`).
+/// What a cursor is issued for: a caller's own feed, one album's page, or one album's page as a
+/// federated peer pulls it (`S-C51`, `S-E5`).
///
-/// 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.
+/// Part of the MAC input, so a cursor minted for one shape cannot be presented on another:
+/// positions are the owner's sequence numbers in every shape, and a cursor that crossed between
+/// them would skip a reader's unseen entries. A peer is its own shape rather than an account
+/// reading an album, because a peer id and an account id are different identifiers that could
+/// spell the same string, and one scope byte is what keeps them structurally apart.
#[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>,
+pub enum CursorScope<'a> {
+ /// An account's own feed.
+ Feed {
+ /// The account the cursor was issued to.
+ caller: &'a OwnerId,
+ },
+ /// One album's page, read by an account as its owner or a member.
+ Album {
+ /// The account the cursor was issued to.
+ caller: &'a OwnerId,
+ /// The album whose page it resumes.
+ album: &'a AlbumId,
+ },
+ /// One album's page, pulled by a peer server under a capability.
+ Peer {
+ /// The peer the cursor was issued to.
+ peer: &'a PeerId,
+ /// The album whose page it resumes.
+ album: &'a AlbumId,
+ },
}
impl<'a> CursorScope<'a> {
/// `caller`'s own feed.
#[must_use]
pub fn feed(caller: &'a OwnerId) -> Self {
- Self {
- caller,
- album: None,
- }
+ Self::Feed { caller }
}
/// `album`'s page, as read by `caller`.
#[must_use]
pub fn album(caller: &'a OwnerId, album: &'a AlbumId) -> Self {
- Self {
- caller,
- album: Some(album),
+ Self::Album { caller, album }
+ }
+
+ /// `album`'s page, as pulled by `peer`.
+ #[must_use]
+ pub fn peer(peer: &'a PeerId, album: &'a AlbumId) -> Self {
+ Self::Peer { peer, album }
+ }
+
+ /// The identifier the cursor is bound to, and the scope byte and album that follow it.
+ fn parts(&self) -> (&'a [u8], u8, Option<&'a AlbumId>) {
+ match *self {
+ Self::Feed { caller } => (caller.as_str().as_bytes(), 0, None),
+ Self::Album { caller, album } => (caller.as_str().as_bytes(), 1, Some(album)),
+ Self::Peer { peer, album } => (peer.as_str().as_bytes(), 2, Some(album)),
}
}
}
@@ -145,28 +174,26 @@ impl CursorCodec {
}
}
- /// The bytes the tag is taken over: the payload, the length-prefixed caller, then the
+ /// The bytes the tag is taken over: the payload, the length-prefixed reader, then the
/// scope byte and the album when there is one.
///
- /// The caller is length-prefixed because a second variable-length field now follows it:
+ /// The reader is length-prefixed because a second variable-length field 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.
+ /// The scope byte keeps a feed cursor, an album cursor and a peer cursor apart even when
+ /// the reader's bytes are the same.
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);
+ let (reader, kind, album) = scope.parts();
+ let mut bytes = Vec::with_capacity(payload.len() + 4 + reader.len() + SCOPE_ESTIMATE);
bytes.extend_from_slice(payload);
bytes.extend_from_slice(
- &u32::try_from(caller.len())
+ &u32::try_from(reader.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.extend_from_slice(reader);
+ bytes.push(kind);
+ if let Some(album) = album {
+ bytes.extend_from_slice(album.as_str().as_bytes());
}
bytes
}
@@ -336,6 +363,59 @@ mod tests {
);
}
+ #[test]
+ fn the_account_shapes_mac_input_is_the_one_issued_cursors_were_minted_under() {
+ // Pinned as literals minted before the scope became an enum: the version byte was not
+ // bumped, so every account cursor a client holds must still decode. A re-ordering of
+ // the scope bytes would fail here rather than as a silent fleet-wide resync.
+ let codec = codec(1);
+ let owner = OwnerId::new("owner-1");
+ let album = AlbumId::new("album-1");
+ assert_eq!(
+ codec.encode(&CursorScope::feed(&owner), 42),
+ "AQAAAAAAAAAq0bh6pMbAFpAT3Awj06WzfE_5-4xMYAo6dRjCneIlXvw"
+ );
+ assert_eq!(
+ codec.encode(&CursorScope::album(&owner, &album), 42),
+ "AQAAAAAAAAAqHUJ3cwdgPp9A4G9QbArkodTHyoCrzQ1H8tItJ1yio2M"
+ );
+ }
+
+ #[test]
+ fn a_peers_cursor_does_not_cross_into_an_accounts_even_under_the_same_bytes() {
+ // A peer id and an account id could spell the same string; the scope byte is what keeps
+ // the two cursors apart, and one peer's cursor is not another peer's.
+ let codec = codec(1);
+ let album = AlbumId::new("album-1");
+ let peer = PeerId::new("same-bytes");
+ let account = OwnerId::new("same-bytes");
+ let on_peer = codec.encode(&CursorScope::peer(&peer, &album), 5);
+ assert_eq!(
+ codec.decode(&CursorScope::peer(&peer, &album), Some(&on_peer)),
+ Ok(5)
+ );
+ assert_eq!(
+ codec.decode(&CursorScope::album(&account, &album), Some(&on_peer)),
+ Err(CursorError::NotAuthentic)
+ );
+ assert_eq!(
+ codec.decode(&CursorScope::feed(&account), Some(&on_peer)),
+ Err(CursorError::NotAuthentic)
+ );
+ assert_eq!(
+ codec.decode(
+ &CursorScope::peer(&PeerId::new("other.peer"), &album),
+ Some(&on_peer)
+ ),
+ Err(CursorError::NotAuthentic)
+ );
+ let on_album = codec.encode(&CursorScope::album(&account, &album), 5);
+ assert_eq!(
+ codec.decode(&CursorScope::peer(&peer, &album), Some(&on_album)),
+ Err(CursorError::NotAuthentic)
+ );
+ }
+
#[test]
fn a_cursor_of_the_wrong_shape_is_malformed_not_unauthentic() {
let codec = codec(1);
diff --git a/capsule-server/tests/conformance.rs b/capsule-server/tests/conformance.rs
index 1ca8afe6..47d040e0 100644
--- a/capsule-server/tests/conformance.rs
+++ b/capsule-server/tests/conformance.rs
@@ -26,10 +26,53 @@ use serde_json::json;
use support::{EMAIL, Fixture, PASSWORD, PROTOCOL_VERSION, checksum, create_request, payload};
/// A `Content-Length` no operation will accept.
+/// The roster member every federated grant in the walk is minted for.
+const FEDERATED_MEMBER: &str = "01937b7c-0000-7000-8000-0000000000b0";
+
fn oversized() -> u64 {
capsule_server::limits::MAX_REQUEST_BODY_BYTES + 1
}
+/// A bearer for the peer `other.test`, over a capability this server minted and recorded for
+/// an album nobody provisioned — enough to reach the route's admission and nothing past it.
+async fn federated_peer(fixture: &Fixture) -> String {
+ use capsule_server::federation::{
+ CapabilityRecord, CapabilityStore as _, MintRequest, PeerId, Scope,
+ };
+ use capsule_server::store::{AlbumId, UserId};
+
+ let album = AlbumId::new("018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff");
+ let minted = fixture
+ .codec
+ .mint(&MintRequest {
+ peer: PeerId::new("other.test"),
+ album: album.clone(),
+ scope: Scope::Read,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ ttl: jiff::SignedDuration::from_hours(1),
+ })
+ .expect("it mints");
+ fixture
+ .revocations
+ .issue(CapabilityRecord {
+ jti: minted.grant.jti.clone(),
+ album_id: album,
+ peer_id: PeerId::new("other.test"),
+ member: UserId::new("01937b7c-0000-7000-8000-0000000000b0"),
+ scope: Scope::Read,
+ granted_epoch: 1,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ issued_at: minted.grant.issued_at,
+ expires_at: minted.grant.expires_at,
+ not_after: minted.grant.expires_at,
+ revoked_at: None,
+ refreshed_to: None,
+ })
+ .await
+ .expect("the store records");
+ format!("Bearer {}", minted.token)
+}
+
/// `GET /v1/version` answers the shape `capsule status` reads.
///
/// The literal `capsule-api` is asserted, not derived from the crate name: this crate is
@@ -122,6 +165,10 @@ async fn every_declared_response_is_exercised() {
("GET", "/v1/albums/anything/upgrade"),
("DELETE", "/v1/albums/anything/upgrade"),
("PUT", "/v1/albums/anything/roster"),
+ ("POST", "/v1/albums/anything/capabilities"),
+ ("DELETE", "/v1/albums/anything/capabilities/anything"),
+ ("POST", "/v1/federation/capabilities/refresh"),
+ ("POST", "/v1/federation/reports"),
("GET", "/v1/quota"),
("GET", "/v1/upload/sessions"),
("GET", "/v1/assets/anything/receipts"),
@@ -827,6 +874,37 @@ async fn every_declared_response_is_exercised() {
.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
fixture.index.set_unavailable(false);
+ // 429: a federated peer over its events budget (`S-E5`, invariant 21). The budget is spent
+ // through the counter port and the last hit is the route's; the capability is minted and
+ // recorded exactly as the mint route records one.
+ let peer_bearer = federated_peer(&fixture).await;
+ use capsule_server::counter::CounterStore as _;
+ use capsule_server::store::Clock as _;
+ for _ in 0..capsule_server::counter::budgets::PEER_REQUESTS.limit {
+ fixture
+ .counters
+ .hit(
+ &capsule_server::counter::CounterKey::PeerRequests("other.test".to_owned()),
+ capsule_server::counter::budgets::PEER_REQUESTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ client
+ .get("/v1/sync?album_id=018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff")
+ .header("authorization", &peer_bearer)
+ .send()
+ .await
+ .assert_status(StatusCode::TOO_MANY_REQUESTS);
+ fixture
+ .counters
+ .reset(&capsule_server::counter::CounterKey::PeerRequests(
+ "other.test".to_owned(),
+ ))
+ .await
+ .expect("the counter answers");
+
// 200, on an empty library: a client with nothing to sync still gets a cursor.
client
.get("/v1/sync")
@@ -1006,6 +1084,34 @@ async fn every_declared_response_is_exercised() {
.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
fixture.index.set_unavailable(false);
+ // 429: a federated peer over its events budget (`S-E5`, invariant 21), the same admission
+ // the feed charges — refused before the index is touched, which is why the address it names
+ // does not have to exist.
+ for _ in 0..capsule_server::counter::budgets::PEER_REQUESTS.limit {
+ fixture
+ .counters
+ .hit(
+ &capsule_server::counter::CounterKey::PeerRequests("other.test".to_owned()),
+ capsule_server::counter::budgets::PEER_REQUESTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ client
+ .get(&format!("/v1/blob/{address}"))
+ .header("authorization", &peer_bearer)
+ .send()
+ .await
+ .assert_status(StatusCode::TOO_MANY_REQUESTS);
+ fixture
+ .counters
+ .reset(&capsule_server::counter::CounterKey::PeerRequests(
+ "other.test".to_owned(),
+ ))
+ .await
+ .expect("the counter answers");
+
// 410 last, because it is the one that consumes the asset.
fixture
.index
@@ -1019,6 +1125,382 @@ async fn every_declared_response_is_exercised() {
.await
.assert_status(StatusCode::GONE);
+ // ── The federation capability lifecycle (`S-E2`) ───────────────────────────────────────
+ // A member on the album's roster to mint for, 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};
+ fixture
+ .members
+ .apply_roster(
+ RosterRecord {
+ album_id: support::album(),
+ roster_version: 3,
+ amk_epoch: 3,
+ attested_by_device: support::device(),
+ received_at: jiff::Timestamp::UNIX_EPOCH,
+ document: b"walk-v3".to_vec(),
+ },
+ vec![(
+ capsule_server::store::UserId::new(FEDERATED_MEMBER),
+ MemberRole::Reader,
+ )],
+ )
+ .await
+ .expect("the store applies");
+ }
+ // The album itself, bound to the caller: the walk's fixture seeds the *index* with the
+ // album's assets but provisions no album row, and a capability is minted over an album the
+ // caller owns.
+ client
+ .post("/v1/albums")
+ .header("authorization", &bearer)
+ .header("accept", "application/json")
+ .json(&json!({ "album_id": support::album().as_str() }))
+ .send()
+ .await
+ .assert_status(StatusCode::CREATED);
+ let caps = format!("/v1/albums/{}/capabilities", support::album());
+ let mint = |body: serde_json::Value| {
+ client
+ .post(&caps)
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&body)
+ };
+ // Renewable, because the refresh operation below has to be reachable: a grant nobody made
+ // renewable answers `403` on its first refresh, which is the default and is asserted in
+ // `tests/federation.rs` rather than here.
+ let request = json!({
+ "peer": "other.test",
+ "member": FEDERATED_MEMBER,
+ "scope": "read",
+ // On the fixture's own clock, which starts at the Unix epoch — a wall-clock literal
+ // would be ninety days past the permitted window and answer `400`.
+ "renewable_until": (fixture.clock.now() + jiff::SignedDuration::from_hours(24 * 7))
+ .to_string(),
+ });
+
+ // 401 and 403 are the scheme's, as everywhere.
+ client
+ .post(&caps)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&request)
+ .send()
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+ client
+ .post(&caps)
+ .header(
+ "authorization",
+ &format!("Bearer {}", rotated.refresh_token),
+ )
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&request)
+ .send()
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+
+ // 415 and 422 are the `Json` extractor's; 400 is the surface's own floor.
+ client
+ .post(&caps)
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .body("text/plain", "{}")
+ .send()
+ .await
+ .assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE);
+ mint(json!({ "peer": "other.test", "member": FEDERATED_MEMBER, "scope": "everything" }))
+ .send()
+ .await
+ .assert_status(StatusCode::UNPROCESSABLE_ENTITY);
+ mint(json!({ "peer": " ", "member": FEDERATED_MEMBER, "scope": "read" }))
+ .send()
+ .await
+ .assert_status(StatusCode::BAD_REQUEST);
+
+ // 404: an album that is not the caller's, answered as not-found.
+ client
+ .post("/v1/albums/018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff/capabilities")
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&request)
+ .send()
+ .await
+ .assert_status(StatusCode::NOT_FOUND);
+
+ // 409: a member the roster does not carry.
+ mint(json!({
+ "peer": "other.test",
+ "member": "01937b7c-0000-7000-8000-0000000000cc",
+ "scope": "read",
+ }))
+ .send()
+ .await
+ .assert_status(StatusCode::CONFLICT);
+
+ // 500: the album store could not answer, which must never look like "not your album".
+ fixture.albums.set_unavailable(true);
+ mint(request.clone())
+ .send()
+ .await
+ .assert_status(StatusCode::INTERNAL_SERVER_ERROR);
+ fixture.albums.set_unavailable(false);
+
+ // 201: the grant itself.
+ let minted: serde_json::Value = mint(request.clone())
+ .send()
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ let capability = format!(
+ "Bearer {}",
+ minted["token"].as_str().expect("a minted token")
+ );
+
+ // ── POST /v1/federation/capabilities/refresh ───────────────────────────────────────────
+ let refresh = |credential: &str| {
+ client
+ .post("/v1/federation/capabilities/refresh")
+ .header("authorization", credential)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ };
+ refresh(&bearer)
+ .send()
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+ client
+ .post("/v1/federation/capabilities/refresh")
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+
+ // 500: the store answers `find` — so the credential is admitted — and refuses the write.
+ // A store that could not be read at all would be refused in the authenticator, which can
+ // render only a `401`, so this is the one seam the coded `500` is reachable through.
+ fixture.revocations.set_writes_unavailable(true);
+ refresh(&capability)
+ .send()
+ .await
+ .assert_status(StatusCode::INTERNAL_SERVER_ERROR);
+ fixture.revocations.set_writes_unavailable(false);
+
+ let refreshed: serde_json::Value = refresh(&capability)
+ .send()
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ let successor = format!(
+ "Bearer {}",
+ refreshed["token"].as_str().expect("a successor token")
+ );
+ let successor_jti = refreshed["jti"].as_str().expect("a jti").to_owned();
+
+ // 429: the peer over its events budget.
+ for _ in 0..capsule_server::counter::budgets::PEER_REQUESTS.limit {
+ fixture
+ .counters
+ .hit(
+ &capsule_server::counter::CounterKey::PeerRequests("other.test".to_owned()),
+ capsule_server::counter::budgets::PEER_REQUESTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ refresh(&successor)
+ .send()
+ .await
+ .assert_status(StatusCode::TOO_MANY_REQUESTS);
+ fixture
+ .counters
+ .reset(&capsule_server::counter::CounterKey::PeerRequests(
+ "other.test".to_owned(),
+ ))
+ .await
+ .expect("the counter answers");
+
+ // 409: the member the grant was minted for has left the roster, so no successor will ever
+ // be usable. Applied through the store, as the roster above was.
+ {
+ use capsule_server::membership::{MembershipStore as _, RosterRecord};
+ fixture
+ .members
+ .apply_roster(
+ RosterRecord {
+ album_id: support::album(),
+ roster_version: 4,
+ amk_epoch: 4,
+ attested_by_device: support::device(),
+ received_at: jiff::Timestamp::UNIX_EPOCH,
+ document: b"walk-v4".to_vec(),
+ },
+ vec![],
+ )
+ .await
+ .expect("the store applies");
+ }
+ refresh(&successor)
+ .send()
+ .await
+ .assert_status(StatusCode::CONFLICT);
+
+ // ── DELETE /v1/albums/{album_id}/capabilities/{jti} ────────────────────────────────────
+ let revoke = format!("{caps}/{successor_jti}");
+ client
+ .delete(&revoke)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+ client
+ .delete(&revoke)
+ .header(
+ "authorization",
+ &format!("Bearer {}", rotated.refresh_token),
+ )
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+ client
+ .delete(&format!(
+ "/v1/albums/018f3f1e-4b7a-7c9d-8e2f-1a2b3c4d5eff/capabilities/{successor_jti}"
+ ))
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::NOT_FOUND);
+ fixture.albums.set_unavailable(true);
+ client
+ .delete(&revoke)
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::INTERNAL_SERVER_ERROR);
+ fixture.albums.set_unavailable(false);
+ client
+ .delete(&revoke)
+ .header("authorization", &bearer)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await
+ .assert_status(StatusCode::NO_CONTENT);
+
+ // ── POST /v1/federation/reports (`S-C49`) ──────────────────────────────────────────────
+ // The report carries its own signature and no bearer, so the peer must be *pinned* before
+ // anything it says can be verified: a peer nobody pinned is `403`, which is also what a
+ // pinned peer with no key gets.
+ // Reported against the account the fixture actually seeded: intake refuses a `reported_user`
+ // this server does not host, because these operators could not act on it.
+ let reported = support::user().as_str().to_owned();
+ let (peer_signer, peer_public) = support::peer_keypair();
+ let report = |body: serde_json::Value| {
+ client
+ .post("/v1/federation/reports")
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&body)
+ };
+ let signed = |reason: Option<&str>| {
+ support::signed_report(
+ &peer_signer,
+ "other.test",
+ &reported,
+ &checksum(b"reported bytes"),
+ &support::album(),
+ reason,
+ "2026-09-02T00:00:00Z",
+ )
+ };
+ report(signed(Some("csam")))
+ .send()
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+
+ // 415 and 422 are the `Json` extractor's; 400 is the surface's own floor, decided before
+ // any store is touched — which is why it answers even while the peer is unknown.
+ client
+ .post("/v1/federation/reports")
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .body("text/plain", "{}")
+ .send()
+ .await
+ .assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE);
+ report(json!({ "reporting_server": 42 }))
+ .send()
+ .await
+ .assert_status(StatusCode::UNPROCESSABLE_ENTITY);
+ let mut malformed = signed(None);
+ malformed["reported_at"] = json!("yesterday");
+ report(malformed)
+ .send()
+ .await
+ .assert_status(StatusCode::BAD_REQUEST);
+
+ {
+ use capsule_server::federation::PeerStore as _;
+ fixture
+ .peers
+ .pin(
+ &capsule_server::federation::PeerId::new("other.test"),
+ peer_public,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the operator pins");
+ }
+
+ // 401: signed by a key that is not the pinned one.
+ let (impostor, _) = support::peer_keypair();
+ report(support::signed_report(
+ &impostor,
+ "other.test",
+ &reported,
+ &checksum(b"reported bytes"),
+ &support::album(),
+ None,
+ "2026-09-02T00:00:00Z",
+ ))
+ .send()
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+
+ // 202: filed for an operator to read.
+ report(signed(Some("csam")))
+ .send()
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+
+ // 500: the moderation store could not answer, so nothing was filed.
+ fixture.moderation.set_unavailable(true);
+ report(signed(Some("csam")))
+ .send()
+ .await
+ .assert_status(StatusCode::INTERNAL_SERVER_ERROR);
+ fixture.moderation.set_unavailable(false);
+
+ // 429: this peer has said enough about this account for one hour.
+ for _ in 0..capsule_server::counter::budgets::FEDERATED_REPORTS.limit {
+ fixture
+ .counters
+ .hit(
+ &capsule_server::counter::CounterKey::FederatedReports(format!(
+ "other.test:{reported}"
+ )),
+ capsule_server::counter::budgets::FEDERATED_REPORTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ report(signed(Some("csam")))
+ .send()
+ .await
+ .assert_status(StatusCode::TOO_MANY_REQUESTS);
+
// ── POST /v1/storage/verify ────────────────────────────────────────────────────────────
// 401 and 403 are the scheme's; 415 and 422 are the `Json` extractor's, declared on every
// operation that takes a body.
@@ -2992,6 +3474,51 @@ async fn a_representative_route_per_module_holds_the_handshake_before_anything_e
}
}
+/// One `bearer` component, and every secured operation names it (`S-E5`).
+///
+/// The two read primitives accept a session token *or* a federation capability through a second
+/// scheme type registered under the same component name. What that must not do is split the
+/// carriage in the document: the generated SDK attaches its credential by this one key, so the
+/// component set stays one entry with the session scheme's description and every operation's
+/// `security` is the same requirement it was before the capability arm existed.
+#[test]
+fn the_bearer_scheme_is_one_component_and_every_secured_operation_names_it() {
+ let document = capsule_server::openapi().expect("router describes itself");
+ let document = serde_json::to_value(&document).expect("a document serializes");
+ let schemes = document["components"]["securitySchemes"]
+ .as_object()
+ .expect("security schemes are an object");
+ assert_eq!(schemes.keys().collect::>(), ["bearer"]);
+ assert_eq!(
+ schemes["bearer"],
+ json!({
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "A short-lived Capsule access token, issued by `POST /v1/auth/login` \
+ and rotated by `POST /v1/auth/refresh`.",
+ })
+ );
+ let mut secured = 0;
+ for (method, template, operation) in operations(&document) {
+ if let Some(security) = operation.get("security") {
+ assert_eq!(security, &json!([{ "bearer": [] }]), "{method} {template}");
+ secured += 1;
+ }
+ }
+ assert!(
+ secured > 40,
+ "the secured surface did not describe: {secured}"
+ );
+ for template in ["/v1/sync", "/v1/blob/{hash}"] {
+ assert_eq!(
+ document["paths"][template]["get"]["security"],
+ json!([{ "bearer": [] }]),
+ "{template} keeps the one requirement"
+ );
+ }
+}
+
/// The router builds and describes itself.
///
/// `openapi()` is the only path from this code to a description — there is no document to
diff --git a/capsule-server/tests/federation.rs b/capsule-server/tests/federation.rs
new file mode 100644
index 00000000..ee179d47
--- /dev/null
+++ b/capsule-server/tests/federation.rs
@@ -0,0 +1,2035 @@
+//! Federation (`S-E2`, `S-E5`, `S-C49`), end to end: a peer server pulls a shared album through
+//! the **existing** read primitives with a capability, and the lifecycle around that grant.
+//!
+//! The peer is stood up as what a peer is here — a holder of a capability this server minted,
+//! presenting it on `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` — rather than as a whole
+//! second deployment: every rule under test is a property of the credential and the records
+//! behind it. What is asserted against the *stores* is asserted against the stores the server
+//! actually read, never against a second reading of the response body.
+//!
+//! The cases named `E2E case 4` are the server half of module-map.md's fourth case; the client
+//! half (Bob's client renders) is `capsule-e2e`'s.
+
+mod support;
+
+use capsule_core::crypto::keys::HybridSigningKey;
+use capsule_server::blob::{BlobStore, ContentAddress};
+use capsule_server::counter::{CounterKey, CounterStore as _, budgets};
+use capsule_server::federation::{
+ CapabilityCodec, CapabilityRecord, CapabilityStore as _, FederationCollaborators,
+ FederationContext, MintRequest, PeerId, PeerStore as _, Scope,
+};
+use capsule_server::index::{AssetIndex, BlobRecord, PendingAsset, ServingHold};
+use capsule_server::membership::{MemberRole, MembershipStore as _, RosterRecord};
+use capsule_server::moderation::ModerationStore as _;
+use capsule_server::store::{AlbumId, AssetId, BlobRole, Clock as _, UserId};
+use capsule_server::sync::CursorScope;
+use jiff::{SignedDuration, Timestamp};
+use kynos::http::StatusCode;
+use serde_json::Value;
+use support::{Fixture, PROTOCOL_VERSION, SERVER_ORIGIN, album, owner, second_album};
+
+/// The peer server Bob's account lives on.
+const PEER: &str = "other.test";
+
+/// Bob, the member the owner shares with, as the owner lists him on the roster.
+const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0";
+
+/// A second member, for the cases that need a roster to change without Bob leaving it.
+const OTHER_MEMBER: &str = "01937b7c-0000-7000-8000-0000000000c0";
+
+/// Put `bytes` in the blob store at their own address and return it.
+async fn store_blob(fixture: &Fixture, bytes: &[u8]) -> ContentAddress {
+ let address = ContentAddress::parse(&support::checksum(bytes)).expect("a content address");
+ fixture
+ .blobs
+ .put(&address, bytes)
+ .await
+ .expect("the in-memory store accepts");
+ address
+}
+
+/// Record one finalized blob against `asset`.
+async fn record(fixture: &Fixture, asset: &AssetId, role: BlobRole, address: &ContentAddress) {
+ fixture
+ .index
+ .record_blob(
+ asset,
+ BlobRecord {
+ role,
+ address: address.clone(),
+ size: 32,
+ manifest_sha256: None,
+ finalized_at: Timestamp::UNIX_EPOCH,
+ },
+ )
+ .await
+ .expect("the index records");
+}
+
+/// Publish `asset` into `into` with a provenance blob behind it; returns the sequence number.
+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, format!("manifest-{asset}").as_bytes()).await;
+ record(fixture, &id, BlobRole::Provenance, &provenance).await;
+ let metadata = store_blob(fixture, format!("metadata-{asset}").as_bytes()).await;
+ match fixture
+ .index
+ .record_blob(
+ &id,
+ BlobRecord {
+ role: BlobRole::Metadata,
+ address: metadata,
+ size: 32,
+ manifest_sha256: None,
+ finalized_at: Timestamp::UNIX_EPOCH,
+ },
+ )
+ .await
+ .expect("the index records")
+ {
+ capsule_server::index::BlobOutcome::Recorded {
+ minted: Some(seq), ..
+ } => seq,
+ other => panic!("landing the index tier answered {other:?}"),
+ }
+}
+
+/// Provision the seeded album to the seeded account.
+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` (and epoch `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!("federation-test-v{version}").into_bytes(),
+ },
+ members
+ .iter()
+ .map(|(user, role)| (UserId::new(*user), *role))
+ .collect(),
+ )
+ .await
+ .expect("the store applies");
+}
+
+/// A capability this server minted for `peer` over the seeded album, carrying Bob's membership
+/// at `granted_epoch`, recorded exactly as the mint route records one.
+///
+/// Returns the bearer header value and the `jti`.
+async fn capability(
+ fixture: &Fixture,
+ peer: &str,
+ scope: Scope,
+ granted_epoch: u64,
+) -> (String, String) {
+ capability_over(
+ fixture,
+ &fixture.codec,
+ peer,
+ &album(),
+ scope,
+ granted_epoch,
+ )
+ .await
+}
+
+/// As [`capability`], over `codec` and `album`.
+async fn capability_over(
+ fixture: &Fixture,
+ codec: &CapabilityCodec,
+ peer: &str,
+ album: &AlbumId,
+ scope: Scope,
+ granted_epoch: u64,
+) -> (String, String) {
+ let minted = codec
+ .mint(&MintRequest {
+ peer: PeerId::new(peer),
+ album: album.clone(),
+ scope,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ ttl: SignedDuration::from_hours(6),
+ })
+ .expect("it mints");
+ fixture
+ .revocations
+ .issue(CapabilityRecord {
+ jti: minted.grant.jti.clone(),
+ album_id: album.clone(),
+ peer_id: PeerId::new(peer),
+ member: UserId::new(BOB),
+ scope,
+ granted_epoch,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ issued_at: minted.grant.issued_at,
+ expires_at: minted.grant.expires_at,
+ not_after: minted.grant.expires_at,
+ revoked_at: None,
+ refreshed_to: None,
+ })
+ .await
+ .expect("the store records");
+ (format!("Bearer {}", minted.token), minted.grant.jti)
+}
+
+/// A fixture with the album provisioned, Bob on its roster at epoch 1, two assets in the
+/// album and one elsewhere; returns the fixture and the album's two sequence numbers.
+async fn shared() -> (Fixture, Vec) {
+ let fixture = Fixture::working();
+ let owner_bearer = fixture.bearer().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;
+ roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await;
+ (fixture, vec![first, third])
+}
+
+/// Ask for a page as `bearer` and return the raw response.
+async fn page(fixture: &Fixture, bearer: &str, query: &str) -> kynos::test::TestResponse {
+ let path = if query.is_empty() {
+ "/v1/sync".to_owned()
+ } else {
+ format!("/v1/sync?{query}")
+ };
+ fixture
+ .client
+ .get(&path)
+ .header("authorization", bearer)
+ .header("accept", "application/json")
+ .send()
+ .await
+}
+
+/// The seeded album's page query.
+fn album_query() -> String {
+ format!("album_id={}", album())
+}
+
+// ===========================================================================================
+// The pull path: the sync feed under a capability (S-E5)
+// ===========================================================================================
+
+/// E2E case 4 (server half): a peer holding a capability pulls the album's page.
+#[tokio::test]
+async fn a_peer_pulls_the_albums_page_with_a_capability() {
+ let (fixture, expected) = shared().await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ let response = page(&fixture, &bearer, &album_query()).await;
+ response.assert_status(StatusCode::OK);
+ let body: Value = response.json();
+ let entries = body["entries"].as_array().expect("an array");
+ assert_eq!(
+ entries
+ .iter()
+ .map(|entry| entry["sync_seq"].as_u64().expect("a position"))
+ .collect::>(),
+ expected,
+ "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 the peer's own: it decodes for `(peer, album)` and resumes there.
+ let cursor = body["next_cursor"].as_str().expect("a cursor").to_owned();
+ assert_eq!(
+ fixture.cursors.decode(
+ &CursorScope::peer(&PeerId::new(PEER), &album()),
+ Some(&cursor)
+ ),
+ Ok(expected[1])
+ );
+ let resumed = page(
+ &fixture,
+ &bearer,
+ &format!("{}&cursor={cursor}", album_query()),
+ )
+ .await;
+ resumed.assert_status(StatusCode::OK);
+ let resumed: Value = resumed.json();
+ assert!(resumed["entries"].as_array().expect("an array").is_empty());
+
+ // The peer's cursor is nobody else's: not Bob's own album page, and not another peer's.
+ let bob = fixture.other_bearer(BOB).await;
+ let crossed = page(
+ &fixture,
+ &bob,
+ &format!("{}&cursor={cursor}", album_query()),
+ )
+ .await;
+ crossed.assert_status(StatusCode::BAD_REQUEST);
+ let crossed: Value = crossed.json();
+ assert_eq!(crossed["code"], "error.sync.cursor_invalid");
+ let (other_peer, _) = capability(&fixture, "third.test", Scope::Read, 1).await;
+ let crossed = page(
+ &fixture,
+ &other_peer,
+ &format!("{}&cursor={cursor}", album_query()),
+ )
+ .await;
+ crossed.assert_status(StatusCode::BAD_REQUEST);
+ let crossed: Value = crossed.json();
+ assert_eq!(crossed["code"], "error.sync.cursor_invalid");
+}
+
+#[tokio::test]
+async fn a_session_token_on_the_feed_is_unchanged_by_the_capability_arm() {
+ // The account arm is the same code path it was: the owner's feed, and Bob's album page.
+ let (fixture, expected) = shared().await;
+ let owner_bearer = fixture.bearer().await;
+ let own = page(&fixture, &owner_bearer, "").await;
+ own.assert_status(StatusCode::OK);
+ let own: Value = own.json();
+ assert_eq!(own["entries"].as_array().expect("an array").len(), 3);
+
+ let bob = fixture.other_bearer(BOB).await;
+ let body = page(&fixture, &bob, &album_query()).await;
+ body.assert_status(StatusCode::OK);
+ let body: Value = body.json();
+ assert_eq!(
+ body["entries"]
+ .as_array()
+ .expect("an array")
+ .iter()
+ .map(|entry| entry["sync_seq"].as_u64().expect("a position"))
+ .collect::>(),
+ expected
+ );
+}
+
+#[tokio::test]
+async fn a_capability_for_another_album_or_no_album_is_an_audience_mismatch() {
+ let (fixture, _) = shared().await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ // A peer has no "own feed".
+ let none = page(&fixture, &bearer, "").await;
+ none.assert_status(StatusCode::FORBIDDEN);
+ let none: Value = none.json();
+ assert_eq!(none["code"], "error.federation.audience_mismatch");
+
+ // And the capability is for one album only, whether or not the other exists.
+ let other = page(&fixture, &bearer, &format!("album_id={}", second_album())).await;
+ other.assert_status(StatusCode::FORBIDDEN);
+ let other: Value = other.json();
+ assert_eq!(other["code"], "error.federation.audience_mismatch");
+}
+
+#[tokio::test]
+async fn a_capability_whose_member_left_the_roster_is_refused_and_a_re_admission_needs_a_fresh_one()
+{
+ // The epoch is the server-side half of the grant. A member removed and re-admitted at a
+ // later epoch gets a fresh membership; the old capability was minted for one that ended.
+ let (fixture, _) = shared().await;
+ let (old, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ page(&fixture, &old, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+
+ roster(&fixture, 2, &[]).await;
+ let removed = page(&fixture, &old, &album_query()).await;
+ removed.assert_status(StatusCode::FORBIDDEN);
+ let removed: Value = removed.json();
+ assert_eq!(removed["code"], "error.sync.album_access_denied");
+
+ roster(&fixture, 3, &[(BOB, MemberRole::Reader)]).await;
+ let stale = page(&fixture, &old, &album_query()).await;
+ stale.assert_status(StatusCode::FORBIDDEN);
+ let stale: Value = stale.json();
+ assert_eq!(
+ stale["code"], "error.sync.album_access_denied",
+ "re-admitted at epoch 3, and the grant was for epoch 1"
+ );
+
+ let (fresh, _) = capability(&fixture, PEER, Scope::Read, 3).await;
+ page(&fixture, &fresh, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_revoked_capability_is_refused_with_its_code_and_appears_on_the_list() {
+ let (fixture, _) = shared().await;
+ let (bearer, jti) = capability(&fixture, PEER, Scope::Read, 1).await;
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+
+ fixture
+ .revocations
+ .revoke_issued(&jti, fixture.clock.now())
+ .await
+ .expect("revokes");
+
+ let refused = page(&fixture, &bearer, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+ assert_eq!(
+ fixture
+ .counters
+ .peek(
+ &CounterKey::PeerRequests(PEER.to_owned()),
+ budgets::PEER_REQUESTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers"),
+ capsule_server::counter::Verdict::Admitted {
+ remaining: budgets::PEER_REQUESTS.limit - 1
+ },
+ "the admitted page was charged; the revoked presentation was not"
+ );
+
+ // And a peer polling the published list sees the same fact.
+ let list: Value = fixture
+ .client
+ .get("/.well-known/capsule/revoked-jti")
+ .header("accept", "application/json")
+ .send()
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ assert!(
+ list["revoked"]
+ .as_array()
+ .expect("an array")
+ .iter()
+ .any(|token| token["jti"] == jti),
+ "{list}"
+ );
+}
+
+#[tokio::test]
+async fn a_blocked_peer_is_refused_before_anything_is_charged() {
+ let (fixture, _) = shared().await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ fixture
+ .peers
+ .block(&PeerId::new(PEER), fixture.clock.now(), None)
+ .await
+ .expect("blocks");
+
+ let refused = page(&fixture, &bearer, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.server_blocked");
+ assert!(
+ fixture
+ .counters
+ .peek(
+ &CounterKey::PeerRequests(PEER.to_owned()),
+ budgets::PEER_REQUESTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers")
+ == capsule_server::counter::Verdict::Admitted {
+ remaining: budgets::PEER_REQUESTS.limit
+ },
+ "a blocked peer's request is not charged"
+ );
+
+ fixture
+ .peers
+ .unblock(&PeerId::new(PEER))
+ .await
+ .expect("unblocks");
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_peers_budget_is_enforced_and_resets_with_the_window() {
+ // Invariant 21. The budget is spent through the counter port directly up to its last hit —
+ // ten thousand requests through the router would be a test of the router's speed — and the
+ // last two are real requests, so the `429` is the route's.
+ let (fixture, _) = shared().await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ let key = CounterKey::PeerRequests(PEER.to_owned());
+ for _ in 1..budgets::PEER_REQUESTS.limit {
+ fixture
+ .counters
+ .hit(&key, budgets::PEER_REQUESTS, fixture.clock.now())
+ .await
+ .expect("the counter answers");
+ }
+
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+ let refused = page(&fixture, &bearer, &album_query()).await;
+ refused.assert_status(StatusCode::TOO_MANY_REQUESTS);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.rate_budget_exceeded");
+
+ // Another peer is its own boundary.
+ let (other, _) = capability(&fixture, "third.test", Scope::Read, 1).await;
+ page(&fixture, &other, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+
+ fixture.clock.advance(budgets::PEER_REQUESTS.window);
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_capability_this_server_did_not_issue_is_unauthenticated() {
+ let (fixture, _) = shared().await;
+
+ // Another server's key, claiming to be this one.
+ let foreign = CapabilityCodec::from_pkcs8(
+ &support::signing_key_der(),
+ SERVER_ORIGIN,
+ fixture.clock.clone(),
+ )
+ .expect("a key parses");
+ let forged = foreign
+ .mint(&MintRequest {
+ peer: PeerId::new(PEER),
+ album: album(),
+ scope: Scope::Read,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ ttl: SignedDuration::from_hours(1),
+ })
+ .expect("it mints");
+ page(
+ &fixture,
+ &format!("Bearer {}", forged.token),
+ &album_query(),
+ )
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+
+ // This server's key, but a jti the store never recorded: minted and thrown away.
+ let unrecorded = fixture
+ .codec
+ .mint(&MintRequest {
+ peer: PeerId::new(PEER),
+ album: album(),
+ scope: Scope::Read,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ ttl: SignedDuration::from_hours(1),
+ })
+ .expect("it mints");
+ page(
+ &fixture,
+ &format!("Bearer {}", unrecorded.token),
+ &album_query(),
+ )
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+
+ // And an expired one, on the clock.
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ fixture.clock.advance(SignedDuration::from_hours(7));
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn a_store_that_cannot_answer_a_peer_is_an_outage_never_an_admission() {
+ let (fixture, _) = shared().await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ // The capability store: the authenticator can render only `401`, and does, closed.
+ fixture.revocations.set_unavailable(true);
+ page(&fixture, &bearer, &album_query())
+ .await
+ .assert_status(StatusCode::UNAUTHORIZED);
+ fixture.revocations.set_unavailable(false);
+
+ // The membership store, at the route: a coded `500`.
+ fixture.members.set_unavailable(true);
+ let refused = page(&fixture, &bearer, &album_query()).await;
+ refused.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.sync.unavailable");
+ fixture.members.set_unavailable(false);
+}
+
+// ===========================================================================================
+// The pull path: blob bytes under a capability (S-E5)
+// ===========================================================================================
+
+/// Fetch `address` as `bearer` and return the raw response.
+async fn fetch(
+ fixture: &Fixture,
+ bearer: &str,
+ address: &ContentAddress,
+) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .get(&format!("/v1/blob/{address}"))
+ .header("authorization", bearer)
+ .send()
+ .await
+}
+
+/// Publish an asset into `into` carrying an original and a derivative, and return their
+/// addresses.
+///
+/// Landed first — a reserved row is not a reference, so its blobs resolve to `404` for
+/// everybody — and the two roles recorded onto it after.
+async fn two_roles(
+ fixture: &Fixture,
+ asset: &str,
+ into: &AlbumId,
+) -> (ContentAddress, ContentAddress) {
+ publish_into(fixture, asset, into).await;
+ let id = AssetId::new(asset);
+ let original = store_blob(fixture, format!("original-{asset}").as_bytes()).await;
+ record(fixture, &id, BlobRole::Original, &original).await;
+ let derivative = store_blob(fixture, format!("derivative-{asset}").as_bytes()).await;
+ record(fixture, &id, BlobRole::Derivative, &derivative).await;
+ (original, derivative)
+}
+
+/// E2E case 4 (server half): the peer fetches the bytes the page named.
+#[tokio::test]
+async fn a_peer_fetches_the_albums_blobs_and_a_derivative_only_grant_is_refused_the_original() {
+ let (fixture, _) = shared().await;
+ let (original, derivative) = two_roles(&fixture, "shared-3", &album()).await;
+
+ // `read` covers both roles, and the bytes are the bytes.
+ let (full, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ let served = fetch(&fixture, &full, &original).await;
+ served.assert_status(StatusCode::OK);
+ assert_eq!(served.bytes().as_ref(), b"original-shared-3");
+ fetch(&fixture, &full, &derivative)
+ .await
+ .assert_status(StatusCode::OK);
+
+ // `read-derivative-only` is refused the original, by the blob's server-visible role and not
+ // by anything the peer said it was fetching.
+ let (thumbs, _) = capability(&fixture, PEER, Scope::ReadDerivativeOnly, 1).await;
+ let refused = fetch(&fixture, &thumbs, &original).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.scope_insufficient");
+ fetch(&fixture, &thumbs, &derivative)
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_capability_for_another_album_gets_the_answer_an_unknown_address_gets() {
+ // The disclosure boundary: a `403` would confirm the address is referenced by somebody, so
+ // a peer outside the album is told exactly what a stranger naming a random hash is told —
+ // byte-identical, headers and body.
+ let (fixture, _) = shared().await;
+ let (elsewhere, _derivative) = two_roles(&fixture, "private-2", &second_album()).await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ let refused = fetch(&fixture, &bearer, &elsewhere).await;
+ refused.assert_status(StatusCode::NOT_FOUND);
+ let refused: Value = refused.json();
+ let unknown = fetch(
+ &fixture,
+ &bearer,
+ &ContentAddress::parse(&support::checksum(b"nothing holds these")).expect("an address"),
+ )
+ .await;
+ unknown.assert_status(StatusCode::NOT_FOUND);
+ let unknown: Value = unknown.json();
+ assert_eq!(refused, unknown);
+}
+
+#[tokio::test]
+async fn a_peer_is_told_a_revoked_grant_apart_from_an_accounts_revoked_membership() {
+ // Both are `403`; the codes differ because the actions differ — an account re-syncs its
+ // membership, a peer asks its home server for a fresh grant.
+ let (fixture, _) = shared().await;
+ let (original, _) = two_roles(&fixture, "shared-4", &album()).await;
+ let (bearer, jti) = capability(&fixture, PEER, Scope::Read, 1).await;
+ fixture
+ .revocations
+ .revoke_issued(&jti, fixture.clock.now())
+ .await
+ .expect("revokes");
+
+ let refused = fetch(&fixture, &bearer, &original).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+
+ // And a former member of the same album still gets the account's code.
+ let bob = fixture.other_bearer(BOB).await;
+ fetch(&fixture, &bob, &original)
+ .await
+ .assert_status(StatusCode::OK);
+ roster(&fixture, 2, &[]).await;
+ let former = fetch(&fixture, &bob, &original).await;
+ former.assert_status(StatusCode::FORBIDDEN);
+ let former: Value = former.json();
+ assert_eq!(former["code"], "error.blob.access_revoked");
+}
+
+#[tokio::test]
+async fn a_peer_is_never_told_about_an_accounts_upload_in_flight() {
+ // The transient `409` reports the caller's *own* device still sending the bytes. A peer has
+ // no device here, so it gets what an unreferenced address gives and waits for the feed's
+ // `original_held` to flip.
+ let (fixture, _) = shared().await;
+ let owner_bearer = fixture.bearer().await;
+ let coming = vec![b'y'; 4096];
+ let promised = support::checksum(&coming);
+ let address = ContentAddress::parse(&promised).expect("an address");
+ fixture
+ .open_session(&coming, "original", &owner_bearer)
+ .await;
+
+ fetch(&fixture, &owner_bearer, &address)
+ .await
+ .assert_status(StatusCode::CONFLICT);
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ fetch(&fixture, &bearer, &address)
+ .await
+ .assert_status(StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn a_takedown_answers_a_peer_the_same_410_and_leaves_the_bytes_alone() {
+ // The authority is asked first, so the `410` is legible only to a reader entitled to the
+ // bytes — and the hold is a serving constraint, never a destruction.
+ let (fixture, _) = shared().await;
+ let (original, _) = two_roles(&fixture, "shared-5", &album()).await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+ fetch(&fixture, &bearer, &original)
+ .await
+ .assert_status(StatusCode::OK);
+
+ fixture
+ .index
+ .set_hold(&AssetId::new("shared-5"), Some(ServingHold::Takedown))
+ .await
+ .expect("the index holds");
+ fetch(&fixture, &bearer, &original)
+ .await
+ .assert_status(StatusCode::GONE);
+
+ // A peer outside the album still sees nothing, not even the takedown.
+ let (other_album, _) = capability_over(
+ &fixture,
+ &fixture.codec,
+ PEER,
+ &second_album(),
+ Scope::Read,
+ 1,
+ )
+ .await;
+ fetch(&fixture, &other_album, &original)
+ .await
+ .assert_status(StatusCode::NOT_FOUND);
+
+ assert_eq!(
+ fixture
+ .blobs
+ .read_at(&original, 0, 64)
+ .await
+ .expect("the store answers")
+ .expect("the bytes are there"),
+ b"original-shared-5",
+ "a takedown does not touch the ciphertext"
+ );
+}
+
+#[tokio::test]
+async fn a_blocked_peer_and_a_spent_budget_refuse_the_blob_route_too() {
+ let (fixture, _) = shared().await;
+ let (original, _) = two_roles(&fixture, "shared-6", &album()).await;
+ let (bearer, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ fixture
+ .peers
+ .block(&PeerId::new(PEER), fixture.clock.now(), None)
+ .await
+ .expect("blocks");
+ let blocked = fetch(&fixture, &bearer, &original).await;
+ blocked.assert_status(StatusCode::FORBIDDEN);
+ let blocked: Value = blocked.json();
+ assert_eq!(blocked["code"], "error.moderation.server_blocked");
+ fixture
+ .peers
+ .unblock(&PeerId::new(PEER))
+ .await
+ .expect("unblocks");
+
+ let key = CounterKey::PeerRequests(PEER.to_owned());
+ for _ in 0..budgets::PEER_REQUESTS.limit {
+ fixture
+ .counters
+ .hit(&key, budgets::PEER_REQUESTS, fixture.clock.now())
+ .await
+ .expect("the counter answers");
+ }
+ let spent = fetch(&fixture, &bearer, &original).await;
+ spent.assert_status(StatusCode::TOO_MANY_REQUESTS);
+ let spent: Value = spent.json();
+ assert_eq!(spent["code"], "error.federation.rate_budget_exceeded");
+}
+
+// ===========================================================================================
+// The lifecycle: minting, revoking and refreshing the grant (S-E2)
+// ===========================================================================================
+
+/// A fixture whose seeded account is anchored on `dsk` and whose album is provisioned, so the
+/// roster route — and therefore the revocation write behind it — can run for real.
+async fn anchored(dsk: &HybridSigningKey) -> (Fixture, String) {
+ let fixture = Fixture::working();
+ let bearer = fixture.bearer().await;
+ fixture
+ .client
+ .post("/v1/auth/devices/directory")
+ .header("authorization", &bearer)
+ .header("x-capsule-identity-key", &support::identity_header(dsk))
+ .body(
+ "application/cbor",
+ support::signed_directory_with_device(
+ dsk,
+ 1,
+ support::device(),
+ dsk,
+ "1970-01-01T00:00:00Z",
+ ),
+ )
+ .send()
+ .await
+ .assert_status(StatusCode::OK);
+ provision(&fixture, &bearer).await;
+ (fixture, bearer)
+}
+
+/// PUT the seeded album's roster through the route, as the owner's client does.
+async fn publish_roster(
+ fixture: &Fixture,
+ bearer: &str,
+ dsk: &HybridSigningKey,
+ version: u64,
+ members: &[(&str, MemberRole)],
+) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .put(&format!("/v1/albums/{}/roster", album()))
+ .header("authorization", bearer)
+ .header("accept", "application/json")
+ .json(&serde_json::json!({
+ "roster_cbor": support::signed_roster(
+ dsk,
+ support::device(),
+ &album(),
+ version,
+ u32::try_from(version).expect("a small epoch"),
+ members,
+ ),
+ }))
+ .send()
+ .await
+}
+
+/// Mint a capability through the route, as the owner's client does.
+async fn mint(fixture: &Fixture, bearer: &str, body: Value) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .post(&format!("/v1/albums/{}/capabilities", album()))
+ .header("authorization", bearer)
+ .header("accept", "application/json")
+ .json(&body)
+ .send()
+ .await
+}
+
+/// The mint body for `PEER` over Bob's membership. Not renewable, which is the default.
+fn mint_body(scope: &str) -> Value {
+ serde_json::json!({ "peer": PEER, "member": BOB, "scope": scope })
+}
+
+/// The same, renewable until `hours` from the fixture's now.
+fn renewable_body(fixture: &Fixture, scope: &str, hours: i64) -> Value {
+ serde_json::json!({
+ "peer": PEER,
+ "member": BOB,
+ "scope": scope,
+ "renewable_until": crate::support::deadline(fixture, hours).to_string(),
+ })
+}
+
+/// DELETE one capability of `on`, as `bearer`.
+async fn revoke(
+ fixture: &Fixture,
+ bearer: &str,
+ on: &AlbumId,
+ jti: &str,
+) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .delete(&format!("/v1/albums/{on}/capabilities/{jti}"))
+ .header("authorization", bearer)
+ .send()
+ .await
+}
+
+/// POST the refresh, presenting `credential`.
+async fn refresh(fixture: &Fixture, credential: &str) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .post("/v1/federation/capabilities/refresh")
+ .header("authorization", credential)
+ .header("accept", "application/json")
+ .send()
+ .await
+}
+
+/// The `jti`s the published revocation list carries.
+async fn published(fixture: &Fixture) -> Vec {
+ let list: Value = fixture
+ .client
+ .get("/.well-known/capsule/revoked-jti")
+ .header("accept", "application/json")
+ .send()
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ list["revoked"]
+ .as_array()
+ .expect("an array")
+ .iter()
+ .map(|token| token["jti"].as_str().expect("a jti").to_owned())
+ .collect()
+}
+
+/// E2E case 4 (server half), whole: the owner mints, the peer pulls, the roster cuts the grant.
+#[tokio::test]
+async fn e2e_case_4_the_owner_mints_the_peer_pulls_and_a_roster_change_cuts_the_grant() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+ let seq = publish_into(&fixture, "case-4", &album()).await;
+
+ // The owner's client mints, over the album's own protocol pin.
+ let minted: Value = mint(&fixture, &bearer, mint_body("read"))
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ assert_eq!(minted["album_id"], album().as_str());
+ assert_eq!(minted["peer"], PEER);
+ assert_eq!(minted["member"], BOB);
+ assert_eq!(minted["scope"], "read");
+ assert_eq!(minted["min_protocol_version"], PROTOCOL_VERSION);
+ let jti = minted["jti"].as_str().expect("a jti").to_owned();
+ let capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ // The peer pulls the page with it.
+ let body: Value = page(&fixture, &capability, &album_query())
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ assert_eq!(
+ body["entries"]
+ .as_array()
+ .expect("an array")
+ .iter()
+ .map(|entry| entry["sync_seq"].as_u64().expect("a position"))
+ .collect::>(),
+ vec![seq]
+ );
+ assert!(!published(&fixture).await.contains(&jti));
+
+ // The owner publishes a roster that omits Bob. The grant is cut and published, without the
+ // owner having named it.
+ publish_roster(&fixture, &bearer, &dsk, 2, &[])
+ .await
+ .assert_status(StatusCode::OK);
+ assert!(
+ published(&fixture).await.contains(&jti),
+ "the roster change published the grant's jti"
+ );
+ let refused = page(&fixture, &capability, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+}
+
+#[tokio::test]
+async fn a_roster_change_that_keeps_the_member_cuts_nothing() {
+ // An epoch bump is not a removal: the member still holds their keys and the server has
+ // nothing to cut. Only the *member* leaving revokes.
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+ let minted: Value = mint(&fixture, &bearer, mint_body("read"))
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ let jti = minted["jti"].as_str().expect("a jti").to_owned();
+ let capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ publish_roster(
+ &fixture,
+ &bearer,
+ &dsk,
+ 2,
+ &[
+ (BOB, MemberRole::Writer),
+ (OTHER_MEMBER, MemberRole::Reader),
+ ],
+ )
+ .await
+ .assert_status(StatusCode::OK);
+ assert!(!published(&fixture).await.contains(&jti));
+ page(&fixture, &capability, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_mint_is_refused_for_another_account_a_blocked_peer_and_a_member_off_the_roster() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+
+ // Not the caller's album is not found — the album ceremonies' answer, so a member holding
+ // somebody else's album id learns nothing.
+ let bob = fixture.other_bearer(BOB).await;
+ let refused = mint(&fixture, &bob, mint_body("read")).await;
+ refused.assert_status(StatusCode::NOT_FOUND);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.album_not_found");
+
+ // A member the roster does not carry.
+ let refused = mint(
+ &fixture,
+ &bearer,
+ serde_json::json!({ "peer": PEER, "member": OTHER_MEMBER, "scope": "read" }),
+ )
+ .await;
+ refused.assert_status(StatusCode::CONFLICT);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.member_not_on_roster");
+
+ // A blocked peer gets no new grant, and gets one again when the block lifts.
+ fixture
+ .peers
+ .block(&PeerId::new(PEER), fixture.clock.now(), None)
+ .await
+ .expect("blocks");
+ let refused = mint(&fixture, &bearer, mint_body("read")).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.server_blocked");
+ fixture
+ .peers
+ .unblock(&PeerId::new(PEER))
+ .await
+ .expect("unblocks");
+ mint(&fixture, &bearer, mint_body("read"))
+ .await
+ .assert_status(StatusCode::CREATED);
+
+ // And an empty peer origin is a client bug, not an unknown server.
+ let refused = mint(
+ &fixture,
+ &bearer,
+ serde_json::json!({ "peer": " ", "member": BOB, "scope": "read" }),
+ )
+ .await;
+ refused.assert_status(StatusCode::BAD_REQUEST);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_malformed");
+}
+
+#[tokio::test]
+async fn an_owner_revokes_one_grant_idempotently_and_cannot_reach_another_albums() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+ let minted: Value = mint(&fixture, &bearer, mint_body("read"))
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ let jti = minted["jti"].as_str().expect("a jti").to_owned();
+ let capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ // Another album cannot revoke this album's grant, even though both are the owner's: the
+ // record's own album is checked, so a `jti` is not a handle on somebody else's grant. The
+ // second album is not provisioned, so the answer is the same not-found either way.
+ revoke(&fixture, &bearer, &second_album(), &jti)
+ .await
+ .assert_status(StatusCode::NOT_FOUND);
+ assert!(!published(&fixture).await.contains(&jti));
+
+ revoke(&fixture, &bearer, &album(), &jti)
+ .await
+ .assert_status(StatusCode::NO_CONTENT);
+ assert!(published(&fixture).await.contains(&jti));
+ // Idempotent, and silent about a jti it never held: not a probe over identifiers.
+ revoke(&fixture, &bearer, &album(), &jti)
+ .await
+ .assert_status(StatusCode::NO_CONTENT);
+ revoke(
+ &fixture,
+ &bearer,
+ &album(),
+ "01937b7c-0000-7000-8000-0000000000ff",
+ )
+ .await
+ .assert_status(StatusCode::NO_CONTENT);
+
+ let refused = page(&fixture, &capability, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+}
+
+#[tokio::test]
+async fn a_refresh_issues_a_successor_cuts_the_predecessor_and_replays_to_the_same_token() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+ publish_into(&fixture, "refresh-1", &album()).await;
+ let minted: Value = mint(
+ &fixture,
+ &bearer,
+ renewable_body(&fixture, "read-derivative-only", 24 * 7),
+ )
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ assert_eq!(minted["renewable"], true);
+ assert_ne!(minted["not_after"], minted["expires_at"]);
+ let not_after = minted["not_after"].as_str().expect("a deadline").to_owned();
+ let old_jti = minted["jti"].as_str().expect("a jti").to_owned();
+ let old = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ // An account has nothing to refresh here.
+ let refused = refresh(&fixture, &bearer).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_invalid");
+
+ let first: Value = refresh(&fixture, &old)
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ assert_eq!(first["replayed"], false);
+ assert_eq!(
+ first["not_after"], not_after,
+ "a refresh carries the grant's deadline unchanged"
+ );
+ let successor = format!("Bearer {}", first["token"].as_str().expect("a token"));
+ assert_ne!(first["jti"], old_jti.as_str());
+
+ // The predecessor is cut and published; the successor pulls, and carries the same scope.
+ assert!(published(&fixture).await.contains(&old_jti));
+ page(&fixture, &old, &album_query())
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+ page(&fixture, &successor, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+
+ // A replay of the same predecessor answers the same successor, byte for byte: the grant is
+ // re-signed from its record, and every instant is at whole seconds.
+ let replay: Value = refresh(&fixture, &old)
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ assert_eq!(replay["replayed"], true);
+ assert_eq!(replay["token"], first["token"]);
+ assert_eq!(replay["jti"], first["jti"]);
+
+ // And a replay whose successor has since been revoked is refused rather than re-issued.
+ fixture
+ .revocations
+ .revoke_issued(first["jti"].as_str().expect("a jti"), fixture.clock.now())
+ .await
+ .expect("revokes");
+ let refused = refresh(&fixture, &old).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+}
+
+#[tokio::test]
+async fn a_deployment_that_does_not_federate_mints_nothing_but_still_revokes() {
+ // Turning federation off must never be the thing that takes away an operator's ability to
+ // cut a grant that is already out there.
+ let fixture = Fixture::without_federation();
+ let bearer = fixture.bearer().await;
+ provision(&fixture, &bearer).await;
+ roster(&fixture, 1, &[(BOB, MemberRole::Reader)]).await;
+
+ let refused = mint(&fixture, &bearer, mint_body("read")).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.not_configured");
+
+ // A grant minted while it federated still verifies — a token is not un-minted by a
+ // configuration change — and can still be revoked and refused.
+ let (capability, jti) = capability(&fixture, PEER, Scope::Read, 1).await;
+ publish_into(&fixture, "unfederated-1", &album()).await;
+ page(&fixture, &capability, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+ // But it cannot be continued.
+ refresh(&fixture, &capability)
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+ revoke(&fixture, &bearer, &album(), &jti)
+ .await
+ .assert_status(StatusCode::NO_CONTENT);
+ let refused = page(&fixture, &capability, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+}
+
+// ===========================================================================================
+// Moderation's federated halves (S-C49)
+// ===========================================================================================
+
+/// Pin `key` as `PEER`'s operational key, as an operator does.
+async fn pin(fixture: &Fixture, key: [u8; 32]) {
+ fixture
+ .peers
+ .pin(&PeerId::new(PEER), key, fixture.clock.now())
+ .await
+ .expect("the operator pins");
+}
+
+/// POST a federated report body.
+async fn file(fixture: &Fixture, body: Value) -> kynos::test::TestResponse {
+ fixture
+ .client
+ .post("/v1/federation/reports")
+ .header("accept", "application/json")
+ .json(&body)
+ .send()
+ .await
+}
+
+/// The account the suite's reports are about: the one the fixture actually seeded.
+///
+/// Not `BOB`, who is a roster member and not an account here. Intake refuses a `reported_user`
+/// this server does not host — these operators could not act on it — so a case that reported
+/// against a made-up id would be testing the `404` rather than what it meant to.
+fn reported() -> String {
+ support::user().as_str().to_owned()
+}
+
+/// A report from `PEER` about the seeded account's copy of `hash`, signed by `pair`.
+fn report(pair: &ring::signature::Ed25519KeyPair, hash: &str, reason: Option<&str>) -> Value {
+ support::signed_report(
+ pair,
+ PEER,
+ &reported(),
+ hash,
+ &album(),
+ reason,
+ "2026-09-02T00:00:00Z",
+ )
+}
+
+#[tokio::test]
+async fn a_signed_report_from_a_pinned_peer_is_filed_and_changes_nothing_about_the_account() {
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ pin(&fixture, public).await;
+ let hash = support::checksum(b"the reported bytes");
+
+ let accepted: Value = file(&fixture, report(&signer, &hash, Some("csam")))
+ .await
+ .assert_status(StatusCode::ACCEPTED)
+ .json();
+ let report_id = accepted["report_id"].as_str().expect("a report id");
+
+ // Asserted against the store the server wrote, never against a second read of the body.
+ let pending = fixture
+ .moderation
+ .pending_reports()
+ .await
+ .expect("the store answers");
+ assert_eq!(pending.len(), 1);
+ assert_eq!(pending[0].report_id, report_id);
+ assert_eq!(pending[0].reporting_server, PEER);
+ assert_eq!(pending[0].reported_user, UserId::new(reported()));
+ assert_eq!(pending[0].asset_hash, hash);
+ assert_eq!(pending[0].album_id, album());
+ assert_eq!(pending[0].reason.as_deref(), Some("csam"));
+ assert!(
+ !pending[0].signature.is_empty(),
+ "the signature is kept so an operator can re-verify it"
+ );
+
+ // A report is an input to a decision, never a decision: nothing was done to the account.
+ assert_eq!(
+ fixture
+ .moderation
+ .standing(&UserId::new(reported()))
+ .await
+ .expect("the store answers"),
+ capsule_server::moderation::Standing::Active
+ );
+ assert!(
+ fixture
+ .moderation
+ .events_for_user(&UserId::new(reported()))
+ .await
+ .expect("the store answers")
+ .is_empty(),
+ "nothing was done to the account, so nothing is on its record"
+ );
+}
+
+#[tokio::test]
+async fn a_filed_reports_signature_re_verifies_against_the_row_that_was_stored() {
+ // H2. The stored fields are *normalized* — `reporting_server` is the canonical PeerId form
+ // and `reported_at` is a parsed instant — so rebuilding a claim from them produces different
+ // bytes and a signature that no longer verifies. What makes the row re-verifiable is that
+ // the exact signed bytes are kept beside it. This case is the round trip an operator does.
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ pin(&fixture, public).await;
+ let hash = support::checksum(b"the reported bytes");
+
+ // Sent the way a real peer might: a trailing dot and mixed case on the origin, and padding
+ // the intake trims. Every one of them survives into the signed bytes and none into the row.
+ let body = support::signed_report(
+ &signer,
+ "Other.Test.",
+ &reported(),
+ &hash,
+ &album(),
+ Some("csam"),
+ "2026-09-02T00:00:00Z",
+ );
+ file(&fixture, body.clone())
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+
+ let filed = fixture
+ .moderation
+ .pending_reports()
+ .await
+ .expect("the store answers");
+ let filed = filed.first().expect("one report");
+ assert_eq!(
+ filed.reporting_server, PEER,
+ "the row carries the canonical peer id, not what the peer wrote"
+ );
+ assert_eq!(
+ filed.reported_at,
+ "2026-09-02T00:00:00Z".parse::().unwrap()
+ );
+
+ // The row re-verifies, months later, with nothing but its own two byte strings and the key.
+ assert_eq!(
+ capsule_server::federation::verify_signed_report(&filed.signed, &filed.signature, &public),
+ Ok(()),
+ "a filed report must re-verify from what was stored"
+ );
+ // And a different peer's key does not, so this is a real check and not a tautology.
+ let (_, other) = support::peer_keypair();
+ assert!(
+ capsule_server::federation::verify_signed_report(&filed.signed, &filed.signature, &other)
+ .is_err()
+ );
+
+ // The bytes are the peer's own, not a re-encoding of the row: rebuilding a claim from the
+ // normalized fields would produce something else entirely.
+ let rebuilt = capsule_server::federation::ReportClaim {
+ reporting_server: filed.reporting_server.clone(),
+ reported_user: filed.reported_user.as_str().to_owned(),
+ asset_hash: filed.asset_hash.clone(),
+ album_id: filed.album_id.as_str().to_owned(),
+ reason: filed.reason.clone(),
+ reported_at: filed.reported_at.to_string(),
+ };
+ assert_ne!(
+ rebuilt.signing_bytes().expect("it encodes"),
+ filed.signed,
+ "if these were equal the stored bytes would be redundant and this case pointless"
+ );
+}
+
+#[tokio::test]
+async fn a_report_is_refused_unsigned_from_an_unknown_peer_and_from_a_blocked_one() {
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ let hash = support::checksum(b"the reported bytes");
+
+ // Nobody has pinned this peer, so there is nothing to verify against.
+ let refused = file(&fixture, report(&signer, &hash, None)).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.peer_unknown");
+
+ pin(&fixture, public).await;
+
+ // Another key's signature over the same claim.
+ let (impostor, _) = support::peer_keypair();
+ let refused = file(&fixture, report(&impostor, &hash, None)).await;
+ refused.assert_status(StatusCode::UNAUTHORIZED);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.report_unsigned");
+
+ // The peer's own signature over a *different* claim, replayed onto this one.
+ let mut tampered = report(&signer, &hash, Some("csam"));
+ tampered["asset_hash"] = Value::from(support::checksum(b"other bytes"));
+ let refused = file(&fixture, tampered).await;
+ refused.assert_status(StatusCode::UNAUTHORIZED);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.report_unsigned");
+
+ // Blocked: refused before the signature is even looked at.
+ fixture
+ .peers
+ .block(
+ &PeerId::new(PEER),
+ fixture.clock.now(),
+ Some("noise".into()),
+ )
+ .await
+ .expect("blocks");
+ let refused = file(&fixture, report(&signer, &hash, None)).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.server_blocked");
+
+ assert!(
+ fixture
+ .moderation
+ .pending_reports()
+ .await
+ .expect("the store answers")
+ .is_empty(),
+ "nothing a refusal saw reached the queue"
+ );
+}
+
+#[tokio::test]
+async fn a_peers_reports_about_one_account_are_bounded_and_another_account_is_its_own_budget() {
+ // Invariant 24: the budget is per `(reporting_server, reported_user)`, so a flood against
+ // one user cannot silence a peer that has something to say about another.
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ pin(&fixture, public).await;
+ let hash = support::checksum(b"the reported bytes");
+
+ for _ in 1..budgets::FEDERATED_REPORTS.limit {
+ fixture
+ .counters
+ .hit(
+ &CounterKey::FederatedReports(format!("{PEER}:{}", reported())),
+ budgets::FEDERATED_REPORTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ file(&fixture, report(&signer, &hash, None))
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+ let refused = file(&fixture, report(&signer, &hash, None)).await;
+ refused.assert_status(StatusCode::TOO_MANY_REQUESTS);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.report_rate_limited");
+
+ // Another account on this server is a different boundary — and it has to be a *real* one,
+ // which is the point of the account check this case leans on.
+ fixture
+ .accounts
+ .insert("second@example.com", "pw", &UserId::new(OTHER_MEMBER));
+ file(
+ &fixture,
+ support::signed_report(
+ &signer,
+ PEER,
+ OTHER_MEMBER,
+ &hash,
+ &album(),
+ None,
+ "2026-09-02T00:00:00Z",
+ ),
+ )
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+
+ fixture.clock.advance(budgets::FEDERATED_REPORTS.window);
+ file(&fixture, report(&signer, &hash, None))
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+}
+
+#[tokio::test]
+async fn blocking_a_peer_cuts_and_publishes_every_grant_it_holds() {
+ // The blocklist already refuses at every boundary; the cascade is what puts the peer's jtis
+ // on the record every peer polls, so a block is legible rather than only enforced.
+ let (fixture, _) = shared().await;
+ let (first, first_jti) = capability(&fixture, PEER, Scope::Read, 1).await;
+ let (_, second_jti) = capability(&fixture, PEER, Scope::ReadDerivativeOnly, 1).await;
+ let (other, other_jti) = capability(&fixture, "third.test", Scope::Read, 1).await;
+
+ fixture
+ .peers
+ .block(&PeerId::new(PEER), fixture.clock.now(), None)
+ .await
+ .expect("blocks");
+ // Over the *same* stores the server holds, so what the cascade writes is what the
+ // published list and the next presentation read.
+ let federation = FederationContext::new(FederationCollaborators {
+ codec: fixture.codec.clone(),
+ capabilities: fixture.revocations.clone(),
+ peers: fixture.peers.clone(),
+ clock: fixture.clock.clone(),
+ federation_url: Some(support::FEDERATION_URL.to_owned()),
+ });
+ let cut = capsule_server::federation::on_peer_blocked(&federation, &PeerId::new(PEER))
+ .await
+ .expect("the cascade runs");
+ assert_eq!(cut, 2);
+
+ let list: Value = fixture
+ .client
+ .get("/.well-known/capsule/revoked-jti")
+ .header("accept", "application/json")
+ .send()
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ let published: Vec<&str> = list["revoked"]
+ .as_array()
+ .expect("an array")
+ .iter()
+ .map(|token| token["jti"].as_str().expect("a jti"))
+ .collect();
+ assert!(published.contains(&first_jti.as_str()));
+ assert!(published.contains(&second_jti.as_str()));
+ assert!(
+ !published.contains(&other_jti.as_str()),
+ "another peer's grant is not this peer's block"
+ );
+
+ // Unblocking does not restore what the cascade cut.
+ fixture
+ .peers
+ .unblock(&PeerId::new(PEER))
+ .await
+ .expect("unblocks");
+ let refused = page(&fixture, &first, &album_query()).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_revoked");
+ page(&fixture, &other, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+// ===========================================================================================
+// The grant's absolute deadline (H1) and the refresh's own roster check
+// ===========================================================================================
+
+#[tokio::test]
+async fn a_grant_the_owner_did_not_make_renewable_cannot_be_refreshed_at_all() {
+ // The default, and the whole of H1's fix: without an absolute deadline a peer holding a
+ // deliberately short grant refreshes to the default TTL inside its own lifetime and chains
+ // forever, leaving `ttl_seconds` advisory for exactly one hop.
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+
+ let minted: Value = mint(
+ &fixture,
+ &bearer,
+ serde_json::json!({ "peer": PEER, "member": BOB, "scope": "read", "ttl_seconds": 60 }),
+ )
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ assert_eq!(minted["renewable"], false);
+ assert_eq!(
+ minted["not_after"], minted["expires_at"],
+ "a grant nobody made renewable dies with its first token"
+ );
+ let capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ let refused = refresh(&fixture, &capability).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_expired");
+}
+
+#[tokio::test]
+async fn a_renewable_grant_stops_at_its_deadline_and_its_last_token_does_not_overhang_it() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+
+ // Renewable for ten hours; the default token life is six.
+ let minted: Value = mint(&fixture, &bearer, renewable_body(&fixture, "read", 10))
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ let deadline: Timestamp = minted["not_after"]
+ .as_str()
+ .expect("a deadline")
+ .parse()
+ .expect("an instant");
+ let mut capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+
+ // Five hours in, the grant has five left and the default token life is six: the successor
+ // is minted for the five that remain, so it ends *at* the deadline rather than past it.
+ fixture.clock.advance(SignedDuration::from_hours(5));
+ let renewed: Value = refresh(&fixture, &capability)
+ .await
+ .assert_status(StatusCode::OK)
+ .json();
+ let expires: Timestamp = renewed["expires_at"]
+ .as_str()
+ .expect("an expiry")
+ .parse()
+ .expect("an instant");
+ assert_eq!(
+ expires, deadline,
+ "the last token of a grant is minted for exactly what is left"
+ );
+ assert_eq!(
+ renewed["not_after"], minted["not_after"],
+ "and the deadline itself never moves"
+ );
+ capability = format!("Bearer {}", renewed["token"].as_str().expect("a token"));
+
+ // That successor is the last one. Refused while it is still a perfectly valid token, so the
+ // answer is "this grant is over" and not "your token expired" — the peer's next move is the
+ // album's owner, not this server.
+ let refused = refresh(&fixture, &capability).await;
+ refused.assert_status(StatusCode::FORBIDDEN);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.capability_expired");
+
+ // And the token itself still works right up to the deadline.
+ page(&fixture, &capability, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_mint_refuses_a_deadline_that_is_past_or_absurd() {
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+
+ for (name, until) in [
+ ("in the past", support::deadline(&fixture, -1).to_string()),
+ (
+ "a century out",
+ support::deadline(&fixture, 24 * 365 * 100).to_string(),
+ ),
+ ("not a date", "next tuesday".to_owned()),
+ ] {
+ let refused = mint(
+ &fixture,
+ &bearer,
+ serde_json::json!({
+ "peer": PEER, "member": BOB, "scope": "read", "renewable_until": until,
+ }),
+ )
+ .await;
+ refused.assert_status(StatusCode::BAD_REQUEST);
+ let refused: Value = refused.json();
+ assert_eq!(
+ refused["code"], "error.federation.capability_malformed",
+ "{name}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn a_refresh_stops_once_the_member_leaves_the_roster() {
+ // The read path already refuses such a token, so nothing is exposed — but a server that
+ // kept issuing successors for a membership that had ended would be minting tokens that can
+ // never be used and writing a store row for each.
+ let dsk = support::identity_key();
+ let (fixture, bearer) = anchored(&dsk).await;
+ publish_roster(&fixture, &bearer, &dsk, 1, &[(BOB, MemberRole::Reader)])
+ .await
+ .assert_status(StatusCode::OK);
+ let minted: Value = mint(&fixture, &bearer, renewable_body(&fixture, "read", 24 * 7))
+ .await
+ .assert_status(StatusCode::CREATED)
+ .json();
+ let capability = format!("Bearer {}", minted["token"].as_str().expect("a token"));
+ refresh(&fixture, &capability)
+ .await
+ .assert_status(StatusCode::OK);
+
+ publish_roster(&fixture, &bearer, &dsk, 2, &[])
+ .await
+ .assert_status(StatusCode::OK);
+ let refused = refresh(&fixture, &capability).await;
+ refused.assert_status(StatusCode::CONFLICT);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.federation.member_not_on_roster");
+}
+
+#[tokio::test]
+async fn every_report_field_is_bounded_before_any_store_is_touched() {
+ // M1. Each of these ends up in a store row, a log line or a counter key, and none is bounded
+ // by anything but the route: `reported_user`, `asset_hash` and `album_id` are strings a peer
+ // chooses. Asserted with the peer *unpinned*, which is what pins the ordering — a `400` here
+ // rather than the `403` an unknown peer gets proves the bound ran before the peer lookup.
+ let (fixture, _) = shared().await;
+ let (signer, _) = support::peer_keypair();
+ let hash = support::checksum(b"the reported bytes");
+
+ for (name, mutate) in [
+ ("reporting_server", "reporting_server"),
+ ("reported_user", "reported_user"),
+ ("asset_hash", "asset_hash"),
+ ("album_id", "album_id"),
+ ("reason", "reason"),
+ ("reported_at", "reported_at"),
+ ("signature", "signature"),
+ ] {
+ let mut body = report(&signer, &hash, Some("csam"));
+ body[mutate] = Value::from("x".repeat(4096));
+ let refused = file(&fixture, body).await;
+ refused.assert_status(StatusCode::BAD_REQUEST);
+ let refused: Value = refused.json();
+ assert_eq!(
+ refused["code"], "error.moderation.report_malformed",
+ "an oversized {name} must be refused before the peer is looked up"
+ );
+ }
+
+ // An empty field is the same refusal, and so is one that is only padding.
+ for blank in ["", " "] {
+ let mut body = report(&signer, &hash, Some("csam"));
+ body["asset_hash"] = Value::from(blank);
+ file(&fixture, body)
+ .await
+ .assert_status(StatusCode::BAD_REQUEST);
+ }
+}
+
+#[tokio::test]
+async fn a_peer_cycling_accounts_meets_a_ceiling_the_per_account_budget_cannot_give() {
+ // M1's other half. `reported_user` is a string the peer chooses, so a peer that names a new
+ // account each time mints itself a fresh per-account allowance; only a key that ignores the
+ // account bounds the peer's total volume.
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ pin(&fixture, public).await;
+ let hash = support::checksum(b"the reported bytes");
+
+ // Spend the peer's whole ceiling through the port, leaving one.
+ for _ in 1..budgets::PEER_REPORTS.limit {
+ fixture
+ .counters
+ .hit(
+ &CounterKey::PeerReports(PEER.to_owned()),
+ budgets::PEER_REPORTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ file(&fixture, report(&signer, &hash, None))
+ .await
+ .assert_status(StatusCode::ACCEPTED);
+
+ // A different account — a fresh per-account budget — and still refused.
+ fixture
+ .accounts
+ .insert("third@example.com", "pw", &UserId::new(OTHER_MEMBER));
+ let refused = file(
+ &fixture,
+ support::signed_report(
+ &signer,
+ PEER,
+ OTHER_MEMBER,
+ &hash,
+ &album(),
+ None,
+ "2026-09-02T00:00:00Z",
+ ),
+ )
+ .await;
+ refused.assert_status(StatusCode::TOO_MANY_REQUESTS);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.report_rate_limited");
+}
+
+#[tokio::test]
+async fn an_anonymous_caller_is_bounded_before_the_peer_store_is_read() {
+ // M2's deliverable half. `POST /v1/federation/reports` is this server's only unauthenticated
+ // write; everything past the intake budget is a store read and an Ed25519 verification, and
+ // an anonymous caller would otherwise get both for free on every request.
+ let (fixture, _) = shared().await;
+ let (signer, _) = support::peer_keypair();
+ let hash = support::checksum(b"the reported bytes");
+
+ // Nobody pinned this peer, so a report is `403` — until the intake budget is spent, after
+ // which it is `429` and the peer store is never asked at all.
+ file(&fixture, report(&signer, &hash, None))
+ .await
+ .assert_status(StatusCode::FORBIDDEN);
+ for _ in 1..budgets::FEDERATED_INTAKE.limit {
+ fixture
+ .counters
+ .hit(
+ &CounterKey::FederatedIntake(PEER.to_owned()),
+ budgets::FEDERATED_INTAKE,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers");
+ }
+ let refused = file(&fixture, report(&signer, &hash, None)).await;
+ refused.assert_status(StatusCode::TOO_MANY_REQUESTS);
+ let refused: Value = refused.json();
+ assert_eq!(refused["code"], "error.moderation.report_rate_limited");
+
+ // The budget is above the policy one, so a real peer meets the budget that *is* the policy
+ // first and never this one.
+ assert!(budgets::FEDERATED_INTAKE.limit > budgets::PEER_REPORTS.limit);
+}
+
+#[tokio::test]
+async fn a_capability_is_refused_on_every_surface_that_is_not_a_federated_read() {
+ // The codec proves a capability is unreadable to the session verifier; this proves the
+ // *routing*. Only three operations take `ReadBearer` — the two reads and the refresh — and
+ // every other secured operation takes `Auth`, so a capability presented there
+ // must be refused by the scheme rather than admitted as some default principal. Asserted on
+ // the wire, because "which scheme is mounted where" is a property of the router.
+ let (fixture, _) = shared().await;
+ let (capability, _) = capability(&fixture, PEER, Scope::Read, 1).await;
+
+ // A write on the album the capability is *for*, so nothing here is refused merely for
+ // naming the wrong album.
+ let roster = format!("/v1/albums/{}/roster", album());
+ let capabilities = format!("/v1/albums/{}/capabilities", album());
+ for (name, response) in [
+ (
+ "POST /v1/albums",
+ fixture
+ .client
+ .post("/v1/albums")
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&serde_json::json!({ "album_id": second_album().as_str() }))
+ .send()
+ .await,
+ ),
+ (
+ "PUT /v1/albums/{album_id}/roster",
+ fixture
+ .client
+ .put(&roster)
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&serde_json::json!({ "roster_cbor": "" }))
+ .send()
+ .await,
+ ),
+ (
+ "POST /v1/albums/{album_id}/capabilities",
+ fixture
+ .client
+ .post(&capabilities)
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&mint_body("read"))
+ .send()
+ .await,
+ ),
+ (
+ "POST /v1/upload",
+ fixture
+ .client
+ .post("/v1/upload")
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .json(&serde_json::json!({ "album_id": album().as_str() }))
+ .send()
+ .await,
+ ),
+ (
+ "GET /v1/quota",
+ fixture
+ .client
+ .get("/v1/quota")
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await,
+ ),
+ (
+ "GET /v1/moderation/record",
+ fixture
+ .client
+ .get("/v1/moderation/record")
+ .header("authorization", &capability)
+ .header("x-capsule-protocol", PROTOCOL_VERSION)
+ .send()
+ .await,
+ ),
+ ] {
+ assert_eq!(
+ response.status(),
+ StatusCode::UNAUTHORIZED,
+ "{name} admitted a federation capability, or refused it as something other than \
+ an unreadable credential"
+ );
+ }
+
+ // And the same token is admitted on the one read it is for, so the cases above are refusing
+ // the *surface* and not a token that had gone bad.
+ page(&fixture, &capability, &album_query())
+ .await
+ .assert_status(StatusCode::OK);
+}
+
+#[tokio::test]
+async fn a_report_about_an_account_this_server_does_not_host_is_accepted_and_dropped() {
+ // The intake must not become an account-enumeration oracle. An unresolvable report is a
+ // permanent orphan row nobody can act on, so it is *not filed* — but the peer is told the
+ // same thing either way, because a distinct status is exactly what a peer would sweep
+ // identifiers against. "Pinned" is not "trusted with enumeration".
+ let (fixture, _) = shared().await;
+ let (signer, public) = support::peer_keypair();
+ pin(&fixture, public).await;
+ let hash = support::checksum(b"the reported bytes");
+
+ let hosted = file(&fixture, report(&signer, &hash, Some("csam"))).await;
+ let hosted_status = hosted.status();
+ let hosted: Value = hosted.json();
+
+ let stranger = file(
+ &fixture,
+ support::signed_report(
+ &signer,
+ PEER,
+ "01937b7c-0000-7000-8000-0000000000dd",
+ &hash,
+ &album(),
+ Some("csam"),
+ "2026-09-02T00:00:00Z",
+ ),
+ )
+ .await;
+ assert_eq!(
+ stranger.status(),
+ hosted_status,
+ "the status must not vary with whether the account exists"
+ );
+ let stranger: Value = stranger.json();
+
+ // The bodies must be the same *shape*, differing only in the identifier every accepted
+ // report gets a fresh one of — a body that were empty, or missing a field, would be the
+ // oracle back again one field along.
+ assert_eq!(
+ stranger
+ .as_object()
+ .expect("an object")
+ .keys()
+ .collect::>(),
+ hosted
+ .as_object()
+ .expect("an object")
+ .keys()
+ .collect::>()
+ );
+ assert!(
+ stranger["report_id"]
+ .as_str()
+ .is_some_and(|id| !id.is_empty())
+ );
+ assert_ne!(stranger["report_id"], hosted["report_id"]);
+
+ // Only the resolvable one reached the queue.
+ let pending = fixture
+ .moderation
+ .pending_reports()
+ .await
+ .expect("the store answers");
+ assert_eq!(pending.len(), 1, "an unresolvable report is not filed");
+ assert_eq!(pending[0].reported_user, UserId::new(reported()));
+ assert_eq!(
+ pending[0].report_id,
+ hosted["report_id"].as_str().expect("a report id")
+ );
+
+ // And probing is not free: both requests were charged before the account was looked at.
+ assert_eq!(
+ fixture
+ .counters
+ .peek(
+ &CounterKey::PeerReports(PEER.to_owned()),
+ budgets::PEER_REPORTS,
+ fixture.clock.now(),
+ )
+ .await
+ .expect("the counter answers"),
+ capsule_server::counter::Verdict::Admitted {
+ remaining: budgets::PEER_REPORTS.limit - 2
+ },
+ "a swept identifier costs the peer its allowance"
+ );
+}
diff --git a/capsule-server/tests/sdk_client.rs b/capsule-server/tests/sdk_client.rs
index 8984f968..8f488726 100644
--- a/capsule-server/tests/sdk_client.rs
+++ b/capsule-server/tests/sdk_client.rs
@@ -287,3 +287,147 @@ async fn the_sdk_completes_a_real_second_factor_over_a_socket() {
.expect("the code completes the sign-in");
assert!(session.is_authenticated().await);
}
+
+/// E2E case 4 (server half, SDK client): a peer pulls a shared album over a socket.
+///
+/// What only a socket can prove for the federated path: the capability is presentable under the
+/// **one** `bearer` component the generated client attaches credentials by — the whole reason
+/// the second scheme registers under the same name — the album-scoped page and its opaque
+/// cursor round-trip through JSON, the blob comes back through the generated byte-serving
+/// operation and verifies against its own address, and a revocation the home server publishes
+/// stops the pull on the client's own fail-closed rule rather than on a refusal it stumbles
+/// into.
+#[tokio::test]
+async fn e2e_case_4_a_peer_pulls_a_shared_album_through_the_sdk_over_a_socket() {
+ use capsule_sdk::federation::{FederationError, FederationPull};
+ use capsule_server::federation::{
+ CapabilityRecord, CapabilityStore as _, MintRequest, PeerId, Scope,
+ };
+ use capsule_server::membership::{MemberRole, MembershipStore as _, RosterRecord};
+ use capsule_server::store::UserId;
+
+ const PEER: &str = "other.test";
+ const BOB: &str = "01937b7c-0000-7000-8000-0000000000b0";
+
+ use capsule_server::album::{AlbumRecord, AlbumStore as _};
+
+ let fixture = Fixture::working();
+ // The album row itself: a peer's page is bound to the album's *owner*, which is a fact of
+ // the album store rather than of the index.
+ fixture
+ .albums
+ .provision(AlbumRecord {
+ album_id: album(),
+ owner_id: owner(),
+ protocol_version: PROTOCOL_VERSION.to_owned(),
+ upgrade: None,
+ created_at: Timestamp::UNIX_EPOCH,
+ })
+ .await
+ .expect("the album store provisions");
+ publish(&fixture, "case-4-one").await;
+ let manifest = publish(&fixture, "case-4-two").await;
+ let address = support::checksum(&manifest);
+ fixture
+ .members
+ .apply_roster(
+ RosterRecord {
+ album_id: album(),
+ roster_version: 1,
+ amk_epoch: 1,
+ attested_by_device: support::device(),
+ received_at: Timestamp::UNIX_EPOCH,
+ document: b"sdk-case-4".to_vec(),
+ },
+ vec![(UserId::new(BOB), MemberRole::Reader)],
+ )
+ .await
+ .expect("the store applies");
+
+ // A grant, recorded exactly as the mint route records one.
+ let minted = fixture
+ .codec
+ .mint(&MintRequest {
+ peer: PeerId::new(PEER),
+ album: album(),
+ scope: Scope::Read,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ ttl: jiff::SignedDuration::from_hours(6),
+ })
+ .expect("it mints");
+ fixture
+ .revocations
+ .issue(CapabilityRecord {
+ jti: minted.grant.jti.clone(),
+ album_id: album(),
+ peer_id: PeerId::new(PEER),
+ member: UserId::new(BOB),
+ scope: Scope::Read,
+ granted_epoch: 1,
+ min_protocol_version: PROTOCOL_VERSION.to_owned(),
+ issued_at: minted.grant.issued_at,
+ expires_at: minted.grant.expires_at,
+ not_after: minted.grant.expires_at,
+ revoked_at: None,
+ refreshed_to: None,
+ })
+ .await
+ .expect("the store records");
+
+ let base_url = serve(&fixture).await;
+ let pull = FederationPull::new(
+ &base_url,
+ album().as_str(),
+ minted.token.clone(),
+ minted.grant.jti.clone(),
+ )
+ .expect("the base url is a url");
+
+ // The page: the album's entries, in order, with a cursor that resumes.
+ let page = pull
+ .page(&SyncCursor::start(), 10)
+ .await
+ .expect("the peer's page is served");
+ assert_eq!(page.entries.len(), 2);
+ assert!(
+ page.entries
+ .iter()
+ .all(|entry| entry.album_id == album().as_str().as_bytes())
+ );
+ let resumed = pull
+ .page(&page.next_cursor, 10)
+ .await
+ .expect("the cursor resumes");
+ assert!(resumed.entries.is_empty());
+
+ // The bytes, through the generated byte-serving operation, verified against the address.
+ let fetched = pull
+ .blob(&address, manifest.len() as u64)
+ .await
+ .expect("the blob is served");
+ assert_eq!(fetched, manifest);
+
+ // A revocation the home server publishes stops the pull on the client's own rule: the
+ // snapshot is re-read because the fixture's list publishes a zero staleness bound only when
+ // it has nothing to say, so the poll is forced here to make the check the client's.
+ fixture
+ .revocations
+ .revoke_issued(&minted.grant.jti, fixture.clock.now())
+ .await
+ .expect("the issuer revokes");
+ pull.poll_revocations()
+ .await
+ .expect("the list is published");
+ assert!(
+ matches!(pull.admit().await, Err(FederationError::Revoked)),
+ "a revoked jti stops the pull before anything is presented"
+ );
+ assert!(matches!(
+ pull.page(&SyncCursor::start(), 10).await,
+ Err(FederationError::Revoked)
+ ));
+ assert!(matches!(
+ pull.blob(&address, manifest.len() as u64).await,
+ Err(FederationError::Revoked)
+ ));
+}
diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs
index cd9f25f2..2360120e 100644
--- a/capsule-server/tests/support/mod.rs
+++ b/capsule-server/tests/support/mod.rs
@@ -53,7 +53,7 @@ use capsule_server::directory::{
PublishedDirectory,
};
use capsule_server::discovery::revocation::{
- InMemoryRevocations, PublishedRevocations, RevocationList, RevokeFuture, RevokedToken,
+ PublishedRevocations, RevocationList, RevokeFuture, RevokedToken,
};
use capsule_server::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo};
use capsule_server::drop::{
@@ -61,6 +61,11 @@ use capsule_server::drop::{
};
use capsule_server::enrollment::EnrollmentContext;
use capsule_server::escrow::{EscrowContext, EscrowRecord, EscrowStore, InMemoryEscrow, Replaced};
+use capsule_server::federation::{
+ CapabilityCodec, CapabilityFilter, CapabilityRecord, CapabilityStore, FederationCollaborators,
+ FederationContext, InMemoryCapabilities, InMemoryPeers, RefreshOutcome, ReportClaim,
+ RevokeOutcome,
+};
use capsule_server::gc::memory::InMemoryCollection;
use capsule_server::index::memory::InMemoryAssetIndex;
use capsule_server::index::{
@@ -72,7 +77,8 @@ use capsule_server::membership::{
RosterRecord,
};
use capsule_server::moderation::{
- InMemoryModeration, ModerationContext, ModerationEvent, ModerationStore, Standing,
+ FederatedReport, InMemoryModeration, ModerationContext, ModerationEvent, ModerationStore,
+ Standing,
};
use capsule_server::quota::{
ChargeOutcome, InMemoryQuota, QuotaContext, QuotaLimits, QuotaStore, StoredUsage,
@@ -196,6 +202,9 @@ pub(crate) fn identity_header(ik: &HybridSigningKey) -> String {
/// asserting about one fact rather than two matching literals.
pub(crate) const SERVER_ORIGIN: &str = "capsule.test";
+/// Where peers pull from, in every fixture that federates. The API base, as the design has it.
+pub(crate) const FEDERATION_URL: &str = "https://capsule.test/v1";
+
/// The account [`Fixture::working`] seeds.
pub(crate) const EMAIL: &str = "somebody@example.test";
@@ -944,6 +953,20 @@ impl ModerationStore for SwitchableModeration {
}
self.inner.events_for_user(user)
}
+
+ fn file_report(&self, report: FederatedReport) -> StoreFuture<'_, ()> {
+ if self.is_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.file_report(report)
+ }
+
+ fn pending_reports(&self) -> StoreFuture<'_, Vec> {
+ if self.is_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.pending_reports()
+ }
}
/// An enrollment-code store that can be made to fail on demand.
@@ -2106,25 +2129,31 @@ impl AlbumStore for SwitchableAlbums {
}
}
-/// A revocation list that can be made to fail on demand.
+/// A capability store — and therefore a revocation list — that can be made to fail on demand.
///
-/// Delegates to a real in-memory list, so the failing case and the working case differ in
+/// Delegates to the real in-memory store, so the failing case and the working case differ in
/// exactly one thing. It exists because `503` on the published record is a *claim*: the
/// endpoint refuses to serve an empty list on a storage failure, since an empty list is the
/// strongest statement the record can make and serving it during an outage would silently
/// un-revoke every token a peer holds. A status nothing can reach is a status nothing proves.
+///
+/// One object behind two ports, exactly as `boot` wires it: discovery reads it as the list and
+/// federation writes it as the store, so a revocation the federation layer records is the one
+/// `revoked-jti` publishes.
#[derive(Debug)]
pub(crate) struct SwitchableRevocations {
- inner: InMemoryRevocations,
+ inner: InMemoryCapabilities,
unavailable: AtomicBool,
+ writes_unavailable: AtomicBool,
}
impl SwitchableRevocations {
- /// A working list reading `clock` for pruning.
+ /// A working store reading `clock` for pruning.
pub(crate) fn new(clock: Arc) -> Self {
Self {
- inner: InMemoryRevocations::new(clock),
+ inner: InMemoryCapabilities::new(clock),
unavailable: AtomicBool::new(false),
+ writes_unavailable: AtomicBool::new(false),
}
}
@@ -2133,6 +2162,16 @@ impl SwitchableRevocations {
self.unavailable.store(unavailable, Ordering::SeqCst);
}
+ /// Make every subsequent *write* fail while reads keep answering, or stop.
+ ///
+ /// The one seam a route-level `500` can be reached through: a store that cannot be **read**
+ /// refuses the credential in the authenticator, which can render only `401`, so a whole
+ /// outage never reaches a handler. A store that answers `find` and refuses `refresh` is the
+ /// partial failure the coded `500` exists for.
+ pub(crate) fn set_writes_unavailable(&self, unavailable: bool) {
+ self.writes_unavailable.store(unavailable, Ordering::SeqCst);
+ }
+
fn refuse() -> Result {
Err(StoreError::Unavailable {
store: "revocations",
@@ -2143,11 +2182,15 @@ impl SwitchableRevocations {
fn is_down(&self) -> bool {
self.unavailable.load(Ordering::SeqCst)
}
+
+ fn writes_down(&self) -> bool {
+ self.is_down() || self.writes_unavailable.load(Ordering::SeqCst)
+ }
}
impl RevocationList for SwitchableRevocations {
fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> {
- if self.is_down() {
+ if self.writes_down() {
return Box::pin(async { Self::refuse().map_err(Into::into) });
}
self.inner.revoke(token)
@@ -2161,6 +2204,52 @@ impl RevocationList for SwitchableRevocations {
}
}
+impl CapabilityStore for SwitchableRevocations {
+ fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> {
+ if self.writes_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.issue(record)
+ }
+
+ fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> {
+ if self.is_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.find(jti)
+ }
+
+ fn live<'a>(
+ &'a self,
+ filter: &'a CapabilityFilter,
+ now: Timestamp,
+ ) -> StoreFuture<'a, Vec> {
+ if self.is_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.live(filter, now)
+ }
+
+ fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> {
+ if self.writes_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.revoke_issued(jti, at)
+ }
+
+ fn refresh<'a>(
+ &'a self,
+ predecessor: &'a str,
+ successor: CapabilityRecord,
+ at: Timestamp,
+ ) -> StoreFuture<'a, RefreshOutcome> {
+ if self.writes_down() {
+ return Box::pin(async { Self::refuse() });
+ }
+ self.inner.refresh(predecessor, successor, at)
+ }
+}
+
/// A device-directory store that can be made to fail on demand.
///
/// Delegates to a real in-memory store, so the failing case and the working case differ in
@@ -2506,8 +2595,13 @@ pub(crate) struct Fixture {
/// The attestation key the server signs receipts with — the *same* one, so a test can
/// verify a fetched receipt the way a client would.
pub(crate) attestation_key: Arc,
- /// The federation capability revocations this server publishes.
+ /// The federation capabilities this server issued, and the revocations it publishes.
pub(crate) revocations: Arc,
+ /// The peers this server has pinned or blocked.
+ pub(crate) peers: Arc,
+ /// The capability codec the server mints with — the *same* one, over the *same* key as
+ /// `tokens`, so a test can mint a capability the server will accept, or one it must not.
+ pub(crate) codec: Arc,
/// The single-use revoke-all challenges.
pub(crate) challenges: Arc,
/// The account's wrapped master key.
@@ -2544,11 +2638,31 @@ impl Fixture {
/// The same server, with a deployment's quota thresholds.
pub(crate) fn with_quota(quota_limits: QuotaLimits) -> Self {
+ Self::build(quota_limits, Some(FEDERATION_URL.to_owned()))
+ }
+
+ /// The same server on a deployment that does **not** federate: `FEDERATION_URL` unset.
+ ///
+ /// Its own constructor rather than a switch on the built fixture, because the setting is
+ /// read once at boot and a server that changed its mind at runtime would be testing a
+ /// deployment nobody runs.
+ pub(crate) fn without_federation() -> Self {
+ Self::build(QuotaLimits::unlimited(), None)
+ }
+
+ fn build(quota_limits: QuotaLimits, federation_url: Option) -> Self {
let clock = Arc::new(ManualClock::default());
let sessions = Arc::new(SwitchableSessions::new(clock.clone()));
let accounts = Arc::new(InMemoryAccounts::new());
accounts.insert(EMAIL, PASSWORD, &user());
- let tokens = Arc::new(signer(clock.clone()));
+ // One key pair for both token types, as `boot` wires it: the capability a peer verifies
+ // against `server-info`'s key is signed by the key that signs sessions.
+ let der = signing_key_der();
+ let tokens = Arc::new(signer_from(&der, clock.clone()));
+ let codec = Arc::new(
+ CapabilityCodec::from_pkcs8(&der, SERVER_ORIGIN, clock.clone())
+ .expect("a key just generated parses"),
+ );
let uploads = Arc::new(SwitchableUploads::new(clock.clone()));
let blobs = Arc::new(SwallowingBlobs::new());
@@ -2577,6 +2691,7 @@ impl Fixture {
capsule_core::crypto::keys::HybridSigningKey::generate(),
));
let revocations = Arc::new(SwitchableRevocations::new(clock.clone()));
+ let peers = Arc::new(InMemoryPeers::new());
let challenges = Arc::new(SwitchableChallenges::new(clock.clone()));
let escrows = Arc::new(SwitchableEscrow::new());
let cohorts = Arc::new(SwitchableCohorts::new());
@@ -2645,6 +2760,14 @@ impl Fixture {
),
discovery: DiscoveryContext::new(Arc::new(server_info(&tokens)), revocations.clone()),
escrow: EscrowContext::new(escrows.clone(), clock.clone()),
+ // Configured unless the case asked otherwise ([`Fixture::without_federation`]).
+ federation: FederationContext::new(FederationCollaborators {
+ codec: codec.clone(),
+ capabilities: revocations.clone(),
+ peers: peers.clone(),
+ clock: clock.clone(),
+ federation_url,
+ }),
enrollment: EnrollmentContext::new(
enrollments.clone(),
channels.clone(),
@@ -2685,6 +2808,8 @@ impl Fixture {
receipts,
attestation_key,
revocations,
+ peers,
+ codec,
challenges,
escrows,
cohorts,
@@ -2728,7 +2853,9 @@ impl Fixture {
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 der = signing_key_der();
+ let tokens = Arc::new(signer_from(&der, clock.clone()));
+ let issued = Arc::new(SwitchableRevocations::new(clock.clone()));
let app = App::new(Modules {
auth: AuthContext::new(AuthCollaborators {
sessions: Arc::new(SwitchableSessions::new(clock.clone())),
@@ -2788,11 +2915,18 @@ impl Fixture {
)),
Timestamp::UNIX_EPOCH,
),
- discovery: DiscoveryContext::new(
- Arc::new(server_info(&tokens)),
- Arc::new(SwitchableRevocations::new(clock.clone())),
- ),
+ discovery: DiscoveryContext::new(Arc::new(server_info(&tokens)), issued.clone()),
escrow: EscrowContext::new(Arc::new(SwitchableEscrow::new()), clock.clone()),
+ federation: FederationContext::new(FederationCollaborators {
+ codec: Arc::new(
+ CapabilityCodec::from_pkcs8(&der, SERVER_ORIGIN, clock.clone())
+ .expect("a key just generated parses"),
+ ),
+ capabilities: issued,
+ peers: Arc::new(InMemoryPeers::new()),
+ clock: clock.clone(),
+ federation_url: Some(FEDERATION_URL.to_owned()),
+ }),
enrollment: EnrollmentContext::new(
Arc::new(InMemoryEnrollments::new(clock.clone(), ENROLLMENT_CODE_TTL)),
Arc::new(InMemoryChannels::new(clock.clone(), RELAY_CHANNEL_TTL)),
@@ -2920,6 +3054,67 @@ impl Fixture {
}
}
+/// A peer server's operational key pair: the signer, and the raw thirty-two public bytes an
+/// operator pins with `PeerStore::pin`.
+///
+/// Generated per call rather than fixed, so a case that means "a *different* peer's key" gets
+/// one by asking again.
+pub(crate) fn peer_keypair() -> (ring::signature::Ed25519KeyPair, [u8; 32]) {
+ use ring::signature::KeyPair as _;
+
+ let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
+ .expect("a key generates");
+ let pair = ring::signature::Ed25519KeyPair::from_pkcs8(der.as_ref()).expect("it parses");
+ let public = pair
+ .public_key()
+ .as_ref()
+ .try_into()
+ .expect("an Ed25519 public key is thirty-two bytes");
+ (pair, public)
+}
+
+/// A federated moderation report body, signed by `pair` exactly as a peer signs one.
+///
+/// Built through [`ReportClaim::signing_bytes`] rather than by re-encoding the JSON, so the
+/// suite signs the same bytes the server verifies and a change to the signing contract fails as
+/// a verification failure rather than as a silently-different test.
+pub(crate) fn signed_report(
+ pair: &ring::signature::Ed25519KeyPair,
+ reporting_server: &str,
+ reported_user: &str,
+ asset_hash: &str,
+ album: &AlbumId,
+ reason: Option<&str>,
+ reported_at: &str,
+) -> serde_json::Value {
+ let claim = ReportClaim {
+ reporting_server: reporting_server.to_owned(),
+ reported_user: reported_user.to_owned(),
+ asset_hash: asset_hash.to_owned(),
+ album_id: album.as_str().to_owned(),
+ reason: reason.map(str::to_owned),
+ reported_at: reported_at.to_owned(),
+ };
+ let signature = pair.sign(&claim.signing_bytes().expect("a claim encodes"));
+ serde_json::json!({
+ "reporting_server": claim.reporting_server,
+ "reported_user": claim.reported_user,
+ "asset_hash": claim.asset_hash,
+ "album_id": claim.album_id,
+ "reason": claim.reason,
+ "reported_at": claim.reported_at,
+ "signature": base64::engine::general_purpose::STANDARD.encode(signature.as_ref()),
+ })
+}
+
+/// `hours` from the fixture's own clock, as an RFC 3339 instant.
+///
+/// The suite's clock starts at the Unix epoch, so a wall-clock literal in a request body is
+/// decades out and refused; every deadline a case names is relative to this.
+pub(crate) fn deadline(fixture: &Fixture, hours: i64) -> jiff::Timestamp {
+ fixture.clock.now() + jiff::SignedDuration::from_hours(hours)
+}
+
/// The protocol version the suite's manifests and sessions are written under.
pub(crate) const PROTOCOL_VERSION: &str = capsule_core::crypto::primitives::PROTOCOL_VERSION;
@@ -3064,10 +3259,23 @@ pub(crate) fn server_info(tokens: &SessionTokens) -> ServerInfo {
}
pub(crate) fn signer(clock: Arc