diff --git a/Cargo.lock b/Cargo.lock index 51e45f8..d4a65cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2211,7 +2211,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.24.0" +version = "0.25.0" dependencies = [ "async-trait", "axum", @@ -2266,7 +2266,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.67.0" +version = "0.68.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2367846..b74e565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.67.0" +version = "0.68.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index c4fb250..a016ee3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -455,8 +455,8 @@ Allowed methods: `GET`, `POST`, `OPTIONS`. Allowed request headers: `Content-Typ node-first path) can READ them — a cross-origin `fetch` can otherwise read only a short safelist, and a resolver that cannot see `X-Dig-Verified` fails CLOSED and drops to the verified rpc tier. The exposed set is: `X-Dig-Verified`, `X-Dig-Root`, `X-Dig-Inclusion-Proof`, `X-Dig-Chunk-Lens`, -`X-Dig-Source`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`, `X-Dig-Owner-Puzzle-Hash`, -`X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only +`X-Dig-Source`, `X-Dig-Peer-Tier`, `X-Dig-Store-Id`, `X-Dig-Capsule`, `X-Dig-Resource-Key`, +`X-Dig-Owner-Puzzle-Hash`, `X-Dig-Generation`. These are read-only provenance metadata, so exposing them broadens only readability. (Cross-repo contract with dig-urn-resolver — mirrored in `SYSTEM.md`.) **Private Network Access (PNA, #285).** The server MUST advertise `allow_private_network` on the @@ -598,6 +598,23 @@ verified server-side — `false` only when the node-side pin is disabled via `DI `X-Dig-Root: ` (the resolved root served against), and `X-Dig-Source: local|peer|rpc` (the tier that served the MAIN resource). A consumer's DIG Shields / toolbar reads these. +**Peer-tier attachment (every serve, #1763).** Every served response MUST additionally carry +`X-Dig-Peer-Tier: attached|unattached`, reporting whether the P2P content engine was attached at the +moment the read was ROUTED — i.e. whether Tier 2 was consultable at all. The node MUST capture this +once, before any tier runs, and report the same value regardless of which tier ultimately served the +bytes; it MUST NOT be derived from `X-Dig-Source`. + +The two are independent and both are required, because `X-Dig-Source` alone cannot express the +difference between a gateway serve that MISSED on the peer tier and a gateway serve that never had +one. The node serves content from the moment its HTTP surface opens, which is BEFORE the peer network +attaches (§7.8) — availability is deliberately not traded away for readiness — so reads inside that +window skip Tier 2 entirely and are answered by the public RPC. `unattached` is the node stating that; +a caller MUST NOT treat such a read as evidence about peer replication. `unattached` is also the +permanent value on the in-process/FFI path, which brings up no peer network. + +`X-Dig-Peer-Tier` reports engine attachment ONLY: not peer count, not reachability, not whether a +fetch was attempted, and never verification (that is `X-Dig-Verified`). + **Serve-metadata headers (every serve, #486).** Alongside the provenance set, every served resource carries: `X-Dig-Store-Id: <64-hex>` (the storeId serving this resource); `X-Dig-Owner-Puzzle-Hash: <64-hex>` — the store's on-chain OWNER puzzle hash, resolved from the SAME chain read as the @@ -882,11 +899,18 @@ are asserted by classification only). ### 6.1. `GET /health` Returns `{ status: "ok", service, version, commit, mode: "local-node", addr, upstream, cache: -{ dir, cap_bytes, used_bytes, shared }, methods: [names…] }`. The fields `status`, `version`, +{ dir, cap_bytes, used_bytes, shared }, sync: { available }, peer_tier: { attached }, +methods: [names…] }`. The fields `status`, `version`, `mode`, `upstream`, `cache` are the stable probe contract (the v0.2 server's health shape); additions MUST be additive. `cache.shared` reports whether the effective cache dir is the shared canonical one (`true`) or a process-private fallback (`false`), from the read path's resolver. +`peer_tier.attached` (#1763) reports whether the P2P content engine is attached RIGHT NOW — the same +state a served response reports as `X-Dig-Peer-Tier` (§4.6). `status: "ok"` means the node is live and +answering; it does NOT imply a usable peer tier, since the HTTP surface opens before the peer network +attaches (§7.8). A client or harness that needs the peer tier MUST poll `peer_tier.attached` until it +is `true` rather than waiting a fixed interval. + ### 6.2. `GET /version` Returns `{ service, version, commit, protocol }` (§2; `version` is the one canonical version). @@ -1173,6 +1197,13 @@ token-gated test is read from `/control-token` after startup; a test `DIG_NODE_STATE_DIR` to an isolated temp dir (§7.3a) so its token/paired-token state is hermetic regardless of any real machine-wide state dir on the host. +**The peer tier is NOT up when `/health` first answers (#1763).** The HTTP surface opens immediately, +while the peer network attaches seconds later, so a read issued as soon as `/health` responds skips +Tier 2 and is answered by the public RPC. A harness that intends to exercise the P2P path MUST poll +`peer_tier.attached` on `/health` (§6.1) until it is `true` — a fixed sleep is neither sufficient nor +checkable — and MUST confirm `X-Dig-Peer-Tier: attached` on the response it measures (§4.6). A result +gathered from a response carrying `unattached` is a measurement of the gateway, not of peer replication. + ### 7.9. Cache-method families (open `cache.*` vs gated `control.cache.*`) The node exposes cache operations under TWO method families, BY DESIGN — a consumer picks the one diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 3f7ef15..2ab52e4 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -6,7 +6,7 @@ name = "dig-node-core" # compatibility-relevant change even though the crate is consumed in-workspace and is not published to # crates.io. Holding it at 0.23.0 would have overstated stability to anyone reading the manifest for # what the exported types are (dig_ecosystem#1785c). -version = "0.24.0" +version = "0.25.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-node-core/src/download.rs b/crates/dig-node-core/src/download.rs index d228563..539e14b 100644 --- a/crates/dig-node-core/src/download.rs +++ b/crates/dig-node-core/src/download.rs @@ -1317,6 +1317,21 @@ impl crate::Node { self.p2p_content.get() } + /// Whether the peer (Tier-2) content path is consultable RIGHT NOW (#1763). + /// + /// The peer network attaches ~30 s after the HTTP surface starts answering, so this is + /// [`PeerTier::Unattached`](crate::content_serve::PeerTier::Unattached) for the whole + /// cold-start window — and permanently on the FFI/in-process path, which brings up no peer + /// network. It is reported per read (`X-Dig-Peer-Tier`) and on `GET /health`, which is what + /// lets a caller wait for the peer tier deterministically instead of sleeping a guessed + /// interval and hoping. + pub fn peer_tier(&self) -> crate::content_serve::PeerTier { + match self.p2p_content() { + Some(_) => crate::content_serve::PeerTier::Attached, + None => crate::content_serve::PeerTier::Unattached, + } + } + /// Decide the #165 miss outcome for `content` at redirect depth `depth`: fetch-through when /// configured (falling back to redirect if the fetch fails), else locate + redirect within the /// hop budget, else not-found. NEVER a silent 404 while a provider exists. diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index 72e7d39..8d9cbb4 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -6209,6 +6209,7 @@ mod tests { root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => { @@ -6219,6 +6220,9 @@ mod tests { "the chain-anchored pin is enforced → verified=true" ); assert_eq!(source, ServeSource::Local); + // No peer network is brought up on this node, so the read skipped Tier 2 (#1763) — + // reported honestly even though the bytes came from disk and never needed a peer. + assert_eq!(peer_tier, crate::content_serve::PeerTier::Unattached); // The injected resolver (`MockResolver::one`) reports no owner (#486) — the header // must be OMITTED, never guessed. assert_eq!(owner_puzzle_hash, None); @@ -6293,6 +6297,89 @@ mod tests { assert_eq!(latest.resources.len(), 2); } + /// **Regression (#1763):** `peer_tier` reports whether the P2P content engine was ATTACHED when + /// a read was routed — a fact about the node, independent of which tier ended up serving. Before + /// this fix nothing carried it, so a read taken inside the ~30 s cold-start window looked exactly + /// like a read taken after attach and any peer-replication conclusion drawn from it was unfounded. + /// + /// **The fixture varies ONE actor.** Both arms drive the IDENTICAL locally-seeded, chain-anchored + /// capsule and both are served from disk (`ServeSource::Local`) — the only difference is whether an + /// engine is attached. That is deliberate: the nearest wrong implementation derives the value from + /// the SERVE SOURCE (`peer_tier = if source == Rpc { Unattached } else { Attached }`), which is + /// indistinguishable from the real thing on a gateway-serve fixture, and which this pair kills from + /// both directions — arm 1 is Local-and-Unattached (that impl says Attached) while arm 2 is + /// Local-and-Attached, so it cannot be satisfied by any constant either. + /// + /// The end-to-end cold-start case — a real gateway serve inside the window, reported over HTTP — + /// is `dig-node-service`'s `cold_start_gateway_serve_reports_the_peer_tier_as_unattached`. + #[test] + fn serve_reports_peer_tier_attachment_independently_of_the_serving_tier() { + use crate::content_serve::{PeerTier, PlaintextOutcome, ServeSource}; + use crate::ContentServer; + let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + std::env::remove_var("DIG_NODE_PIN"); // enforce the chain-anchored pin (the default) + let rt = pin_test_rt(); + let store = Bytes32([23u8; 32]); + let files = vec![("index.html".to_string(), b"

local

".to_vec())]; + let (root, module) = + compile_fixture_module(store, digstore_core::Visibility::Public, true, &files); + + for attach in [false, true] { + let (node, td) = + test_node_with_resolver(None, MockResolver::one(&store.to_hex(), root)); + seed_cached_module(&node.cache_dir, &store.to_hex(), &root.to_hex(), &module); + if attach { + // An engine with NO providers: the peer tier EXISTS but holds nothing, so the read is + // still served from disk. Attachment, not peer availability, is what is under test. + let (content, _unrelated_root, _pt) = + anchored_sealed_content(Bytes32([24u8; 32]), "index.html", b"elsewhere"); + attach_p2p(&node, vec![], content, MissMode::FetchThrough, &td); + } + + // The node-level accessor `/health` reads (#1763) must flip with attachment too — the + // header path and the health path share this one source of truth, so a `peer_tier()` + // pinned to either arm is caught here rather than only at whichever surface has coverage. + assert_eq!( + node.peer_tier(), + if attach { + PeerTier::Attached + } else { + PeerTier::Unattached + }, + "attach={attach}: Node::peer_tier must track engine attachment" + ); + + let out = rt.block_on(node.serve_content_plaintext( + &store.to_hex(), + &root.to_hex(), + "index.html", + None, + crate::download::ReadOrigin::Local, + )); + match out { + PlaintextOutcome::Served { + source, peer_tier, .. + } => { + assert_eq!( + source, + ServeSource::Local, + "attach={attach}: both arms serve from disk — the serving tier is the CONTROL" + ); + assert_eq!( + peer_tier, + if attach { + PeerTier::Attached + } else { + PeerTier::Unattached + }, + "attach={attach}: peer_tier must track engine attachment, not the serve source" + ); + } + other => panic!("attach={attach}: expected a local Served, got {other:?}"), + } + } + } + /// **Proves:** the serve-metadata `X-Dig-Owner-Puzzle-Hash` source (#486) — when the chain-anchored /// pin is ENFORCED and the resolver reports the store's on-chain owner, `serve_content_plaintext` /// surfaces it on the `Served` outcome, resolved from the SAME chain read as the root pin (no second @@ -6976,9 +7063,21 @@ mod tests { )); std::env::remove_var("DIG_NODE_PIN"); match out { - PlaintextOutcome::Served { bytes, source, .. } => { + PlaintextOutcome::Served { + bytes, + source, + peer_tier, + .. + } => { assert_eq!(source, ServeSource::Peer, "must serve from the P2P holder"); assert_eq!(bytes, plaintext, "the decrypted P2P bytes match the source"); + // The post-attach half of #1763: a read that genuinely reached a peer reports the + // tier as attached, so it is distinguishable from a cold-start read that could not. + assert_eq!( + peer_tier, + crate::content_serve::PeerTier::Attached, + "a peer-served read was routed with the engine up" + ); } other => panic!("expected a peer Served (no upstream), got {other:?}"), } diff --git a/crates/dig-node-core/src/seams/content/content_serve.rs b/crates/dig-node-core/src/seams/content/content_serve.rs index adc392c..f73e3c5 100644 --- a/crates/dig-node-core/src/seams/content/content_serve.rs +++ b/crates/dig-node-core/src/seams/content/content_serve.rs @@ -59,6 +59,40 @@ impl ServeSource { } } +/// Whether the P2P content engine was attached when a read was routed — surfaced verbatim as +/// `X-Dig-Peer-Tier` (#1763). +/// +/// The engine attaches ~30 s after the HTTP surface opens, so a node answers content reads before +/// there is any peer tier to consult. Such a read is still SERVED (refusing content for half a +/// minute is worse), but it reaches the gateway having skipped Tier 2 entirely. `X-Dig-Source` +/// alone cannot express that: a gateway serve because the peer tier was DOWN and a gateway serve +/// because no peer HELD the resource are both `rpc`. This value is the difference, so a caller — +/// or an acceptance test — can tell whether the peer path was measured or merely absent. +/// +/// It reports engine ATTACHMENT and nothing else: not peer count, not reachability, not whether a +/// fetch was attempted, and emphatically not verification (see `X-Dig-Verified`, whose overloaded +/// name is #1738). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeerTier { + /// The P2P content engine was attached when this read was routed, so Tier 2 was consultable. + /// A read that nonetheless came from the gateway genuinely missed on the peer tier. + Attached, + /// No P2P content engine was attached, so this read SKIPPED Tier 2. Either the node is still + /// inside its cold-start window, or it runs on the FFI/in-process path that has no peer + /// network at all. Any peer-replication conclusion drawn from this read is unfounded. + Unattached, +} + +impl PeerTier { + /// The lowercase `X-Dig-Peer-Tier` header value. + pub fn as_str(self) -> &'static str { + match self { + PeerTier::Attached => "attached", + PeerTier::Unattached => "unattached", + } + } +} + /// The result of a local plaintext content serve. The HTTP layer (`dig-node-service`) maps each /// variant to a response: `Served` → 200 with the plaintext + `X-Dig-*` headers; `NotFound` → the /// SPA-fallback-vs-404 decision (a route serves the store's `index.html`, an asset misses honestly); @@ -74,6 +108,13 @@ pub enum PlaintextOutcome { root_hex: String, verified: bool, source: ServeSource, + /// Whether the P2P content engine was attached when this read was ROUTED — surfaced as + /// `X-Dig-Peer-Tier` (#1763). Independent of `source`: a gateway serve is `Unattached` + /// during cold start but `Attached` once the engine is up and simply missed. Captured + /// ONCE at request entry and applied uniformly across whichever tier served the bytes + /// (see [`with_serve_metadata`]), so it describes the routing decision this read faced + /// rather than a later moment. + peer_tier: PeerTier, /// The store's on-chain OWNER puzzle hash (64-hex) — the future tip recipient, surfaced /// as `X-Dig-Owner-Puzzle-Hash` (#486). `None` when the chain-anchored pin did not run /// (`DIG_NODE_PIN=off`) or the resolver could not supply it — the header is OMITTED @@ -385,6 +426,12 @@ impl ContentServer for Node { .map(|r| r.to_hex()) .unwrap_or_else(|| requested_root_hex.to_string()); let verified = enforced; + // Whether Tier 2 even EXISTS for this read (#1763), captured before any tier runs so the + // answer describes the routing decision this request faced — not whether the engine + // happened to attach while the read was in flight. A read routed with no engine skipped the + // peer tier outright, and the response says so rather than letting a gateway serve pass for + // a measured P2P result. + let peer_tier = self.peer_tier(); // The store generation this resource came from (#486) — a local-only manifest lookup, no // chain call. `None` on a rootless/pin-off request or when the generation is unknowable. let generation = if root_hex.is_empty() { @@ -415,7 +462,7 @@ impl ContentServer for Node { &root_hex, verified, ) { - return with_serve_metadata(served, owner_puzzle_hash, generation); + return with_serve_metadata(served, owner_puzzle_hash, generation, peer_tier); } // else: a decoy / verify or decrypt failure → fall through to peer/RPC. } @@ -441,7 +488,7 @@ impl ContentServer for Node { { // A peer served the resource; warm the whole capsule locally for next time (#290). self.maybe_backfill_capsule(store_hex, &root_hex, origin); - return with_serve_metadata(peer, owner_puzzle_hash, generation); + return with_serve_metadata(peer, owner_puzzle_hash, generation, peer_tier); } } @@ -478,11 +525,14 @@ impl ContentServer for Node { root_hex: root_hex.clone(), verified, source: ServeSource::Rpc, + // All three stamped just below by `with_serve_metadata`. + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, }, owner_puzzle_hash, generation, + peer_tier, ) } // The gateway returned bytes that do not verify against the anchored root — a @@ -622,7 +672,8 @@ impl Node { verified, source: ServeSource::Local, // Stamped by the caller (`serve_content_plaintext`) via `with_serve_metadata` — - // both fields are resolved ONCE per request, not per tier. + // every one of these is resolved ONCE per request, not per tier. + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, } @@ -712,6 +763,7 @@ impl Node { verified, source: ServeSource::Peer, // Stamped by the caller via `with_serve_metadata` (see `decrypt_local`). + peer_tier: PeerTier::Unattached, owner_puzzle_hash: None, generation: None, }) @@ -804,22 +856,26 @@ impl Node { } } -/// Stamp the request-scoped serve-metadata (#486) onto a `Served` outcome — the owner puzzle hash -/// and generation resolved ONCE in [`Node::serve_content_plaintext`], applied uniformly across -/// whichever tier (local/peer/rpc) actually served the bytes. A no-op on any other variant. +/// Stamp the request-scoped serve-metadata onto a `Served` outcome — the owner puzzle hash and +/// generation (#486) plus the peer-tier attachment (#1763), each resolved ONCE in +/// [`Node::serve_content_plaintext`] and applied uniformly across whichever tier (local/peer/rpc) +/// actually served the bytes. A no-op on any other variant. fn with_serve_metadata( mut outcome: PlaintextOutcome, owner_puzzle_hash: Option, generation: Option, + peer_tier: PeerTier, ) -> PlaintextOutcome { if let PlaintextOutcome::Served { owner_puzzle_hash: o, generation: g, + peer_tier: p, .. } = &mut outcome { *o = owner_puzzle_hash; *g = generation; + *p = peer_tier; } outcome } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index c0a7e91..e62a6a5 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -24,7 +24,7 @@ use axum::{ routing::{get, post}, Json, Router, }; -use dig_node_core::content_serve::{PlaintextOutcome, ServeSource}; +use dig_node_core::content_serve::{PeerTier, PlaintextOutcome, ServeSource}; use dig_node_core::{cache_cap_bytes, cache_used_bytes, handle_rpc, ContentServer, Node}; use dig_wallet::sage::events::{SyncEvent, SyncLifecycle, SyncStatus}; use dig_wallet::sage::rpc::WalletBackend; @@ -203,12 +203,13 @@ pub fn router(state: AppState) -> Router { /// attestation + the Merkle-proof/chunk-length headers on the ciphertext path — without which the /// resolver fails closed and drops to the verified rpc tier. Lowercase (header names are /// case-insensitive; `HeaderName::from_static` requires lowercase). -const EXPOSED_DIG_HEADERS: [&str; 10] = [ +const EXPOSED_DIG_HEADERS: [&str; 11] = [ "x-dig-verified", "x-dig-root", "x-dig-inclusion-proof", "x-dig-chunk-lens", "x-dig-source", + "x-dig-peer-tier", "x-dig-store-id", "x-dig-capsule", "x-dig-resource-key", @@ -488,9 +489,26 @@ fn status_fields(state: &AppState) -> serde_json::Map { // §21 whole-store sync availability (whether a §21.9 identity is loaded) — the // "sync state" a live client wants alongside version/addr (#239). m.insert("sync".into(), json!({ "available": state.sync_available })); + // Peer-tier readiness (#1763). The HTTP surface answers content reads ~30 s BEFORE the peer + // network attaches, so "the node responds" has never implied "the node can reach peers". This + // is the checkable signal for the difference: an acceptance test polls `peer_tier.attached` + // until it is true instead of sleeping a guessed interval, and a read taken before then is + // known to be a gateway measurement rather than assumed to be a P2P one. + m.insert("peer_tier".into(), peer_tier_status(state.node.peer_tier())); m } +/// The `/health` `peer_tier` object for a given tier — the ONE place the wire spelling of +/// peer-tier readiness is decided, so both directions are assertable without standing up a peer +/// network (attachment itself is only settable inside `dig-node-core`). +/// +/// `attached: true` is the signal SPEC.md §6.1/§7.8 tell every harness to POLL instead of sleeping, +/// so a field that never reports `true` turns the documented wait into an infinite one with the +/// same observable signature as the legitimate cold-start window. +fn peer_tier_status(tier: PeerTier) -> Value { + json!({ "attached": tier == PeerTier::Attached }) +} + /// `GET /health` (and `GET /`) — liveness + mode + cache stats + discovery hooks. /// Shape extends the Node reference server's health body (existing probes keep parsing /// `status`/`version`/`mode`/`upstream`/`cache`) with agent-friendly additions: @@ -1280,6 +1298,7 @@ async fn serve_resource( root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => served_response( @@ -1287,10 +1306,13 @@ async fn serve_resource( &sp.resource, bytes, &root_hex, - verified, - source, - owner_puzzle_hash.as_deref(), - generation, + ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash: owner_puzzle_hash.as_deref(), + generation, + }, ), PlaintextOutcome::NotFound { root_hex } => serve_miss(state, &sp, &root_hex, origin).await, PlaintextOutcome::InvalidParams { message } => { @@ -1339,6 +1361,7 @@ async fn serve_miss( root_hex, verified, source, + peer_tier, owner_puzzle_hash, generation, } => served_response( @@ -1346,17 +1369,39 @@ async fn serve_miss( "index.html", bytes, &root_hex, - verified, - source, - owner_puzzle_hash.as_deref(), - generation, + ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash: owner_puzzle_hash.as_deref(), + generation, + }, ), _ => not_found(), } } +/// Everything a served response reports ABOUT its bytes rather than the bytes themselves — grouped +/// so [`served_response`] takes one self-describing argument instead of a positional run of five +/// that a caller can silently transpose. +struct ServeProvenance<'a> { + /// Whether the bytes were verified against the CHAIN-ANCHORED root (`X-Dig-Verified`). + verified: bool, + /// Which tier the bytes came from (`X-Dig-Source`). + source: ServeSource, + /// Whether the peer tier was attached when the read was routed (`X-Dig-Peer-Tier`, #1763) — + /// which is what distinguishes "the gateway answered because no peer held this" from "the + /// gateway answered because there was no peer tier yet". + peer_tier: PeerTier, + /// The store's on-chain owner puzzle hash, when resolvable (`X-Dig-Owner-Puzzle-Hash`). + owner_puzzle_hash: Option<&'a str>, + /// The commit ordinal that last wrote the resource, when known (`X-Dig-Generation`). + generation: Option, +} + /// Build the `200` response for a served resource: the ecosystem content-type + `nosniff`, the -/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source` provenance headers (#292), the serve-metadata HEAD +/// `X-Dig-Verified`/`X-Dig-Root`/`X-Dig-Source`/`X-Dig-Peer-Tier` provenance headers (#292, #1763), +/// the serve-metadata HEAD /// (#486: `X-Dig-Store-Id`/`X-Dig-Capsule`/`X-Dig-Resource-Key` always, `X-Dig-Owner-Puzzle-Hash`/ /// `X-Dig-Generation` when resolvable), and — for HTML — the injected store-root /// ``/`` plus the hardened store CSP. @@ -1367,17 +1412,20 @@ async fn serve_miss( /// /// `owner_puzzle_hash`/`generation` are OMITTED (not an empty placeholder) when unknowable — see /// [`PlaintextOutcome::Served`]'s field docs. -#[allow(clippy::too_many_arguments)] fn served_response( sp: &StorePath, resource: &str, bytes: Vec, root_hex: &str, - verified: bool, - source: ServeSource, - owner_puzzle_hash: Option<&str>, - generation: Option, + provenance: ServeProvenance<'_>, ) -> Response { + let ServeProvenance { + verified, + source, + peer_tier, + owner_puzzle_hash, + generation, + } = provenance; let content_type = content_type_for(resource); // The MAIN resource actually served: an empty key (a bare store-root request) resolved to the // default view `index.html` internally, so the header reports that, never a blank string. @@ -1393,6 +1441,7 @@ fn served_response( .header("X-Dig-Verified", if verified { "true" } else { "false" }) .header("X-Dig-Root", root_hex) .header("X-Dig-Source", source.as_str()) + .header("X-Dig-Peer-Tier", peer_tier.as_str()) .header("X-Dig-Store-Id", sp.store_id.as_str()) .header("X-Dig-Capsule", format!("{}:{}", sp.store_id, root_hex)) .header("X-Dig-Resource-Key", resource_key); @@ -1844,12 +1893,92 @@ async fn shutdown_signal() { #[cfg(test)] mod tests { use super::{ - is_allowed_origin, is_app_origin, is_local_origin, read_origin_for, APP_ORIGINS_ENV, - EXPOSED_DIG_HEADERS, + is_allowed_origin, is_app_origin, is_local_origin, peer_tier_status, read_origin_for, + served_response, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, }; + use dig_node_core::content_serve::{PeerTier, ServeSource}; use dig_node_core::download::ReadOrigin; + use serde_json::json; use std::net::{Ipv4Addr, SocketAddr}; + /// **Regression (#1763):** the `X-Dig-Peer-Tier` wire value for BOTH tiers, asserted on the real + /// response builder rather than on the enum alone. + /// + /// SPEC.md §4.3/§4.6 make this header's value set a cross-repo contract with dig-urn-resolver, so + /// the literal is the contract. The only prior HTTP coverage exercised `unattached`, which the + /// `dig-node-service` integration suite cannot escape: every test builds state via `build_state`, + /// which never attaches an engine, so the `Attached` arm was unreachable there BY CONSTRUCTION and + /// spelling it `"unattached"` in core kept the whole workspace green. + /// + /// **The fixture varies ONE actor.** Both arms build the IDENTICAL served response — same store + /// path, same bytes, same `ServeSource::Local` — and differ only in the peer tier, with the + /// `unattached` arm kept as the truthful control. Asserting both spellings also kills the + /// swap/alias mutants (either arm returning the other's literal, or both returning one constant) + /// that a single-direction assertion cannot see. + #[test] + fn served_response_reports_both_peer_tier_wire_values() { + let sp = StorePath { + store_id: "ab".repeat(32), + root: None, + resource: "data.bin".to_string(), + }; + for (tier, expected) in [ + (PeerTier::Attached, "attached"), + (PeerTier::Unattached, "unattached"), + ] { + let resp = served_response( + &sp, + "data.bin", + b"payload".to_vec(), + &"cd".repeat(32), + ServeProvenance { + verified: true, + source: ServeSource::Local, + peer_tier: tier, + owner_puzzle_hash: None, + generation: None, + }, + ); + assert_eq!( + resp.headers() + .get("X-Dig-Peer-Tier") + .map(|v| v.to_str().expect("ascii header")), + Some(expected), + "{tier:?} must serialize as {expected:?} on the wire (#1763)" + ); + // The serving tier is the CONTROL: it is Local in both arms, so a mutant that derives the + // peer tier from the serve source cannot satisfy this pair. + assert_eq!( + resp.headers().get("X-Dig-Source").map(|v| v.to_str().ok()), + Some(Some("local")), + "{tier:?}: the serve source is the control and must not vary" + ); + } + } + + /// **Regression (#1763):** `/health`'s `peer_tier.attached` reports BOTH directions. + /// + /// The `true` direction is the one that matters asymmetrically: SPEC.md §6.1/§7.8 tell every + /// harness to POLL `peer_tier.attached` until it is true instead of sleeping, so a field pinned at + /// `false` turns the documented wait into an infinite one with exactly the observable signature of + /// the legitimate readiness window (`status: "ok"` + `attached: false`). Only the `false` arm had + /// coverage, because `build_state` never attaches an engine and attachment is settable only inside + /// `dig-node-core` — hence the assertion sits on the pure mapping that `status_fields` delegates to, + /// which is reachable on the CI host with no peer network and no widened production visibility. + #[test] + fn health_peer_tier_status_reports_both_directions() { + assert_eq!( + peer_tier_status(PeerTier::Attached), + json!({ "attached": true }), + "an attached engine must report attached: true — the value harnesses poll for" + ); + assert_eq!( + peer_tier_status(PeerTier::Unattached), + json!({ "attached": false }), + "no engine must report attached: false — the truthful control" + ); + } + #[test] fn read_origin_for_classifies_ipv4_mapped_loopback_as_local() { // #1664b: on a `::` dual-stack bind an IPv4 loopback client arrives as the @@ -1936,6 +2065,13 @@ mod tests { "{required} must be exposed via Access-Control-Expose-Headers" ); } + // #1763: the peer-tier readiness header is part of the SAME cross-origin contract — a + // browser-side resolver that cannot READ it cannot tell a cold-start gateway serve from a + // genuine peer miss, and loses that distinction SILENTLY (the header is still sent). + assert!( + EXPOSED_DIG_HEADERS.contains(&"x-dig-peer-tier"), + "x-dig-peer-tier must be exposed via Access-Control-Expose-Headers (#1763)" + ); // Every entry is a valid lowercase HeaderName (from_static panics otherwise) — this asserts // the const stays constructible, the exact shape the CORS layer relies on. for h in EXPOSED_DIG_HEADERS { diff --git a/crates/dig-node-service/tests/content_serve.rs b/crates/dig-node-service/tests/content_serve.rs index d0f0eb7..61d0927 100644 --- a/crates/dig-node-service/tests/content_serve.rs +++ b/crates/dig-node-service/tests/content_serve.rs @@ -100,6 +100,15 @@ async fn mock_upstream_all_miss() -> String { /// seed a module into), and the env-serialization hold. async fn start_server(upstream: &str) -> (SocketAddr, PathBuf, EnvHold) { let hold = env_guard().lock_owned().await; + let (addr, cache) = spawn_node(upstream).await; + (addr, cache, EnvHold(hold)) +} + +/// Bring up ONE service node — the body of [`start_server`] WITHOUT taking the env-serialization +/// lock, so a single test can stand up two nodes (a reader and the gateway it falls back to) inside +/// one hold. Each node captures its own cache dir at construction, so the process-global +/// `DIG_NODE_CACHE` may be repointed between calls. +async fn spawn_node(upstream: &str) -> (SocketAddr, PathBuf) { let unique = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let base = std::env::temp_dir().join(format!( "dig-node-serve-test-{}-{}", @@ -131,7 +140,7 @@ async fn start_server(upstream: &str) -> (SocketAddr, PathBuf, EnvHold) { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, cache, EnvHold(hold)) + (addr, cache) } fn store_and_files() -> (Bytes32, Vec<(String, Vec)>) { @@ -735,3 +744,77 @@ async fn fallback_serve_labels_a_rerooted_read_from_the_connection() { "a non-loopback reader must be Peer on the fallback door too, got {seen:?}" ); } + +/// **Regression (#1763):** during the ~30 s cold-start window BEFORE the peer network attaches, a +/// content read still succeeds — but it is served by the public gateway, having never consulted the +/// peer tier at all. Before this fix the response was indistinguishable from a post-attach gateway +/// serve, so a fresh node (or a test that started promptly) measured the gateway and recorded it as +/// a P2P result. The read MUST now say, in the response itself, that the peer tier was not attached +/// when it was routed. +/// +/// **The fixture is a real cold start, not a mock of one:** `build_state` is the same constructor +/// the daemon uses and it does NOT attach the P2P content engine (only `spawn_peer_network` does), +/// so the reader node below is genuinely inside the window. Its Tier-3 fallback is a SECOND real +/// dig-node serving the capsule over the real `dig.getContent` wire — the gateway's bytes, proof and +/// chunk lengths are produced by the production serve path, not hand-built. +#[tokio::test] +async fn cold_start_gateway_serve_reports_the_peer_tier_as_unattached() { + let _hold = EnvHold(env_guard().lock_owned().await); + let (store, files) = store_and_files(); + let (root, module) = compile_public_module(store, &files); + + // The GATEWAY: a node that holds the capsule and answers `dig.getContent` from its own cache. + // Its own upstream is unroutable — every byte it returns comes from the module seeded below. + let (gw_addr, gw_cache) = spawn_node("http://127.0.0.1:1/").await; + seed_module(&gw_cache, &store.to_hex(), &root.to_hex(), &module); + + // The READER: a freshly-started node with an EMPTY cache and no peer network attached — the + // cold-start window. Local misses, the peer tier is not there, so the read falls to the gateway. + let (addr, _cache) = spawn_node(&format!("http://{gw_addr}")).await; + + let url = format!( + "http://{addr}/s/{}:{}/index.html", + store.to_hex(), + root.to_hex() + ); + let resp = reqwest::Client::new().get(&url).send().await.unwrap(); + assert_eq!( + resp.status(), + 200, + "the read still succeeds — availability is not traded away" + ); + let h = resp.headers(); + assert_eq!( + h.get("x-dig-source").unwrap(), + "rpc", + "the gateway served these bytes" + ); + assert_eq!( + h.get("x-dig-peer-tier") + .map(|v| v.to_str().unwrap()) + .unwrap_or("
"), + "unattached", + "the read must declare that the peer tier was never consulted" + ); +} + +/// **Proves (#1763):** the peer-tier state is ALSO checkable out-of-band, on `GET /health`, so an +/// acceptance test can poll `peer_tier.attached` until the peer network is up instead of sleeping a +/// guessed 30 s and hoping. A node that answers `/health` with `status: ok` is live, but liveness has +/// never implied a usable peer tier — this is the field that separates the two. +#[tokio::test] +async fn health_reports_the_peer_tier_as_unattached_before_the_peer_network_starts() { + let (addr, _cache, _hold) = start_server("http://127.0.0.1:1/").await; + let body: Value = reqwest::get(format!("http://{addr}/health")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["status"], json!("ok"), "the node is live"); + assert_eq!( + body["peer_tier"]["attached"], + json!(false), + "a live node with no peer network must say so rather than leaving it unstated" + ); +}