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
75 lines
3.2 KiB
Rust
75 lines
3.2 KiB
Rust
//! Router construction.
|
|
//!
|
|
//! §6 stage 0/1 body caps are applied here as per-route `DefaultBodyLimit`
|
|
//! layers: Axum rejects on `Content-Length` before reading a body *and* caps the
|
|
//! stream for chunked or mis-declared uploads, which is what makes a lying
|
|
//! header and a chunked upload both safe. Per-route means the bundle endpoint
|
|
//! gets its larger limit without widening the others (§6 stage 1).
|
|
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::DefaultBodyLimit;
|
|
use axum::routing::{get, post};
|
|
use axum::Router;
|
|
use tower_http::timeout::TimeoutLayer;
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
use crate::api::{exists, federation, fetch, report, upload};
|
|
use crate::state::AppState;
|
|
use crate::validate::limits;
|
|
|
|
/// Small cap for endpoints that take a short JSON body. A read endpoint has no
|
|
/// business accepting a large payload, and the batch `exists` form is bounded at
|
|
/// 100 items.
|
|
const SMALL_BODY_LIMIT: usize = 256 * 1024;
|
|
|
|
/// TRACES: DR-009, DR-013 | SR-004
|
|
pub fn router(state: AppState) -> Router {
|
|
let timeout = state.config.request_timeout;
|
|
|
|
let v1 = Router::new()
|
|
// UR-1 — existence probes.
|
|
.route("/manifests/exists", get(exists::exists).post(exists::exists_batch))
|
|
// Reads.
|
|
.route("/manifests/movie", get(fetch::get_movie))
|
|
.route("/manifests/episode", get(fetch::get_episode))
|
|
.route("/manifests/series/{series_tmdb_id}", get(fetch::get_series))
|
|
.route("/manifests/{id}", get(fetch::get_by_id))
|
|
.route("/manifests/{id}/status", get(fetch::get_status))
|
|
.route("/manifests/{id}/report", post(report::post_report))
|
|
// UR-2 — contribution.
|
|
.route(
|
|
"/manifests",
|
|
post(upload::post_manifest).layer(DefaultBodyLimit::max(limits::BODY_LIMIT_MANIFEST)),
|
|
)
|
|
// UR-6 — whole-series contribution, with its own larger cap.
|
|
.route(
|
|
"/manifests/bundle",
|
|
post(upload::post_bundle).layer(DefaultBodyLimit::max(limits::BODY_LIMIT_BUNDLE)),
|
|
)
|
|
// §5a — anonymous bearer capability, not an account.
|
|
.route("/tokens", post(upload::post_token))
|
|
// §9a federation. Pull-based: a peer chooses what it ingests and when,
|
|
// so every route here is a read. There is deliberately no push endpoint.
|
|
.route("/federation/changes", get(federation::get_changes))
|
|
.route("/federation/manifests/{content_id}", get(federation::get_manifest_by_content_id))
|
|
.route("/federation/have", post(federation::post_have))
|
|
.route("/federation/peers", get(federation::get_peers))
|
|
.route("/federation/capabilities", get(federation::get_capabilities))
|
|
.layer(DefaultBodyLimit::max(SMALL_BODY_LIMIT));
|
|
|
|
Router::new()
|
|
.route("/health", get(report::health))
|
|
.route("/ready", get(report::ready))
|
|
.nest("/api/v1", v1)
|
|
// §8: a request timeout so a slow bundle query fails fast.
|
|
.layer(TimeoutLayer::with_status_code(axum::http::StatusCode::REQUEST_TIMEOUT, timeout))
|
|
.layer(TraceLayer::new_for_http())
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Convenience for tests and `main`.
|
|
pub fn default_timeout() -> Duration {
|
|
Duration::from_secs(30)
|
|
}
|