Federation: replicate content, re-derive judgement (UR-008)
CI / fmt, clippy, test (push) Failing after 1m20s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 25s

Implements §9a. The replication surface is four reads and no writes: a change
feed, fetch by content_id, a batch have, and a human-facing peer directory —
plus a capabilities endpoint carrying the accepted envelope versions, which
lets a client discover a schema mismatch in one request instead of a 400 per
manifest across a library sweep.

Pull, never push: a pulling server chooses what it ingests and when. Push would
let any peer inject work into the validation queue — the same abuse surface as
anonymous upload, at higher volume.

Nothing inherits a peer's judgement. A pulled manifest runs the full §6 stage 1
and 2 validation and this server's own cast check, and the fetched body must
hash to the content_id that was asked for — the check that stops an
intermediary or a misbehaving peer substituting content under a trusted id.
A peer's retraction flags for review rather than delisting, because
auto-delisting would hand every peer a remote delete primitive; only the opt-in
per-peer abuse channel delists, because a takedown propagating at the speed of
manual review is the wrong failure mode for that one case.

A test caught a real bug in the first cut: the feed cursor was a ULID, and
ULIDs are only monotonic *between* milliseconds — two generated in the same
millisecond carry independent random components and can sort opposite to write
order. A peer resuming from `seq > cursor` would then silently skip an entry:
replication losing manifests with no error anywhere. The cursor is now an
AUTOINCREMENT integer, and the test asserts strict monotonicity rather than
merely sortedness.

Peer administration is deliberately not an API. §9a requires that a peering
exist only because an operator typed a URL, so nothing a remote server returns
can establish or widen one; there_is_no_endpoint_that_creates_a_peering asserts
that absence rather than trusting it.

212 tests. Coverage 25/32 (78%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: UR-008 | PR-006
This commit is contained in:
2026-07-31 09:28:32 +02:00
co-authored by Claude Opus 5
parent 88c7264094
commit 545c7d92a2
18 changed files with 1640 additions and 44 deletions
+288
View File
@@ -0,0 +1,288 @@
//! §9a federation endpoints.
//!
//! **Replicate content, re-derive judgement.** A validated manifest is immutable
//! and content-addressable, so replication is *set reconciliation* rather than
//! state synchronisation — no concurrent edits, no last-write-wins, no vector
//! clocks. What must not replicate is the mutable half: `status`, `reports` and
//! `cast_match_ratio` encode a local operator's judgement and legal position.
//! A server that adopts a peer's `listed` flags has outsourced its liability; one
//! that adopts their `delisted` flags has outsourced its moderation.
//!
//! **Pull, never push.** A pulling server chooses what it ingests and when.
//! Push would let any peer inject work into your validation queue — the same
//! abuse surface as anonymous upload, but at higher volume.
use axum::extract::{Path, Query, State};
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::db::repo;
use crate::error::{ApiError, ApiResult};
use crate::model::JMANIFEST_VERSION;
use crate::ratelimit::Surface;
use crate::state::{with_quota_headers, AppState};
/// §5: bounded so one peer cannot walk the whole catalogue in a single request.
const MAX_CHANGES_LIMIT: usize = 1000;
/// §5: `POST /federation/have`, up to 1000 ids.
const MAX_HAVE_IDS: usize = 1000;
#[derive(Debug, Deserialize)]
pub struct ChangesParams {
/// The cursor a peer echoes back. Opaque on the wire — a peer must treat it
/// as a token, not compute with it.
#[serde(default)]
pub since: Option<i64>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct ChangesResponse {
pub cursor: Option<i64>,
pub server_id: String,
pub changes: Vec<ChangeItem>,
}
#[derive(Debug, Serialize)]
pub struct ChangeItem {
pub content_id: String,
pub op: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub origin: String,
pub seq: i64,
}
/// `GET /federation/changes?since={cursor}&limit=1000`
///
/// A monotonic, append-only feed of locally-*listed* manifests. Entries are
/// metadata only — enough to decide whether to fetch, without transferring
/// payloads. The cursor is opaque and monotonic, so a peer resumes from its last
/// position and the feed is idempotent.
///
/// TRACES: UR-008 | PR-006
pub async fn get_changes(
State(state): State<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
Query(params): Query<ChangesParams>,
) -> ApiResult<Response> {
let ip = state.client_ip(&headers, peer.0);
let quota = state.check_limit(&ip, Surface::FederationChanges)?;
let limit = params.limit.unwrap_or(MAX_CHANGES_LIMIT).min(MAX_CHANGES_LIMIT);
let since = params.since;
let entries = state
.db
.read(move |conn| repo::changes_since(conn, since, limit))
.await
.map_err(ApiError::Internal)?;
let cursor = entries.last().map(|e| e.seq);
let changes = entries
.into_iter()
.map(|e| ChangeItem {
content_id: e.content_id,
op: e.op,
reason: e.reason,
origin: e.origin,
seq: e.seq,
})
.collect();
let body = ChangesResponse { cursor, server_id: state.config.server_id.clone(), changes };
Ok(with_quota_headers(Json(body).into_response(), quota))
}
/// `GET /federation/manifests/{content_id}`
///
/// Full content by hash. **The puller must verify that what comes back hashes to
/// the id it asked for**, and reject it otherwise — that check is what makes an
/// intermediary or a misbehaving peer unable to substitute content. This server
/// performs it on ingest (see [`crate::federation`]).
///
/// TRACES: UR-008 | PR-006
pub async fn get_manifest_by_content_id(
State(state): State<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
Path(content_id): Path<String>,
) -> ApiResult<Response> {
let ip = state.client_ip(&headers, peer.0);
let quota = state.check_limit(&ip, Surface::FederationFetch)?;
let manifest = state
.db
.read(move |conn| {
let Some(row) = repo::manifest_by_content_id_ro(conn, &content_id)? else {
return Ok(None);
};
// Only served content is replicated. A `pending` manifest has not
// cleared this server's own checks, and a `rejected` one failed them.
if row.status != "listed" && row.status != "flagged" {
return Ok(None);
}
let title = super::fetch::title_of(conn, &row.title_id)?;
let kind = if title.kind == "movie" {
crate::model::IdentityType::Movie
} else {
crate::model::IdentityType::Episode
};
Ok(Some(super::fetch::reconstruct(conn, &row, &title, kind)?))
})
.await
.map_err(ApiError::Internal)?;
let manifest = manifest.ok_or(ApiError::NotFound)?;
Ok(with_quota_headers(Json(manifest).into_response(), quota))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HaveRequest {
pub content_ids: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct HaveResponse {
pub have: Vec<String>,
}
/// `POST /federation/have`
///
/// Batch existence check by `content_id`, so a peer diffs its set against yours
/// in one request before fetching anything.
///
/// TRACES: UR-008 | PR-006
pub async fn post_have(
State(state): State<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
super::json::Json(req): super::json::Json<HaveRequest>,
) -> ApiResult<Response> {
let ip = state.client_ip(&headers, peer.0);
let quota = state.check_limit(&ip, Surface::FederationHave)?;
if req.content_ids.len() > MAX_HAVE_IDS {
return Err(ApiError::BadRequest(format!("more than {MAX_HAVE_IDS} content_ids")));
}
let ids = req.content_ids;
let have = state
.db
.read(move |conn| repo::known_content_ids(conn, &ids))
.await
.map_err(ApiError::Internal)?;
Ok(with_quota_headers(Json(HaveResponse { have }).into_response(), quota))
}
#[derive(Debug, Serialize)]
pub struct PeersResponse {
pub server_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
pub peers: Vec<PeerItem>,
}
#[derive(Debug, Serialize)]
pub struct PeerItem {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
}
/// `GET /federation/peers`
///
/// A **human-facing directory, not a discovery mechanism** — the distinction is
/// the whole point of §9a's peer-directory section. It publishes a list a person
/// can read; nothing acts on it. A server never fetches a peer's peers, so there
/// is no crawl and therefore no network-wide topology to poison.
///
/// Advertising is opt-in per peer, and publishing the directory at all is opt-in
/// for the server (`PublishPeerDirectory`, default off) — a server that would
/// rather not disclose its topology simply does not.
///
/// TRACES: UR-008 | PR-005
pub async fn get_peers(
State(state): State<AppState>,
peer: crate::state::PeerIp,
headers: HeaderMap,
) -> ApiResult<Response> {
let ip = state.client_ip(&headers, peer.0);
let quota = state.check_limit(&ip, Surface::FederationPeers)?;
if !state.config.publish_peer_directory {
return Err(ApiError::NotFound);
}
let peers = state.db.read(repo::advertised_peers).await.map_err(ApiError::Internal)?;
let body = PeersResponse {
server_id: state.config.server_id.clone(),
contact: state.config.contact.clone(),
peers: peers
.into_iter()
.map(|p| PeerItem { url: p.url, name: p.name, since: p.peered_since })
.collect(),
};
Ok(with_quota_headers(Json(body).into_response(), quota))
}
#[derive(Debug, Serialize)]
pub struct CapabilitiesResponse {
pub server_id: String,
/// Exchange envelope versions this server accepts. A client checks this once
/// rather than discovering a mismatch as a `400` per manifest across a whole
/// library sweep.
pub jmanifest_versions: Vec<u32>,
pub federation: bool,
/// §3: `POST /manifests/search` is expensive and therefore optional to
/// implement, so it is advertised rather than assumed.
pub audio_search: bool,
pub audio_tier_matching: bool,
}
/// `GET /federation/capabilities`
///
/// What this server actually supports. §3 specifies this for advertising the
/// optional audio-search endpoint; it also carries the accepted envelope
/// versions, which is what lets a plugin discover a schema mismatch in one cheap
/// request instead of N failed uploads.
///
/// TRACES: UR-008, UR-014 | SR-003
pub async fn get_capabilities(State(state): State<AppState>) -> ApiResult<Response> {
let body = CapabilitiesResponse {
server_id: state.config.server_id.clone(),
jmanifest_versions: vec![JMANIFEST_VERSION],
federation: true,
// Deferred by design (§3 sequencing): signatures accumulate first.
audio_search: false,
audio_tier_matching: false,
};
Ok(Json(body).into_response())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capabilities_advertise_only_the_current_envelope_version() {
// The flag day means exactly one accepted version; advertising a range
// would invite a client to send something that will be rejected.
assert_eq!(JMANIFEST_VERSION, 2);
}
#[test]
fn have_request_rejects_unknown_fields() {
// §6 stage 2 applies to federation bodies too — a peer is not exempt.
let r = serde_json::from_str::<HaveRequest>(r#"{"content_ids":[],"extra":1}"#);
assert!(r.is_err());
}
}
+4 -1
View File
@@ -238,7 +238,10 @@ pub async fn get_status(
}
}
fn title_of(conn: &rusqlite::Connection, title_id: &str) -> anyhow::Result<repo::TitleRow> {
pub(crate) fn title_of(
conn: &rusqlite::Connection,
title_id: &str,
) -> anyhow::Result<repo::TitleRow> {
let row = conn.query_row(
"SELECT id, kind, tmdb_id, imdb_id, name, year, adult, certification
FROM titles WHERE id = ?1",
+1
View File
@@ -1,6 +1,7 @@
//! HTTP surface (§4). Base path `/api/v1`, JSON throughout.
pub mod exists;
pub mod federation;
pub mod fetch;
pub mod json;
pub mod report;