Files
JRay-public-server/src/app.rs
T
dtourolleandClaude Opus 5 545c7d92a2
CI / fmt, clippy, test (push) Failing after 1m20s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 25s
Federation: replicate content, re-derive judgement (UR-008)
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
2026-07-31 09:28:32 +02:00

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)
}