Skip to main content

gonzalo_server/
http.rs

1//! HTTP/JSON transport over the shared `Service`, using axum.
2
3use crate::Service;
4use crate::auth::{Access, Auth, Principal};
5use axum::{
6    Extension, Json, Router,
7    body::Bytes,
8    extract::{DefaultBodyLimit, Path, Query, Request, State},
9    http::{HeaderMap, StatusCode, header},
10    middleware::{Next, from_fn},
11    response::{IntoResponse, Response},
12    routing::get,
13};
14use gonzalo_core::{
15    ContentHash, CoreError, DeleteResult, Identity, KeyPrefix, PutResult, RecordKey,
16};
17use gonzalo_proto::http::{
18    DeleteBody, DeleteOutcome, PurgeBody, PutBody, PutOutcome, RawRecordBody,
19};
20use serde::Deserialize;
21use std::sync::Arc;
22
23/// Blobs are namespace-agnostic; they authorize against this reserved
24/// namespace (ADR 0015). Admins (`*`) and open mode cover it; a scoped
25/// principal is granted blob access by listing `_blobs` in its read/write set.
26const BLOB_NS: &str = "_blobs";
27
28/// Every `(METHOD, path)` the daemon serves, in the path-template form the
29/// published OpenAPI document uses (gonzalo#198).
30///
31/// This is the list the schema artifact is checked against, in both directions:
32/// a route added here but not to `docs/api/openapi.json` fails a test, and so
33/// does one documented but not served. It exists because a generated client is
34/// only as good as the description it was generated from, and a description
35/// nothing checks drifts silently.
36pub const SERVED_OPERATIONS: &[(&str, &str)] = &[
37    ("GET", "/healthz"),
38    ("GET", "/readyz"),
39    ("GET", "/v1/records/{ns}/{col}/{id}"),
40    ("PUT", "/v1/records/{ns}/{col}/{id}"),
41    ("DELETE", "/v1/records/{ns}/{col}/{id}"),
42    ("GET", "/v1/keys"),
43    ("GET", "/v1/raw/records/{ns}/{col}/{id}"),
44    ("PUT", "/v1/raw/records/{ns}/{col}/{id}"),
45    ("GET", "/v1/raw/keys"),
46    ("POST", "/v1/purge/{ns}/{col}/{id}"),
47    ("POST", "/v1/tickets/sync"),
48    ("GET", "/v1/graph/definitions"),
49    ("GET", "/v1/graph/references"),
50    ("GET", "/v1/graph/callers"),
51    ("GET", "/v1/graph/callees"),
52    ("GET", "/v1/graph/impact"),
53    ("GET", "/v1/blobs"),
54    ("GET", "/v1/blobs/{hash}"),
55    ("HEAD", "/v1/blobs/{hash}"),
56    ("PUT", "/v1/blobs/{hash}"),
57    ("DELETE", "/v1/blobs/{hash}"),
58];
59
60/// Build the axum router. `auth` governs per-namespace authorization (ADR 0015);
61/// `Auth::Disabled` serves open. The middleware authenticates every non-probe
62/// request (bearer → [`Principal`], or `401`) and hands the principal to the
63/// handlers, which authorize against the target namespace.
64pub fn router(service: Service, auth: Arc<Auth>) -> Router {
65    let max_blob = service.max_blob_size();
66    let blob_routes = Router::new()
67        .route(
68            "/v1/blobs/{hash}",
69            get(get_blob)
70                .head(head_blob)
71                .put(put_blob)
72                .delete(delete_blob),
73        )
74        .route("/v1/blobs", get(list_blobs))
75        .layer(DefaultBodyLimit::max(max_blob));
76
77    let app = Router::new()
78        .route("/healthz", get(healthz))
79        .route("/readyz", get(readyz))
80        .route(
81            "/v1/records/{ns}/{col}/{id}",
82            get(get_record).put(put_record).delete(delete_record),
83        )
84        .route("/v1/keys", get(list_keys))
85        // Replication surface (gonzalo#203): tombstones visible, purge is admin.
86        .route(
87            "/v1/raw/records/{ns}/{col}/{id}",
88            get(get_raw_record).put(put_raw_record),
89        )
90        .route("/v1/raw/keys", get(list_raw_keys))
91        .route(
92            "/v1/purge/{ns}/{col}/{id}",
93            axum::routing::post(purge_record),
94        )
95        .route("/v1/tickets/sync", axum::routing::post(ticket_sync))
96        .route("/v1/graph/definitions", get(graph_definitions))
97        .route("/v1/graph/references", get(graph_references_to))
98        .route("/v1/graph/callers", get(graph_callers_of))
99        .route("/v1/graph/callees", get(graph_callees))
100        .route("/v1/graph/impact", get(graph_impact))
101        .merge(blob_routes)
102        .with_state(Arc::new(service));
103    app.layer(from_fn(move |mut req: Request, next: Next| {
104        let auth = auth.clone();
105        async move {
106            // Health/readiness probes are unauthenticated: k8s liveness and
107            // readiness checks carry no bearer token, and a probe gated behind
108            // auth would fail closed and get the pod killed.
109            if is_probe_path(req.uri().path()) {
110                return next.run(req).await;
111            }
112            match auth.authenticate(bearer(req.headers())) {
113                Some(principal) => {
114                    req.extensions_mut().insert(principal);
115                    next.run(req).await
116                }
117                None => StatusCode::UNAUTHORIZED.into_response(),
118            }
119        }
120    }))
121}
122
123/// `403` when a principal lacks the required access on a namespace.
124fn forbidden(principal: &Principal, access: Access, namespace: &str) -> Response {
125    (
126        StatusCode::FORBIDDEN,
127        format!(
128            "principal {:?} lacks {access:?} on namespace {namespace:?}",
129            principal.name()
130        ),
131    )
132        .into_response()
133}
134
135/// `403` when an operation requires an admin (`read` and `write` on `"*"`).
136fn forbidden_admin(principal: &Principal, operation: &str) -> Response {
137    (
138        StatusCode::FORBIDDEN,
139        format!(
140            "principal {:?} is not an admin; {operation} requires admin",
141            principal.name()
142        ),
143    )
144        .into_response()
145}
146
147/// `400` when the URL path and the body's record key disagree (#158), else
148/// `None`. Shared by `PUT /v1/records/...` and `PUT /v1/raw/records/...`.
149fn path_key_mismatch(path: &(String, String, String), key: &RecordKey) -> Option<Response> {
150    let (ns, col, id) = path;
151    (*ns != key.namespace || *col != key.collection || *id != key.id).then(|| {
152        (
153            StatusCode::BAD_REQUEST,
154            "URL path does not match record key",
155        )
156            .into_response()
157    })
158}
159
160/// `200` + `Committed`, `409` + `Conflict`, `412` when `expected` names a
161/// revision the store does not hold (`CoreError::NotFound`), or an opaque
162/// `500`. `412` rather than `404`: on the raw routes a `404` means "old daemon".
163/// Shared by both put routes.
164fn put_outcome_response(result: gonzalo_core::Result<PutResult>) -> Response {
165    match result {
166        Ok(PutResult::Committed(revision)) => {
167            (StatusCode::OK, Json(PutOutcome::Committed { revision })).into_response()
168        }
169        Ok(PutResult::Conflict(conflict)) => (
170            StatusCode::CONFLICT,
171            Json(PutOutcome::Conflict { conflict }),
172        )
173            .into_response(),
174        Err(CoreError::NotFound(key)) => (
175            StatusCode::PRECONDITION_FAILED,
176            format!("record not found: {key}"),
177        )
178            .into_response(),
179        // The caller asked for something the store will never accept, such as a
180        // consumer put of a tombstone. Retrying is pointless, so say so with a
181        // 400 instead of hiding it behind an opaque 500 (gonzalo#299).
182        Err(CoreError::Invalid(reason)) => (StatusCode::BAD_REQUEST, reason).into_response(),
183        Err(e) => server_error(e),
184    }
185}
186
187/// `200` + `Deleted`, `409` + `Conflict`, `400` for a call the store will never
188/// accept, or an opaque `500`. Shared by `DELETE /v1/records/...` and
189/// `POST /v1/purge/...`.
190fn delete_outcome_response(result: gonzalo_core::Result<DeleteResult>) -> Response {
191    match result {
192        Ok(DeleteResult::Deleted) => (StatusCode::OK, Json(DeleteOutcome::Deleted)).into_response(),
193        Ok(DeleteResult::Conflict(conflict)) => (
194            StatusCode::CONFLICT,
195            Json(DeleteOutcome::Conflict { conflict }),
196        )
197            .into_response(),
198        // A call the store will never accept, such as a purge of a live record
199        // (gonzalo#341). Retrying is pointless, so say so with a 400 instead of
200        // hiding it behind an opaque 500 (gonzalo#299).
201        Err(CoreError::Invalid(reason)) => (StatusCode::BAD_REQUEST, reason).into_response(),
202        Err(e) => server_error(e),
203    }
204}
205
206/// Paths served without authentication (k8s probes).
207fn is_probe_path(path: &str) -> bool {
208    path == "/healthz" || path == "/readyz"
209}
210
211/// Liveness: the process is up and serving. No store access — a `/healthz` that
212/// touched the store would conflate liveness with readiness and kill a pod that
213/// is merely waiting on its backend.
214async fn healthz() -> Response {
215    (StatusCode::OK, "ok").into_response()
216}
217
218/// Readiness: `200` when the backing store is reachable, `503` otherwise, so a
219/// load balancer only routes to replicas that can actually serve.
220async fn readyz(State(svc): State<Arc<Service>>) -> Response {
221    if svc.ready().await {
222        (StatusCode::OK, "ready").into_response()
223    } else {
224        (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response()
225    }
226}
227
228fn bearer(h: &HeaderMap) -> Option<&str> {
229    h.get("authorization")?
230        .to_str()
231        .ok()?
232        .strip_prefix("Bearer ")
233}
234
235/// Map a backend failure to an opaque `500`. The full error is logged
236/// server-side; the client sees only "internal error" so on-disk graph paths
237/// (`view_db_path`), SQLite text, and S3 endpoint/bucket detail never leak to
238/// the network (#148).
239fn server_error<E: std::fmt::Display>(e: E) -> Response {
240    eprintln!("gonzalod: internal error: {e}");
241    (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
242}
243
244async fn get_record(
245    State(svc): State<Arc<Service>>,
246    Extension(principal): Extension<Principal>,
247    Path((ns, col, id)): Path<(String, String, String)>,
248) -> Response {
249    if !principal.allows(Access::Read, &ns) {
250        return forbidden(&principal, Access::Read, &ns);
251    }
252    match svc.get(&RecordKey::new(ns, col, id)).await {
253        Ok(Some(rec)) => (StatusCode::OK, Json(rec)).into_response(),
254        Ok(None) => StatusCode::NOT_FOUND.into_response(),
255        Err(e) => server_error(e),
256    }
257}
258
259async fn put_record(
260    State(svc): State<Arc<Service>>,
261    Extension(principal): Extension<Principal>,
262    Path(path): Path<(String, String, String)>,
263    Json(mut body): Json<PutBody>,
264) -> Response {
265    // The URL path addresses the record; the body must agree with it. Without
266    // this check the path is decorative and authz/write key off the body alone,
267    // so a path-based proxy control could be bypassed by a mismatched body
268    // (#158). Reject the disagreement with 400 before any authz or write.
269    if let Some(bad) = path_key_mismatch(&path, &body.record.key) {
270        return bad;
271    }
272    let ns = &body.record.key.namespace;
273    if !principal.allows(Access::Write, ns) {
274        return forbidden(&principal, Access::Write, &ns.clone());
275    }
276    // Stamp the author from the authenticated principal — unforgeable (ADR
277    // 0015). Open mode (no auth) leaves the record's author untouched.
278    if principal.is_authenticated() {
279        body.record.meta.author = Identity::new(principal.name());
280    }
281    put_outcome_response(svc.put(body.record, body.expected).await)
282}
283
284/// The URL path addresses the record; the OCC precondition and an optional
285/// claimed deleter identity ride in an optional JSON body. Authorize `Write`
286/// on the path's namespace, then delegate — the key is taken from the path,
287/// so there is no body-key-vs-path check to make.
288///
289/// The tombstone's author follows `Principal::delete_author` (gonzalo#203,
290/// mirrors `put_raw`'s authorship rule): a non-admin is always stamped as
291/// itself and any claimed author in the body is ignored; an admin or open
292/// mode may name the deleter via the body's `author` field; and otherwise an
293/// authenticated admin is stamped as itself while open mode keeps the prior
294/// author (no identity to stamp, ADR 0015).
295async fn delete_record(
296    State(svc): State<Arc<Service>>,
297    Extension(principal): Extension<Principal>,
298    Path((ns, col, id)): Path<(String, String, String)>,
299    body: Option<Json<DeleteBody>>,
300) -> Response {
301    if !principal.allows(Access::Write, &ns) {
302        return forbidden(&principal, Access::Write, &ns);
303    }
304    let (expected, claimed) = body
305        .map(|Json(b)| (b.expected, b.author))
306        .unwrap_or_default();
307    let author = principal.delete_author(claimed);
308    let key = RecordKey::new(ns, col, id);
309    delete_outcome_response(svc.delete_as(&key, expected, author).await)
310}
311
312#[derive(Deserialize)]
313struct ListQuery {
314    namespace: Option<String>,
315    collection: Option<String>,
316}
317
318/// Authorize a key listing (`read` on the namespace; `read` on `"*"` when
319/// unscoped) and build its prefix. Shared by `/v1/keys` and `/v1/raw/keys`.
320// `Response` is large (axum::http::Response<Body>); boxing it isn't worth it
321// for an error path returned at most once per request (D12).
322#[allow(clippy::result_large_err)]
323fn list_prefix(principal: &Principal, q: ListQuery) -> Result<KeyPrefix, Response> {
324    // No namespace → spans all → requires admin (`read` on `"*"`).
325    let ns = q.namespace.as_deref().unwrap_or("*");
326    if !principal.allows(Access::Read, ns) {
327        return Err(forbidden(principal, Access::Read, ns));
328    }
329    Ok(KeyPrefix {
330        namespace: q.namespace,
331        collection: q.collection,
332    })
333}
334
335async fn list_keys(
336    State(svc): State<Arc<Service>>,
337    Extension(principal): Extension<Principal>,
338    Query(q): Query<ListQuery>,
339) -> Response {
340    let prefix = match list_prefix(&principal, q) {
341        Ok(p) => p,
342        Err(denied) => return denied,
343    };
344    match svc.list(&prefix).await {
345        Ok(keys) => (StatusCode::OK, Json(keys)).into_response(),
346        Err(e) => server_error(e),
347    }
348}
349
350/// `GET /v1/raw/records/{ns}/{col}/{id}` — replication read that includes
351/// tombstones (gonzalo#203). Always `200` with a [`RawRecordBody`]; absence is
352/// `{"record": null}` so `404` unambiguously means "no such route".
353async fn get_raw_record(
354    State(svc): State<Arc<Service>>,
355    Extension(principal): Extension<Principal>,
356    Path((ns, col, id)): Path<(String, String, String)>,
357) -> Response {
358    if !principal.allows(Access::Read, &ns) {
359        return forbidden(&principal, Access::Read, &ns);
360    }
361    match svc.get_raw(&RecordKey::new(ns, col, id)).await {
362        Ok(record) => (StatusCode::OK, Json(RawRecordBody { record })).into_response(),
363        Err(e) => server_error(e),
364    }
365}
366
367/// `PUT /v1/raw/records/{ns}/{col}/{id}` with a [`PutBody`] — replication write
368/// (gonzalo#203). The store writes the record verbatim (revision and
369/// tombstones included), with the same path/body agreement check as `put`
370/// (#158).
371///
372/// **Admin only (gonzalo#342), matching `POST /v1/purge/...`.** This route
373/// reaches `plan_put_raw`, so it bypasses the guard that stops a consumer
374/// creating over a manifest tombstone (ADR 0030) — by design, because that
375/// same verbatim arm is what `undelete` rides. A principal holding plain
376/// `Write` could therefore discard a deleted manifest's retained body, the
377/// only record of which blob was which index shard (ADR 0029), by aiming a
378/// fresh manifest at the tombstone's revision — which is predictable, since
379/// `tombstone_hash()` is a constant. Refusing a kind-changing write here
380/// instead would break convergence: a peer that legitimately purged and
381/// recreated a manifest could no longer replicate that state. So the route
382/// requires the credential replication already uses.
383///
384/// **Authorship stays unforgeable (ADR 0015)** and is now so by construction:
385/// an admin token is the replication credential, so the replicated record
386/// keeps its original `meta.author`, and no other principal reaches this
387/// route. Open mode's implicit principal is an admin, so it keeps the author
388/// too.
389async fn put_raw_record(
390    State(svc): State<Arc<Service>>,
391    Extension(principal): Extension<Principal>,
392    Path(path): Path<(String, String, String)>,
393    Json(body): Json<PutBody>,
394) -> Response {
395    // Authorize before inspecting the body, so an unauthorized caller cannot
396    // learn whether its record agreed with the path.
397    if !principal.is_admin() {
398        return forbidden_admin(&principal, "a raw record write");
399    }
400    if let Some(bad) = path_key_mismatch(&path, &body.record.key) {
401        return bad;
402    }
403    put_outcome_response(svc.put_raw(body.record, body.expected).await)
404}
405
406/// `GET /v1/raw/keys?namespace=&collection=` — replication list that includes
407/// tombstoned keys. Same authorization as `/v1/keys`: no namespace spans all
408/// namespaces and requires `read` on `"*"`.
409async fn list_raw_keys(
410    State(svc): State<Arc<Service>>,
411    Extension(principal): Extension<Principal>,
412    Query(q): Query<ListQuery>,
413) -> Response {
414    let prefix = match list_prefix(&principal, q) {
415        Ok(p) => p,
416        Err(denied) => return denied,
417    };
418    match svc.list_raw(&prefix).await {
419        Ok(keys) => (StatusCode::OK, Json(keys)).into_response(),
420        Err(e) => server_error(e),
421    }
422}
423
424/// `POST /v1/purge/{ns}/{col}/{id}` with a [`PurgeBody`] — physically remove the
425/// record iff its revision is `expected` (gonzalo#203). **Admin only**: an early
426/// purge is data loss that surfaces later on another machine (spec §5.2). The
427/// body is parsed only after the admin check (#146).
428async fn purge_record(
429    State(svc): State<Arc<Service>>,
430    Extension(principal): Extension<Principal>,
431    Path((ns, col, id)): Path<(String, String, String)>,
432    body: Bytes,
433) -> Response {
434    if !principal.is_admin() {
435        return forbidden_admin(&principal, "purge");
436    }
437    let PurgeBody { expected } = match serde_json::from_slice(&body) {
438        Ok(b) => b,
439        Err(e) => {
440            return (StatusCode::BAD_REQUEST, format!("invalid purge body: {e}")).into_response();
441        }
442    };
443    let key = RecordKey::new(ns, col, id);
444    delete_outcome_response(svc.purge(&key, expected).await)
445}
446
447/// `GET /v1/blobs/{hash}` — raw blob bytes, or `404`. Authorized `Read` on the
448/// reserved `_blobs` namespace.
449async fn get_blob(
450    State(svc): State<Arc<Service>>,
451    Extension(principal): Extension<Principal>,
452    Path(hash): Path<String>,
453) -> Response {
454    if !principal.allows(Access::Read, BLOB_NS) {
455        return forbidden(&principal, Access::Read, BLOB_NS);
456    }
457    match svc.get_blob(&ContentHash(hash)).await {
458        Ok(Some(bytes)) => (
459            StatusCode::OK,
460            [(header::CONTENT_TYPE, "application/octet-stream")],
461            bytes,
462        )
463            .into_response(),
464        Ok(None) => StatusCode::NOT_FOUND.into_response(),
465        Err(e) => server_error(e),
466    }
467}
468
469/// `HEAD /v1/blobs/{hash}` — whether the blob exists: `200` or `404`, no body.
470/// Authorized `Read` on `_blobs`, the same as `GET`: presence is information,
471/// so a principal without the scope must not learn it.
472///
473/// This exists so a writer confirming its referenced blobs survived a commit
474/// does not download them (ADR 0028, #329). It answers from
475/// [`Service::has_blob`] — a stat, not a read — and therefore reports
476/// `content-length: 0` rather than the blob's size. Without this handler axum
477/// would answer `HEAD` by running [`get_blob`] and discarding the body, which
478/// reports the true size but pays the whole read.
479async fn head_blob(
480    State(svc): State<Arc<Service>>,
481    Extension(principal): Extension<Principal>,
482    Path(hash): Path<String>,
483) -> Response {
484    if !principal.allows(Access::Read, BLOB_NS) {
485        return forbidden(&principal, Access::Read, BLOB_NS);
486    }
487    match svc.has_blob(&ContentHash(hash)).await {
488        Ok(true) => StatusCode::OK.into_response(),
489        Ok(false) => StatusCode::NOT_FOUND.into_response(),
490        Err(e) => server_error(e),
491    }
492}
493
494/// `PUT /v1/blobs/{hash}` — store raw body content, write-if-absent. The server
495/// recomputes the content hash and rejects a mismatch with the URL `{hash}`
496/// (`400`) before writing, so the address is authoritative. Authorized `Write`
497/// on `_blobs`. A body over `max_blob_size` is rejected upstream as `413` by the
498/// route's `DefaultBodyLimit`.
499async fn put_blob(
500    State(svc): State<Arc<Service>>,
501    Extension(principal): Extension<Principal>,
502    Path(hash): Path<String>,
503    body: Bytes,
504) -> Response {
505    if !principal.allows(Access::Write, BLOB_NS) {
506        return forbidden(&principal, Access::Write, BLOB_NS);
507    }
508    let computed = ContentHash::of(&body);
509    if computed.0 != hash {
510        return (
511            StatusCode::BAD_REQUEST,
512            "blob content does not match the URL hash",
513        )
514            .into_response();
515    }
516    match svc.put_blob(&body).await {
517        Ok(_) => StatusCode::OK.into_response(),
518        Err(e) => server_error(e),
519    }
520}
521
522/// `DELETE /v1/blobs/{hash}` — idempotent delete. Authorized `Write` on `_blobs`.
523async fn delete_blob(
524    State(svc): State<Arc<Service>>,
525    Extension(principal): Extension<Principal>,
526    Path(hash): Path<String>,
527) -> Response {
528    if !principal.allows(Access::Write, BLOB_NS) {
529        return forbidden(&principal, Access::Write, BLOB_NS);
530    }
531    match svc.delete_blob(&ContentHash(hash)).await {
532        Ok(()) => StatusCode::OK.into_response(),
533        Err(e) => server_error(e),
534    }
535}
536
537/// `GET /v1/blobs` — JSON array of every stored blob as
538/// `{hash, modified_unix_ms}`. Authorized `Read` on `_blobs`.
539async fn list_blobs(
540    State(svc): State<Arc<Service>>,
541    Extension(principal): Extension<Principal>,
542) -> Response {
543    if !principal.allows(Access::Read, BLOB_NS) {
544        return forbidden(&principal, Access::Read, BLOB_NS);
545    }
546    match svc.list_blobs().await {
547        Ok(entries) => (StatusCode::OK, Json(entries)).into_response(),
548        Err(e) => server_error(e),
549    }
550}
551
552async fn ticket_sync(
553    State(svc): State<Arc<Service>>,
554    Extension(principal): Extension<Principal>,
555    Json(conn): Json<gonzalo_ticket_config::Connection>,
556) -> Response {
557    // Ticket sync writes records in the `tickets` namespace.
558    if !principal.allows(Access::Write, "tickets") {
559        return forbidden(&principal, Access::Write, "tickets");
560    }
561    match svc.ticket_sync(&conn, "gonzalod").await {
562        Ok(summary) => (StatusCode::OK, Json(summary)).into_response(),
563        // A misconfigured request is the caller's own input → safe to echo. An
564        // internal failure goes through `server_error` so its detail is logged,
565        // not leaked (#148).
566        Err(crate::service::TicketSyncError::BadRequest(m)) => {
567            (StatusCode::BAD_REQUEST, m).into_response()
568        }
569        Err(crate::service::TicketSyncError::Internal(m)) => server_error(m),
570    }
571}
572
573/// Selects a code-graph view `(repo, view)` and the `name` a query is about,
574/// e.g. `GET /v1/graph/impact?repo=acme/widgets&view=main&name=helper`.
575#[derive(Deserialize)]
576struct GraphQuery {
577    repo: String,
578    view: String,
579    name: String,
580}
581
582/// Graph queries read the view for `repo`, whose records live in the `repo`
583/// namespace — so they require `read` on `repo`.
584fn graph_authz(principal: &Principal, repo: &str) -> Option<Response> {
585    (!principal.allows(Access::Read, repo)).then(|| forbidden(principal, Access::Read, repo))
586}
587
588async fn graph_definitions(
589    State(svc): State<Arc<Service>>,
590    Extension(principal): Extension<Principal>,
591    Query(q): Query<GraphQuery>,
592) -> Response {
593    if let Some(denied) = graph_authz(&principal, &q.repo) {
594        return denied;
595    }
596    match svc.graph_definitions(&q.repo, &q.view, &q.name).await {
597        Ok(items) => (StatusCode::OK, Json(items)).into_response(),
598        Err(e) => server_error(e),
599    }
600}
601
602async fn graph_references_to(
603    State(svc): State<Arc<Service>>,
604    Extension(principal): Extension<Principal>,
605    Query(q): Query<GraphQuery>,
606) -> Response {
607    if let Some(denied) = graph_authz(&principal, &q.repo) {
608        return denied;
609    }
610    match svc.graph_references_to(&q.repo, &q.view, &q.name).await {
611        Ok(items) => (StatusCode::OK, Json(items)).into_response(),
612        Err(e) => server_error(e),
613    }
614}
615
616async fn graph_callers_of(
617    State(svc): State<Arc<Service>>,
618    Extension(principal): Extension<Principal>,
619    Query(q): Query<GraphQuery>,
620) -> Response {
621    if let Some(denied) = graph_authz(&principal, &q.repo) {
622        return denied;
623    }
624    match svc.graph_callers_of(&q.repo, &q.view, &q.name).await {
625        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
626        Err(e) => server_error(e),
627    }
628}
629
630async fn graph_callees(
631    State(svc): State<Arc<Service>>,
632    Extension(principal): Extension<Principal>,
633    Query(q): Query<GraphQuery>,
634) -> Response {
635    if let Some(denied) = graph_authz(&principal, &q.repo) {
636        return denied;
637    }
638    match svc.graph_callees(&q.repo, &q.view, &q.name).await {
639        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
640        Err(e) => server_error(e),
641    }
642}
643
644async fn graph_impact(
645    State(svc): State<Arc<Service>>,
646    Extension(principal): Extension<Principal>,
647    Query(q): Query<GraphQuery>,
648) -> Response {
649    if let Some(denied) = graph_authz(&principal, &q.repo) {
650        return denied;
651    }
652    match svc.graph_impact_names(&q.repo, &q.view, &q.name).await {
653        Ok(names) => (StatusCode::OK, Json(names)).into_response(),
654        Err(e) => server_error(e),
655    }
656}
657
658/// Serve HTTP/JSON on an already-bound listener until the process ends.
659pub async fn serve_http(
660    listener: tokio::net::TcpListener,
661    service: Service,
662    auth: Arc<Auth>,
663) -> std::io::Result<()> {
664    axum::serve(listener, router(service, auth)).await
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use axum::body::Body;
671    use axum::http::Request as HttpRequest;
672    use gonzalo_core::{
673        BlobStore, ContentHash, CoreError, Record, Result as CoreResult, Revision, Store,
674    };
675    use gonzalo_store_fs::FsStore;
676    use std::collections::BTreeSet;
677    use tempfile::TempDir;
678    use tower::ServiceExt; // oneshot
679
680    // ---- published schema artifacts (#198) --------------------------------
681
682    /// [`SERVED_OPERATIONS`] is exactly what `router()` declares.
683    ///
684    /// Both directions matter. An entry with no route would put an operation in
685    /// the published OpenAPI document that the daemon does not serve — a
686    /// generated client calling a 404. A route with no entry would serve
687    /// something the document never describes, which is how an API quietly
688    /// grows an undocumented corner.
689    #[test]
690    fn served_operations_is_exactly_what_the_router_declares() {
691        let declared: BTreeSet<(String, String)> = routes_in_router_source().into_iter().collect();
692        let listed: BTreeSet<(String, String)> = SERVED_OPERATIONS
693            .iter()
694            .map(|(m, p)| (m.to_string(), p.to_string()))
695            .collect();
696
697        let unrouted: Vec<_> = listed.difference(&declared).collect();
698        let unlisted: Vec<_> = declared.difference(&listed).collect();
699        assert!(
700            unrouted.is_empty(),
701            "in SERVED_OPERATIONS but not routed: {unrouted:?}"
702        );
703        assert!(
704            unlisted.is_empty(),
705            "routed but missing from SERVED_OPERATIONS: {unlisted:?}"
706        );
707    }
708
709    /// The published OpenAPI document describes exactly the served surface.
710    ///
711    /// This is the drift check the ticket asks CI for (#198): the artifact
712    /// consumers generate clients from cannot describe an operation the daemon
713    /// does not serve, or miss one it does.
714    #[test]
715    fn openapi_document_matches_the_served_surface() {
716        // Everything a path item may hold that is not an operation.
717        const NOT_OPERATIONS: &[&str] =
718            &["parameters", "summary", "description", "servers", "$ref"];
719
720        let spec: serde_json::Value =
721            serde_json::from_str(include_str!("../../../docs/api/openapi.json"))
722                .expect("docs/api/openapi.json is valid JSON");
723
724        let mut documented = BTreeSet::new();
725        for (path, item) in spec["paths"].as_object().expect("paths object") {
726            for key in item.as_object().expect("path item object").keys() {
727                if NOT_OPERATIONS.contains(&key.as_str()) {
728                    continue;
729                }
730                documented.insert((key.to_uppercase(), path.clone()));
731            }
732        }
733        let served: BTreeSet<(String, String)> = SERVED_OPERATIONS
734            .iter()
735            .map(|(m, p)| (m.to_string(), p.to_string()))
736            .collect();
737
738        let undocumented: Vec<_> = served.difference(&documented).collect();
739        let unserved: Vec<_> = documented.difference(&served).collect();
740        assert!(
741            undocumented.is_empty(),
742            "served but absent from docs/api/openapi.json: {undocumented:?}"
743        );
744        assert!(
745            unserved.is_empty(),
746            "documented in docs/api/openapi.json but not served: {unserved:?}"
747        );
748    }
749
750    /// The `(METHOD, path)` pairs `router()` declares, parsed out of its source.
751    ///
752    /// axum exposes no way to enumerate a built `Router`, so the declaration
753    /// site is read instead. Only `router()`'s own body is parsed, and comment
754    /// lines are dropped — otherwise this reads the `.route(...)` written in
755    /// these very doc comments and reports routes nothing serves.
756    ///
757    /// Deliberately literal: it understands exactly the
758    /// `.route("path", get(h).put(h))` shape this file uses, so keep new routes
759    /// in that shape.
760    fn routes_in_router_source() -> Vec<(String, String)> {
761        let file = include_str!("http.rs");
762        let body_start = file
763            .find("pub fn router(")
764            .expect("router() is defined in this file");
765        // Stop before the tests, so this never parses its own examples.
766        let body_end = file[body_start..]
767            .find("#[cfg(test)]")
768            .map(|i| body_start + i)
769            .unwrap_or(file.len());
770        let body: String = file[body_start..body_end]
771            .lines()
772            .filter(|l| !l.trim_start().starts_with("//"))
773            .collect::<Vec<_>>()
774            .join("\n");
775
776        let mut out = Vec::new();
777        for (idx, _) in body.match_indices(".route(") {
778            let rest = &body[idx + ".route(".len()..];
779            let Some(open) = rest.find('"') else { continue };
780            let Some(close) = rest[open + 1..].find('"') else {
781                continue;
782            };
783            let path = &rest[open + 1..open + 1 + close];
784            // The handler chain runs to the closing paren of this `.route(`.
785            let mut depth = 1usize;
786            let mut end = rest.len();
787            for (i, c) in rest.char_indices() {
788                match c {
789                    '(' => depth += 1,
790                    ')' => {
791                        depth -= 1;
792                        if depth == 0 {
793                            end = i;
794                            break;
795                        }
796                    }
797                    _ => {}
798                }
799            }
800            let chain = &rest[..end];
801            for verb in ["get", "head", "put", "delete", "post", "patch"] {
802                if chain.contains(&format!("{verb}(")) {
803                    out.push((verb.to_uppercase(), path.to_string()));
804                }
805            }
806        }
807        out
808    }
809
810    /// The parser is load-bearing, so prove it reads the shape it claims to.
811    #[test]
812    fn the_route_parser_reads_paths_and_every_chained_method() {
813        let found = routes_in_router_source();
814        assert!(
815            found.contains(&("GET".into(), "/healthz".into())),
816            "a single-method route"
817        );
818        // One `.route(...)` with three methods chained must yield all three.
819        for verb in ["GET", "PUT", "DELETE"] {
820            assert!(
821                found.contains(&(verb.into(), "/v1/records/{ns}/{col}/{id}".into())),
822                "{verb} on the records route"
823            );
824        }
825        assert!(
826            found.contains(&("POST".into(), "/v1/tickets/sync".into())),
827            "a fully-qualified axum::routing::post route"
828        );
829        // A verb the parser does not know is a route the drift checks cannot
830        // see, in either direction — which is how an undocumented operation
831        // gets served (#329).
832        assert!(
833            found.contains(&("HEAD".into(), "/v1/blobs/{hash}".into())),
834            "HEAD on the hash-addressed blob route"
835        );
836        assert!(
837            !found.iter().any(|(_, p)| p.is_empty() || p.contains('…')),
838            "the parser picked up prose rather than a route: {found:?}"
839        );
840    }
841
842    /// A Service backed by a fresh filesystem store (reachable → ready).
843    fn fs_service() -> (Service, TempDir) {
844        let dir = TempDir::new().unwrap();
845        let fs = Arc::new(FsStore::new(dir.path()));
846        (Service::new(fs.clone(), fs), dir)
847    }
848
849    fn open() -> Arc<Auth> {
850        Arc::new(Auth::Disabled)
851    }
852
853    /// An `Enabled` registry with one admin token.
854    fn admin_token(token: &str) -> Arc<Auth> {
855        Arc::new(Auth::Enabled(std::collections::HashMap::from([(
856            token.to_string(),
857            Principal::admin("admin"),
858        )])))
859    }
860
861    async fn status_of(service: Service, auth: Arc<Auth>, path: &str) -> StatusCode {
862        router(service, auth)
863            .oneshot(
864                HttpRequest::builder()
865                    .uri(path)
866                    .body(Body::empty())
867                    .unwrap(),
868            )
869            .await
870            .unwrap()
871            .status()
872    }
873
874    #[tokio::test]
875    async fn healthz_is_ok() {
876        let (svc, _dir) = fs_service();
877        assert_eq!(status_of(svc, open(), "/healthz").await, StatusCode::OK);
878    }
879
880    #[tokio::test]
881    async fn readyz_is_ok_when_store_reachable() {
882        let (svc, _dir) = fs_service();
883        assert_eq!(status_of(svc, open(), "/readyz").await, StatusCode::OK);
884    }
885
886    #[tokio::test]
887    async fn probes_bypass_auth_but_other_routes_do_not() {
888        let (svc, _d1) = fs_service();
889        assert_eq!(
890            status_of(svc, admin_token("secret"), "/healthz").await,
891            StatusCode::OK,
892            "healthz must not require a token"
893        );
894        let (svc, _d2) = fs_service();
895        assert_eq!(
896            status_of(svc, admin_token("secret"), "/readyz").await,
897            StatusCode::OK,
898            "readyz must not require a token"
899        );
900        // A normal route without the token is still rejected.
901        let (svc, _d3) = fs_service();
902        assert_eq!(
903            status_of(svc, admin_token("secret"), "/v1/keys").await,
904            StatusCode::UNAUTHORIZED
905        );
906    }
907
908    /// A store whose every operation fails — models an unreachable backend.
909    struct DownStore;
910
911    fn down() -> CoreError {
912        CoreError::Backend("store unreachable".into())
913    }
914
915    #[async_trait::async_trait]
916    impl Store for DownStore {
917        async fn get(&self, _key: &RecordKey) -> CoreResult<Option<Record>> {
918            Err(down())
919        }
920        async fn put(&self, _record: Record, _expected: Option<Revision>) -> CoreResult<PutResult> {
921            Err(down())
922        }
923        async fn list(&self, _prefix: &KeyPrefix) -> CoreResult<Vec<RecordKey>> {
924            Err(down())
925        }
926        async fn delete_as(
927            &self,
928            _key: &RecordKey,
929            _expected: Option<Revision>,
930            _author: Option<Identity>,
931        ) -> CoreResult<DeleteResult> {
932            Err(down())
933        }
934        async fn get_raw(&self, _key: &RecordKey) -> CoreResult<Option<Record>> {
935            Err(down())
936        }
937        async fn list_raw(&self, _prefix: &KeyPrefix) -> CoreResult<Vec<RecordKey>> {
938            Err(down())
939        }
940        async fn put_raw(
941            &self,
942            _record: Record,
943            _expected: Option<Revision>,
944        ) -> CoreResult<PutResult> {
945            Err(down())
946        }
947        async fn purge(&self, _key: &RecordKey, _expected: Revision) -> CoreResult<DeleteResult> {
948            Err(down())
949        }
950    }
951
952    // --- namespace-scoped auth (ADR 0015) ---
953
954    /// `writer` scoped to `memory`, plus an `admin`.
955    fn scoped() -> Arc<Auth> {
956        Arc::new(Auth::Enabled(std::collections::HashMap::from([
957            (
958                "wtok".to_string(),
959                Principal::new("writer", vec!["memory".into()], vec!["memory".into()]),
960            ),
961            ("atok".to_string(), Principal::admin("admin")),
962        ])))
963    }
964
965    async fn call(
966        service: Service,
967        auth: Arc<Auth>,
968        method: &str,
969        path: &str,
970        token: Option<&str>,
971        body: Option<Vec<u8>>,
972    ) -> (StatusCode, Vec<u8>) {
973        let mut b = HttpRequest::builder().method(method).uri(path);
974        if let Some(t) = token {
975            b = b.header("authorization", format!("Bearer {t}"));
976        }
977        if body.is_some() {
978            b = b.header("content-type", "application/json");
979        }
980        let req = b
981            .body(body.map(Body::from).unwrap_or_else(Body::empty))
982            .unwrap();
983        let resp = router(service, auth).oneshot(req).await.unwrap();
984        let status = resp.status();
985        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
986            .await
987            .unwrap()
988            .to_vec();
989        (status, bytes)
990    }
991
992    fn put_body(namespace: &str, author: &str) -> Vec<u8> {
993        let record = Record {
994            revision: Revision::initial(b"{}"),
995            parent: None,
996            body: gonzalo_core::Body::Inline(b"{}".to_vec()),
997            kind: gonzalo_core::RecordKind::MemoryTier,
998            meta: gonzalo_core::Meta {
999                author: gonzalo_core::Identity::new(author),
1000                origin_system: "test".into(),
1001                created: 0,
1002                updated: 0,
1003                labels: std::collections::BTreeMap::new(),
1004            },
1005            links: Vec::new(),
1006            key: RecordKey::new(namespace, "col", "x"),
1007            ancestors: Vec::new(),
1008            deleted_at: None,
1009            deleted_blob: None,
1010            deleted_kind: None,
1011        };
1012        serde_json::to_vec(&PutBody {
1013            record,
1014            expected: None,
1015        })
1016        .unwrap()
1017    }
1018
1019    #[tokio::test]
1020    async fn read_scope_and_missing_token() {
1021        let (svc, _d) = fs_service();
1022        // In-scope read of an absent record: authorized → 404 (not 401/403).
1023        let (s, _) = call(
1024            svc,
1025            scoped(),
1026            "GET",
1027            "/v1/records/memory/col/x",
1028            Some("wtok"),
1029            None,
1030        )
1031        .await;
1032        assert_eq!(s, StatusCode::NOT_FOUND);
1033
1034        let (svc, _d) = fs_service();
1035        // Out-of-scope read → 403.
1036        let (s, _) = call(
1037            svc,
1038            scoped(),
1039            "GET",
1040            "/v1/records/secrets/col/x",
1041            Some("wtok"),
1042            None,
1043        )
1044        .await;
1045        assert_eq!(s, StatusCode::FORBIDDEN);
1046
1047        let (svc, _d) = fs_service();
1048        // No token → 401.
1049        let (s, _) = call(svc, scoped(), "GET", "/v1/records/memory/col/x", None, None).await;
1050        assert_eq!(s, StatusCode::UNAUTHORIZED);
1051    }
1052
1053    #[tokio::test]
1054    async fn write_scope_and_author_stamping() {
1055        // Out-of-scope write → 403.
1056        let (svc, _d) = fs_service();
1057        let (s, _) = call(
1058            svc,
1059            scoped(),
1060            "PUT",
1061            "/v1/records/secrets/col/x",
1062            Some("wtok"),
1063            Some(put_body("secrets", "writer")),
1064        )
1065        .await;
1066        assert_eq!(s, StatusCode::FORBIDDEN);
1067
1068        // In-scope write commits and stamps the authenticated principal over the
1069        // client-claimed "forged" author.
1070        let (svc, dir) = fs_service();
1071        let auth = scoped();
1072        let (s, _) = call(
1073            svc.clone(),
1074            auth.clone(),
1075            "PUT",
1076            "/v1/records/memory/col/x",
1077            Some("wtok"),
1078            Some(put_body("memory", "forged")),
1079        )
1080        .await;
1081        assert_eq!(s, StatusCode::OK);
1082        let _ = dir;
1083
1084        let (s, body) = call(
1085            svc,
1086            auth,
1087            "GET",
1088            "/v1/records/memory/col/x",
1089            Some("wtok"),
1090            None,
1091        )
1092        .await;
1093        assert_eq!(s, StatusCode::OK);
1094        let record: Record = serde_json::from_slice(&body).unwrap();
1095        assert_eq!(record.meta.author, gonzalo_core::Identity::new("writer"));
1096    }
1097
1098    #[tokio::test]
1099    async fn list_without_namespace_requires_admin() {
1100        let (svc, _d) = fs_service();
1101        let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("wtok"), None).await;
1102        assert_eq!(s, StatusCode::FORBIDDEN);
1103
1104        let (svc, _d) = fs_service();
1105        let (s, _) = call(svc, scoped(), "GET", "/v1/keys", Some("atok"), None).await;
1106        assert_eq!(s, StatusCode::OK);
1107    }
1108
1109    #[tokio::test]
1110    async fn put_rejects_url_path_body_key_mismatch() {
1111        // Body key is memory/col/x; URL path addresses .../col/y — a disagreement
1112        // that must be rejected with 400 before any authz or write (#158).
1113        let (svc, _d) = fs_service();
1114        let (s, _) = call(
1115            svc,
1116            scoped(),
1117            "PUT",
1118            "/v1/records/memory/col/y",
1119            Some("wtok"),
1120            Some(put_body("memory", "writer")),
1121        )
1122        .await;
1123        assert_eq!(s, StatusCode::BAD_REQUEST);
1124
1125        // A matching path still commits.
1126        let (svc, _d) = fs_service();
1127        let (s, _) = call(
1128            svc,
1129            scoped(),
1130            "PUT",
1131            "/v1/records/memory/col/x",
1132            Some("wtok"),
1133            Some(put_body("memory", "writer")),
1134        )
1135        .await;
1136        assert_eq!(s, StatusCode::OK);
1137    }
1138
1139    #[tokio::test]
1140    async fn consumer_put_of_a_tombstone_is_a_bad_request() {
1141        // Reading a record through a raw route and writing it back through the
1142        // consumer route is an easy mistake. The store refuses it, and that
1143        // refusal must read as the caller's error, not an outage (#299).
1144        let (svc, _d) = fs_service();
1145        let mut record = record_at("writer", Revision::initial(b"{}"));
1146        record.kind = gonzalo_core::RecordKind::Tombstone;
1147        record.deleted_at = Some(1);
1148        let (s, body) = call(
1149            svc,
1150            scoped(),
1151            "PUT",
1152            "/v1/records/memory/col/x",
1153            Some("wtok"),
1154            Some(put_body_with(record, None)),
1155        )
1156        .await;
1157        assert_eq!(s, StatusCode::BAD_REQUEST);
1158        let body = String::from_utf8(body).unwrap();
1159        assert!(
1160            body.contains("delete_as"),
1161            "should name the API to use: {body}"
1162        );
1163    }
1164
1165    #[tokio::test]
1166    async fn backend_error_is_opaque() {
1167        // A forced backend failure yields an opaque 500 body — the leaky
1168        // "store unreachable" detail never reaches the client (#148).
1169        let dir = TempDir::new().unwrap();
1170        let blobs = Arc::new(FsStore::new(dir.path()));
1171        let svc = Service::new(Arc::new(DownStore), blobs);
1172        let (s, body) = call(
1173            svc,
1174            scoped(),
1175            "GET",
1176            "/v1/records/memory/col/x",
1177            Some("wtok"),
1178            None,
1179        )
1180        .await;
1181        assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
1182        let text = String::from_utf8(body).unwrap();
1183        assert_eq!(text, "internal error");
1184        assert!(!text.contains("unreachable"));
1185    }
1186
1187    #[tokio::test]
1188    async fn readyz_is_503_when_store_unreachable() {
1189        let dir = TempDir::new().unwrap();
1190        // Records via the down store; blobs via fs (readiness only probes records).
1191        let blobs = Arc::new(FsStore::new(dir.path()));
1192        let svc = Service::new(Arc::new(DownStore), blobs);
1193        assert_eq!(
1194            status_of(svc, open(), "/readyz").await,
1195            StatusCode::SERVICE_UNAVAILABLE
1196        );
1197    }
1198
1199    // --- blob routes (#184) ---
1200
1201    #[tokio::test]
1202    async fn blob_put_get_list_delete_roundtrip_open() {
1203        let (svc, _d) = fs_service();
1204        let auth = open();
1205        let content = b"remote blob body".to_vec();
1206        let hash = gonzalo_core::ContentHash::of(&content).0;
1207
1208        // PUT the blob at its hash-addressed URL.
1209        let (s, _) = call(
1210            svc.clone(),
1211            auth.clone(),
1212            "PUT",
1213            &format!("/v1/blobs/{hash}"),
1214            None,
1215            Some(content.clone()),
1216        )
1217        .await;
1218        assert_eq!(s, StatusCode::OK);
1219
1220        // GET returns the raw bytes.
1221        let (s, body) = call(
1222            svc.clone(),
1223            auth.clone(),
1224            "GET",
1225            &format!("/v1/blobs/{hash}"),
1226            None,
1227            None,
1228        )
1229        .await;
1230        assert_eq!(s, StatusCode::OK);
1231        assert_eq!(body, content);
1232
1233        // LIST reports the hash.
1234        let (s, body) = call(svc.clone(), auth.clone(), "GET", "/v1/blobs", None, None).await;
1235        assert_eq!(s, StatusCode::OK);
1236        let entries: serde_json::Value = serde_json::from_slice(&body).unwrap();
1237        let first = &entries.as_array().expect("an array of blob entries")[0];
1238        assert_eq!(
1239            first.get("hash").and_then(|h| h.as_str()),
1240            Some(gonzalo_core::ContentHash::of(&content).0.as_str())
1241        );
1242        let ms = first
1243            .get("modified_unix_ms")
1244            .and_then(|m| m.as_i64())
1245            .expect("modified_unix_ms is present and an integer");
1246        assert_ne!(
1247            ms, 0,
1248            "a zero timestamp means the substrate never reported one"
1249        );
1250
1251        // DELETE removes it; a follow-up GET is 404.
1252        let (s, _) = call(
1253            svc.clone(),
1254            auth.clone(),
1255            "DELETE",
1256            &format!("/v1/blobs/{hash}"),
1257            None,
1258            None,
1259        )
1260        .await;
1261        assert_eq!(s, StatusCode::OK);
1262        let (s, _) = call(svc, auth, "GET", &format!("/v1/blobs/{hash}"), None, None).await;
1263        assert_eq!(s, StatusCode::NOT_FOUND);
1264    }
1265
1266    #[tokio::test]
1267    async fn blob_put_rejects_hash_mismatch_with_400() {
1268        let (svc, _d) = fs_service();
1269        // Address the PUT with a hash that does NOT match the body.
1270        let wrong = gonzalo_core::ContentHash::of(b"a different thing").0;
1271        let (s, _) = call(
1272            svc,
1273            open(),
1274            "PUT",
1275            &format!("/v1/blobs/{wrong}"),
1276            None,
1277            Some(b"actual body".to_vec()),
1278        )
1279        .await;
1280        assert_eq!(s, StatusCode::BAD_REQUEST);
1281    }
1282
1283    #[tokio::test]
1284    async fn blob_put_over_limit_is_413() {
1285        let dir = tempfile::TempDir::new().unwrap();
1286        let fs = Arc::new(FsStore::new(dir.path()));
1287        // A tiny limit so a small body trips it.
1288        let svc = Service::new(fs.clone(), fs).with_max_blob_size(8);
1289        let big = vec![b'x'; 64];
1290        let hash = gonzalo_core::ContentHash::of(&big).0;
1291        let (s, _) = call(
1292            svc,
1293            open(),
1294            "PUT",
1295            &format!("/v1/blobs/{hash}"),
1296            None,
1297            Some(big),
1298        )
1299        .await;
1300        assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE);
1301    }
1302
1303    #[tokio::test]
1304    async fn blob_ops_require_blobs_namespace_scope() {
1305        // `scoped()` grants read/write on `memory` only — not `_blobs`.
1306        let (svc, _d) = fs_service();
1307        let content = b"scoped blob".to_vec();
1308        let hash = gonzalo_core::ContentHash::of(&content).0;
1309
1310        // Write without `_blobs` scope → 403.
1311        let (s, _) = call(
1312            svc.clone(),
1313            scoped(),
1314            "PUT",
1315            &format!("/v1/blobs/{hash}"),
1316            Some("wtok"),
1317            Some(content.clone()),
1318        )
1319        .await;
1320        assert_eq!(s, StatusCode::FORBIDDEN);
1321
1322        // Read without `_blobs` scope → 403.
1323        let (s, _) = call(
1324            svc.clone(),
1325            scoped(),
1326            "GET",
1327            "/v1/blobs",
1328            Some("wtok"),
1329            None,
1330        )
1331        .await;
1332        assert_eq!(s, StatusCode::FORBIDDEN);
1333
1334        // Admin (wildcard) may write then read.
1335        let (s, _) = call(
1336            svc.clone(),
1337            scoped(),
1338            "PUT",
1339            &format!("/v1/blobs/{hash}"),
1340            Some("atok"),
1341            Some(content),
1342        )
1343        .await;
1344        assert_eq!(s, StatusCode::OK);
1345        let (s, _) = call(svc, scoped(), "GET", "/v1/blobs", Some("atok"), None).await;
1346        assert_eq!(s, StatusCode::OK);
1347    }
1348
1349    /// A blob store that stores, lists, deletes and reports presence normally
1350    /// but refuses to hand back bytes.
1351    ///
1352    /// `HEAD /v1/blobs/{hash}` answered through it proves the route consults
1353    /// presence rather than reading the blob (#329). Without an explicit `head`
1354    /// route, axum answers `HEAD` by running the `GET` handler and discarding
1355    /// the body, so the read still happens — here that surfaces as a `500`.
1356    struct PresenceOnlyBlobs(FsStore);
1357
1358    #[async_trait::async_trait]
1359    impl BlobStore for PresenceOnlyBlobs {
1360        async fn put_blob(&self, content: &[u8]) -> CoreResult<ContentHash> {
1361            self.0.put_blob(content).await
1362        }
1363        async fn get_blob(&self, _hash: &ContentHash) -> CoreResult<Option<Vec<u8>>> {
1364            Err(CoreError::Backend("get_blob must not be called".into()))
1365        }
1366        async fn list_blobs(&self) -> CoreResult<Vec<gonzalo_core::BlobEntry>> {
1367            self.0.list_blobs().await
1368        }
1369        async fn has_blob(&self, hash: &ContentHash) -> CoreResult<bool> {
1370            self.0.has_blob(hash).await
1371        }
1372        async fn delete_blob(&self, hash: &ContentHash) -> CoreResult<()> {
1373            self.0.delete_blob(hash).await
1374        }
1375    }
1376
1377    #[tokio::test]
1378    async fn head_blob_answers_presence_without_reading_the_blob() {
1379        let dir = TempDir::new().unwrap();
1380        let blobs = Arc::new(PresenceOnlyBlobs(FsStore::new(dir.path())));
1381        let hash = blobs
1382            .put_blob(b"bytes that must stay on disk")
1383            .await
1384            .unwrap()
1385            .0;
1386        let svc = Service::new(Arc::new(FsStore::new(dir.path())), blobs);
1387
1388        let (s, body) = call(
1389            svc.clone(),
1390            open(),
1391            "HEAD",
1392            &format!("/v1/blobs/{hash}"),
1393            None,
1394            None,
1395        )
1396        .await;
1397        assert_eq!(s, StatusCode::OK, "a stored blob is present");
1398        assert!(body.is_empty(), "HEAD never carries a body");
1399
1400        let absent = ContentHash::of(b"never stored").0;
1401        let (s, _) = call(
1402            svc,
1403            open(),
1404            "HEAD",
1405            &format!("/v1/blobs/{absent}"),
1406            None,
1407            None,
1408        )
1409        .await;
1410        assert_eq!(s, StatusCode::NOT_FOUND, "an unstored blob is absent");
1411    }
1412
1413    /// `HEAD` reads presence, so it cannot report a size without the read it
1414    /// exists to avoid: `content-length` is `0`, not the blob's length.
1415    ///
1416    /// Deliberate, and a change from the `HEAD` axum used to synthesize from
1417    /// the `GET` handler, which reported the real size. Nothing in this repo
1418    /// reads it; a caller that needs a size should `GET`. Pinned so reinstating
1419    /// one is a decision rather than an accident (#329).
1420    #[tokio::test]
1421    async fn head_blob_does_not_report_the_blob_size() {
1422        let (svc, _d) = fs_service();
1423        let content = b"sixteen bytes!!!".to_vec();
1424        let hash = ContentHash::of(&content).0;
1425        let (s, _) = call(
1426            svc.clone(),
1427            open(),
1428            "PUT",
1429            &format!("/v1/blobs/{hash}"),
1430            None,
1431            Some(content),
1432        )
1433        .await;
1434        assert_eq!(s, StatusCode::OK);
1435
1436        let req = HttpRequest::builder()
1437            .method("HEAD")
1438            .uri(format!("/v1/blobs/{hash}"))
1439            .body(Body::empty())
1440            .unwrap();
1441        let resp = router(svc, open()).oneshot(req).await.unwrap();
1442        assert_eq!(resp.status(), StatusCode::OK);
1443        assert_eq!(
1444            resp.headers()
1445                .get(header::CONTENT_LENGTH)
1446                .map(|v| v.to_str().unwrap().to_string()),
1447            Some("0".to_string()),
1448        );
1449    }
1450
1451    /// `HEAD` is a read of `_blobs`: a principal without that scope must not
1452    /// learn whether a blob exists.
1453    #[tokio::test]
1454    async fn head_blob_requires_blobs_read_scope() {
1455        let (svc, _d) = fs_service();
1456        let content = b"scoped presence".to_vec();
1457        let hash = ContentHash::of(&content).0;
1458        let (s, _) = call(
1459            svc.clone(),
1460            scoped(),
1461            "PUT",
1462            &format!("/v1/blobs/{hash}"),
1463            Some("atok"),
1464            Some(content),
1465        )
1466        .await;
1467        assert_eq!(s, StatusCode::OK);
1468
1469        // `scoped()` grants read/write on `memory` only — not `_blobs`.
1470        let (s, _) = call(
1471            svc.clone(),
1472            scoped(),
1473            "HEAD",
1474            &format!("/v1/blobs/{hash}"),
1475            Some("wtok"),
1476            None,
1477        )
1478        .await;
1479        assert_eq!(s, StatusCode::FORBIDDEN);
1480
1481        let (s, _) = call(
1482            svc,
1483            scoped(),
1484            "HEAD",
1485            &format!("/v1/blobs/{hash}"),
1486            Some("atok"),
1487            None,
1488        )
1489        .await;
1490        assert_eq!(s, StatusCode::OK);
1491    }
1492
1493    // --- replication surface: raw reads/writes, purge, delete stamping (#203) ---
1494
1495    /// `reader` reads `memory` only; `writer` reads and writes `memory`;
1496    /// `admin` is `*`/`*`.
1497    fn tomb_auth() -> Arc<Auth> {
1498        Arc::new(Auth::Enabled(std::collections::HashMap::from([
1499            (
1500                "rtok".to_string(),
1501                Principal::new("reader", vec!["memory".into()], vec![]),
1502            ),
1503            (
1504                "wtok".to_string(),
1505                Principal::new("writer", vec!["memory".into()], vec!["memory".into()]),
1506            ),
1507            ("atok".to_string(), Principal::admin("admin")),
1508        ])))
1509    }
1510
1511    const LIVE: &str = "/v1/records/memory/col/x";
1512    const RAW: &str = "/v1/raw/records/memory/col/x";
1513    const PURGE: &str = "/v1/purge/memory/col/x";
1514
1515    /// A record at memory/col/x with `author` and `revision`.
1516    fn record_at(author: &str, revision: Revision) -> Record {
1517        Record {
1518            revision,
1519            parent: None,
1520            body: gonzalo_core::Body::Inline(b"{}".to_vec()),
1521            kind: gonzalo_core::RecordKind::MemoryTier,
1522            meta: gonzalo_core::Meta {
1523                author: gonzalo_core::Identity::new(author),
1524                origin_system: "test".into(),
1525                created: 0,
1526                updated: 0,
1527                labels: std::collections::BTreeMap::new(),
1528            },
1529            links: Vec::new(),
1530            ancestors: Vec::new(),
1531            deleted_at: None,
1532            deleted_blob: None,
1533            key: RecordKey::new("memory", "col", "x"),
1534            deleted_kind: None,
1535        }
1536    }
1537
1538    fn put_body_with(record: Record, expected: Option<Revision>) -> Vec<u8> {
1539        serde_json::to_vec(&PutBody { record, expected }).unwrap()
1540    }
1541
1542    fn delete_body() -> Vec<u8> {
1543        serde_json::to_vec(&DeleteBody::default()).unwrap()
1544    }
1545
1546    /// A DELETE body claiming `author` as the deleter (R1: gonzalo#203).
1547    fn delete_body_claiming(author: &str) -> Vec<u8> {
1548        serde_json::to_vec(&DeleteBody {
1549            expected: None,
1550            author: Some(gonzalo_core::Identity::new(author)),
1551        })
1552        .unwrap()
1553    }
1554
1555    fn purge_body(expected: &Revision) -> Vec<u8> {
1556        serde_json::to_vec(&PurgeBody {
1557            expected: expected.clone(),
1558        })
1559        .unwrap()
1560    }
1561
1562    /// PUT a live record at memory/col/x with `put_token`, then DELETE it with
1563    /// `delete_token`, leaving a tombstone. Tokens are `None` in open mode.
1564    async fn seed_tombstone(
1565        svc: &Service,
1566        auth: &Arc<Auth>,
1567        put_token: Option<&str>,
1568        delete_token: Option<&str>,
1569    ) {
1570        let (s, _) = call(
1571            svc.clone(),
1572            auth.clone(),
1573            "PUT",
1574            LIVE,
1575            put_token,
1576            Some(put_body("memory", "client")),
1577        )
1578        .await;
1579        assert_eq!(s, StatusCode::OK);
1580        let (s, _) = call(
1581            svc.clone(),
1582            auth.clone(),
1583            "DELETE",
1584            LIVE,
1585            delete_token,
1586            Some(delete_body()),
1587        )
1588        .await;
1589        assert_eq!(s, StatusCode::OK);
1590    }
1591
1592    async fn raw_get(
1593        svc: &Service,
1594        auth: &Arc<Auth>,
1595        path: &str,
1596        token: Option<&str>,
1597    ) -> (StatusCode, Option<Record>) {
1598        let (s, body) = call(svc.clone(), auth.clone(), "GET", path, token, None).await;
1599        let record = if s == StatusCode::OK {
1600            serde_json::from_slice::<RawRecordBody>(&body)
1601                .unwrap()
1602                .record
1603        } else {
1604            None
1605        };
1606        (s, record)
1607    }
1608
1609    async fn keys(svc: &Service, auth: &Arc<Auth>, path: &str, token: &str) -> Vec<RecordKey> {
1610        let (s, body) = call(svc.clone(), auth.clone(), "GET", path, Some(token), None).await;
1611        assert_eq!(s, StatusCode::OK, "{path}");
1612        serde_json::from_slice(&body).unwrap()
1613    }
1614
1615    #[tokio::test]
1616    async fn delete_over_the_wire_leaves_a_tombstone_for_raw_reads() {
1617        let (svc, _d) = fs_service();
1618        let auth = tomb_auth();
1619        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
1620        let key = RecordKey::new("memory", "col", "x");
1621
1622        // Consumer surface: gone.
1623        let (s, _) = call(svc.clone(), auth.clone(), "GET", LIVE, Some("rtok"), None).await;
1624        assert_eq!(s, StatusCode::NOT_FOUND);
1625        assert!(
1626            !keys(&svc, &auth, "/v1/keys?namespace=memory", "rtok")
1627                .await
1628                .contains(&key)
1629        );
1630
1631        // Raw surface: a reader sees the tombstone.
1632        let (s, tomb) = raw_get(&svc, &auth, RAW, Some("rtok")).await;
1633        assert_eq!(s, StatusCode::OK);
1634        let tomb = tomb.expect("tombstone");
1635        assert_eq!(tomb.kind, gonzalo_core::RecordKind::Tombstone);
1636        assert_eq!(tomb.revision.hash, gonzalo_core::tombstone_hash());
1637        assert!(tomb.deleted_at.is_some());
1638        assert!(
1639            keys(&svc, &auth, "/v1/raw/keys?namespace=memory", "rtok")
1640                .await
1641                .contains(&key)
1642        );
1643    }
1644
1645    #[tokio::test]
1646    async fn delete_stamps_the_deleter_on_the_tombstone() {
1647        let (svc, _d) = fs_service();
1648        let auth = tomb_auth();
1649        // `writer` wrote the live record; `admin` deletes it.
1650        seed_tombstone(&svc, &auth, Some("wtok"), Some("atok")).await;
1651        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1652        assert_eq!(
1653            tomb.expect("tombstone").meta.author,
1654            gonzalo_core::Identity::new("admin")
1655        );
1656    }
1657
1658    #[tokio::test]
1659    async fn open_mode_delete_keeps_the_prior_author() {
1660        // Open mode has no identity to stamp (ADR 0015), for delete as for put.
1661        let (svc, _d) = fs_service();
1662        let auth = open();
1663        seed_tombstone(&svc, &auth, None, None).await;
1664        let (_, tomb) = raw_get(&svc, &auth, RAW, None).await;
1665        assert_eq!(
1666            tomb.expect("tombstone").meta.author,
1667            gonzalo_core::Identity::new("client")
1668        );
1669    }
1670
1671    #[tokio::test]
1672    async fn http_open_mode_delete_keeps_a_claimed_author() {
1673        // Open mode's implicit principal is an admin (`delete_author` returns
1674        // the claim), so a claimed deleter is honoured, unlike the prior
1675        // author kept when there is no claim at all.
1676        let (svc, _d) = fs_service();
1677        let auth = open();
1678        let (s, _) = call(
1679            svc.clone(),
1680            auth.clone(),
1681            "PUT",
1682            LIVE,
1683            None,
1684            Some(put_body("memory", "client")),
1685        )
1686        .await;
1687        assert_eq!(s, StatusCode::OK);
1688        let (s, _) = call(
1689            svc.clone(),
1690            auth.clone(),
1691            "DELETE",
1692            LIVE,
1693            None,
1694            Some(delete_body_claiming("origin")),
1695        )
1696        .await;
1697        assert_eq!(s, StatusCode::OK);
1698        let (_, tomb) = raw_get(&svc, &auth, RAW, None).await;
1699        assert_eq!(
1700            tomb.expect("tombstone").meta.author,
1701            gonzalo_core::Identity::new("origin")
1702        );
1703    }
1704
1705    #[tokio::test]
1706    async fn http_delete_by_non_admin_ignores_a_claimed_author() {
1707        // A non-admin cannot forge the deleter's identity through the wire
1708        // field either (R1, mirrors `Principal::delete_author`).
1709        //
1710        // The live record is seeded by `atok` (admin), so the prior author is
1711        // "admin" — distinct from both "forged" (the claim) and "writer" (the
1712        // deleter). Asserting "writer" then fails if the claim is honoured
1713        // ("forged") and fails if the author is left untouched ("admin"): the
1714        // only way to pass is for the handler to stamp the non-admin deleter's
1715        // own identity, exactly as `delete_stamps_the_deleter_on_the_tombstone`
1716        // discriminates the admin branch.
1717        let (svc, _d) = fs_service();
1718        let auth = tomb_auth();
1719        let (s, _) = call(
1720            svc.clone(),
1721            auth.clone(),
1722            "PUT",
1723            LIVE,
1724            Some("atok"),
1725            Some(put_body("memory", "client")),
1726        )
1727        .await;
1728        assert_eq!(s, StatusCode::OK);
1729        let (s, _) = call(
1730            svc.clone(),
1731            auth.clone(),
1732            "DELETE",
1733            LIVE,
1734            Some("wtok"),
1735            Some(delete_body_claiming("forged")),
1736        )
1737        .await;
1738        assert_eq!(s, StatusCode::OK);
1739        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("wtok")).await;
1740        assert_eq!(
1741            tomb.expect("tombstone").meta.author,
1742            gonzalo_core::Identity::new("writer")
1743        );
1744    }
1745
1746    #[tokio::test]
1747    async fn http_delete_by_admin_keeps_a_claimed_author() {
1748        // An admin token is the replication credential: it may name the
1749        // deleter (R1, mirrors `Principal::delete_author`).
1750        let (svc, _d) = fs_service();
1751        let auth = tomb_auth();
1752        let (s, _) = call(
1753            svc.clone(),
1754            auth.clone(),
1755            "PUT",
1756            LIVE,
1757            Some("wtok"),
1758            Some(put_body("memory", "client")),
1759        )
1760        .await;
1761        assert_eq!(s, StatusCode::OK);
1762        let (s, _) = call(
1763            svc.clone(),
1764            auth.clone(),
1765            "DELETE",
1766            LIVE,
1767            Some("atok"),
1768            Some(delete_body_claiming("origin")),
1769        )
1770        .await;
1771        assert_eq!(s, StatusCode::OK);
1772        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1773        assert_eq!(
1774            tomb.expect("tombstone").meta.author,
1775            gonzalo_core::Identity::new("origin")
1776        );
1777    }
1778
1779    #[tokio::test]
1780    async fn raw_reads_need_read_scope_and_absence_is_200_null() {
1781        let (svc, _d) = fs_service();
1782        let auth = tomb_auth();
1783
1784        // In scope, absent: 200 with a null record, never 404 (404 is reserved
1785        // for "this daemon has no raw route").
1786        let (s, rec) = raw_get(
1787            &svc,
1788            &auth,
1789            "/v1/raw/records/memory/col/absent",
1790            Some("rtok"),
1791        )
1792        .await;
1793        assert_eq!(s, StatusCode::OK);
1794        assert_eq!(rec, None);
1795
1796        // Out of scope.
1797        let (s, _) = raw_get(&svc, &auth, "/v1/raw/records/secrets/col/x", Some("rtok")).await;
1798        assert_eq!(s, StatusCode::FORBIDDEN);
1799        let (s, _) = call(
1800            svc.clone(),
1801            auth.clone(),
1802            "GET",
1803            "/v1/raw/keys?namespace=secrets",
1804            Some("rtok"),
1805            None,
1806        )
1807        .await;
1808        assert_eq!(s, StatusCode::FORBIDDEN);
1809
1810        // No token.
1811        let (s, _) = call(svc, auth, "GET", RAW, None, None).await;
1812        assert_eq!(s, StatusCode::UNAUTHORIZED);
1813    }
1814
1815    #[tokio::test]
1816    async fn unscoped_list_raw_requires_admin() {
1817        let (svc, _d) = fs_service();
1818        let auth = tomb_auth();
1819        for token in ["rtok", "wtok"] {
1820            let (s, _) = call(
1821                svc.clone(),
1822                auth.clone(),
1823                "GET",
1824                "/v1/raw/keys",
1825                Some(token),
1826                None,
1827            )
1828            .await;
1829            assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
1830        }
1831        let (s, _) = call(svc, auth, "GET", "/v1/raw/keys", Some("atok"), None).await;
1832        assert_eq!(s, StatusCode::OK);
1833    }
1834
1835    /// `put_raw` a record claiming author `"origin"` with `token`, assert it
1836    /// committed at the incoming revision, and return the stored author.
1837    async fn put_raw_author(
1838        svc: &Service,
1839        auth: &Arc<Auth>,
1840        token: Option<&str>,
1841    ) -> gonzalo_core::Identity {
1842        let replica = record_at("origin", Revision::initial(b"{}"));
1843        let (s, body) = call(
1844            svc.clone(),
1845            auth.clone(),
1846            "PUT",
1847            RAW,
1848            token,
1849            Some(put_body_with(replica.clone(), None)),
1850        )
1851        .await;
1852        assert_eq!(s, StatusCode::OK);
1853        assert!(matches!(
1854            serde_json::from_slice::<PutOutcome>(&body).unwrap(),
1855            PutOutcome::Committed { revision } if revision == replica.revision
1856        ));
1857        let (_, stored) = raw_get(svc, auth, RAW, token).await;
1858        stored.expect("stored").meta.author
1859    }
1860
1861    #[tokio::test]
1862    async fn put_raw_needs_admin_and_matching_path() {
1863        let (svc, _d) = fs_service();
1864        let auth = tomb_auth();
1865        let replica = record_at("origin", Revision::initial(b"{}"));
1866
1867        // Neither a reader nor a namespace writer may replicate (gonzalo#342):
1868        // the raw route bypasses `plan_put`'s guards, so it is admin-only.
1869        for token in ["rtok", "wtok"] {
1870            let (s, _) = call(
1871                svc.clone(),
1872                auth.clone(),
1873                "PUT",
1874                RAW,
1875                Some(token),
1876                Some(put_body_with(replica.clone(), None)),
1877            )
1878            .await;
1879            assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
1880        }
1881
1882        // Path and body key must agree (#158) — checked for an admin, since a
1883        // non-admin is now refused before the body is inspected at all.
1884        let (s, _) = call(
1885            svc,
1886            auth,
1887            "PUT",
1888            "/v1/raw/records/memory/col/y",
1889            Some("atok"),
1890            Some(put_body_with(replica, None)),
1891        )
1892        .await;
1893        assert_eq!(s, StatusCode::BAD_REQUEST);
1894    }
1895
1896    /// The authorization check precedes the path/body agreement check, so an
1897    /// unauthorized caller cannot learn whether its body matched the path
1898    /// (gonzalo#342).
1899    #[tokio::test]
1900    async fn put_raw_authorizes_before_checking_the_path() {
1901        let (svc, _d) = fs_service();
1902        let auth = tomb_auth();
1903        let replica = record_at("origin", Revision::initial(b"{}"));
1904        let (s, _) = call(
1905            svc,
1906            auth,
1907            "PUT",
1908            "/v1/raw/records/memory/col/y",
1909            Some("wtok"),
1910            Some(put_body_with(replica, None)),
1911        )
1912        .await;
1913        assert_eq!(s, StatusCode::FORBIDDEN);
1914    }
1915
1916    #[tokio::test]
1917    async fn put_raw_by_admin_keeps_the_incoming_author() {
1918        // An admin token is the replication credential: the original writer
1919        // survives replication.
1920        let (svc, _d) = fs_service();
1921        let auth = tomb_auth();
1922        assert_eq!(
1923            put_raw_author(&svc, &auth, Some("atok")).await,
1924            gonzalo_core::Identity::new("origin")
1925        );
1926    }
1927
1928    #[tokio::test]
1929    async fn put_raw_in_open_mode_keeps_the_incoming_author() {
1930        // Open mode's implicit principal is an admin.
1931        let (svc, _d) = fs_service();
1932        let auth = open();
1933        assert_eq!(
1934            put_raw_author(&svc, &auth, None).await,
1935            gonzalo_core::Identity::new("origin")
1936        );
1937    }
1938
1939    #[tokio::test]
1940    async fn put_raw_overwrites_a_tombstone_verbatim_and_none_conflicts() {
1941        let (svc, _d) = fs_service();
1942        let auth = tomb_auth();
1943        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
1944        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
1945        let tomb = tomb.expect("tombstone");
1946
1947        // expected = None over a tombstone is a conflict for put_raw, and the
1948        // conflict may carry the tombstone. Replication is admin-only
1949        // (gonzalo#342), so these run with the admin token.
1950        let peer = record_at("peer", Revision::initial(b"peer"));
1951        let (s, body) = call(
1952            svc.clone(),
1953            auth.clone(),
1954            "PUT",
1955            RAW,
1956            Some("atok"),
1957            Some(put_body_with(peer.clone(), None)),
1958        )
1959        .await;
1960        assert_eq!(s, StatusCode::CONFLICT);
1961        assert!(matches!(
1962            serde_json::from_slice::<PutOutcome>(&body).unwrap(),
1963            PutOutcome::Conflict { conflict } if conflict.current.revision == tomb.revision
1964        ));
1965
1966        // expected = the tombstone's revision writes the record unchanged.
1967        let (s, _) = call(
1968            svc.clone(),
1969            auth.clone(),
1970            "PUT",
1971            RAW,
1972            Some("atok"),
1973            Some(put_body_with(peer.clone(), Some(tomb.revision.clone()))),
1974        )
1975        .await;
1976        assert_eq!(s, StatusCode::OK);
1977        let (_, stored) = raw_get(&svc, &auth, RAW, Some("rtok")).await;
1978        let stored = stored.expect("stored");
1979        assert_eq!(stored.revision, peer.revision);
1980        assert_eq!(stored.kind, gonzalo_core::RecordKind::MemoryTier);
1981    }
1982
1983    #[tokio::test]
1984    async fn put_not_found_is_412_on_both_put_routes() {
1985        let (svc, _d) = fs_service();
1986        let auth = tomb_auth();
1987        let never = Revision::initial(b"never current");
1988
1989        // put_raw with Some over an absent key → NotFound → 412. Replication is
1990        // admin-only (gonzalo#342).
1991        let (s, _) = call(
1992            svc.clone(),
1993            auth.clone(),
1994            "PUT",
1995            RAW,
1996            Some("atok"),
1997            Some(put_body_with(
1998                record_at("w", Revision::initial(b"{}")),
1999                Some(never.clone()),
2000            )),
2001        )
2002        .await;
2003        assert_eq!(s, StatusCode::PRECONDITION_FAILED);
2004
2005        // Consumer put with Some over a tombstone → NotFound → 412.
2006        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2007        let (s, _) = call(
2008            svc,
2009            auth,
2010            "PUT",
2011            LIVE,
2012            Some("wtok"),
2013            Some(put_body_with(
2014                record_at("w", Revision::initial(b"{}")),
2015                Some(never),
2016            )),
2017        )
2018        .await;
2019        assert_eq!(s, StatusCode::PRECONDITION_FAILED);
2020    }
2021
2022    #[tokio::test]
2023    async fn purge_requires_admin() {
2024        let (svc, _d) = fs_service();
2025        let auth = tomb_auth();
2026        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2027        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2028        let rev = tomb.expect("tombstone").revision;
2029
2030        for token in ["rtok", "wtok"] {
2031            let (s, _) = call(
2032                svc.clone(),
2033                auth.clone(),
2034                "POST",
2035                PURGE,
2036                Some(token),
2037                Some(purge_body(&rev)),
2038            )
2039            .await;
2040            assert_eq!(s, StatusCode::FORBIDDEN, "{token}");
2041        }
2042
2043        let (s, body) = call(
2044            svc.clone(),
2045            auth.clone(),
2046            "POST",
2047            PURGE,
2048            Some("atok"),
2049            Some(purge_body(&rev)),
2050        )
2051        .await;
2052        assert_eq!(s, StatusCode::OK);
2053        assert!(matches!(
2054            serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2055            DeleteOutcome::Deleted
2056        ));
2057        let (s, rec) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2058        assert_eq!(s, StatusCode::OK);
2059        assert_eq!(rec, None);
2060    }
2061
2062    /// gonzalo#342: a namespace writer cannot destroy a deleted manifest's
2063    /// restore window through the raw route. Before the admin gate, `Write` on
2064    /// the namespace plus the tombstone's revision — which is predictable,
2065    /// because `tombstone_hash()` is a constant — was enough to overwrite the
2066    /// retained body that names the index's shard blobs (ADR 0029).
2067    #[tokio::test]
2068    async fn a_namespace_writer_cannot_destroy_a_manifest_restore_window() {
2069        let (svc, _d) = fs_service();
2070        let auth = tomb_auth();
2071
2072        // Seed a live manifest, then delete it: the tombstone keeps the body
2073        // and records `deleted_kind` (ADR 0029).
2074        let mut manifest = record_at("client", Revision::initial(b"{}"));
2075        manifest.kind = gonzalo_core::RecordKind::VectorManifest;
2076        manifest.body = gonzalo_core::Body::Inline(br#"{"shards":["blob-a"]}"#.to_vec());
2077        let (s, _) = call(
2078            svc.clone(),
2079            auth.clone(),
2080            "PUT",
2081            LIVE,
2082            Some("wtok"),
2083            Some(put_body_with(manifest, None)),
2084        )
2085        .await;
2086        assert_eq!(s, StatusCode::OK);
2087        let (s, _) = call(
2088            svc.clone(),
2089            auth.clone(),
2090            "DELETE",
2091            LIVE,
2092            Some("wtok"),
2093            Some(delete_body()),
2094        )
2095        .await;
2096        assert_eq!(s, StatusCode::OK);
2097
2098        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2099        let tomb = tomb.expect("tombstone");
2100        assert!(tomb.is_tombstone());
2101        assert_eq!(
2102            tomb.deleted_kind,
2103            Some(gonzalo_core::RecordKind::VectorManifest)
2104        );
2105
2106        // The writer knows the tombstone's revision and aims a fresh, empty
2107        // manifest at it. The raw route refuses it.
2108        let mut replacement = record_at("writer", Revision::initial(b"empty"));
2109        replacement.kind = gonzalo_core::RecordKind::VectorManifest;
2110        replacement.body = gonzalo_core::Body::Inline(br#"{"shards":[]}"#.to_vec());
2111        let (s, _) = call(
2112            svc.clone(),
2113            auth.clone(),
2114            "PUT",
2115            RAW,
2116            Some("wtok"),
2117            Some(put_body_with(replacement, Some(tomb.revision.clone()))),
2118        )
2119        .await;
2120        assert_eq!(s, StatusCode::FORBIDDEN);
2121
2122        // The restore window is intact: same revision, same retained body.
2123        let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2124        let after = after.expect("tombstone survives");
2125        assert_eq!(after.revision, tomb.revision);
2126        assert_eq!(after.body, tomb.body);
2127        assert_eq!(after.deleted_kind, tomb.deleted_kind);
2128    }
2129
2130    /// gonzalo#341: the daemon refuses to purge a live record, as
2131    /// `gonzalo purge` does. Purging a live record leaves no tombstone, so a
2132    /// peer that has not synced since would resurrect it (ADR 0021).
2133    #[tokio::test]
2134    async fn purge_of_a_live_record_is_refused() {
2135        let (svc, _d) = fs_service();
2136        let auth = tomb_auth();
2137        let (s, _) = call(
2138            svc.clone(),
2139            auth.clone(),
2140            "PUT",
2141            LIVE,
2142            Some("wtok"),
2143            Some(put_body("memory", "client")),
2144        )
2145        .await;
2146        assert_eq!(s, StatusCode::OK);
2147        let (_, live) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2148        let live = live.expect("live record");
2149
2150        let (s, body) = call(
2151            svc.clone(),
2152            auth.clone(),
2153            "POST",
2154            PURGE,
2155            Some("atok"),
2156            Some(purge_body(&live.revision)),
2157        )
2158        .await;
2159        assert_eq!(s, StatusCode::BAD_REQUEST);
2160        let msg = String::from_utf8_lossy(&body);
2161        assert!(msg.contains("gonzalo delete"), "{msg}");
2162
2163        // Still there, unchanged.
2164        let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2165        let after = after.expect("live record survives");
2166        assert_eq!(after.revision, live.revision);
2167        assert!(!after.is_tombstone());
2168    }
2169
2170    /// The live-record guard is narrow by design: a *mismatched* revision over a
2171    /// live record is collection losing an OCC race to a recreate, and must stay
2172    /// a `409 Conflict` — `collect` records that and carries on, where an error
2173    /// would fail the whole run (gonzalo#341,
2174    /// `conformance::purge_conflicts_after_recreation`).
2175    #[tokio::test]
2176    async fn purge_of_a_recreated_record_still_conflicts() {
2177        let (svc, _d) = fs_service();
2178        let auth = tomb_auth();
2179        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2180        let (_, tomb) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2181        let tomb_rev = tomb.expect("tombstone").revision;
2182
2183        // The key comes back while collection is still holding the tombstone's
2184        // revision.
2185        let (s, _) = call(
2186            svc.clone(),
2187            auth.clone(),
2188            "PUT",
2189            LIVE,
2190            Some("wtok"),
2191            Some(put_body("memory", "client")),
2192        )
2193        .await;
2194        assert_eq!(s, StatusCode::OK);
2195
2196        let (s, body) = call(
2197            svc.clone(),
2198            auth.clone(),
2199            "POST",
2200            PURGE,
2201            Some("atok"),
2202            Some(purge_body(&tomb_rev)),
2203        )
2204        .await;
2205        assert_eq!(s, StatusCode::CONFLICT);
2206        assert!(matches!(
2207            serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2208            DeleteOutcome::Conflict { .. }
2209        ));
2210
2211        // The recreated record survives.
2212        let (_, after) = raw_get(&svc, &auth, RAW, Some("atok")).await;
2213        let after = after.expect("recreated record survives");
2214        assert!(!after.is_tombstone());
2215    }
2216
2217    /// The live-record guard must not break collection's re-run safety: a purge
2218    /// of a key that is already gone still reports `Deleted` (gonzalo#341).
2219    #[tokio::test]
2220    async fn purge_of_an_absent_record_is_still_deleted() {
2221        let (svc, _d) = fs_service();
2222        let auth = tomb_auth();
2223        let (s, body) = call(
2224            svc,
2225            auth,
2226            "POST",
2227            PURGE,
2228            Some("atok"),
2229            Some(purge_body(&Revision::initial(b"anything"))),
2230        )
2231        .await;
2232        assert_eq!(s, StatusCode::OK);
2233        assert!(matches!(
2234            serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2235            DeleteOutcome::Deleted
2236        ));
2237    }
2238
2239    #[tokio::test]
2240    async fn purge_with_stale_expected_is_409() {
2241        let (svc, _d) = fs_service();
2242        let auth = tomb_auth();
2243        seed_tombstone(&svc, &auth, Some("wtok"), Some("wtok")).await;
2244        let (s, body) = call(
2245            svc,
2246            auth,
2247            "POST",
2248            PURGE,
2249            Some("atok"),
2250            Some(purge_body(&Revision::initial(b"not the tombstone"))),
2251        )
2252        .await;
2253        assert_eq!(s, StatusCode::CONFLICT);
2254        assert!(matches!(
2255            serde_json::from_slice::<DeleteOutcome>(&body).unwrap(),
2256            DeleteOutcome::Conflict { .. }
2257        ));
2258    }
2259
2260    #[tokio::test]
2261    async fn purge_authorizes_before_parsing_the_body() {
2262        let (svc, _d) = fs_service();
2263        let auth = tomb_auth();
2264        let (s, _) = call(
2265            svc.clone(),
2266            auth.clone(),
2267            "POST",
2268            PURGE,
2269            Some("wtok"),
2270            Some(b"not json".to_vec()),
2271        )
2272        .await;
2273        assert_eq!(s, StatusCode::FORBIDDEN);
2274        let (s, _) = call(
2275            svc,
2276            auth,
2277            "POST",
2278            PURGE,
2279            Some("atok"),
2280            Some(b"not json".to_vec()),
2281        )
2282        .await;
2283        assert_eq!(s, StatusCode::BAD_REQUEST);
2284    }
2285
2286    #[tokio::test]
2287    async fn raw_backend_error_is_opaque() {
2288        let dir = TempDir::new().unwrap();
2289        let blobs = Arc::new(FsStore::new(dir.path()));
2290        let svc = Service::new(Arc::new(DownStore), blobs);
2291        let (s, body) = call(svc, scoped(), "GET", RAW, Some("wtok"), None).await;
2292        assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
2293        assert_eq!(String::from_utf8(body).unwrap(), "internal error");
2294    }
2295}