Skip to main content

solid_pod_rs_server/
lib.rs

1//! # solid-pod-rs-server
2//!
3//! Drop-in Solid Pod server binary wrapping
4//! [`solid-pod-rs`](https://crates.io/crates/solid-pod-rs) with
5//! [actix-web](https://docs.rs/actix-web). This crate is both a
6//! library (for integration-test reuse) and a binary.
7//!
8//! ## Public types
9//!
10//! - [`AppState`]  — Shared actix-web application state (storage, dotfile policy, body cap).
11//! - [`build_app`] — Builds the fully-configured `actix_web::App` with all routes and middleware.
12//! - [`NodeInfoMeta`] — NodeInfo 2.1 metadata inputs.
13//! - [`PathTraversalGuard`] — Middleware that rejects `..` path-traversal attempts.
14//! - [`DotfileGuard`] — Middleware that enforces the dotfile allowlist.
15//! - [`ErrorLoggingMiddleware`] — Middleware that logs 5xx responses with full error chains.
16//! - [`body_cap_from_env`] — Reads `JSS_MAX_REQUEST_BODY` from the environment.
17//! - [`cli`] — CLI argument definitions (clap derive).
18//!
19//! ## Route table
20//!
21//! | Method   | Path                                     | Handler              |
22//! |----------|------------------------------------------|----------------------|
23//! | GET/HEAD | `/{tail:.*}`                             | `handle_get`         |
24//! | GET      | `/{folder}/*`                            | Glob merged Turtle   |
25//! | PUT      | `/{tail:.*}`                             | `handle_put`         |
26//! | PUT      | `/{tail:.*}/` + `Link: BasicContainer`   | Container creation   |
27//! | POST     | `/{tail:.*}/`                            | `handle_post`        |
28//! | PATCH    | `/{tail:.*}`                             | `handle_patch`       |
29//! | DELETE   | `/{tail:.*}`                             | `handle_delete`      |
30//! | COPY     | `/{tail:.*}` + `Source` header           | `handle_copy`        |
31//! | OPTIONS  | `/{tail:.*}`                             | `handle_options`     |
32//! | POST     | `/api/accounts/new`                      | Pod provisioning     |
33//! | GET      | `/pods/check/{name}`                     | Pod existence check  |
34//! | POST     | `/login/password`                        | Credentials login    |
35//! | POST     | `/account/password/reset`                | Password reset       |
36//! | POST     | `/account/password/change`               | Password change      |
37//! | GET      | `/.well-known/solid`                     | Solid discovery      |
38//! | GET      | `/.well-known/webfinger`                 | WebFinger JRD        |
39//! | GET      | `/.well-known/nodeinfo`                  | NodeInfo discovery   |
40//! | GET      | `/.well-known/nodeinfo/2.1`              | NodeInfo 2.1         |
41//! | GET      | `/.well-known/did/nostr/{pubkey}.json`   | DID:nostr document   |
42//! | GET      | `/pay/.info`                             | Payment discovery    |
43//! | GET      | `/pay/.balance`                          | Web-Ledger balance   |
44//! | POST     | `/pay/.deposit`                          | TXO + MRC20 deposit  |
45//! | GET      | `/pay/.address`                          | Tweaked deposit addr |
46//! | GET/POST | `/pay/.offers` `.sell` `.swap` `.pool`   | Order book + AMM     |
47//! | POST     | `/pay/.buy` `.withdraw` `.withdraw-sats` | Token mint/voucher   |
48//! | GET      | `/{pod}/{path}.prov.ttl`                 | PROV-O git-mark sidecar |
49//! | GET      | `/{pod}/_prov/{commit_sha}`              | Resolve a git-mark   |
50//! | POST     | `/{pod}/_prov/anchor`                    | Upgrade to Bitcoin anchor |
51//! | GET      | `/api/exports/all`                       | JSON-LD pod export (`export-jsonld`, `acl:Control`-gated) |
52//! | GET/POST | `/{pod}/info/refs` `…/git-{upload,receive}-pack` | Git smart-HTTP (WAC-gated) |
53//! | GET/POST | `/forge` `/forge/{tail:.*}`               | Git forge — browse, issues, push tokens (`forge` feature, namespace-scoped, not WAC-gated) |
54//!
55//! The `/pay/*` HTTP-402 economy routes (`handlers::pay`) wire the
56//! `solid-pod-rs` Web-Ledger / order-book / AMM core onto actix; the `_prov`
57//! routes (`handlers::prov`, `--features git`) expose the git-mark +
58//! block-trail provenance API (ADR-059). Block-trail anchor verification and
59//! broadcast go through the native [`mempool`] client (mempool.space testnet4
60//! by default), with trail persistence via [`trail_store`]. Every LDP
61//! `PUT`/`POST`/`PATCH` to a git-backed pod additionally fires the always-on
62//! git-mark write hook (`git_mark_write`, when built with `--features git`).
63//!
64//! The `/forge/*` git forge (JSS `forge` plugin port; `solid_pod_rs_forge`)
65//! is mounted under `--features forge`, which implies `git` (the forge
66//! reuses the smart-HTTP CGI). `forge-anchoring` adds the Blocktrails tier
67//! (`solid_pod_rs::mrc20`); `forge-announce` adds NIP-34 discovery
68//! publication over `solid-pod-rs-nostr`. Forge routes are registered
69//! *before* the pod-git smart-HTTP catch-all so
70//! `/forge/<owner>/<repo>.git/info/refs` resolves to the forge's own CGI
71//! forwarding rather than the pod-git handler. Unlike `handle_git`, the
72//! forge does **not** go through the pod's WAC ACL evaluator: it enforces
73//! its own fail-closed namespace-ownership guard (an agent may only
74//! push/comment into the namespace matching its own `did:nostr` pubkey or
75//! pod username; see `solid_pod_rs_forge::ownership`) plus an own-area SSRF
76//! check on pod-hosted issue/PR bodies. Repository browsing (tree/blob/
77//! commits/issues list) is unauthenticated-public by design.
78//!
79//! ## Middleware stack (applied in order)
80//!
81//! 1. `NormalizePath` -- collapse `//` and decode %-encoded segments.
82//! 2. `PathTraversalGuard` -- defence-in-depth `..` re-check.
83//! 3. `DotfileGuard` -- rejects `.env` etc unless on the allowlist.
84//! 4. `PayloadConfig` -- enforces `JSS_MAX_REQUEST_BODY` body cap.
85//! 5. `ErrorLoggingMiddleware` -- structured 5xx logging.
86//! 6. WAC-on-write -- PUT/POST/PATCH/DELETE require a write/append grant.
87//!
88//! ## Security posture (closeout, 0.5.0-alpha.4)
89//!
90//! - **NIP-98 single-use replay guard** — every request runs through a
91//!   shared process-local `Nip98ReplayCache`, so a captured token cannot be
92//!   replayed within the ±120s NIP-98 tolerance window (`extract_pubkey`
93//!   returns `None` on a replayed id → the WAC gate denies with 401). TTL /
94//!   size via `SOLID_POD_NIP98_REPLAY_TTL_SECS` / `SOLID_POD_NIP98_REPLAY_MAX_SIZE`.
95//!   The cache is per-process; multi-replica deployments share no state.
96//! - **Fail-open compile guard** — this binary references
97//!   `solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled` in const
98//!   context, so a build that ever dropped BIP-340 signature verification from
99//!   the NIP-98 verifier is a **compile error**, not a silently fail-open
100//!   server.
101//! - **WAC `acl:origin` gate** — the request `Origin` header is threaded into
102//!   the evaluator (`enforce_read_ctx` / `enforce_write_ctx`), so ACLs bearing
103//!   `acl:origin` triples gate cross-origin access. Plain ACLs are unaffected;
104//!   `acl:Control` bypasses the origin gate by design.
105
106#![doc = include_str!("../README.md")]
107#![deny(unsafe_code)]
108#![warn(rust_2018_idioms)]
109
110#[cfg(feature = "activitypub")]
111pub mod activitypub;
112/// CLI argument definitions (clap derive structs).
113pub mod cli;
114pub mod cross_identity;
115pub mod scheme;
116
117pub(crate) mod hardened_git;
118
119/// HTTP request handlers grouped by domain. Currently hosts the payment
120/// routing layer ([`handlers::pay`]) which wires the orphaned
121/// `solid-pod-rs` order-book / AMM / Web-Ledger logic onto actix routes
122/// with JSS-parity JSON.
123mod handlers;
124
125/// MCP (Model Context Protocol) server subsystem — `POST /mcp`, mounted
126/// only when [`AppState::mcp_enabled`] (`--mcp` / `JSS_MCP`, JSS #490).
127mod mcp;
128
129/// Native mempool.space REST client (provenance-upgrade Phase 3). Concrete
130/// [`solid_pod_rs::mrc20::MempoolLookup`] + the verify-side
131/// [`solid_pod_rs::provenance::BlockAnchorer`]. Server-side only (builds a
132/// `reqwest::Client`); wasm consumers implement the trait over `fetch`.
133pub mod mempool;
134
135/// MRC20 trail persistence (provenance-upgrade Phase 4). Loads/saves a
136/// token's Bitcoin-anchored state chain at `/.well-known/token/{ticker}.json`
137/// via the pod's [`solid_pod_rs::storage::Storage`] backend (JSS
138/// `token.js:189-208`). Native-only; holds the issuer secret off the public
139/// [`solid_pod_rs::mrc20::Mrc20Trail`] type.
140pub mod trail_store;
141
142use std::collections::HashMap;
143use std::net::{IpAddr, Ipv4Addr};
144use std::path::{Path, PathBuf};
145use std::sync::{Arc, Mutex};
146use std::time::{Duration, Instant};
147
148use actix_web::body::{BoxBody, EitherBody};
149use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
150use actix_web::http::{header, StatusCode};
151use actix_web::middleware::{NormalizePath, TrailingSlash};
152use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
153use bytes::Bytes;
154use futures_util::future::{ready, LocalBoxFuture, Ready};
155use futures_util::StreamExt;
156use percent_encoding::percent_decode_str;
157use serde::Deserialize;
158use solid_pod_rs::{
159    // `ReplayStore` is the seam the process-local replay cache implements
160    // (ADR-060 Decision 2); it must be in scope to call `check_and_record`
161    // through the trait rather than an inherent method.
162    auth::{nip98, replay::ReplayStore},
163    config::sources::parse_size,
164    interop,
165    ldp::{
166        self, cache_control_for_response, LdpContainerOps, PatchCreateOutcome, ResponseAudience,
167    },
168    mashlib::{self, MashlibConfig},
169    provenance::{ProvenanceReceipt, ProvenanceSkip},
170    security::DotfileAllowlist,
171    storage::Storage,
172    wac::{
173        self, conditions::RequestContext, effective_acl_target, parse_jsonld_acl,
174        parser::parse_turtle_acl, protected_resource_for_acl, AccessMode,
175    },
176    PodError,
177};
178
179// ---------------------------------------------------------------------------
180// F3 compile guard — refuse to build a fail-open NIP-98 auth path.
181// ---------------------------------------------------------------------------
182//
183// `assert_schnorr_verification_enabled` is a `const fn` that exists ONLY
184// under the core crate's `nip98-schnorr` feature. Referencing it in const
185// context here makes this binary impossible to build if BIP-340 signature
186// verification is ever dropped from the NIP-98 verifier — a dependency
187// change or a feature-unification regression that removed it becomes a
188// compile error rather than a server that silently accepts any forged
189// pubkey after structural checks alone.
190const _: () = solid_pod_rs::auth::nip98::assert_schnorr_verification_enabled();
191
192/// Process-local NIP-98 single-use replay guard (F3). Shared across every
193/// request in this server process; a captured token cannot be replayed
194/// within the ~120s NIP-98 tolerance window. TTL/size are overridable via
195/// `SOLID_POD_NIP98_REPLAY_TTL_SECS` / `SOLID_POD_NIP98_REPLAY_MAX_SIZE`.
196/// See `solid_pod_rs::auth::replay` for the tier persistence limit
197/// (process-local; multi-replica deployments share no state).
198static NIP98_REPLAY: std::sync::LazyLock<solid_pod_rs::auth::replay::Nip98ReplayCache> =
199    std::sync::LazyLock::new(solid_pod_rs::auth::replay::Nip98ReplayCache::from_env);
200
201/// Serialises every local payment-state transition in this process. The
202/// storage API has no cross-resource CAS primitive, so handlers must hold
203/// this guard across replay checks, ledger/order/pool/trail mutation, and
204/// persistence. Durable intent records add crash recovery where an external
205/// broadcast is involved.
206pub(crate) static PAYMENT_STATE_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
207    std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
208
209// ---------------------------------------------------------------------------
210// Shared app state
211// ---------------------------------------------------------------------------
212
213/// Actix-web shared state.
214#[derive(Clone)]
215pub struct AppState {
216    pub storage: Arc<dyn Storage>,
217    pub dotfiles: Arc<DotfileAllowlist>,
218    pub body_cap: usize,
219    pub nodeinfo: NodeInfoMeta,
220    pub mashlib: MashlibConfig,
221    /// Legacy alias — reads from `mashlib.mode` when `Cdn`.  Deprecated;
222    /// use `mashlib` directly.
223    pub mashlib_cdn: Option<String>,
224    /// Inject a development-only WebSocket reload client into HTML responses.
225    pub live_reload: bool,
226    /// Payment configuration — drives `/pay/.info` and the `X-Balance` /
227    /// `X-Cost` / `X-Pay-Currency` response headers on paid resources.
228    pub pay_config: solid_pod_rs::payments::PayConfig,
229    /// Absolute filesystem root of the pod storage tree. `Some` when the
230    /// backend is `FsBackend`; `None` for in-memory or cloud-backed
231    /// storage. Required by the `git` feature to locate pod directories
232    /// for `GitAutoInit` (provisioning) and `GitHttpService` (serving).
233    pub data_root: Option<PathBuf>,
234    /// Optional atomic per-pod quota policy applied to every LDP mutation.
235    pub quota: Option<Arc<dyn solid_pod_rs::quota::QuotaPolicy>>,
236    /// JSS-compatible pod creation limiter: one `POST /.pods` per IP per day.
237    pub pod_create_limiter: Arc<PodCreateLimiter>,
238    /// When non-empty, CORS responses are only reflected for origins in this
239    /// list. Origins not in the list receive no `Access-Control-Allow-Origin`
240    /// header. When empty (the default), the request `Origin` is echoed back
241    /// (wildcard-equivalent behaviour, suitable for local dev).
242    ///
243    /// Configured via `--allowed-origins` / `SOLID_ALLOWED_ORIGINS` (comma-separated).
244    pub allowed_origins: Vec<String>,
245    /// Pre-shared key for the `POST /_admin/provision/{pubkey}` endpoint.
246    /// When `None`, the endpoint returns 403 unconditionally.
247    ///
248    /// Configured via `--admin-key` / `SOLID_ADMIN_KEY`.
249    pub admin_key: Option<String>,
250    /// When true, the MCP (Model Context Protocol) server is mounted at
251    /// `POST /mcp`, exposing the pod as a tool surface for agents. OFF by
252    /// default — keys-on-disk and agent write access are an opt-in
253    /// security tradeoff. Configured via `--mcp` / `JSS_MCP` (JSS #490).
254    pub mcp_enabled: bool,
255    /// Optional override for the mempool REST base URL used by the MRC20
256    /// `/pay/.deposit` anchor verification (provenance-upgrade Phase 3).
257    /// `None` ⇒ the handler reads `JSS_PAY_MEMPOOL_URL` (default testnet4).
258    /// Tests point this at a local fixture server so they never reach
259    /// mempool.space; production leaves it `None`.
260    pub mempool_url: Option<String>,
261    /// Content negotiation (JSS `--conneg`). When on, `text/n3` is accepted
262    /// and advertised alongside Turtle for request bodies; when off a
263    /// `text/n3` body is refused with 415 as in JSS `canAcceptInput`.
264    pub conneg_enabled: bool,
265    /// Honour `X-Forwarded-Proto` / `cf-visitor` for the request scheme and
266    /// `X-Forwarded-Host` / `Forwarded` for the host.
267    /// Off by default: only enable behind a proxy that overwrites them.
268    pub trust_proxy: bool,
269    /// Public base URL of this server (`JSS_PUBLIC_BASE_URL`). When set it
270    /// is the origin used for NIP-98 URL matching and every generated
271    /// absolute URL, whatever `Host` or forwarding headers say.
272    pub public_base_url: Option<String>,
273    /// Hosts this server answers for (`JSS_ALLOWED_HOSTS`). Empty permits
274    /// any `Host`; otherwise a request for another host gets `421`.
275    pub allowed_hosts: Vec<String>,
276    /// did:nostr to WebID resolver applied to NIP-98 callers. `None`
277    /// disables cross-identity (the caller is the `did:nostr` agent only).
278    pub cross_identity: Option<Arc<dyn cross_identity::CrossIdentityResolver>>,
279    /// ActivityPub actor provider; `Some` mounts the actor route (set by the
280    /// `activitypub` feature's `ApConfig`; always `None` otherwise).
281    pub activitypub: Option<Arc<dyn ActorDocument>>,
282}
283
284/// Source of the ActivityPub actor document served at
285/// `GET /profile/card.jsonld` for activity+json requests.
286pub trait ActorDocument: Send + Sync {
287    /// The actor JSON for the site at `base_url` (`scheme://host`).
288    fn actor_json(&self, base_url: &str) -> serde_json::Value;
289}
290
291#[cfg(test)]
292mod nip98_request_binding_tests {
293    use super::*;
294    use actix_web::test::TestRequest;
295
296    const SECRET: &str = "3333333333333333333333333333333333333333333333333333333333333333";
297
298    fn now() -> u64 {
299        std::time::SystemTime::now()
300            .duration_since(std::time::UNIX_EPOCH)
301            .unwrap()
302            .as_secs()
303    }
304
305    #[actix_web::test]
306    async fn authentication_binds_the_complete_query_string() {
307        let signed_url = "http://localhost:8080/private?include=true";
308        let token = nip98::mint(signed_url, "GET", SECRET, now()).unwrap();
309        let request = TestRequest::get()
310            .uri("/private?include=true")
311            .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
312            .to_http_request();
313        assert!(extract_pubkey(&request).await.is_some());
314
315        let path_only = nip98::mint("http://localhost:8080/private", "GET", SECRET, now()).unwrap();
316        let request = TestRequest::get()
317            .uri("/private?include=true")
318            .insert_header((header::AUTHORIZATION, format!("Nostr {path_only}")))
319            .to_http_request();
320        assert!(extract_pubkey(&request).await.is_none());
321    }
322
323    #[actix_web::test]
324    async fn authentication_binds_the_exact_raw_body() {
325        let expected = br#"{"value":"authorised"}"#;
326        let token = nip98::mint_with_payload(
327            "http://localhost:8080/resource",
328            "PUT",
329            Some(expected),
330            SECRET,
331            now(),
332        )
333        .unwrap();
334        let request = TestRequest::put()
335            .uri("/resource")
336            .insert_header((header::AUTHORIZATION, format!("Nostr {token}")))
337            .to_http_request();
338
339        assert!(
340            extract_pubkey_with_body(&request, Some(br#"{"value":"tampered"}"#))
341                .await
342                .is_none()
343        );
344        assert!(extract_pubkey_with_body(&request, Some(expected))
345            .await
346            .is_some());
347    }
348}
349
350/// NodeInfo 2.1 body inputs. Kept here so tests can override them.
351#[derive(Clone, Debug)]
352pub struct NodeInfoMeta {
353    pub software_name: String,
354    pub software_version: String,
355    pub open_registrations: bool,
356    pub total_users: u64,
357    pub base_url: String,
358}
359
360impl Default for NodeInfoMeta {
361    fn default() -> Self {
362        Self {
363            software_name: "solid-pod-rs-server".to_string(),
364            software_version: env!("CARGO_PKG_VERSION").to_string(),
365            open_registrations: false,
366            total_users: 0,
367            base_url: "http://localhost".to_string(),
368        }
369    }
370}
371
372/// Discover the body cap from the environment. Accepts values like
373/// `50MB`, `1.5GB`, or a bare integer (bytes). Falls back to 50 MiB.
374pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
375
376/// Read `JSS_MAX_REQUEST_BODY` (or, as a drop-in-config alias, upstream
377/// JSS's `JSS_BODY_LIMIT`, #474/#543) and parse via [`parse_size`]. The
378/// canonical name wins when both are set. On any failure, returns
379/// [`DEFAULT_BODY_CAP`].
380pub fn body_cap_from_env() -> usize {
381    match std::env::var("JSS_MAX_REQUEST_BODY").or_else(|_| std::env::var("JSS_BODY_LIMIT")) {
382        Ok(v) => parse_size(&v)
383            .map(|u| u as usize)
384            .unwrap_or(DEFAULT_BODY_CAP),
385        Err(_) => DEFAULT_BODY_CAP,
386    }
387}
388
389impl AppState {
390    /// The configured public origin (`scheme://authority`), if any.
391    pub fn public_origin(&self) -> Option<String> {
392        self.public_base_url
393            .as_deref()
394            .and_then(scheme::normalise_origin)
395    }
396
397    /// Convenience constructor for tests and the binary. Callers may
398    /// replace fields after creation since `AppState` is a plain struct.
399    pub fn new(storage: Arc<dyn Storage>) -> Self {
400        Self {
401            storage,
402            dotfiles: Arc::new(DotfileAllowlist::from_env()),
403            body_cap: body_cap_from_env(),
404            nodeinfo: NodeInfoMeta::default(),
405            mashlib: MashlibConfig::default(),
406            mashlib_cdn: None,
407            live_reload: false,
408            pay_config: solid_pod_rs::payments::PayConfig::default(),
409            data_root: None,
410            quota: None,
411            pod_create_limiter: Arc::new(PodCreateLimiter::default()),
412            allowed_origins: Vec::new(),
413            admin_key: None,
414            mcp_enabled: false,
415            mempool_url: None,
416            conneg_enabled: false,
417            trust_proxy: false,
418            public_base_url: None,
419            allowed_hosts: Vec::new(),
420            cross_identity: None,
421            activitypub: None,
422        }
423    }
424}
425
426const LIVE_RELOAD_SCRIPT: &str = "<script>(function(){var ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/.notifications');ws.onopen=function(){ws.send('sub '+location.href)};ws.onmessage=function(e){if(e.data.startsWith('pub '))location.reload()};ws.onclose=function(){setTimeout(function(){location.reload()},1000)}})();</script>";
427
428fn inject_live_reload(html: impl AsRef<[u8]>, enabled: bool) -> Vec<u8> {
429    let bytes = html.as_ref();
430    if !enabled {
431        return bytes.to_vec();
432    }
433    let html = String::from_utf8_lossy(bytes);
434    if let Some(index) = html.find("</body>") {
435        let mut out = String::with_capacity(html.len() + LIVE_RELOAD_SCRIPT.len());
436        out.push_str(&html[..index]);
437        out.push_str(LIVE_RELOAD_SCRIPT);
438        out.push_str(&html[index..]);
439        out.into_bytes()
440    } else {
441        format!("{html}{LIVE_RELOAD_SCRIPT}").into_bytes()
442    }
443}
444
445#[cfg(test)]
446mod live_reload_tests {
447    use super::*;
448
449    #[test]
450    fn injects_before_body_close_and_is_disabled_by_default() {
451        let html = b"<html><body>ok</body></html>";
452        assert_eq!(inject_live_reload(html, false), html);
453        let injected = String::from_utf8(inject_live_reload(html, true)).unwrap();
454        assert!(injected.contains("/.notifications"));
455        assert!(injected.find("<script>").unwrap() < injected.find("</body>").unwrap());
456    }
457}
458
459#[cfg(all(test, feature = "dev-bearer"))]
460mod dev_bearer_tests {
461    use super::*;
462    use base64::Engine as _;
463    use hmac::{Hmac, Mac};
464    use sha2::Sha256;
465    use std::sync::Mutex;
466
467    static ENV: Mutex<()> = Mutex::new(());
468
469    fn now() -> u64 {
470        std::time::SystemTime::now()
471            .duration_since(std::time::UNIX_EPOCH)
472            .unwrap()
473            .as_secs()
474    }
475
476    fn token_with(iat: u64, exp: u64, aud: Option<&str>) -> String {
477        let mut claims = serde_json::json!({"webId":"https://alice.example/profile/card#me","iat":iat,"exp":exp});
478        if let Some(a) = aud {
479            claims["aud"] = serde_json::json!(a);
480        }
481        let data = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
482        let mut mac = Hmac::<Sha256>::new_from_slice(b"01234567890123456789012345678901").unwrap();
483        mac.update(data.as_bytes());
484        let sig =
485            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
486        format!("{data}.{sig}")
487    }
488
489    fn token(exp: u64) -> String {
490        token_with(now().saturating_sub(10), exp, Some(DEV_BEARER_AUDIENCE))
491    }
492
493    #[test]
494    fn simple_bearer_verifies_signature_and_expiry() {
495        let _guard = ENV.lock().unwrap();
496        std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
497        assert_eq!(
498            verify_dev_bearer(&token(now() + 3600)).as_deref(),
499            Some("https://alice.example/profile/card#me")
500        );
501        assert!(verify_dev_bearer(&(token(now() + 3600) + "x")).is_none());
502        assert!(verify_dev_bearer(&token(2)).is_none());
503        std::env::remove_var("TOKEN_SECRET");
504    }
505
506    #[test]
507    fn dev_bearer_enforces_audience_and_max_lifetime() {
508        let _guard = ENV.lock().unwrap();
509        std::env::set_var("TOKEN_SECRET", "01234567890123456789012345678901");
510        let n = now();
511        // Missing and wrong audience are refused.
512        assert!(verify_dev_bearer(&token_with(n, n + 60, None)).is_none());
513        assert!(verify_dev_bearer(&token_with(n, n + 60, Some("solid"))).is_none());
514        // Exactly 24 h is the ceiling; one second more is refused.
515        assert!(verify_dev_bearer(&token_with(
516            n,
517            n + DEV_BEARER_MAX_LIFETIME_SECS,
518            Some(DEV_BEARER_AUDIENCE)
519        ))
520        .is_some());
521        assert!(verify_dev_bearer(&token_with(
522            n,
523            n + DEV_BEARER_MAX_LIFETIME_SECS + 1,
524            Some(DEV_BEARER_AUDIENCE)
525        ))
526        .is_none());
527        // A back-dated iat cannot stretch the lifetime beyond 24 h from now.
528        assert!(verify_dev_bearer(&token_with(1, n + 3600, Some(DEV_BEARER_AUDIENCE))).is_none());
529        assert!(
530            verify_dev_bearer(&token_with(1, u64::MAX / 2, Some(DEV_BEARER_AUDIENCE))).is_none()
531        );
532        std::env::remove_var("TOKEN_SECRET");
533    }
534}
535
536/// In-process sliding-window limiter for JSS-compatible `POST /.pods`.
537#[derive(Debug)]
538pub struct PodCreateLimiter {
539    hits: Mutex<HashMap<IpAddr, Instant>>,
540    window: Duration,
541}
542
543impl Default for PodCreateLimiter {
544    fn default() -> Self {
545        Self {
546            hits: Mutex::new(HashMap::new()),
547            window: Duration::from_secs(24 * 60 * 60),
548        }
549    }
550}
551
552impl PodCreateLimiter {
553    fn check(&self, ip: IpAddr) -> Result<(), u64> {
554        let now = Instant::now();
555        let mut hits = self.hits.lock().unwrap();
556        if let Some(last) = hits.get(&ip).copied() {
557            let elapsed = now.saturating_duration_since(last);
558            if elapsed < self.window {
559                return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
560            }
561        }
562        hits.insert(ip, now);
563        Ok(())
564    }
565}
566
567// ---------------------------------------------------------------------------
568// Error translation
569// ---------------------------------------------------------------------------
570
571pub(crate) fn to_actix(e: PodError) -> ActixError {
572    match e {
573        PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
574        PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
575        PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
576        PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
577        PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
578        PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
579        _ => actix_web::error::ErrorInternalServerError(e.to_string()),
580    }
581}
582
583// ---------------------------------------------------------------------------
584// Auth helper — shared across handlers
585// ---------------------------------------------------------------------------
586
587/// Attempt NIP-98 bearer verification; returns the pubkey on success.
588///
589/// Runs the full structural + BIP-340 signature check and then a
590/// **single-use replay check** on the canonical event id: a token whose id
591/// was already seen within the replay window is rejected (returns `None`,
592/// i.e. treated as unauthenticated → the WAC gate denies with 401). This
593/// closes the ~120s replay window the stateless verifier leaves open.
594pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
595    extract_pubkey_with_body(req, None).await
596}
597
598/// NIP-98 verification for a request whose raw body is available.
599/// Supplying the bytes requires a signature-bound `payload` tag and prevents
600/// an otherwise valid token from being replayed with altered content.
601pub(crate) async fn extract_pubkey_with_body(
602    req: &HttpRequest,
603    body: Option<&[u8]>,
604) -> Option<String> {
605    let header_val = req
606        .headers()
607        .get(header::AUTHORIZATION)
608        .and_then(|v| v.to_str().ok())?;
609    if let Some(_token) = header_val.strip_prefix("Bearer ") {
610        // The home-made HMAC bearer exists only in `dev-bearer` builds.
611        // Production builds treat every `Bearer` header as unauthenticated.
612        #[cfg(feature = "dev-bearer")]
613        return verify_dev_bearer(_token);
614        #[cfg(not(feature = "dev-bearer"))]
615        return None;
616    }
617    // Reconstruct the request URL the NIP-98 event was signed over. The
618    // scheme must reflect the externally-visible scheme (honouring
619    // `X-Forwarded-Proto`, only behind `trust_proxy`) — a pod behind TLS
620    // or a federation reverse proxy is reached at `https://`, and the agent
621    // signs that URL. Hardcoding `http://` would break URL matching for
622    // every TLS-fronted deployment. Mirrors the base-URI construction used
623    // elsewhere in this file (see `conn.scheme()` call sites).
624    // Scope the actix `Ref<ConnectionInfo>` guard to a block so it is dropped
625    // before the `.await` below (clippy::await_holding_refcell_ref keys on
626    // lexical scope, not liveness, so an explicit `drop` does not suffice).
627    // Origin from configuration or the plain Host header, never from
628    // client-controlled forwarding headers (`scheme::request_url`).
629    let url = scheme::request_url(req);
630    let now = std::time::SystemTime::now()
631        .duration_since(std::time::UNIX_EPOCH)
632        .map(|d| d.as_secs())
633        .unwrap_or(0);
634    let verified = nip98::verify_at(header_val, &url, req.method().as_str(), body, now).ok()?;
635
636    // F3 replay guard: reject a re-presented token. The event id is the
637    // signature-bound single-use nonce; a hit means the same signed request
638    // was already accepted within the replay window. Fail closed.
639    if NIP98_REPLAY
640        .check_and_record(&verified.event_id)
641        .await
642        .is_err()
643    {
644        tracing::warn!(
645            pubkey = %verified.pubkey,
646            method = %req.method(),
647            "NIP-98 replay rejected: token id already used within window"
648        );
649        return None;
650    }
651
652    Some(verified.pubkey)
653}
654
655pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
656    pubkey.map(|pk| {
657        if pk.starts_with("https://") || pk.starts_with("http://") {
658            pk.clone()
659        } else {
660            format!("did:nostr:{pk}")
661        }
662    })
663}
664
665/// Audience a development bearer token must carry in its `aud` claim.
666#[cfg(feature = "dev-bearer")]
667pub const DEV_BEARER_AUDIENCE: &str = "solid-pod-rs-dev-bearer";
668
669/// Hard ceiling on a development bearer token's lifetime (24 h), applied to
670/// both `exp - iat` and `exp - now`.
671#[cfg(feature = "dev-bearer")]
672pub const DEV_BEARER_MAX_LIFETIME_SECS: u64 = 24 * 60 * 60;
673
674/// Verify the development-only HMAC bearer (`<b64url claims>.<b64url mac>`,
675/// HMAC-SHA256 keyed by `TOKEN_SECRET`, ≥ 32 bytes).
676///
677/// Compiled only with the `dev-bearer` cargo feature (off by default). Even
678/// then the token must carry `aud == DEV_BEARER_AUDIENCE` and live at most
679/// 24 h. There is no replay guard: it is a development convenience, not an
680/// authentication scheme for production.
681#[cfg(feature = "dev-bearer")]
682fn verify_dev_bearer(token: &str) -> Option<String> {
683    use base64::Engine as _;
684    use hmac::{Hmac, Mac};
685    use sha2::Sha256;
686
687    let secret = std::env::var("TOKEN_SECRET").ok()?;
688    if secret.len() < 32 {
689        tracing::warn!("TOKEN_SECRET shorter than 32 bytes; refusing development bearer token");
690        return None;
691    }
692    let (data, signature) = token.split_once('.')?;
693    if signature.contains('.') {
694        return None;
695    }
696    let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD
697        .decode(signature)
698        .ok()?;
699    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
700    mac.update(data.as_bytes());
701    mac.verify_slice(&signature).ok()?;
702
703    #[derive(serde::Deserialize)]
704    #[serde(rename_all = "camelCase")]
705    struct Claims {
706        web_id: String,
707        iat: u64,
708        exp: u64,
709        #[serde(default)]
710        aud: Option<String>,
711    }
712    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
713        .decode(data)
714        .ok()?;
715    let claims: Claims = serde_json::from_slice(&payload).ok()?;
716    let now = std::time::SystemTime::now()
717        .duration_since(std::time::UNIX_EPOCH)
718        .ok()?
719        .as_secs();
720    if claims.aud.as_deref() != Some(DEV_BEARER_AUDIENCE) {
721        return None;
722    }
723    if claims.exp < now || claims.iat > now.saturating_add(60) || claims.exp <= claims.iat {
724        return None;
725    }
726    if claims.exp - claims.iat > DEV_BEARER_MAX_LIFETIME_SECS
727        || claims.exp - now > DEV_BEARER_MAX_LIFETIME_SECS
728    {
729        return None;
730    }
731    let url = url::Url::parse(&claims.web_id).ok()?;
732    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
733        return None;
734    }
735    Some(claims.web_id)
736}
737
738/// The request `Origin` header value, if present and valid UTF-8.
739///
740/// Threaded into the WAC `acl:origin` gate (F4) via the `enforce_*_ctx`
741/// paths so an ACL that declares `acl:origin` triples can restrict
742/// cross-origin access (CSRF defence). Requests without an `Origin` header
743/// (server-to-server, git smart-protocol, curl) yield `None`, which the
744/// evaluator rejects only for resources whose ACL explicitly restricts
745/// origins — plain ACLs (no `acl:origin`) are unaffected.
746fn req_origin(req: &HttpRequest) -> Option<&str> {
747    req.headers()
748        .get(header::ORIGIN)
749        .and_then(|v| v.to_str().ok())
750}
751
752/// Canonical pod-relative path of the Web Ledger document. The
753/// `acl:PaymentCondition` evaluator is fed the requesting principal's
754/// satoshi balance read from this resource.
755pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
756
757/// Resolve the requesting principal's satoshi balance from the pod's
758/// Web Ledger so the WAC `acl:PaymentCondition` evaluator receives a
759/// concrete value instead of `None`.
760///
761/// Returns:
762/// * `None` when there is no authenticated principal (anonymous request)
763///   — a `PaymentCondition` then fails closed (402/403);
764/// * `Some(0)` when the principal is authenticated but has no ledger
765///   entry (or no ledger exists yet) — sufficient to satisfy only a
766///   zero-cost condition;
767/// * `Some(balance)` resolved from the ledger entry keyed by the
768///   principal's `did:nostr` URI otherwise.
769///
770/// The lookup is keyed by the authenticated principal's WebID, which for
771/// a NIP-98 caller is `did:nostr:<hex-pubkey>` — the same key the
772/// `/pay/.deposit` credit path writes into the ledger.
773async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
774    let did = agent_uri?;
775    let balance = match storage.get(WEBLEDGER_PATH).await {
776        Ok((bytes, _meta)) => {
777            match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
778                Ok(ledger) => ledger.get_balance(did),
779                // A malformed ledger document must not crash the auth
780                // path; treat it as an empty balance (fail-closed for
781                // any non-zero PaymentCondition).
782                Err(_) => 0,
783            }
784        }
785        // No ledger provisioned yet: authenticated principal with zero
786        // balance.
787        Err(_) => 0,
788    };
789    Some(balance)
790}
791
792/// Return `true` when the `Accept` header includes `text/html`.
793///
794/// Used for container `index.html` content negotiation: if a browser
795/// requests `text/html` on a container URL and that container contains
796/// an `index.html` resource, the server serves the HTML file instead of
797/// the RDF container listing. Solid clients that send `Accept: text/turtle`
798/// or `application/ld+json` skip this path entirely.
799fn accept_includes_html(accept: &str) -> bool {
800    accept.split(',').any(|entry| {
801        let mime = entry.split(';').next().unwrap_or("").trim();
802        mime.eq_ignore_ascii_case("text/html")
803    })
804}
805
806// ---------------------------------------------------------------------------
807// WAC enforcement for writes (PUT / POST / PATCH / DELETE)
808// ---------------------------------------------------------------------------
809
810// `protected_resource_for_acl` and the sidecar-elevation decision now live
811// in wasm-safe core (`solid_pod_rs::wac::{protected_resource_for_acl,
812// effective_acl_target}`) so this server, the CF-Workers pod, and any
813// downstream consumer share one policy. Imported above via the `wac::{…}`
814// use; the lockout-guard call sites below and the enforcement functions
815// both route through it.
816
817/// [`effective_acl_target`] plus the server-managed-state rules. A trail file
818/// holding an issuer private key (`trail_store::is_secret_trail_path`), the
819/// `/.well-known/prov/` state, the `/.well-known/webledgers/` payment
820/// documents (a forged ledger entry is spendable) are evaluated as `acl:Control`
821/// on themselves, so only the pod's Control holder can read or overwrite them.
822/// An `acl:Read` or `acl:Write` grant, including a public one on the pod root,
823/// is not enough to reach them over HTTP.
824pub(crate) fn guarded_acl_target(path: &str, mode: AccessMode) -> (String, AccessMode) {
825    let (resource, mode) = effective_acl_target(path, mode);
826    // Judge the protected-path rules on the canonical spelling, whatever the
827    // caller handed in; a path that cannot be canonicalised is held to the
828    // strictest mode rather than waved through.
829    match canonical_pod_path(&resource) {
830        Some(canon)
831            if !trail_store::is_secret_trail_path(&canon)
832                && !trail_store::is_prov_state_path(&canon)
833                && !trail_store::is_server_state_path(&canon) =>
834        {
835            (resource, mode)
836        }
837        _ => (resource, AccessMode::Control),
838    }
839}
840
841/// The one canonical spelling of a pod path, or `None` to reject it.
842///
843/// Percent-decodes exactly once, rejects `.` and `..` segments, backslashes,
844/// NUL and invalid UTF-8, collapses empty segments (`//`), forces a leading
845/// `/` and keeps a trailing `/`. The permission check and the storage call
846/// must both use this output, never the caller's spelling, so that a check
847/// and a read cannot see different files.
848pub(crate) fn canonical_pod_path(raw: &str) -> Option<String> {
849    let decoded = percent_decode_str(raw).decode_utf8().ok()?;
850    if decoded.contains(['\\', '\0']) {
851        return None;
852    }
853    let mut out = String::with_capacity(decoded.len() + 1);
854    for seg in decoded.split('/') {
855        match seg {
856            "" => {}
857            "." | ".." => return None,
858            _ => {
859                out.push('/');
860                out.push_str(seg);
861            }
862        }
863    }
864    if out.is_empty() || decoded.ends_with('/') {
865        out.push('/');
866    }
867    Some(out)
868}
869
870/// P0-2 lockout guard (mirrors `mcp/tools.rs:511-552`). Parse a proposed
871/// `.acl` document body and confirm at least one authorization still
872/// grants `acl:Control` to `caller` (by exact WebID, `foaf:Agent`, or —
873/// for an authenticated caller — `acl:AuthenticatedAgent`). Returns
874/// `true` when the proposed ACL is unparseable (the storage layer will
875/// reject malformed bodies; the guard only fires on a parseable ACL that
876/// would strip the caller's Control) or when Control is preserved.
877fn proposed_acl_keeps_caller_control(
878    body: &[u8],
879    content_type: &str,
880    caller: Option<&str>,
881) -> bool {
882    let doc = match parse_jsonld_acl(body) {
883        Ok(d) => Some(d),
884        Err(_) => {
885            let ct = content_type.to_ascii_lowercase();
886            let text = std::str::from_utf8(body).unwrap_or("");
887            let looks_turtle = ct.starts_with("text/turtle")
888                || ct.starts_with("application/turtle")
889                || ct.starts_with("application/x-turtle")
890                || ct.starts_with("application/n-triples")
891                || text.contains("@prefix")
892                || text.contains("acl:Authorization")
893                // N-Triples ACL bodies (e.g. the post-PATCH write-back form)
894                // carry no `@prefix`/`acl:` shorthand — they use the full
895                // ACL IRI. Recognise that so the lockout guard can parse them.
896                || text.contains("auth/acl#Authorization");
897            if looks_turtle {
898                parse_turtle_acl(text).ok()
899            } else {
900                None
901            }
902        }
903    };
904    let Some(doc) = doc else {
905        // Unparseable as an ACL — not our concern; let storage reject it.
906        return true;
907    };
908    let Some(graph) = doc.graph.as_ref() else {
909        return false;
910    };
911    graph.iter().any(|auth| {
912        let grants_control = ids_of_acl_field(&auth.mode)
913            .iter()
914            .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
915        if !grants_control {
916            return false;
917        }
918        let agents = ids_of_acl_field(&auth.agent);
919        if let Some(web_id) = caller {
920            if agents.contains(&web_id) {
921                return true;
922            }
923        }
924        let classes = ids_of_acl_field(&auth.agent_class);
925        if classes
926            .iter()
927            .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
928        {
929            return true;
930        }
931        if caller.is_some()
932            && classes.iter().any(|c| {
933                *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
934                    || *c == "acl:AuthenticatedAgent"
935            })
936        {
937            return true;
938        }
939        false
940    })
941}
942
943/// Flatten an optional `IdOrIds` ACL field into a `Vec<&str>` of IRIs.
944fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
945    match field {
946        None => Vec::new(),
947        Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
948        Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
949    }
950}
951
952/// Origin-unaware convenience wrapper (used where no HTTP `Origin` is
953/// available — internal callers and unit tests). Delegates with
954/// `request_origin = None`; under `acl-origin` this denies only ACLs that
955/// explicitly restrict by origin, leaving plain ACLs (NoPolicySet) intact.
956/// Live request handlers call [`enforce_write_ctx`] directly, so in a
957/// non-test build this wrapper is exercised only by the test suite.
958#[cfg_attr(not(test), allow(dead_code))]
959async fn enforce_write(
960    state: &AppState,
961    path: &str,
962    mode: AccessMode,
963    agent_uri: Option<&str>,
964) -> Result<(), ActixError> {
965    enforce_write_ctx(state, path, mode, agent_uri, None).await
966}
967
968/// WAC write enforcement threading the request `Origin` (F4 `acl:origin`).
969///
970/// `request_origin` is the raw HTTP `Origin` header; it is parsed to a
971/// canonical [`wac::Origin`] and passed to the evaluator so an ACL bearing
972/// `acl:origin` triples gates cross-origin writes. `acl:Control` (the
973/// sidecar path below) bypasses the origin gate by design so an owner can
974/// always repair a mis-configured ACL from any origin.
975async fn enforce_write_ctx(
976    state: &AppState,
977    path: &str,
978    mode: AccessMode,
979    agent_uri: Option<&str>,
980    request_origin: Option<&str>,
981) -> Result<(), ActixError> {
982    let origin = request_origin.and_then(wac::Origin::parse);
983    // P0-2 / P0-4: an `.acl`/`.meta` sidecar governs *another* resource's
984    // permissions. Authorising its mutation as plain `Write`/`Append` on
985    // the sidecar path lets any writer rewrite the ACL and self-escalate
986    // (privilege escalation). WAC §4.3.5 requires `acl:Control` on the
987    // PROTECTED resource. That elevation is no longer hand-rolled here:
988    // `wac::effective_acl_target` is the single source of truth, shared
989    // with the read path (`enforce_read_ctx`) and any wasm runtime. For an
990    // ordinary resource it returns `(path, mode)` unchanged; for a sidecar
991    // it returns `(protected_resource, Control)`. The lockout guard
992    // (`proposed_acl_keeps_caller_control`) still runs at the PUT/POST/PATCH
993    // handler call sites, keyed on the same `protected_resource_for_acl`.
994    let (resource, eff_mode) = guarded_acl_target(path, mode);
995
996    // `StorageAclResolver` is generic over a concrete backend. `state`
997    // holds an `Arc<dyn Storage>`; `find_effective_acl_dyn` wraps it in a
998    // trait-object-friendly adapter so the resolver runs against the
999    // effective resource (the sidecar's governed resource, or `path`).
1000    // ADR-2005: resolve with typed outcomes. A missing policy inherits (the
1001    // walk already did that); an invalid or unreadable one denies here and is
1002    // never allowed to fall through to a broader ancestor grant.
1003    let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1004    if outcome.is_failure() {
1005        return Err(policy_failure_to_actix(&outcome, &resource));
1006    }
1007    let acl_doc = outcome.document().cloned();
1008
1009    // Resolve the principal's satoshi balance from the Web Ledger so a
1010    // sat-priced resource (`acl:PaymentCondition`) is actually gated.
1011    // `None` only for anonymous callers (no `did:nostr` principal), in
1012    // which case any PaymentCondition fails closed.
1013    let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1014
1015    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1016    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1017    // Row 60: a did:nostr caller with a verified WebID link presents as
1018    // both, so an ACL naming either matches.
1019    let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1020    let Some(ctx) = grant_for_identities(
1021        acl_doc.as_ref(),
1022        agent_uri,
1023        &linked,
1024        payment_balance_sats,
1025        &resource,
1026        eff_mode,
1027        origin.as_ref(),
1028        &groups,
1029        &registry,
1030    ) else {
1031        return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1032    };
1033    // Sat-gating consumption applies only to a non-elevated (ordinary)
1034    // write. A sidecar elevation demands `acl:Control` on the protected
1035    // resource — the owner-repair path — and is never itself a metered
1036    // write, matching the pre-unification behaviour where the Control
1037    // pre-check returned `Ok` without a Web-Ledger debit. `resource ==
1038    // path` iff `effective_acl_target` did NOT elevate a sidecar.
1039    if resource.as_str() == path {
1040        // A granted write whose authorising rule carried an
1041        // `acl:PaymentCondition` debits the caller's Web Ledger by the
1042        // matched rule's cost. The WAC gate above already proved `balance
1043        // >= cost`, so a debit failure can only mean a concurrent spend
1044        // raced the balance below cost — fail closed, never serve unpaid.
1045        charge_granted_payment(
1046            state,
1047            acl_doc.as_ref(),
1048            &ctx,
1049            &resource,
1050            eff_mode,
1051            &groups,
1052            &registry,
1053            agent_uri,
1054        )
1055        .await?;
1056    }
1057    Ok(())
1058}
1059
1060/// Apply the `acl:PaymentCondition` debit for a request the WAC gate has
1061/// already granted. Computes the cost of the single granting rule via
1062/// [`wac::granted_payment_cost`] and, when that cost is non-zero and the
1063/// caller is an authenticated principal, debits their Web Ledger exactly
1064/// once. A zero cost (no PaymentCondition on the granting rule) is a
1065/// no-op. A debit failure (insufficient balance after a concurrent
1066/// spend, or ledger I/O error) is surfaced as the same WAC denial the
1067/// caller would have received, so the request is never served unpaid.
1068#[allow(clippy::too_many_arguments)]
1069async fn charge_granted_payment(
1070    state: &AppState,
1071    acl_doc: Option<&wac::AclDocument>,
1072    ctx: &RequestContext<'_>,
1073    path: &str,
1074    mode: AccessMode,
1075    groups: &wac::StaticGroupMembership,
1076    registry: &wac::conditions::ConditionRegistry,
1077    payer: Option<&str>,
1078) -> Result<(), ActixError> {
1079    let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
1080    if cost == 0 {
1081        return Ok(());
1082    }
1083    // The ledger is keyed by the authenticated principal (`payer`), which is
1084    // the `did:nostr` agent even when a linked WebID is what the ACL granted.
1085    if let Some(did) = payer {
1086        if debit_ledger(&*state.storage, did, cost).await.is_err() {
1087            return Err(acl_denial(acl_doc, payer, path));
1088        }
1089    }
1090    Ok(())
1091}
1092
1093/// Evaluate WAC for the principal and each identity linked to it (row 60),
1094/// returning the request context of the first identity the ACL grants.
1095///
1096/// Evaluating identities one at a time (rather than widening a single
1097/// agent match) keeps every ACL rule, `acl:origin` gate and payment
1098/// condition evaluated exactly as for an ordinary single-identity caller.
1099#[allow(clippy::too_many_arguments)]
1100pub(crate) fn grant_for_identities<'a>(
1101    acl_doc: Option<&wac::AclDocument>,
1102    primary: Option<&'a str>,
1103    linked: &'a [String],
1104    payment_balance_sats: Option<u64>,
1105    resource: &str,
1106    mode: AccessMode,
1107    origin: Option<&wac::Origin>,
1108    groups: &wac::StaticGroupMembership,
1109    registry: &wac::conditions::ConditionRegistry,
1110) -> Option<RequestContext<'a>> {
1111    std::iter::once(primary)
1112        .chain(linked.iter().map(|s| Some(s.as_str())))
1113        .map(|web_id| RequestContext {
1114            web_id,
1115            client_id: None,
1116            issuer: None,
1117            payment_balance_sats,
1118        })
1119        .find(|ctx| {
1120            wac::evaluate_access_ctx_with_registry(
1121                acl_doc, ctx, resource, mode, origin, groups, registry,
1122            )
1123        })
1124}
1125
1126/// Build the WAC denial `actix_web::Error` shared by the read and write
1127/// enforcement paths: `401` (with a `WWW-Authenticate` challenge) for an
1128/// unauthenticated caller so a retry with credentials is signalled, or
1129/// `403` for an authenticated caller the ACL does not grant. Both carry
1130/// the advisory `WAC-Allow` header describing the effective permissions.
1131fn acl_denial(
1132    acl_doc: Option<&wac::AclDocument>,
1133    agent_uri: Option<&str>,
1134    path: &str,
1135) -> ActixError {
1136    let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
1137    let (status, body, unauthenticated) = if agent_uri.is_none() {
1138        (StatusCode::UNAUTHORIZED, "authentication required", true)
1139    } else {
1140        (StatusCode::FORBIDDEN, "access forbidden", false)
1141    };
1142    let mut rsp = HttpResponse::new(status);
1143    rsp.headers_mut().insert(
1144        header::HeaderName::from_static("wac-allow"),
1145        header::HeaderValue::from_str(&allow_header)
1146            .unwrap_or(header::HeaderValue::from_static("")),
1147    );
1148    if unauthenticated {
1149        // Advertise every auth scheme the pod accepts so an
1150        // unauthenticated agent knows how to retry. `extract_pubkey` verifies
1151        // NIP-98 (`Authorization: Nostr <base64(kind-27235 event)>`), which is
1152        // how a `did:nostr` agent authenticates against the pod — without the
1153        // `Nostr` challenge an agent has no protocol signal that NIP-98 is
1154        // accepted. DPoP/Bearer remain advertised for OIDC/DPoP clients.
1155        rsp.headers_mut().insert(
1156            header::WWW_AUTHENTICATE,
1157            header::HeaderValue::from_static(
1158                "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
1159            ),
1160        );
1161    }
1162    actix_web::error::InternalError::from_response(body, rsp).into()
1163}
1164
1165/// P0-1: WAC `acl:Read` enforcement for GET / HEAD / container listing.
1166///
1167/// Mirror of [`enforce_write`] for the `Read` mode. Before this guard the
1168/// GET path resolved an advisory `WAC-Allow` header but returned the
1169/// resource body verbatim with no read-authz check, so every private
1170/// resource was world-readable. Returns `Ok(())` on grant; on deny a
1171/// `401`/`403` matching the write path's denial shape.
1172/// Origin-unaware convenience wrapper (see [`enforce_write`]). Delegates
1173/// with `request_origin = None`. Live handlers call [`enforce_read_ctx`]
1174/// directly, so in a non-test build this is exercised only by tests.
1175#[cfg_attr(not(test), allow(dead_code))]
1176async fn enforce_read(
1177    state: &AppState,
1178    path: &str,
1179    agent_uri: Option<&str>,
1180) -> Result<ResponseAudience, ActixError> {
1181    enforce_read_ctx(state, path, agent_uri, None).await
1182}
1183
1184/// WAC read enforcement threading the request `Origin` (F4 `acl:origin`).
1185/// See [`enforce_write_ctx`] for the origin-gate semantics.
1186async fn enforce_read_ctx(
1187    state: &AppState,
1188    path: &str,
1189    agent_uri: Option<&str>,
1190    request_origin: Option<&str>,
1191) -> Result<ResponseAudience, ActixError> {
1192    enforce_read_inner(state, path, agent_uri, request_origin, true).await
1193}
1194
1195/// [`enforce_read_ctx`] with the payment step selectable. With
1196/// `charge == false` a read whose authorising rule carries an
1197/// `acl:PaymentCondition` is refused instead of debited, for callers that
1198/// would otherwise bill one request many times (glob GET).
1199async fn enforce_read_inner(
1200    state: &AppState,
1201    path: &str,
1202    agent_uri: Option<&str>,
1203    request_origin: Option<&str>,
1204    charge: bool,
1205) -> Result<ResponseAudience, ActixError> {
1206    let origin = request_origin.and_then(wac::Origin::parse);
1207    // P0-3: reading an `.acl`/`.meta` sidecar discloses the full
1208    // authorization graph of the resource it governs — every WebID,
1209    // `acl:agentGroup` IRI, `acl:origin`, and `acl:PaymentCondition`
1210    // amount. WAC §4.3.5 requires `acl:Control` on the PROTECTED resource
1211    // to read (as well as write) its ACL — the invariant a downstream
1212    // forum previously got wrong on the READ side. This elevation is now
1213    // the SAME shared `wac::effective_acl_target` the write path uses
1214    // (base mode `Read`), so read and write can no longer drift: an
1215    // ordinary resource stays `(path, Read)`; a sidecar becomes
1216    // `(protected_resource, Control)`, gating ACL disclosure on Control
1217    // exactly as JSS does (`auth/middleware.js:93`).
1218    let (resource, eff_mode) = guarded_acl_target(path, AccessMode::Read);
1219    // ADR-2005: see `enforce_write_ctx` — invalid/unavailable policy denies
1220    // rather than inheriting an ancestor's broader grant.
1221    let outcome = resolve_policy_dyn(&*state.storage, &resource).await;
1222    if outcome.is_failure() {
1223        return Err(policy_failure_to_actix(&outcome, &resource));
1224    }
1225    let acl_doc = outcome.document().cloned();
1226    let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
1227    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
1228    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
1229    let linked = cross_identity::linked_identities(state.cross_identity.as_ref(), agent_uri).await;
1230    let Some(ctx) = grant_for_identities(
1231        acl_doc.as_ref(),
1232        agent_uri,
1233        &linked,
1234        payment_balance_sats,
1235        &resource,
1236        eff_mode,
1237        origin.as_ref(),
1238        &groups,
1239        &registry,
1240    ) else {
1241        return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1242    };
1243    // Sat-gating consumption applies only to a non-elevated (ordinary)
1244    // read; a sidecar Control elevation is never metered (see
1245    // `enforce_write_ctx`). `resource == path` iff no elevation happened.
1246    if resource.as_str() == path && !charge {
1247        if wac::granted_payment_cost(
1248            acl_doc.as_ref(),
1249            &ctx,
1250            &resource,
1251            eff_mode,
1252            &groups,
1253            &registry,
1254        ) > 0
1255        {
1256            return Err(acl_denial(acl_doc.as_ref(), agent_uri, &resource));
1257        }
1258    } else if resource.as_str() == path {
1259        // A granted read whose authorising rule carried an
1260        // `acl:PaymentCondition` debits the caller's Web Ledger by the
1261        // matched rule's cost (fail-closed on a raced balance). See
1262        // `charge_granted_payment`.
1263        charge_granted_payment(
1264            state,
1265            acl_doc.as_ref(),
1266            &ctx,
1267            &resource,
1268            eff_mode,
1269            &groups,
1270            &registry,
1271            agent_uri,
1272        )
1273        .await?;
1274    }
1275    // ADR-2002: classify the response's audience so the caller can mark it
1276    // uncacheable. The question is not "what media type is this?" but "could
1277    // an anonymous client have received the same body?" — so re-evaluate the
1278    // SAME already-resolved ACL with no principal. Pure; no extra I/O.
1279    //
1280    // A sidecar read (elevated to Control) is never public regardless of what
1281    // the ACL says, and a payment-gated read fails the anonymous evaluation
1282    // (no balance), so both correctly classify as private.
1283    let anon_ctx = RequestContext {
1284        web_id: None,
1285        client_id: None,
1286        issuer: None,
1287        payment_balance_sats: None,
1288    };
1289    let anonymous_would_be_granted = wac::evaluate_access_ctx_with_registry(
1290        acl_doc.as_ref(),
1291        &anon_ctx,
1292        &resource,
1293        eff_mode,
1294        origin.as_ref(),
1295        &groups,
1296        &registry,
1297    );
1298    Ok(if anonymous_would_be_granted && resource.as_str() == path {
1299        ResponseAudience::Public
1300    } else {
1301        ResponseAudience::Private
1302    })
1303}
1304
1305/// Debit `cost` satoshis from `did`'s Web Ledger entry and persist the
1306/// updated ledger document, deducting exactly once for a granted
1307/// payment-gated request.
1308///
1309/// Reads [`WEBLEDGER_PATH`], applies [`WebLedger::charge`] (which fails
1310/// closed on an insufficient or missing balance), and writes the ledger
1311/// back. A read, debit, or write failure returns `Err` so the caller can
1312/// deny the request rather than serve it unpaid.
1313async fn debit_ledger(
1314    storage: &dyn Storage,
1315    did: &str,
1316    cost: u64,
1317) -> Result<(), solid_pod_rs::payments::PaymentError> {
1318    use solid_pod_rs::payments::{PaymentError, WebLedger};
1319    let _transaction = PAYMENT_STATE_LOCK.lock().await;
1320
1321    let (bytes, _meta) = storage
1322        .get(WEBLEDGER_PATH)
1323        .await
1324        .map_err(|e| PaymentError::Store(e.to_string()))?;
1325    let mut ledger: WebLedger = serde_json::from_slice(&bytes)
1326        .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
1327    // The access fee is spent, not refunded: the request is served.
1328    let _charge = ledger.charge(did, cost)?;
1329    let body = serde_json::to_vec(&ledger)
1330        .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
1331    storage
1332        .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
1333        .await
1334        .map_err(|e| PaymentError::Store(e.to_string()))?;
1335    Ok(())
1336}
1337
1338// ---------------------------------------------------------------------------
1339// Handlers
1340// ---------------------------------------------------------------------------
1341
1342fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
1343    let links = ldp::link_headers(path).join(", ");
1344    if let Ok(value) = header::HeaderValue::from_str(&links) {
1345        rsp.headers_mut()
1346            .insert(header::HeaderName::from_static("link"), value);
1347    }
1348}
1349
1350fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
1351    if let Ok(v) = header::HeaderValue::from_str(header_value) {
1352        rsp.headers_mut()
1353            .insert(header::HeaderName::from_static("wac-allow"), v);
1354    }
1355}
1356
1357fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
1358    let ws_base = base_url
1359        .replacen("https://", "wss://", 1)
1360        .replacen("http://", "ws://", 1);
1361    let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
1362    if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
1363        rsp.headers_mut()
1364            .insert(header::HeaderName::from_static("updates-via"), v);
1365    }
1366}
1367
1368/// Surface a [`ProvenanceReceipt`] to the caller on the write's response
1369/// (ADR-2004).
1370///
1371/// The LDP write has already succeeded, so the status code must not change —
1372/// but the client is entitled to know which provenance tier its write actually
1373/// reached. `X-Provenance` carries the stage (`resource-stored`,
1374/// `local-mark-committed`, `anchor-submitted`, `anchor-confirmed`, `skipped`)
1375/// plus any failure detail; `X-Provenance-Commit` carries the git SHA when one
1376/// exists. A failed mark is therefore visible in the response rather than only
1377/// in the server log.
1378fn set_provenance_headers(rsp: &mut HttpResponse, receipt: &ProvenanceReceipt) {
1379    if let Ok(v) = header::HeaderValue::from_str(&receipt.summary()) {
1380        rsp.headers_mut()
1381            .insert(header::HeaderName::from_static("x-provenance"), v);
1382    }
1383    if let Some(sha) = receipt.commit_sha() {
1384        if let Ok(v) = header::HeaderValue::from_str(sha) {
1385            rsp.headers_mut()
1386                .insert(header::HeaderName::from_static("x-provenance-commit"), v);
1387        }
1388    }
1389}
1390
1391/// Apply the ADR-2002 cache policy to a response.
1392///
1393/// A response that an anonymous client would not have received is marked
1394/// `private, no-store` so no shared cache may store or re-serve it, and
1395/// `Vary: Authorization` is set so a cache keyed on the request cannot fuse
1396/// an authenticated response with an anonymous one. Public responses keep the
1397/// media-type policy (RDF gets `private, no-cache, must-revalidate`; public
1398/// binaries are left to ordinary caching).
1399///
1400/// Never overwrites a `Cache-Control` a handler has already set deliberately
1401/// (the mashlib HTML wrapper sets `no-store` of its own).
1402fn set_cache_policy(rsp: &mut HttpResponse, content_type: &str, audience: ResponseAudience) {
1403    if audience == ResponseAudience::Private {
1404        // Vary matters even when the body is uncacheable: an intermediary
1405        // that ignores `no-store` must at least not key this response for an
1406        // anonymous requester.
1407        append_vary(rsp, "Authorization");
1408    }
1409    if rsp.headers().contains_key(header::CACHE_CONTROL) {
1410        return;
1411    }
1412    if let Some(value) = cache_control_for_response(content_type, audience) {
1413        rsp.headers_mut().insert(
1414            header::CACHE_CONTROL,
1415            header::HeaderValue::from_static(value),
1416        );
1417    }
1418}
1419
1420/// Add `field` to the response's `Vary` header, preserving any existing value.
1421fn append_vary(rsp: &mut HttpResponse, field: &str) {
1422    let existing = rsp
1423        .headers()
1424        .get(header::VARY)
1425        .and_then(|v| v.to_str().ok())
1426        .unwrap_or("")
1427        .to_string();
1428    if existing
1429        .split(',')
1430        .any(|f| f.trim().eq_ignore_ascii_case(field))
1431    {
1432        return;
1433    }
1434    let merged = if existing.trim().is_empty() {
1435        field.to_string()
1436    } else {
1437        format!("{existing}, {field}")
1438    };
1439    if let Ok(v) = header::HeaderValue::from_str(&merged) {
1440        rsp.headers_mut().insert(header::VARY, v);
1441    }
1442}
1443
1444async fn handle_get(
1445    req: HttpRequest,
1446    state: web::Data<AppState>,
1447) -> Result<HttpResponse, ActixError> {
1448    let path = req.uri().path().to_string();
1449
1450    // JSS server.js 545-570: the dedicated `/profile/card.jsonld` route
1451    // answers ActivityPub-flavoured Accept headers with the actor document
1452    // and defers to the normal resource handler otherwise. Public, like
1453    // the JSS route.
1454    #[cfg(feature = "activitypub")]
1455    if let (Some(ap), "/profile/card.jsonld") = (state.activitypub.as_ref(), path.as_str()) {
1456        let accept = req
1457            .headers()
1458            .get(header::ACCEPT)
1459            .and_then(|v| v.to_str().ok())
1460            .unwrap_or("");
1461        if solid_pod_rs_activitypub::negotiate_actor_format(accept)
1462            == solid_pod_rs_activitypub::ActorFormat::ActivityJson
1463        {
1464            let doc = ap.actor_json(&scheme::request_origin(&req));
1465            return Ok(HttpResponse::Ok()
1466                .content_type("application/activity+json")
1467                .insert_header((header::VARY, "Accept"))
1468                .json(doc));
1469        }
1470    }
1471
1472    if path.contains('*') {
1473        return handle_glob_get(req, state).await;
1474    }
1475
1476    let auth_pk = extract_pubkey(&req).await;
1477    let agent = agent_uri(auth_pk.as_ref());
1478
1479    // P0-1: enforce WAC `acl:Read` before serving any bytes. This guards
1480    // both resource GETs and the RDF container listing below, and — since
1481    // HEAD is routed to this same handler — HEAD requests too. Without it
1482    // a private resource is world-readable.
1483    let audience = enforce_read_ctx(&state, &path, agent.as_deref(), req_origin(&req)).await?;
1484
1485    let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
1486
1487    if ldp::is_container(&path) {
1488        let accept = req
1489            .headers()
1490            .get(header::ACCEPT)
1491            .and_then(|v| v.to_str().ok())
1492            .unwrap_or("");
1493
1494        // Content negotiation: when a browser requests text/html, check
1495        // whether the container has an index.html child resource. If so,
1496        // serve it directly instead of the RDF container listing. This is
1497        // standard HTTP content negotiation — browsers get HTML, Solid
1498        // clients get RDF.
1499        if accept_includes_html(accept) {
1500            let index_path = format!("{path}index.html");
1501            // The child has its own ACL: Read on the container does not entitle
1502            // the caller to it. On denial fall through to the listing.
1503            let index_audience =
1504                enforce_read_ctx(&state, &index_path, agent.as_deref(), req_origin(&req))
1505                    .await
1506                    .ok();
1507            if let (Some(index_audience), Ok((body, _meta))) =
1508                (index_audience, state.storage.get(&index_path).await)
1509            {
1510                let mut rsp = HttpResponse::Ok()
1511                    .content_type("text/html; charset=utf-8")
1512                    .body(inject_live_reload(&body, state.live_reload));
1513                set_cache_policy(&mut rsp, "text/html", index_audience);
1514                set_wac_allow(&mut rsp, &wac_allow);
1515                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1516                set_link_headers(&mut rsp, &path);
1517                return Ok(rsp);
1518            }
1519        }
1520
1521        let v = state
1522            .storage
1523            .container_representation(&path)
1524            .await
1525            .map_err(to_actix)?;
1526
1527        // Mashlib: serve HTML wrapper for browser navigation.
1528        let sec_fetch_dest = req
1529            .headers()
1530            .get("sec-fetch-dest")
1531            .and_then(|v| v.to_str().ok());
1532        if mashlib::should_serve(
1533            accept,
1534            sec_fetch_dest,
1535            "application/ld+json",
1536            state.mashlib.enabled,
1537        ) {
1538            let json_ld = serde_json::to_string(&v).ok();
1539            let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
1540            let mut rsp = HttpResponse::Ok()
1541                .content_type("text/html; charset=utf-8")
1542                .insert_header(("X-Frame-Options", "DENY"))
1543                .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1544                .insert_header(("Cache-Control", "no-store"))
1545                .body(inject_live_reload(html, state.live_reload));
1546            set_cache_policy(&mut rsp, "text/html", audience);
1547            set_wac_allow(&mut rsp, &wac_allow);
1548            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1549            set_link_headers(&mut rsp, &path);
1550            return Ok(rsp);
1551        }
1552
1553        let mut rsp = HttpResponse::Ok().json(v);
1554        rsp.headers_mut().insert(
1555            header::CONTENT_TYPE,
1556            header::HeaderValue::from_static("application/ld+json"),
1557        );
1558        set_cache_policy(&mut rsp, "application/ld+json", audience);
1559        set_wac_allow(&mut rsp, &wac_allow);
1560        set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1561        set_link_headers(&mut rsp, &path);
1562        return Ok(rsp);
1563    }
1564
1565    match state.storage.get(&path).await {
1566        Ok((body, meta)) => {
1567            // Mashlib: serve HTML wrapper for browser navigation to RDF resources.
1568            let accept = req
1569                .headers()
1570                .get(header::ACCEPT)
1571                .and_then(|v| v.to_str().ok())
1572                .unwrap_or("");
1573            let sec_fetch_dest = req
1574                .headers()
1575                .get("sec-fetch-dest")
1576                .and_then(|v| v.to_str().ok());
1577            if mashlib::should_serve(
1578                accept,
1579                sec_fetch_dest,
1580                &meta.content_type,
1581                state.mashlib.enabled,
1582            ) {
1583                let embed = if body.len() <= state.mashlib.data_island_max_bytes {
1584                    std::str::from_utf8(&body).ok().map(|s| s.to_string())
1585                } else {
1586                    None
1587                };
1588                let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
1589                let mut rsp = HttpResponse::Ok()
1590                    .content_type("text/html; charset=utf-8")
1591                    .insert_header(("X-Frame-Options", "DENY"))
1592                    .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
1593                    .insert_header(("Cache-Control", "no-store"))
1594                    .body(inject_live_reload(html, state.live_reload));
1595                set_cache_policy(&mut rsp, "text/html", audience);
1596                set_wac_allow(&mut rsp, &wac_allow);
1597                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1598                set_link_headers(&mut rsp, &path);
1599                return Ok(rsp);
1600            }
1601
1602            // RDF content negotiation: when the client explicitly asks for
1603            // a concrete RDF serialisation that differs from how the
1604            // resource is stored, transcode it. KG resources persist as
1605            // N-Triples (see the PATCH handler), so an agent or extractor
1606            // can GET the same graph as Turtle, N-Triples, or JSON-LD on
1607            // demand (PRD-014 Seam C / C4). Non-RDF resources, unparseable
1608            // bodies, and wildcard/`*/*` Accepts fall through to verbatim.
1609            if let Some((negotiated_body, negotiated_ct)) =
1610                rdf_content_negotiate(&body, &meta.content_type, accept)
1611            {
1612                let mut rsp = HttpResponse::Ok().body(negotiated_body);
1613                rsp.headers_mut().insert(
1614                    header::CONTENT_TYPE,
1615                    header::HeaderValue::from_str(negotiated_ct)
1616                        .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
1617                );
1618                rsp.headers_mut()
1619                    .insert(header::VARY, header::HeaderValue::from_static("Accept"));
1620                if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1621                    rsp.headers_mut().insert(header::ETAG, etag);
1622                }
1623                set_cache_policy(&mut rsp, negotiated_ct, audience);
1624                set_wac_allow(&mut rsp, &wac_allow);
1625                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1626                set_link_headers(&mut rsp, &path);
1627                return Ok(rsp);
1628            }
1629
1630            let response_body = if meta.content_type.starts_with("text/html") {
1631                inject_live_reload(&body, state.live_reload)
1632            } else {
1633                body.to_vec()
1634            };
1635            let mut rsp = HttpResponse::Ok().body(response_body);
1636            rsp.headers_mut().insert(
1637                header::CONTENT_TYPE,
1638                header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
1639                    header::HeaderValue::from_static("application/octet-stream")
1640                }),
1641            );
1642            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1643                rsp.headers_mut().insert(header::ETAG, etag);
1644            }
1645            set_cache_policy(&mut rsp, &meta.content_type, audience);
1646            set_wac_allow(&mut rsp, &wac_allow);
1647            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1648            set_link_headers(&mut rsp, &path);
1649            Ok(rsp)
1650        }
1651        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1652        Err(e) => Err(to_actix(e)),
1653    }
1654}
1655
1656fn has_basic_container_link(req: &HttpRequest) -> bool {
1657    req.headers()
1658        .get_all(header::LINK)
1659        .filter_map(|v| v.to_str().ok())
1660        .any(|v| {
1661            v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
1662        })
1663}
1664
1665/// Validate a Turtle / N3 request body (parity row 35).
1666///
1667/// `text/turtle` bodies must parse (400 on a syntax error). `text/n3` is
1668/// accepted only with conneg on (415 otherwise, JSS `canAcceptInput`), and
1669/// N3 formulas, variables and rules are refused with 415. The base IRI is
1670/// the request URL of the resource being written. Other content types pass
1671/// through untouched. The parsed graph is discarded: the stored bytes are
1672/// the client's own representation.
1673fn validate_rdf_body(
1674    req: &HttpRequest,
1675    state: &AppState,
1676    resource_path: &str,
1677    content_type: &str,
1678    body: &[u8],
1679) -> Result<(), ActixError> {
1680    use solid_pod_rs::rdf_input::{parse_rdf_body, RdfInputSyntax};
1681    let Some(syntax) = RdfInputSyntax::from_content_type(content_type) else {
1682        return Ok(());
1683    };
1684    if syntax == RdfInputSyntax::N3 && !state.conneg_enabled {
1685        return Err(actix_web::error::ErrorUnsupportedMediaType(
1686            "text/n3 request bodies require content negotiation (conneg) to be enabled",
1687        ));
1688    }
1689    let base = format!("{}{}", scheme::request_origin(req), resource_path);
1690    match parse_rdf_body(syntax, body, &base) {
1691        Ok(_) => Ok(()),
1692        Err(solid_pod_rs::PodError::PayloadTooLarge(m)) => {
1693            Err(actix_web::error::ErrorPayloadTooLarge(m))
1694        }
1695        Err(e) => Err(to_actix(e)),
1696    }
1697}
1698
1699async fn handle_put(
1700    req: HttpRequest,
1701    body: web::Bytes,
1702    state: web::Data<AppState>,
1703) -> Result<HttpResponse, ActixError> {
1704    let path = req.uri().path().to_string();
1705
1706    if ldp::is_container(&path) {
1707        if has_basic_container_link(&req) {
1708            let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1709            let agent = agent_uri(auth_pk.as_ref());
1710            enforce_write_ctx(
1711                &state,
1712                &path,
1713                AccessMode::Write,
1714                agent.as_deref(),
1715                req_origin(&req),
1716            )
1717            .await?;
1718            let meta = state
1719                .storage
1720                .create_container(&path)
1721                .await
1722                .map_err(to_actix)?;
1723            let mut rsp = HttpResponse::Created().finish();
1724            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1725                rsp.headers_mut().insert(header::ETAG, etag);
1726            }
1727            set_link_headers(&mut rsp, &path);
1728            return Ok(rsp);
1729        }
1730        return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
1731    }
1732
1733    let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1734    let agent = agent_uri(auth_pk.as_ref());
1735    enforce_write_ctx(
1736        &state,
1737        &path,
1738        AccessMode::Write,
1739        agent.as_deref(),
1740        req_origin(&req),
1741    )
1742    .await?;
1743
1744    let ct = req
1745        .headers()
1746        .get(header::CONTENT_TYPE)
1747        .and_then(|v| v.to_str().ok())
1748        .unwrap_or("application/octet-stream");
1749
1750    // P0-2 lockout guard: when writing an `.acl`/`.meta` sidecar, refuse a
1751    // proposed ACL that would strip the caller's own Control — the same
1752    // footgun the MCP `write_acl` path blocks (mcp/tools.rs:511). Without
1753    // this a Control holder could lock themselves (and everyone) out.
1754    if protected_resource_for_acl(&path).is_some()
1755        && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
1756    {
1757        return Ok(HttpResponse::Conflict().body(
1758            "refused: the proposed ACL would not grant Control to the caller \
1759             (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1760        ));
1761    }
1762
1763    validate_rdf_body(&req, &state, &path, ct, &body)?;
1764
1765    let quota = reserve_quota_for_size(&state, &path, body.len() as u64).await?;
1766    let write = state
1767        .storage
1768        .put(&path, Bytes::from(body.to_vec()), ct)
1769        .await;
1770    finish_quota_reservation(&state, quota, write.is_ok()).await;
1771    let meta = write.map_err(to_actix)?;
1772    // git-mark (Phase 2): commit + PROV-O sidecar on git-backed pods. Runs
1773    // AFTER the write succeeded; additive + best-effort (errors swallowed),
1774    // git-backed-only, never changes the response.
1775    let provenance = git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
1776    let mut rsp = HttpResponse::Created().finish();
1777    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1778        rsp.headers_mut().insert(header::ETAG, etag);
1779    }
1780    set_provenance_headers(&mut rsp, &provenance);
1781    set_link_headers(&mut rsp, &path);
1782    Ok(rsp)
1783}
1784
1785/// P1-k: probe storage for `target` and, if it already exists, append
1786/// `-1`, `-2`, … before the file extension (or at the end when there is no
1787/// extension) until a free name is found — mirroring JSS
1788/// `generateUniqueFilename` so an LDP POST never overwrites a sibling. The
1789/// probe is bounded; on the practically-unreachable ceiling it falls back
1790/// to a content-hash suffix so the write still lands on a fresh name.
1791async fn mint_unique_target(storage: &dyn Storage, target: &str) -> String {
1792    if !storage.exists(target).await.unwrap_or(false) {
1793        return target.to_string();
1794    }
1795    // Split stem/ext at the last '.' that falls after the last '/', so a
1796    // leading-dot filename (e.g. `/c/.keep`) is treated as extension-less.
1797    let seg_start = target.rfind('/').map(|s| s + 1).unwrap_or(0);
1798    let (stem, ext) = match target.rfind('.') {
1799        Some(dot) if dot > seg_start => (&target[..dot], &target[dot..]),
1800        _ => (target, ""),
1801    };
1802    for n in 1..10_000u32 {
1803        let candidate = format!("{stem}-{n}{ext}");
1804        if !storage.exists(&candidate).await.unwrap_or(false) {
1805            return candidate;
1806        }
1807    }
1808    use std::hash::{Hash, Hasher};
1809    let mut h = std::collections::hash_map::DefaultHasher::new();
1810    target.hash(&mut h);
1811    format!("{stem}-{:x}{ext}", h.finish())
1812}
1813
1814async fn handle_post(
1815    req: HttpRequest,
1816    body: web::Bytes,
1817    state: web::Data<AppState>,
1818) -> Result<HttpResponse, ActixError> {
1819    let path = req.uri().path().to_string();
1820    // POST route only matches container paths (trailing slash) via the
1821    // `POST /{tail:.*}/` registration.
1822    let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1823    let agent = agent_uri(auth_pk.as_ref());
1824    enforce_write_ctx(
1825        &state,
1826        &path,
1827        AccessMode::Append,
1828        agent.as_deref(),
1829        req_origin(&req),
1830    )
1831    .await?;
1832
1833    let slug = req
1834        .headers()
1835        .get(header::HeaderName::from_static("slug"))
1836        .and_then(|v| v.to_str().ok());
1837    let mut target = match ldp::resolve_slug(&path, slug) {
1838        Ok(p) => p,
1839        Err(e) => return Err(to_actix(e)),
1840    };
1841    let ct = req
1842        .headers()
1843        .get(header::CONTENT_TYPE)
1844        .and_then(|v| v.to_str().ok())
1845        .unwrap_or("application/octet-stream");
1846
1847    // P0-4: POST authorised only `Append` on the container. If the client's
1848    // Slug resolves to an `.acl`/`.meta` sidecar, that sidecar governs
1849    // ANOTHER resource's permissions — minting it from Append-only rights is
1850    // privilege escalation (an attacker self-grants Control on a sibling).
1851    // Elevate exactly as the PUT path does: require `acl:Control` on the
1852    // protected resource AND run the lockout guard on the proposed body.
1853    // (This gap is shared with JSS, which keys its Control guard on the
1854    // container path, not the resolved `.acl` — candidate upstream PR.)
1855    if protected_resource_for_acl(&target).is_some() {
1856        enforce_write_ctx(
1857            &state,
1858            &target,
1859            AccessMode::Write,
1860            agent.as_deref(),
1861            req_origin(&req),
1862        )
1863        .await?;
1864        if !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref()) {
1865            return Ok(HttpResponse::Conflict().body(
1866                "refused: the proposed ACL would not grant Control to the caller \
1867                 (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
1868            ));
1869        }
1870    } else {
1871        // P1-k: LDP POST must CREATE a new resource, never overwrite one.
1872        // `resolve_slug` joins the Slug verbatim, so a repeated `Slug: note`
1873        // would clobber the first note (silent data loss). Mint a unique
1874        // target by probing existence and appending `-1`, `-2`, … exactly as
1875        // JSS `generateUniqueFilename` does.
1876        target = mint_unique_target(&*state.storage, &target).await;
1877    }
1878
1879    validate_rdf_body(&req, &state, &target, ct, &body)?;
1880
1881    let quota = reserve_quota_for_size(&state, &target, body.len() as u64).await?;
1882    let write = state
1883        .storage
1884        .put(&target, Bytes::from(body.to_vec()), ct)
1885        .await;
1886    finish_quota_reservation(&state, quota, write.is_ok()).await;
1887    let meta = write.map_err(to_actix)?;
1888    // git-mark (Phase 2): commit + PROV-O sidecar for the newly-created child.
1889    // Additive + best-effort, git-backed-only.
1890    let provenance = git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1891    let mut rsp = HttpResponse::Created().finish();
1892    if let Ok(loc) = header::HeaderValue::from_str(&target) {
1893        rsp.headers_mut().insert(header::LOCATION, loc);
1894    }
1895    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1896        rsp.headers_mut().insert(header::ETAG, etag);
1897    }
1898    set_provenance_headers(&mut rsp, &provenance);
1899    set_link_headers(&mut rsp, &target);
1900    Ok(rsp)
1901}
1902
1903async fn handle_patch(
1904    req: HttpRequest,
1905    body: web::Bytes,
1906    state: web::Data<AppState>,
1907) -> Result<HttpResponse, ActixError> {
1908    let path = req.uri().path().to_string();
1909    if ldp::is_container(&path) {
1910        return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1911    }
1912    let auth_pk = extract_pubkey_with_body(&req, Some(&body)).await;
1913    let agent = agent_uri(auth_pk.as_ref());
1914    // PATCH can modify or delete data (e.g. N3 Patch with solid:deletes),
1915    // so it requires full Write permission — not just Append. Only POST
1916    // (which creates new child resources in a container) is allowed with
1917    // Append-only permission. This prevents Append-only users from
1918    // overwriting or deleting resource content via PATCH.
1919    enforce_write_ctx(
1920        &state,
1921        &path,
1922        AccessMode::Write,
1923        agent.as_deref(),
1924        req_origin(&req),
1925    )
1926    .await?;
1927
1928    let ct = req
1929        .headers()
1930        .get(header::CONTENT_TYPE)
1931        .and_then(|v| v.to_str().ok())
1932        .unwrap_or("");
1933    let dialect = match ldp::patch_dialect_from_mime(ct) {
1934        Some(d) => d,
1935        None => {
1936            return Ok(HttpResponse::UnsupportedMediaType()
1937                .body(format!("unsupported patch dialect for content-type {ct:?}")))
1938        }
1939    };
1940    let body_str = match std::str::from_utf8(&body) {
1941        Ok(s) => s.to_string(),
1942        Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1943    };
1944
1945    // Existing resource?
1946    let existing = state.storage.get(&path).await;
1947    match existing {
1948        Ok((current_body, meta)) => {
1949            // Seed the working graph from the EXISTING resource body so the
1950            // mutation lands on top of the triples already stored, rather
1951            // than on an empty graph (which silently discarded everything
1952            // on every incremental write — the data-loss bug fixed here;
1953            // PRD-014 Seam C / DDD-012 A2 non-destructive-write invariant).
1954            // RDF resources are persisted as N-Triples by `graph_to_turtle`,
1955            // so the current body round-trips through `parse_ntriples`. A
1956            // body that is not parseable N-Triples is refused, not
1957            // overwritten — fail closed rather than destroy.
1958            let out = match dialect {
1959                ldp::PatchDialect::N3 => {
1960                    let seed = seed_graph_from_patch_target(&current_body)?;
1961                    ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1962                }
1963                ldp::PatchDialect::SparqlUpdate => {
1964                    let seed = seed_graph_from_patch_target(&current_body)?;
1965                    ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1966                }
1967                ldp::PatchDialect::JsonPatch => {
1968                    let mut json: serde_json::Value = match serde_json::from_slice(&current_body) {
1969                        Ok(v) => v,
1970                        Err(_) => serde_json::json!({}),
1971                    };
1972                    let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1973                        Ok(v) => v,
1974                        Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1975                    };
1976                    ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1977                    let bytes = serde_json::to_vec(&json)
1978                        .map_err(PodError::from)
1979                        .map_err(to_actix)?;
1980                    let quota = reserve_quota_for_size(&state, &path, bytes.len() as u64).await?;
1981                    let write = state
1982                        .storage
1983                        .put(&path, Bytes::from(bytes), &meta.content_type)
1984                        .await;
1985                    finish_quota_reservation(&state, quota, write.is_ok()).await;
1986                    let _ = write.map_err(to_actix)?;
1987                    let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1988                    let mut rsp = HttpResponse::NoContent().finish();
1989                    set_provenance_headers(&mut rsp, &provenance);
1990                    return Ok(rsp);
1991                }
1992            };
1993            let outcome = out?;
1994            // Round-trip the updated graph back to Turtle so the next
1995            // GET reflects the mutation.
1996            let serialised = graph_to_turtle(&outcome.graph);
1997            // F7: PATCHing an `.acl`/`.meta` sidecar already required Control
1998            // (enforced above), but — unlike PUT — the POST-patch result was
1999            // never checked against the lockout guard, so a Control holder
2000            // could strip every principal's Control in one patch. Apply the
2001            // same guard PUT uses, on the serialised post-patch ACL.
2002            if protected_resource_for_acl(&path).is_some()
2003                && !proposed_acl_keeps_caller_control(
2004                    serialised.as_bytes(),
2005                    "application/n-triples",
2006                    agent.as_deref(),
2007                )
2008            {
2009                return Ok(HttpResponse::Conflict().body(
2010                    "refused: the patched ACL would not grant Control to the caller \
2011                     (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2012                ));
2013            }
2014            let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2015            let write = state
2016                .storage
2017                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
2018                .await;
2019            finish_quota_reservation(&state, quota, write.is_ok()).await;
2020            let _ = write.map_err(to_actix)?;
2021            let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2022            let mut rsp = HttpResponse::NoContent().finish();
2023            set_provenance_headers(&mut rsp, &provenance);
2024            Ok(rsp)
2025        }
2026        Err(PodError::NotFound(_)) => {
2027            // PATCH against an absent resource — create it.
2028            let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
2029            let PatchCreateOutcome::Created { graph, .. } = create else {
2030                return Err(to_actix(PodError::Unsupported(
2031                    "unexpected patch outcome on absent resource".into(),
2032                )));
2033            };
2034            let serialised = graph_to_turtle(&graph);
2035            // F7: same lockout guard on the create-via-PATCH `.acl` path.
2036            if protected_resource_for_acl(&path).is_some()
2037                && !proposed_acl_keeps_caller_control(
2038                    serialised.as_bytes(),
2039                    "application/n-triples",
2040                    agent.as_deref(),
2041                )
2042            {
2043                return Ok(HttpResponse::Conflict().body(
2044                    "refused: the patched ACL would not grant Control to the caller \
2045                     (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2046                ));
2047            }
2048            let quota = reserve_quota_for_size(&state, &path, serialised.len() as u64).await?;
2049            let write = state
2050                .storage
2051                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
2052                .await;
2053            finish_quota_reservation(&state, quota, write.is_ok()).await;
2054            let _ = write.map_err(to_actix)?;
2055            let provenance = git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
2056            let mut rsp = HttpResponse::Created().finish();
2057            set_provenance_headers(&mut rsp, &provenance);
2058            Ok(rsp)
2059        }
2060        Err(e) => Err(to_actix(e)),
2061    }
2062}
2063
2064/// Map a PATCH body parse error to 400 Bad Request. Distinguishes
2065/// "client sent garbage in a supported dialect" (400) from "client
2066/// chose an unsupported dialect" (415 — handled by the dispatcher).
2067fn patch_parse_err(e: PodError) -> ActixError {
2068    match e {
2069        PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
2070            actix_web::error::ErrorBadRequest(msg)
2071        }
2072        other => to_actix(other),
2073    }
2074}
2075
2076/// Serialise a graph to N-Triples so the next GET reflects PATCH
2077/// mutations verbatim. Delegates to the library's canonical serialiser
2078/// — the handler does not add its own formatting.
2079fn graph_to_turtle(g: &ldp::Graph) -> String {
2080    g.to_ntriples()
2081}
2082
2083/// Parse an `Accept` header and return the highest-q *explicit* RDF
2084/// format named by the client. Unlike `ldp::negotiate_format`, wildcard
2085/// media ranges (`*/*`, `text/*`, `application/*`) are NOT mapped to a
2086/// default: a request that names no concrete RDF type yields `None`, so
2087/// the GET handler serves the stored representation verbatim instead of
2088/// surprising a browser (which sends `*/*`) with a transcode.
2089fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
2090    let mut best: Option<(f32, ldp::RdfFormat)> = None;
2091    for entry in accept.split(',') {
2092        let entry = entry.trim();
2093        if entry.is_empty() {
2094            continue;
2095        }
2096        let mut parts = entry.split(';').map(|s| s.trim());
2097        let mime = match parts.next() {
2098            Some(m) => m,
2099            None => continue,
2100        };
2101        let mut q: f32 = 1.0;
2102        for token in parts {
2103            if let Some(v) = token.strip_prefix("q=") {
2104                if let Ok(parsed) = v.parse::<f32>() {
2105                    q = parsed;
2106                }
2107            }
2108        }
2109        // `from_mime` rejects wildcards, so only concrete RDF media types
2110        // ever enter the running.
2111        if let Some(format) = ldp::RdfFormat::from_mime(mime) {
2112            match best {
2113                None => best = Some((q, format)),
2114                Some((bq, _)) if q > bq => best = Some((q, format)),
2115                _ => {}
2116            }
2117        }
2118    }
2119    best.map(|(_, f)| f)
2120}
2121
2122/// RDF content negotiation for GET. When a client explicitly asks (via
2123/// `Accept`) for a concrete RDF serialisation different from how the
2124/// resource is stored, transcode the body and return the negotiated
2125/// `(bytes, content-type)`. KG resources persist as N-Triples (see the
2126/// PATCH handler), so an agent or extractor can GET the same graph as
2127/// Turtle, N-Triples, or JSON-LD on demand (PRD-014 Seam C / C4).
2128///
2129/// Returns `None` — meaning "serve the stored body verbatim" — when:
2130///   * the `Accept` header is empty,
2131///   * the stored content-type is not an RDF media type,
2132///   * the client named no concrete RDF type (only wildcards),
2133///   * the requested format equals the stored format (no transcode),
2134///   * the stored body does not parse as N-Triples (GET fails soft to
2135///     verbatim — it never destroys or misrepresents), or
2136///   * the requested target has no serialiser (RDF/XML).
2137fn rdf_content_negotiate(
2138    body: &[u8],
2139    stored_ct: &str,
2140    accept: &str,
2141) -> Option<(Vec<u8>, &'static str)> {
2142    if accept.trim().is_empty() {
2143        return None;
2144    }
2145    let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
2146    let target = best_explicit_rdf_format(accept)?;
2147    if target == stored_format {
2148        return None;
2149    }
2150    let text = std::str::from_utf8(body).ok()?;
2151    let graph = ldp::Graph::parse_ntriples(text).ok()?;
2152    match target {
2153        // N-Triples is a syntactic subset of Turtle; the canonical
2154        // serialiser emits N-Triples, which is valid Turtle.
2155        ldp::RdfFormat::Turtle => Some((
2156            graph.to_ntriples().into_bytes(),
2157            ldp::RdfFormat::Turtle.mime(),
2158        )),
2159        ldp::RdfFormat::NTriples => Some((
2160            graph.to_ntriples().into_bytes(),
2161            ldp::RdfFormat::NTriples.mime(),
2162        )),
2163        ldp::RdfFormat::JsonLd => {
2164            let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
2165            Some((json, ldp::RdfFormat::JsonLd.mime()))
2166        }
2167        // The hand-rolled graph has no RDF/XML serialiser; decline.
2168        ldp::RdfFormat::RdfXml => None,
2169    }
2170}
2171
2172/// Seed the PATCH working graph from the existing resource body so an
2173/// N3/SPARQL-Update mutation is applied on top of the triples already
2174/// stored. RDF resources are persisted as N-Triples (see `graph_to_turtle`),
2175/// so the current body round-trips through `Graph::parse_ntriples`. An
2176/// empty body yields an empty graph. A body that is neither empty nor
2177/// parseable N-Triples is REFUSED (409) rather than silently overwritten:
2178/// destroying a resource the patch engine cannot read back would violate
2179/// the non-destructive-write invariant (PRD-014 Seam C, DDD-012 A2).
2180fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
2181    let text = std::str::from_utf8(current_body).map_err(|_| {
2182        actix_web::error::ErrorConflict(
2183            "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
2184        )
2185    })?;
2186    if text.trim().is_empty() {
2187        return Ok(ldp::Graph::new());
2188    }
2189    ldp::Graph::parse_ntriples(text).map_err(|_| {
2190        actix_web::error::ErrorConflict(
2191            "existing resource is not N-Triples RDF and cannot be non-destructively \
2192             patched; PUT an N-Triples representation or use a JSON Patch",
2193        )
2194    })
2195}
2196
2197/// Walk the storage tree from `path` upward, returning the first
2198/// `*.acl` document that parses as JSON-LD or Turtle.
2199///
2200/// Object-safe equivalent of `StorageAclResolver::find_effective_acl` — the
2201/// latter is generic over a concrete `Storage`, whereas the binary holds an
2202/// `Arc<dyn Storage>`.
2203///
2204/// **ADR-2005.** The walk itself is no longer duplicated here. Both this
2205/// function and `StorageAclResolver` now delegate to the single implementation
2206/// in `solid_pod_rs::wac::resolve_policy_from_storage`, so the native and
2207/// object-safe paths cannot drift apart on the missing / invalid / unavailable
2208/// distinction. This wrapper keeps the historical `Result<Option<_>, _>` shape
2209/// for callers that do not need the typed outcome; a malformed or unreadable
2210/// `.acl` now surfaces as `Err` instead of silently inheriting an ancestor's
2211/// broader grant.
2212pub(crate) async fn find_effective_acl_dyn(
2213    storage: &dyn Storage,
2214    resource_path: &str,
2215) -> Result<Option<wac::AclDocument>, PodError> {
2216    resolve_policy_dyn(storage, resource_path)
2217        .await
2218        .into_result()
2219}
2220
2221/// Typed-outcome resolution against a trait-object backend (ADR-2005).
2222///
2223/// The enforcement paths use this directly so they can deny on
2224/// `Invalid` / `Unavailable` rather than inheriting past them.
2225pub(crate) async fn resolve_policy_dyn(
2226    storage: &dyn Storage,
2227    resource_path: &str,
2228) -> wac::PolicyOutcome {
2229    wac::resolve_policy_from_storage(storage, resource_path).await
2230}
2231
2232/// Map a failed [`wac::PolicyOutcome`] onto the response the WAC gate must
2233/// return, and log it.
2234///
2235/// `Invalid` is the operator's own unparseable policy: deny with 403, exactly
2236/// as if the policy had granted nothing. Inheriting past it (the pre-ADR-2005
2237/// behaviour) would hand the caller an ancestor's broader rights.
2238/// `Unavailable` is a backend fault: the policy is unknown, so the request
2239/// cannot be authorised — 503, which is retryable and not mistakable for a
2240/// deliberate denial.
2241fn policy_failure_to_actix(outcome: &wac::PolicyOutcome, resource: &str) -> ActixError {
2242    match outcome {
2243        wac::PolicyOutcome::Invalid {
2244            policy_path,
2245            reason,
2246        } => {
2247            tracing::warn!(
2248                target: "solid_pod_rs_server::wac",
2249                resource = %resource,
2250                policy = %policy_path,
2251                "denying: effective ACL is invalid and MUST NOT inherit: {reason}"
2252            );
2253            actix_web::error::ErrorForbidden("access forbidden: governing ACL is invalid")
2254        }
2255        wac::PolicyOutcome::Unavailable {
2256            policy_path,
2257            detail,
2258        } => {
2259            tracing::error!(
2260                target: "solid_pod_rs_server::wac",
2261                resource = %resource,
2262                policy = %policy_path,
2263                "denying: effective ACL could not be read: {detail}"
2264            );
2265            actix_web::error::ErrorServiceUnavailable(
2266                "access control unavailable: governing ACL could not be read",
2267            )
2268        }
2269        // Not a failure — callers only reach here via `is_failure()`.
2270        _ => actix_web::error::ErrorInternalServerError("policy resolution error"),
2271    }
2272}
2273
2274async fn handle_delete(
2275    req: HttpRequest,
2276    state: web::Data<AppState>,
2277) -> Result<HttpResponse, ActixError> {
2278    let path = req.uri().path().to_string();
2279    let auth_pk = extract_pubkey(&req).await;
2280    let agent = agent_uri(auth_pk.as_ref());
2281    enforce_write_ctx(
2282        &state,
2283        &path,
2284        AccessMode::Write,
2285        agent.as_deref(),
2286        req_origin(&req),
2287    )
2288    .await?;
2289
2290    let quota = reserve_quota_for_size(&state, &path, 0).await?;
2291    match state.storage.delete(&path).await {
2292        Ok(()) => {
2293            finish_quota_reservation(&state, quota, true).await;
2294            Ok(HttpResponse::NoContent().finish())
2295        }
2296        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
2297        Err(e) => Err(to_actix(e)),
2298    }
2299}
2300
2301async fn handle_options(
2302    req: HttpRequest,
2303    state: web::Data<AppState>,
2304) -> Result<HttpResponse, ActixError> {
2305    let path = req.uri().path().to_string();
2306    let o = ldp::options_for_conneg(&path, state.conneg_enabled);
2307    let mut rsp = HttpResponse::NoContent().finish();
2308    if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
2309        rsp.headers_mut()
2310            .insert(header::HeaderName::from_static("allow"), v);
2311    }
2312    if let Some(ap) = o.accept_post {
2313        if let Ok(v) = header::HeaderValue::from_str(ap) {
2314            rsp.headers_mut()
2315                .insert(header::HeaderName::from_static("accept-post"), v);
2316        }
2317    }
2318    if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
2319        rsp.headers_mut()
2320            .insert(header::HeaderName::from_static("accept-patch"), v);
2321    }
2322    if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
2323        rsp.headers_mut()
2324            .insert(header::HeaderName::from_static("accept-ranges"), v);
2325    }
2326    set_updates_via(&mut rsp, &state.nodeinfo.base_url);
2327    Ok(rsp)
2328}
2329
2330// ---------------------------------------------------------------------------
2331// .well-known handlers
2332// ---------------------------------------------------------------------------
2333
2334async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
2335    let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
2336    HttpResponse::Ok()
2337        .content_type("application/ld+json")
2338        .json(doc)
2339}
2340
2341#[derive(Debug, Deserialize)]
2342struct WebFingerQuery {
2343    resource: Option<String>,
2344}
2345
2346async fn handle_well_known_webfinger(
2347    state: web::Data<AppState>,
2348    q: web::Query<WebFingerQuery>,
2349) -> HttpResponse {
2350    let resource = q.resource.clone().unwrap_or_else(|| {
2351        format!(
2352            "acct:anonymous@{}",
2353            state
2354                .nodeinfo
2355                .base_url
2356                .trim_start_matches("http://")
2357                .trim_start_matches("https://")
2358        )
2359    });
2360    let webid = format!(
2361        "{}/profile/card#me",
2362        state.nodeinfo.base_url.trim_end_matches('/')
2363    );
2364    match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
2365        Some(jrd) => HttpResponse::Ok()
2366            .content_type("application/jrd+json")
2367            .json(jrd),
2368        None => HttpResponse::NotFound().finish(),
2369    }
2370}
2371
2372async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
2373    let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
2374    HttpResponse::Ok()
2375        .content_type("application/json")
2376        .json(doc)
2377}
2378
2379async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
2380    let doc = interop::nodeinfo_2_1(
2381        &state.nodeinfo.software_name,
2382        &state.nodeinfo.software_version,
2383        state.nodeinfo.open_registrations,
2384        state.nodeinfo.total_users,
2385    );
2386    HttpResponse::Ok()
2387        .content_type("application/json")
2388        .json(doc)
2389}
2390
2391#[cfg(feature = "did-nostr")]
2392async fn handle_well_known_did_nostr(
2393    state: web::Data<AppState>,
2394    path: web::Path<String>,
2395) -> HttpResponse {
2396    let pubkey = path.into_inner();
2397    // did:nostr Tier-1 resolution (https://nostrcg.github.io/did-nostr/): a
2398    // malformed identifier is a client error that must never be cached; a
2399    // valid 64-char lowercase-hex key yields a deterministic offline
2400    // (Tier-2) document. Mirrors the JSS resolver's per-status header policy.
2401    let pubkey_is_valid = pubkey.len() == 64
2402        && pubkey
2403            .bytes()
2404            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
2405    if !pubkey_is_valid {
2406        return HttpResponse::BadRequest()
2407            .insert_header(("Cache-Control", "no-store"))
2408            .json(serde_json::json!({
2409                "error": "invalid did:nostr pubkey (expected 64-char lowercase hex)"
2410            }));
2411    }
2412    // P1-l: this endpoint asserts an identity binding (did:nostr:<pubkey> ⇒
2413    // this pod's WebID via `alsoKnownAs`). Returning a document for ANY
2414    // well-formed pubkey asserted a FALSE binding for every key the pod
2415    // owner does not hold (and is flatly wrong on a multi-user pod). Resolve
2416    // the owner's declared Nostr key from the pod profile card and return
2417    // 404 ("no account claims this key", JSS behaviour) unless the queried
2418    // key is the owner's — mirroring the NIP-05 owner-resolution path.
2419    let owner_pubkey = match state.storage.get("/profile/card").await {
2420        Ok((body, _)) => solid_pod_rs::webid::extract_nostr_pubkey(&body)
2421            .ok()
2422            .flatten(),
2423        Err(_) => None,
2424    };
2425    let owner_claims_key = owner_pubkey
2426        .as_deref()
2427        .is_some_and(|owner| owner.eq_ignore_ascii_case(&pubkey));
2428    if !owner_claims_key {
2429        return HttpResponse::NotFound()
2430            .insert_header(("Cache-Control", "no-store"))
2431            .json(serde_json::json!({
2432                "error": "no account on this pod claims this did:nostr pubkey"
2433            }));
2434    }
2435    let also = vec![format!(
2436        "{}/profile/card#me",
2437        state.nodeinfo.base_url.trim_end_matches('/')
2438    )];
2439    let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
2440    let body = serde_json::to_string(&doc).unwrap_or_else(|_| "{}".to_string());
2441    // Feature-match the JSS Tier-1 header policy: max-age=3600 (the DID doc
2442    // seldom changes) + a weak ETag over the deterministic body. Last-Modified
2443    // is intentionally omitted — the document is generated deterministically
2444    // from the pubkey (Tier-2), so there is no underlying mutable resource to
2445    // date; the ETag is the correct validator here.
2446    use std::hash::{Hash, Hasher};
2447    let mut hasher = std::collections::hash_map::DefaultHasher::new();
2448    body.hash(&mut hasher);
2449    let etag = format!("\"{:016x}\"", hasher.finish());
2450    HttpResponse::Ok()
2451        .content_type("application/did+json")
2452        .insert_header(("Cache-Control", "max-age=3600"))
2453        .insert_header(("ETag", etag))
2454        .body(body)
2455}
2456
2457// ---------------------------------------------------------------------------
2458// JSS v0.0.190 Phase 1 port (issue #437) — pod-resident NIP-05 endpoint.
2459//
2460// Parity row 197. Feature `nip05-endpoint`. Resolves `?name=<local>`
2461// against the per-pod WebID `nostr:pubkey` triple.
2462// ---------------------------------------------------------------------------
2463
2464#[cfg(feature = "nip05-endpoint")]
2465#[derive(Debug, Deserialize)]
2466struct Nip05Query {
2467    /// Optional `name=<local>` query parameter per NIP-05. When
2468    /// absent, defaults to `_` (the pod owner / single-user mode).
2469    name: Option<String>,
2470}
2471
2472#[cfg(feature = "nip05-endpoint")]
2473fn nip05_name_is_valid(name: &str) -> bool {
2474    // NIP-05 §"Local part": ^[a-z0-9._-]+$ (case-insensitive in practice).
2475    // Also allow the singleton `_` which means "the pod owner".
2476    if name.is_empty() {
2477        return false;
2478    }
2479    name.bytes()
2480        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
2481}
2482
2483#[cfg(feature = "nip05-endpoint")]
2484async fn handle_well_known_nip05(
2485    state: web::Data<AppState>,
2486    query: web::Query<Nip05Query>,
2487) -> HttpResponse {
2488    use solid_pod_rs::webid::extract_nostr_pubkey;
2489
2490    // JSS Phase 1 (issue #437) parity row 197.
2491    let name = query.name.clone().unwrap_or_else(|| "_".to_string());
2492    if !nip05_name_is_valid(&name) {
2493        return HttpResponse::BadRequest().json(serde_json::json!({
2494            "error": "invalid NIP-05 local part",
2495        }));
2496    }
2497
2498    // Single-pod-per-host: profile lives at `/profile/card`. Multi-user
2499    // path-based mode wires the bind via NormalizePath middleware,
2500    // so the lookup happens at the resolved storage path.
2501    // For `_` (default) we look up `/profile/card`. For a non-special
2502    // name we try `/<name>/profile/card` (multi-user path layout).
2503    let profile_path = if name == "_" {
2504        "/profile/card".to_string()
2505    } else {
2506        format!("/{name}/profile/card")
2507    };
2508
2509    let (body, _meta) = match state.storage.get(&profile_path).await {
2510        Ok(v) => v,
2511        Err(_) => {
2512            // Spec behaviour: return an empty `names` map with 200 OK
2513            // when the lookup yields nothing. Damus / nos.lol use this
2514            // shape to mean "no such user".
2515            return nip05_empty_response();
2516        }
2517    };
2518
2519    let pubkey_hex = match extract_nostr_pubkey(&body) {
2520        Ok(Some(p)) => p,
2521        _ => return nip05_empty_response(),
2522    };
2523
2524    let doc = interop::nip05_document([(name, pubkey_hex)]);
2525    HttpResponse::Ok()
2526        .insert_header(("Access-Control-Allow-Origin", "*"))
2527        .content_type("application/json")
2528        .json(doc)
2529}
2530
2531#[cfg(feature = "nip05-endpoint")]
2532fn nip05_empty_response() -> HttpResponse {
2533    HttpResponse::Ok()
2534        .insert_header(("Access-Control-Allow-Origin", "*"))
2535        .content_type("application/json")
2536        .json(serde_json::json!({ "names": {} }))
2537}
2538
2539// ---------------------------------------------------------------------------
2540// JSS v0.0.190 Phase 1 port (issue #437), parity row 198.
2541// JSON-LD time-chain pod export (`GET /api/exports/all`). NATIVE-ONLY:
2542// gated behind `export-jsonld` (default-off); the CF-Workers pod tier does
2543// not walk a local storage tree. `solid_pod_rs::export::export_pod_jsonld`
2544// is the pure-logic walker — this handler adds the HTTP surface + WAC gate.
2545// ---------------------------------------------------------------------------
2546
2547/// `GET /api/exports/all` — export the whole pod as a JSON-LD time-chain
2548/// bundle. Owner-gated: the caller must hold `acl:Control` on the pod root
2549/// (`/`), because the bundle can expose every resource — including
2550/// `/private/*` when `?include_private=true` — bypassing per-resource ACLs.
2551/// A Control credential is therefore the correct (highest) authorisation.
2552#[cfg(feature = "export-jsonld")]
2553async fn handle_export_all(
2554    req: HttpRequest,
2555    state: web::Data<AppState>,
2556) -> Result<HttpResponse, ActixError> {
2557    let auth_pk = extract_pubkey(&req).await;
2558    let agent = agent_uri(auth_pk.as_ref());
2559
2560    // Owner gate: require `acl:Control` on the pod root. `enforce_write_ctx`
2561    // evaluates the passed mode against the root ACL and returns the shared
2562    // 401/403 WAC denial; `Control` bypasses the origin gate by design so an
2563    // owner can always export from any origin.
2564    enforce_write_ctx(
2565        &state,
2566        "/",
2567        AccessMode::Control,
2568        agent.as_deref(),
2569        req_origin(&req),
2570    )
2571    .await?;
2572
2573    // `include_private=true` is honoured only for this Control-authorised
2574    // caller (the export function itself is unauthenticated — the gate above
2575    // is the credential the docs require before flipping the flag).
2576    let include_private = web::Query::<HashMap<String, String>>::from_query(req.query_string())
2577        .ok()
2578        .and_then(|q| q.get("include_private").map(|v| v == "true"))
2579        .unwrap_or(false);
2580
2581    // Pod base URL stamped into the bundle envelope: the externally-visible
2582    // scheme + host (honours `X-Forwarded-Proto`), matching the URL agents
2583    // sign over elsewhere in this file.
2584    let pod_base = format!("{}/", scheme::request_origin(&req));
2585
2586    let options = solid_pod_rs::ExportOptions { include_private };
2587    let bundle = solid_pod_rs::export::export_pod_jsonld(&*state.storage, &pod_base, options)
2588        .await
2589        .map_err(to_actix)?;
2590
2591    let body = serde_json::to_vec(&bundle).map_err(|e| {
2592        actix_web::error::ErrorInternalServerError(format!("export serialise: {e}"))
2593    })?;
2594    Ok(HttpResponse::Ok()
2595        .content_type(solid_pod_rs::export::EXPORT_CONTENT_TYPE)
2596        .body(body))
2597}
2598
2599// ---------------------------------------------------------------------------
2600// Pod management API (JSS parity: /api/accounts/*)
2601// ---------------------------------------------------------------------------
2602
2603#[derive(Debug, Deserialize)]
2604struct CreateAccountRequest {
2605    username: String,
2606    #[serde(default)]
2607    name: Option<String>,
2608}
2609
2610#[derive(Debug, Deserialize)]
2611struct CreatePodRequest {
2612    name: String,
2613}
2614
2615async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
2616    let pod_name = path.into_inner();
2617    let pod_root = format!("/{pod_name}/");
2618    match state.storage.exists(&pod_root).await {
2619        Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
2620        _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
2621    }
2622}
2623
2624fn valid_pod_name(name: &str) -> bool {
2625    !name.is_empty()
2626        && name
2627            .chars()
2628            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
2629}
2630
2631fn request_ip(req: &HttpRequest) -> IpAddr {
2632    req.peer_addr()
2633        .map(|addr| addr.ip())
2634        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
2635}
2636
2637#[derive(Debug)]
2638struct QuotaReservation {
2639    pod: String,
2640    delta: i64,
2641}
2642
2643fn pod_name_from_path(path: &str) -> Option<&str> {
2644    path.trim_start_matches('/')
2645        .split('/')
2646        .next()
2647        .filter(|p| !p.is_empty())
2648}
2649
2650async fn reserve_quota_for_size(
2651    state: &AppState,
2652    path: &str,
2653    new_size: u64,
2654) -> Result<Option<QuotaReservation>, ActixError> {
2655    let (Some(policy), Some(pod)) = (&state.quota, pod_name_from_path(path)) else {
2656        return Ok(None);
2657    };
2658    let old_size = state
2659        .storage
2660        .head(path)
2661        .await
2662        .map(|meta| meta.size)
2663        .unwrap_or(0);
2664    let delta = i128::from(new_size) - i128::from(old_size);
2665    let delta = delta.clamp(i64::MIN as i128, i64::MAX as i128) as i64;
2666    if delta > 0 {
2667        policy
2668            .reserve(pod, delta as u64)
2669            .await
2670            .map_err(|error| actix_web::error::ErrorInsufficientStorage(error.to_string()))?;
2671    }
2672    Ok(Some(QuotaReservation {
2673        pod: pod.to_string(),
2674        delta,
2675    }))
2676}
2677
2678async fn finish_quota_reservation(
2679    state: &AppState,
2680    reservation: Option<QuotaReservation>,
2681    write_succeeded: bool,
2682) {
2683    let (Some(policy), Some(reservation)) = (&state.quota, reservation) else {
2684        return;
2685    };
2686    if write_succeeded {
2687        if reservation.delta < 0 {
2688            policy.record(&reservation.pod, reservation.delta).await;
2689        }
2690    } else if reservation.delta > 0 {
2691        policy.record(&reservation.pod, -reservation.delta).await;
2692    }
2693}
2694
2695async fn handle_create_account(
2696    req: HttpRequest,
2697    state: web::Data<AppState>,
2698    body: web::Json<CreateAccountRequest>,
2699) -> Result<HttpResponse, ActixError> {
2700    if let Some(response) = provisioning_gate(&req, &state, &body.username).await {
2701        return Ok(response);
2702    }
2703    let pod_root = format!("/{}/", body.username);
2704    if state.storage.exists(&pod_root).await.unwrap_or(false) {
2705        return Ok(
2706            HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
2707        );
2708    }
2709
2710    let base_uri = state.nodeinfo.base_url.trim_end_matches('/');
2711    let outcome =
2712        provision_named_pod(&state, &body.username, body.name.as_deref(), base_uri).await?;
2713    Ok(HttpResponse::Created().json(serde_json::json!({
2714        "webid": outcome.webid,
2715        "pod_root": outcome.pod_uri,
2716        "username": body.username,
2717    })))
2718}
2719
2720struct NamedPodOutcome {
2721    webid: String,
2722    pod_uri: String,
2723}
2724
2725/// Apply the same closed-by-default registration policy, validation, and
2726/// per-IP rate limiter to every public provisioning surface.
2727async fn provisioning_gate(
2728    req: &HttpRequest,
2729    state: &AppState,
2730    name: &str,
2731) -> Option<HttpResponse> {
2732    if !valid_pod_name(name) {
2733        return Some(HttpResponse::BadRequest().json(serde_json::json!({
2734            "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
2735        })));
2736    }
2737
2738    let supplied_key = req
2739        .headers()
2740        .get("x-pod-admin-key")
2741        .and_then(|value| value.to_str().ok())
2742        .unwrap_or("");
2743    use subtle::ConstantTimeEq;
2744    let admin_override = state
2745        .admin_key
2746        .as_deref()
2747        .is_some_and(|expected| bool::from(supplied_key.as_bytes().ct_eq(expected.as_bytes())));
2748    let invite = req
2749        .headers()
2750        .get("x-invite-token")
2751        .and_then(|v| v.to_str().ok())
2752        .map(str::trim)
2753        .filter(|t| !t.is_empty());
2754    let closed = !state.nodeinfo.open_registrations && !admin_override;
2755    if closed && invite.is_none() {
2756        return Some(HttpResponse::Forbidden().json(serde_json::json!({
2757            "error": "pod registration is closed"
2758        })));
2759    }
2760
2761    if let Err(retry_after) = state.pod_create_limiter.check(request_ip(req)) {
2762        return Some(
2763            HttpResponse::TooManyRequests()
2764                .insert_header(("Retry-After", retry_after.to_string()))
2765                .json(serde_json::json!({
2766                    "error": "Too Many Requests",
2767                    "message": "Pod creation rate limit exceeded",
2768                    "retryAfter": retry_after
2769                })),
2770        );
2771    }
2772    // Registration is closed and the caller brought an invite: redeem it
2773    // (JSS `validateInvite`). The use is recorded before the pod exists, so a
2774    // single-use invite cannot be spent twice by racing requests; a request
2775    // that then fails validation downstream has still used it.
2776    if closed {
2777        let (Some(token), Some(root)) = (invite, state.data_root.as_deref()) else {
2778            return Some(HttpResponse::Forbidden().json(serde_json::json!({
2779                "error": "pod registration is closed"
2780            })));
2781        };
2782        let refusal =
2783            |why: &str| HttpResponse::Forbidden().json(serde_json::json!({ "error": why }));
2784        match cli::FileInviteStore::new(root).consume(token).await {
2785            Ok(cli::Consume::Granted) => {}
2786            Ok(cli::Consume::Expired) => return Some(refusal("invite has expired")),
2787            Ok(cli::Consume::Exhausted) => return Some(refusal("invite has been used")),
2788            Ok(cli::Consume::Unknown) => return Some(refusal("invalid invite code")),
2789            Err(e) => {
2790                tracing::error!("invite store: {e}");
2791                return Some(
2792                    HttpResponse::InternalServerError()
2793                        .json(serde_json::json!({ "error": "invite store unavailable" })),
2794                );
2795            }
2796        }
2797    }
2798    None
2799}
2800
2801/// Provision only below `/{name}/`; the core `provision_pod` helper expects a
2802/// pod-scoped storage view and must not be called with the server's global
2803/// storage backend.
2804async fn provision_named_pod(
2805    state: &AppState,
2806    name: &str,
2807    display_name: Option<&str>,
2808    base_uri: &str,
2809) -> Result<NamedPodOutcome, ActixError> {
2810    for container in ["", "profile", "inbox", "public", "private", "settings"] {
2811        let path = if container.is_empty() {
2812            format!("/{name}")
2813        } else {
2814            format!("/{name}/{container}")
2815        };
2816        state
2817            .storage
2818            .put(
2819                &format!("{path}.meta"),
2820                Bytes::from_static(b"{}"),
2821                "application/ld+json",
2822            )
2823            .await
2824            .map_err(to_actix)?;
2825    }
2826
2827    let base_uri = base_uri.trim_end_matches('/');
2828    let pod_uri = format!("{base_uri}/{name}/");
2829    let canonical_prefix = format!("{base_uri}/pods/{name}/");
2830    let webid = format!("{pod_uri}profile/card#me");
2831    let profile = solid_pod_rs::webid::generate_webid_html(name, display_name, base_uri)
2832        .replace(&canonical_prefix, &pod_uri);
2833    state
2834        .storage
2835        .put(
2836            &format!("/{name}/profile/card"),
2837            Bytes::from(profile.into_bytes()),
2838            "text/html",
2839        )
2840        .await
2841        .map_err(to_actix)?;
2842
2843    #[cfg(feature = "git")]
2844    if let Some(root) = &state.data_root {
2845        let hook = solid_pod_rs_git::init::GitAutoInit::new();
2846        if let Err(error) = hook.init_repo_at(&root.join(name)).await {
2847            tracing::warn!(pod = name, %error, "git auto-init failed after pod creation");
2848        }
2849    }
2850
2851    Ok(NamedPodOutcome { webid, pod_uri })
2852}
2853
2854async fn handle_create_pod(
2855    req: HttpRequest,
2856    state: web::Data<AppState>,
2857    body: web::Json<CreatePodRequest>,
2858) -> Result<HttpResponse, ActixError> {
2859    if let Some(response) = provisioning_gate(&req, &state, &body.name).await {
2860        return Ok(response);
2861    }
2862
2863    let pod_root = format!("/{}/", body.name);
2864    if state.storage.exists(&pod_root).await.unwrap_or(false) {
2865        return Ok(
2866            HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
2867        );
2868    }
2869
2870    let base_uri = scheme::request_origin(&req);
2871    let outcome = provision_named_pod(&state, &body.name, None, &base_uri).await?;
2872
2873    Ok(HttpResponse::Created()
2874        .insert_header(("Location", outcome.pod_uri.clone()))
2875        .json(serde_json::json!({
2876            "name": body.name,
2877            "webId": outcome.webid,
2878            "podUri": outcome.pod_uri,
2879        })))
2880}
2881
2882// ---------------------------------------------------------------------------
2883// HTTP COPY (JSS parity: handlers/copy.mjs)
2884// ---------------------------------------------------------------------------
2885
2886/// Reduce a COPY `Source` header to a pod path. An absolute URL is accepted
2887/// only for this server's own `host` and is stripped to its path; any other
2888/// host is refused, as are relative references, `.`/`..` segments (also
2889/// percent-encoded) and query or fragment parts. The result is the
2890/// [`canonical_pod_path`] spelling, decoded and cleaned. `None` means reject.
2891fn normalise_copy_source(raw: &str, host: &str) -> Option<String> {
2892    let path = if raw.starts_with("http://") || raw.starts_with("https://") {
2893        let url = url::Url::parse(raw).ok()?;
2894        let authority = match url.port() {
2895            Some(port) => format!("{}:{port}", url.host_str()?),
2896            None => url.host_str()?.to_string(),
2897        };
2898        if !authority.eq_ignore_ascii_case(host)
2899            || url.query().is_some()
2900            || url.fragment().is_some()
2901        {
2902            return None;
2903        }
2904        url.path().to_string()
2905    } else if raw.starts_with('/') && !raw.contains(['?', '#']) {
2906        raw.to_string()
2907    } else {
2908        return None;
2909    };
2910    canonical_pod_path(&path)
2911}
2912
2913async fn handle_copy(
2914    req: HttpRequest,
2915    state: web::Data<AppState>,
2916) -> Result<HttpResponse, ActixError> {
2917    let dest = req.uri().path().to_string();
2918    let auth_pk = extract_pubkey(&req).await;
2919    let agent = agent_uri(auth_pk.as_ref());
2920    enforce_write_ctx(
2921        &state,
2922        &dest,
2923        AccessMode::Write,
2924        agent.as_deref(),
2925        req_origin(&req),
2926    )
2927    .await?;
2928
2929    let source = req
2930        .headers()
2931        .get("source")
2932        .and_then(|v| v.to_str().ok())
2933        .map(|s| s.to_string());
2934    let source = match source {
2935        Some(s) => s,
2936        None => return Ok(HttpResponse::BadRequest().body("Source header required")),
2937    };
2938    let host = scheme::request_host(&req);
2939    let source = match normalise_copy_source(&source, &host) {
2940        Some(s) => s,
2941        None => return Ok(HttpResponse::BadRequest().body("invalid Source")),
2942    };
2943    if !state.dotfiles.is_allowed(Path::new(&source)) {
2944        return Ok(HttpResponse::Forbidden().body("dotfile path denied by allowlist"));
2945    }
2946    // The Source is a read of another resource: it needs the same `acl:Read`
2947    // (sidecars and trail secrets: `acl:Control`) as a plain GET of it.
2948    enforce_read_ctx(&state, &source, agent.as_deref(), req_origin(&req)).await?;
2949
2950    let (body, meta) = match state.storage.get(&source).await {
2951        Ok(v) => v,
2952        Err(PodError::NotFound(_)) => {
2953            return Ok(HttpResponse::NotFound().body("source resource not found"))
2954        }
2955        Err(e) => return Err(to_actix(e)),
2956    };
2957
2958    // Same lockout guard as `handle_put` when the destination is a sidecar.
2959    if protected_resource_for_acl(&dest).is_some()
2960        && !proposed_acl_keeps_caller_control(&body, &meta.content_type, agent.as_deref())
2961    {
2962        return Ok(HttpResponse::Conflict().body(
2963            "refused: the proposed ACL would not grant Control to the caller \
2964             (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
2965        ));
2966    }
2967
2968    let quota = reserve_quota_for_size(&state, &dest, body.len() as u64).await?;
2969    let write = state.storage.put(&dest, body, &meta.content_type).await;
2970    finish_quota_reservation(&state, quota, write.is_ok()).await;
2971    write.map_err(to_actix)?;
2972
2973    // Copy the ACL sidecar only for a caller who holds Control on both the
2974    // source (to read it) and the destination (to write it), and only if the
2975    // copy keeps their own Control. Otherwise it is skipped: the resource
2976    // copy above stands, but no one gains or loses access through COPY.
2977    let src_acl = format!("{}.acl", source.trim_end_matches('/'));
2978    let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
2979    if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
2980        let origin = req_origin(&req);
2981        let permitted = enforce_read_ctx(&state, &src_acl, agent.as_deref(), origin)
2982            .await
2983            .is_ok()
2984            && enforce_write_ctx(
2985                &state,
2986                &dst_acl,
2987                AccessMode::Write,
2988                agent.as_deref(),
2989                origin,
2990            )
2991            .await
2992            .is_ok()
2993            && proposed_acl_keeps_caller_control(
2994                &acl_body,
2995                &acl_meta.content_type,
2996                agent.as_deref(),
2997            );
2998        if permitted {
2999            let quota = reserve_quota_for_size(&state, &dst_acl, acl_body.len() as u64).await?;
3000            let write = state
3001                .storage
3002                .put(&dst_acl, acl_body, &acl_meta.content_type)
3003                .await;
3004            finish_quota_reservation(&state, quota, write.is_ok()).await;
3005            write.map_err(to_actix)?;
3006        }
3007    }
3008
3009    let mut rsp = HttpResponse::Created().finish();
3010    if let Ok(loc) = header::HeaderValue::from_str(&dest) {
3011        rsp.headers_mut().insert(header::LOCATION, loc);
3012    }
3013    Ok(rsp)
3014}
3015
3016// ---------------------------------------------------------------------------
3017// Glob GET (JSS parity: handlers/get.mjs globHandler)
3018// ---------------------------------------------------------------------------
3019
3020async fn handle_glob_get(
3021    req: HttpRequest,
3022    state: web::Data<AppState>,
3023) -> Result<HttpResponse, ActixError> {
3024    let raw_path = req.uri().path().to_string();
3025    // JSS only supports the pattern `{folder}/*`
3026    if !raw_path.ends_with("/*") {
3027        return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
3028    }
3029    let folder = &raw_path[..raw_path.len() - 1]; // strip trailing `*`
3030    let folder = if folder.ends_with('/') {
3031        folder.to_string()
3032    } else {
3033        format!("{folder}/")
3034    };
3035
3036    // P0-1: the glob handler merges every RDF child in `folder` — gate it
3037    // on `acl:Read` of the folder so `GET /private/*` cannot bypass the
3038    // read-authz check applied to plain container GETs.
3039    let auth_pk = extract_pubkey(&req).await;
3040    let agent = agent_uri(auth_pk.as_ref());
3041    let origin = req_origin(&req);
3042    let audience = enforce_read_ctx(&state, &folder, agent.as_deref(), origin).await?;
3043
3044    let children = state.storage.list(&folder).await.map_err(to_actix)?;
3045    let mut merged = String::new();
3046    let mut audience = audience;
3047
3048    for child in &children {
3049        if child.ends_with('/')
3050            || child.ends_with(".acl")
3051            || child.ends_with(".meta")
3052            || child.ends_with(".prov.ttl")
3053        {
3054            continue;
3055        }
3056        let child_path = format!("{folder}{child}");
3057        // Each child needs its own `acl:Read`; one that is refused (or is
3058        // pay-per-read, which a glob must not bill once per child) is left out.
3059        match enforce_read_inner(&state, &child_path, agent.as_deref(), origin, false).await {
3060            Ok(ResponseAudience::Private) => audience = ResponseAudience::Private,
3061            Ok(ResponseAudience::Public) => {}
3062            Err(_) => continue,
3063        }
3064        if let Ok((body, meta)) = state.storage.get(&child_path).await {
3065            if meta.content_type.contains("turtle")
3066                || meta.content_type.contains("n-triples")
3067                || meta.content_type.contains("n3")
3068            {
3069                if let Ok(text) = std::str::from_utf8(&body) {
3070                    merged.push_str(text);
3071                    merged.push('\n');
3072                }
3073            }
3074        }
3075    }
3076
3077    if merged.is_empty() {
3078        return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
3079    }
3080
3081    let mut rsp = HttpResponse::Ok().content_type("text/turtle").body(merged);
3082    set_cache_policy(&mut rsp, "text/turtle", audience);
3083    Ok(rsp)
3084}
3085
3086// ---------------------------------------------------------------------------
3087// Login + password reset (JSS parity: wired to IdP crate)
3088// ---------------------------------------------------------------------------
3089
3090#[derive(Debug, Deserialize)]
3091struct LoginPasswordRequest {
3092    username: String,
3093    password: String,
3094}
3095
3096/// Password login is **not implemented** in this build. Returning a synthetic
3097/// `200 { "message": "login endpoint active" }` (the previous behaviour) is a
3098/// security hazard: a client cannot distinguish it from a genuine successful
3099/// authentication and may treat the caller as logged in. Until a real
3100/// credential verifier is wired to the IdP crate, this returns
3101/// `501 Not Implemented` so no caller mistakes the stub for success.
3102async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
3103    let _ = (&body.username, &body.password);
3104    HttpResponse::NotImplemented().json(serde_json::json!({
3105        "error": "password login is not implemented on this pod"
3106    }))
3107}
3108
3109#[derive(Debug, Deserialize)]
3110struct PasswordResetRequest {
3111    username: String,
3112}
3113
3114/// Password reset is **not implemented**. The prior stub returned a plausible
3115/// "a reset link has been sent" success; that falsely signals a working reset
3116/// flow. Return `501 Not Implemented` instead of faking success.
3117async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
3118    let _ = &body.username;
3119    HttpResponse::NotImplemented().json(serde_json::json!({
3120        "error": "password reset is not implemented on this pod"
3121    }))
3122}
3123
3124#[derive(Debug, Deserialize)]
3125struct PasswordChangeRequest {
3126    token: String,
3127    new_password: String,
3128}
3129
3130/// Password change is **not implemented**. The prior stub returned
3131/// `200 { "message": "password changed" }` without changing anything — a
3132/// caller could believe a new password is in effect. Return
3133/// `501 Not Implemented` rather than fake success.
3134async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
3135    let _ = (&body.token, &body.new_password);
3136    HttpResponse::NotImplemented().json(serde_json::json!({
3137        "error": "password change is not implemented on this pod"
3138    }))
3139}
3140
3141// ---------------------------------------------------------------------------
3142// Payment endpoint (JSS parity: GET /pay/.info)
3143// ---------------------------------------------------------------------------
3144
3145async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
3146    let body = solid_pod_rs::payments::pay_info(&state.pay_config);
3147    HttpResponse::Ok()
3148        .content_type("application/json")
3149        .json(body)
3150}
3151
3152// ---------------------------------------------------------------------------
3153// WAC-gated CORS proxy endpoint — GET /proxy?url=<url>
3154//
3155// Proxies HTTP requests to external URLs after WAC authentication and
3156// SSRF validation. Defence-in-depth:
3157//   1. WAC auth required (reuses existing NIP-98 auth).
3158//   2. Target URL validated against SSRF blocklist (no private/loopback IPs).
3159//   3. Byte cap enforced (default 50 MB).
3160//   4. Redirect targets re-validated against SSRF blocklist.
3161//   5. Sensitive response headers stripped (Set-Cookie, Authorization).
3162//   6. X-Upstream-Authorization header forwarded if present.
3163// ---------------------------------------------------------------------------
3164
3165/// Default byte cap for proxied responses (50 MiB).
3166pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
3167
3168/// Query parameters for the proxy endpoint.
3169#[derive(Debug, Deserialize)]
3170struct ProxyQuery {
3171    url: String,
3172}
3173
3174/// Headers that are stripped from the proxied response for security.
3175const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
3176    "set-cookie",
3177    "set-cookie2",
3178    "authorization",
3179    "www-authenticate",
3180    "proxy-authenticate",
3181    "proxy-authorization",
3182];
3183
3184/// Validate that a URL target is safe for proxying (SSRF protection) and
3185/// return the URL together with the **resolved** IP the connection must be
3186/// pinned to.
3187///
3188/// Layered defence, in order:
3189///   1. scheme allowlist (`http`/`https` only);
3190///   2. literal-host fast reject (IP literals + known cloud-metadata names) —
3191///      [`solid_pod_rs::security::is_safe_url`];
3192///   3. textual `localhost` / unspecified-address variants;
3193///   4. **DNS resolution** via [`solid_pod_rs::security::resolve_and_check`],
3194///      which rejects any hostname resolving to private/loopback/link-local/
3195///      reserved space. The returned `IpAddr` MUST be pinned on the outbound
3196///      client (`ClientBuilder::resolve`) so the socket connects to exactly
3197///      the address that was vetted — closing the DNS-rebinding window between
3198///      this check and the connect, and re-checked on every redirect hop.
3199async fn validate_proxy_target(target: &str) -> Result<(url::Url, IpAddr), HttpResponse> {
3200    let parsed = match url::Url::parse(target) {
3201        Ok(u) => u,
3202        Err(_) => {
3203            return Err(
3204                HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
3205            );
3206        }
3207    };
3208
3209    // Only HTTP(S) schemes are allowed.
3210    match parsed.scheme() {
3211        "http" | "https" => {}
3212        scheme => {
3213            return Err(HttpResponse::BadRequest()
3214                .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
3215        }
3216    }
3217
3218    // SSRF guard: reject URLs with private/loopback/link-local IP hosts
3219    // (literal-host + metadata-hostname fast path, no DNS).
3220    if solid_pod_rs::security::is_safe_url(target).is_err() {
3221        return Err(HttpResponse::Forbidden()
3222            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3223    }
3224
3225    // Additional hostname-based checks for common SSRF bypass patterns.
3226    let host = match parsed.host_str() {
3227        Some(h) => h.to_string(),
3228        None => {
3229            return Err(HttpResponse::BadRequest()
3230                .json(serde_json::json!({"error": "target URL has no host"})))
3231        }
3232    };
3233    let host_lower = host.to_ascii_lowercase();
3234    if host_lower == "localhost"
3235        || host_lower.ends_with(".localhost")
3236        || host_lower == "0.0.0.0"
3237        || host_lower == "[::1]"
3238        || host_lower == "[::0]"
3239    {
3240        return Err(HttpResponse::Forbidden()
3241            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
3242    }
3243
3244    // DNS-resolving check (rebinding guard): resolve the host and reject if
3245    // ANY resolved address is in blocked space. Returns the pinned IP.
3246    match solid_pod_rs::security::resolve_and_check(&host).await {
3247        Ok(ip) => Ok((parsed, ip)),
3248        Err(_) => Err(HttpResponse::Forbidden()
3249            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"}))),
3250    }
3251}
3252
3253/// Build an outbound proxy client with redirects disabled and DNS pinned to
3254/// `ip` for `url`'s host — the connection targets exactly the vetted address.
3255fn build_pinned_proxy_client(url: &url::Url, ip: IpAddr) -> Result<reqwest::Client, ActixError> {
3256    let mut builder = reqwest::Client::builder()
3257        // Never follow redirects automatically — each hop is re-validated and
3258        // re-pinned by the caller.
3259        .redirect(reqwest::redirect::Policy::none());
3260    if let Some(host) = url.host_str() {
3261        // Port 0 ⇒ reqwest uses the URL's port (per its `resolve` docs).
3262        let port = url.port_or_known_default().unwrap_or(0);
3263        builder = builder.resolve(host, std::net::SocketAddr::new(ip, port));
3264    }
3265    builder
3266        .build()
3267        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))
3268}
3269
3270async fn handle_proxy(
3271    req: HttpRequest,
3272    _state: web::Data<AppState>,
3273    query: web::Query<ProxyQuery>,
3274) -> Result<HttpResponse, ActixError> {
3275    // 1. WAC authentication — require an authenticated agent.
3276    let auth_pk = extract_pubkey(&req).await;
3277    let agent = agent_uri(auth_pk.as_ref());
3278    if agent.is_none() {
3279        return Ok(HttpResponse::Unauthorized()
3280            .json(serde_json::json!({"error": "authentication required"})));
3281    }
3282
3283    let mut current_url = query.url.clone();
3284    let mut redirect_count = 0u8;
3285    const MAX_REDIRECTS: u8 = 5;
3286    const TOTAL_TIMEOUT: Duration = Duration::from_secs(30);
3287    let started = Instant::now();
3288
3289    let byte_cap = std::env::var("PROXY_BYTE_CAP")
3290        .ok()
3291        .and_then(|v| {
3292            solid_pod_rs::config::sources::parse_size(&v)
3293                .map(|u| u as usize)
3294                .ok()
3295        })
3296        .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
3297
3298    loop {
3299        let Some(remaining) = TOTAL_TIMEOUT.checked_sub(started.elapsed()) else {
3300            return Ok(HttpResponse::GatewayTimeout()
3301                .json(serde_json::json!({"error": "proxy operation timed out"})));
3302        };
3303        // Validate + DNS-resolve EVERY hop (initial request and each redirect
3304        // target), then pin the outbound client to the resolved IP so the
3305        // socket connects to exactly the vetted address (DNS-rebinding guard).
3306        let (target_url, pinned_ip) = match validate_proxy_target(&current_url).await {
3307            Ok(pair) => pair,
3308            Err(rsp) => return Ok(rsp),
3309        };
3310        let client = build_pinned_proxy_client(&target_url, pinned_ip)?;
3311
3312        let mut upstream_req = client.get(&current_url).timeout(remaining);
3313
3314        // Forward X-Upstream-Authorization if present.
3315        if let Some(auth_val) = req
3316            .headers()
3317            .get("x-upstream-authorization")
3318            .and_then(|v| v.to_str().ok())
3319        {
3320            upstream_req = upstream_req.header("Authorization", auth_val);
3321        }
3322
3323        let response = upstream_req
3324            .send()
3325            .await
3326            .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
3327
3328        // Handle redirects with SSRF re-validation.
3329        if response.status().is_redirection() {
3330            if redirect_count >= MAX_REDIRECTS {
3331                return Ok(HttpResponse::BadGateway()
3332                    .json(serde_json::json!({"error": "too many redirects"})));
3333            }
3334            if let Some(location) = response.headers().get("location") {
3335                let loc_str = location
3336                    .to_str()
3337                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
3338                // Resolve relative redirects against current URL.
3339                let base = url::Url::parse(&current_url)
3340                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
3341                let resolved = base
3342                    .join(loc_str)
3343                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
3344                current_url = resolved.to_string();
3345                redirect_count += 1;
3346                continue;
3347            }
3348            return Ok(HttpResponse::BadGateway()
3349                .json(serde_json::json!({"error": "redirect without location"})));
3350        }
3351
3352        // Read the response body with byte cap enforcement.
3353        let upstream_status = response.status().as_u16();
3354        let upstream_content_type = response
3355            .headers()
3356            .get("content-type")
3357            .and_then(|v| v.to_str().ok())
3358            .unwrap_or("application/octet-stream")
3359            .to_string();
3360        if response
3361            .content_length()
3362            .is_some_and(|length| length > byte_cap as u64)
3363        {
3364            return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3365                "error": "proxied response exceeds byte cap",
3366                "limit": byte_cap
3367            })));
3368        }
3369
3370        // Collect response headers, stripping sensitive ones.
3371        let mut forwarded_headers: Vec<(String, String)> = Vec::new();
3372        for (name, value) in response.headers() {
3373            let name_lower = name.as_str().to_ascii_lowercase();
3374            if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
3375                continue;
3376            }
3377            // Skip hop-by-hop headers.
3378            if matches!(
3379                name_lower.as_str(),
3380                "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
3381            ) {
3382                continue;
3383            }
3384            if let Ok(val_str) = value.to_str() {
3385                forwarded_headers.push((name_lower, val_str.to_string()));
3386            }
3387        }
3388
3389        let mut stream = response.bytes_stream();
3390        let mut body_bytes = Vec::with_capacity(byte_cap.min(64 * 1024));
3391        while let Some(chunk) = stream.next().await {
3392            let chunk =
3393                chunk.map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
3394            if body_bytes.len().saturating_add(chunk.len()) > byte_cap {
3395                return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
3396                    "error": "proxied response exceeds byte cap",
3397                    "limit": byte_cap
3398                })));
3399            }
3400            body_bytes.extend_from_slice(&chunk);
3401        }
3402
3403        // Build the response.
3404        let mut rsp = HttpResponse::build(
3405            StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
3406        );
3407        rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
3408        rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
3409
3410        // Forward non-sensitive headers.
3411        for (name, value) in &forwarded_headers {
3412            if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
3413                if let Ok(hval) = header::HeaderValue::from_str(value) {
3414                    rsp.insert_header((hname, hval));
3415                }
3416            }
3417        }
3418
3419        return Ok(rsp.body(body_bytes));
3420    }
3421}
3422
3423// ---------------------------------------------------------------------------
3424// Percent-decode + dotdot re-check middleware
3425// ---------------------------------------------------------------------------
3426
3427/// Actix middleware that rejects requests containing `..` path-traversal sequences.
3428pub struct PathTraversalGuard;
3429
3430impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
3431where
3432    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3433    B: 'static,
3434{
3435    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3436    type Error = ActixError;
3437    type InitError = ();
3438    type Transform = PathTraversalGuardMiddleware<S>;
3439    type Future = Ready<Result<Self::Transform, Self::InitError>>;
3440
3441    fn new_transform(&self, service: S) -> Self::Future {
3442        ready(Ok(PathTraversalGuardMiddleware { service }))
3443    }
3444}
3445
3446/// Per-request service instance produced by [`PathTraversalGuard`].
3447pub struct PathTraversalGuardMiddleware<S> {
3448    service: S,
3449}
3450
3451impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
3452where
3453    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3454    B: 'static,
3455{
3456    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3457    type Error = ActixError;
3458    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3459
3460    actix_web::dev::forward_ready!(service);
3461
3462    fn call(&self, req: ServiceRequest) -> Self::Future {
3463        // Decode the raw path twice so that `%252e%252e` → `%2e%2e` →
3464        // `..` can be caught even though NormalizePath already ran once.
3465        let raw = req.path().to_string();
3466        if path_is_traversal(&raw) {
3467            let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
3468            let sr = req.into_response(rsp.map_into_boxed_body());
3469            return Box::pin(async move { Ok(sr.map_into_right_body()) });
3470        }
3471        let fut = self.service.call(req);
3472        Box::pin(async move {
3473            let resp = fut.await?;
3474            Ok(resp.map_into_left_body())
3475        })
3476    }
3477}
3478
3479fn path_is_traversal(path: &str) -> bool {
3480    // Two passes of percent-decode catches double-encoding.
3481    let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
3482    let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
3483    for seg in once.split('/').chain(twice.split('/')) {
3484        if seg == ".." || seg == "." {
3485            return true;
3486        }
3487    }
3488    // Also flag any raw escape sequences that decode to a traversal
3489    // segment even when buried inside a component (e.g. `foo%2f..%2fbar`).
3490    twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..")
3491}
3492
3493// ---------------------------------------------------------------------------
3494// JSS-compatible CORS response headers
3495// ---------------------------------------------------------------------------
3496
3497/// Adds the same CORS envelope JSS emits from its global `onRequest` hook.
3498///
3499/// When `allowed_origins` is non-empty, the `Access-Control-Allow-Origin`
3500/// header is only reflected for origins in the list; requests from other
3501/// origins receive no ACAO header. When the list is empty (default), the
3502/// request `Origin` is echoed back (wildcard-equivalent, suitable for local dev).
3503pub struct CorsHeaders {
3504    pub allowed_origins: Arc<Vec<String>>,
3505}
3506
3507impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
3508where
3509    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3510    B: 'static,
3511{
3512    type Response = ServiceResponse<B>;
3513    type Error = ActixError;
3514    type InitError = ();
3515    type Transform = CorsHeadersMiddleware<S>;
3516    type Future = Ready<Result<Self::Transform, Self::InitError>>;
3517
3518    fn new_transform(&self, service: S) -> Self::Future {
3519        ready(Ok(CorsHeadersMiddleware {
3520            service,
3521            allowed_origins: self.allowed_origins.clone(),
3522        }))
3523    }
3524}
3525
3526/// Per-request service instance produced by [`CorsHeaders`].
3527pub struct CorsHeadersMiddleware<S> {
3528    service: S,
3529    allowed_origins: Arc<Vec<String>>,
3530}
3531
3532impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
3533where
3534    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3535    B: 'static,
3536{
3537    type Response = ServiceResponse<B>;
3538    type Error = ActixError;
3539    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3540
3541    actix_web::dev::forward_ready!(service);
3542
3543    fn call(&self, req: ServiceRequest) -> Self::Future {
3544        let origin = req
3545            .headers()
3546            .get(header::ORIGIN)
3547            .and_then(|v| v.to_str().ok())
3548            .map(str::to_string);
3549        let allowed = self.allowed_origins.clone();
3550        let fut = self.service.call(req);
3551        Box::pin(async move {
3552            let mut resp = fut.await?;
3553            add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
3554            Ok(resp)
3555        })
3556    }
3557}
3558
3559/// `Access-Control-Expose-Headers` value stamped on every pod response.
3560///
3561/// Matches JSS 0.0.221 `getCorsHeaders` (`src/ldp/headers.js`): the 0.0.221
3562/// delta (PR #629) adds `Accept-Query`, `Last-Modified` and `WWW-Authenticate`
3563/// so cross-origin clients can read the auth challenge, cache validators and
3564/// the query-format advertisement. `Accept-Query` is exposed for parity; this
3565/// server does not emit it yet (no QUERY method), so listing it is inert.
3566pub(crate) const CORS_EXPOSE_HEADERS: &str = "Accept-Patch, Accept-Post, Accept-Query, Accept-Ranges, Allow, Content-Length, Content-Range, Content-Type, ETag, Last-Modified, Link, Location, Updates-Via, WAC-Allow, WWW-Authenticate, X-Cost, X-Balance, X-Pay-Currency";
3567
3568fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
3569    // Handler-authoritative CORS (JSS #548 parity): the git smart-HTTP and
3570    // forge surfaces stamp their own deliberate `*` CORS trio (those
3571    // protocols are anonymously clonable by design, and browser git clients
3572    // need the `WWW-Authenticate` challenge to be CORS-readable even when
3573    // the pod's app allowlist would block the origin). When a handler has
3574    // already set `Access-Control-Allow-Origin`, defer to it instead of
3575    // clobbering — previously this was mode-dependent (wildcard mode
3576    // overwrote, allowlist mode preserved), which made git/forge CORS
3577    // inconsistent across deployments.
3578    if headers.contains_key(header::ACCESS_CONTROL_ALLOW_ORIGIN) {
3579        return;
3580    }
3581    // Determine the effective ACAO value and whether credentials may be
3582    // allowed. The web platform forbids combining a credentialed response
3583    // (`Access-Control-Allow-Credentials: true`) with a reflected/wildcard
3584    // origin — doing so lets *any* site read authenticated responses. So:
3585    //
3586    //   * allowlist configured + origin in it  → reflect the origin AND allow
3587    //     credentials (the safe, intended cross-origin case);
3588    //   * allowlist configured + origin absent → emit no CORS headers (block);
3589    //   * no allowlist (dev/wildcard mode)      → emit `*` and DO NOT allow
3590    //     credentials — never reflect an arbitrary origin with credentials.
3591    let (origin_value, allow_credentials): (String, bool) = if allowed.is_empty() {
3592        ("*".to_string(), false)
3593    } else {
3594        match origin.filter(|o| allowed.iter().any(|a| a == *o)) {
3595            Some(o) => (o.to_string(), true),
3596            // Origin not in the allowlist — skip all CORS headers so the
3597            // browser's CORS check fails.
3598            None => return,
3599        }
3600    };
3601
3602    let mut pairs = vec![
3603        ("access-control-allow-origin", origin_value.as_str()),
3604        (
3605            "access-control-allow-methods",
3606            "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
3607        ),
3608        (
3609            "access-control-allow-headers",
3610            "Accept, Authorization, Content-Type, DPoP, Git-Protocol, If-Match, If-None-Match, Link, Range, Slug, Origin",
3611        ),
3612        (
3613            "access-control-expose-headers",
3614            CORS_EXPOSE_HEADERS,
3615        ),
3616        ("access-control-max-age", "86400"),
3617    ];
3618    // Only advertise credentials support for an allowlisted, non-wildcard
3619    // origin. Never with `*`.
3620    if allow_credentials {
3621        pairs.push(("access-control-allow-credentials", "true"));
3622    }
3623
3624    for (name, value) in pairs {
3625        if let (Ok(name), Ok(value)) = (
3626            header::HeaderName::from_lowercase(name.as_bytes()),
3627            header::HeaderValue::from_str(value),
3628        ) {
3629            headers.insert(name, value);
3630        }
3631    }
3632}
3633
3634// ---------------------------------------------------------------------------
3635// Sprint 11 (row 158): top-level 5xx logging middleware.
3636//
3637// JSS ref: commit 5b34d72 (#312) — "Top-level Fastify error handler,
3638// full stack on 5xx". Mirror the behaviour in actix: intercept any
3639// response whose status is 5xx, emit a structured `tracing::error!`
3640// with the method, path, status, error chain, and (when
3641// `RUST_BACKTRACE=1`) a captured backtrace. The response body is not
3642// altered; we only observe.
3643// ---------------------------------------------------------------------------
3644
3645/// Observes outbound responses and logs 5xx results with the full
3646/// error chain. Pass-through on 2xx/3xx/4xx. Shaped as an actix
3647/// [`Transform`] so it slots into the middleware stack in
3648/// [`build_app`].
3649pub struct ErrorLoggingMiddleware;
3650
3651impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
3652where
3653    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3654    B: 'static,
3655{
3656    type Response = ServiceResponse<B>;
3657    type Error = ActixError;
3658    type InitError = ();
3659    type Transform = ErrorLoggingMiddlewareService<S>;
3660    type Future = Ready<Result<Self::Transform, Self::InitError>>;
3661
3662    fn new_transform(&self, service: S) -> Self::Future {
3663        ready(Ok(ErrorLoggingMiddlewareService { service }))
3664    }
3665}
3666
3667/// Per-request service instance produced by [`ErrorLoggingMiddleware`].
3668pub struct ErrorLoggingMiddlewareService<S> {
3669    service: S,
3670}
3671
3672impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
3673where
3674    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3675    B: 'static,
3676{
3677    type Response = ServiceResponse<B>;
3678    type Error = ActixError;
3679    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3680
3681    actix_web::dev::forward_ready!(service);
3682
3683    fn call(&self, req: ServiceRequest) -> Self::Future {
3684        // Snapshot fields we need for the log line before the request
3685        // moves into the inner service.
3686        let method = req.method().as_str().to_string();
3687        let path = req.path().to_string();
3688
3689        let fut = self.service.call(req);
3690        Box::pin(async move {
3691            let response = fut.await?;
3692            let status = response.status();
3693            if status.is_server_error() {
3694                log_5xx(&method, &path, status, response.response().error());
3695            }
3696            Ok(response)
3697        })
3698    }
3699}
3700
3701/// Emit the structured 5xx log line. Captures a backtrace only when
3702/// `RUST_BACKTRACE=1` is set so production logs don't bloat unless the
3703/// operator opted in.
3704fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
3705    // Full error chain — include `source()` walk so downstream
3706    // `PodError` variants surface instead of being swallowed by
3707    // actix's top-level wrapper.
3708    let chain = match error {
3709        Some(e) => format_error_chain(e),
3710        None => "<no error attached to response>".to_string(),
3711    };
3712
3713    let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
3714        Some(std::backtrace::Backtrace::force_capture().to_string())
3715    } else {
3716        None
3717    };
3718
3719    tracing::error!(
3720        target: "solid_pod_rs_server::http",
3721        method = %method,
3722        path = %path,
3723        status = %status.as_u16(),
3724        error.chain = %chain,
3725        backtrace = backtrace.as_deref().unwrap_or(""),
3726        "5xx response"
3727    );
3728}
3729
3730/// Walk an actix `Error` + its `source()` chain into a single
3731/// human-readable string (one segment per cause, separated by ` -> `).
3732///
3733/// `actix_web::Error` does not expose a stable `source()` accessor,
3734/// and `ResponseError` in actix-web 4 does not extend
3735/// [`std::error::Error`]. We surface the `Display` form of the
3736/// response error (which captures the message operators care about
3737/// on 5xx) and append the actix `Debug` dump for deep diagnosis —
3738/// the dump already includes the inner cause chain that actix-http
3739/// preserves internally.
3740fn format_error_chain(e: &actix_web::Error) -> String {
3741    let summary = format!("{}", e.as_response_error());
3742    let debug = format!("{e:?}");
3743    if debug == summary || debug.is_empty() {
3744        summary
3745    } else {
3746        format!("{summary} -> {debug}")
3747    }
3748}
3749
3750// ---------------------------------------------------------------------------
3751// Dotfile allowlist middleware
3752// ---------------------------------------------------------------------------
3753
3754/// Actix middleware that blocks dotfile paths unless they appear on the allowlist.
3755pub struct DotfileGuard {
3756    allow: Arc<DotfileAllowlist>,
3757    hosts: Option<Arc<HostPolicy>>,
3758}
3759
3760/// `Host` allow-list enforced by [`DotfileGuard`] (security review M1).
3761///
3762/// A request whose effective host (the plain `Host` header, or the
3763/// forwarding headers behind `trust_proxy`) is not on the list is refused with
3764/// `421 Misdirected Request` before any handler runs, so a client cannot
3765/// address the server under a name it does not serve.
3766pub struct HostPolicy {
3767    /// Permitted hosts (`host` or `host:port`, case-insensitive).
3768    pub allowed: Vec<String>,
3769    /// Honour `X-Forwarded-Host` / `Forwarded` when computing the host.
3770    pub trust_proxy: bool,
3771}
3772
3773impl DotfileGuard {
3774    pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
3775        Self { allow, hosts: None }
3776    }
3777
3778    /// Also enforce a `Host` allow-list; an empty list changes nothing.
3779    pub fn with_host_policy(mut self, policy: HostPolicy) -> Self {
3780        if !policy.allowed.is_empty() {
3781            self.hosts = Some(Arc::new(policy));
3782        }
3783        self
3784    }
3785}
3786
3787impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
3788where
3789    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3790    B: 'static,
3791{
3792    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3793    type Error = ActixError;
3794    type InitError = ();
3795    type Transform = DotfileGuardMiddleware<S>;
3796    type Future = Ready<Result<Self::Transform, Self::InitError>>;
3797
3798    fn new_transform(&self, service: S) -> Self::Future {
3799        ready(Ok(DotfileGuardMiddleware {
3800            service,
3801            allow: self.allow.clone(),
3802            hosts: self.hosts.clone(),
3803        }))
3804    }
3805}
3806
3807/// Per-request service instance produced by [`DotfileGuard`].
3808pub struct DotfileGuardMiddleware<S> {
3809    service: S,
3810    allow: Arc<DotfileAllowlist>,
3811    hosts: Option<Arc<HostPolicy>>,
3812}
3813
3814impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
3815where
3816    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
3817    B: 'static,
3818{
3819    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
3820    type Error = ActixError;
3821    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
3822
3823    actix_web::dev::forward_ready!(service);
3824
3825    fn call(&self, req: ServiceRequest) -> Self::Future {
3826        if let Some(p) = &self.hosts {
3827            let host = scheme::host_with(req.request(), None, p.trust_proxy);
3828            if !scheme::host_allowed(&host, &p.allowed) {
3829                let rsp = HttpResponse::build(actix_web::http::StatusCode::MISDIRECTED_REQUEST)
3830                    .body("host not served here");
3831                let sr = req.into_response(rsp.map_into_boxed_body());
3832                return Box::pin(async move { Ok(sr.map_into_right_body()) });
3833            }
3834        }
3835        let path = req.path().to_string();
3836        // Whitelist the well-known discovery paths even though they
3837        // contain a dotfile component — they are part of Solid's stable
3838        // interop surface. `/pay/.*` is the same case: the payment control
3839        // surface (`.info`, `.balance`, `.deposit`, `.offers`, `.sell`,
3840        // `.swap`, `.pool`) is dot-prefixed protocol endpoints, not pod
3841        // dotfiles, so the dotfile allowlist must not shadow them.
3842        let allow_system_route =
3843            path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
3844        if !allow_system_route {
3845            let pb = PathBuf::from(&path);
3846            if !self.allow.is_allowed(Path::new(&pb)) {
3847                let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
3848                let sr = req.into_response(rsp.map_into_boxed_body());
3849                return Box::pin(async move { Ok(sr.map_into_right_body()) });
3850            }
3851        }
3852        let fut = self.service.call(req);
3853        Box::pin(async move {
3854            let resp = fut.await?;
3855            Ok(resp.map_into_left_body())
3856        })
3857    }
3858}
3859
3860// ---------------------------------------------------------------------------
3861// Git control panel API helpers (feature = "git")
3862// ---------------------------------------------------------------------------
3863
3864#[cfg(feature = "git")]
3865pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
3866    if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
3867        return None;
3868    }
3869    state.data_root.as_ref().map(|root| root.join(pubkey))
3870}
3871
3872/// Provenance composition hook (ADR-059 Phase 5): after a SUCCESSFUL LDP write
3873/// to a **git-backed** pod, record the write through the single canonical
3874/// [`ProvenanceLog::record`] path — the cheap, always-on git-mark **always**,
3875/// plus the expensive Bitcoin block-trail anchor **opt-in** when the resource's
3876/// ACL carries a `ProvenanceAnchor` condition. A PROV-O sidecar is persisted
3877/// at `<resource>.prov.ttl`.
3878///
3879/// **Single path — no parallel mark call.** This composes via
3880/// [`ProvenanceLog`]; it does *not* call `ShellGitMarker::mark_write` directly.
3881/// `ProvenanceLog::record` runs the git-mark, then conditionally the anchor
3882/// (per the resolved [`AnchorPolicy`]), binding the anchor's `state_hash` to
3883/// the git commit SHA (master-plan §2.3). The `Epoch` policy batches the SHA
3884/// into the per-pod epoch ([`handlers::prov::epoch_push_and_maybe_anchor`]) so
3885/// one Bitcoin tx notarises many commits (ADR-059 D5).
3886///
3887/// **Additive and best-effort by contract.** The LDP write has *already*
3888/// succeeded and the HTTP response is already determined when this runs. Every
3889/// failure — no git binary, a commit error, an anchor error, a sidecar-write
3890/// error — is logged at `warn` and swallowed: a provenance failure must NEVER
3891/// change the write's response status. A failed *anchor* never fails the write
3892/// and never suppresses the git-mark sidecar.
3893///
3894/// **git-backed-only.** A mark is produced only when `data_root` is configured
3895/// AND a git repository exists at `data_root/{pod}/.git`. Non-git / in-memory /
3896/// cloud-backed pods are skipped silently.
3897///
3898/// **No recursive marking.** Writes to ACL/meta/provenance sidecars
3899/// (`*.acl`, `*.meta`, `*.prov.ttl`) are skipped — marking a `.prov.ttl` would
3900/// recurse, and ACL/meta writes are control-plane, not content.
3901#[cfg(feature = "git")]
3902async fn git_mark_write(
3903    state: &AppState,
3904    resource_path: &str,
3905    agent: Option<&str>,
3906    message: &str,
3907) -> ProvenanceReceipt {
3908    use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
3909    use solid_pod_rs_git::mark::ShellGitMarker;
3910
3911    // Skip control-plane / provenance sidecars — never mark these, and never
3912    // recurse on our own `.prov.ttl` output.
3913    if resource_path.ends_with(".acl")
3914        || resource_path.ends_with(".meta")
3915        || resource_path.ends_with(".prov.ttl")
3916    {
3917        return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::ExcludedPath);
3918    }
3919    // Containers (trailing slash) are not file writes — nothing to commit.
3920    if resource_path.ends_with('/') {
3921        return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::Container);
3922    }
3923
3924    // data_root is required to locate the pod repo on disk.
3925    let Some(data_root) = state.data_root.as_ref() else {
3926        return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured);
3927    };
3928
3929    // The pod is the first path segment; the repo lives at data_root/{pod}.
3930    let trimmed = resource_path.trim_start_matches('/');
3931    let mut segments = trimmed.splitn(2, '/');
3932    let pod = segments.next().unwrap_or("");
3933    let rel = segments.next().unwrap_or("");
3934    if pod.is_empty() || rel.is_empty() {
3935        return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::UnresolvablePath);
3936    }
3937    let repo = data_root.join(pod);
3938
3939    // git-backed check: a `.git` dir must exist at the pod root. Non-git pods
3940    // are skipped silently — this is the runtime guard that keeps memory /
3941    // cloud pods unaffected even when the `git` feature is compiled in.
3942    if !repo.join(".git").is_dir() {
3943        return ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotGitBacked);
3944    }
3945
3946    let agent_did = agent.unwrap_or("urn:solid:anonymous");
3947    let created = std::time::SystemTime::now()
3948        .duration_since(std::time::UNIX_EPOCH)
3949        .map(|d| d.as_secs())
3950        .unwrap_or(0);
3951
3952    // Resolve this resource's anchor policy from its effective ACL (the
3953    // `ProvenanceAnchor` condition → HighValue/Epoch; absent → Never).
3954    let (policy, ticker_override) =
3955        handlers::prov::resolve_anchor_policy(state, resource_path).await;
3956
3957    // Build the composition log: cheap git-marker ALWAYS; expensive anchorer
3958    // ONLY when the policy wants it AND the pod is configured for anchoring.
3959    // For `Epoch` the anchorer is still needed (to anchor the batch root on
3960    // close), so build it for any anchoring policy.
3961    let marker = std::sync::Arc::new(ShellGitMarker::new());
3962    let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
3963        None
3964    } else {
3965        handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
3966    };
3967    let (log, ticker, network) = match &anchorer_bundle {
3968        Some((anchorer, ticker, network)) => (
3969            ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
3970            ticker.clone(),
3971            network.clone(),
3972        ),
3973        // No anchorer available (or policy Never): git-mark-only log.
3974        None => (
3975            ProvenanceLog::new(marker.clone()),
3976            String::new(),
3977            String::new(),
3978        ),
3979    };
3980
3981    // `record()` anchors INLINE only for HighValue (high_value=true). Epoch
3982    // defers to the accumulator below, so we pass it as Never to `record` and
3983    // batch the SHA ourselves; HighValue/Never flow straight through.
3984    let record_policy = match policy {
3985        AnchorPolicy::Epoch => AnchorPolicy::Never,
3986        other => other,
3987    };
3988    let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
3989
3990    // SINGLE canonical path: compose via ProvenanceLog::record (git-mark always,
3991    // anchor opt-in). A git-mark failure is the only hard error (the write
3992    // already succeeded, so we just log + return).
3993    let write_record = solid_pod_rs::provenance::WriteRecord {
3994        repo: &repo,
3995        path: rel,
3996        agent_did,
3997        message,
3998        policy: record_policy,
3999        high_value,
4000        ticker: &ticker,
4001        network: &network,
4002        created,
4003    };
4004    // ADR-2004: `record_receipt` reports each tier separately instead of
4005    // collapsing a failed git-mark into an error the caller could only log and
4006    // discard. The write has already succeeded; what changes here is that the
4007    // caller now learns WHICH provenance tier it actually got.
4008    let mut receipt = log.record_receipt(write_record).await;
4009    // `record_receipt` only sees the repo-relative path; restore the full
4010    // pod-relative resource path (`/{pod}/{rel}`).
4011    receipt.resource = resource_path.to_string();
4012
4013    let Some(git) = receipt.mark.clone() else {
4014        // The bytes are stored but nothing records the write. Still not an
4015        // HTTP error (the LDP write succeeded), but no longer invisible: the
4016        // typed receipt goes back to the handler, which surfaces it.
4017        tracing::warn!(
4018            target: "solid_pod_rs_server::git_mark",
4019            resource = %resource_path,
4020            "provenance record failed (write already succeeded): {}",
4021            receipt.mark_error.as_deref().unwrap_or("unspecified")
4022        );
4023        return receipt;
4024    };
4025    if let Some(err) = &receipt.anchor_error {
4026        // A failed anchor never suppresses the git-mark, but it must not be
4027        // silently downgraded to "marked" either.
4028        tracing::warn!(
4029            target: "solid_pod_rs_server::git_mark",
4030            resource = %resource_path,
4031            commit = %git.commit_sha,
4032            "block-trail anchor failed (git-mark stands): {err}"
4033        );
4034    }
4035    let mark = solid_pod_rs::provenance::ProvenanceMark {
4036        resource: resource_path.to_string(),
4037        git: git.clone(),
4038        anchor: receipt.anchor.clone(),
4039        agent_did: agent_did.to_string(),
4040        created,
4041    };
4042
4043    // Epoch policy: batch the freshly-produced commit SHA; anchor the batch
4044    // root once when the epoch fills (best-effort — a failed batch anchor never
4045    // fails the write nor the git-mark).
4046    if matches!(policy, AnchorPolicy::Epoch) {
4047        if let Some((anchorer, _, _)) = &anchorer_bundle {
4048            match handlers::prov::epoch_push_and_maybe_anchor(
4049                state,
4050                anchorer,
4051                &ticker,
4052                &network,
4053                &mark.git.commit_sha,
4054            )
4055            .await
4056            {
4057                Ok(Some(closed)) => tracing::debug!(
4058                    target: "solid_pod_rs_server::git_mark",
4059                    root = %closed.root,
4060                    n = closed.commits.len(),
4061                    "epoch anchored (one tx notarises {} commits)", closed.commits.len()
4062                ),
4063                Ok(None) => {}
4064                Err(e) => tracing::warn!(
4065                    target: "solid_pod_rs_server::git_mark",
4066                    "epoch batch/anchor failed (swallowed): {e}"
4067                ),
4068            }
4069        }
4070    }
4071
4072    // Persist the PROV-O sidecar at <resource>.prov.ttl. This write also fires
4073    // the FS-watch StorageEvent the `Updates-via` notification stream relays,
4074    // so subscribers see the new mark. It ends in `.prov.ttl`, so the skip
4075    // guard above prevents any recursion.
4076    let ttl = prov_ttl(&mark);
4077    let sidecar = format!("{resource_path}.prov.ttl");
4078    if let Err(e) = state
4079        .storage
4080        .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
4081        .await
4082    {
4083        tracing::warn!(
4084            target: "solid_pod_rs_server::git_mark",
4085            sidecar = %sidecar,
4086            "provenance sidecar write failed: {e}"
4087        );
4088        // The commit stands, so the receipt keeps its `local-mark-committed`
4089        // stage; the sidecar fault is reported alongside it.
4090        receipt.mark_error = Some(format!("PROV-O sidecar write failed: {e}"));
4091        return receipt;
4092    }
4093
4094    tracing::debug!(
4095        target: "solid_pod_rs_server::git_mark",
4096        resource = %resource_path,
4097        commit = %mark.git.commit_sha,
4098        stage = %receipt.stage(),
4099        "provenance recorded"
4100    );
4101    receipt
4102}
4103
4104/// No-op shim when the `git` feature is disabled, so the write handlers can
4105/// call `git_mark_write(...)` unconditionally without per-call-site `cfg`.
4106#[cfg(not(feature = "git"))]
4107#[inline]
4108async fn git_mark_write(
4109    _state: &AppState,
4110    resource_path: &str,
4111    _agent: Option<&str>,
4112    _message: &str,
4113) -> ProvenanceReceipt {
4114    ProvenanceReceipt::skipped(resource_path, ProvenanceSkip::NotConfigured)
4115}
4116
4117#[cfg(feature = "git")]
4118pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
4119    require_pod_owner_with_body(req, pod_pubkey, None).await
4120}
4121
4122#[cfg(feature = "git")]
4123pub(crate) async fn require_pod_owner_with_body(
4124    req: &HttpRequest,
4125    pod_pubkey: &str,
4126    body: Option<&[u8]>,
4127) -> Option<String> {
4128    let caller = extract_pubkey_with_body(req, body).await?;
4129    if caller != pod_pubkey {
4130        return None;
4131    }
4132    Some(caller)
4133}
4134
4135#[cfg(feature = "git")]
4136fn git_json_err(msg: &str, status: u16) -> HttpResponse {
4137    HttpResponse::build(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
4138        .content_type("application/json")
4139        .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
4140}
4141
4142// Request body types for git control panel endpoints.
4143#[cfg(feature = "git")]
4144#[derive(serde::Deserialize)]
4145struct GitStageBody {
4146    paths: Option<Vec<String>>,
4147    all: Option<bool>,
4148}
4149
4150#[cfg(feature = "git")]
4151#[derive(serde::Deserialize)]
4152struct GitCommitBody {
4153    message: String,
4154    author_name: Option<String>,
4155    author_email: Option<String>,
4156}
4157
4158#[cfg(feature = "git")]
4159#[derive(serde::Deserialize)]
4160struct GitBranchBody {
4161    name: String,
4162}
4163
4164// ── Control panel handlers ──────────────────────────────────────────────────
4165
4166#[cfg(feature = "git")]
4167async fn handle_git_status(
4168    path: web::Path<String>,
4169    req: HttpRequest,
4170    state: web::Data<AppState>,
4171) -> HttpResponse {
4172    let pubkey = path.into_inner();
4173    if require_pod_owner(&req, &pubkey).await.is_none() {
4174        return git_json_err("Authentication required", 401);
4175    }
4176    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4177        return git_json_err("Git not available (no FS backend)", 501);
4178    };
4179    match solid_pod_rs_git::api::git_status(&repo).await {
4180        Ok(s) => HttpResponse::Ok()
4181            .content_type("application/json")
4182            .body(serde_json::to_string(&s).unwrap_or_default()),
4183        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4184    }
4185}
4186
4187#[cfg(feature = "git")]
4188async fn handle_git_log(
4189    path: web::Path<String>,
4190    req: HttpRequest,
4191    state: web::Data<AppState>,
4192    query: web::Query<std::collections::HashMap<String, String>>,
4193) -> HttpResponse {
4194    let pubkey = path.into_inner();
4195    if require_pod_owner(&req, &pubkey).await.is_none() {
4196        return git_json_err("Authentication required", 401);
4197    }
4198    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4199        return git_json_err("Git not available (no FS backend)", 501);
4200    };
4201    let limit: u32 = query
4202        .get("limit")
4203        .and_then(|v| v.parse().ok())
4204        .unwrap_or(20);
4205    match solid_pod_rs_git::api::git_log(&repo, limit).await {
4206        Ok(entries) => HttpResponse::Ok()
4207            .content_type("application/json")
4208            .body(serde_json::to_string(&entries).unwrap_or_default()),
4209        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4210    }
4211}
4212
4213#[cfg(feature = "git")]
4214async fn handle_git_diff(
4215    path: web::Path<String>,
4216    req: HttpRequest,
4217    state: web::Data<AppState>,
4218    query: web::Query<std::collections::HashMap<String, String>>,
4219) -> HttpResponse {
4220    let pubkey = path.into_inner();
4221    if require_pod_owner(&req, &pubkey).await.is_none() {
4222        return git_json_err("Authentication required", 401);
4223    }
4224    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4225        return git_json_err("Git not available (no FS backend)", 501);
4226    };
4227    let file_path = query.get("path").map(String::as_str);
4228    let staged = query
4229        .get("staged")
4230        .map(|v| v == "true" || v == "1")
4231        .unwrap_or(false);
4232    match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
4233        Ok(diff) => HttpResponse::Ok().content_type("text/plain").body(diff),
4234        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4235    }
4236}
4237
4238#[cfg(feature = "git")]
4239async fn handle_git_stage(
4240    path: web::Path<String>,
4241    req: HttpRequest,
4242    state: web::Data<AppState>,
4243    body: web::Bytes,
4244) -> HttpResponse {
4245    let pubkey = path.into_inner();
4246    if require_pod_owner(&req, &pubkey).await.is_none() {
4247        return git_json_err("Authentication required", 401);
4248    }
4249    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4250        return git_json_err("Git not available (no FS backend)", 501);
4251    };
4252    let parsed: GitStageBody = match serde_json::from_slice(&body) {
4253        Ok(v) => v,
4254        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4255    };
4256    let paths = parsed.paths.unwrap_or_default();
4257    let all = parsed.all.unwrap_or(false);
4258    match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
4259        Ok(()) => HttpResponse::Ok()
4260            .content_type("application/json")
4261            .body(r#"{"ok":true}"#),
4262        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4263    }
4264}
4265
4266#[cfg(feature = "git")]
4267async fn handle_git_unstage(
4268    path: web::Path<String>,
4269    req: HttpRequest,
4270    state: web::Data<AppState>,
4271    body: web::Bytes,
4272) -> HttpResponse {
4273    let pubkey = path.into_inner();
4274    if require_pod_owner(&req, &pubkey).await.is_none() {
4275        return git_json_err("Authentication required", 401);
4276    }
4277    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4278        return git_json_err("Git not available (no FS backend)", 501);
4279    };
4280    let parsed: GitStageBody = match serde_json::from_slice(&body) {
4281        Ok(v) => v,
4282        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4283    };
4284    let paths = parsed.paths.unwrap_or_default();
4285    let all = parsed.all.unwrap_or(false);
4286    match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
4287        Ok(()) => HttpResponse::Ok()
4288            .content_type("application/json")
4289            .body(r#"{"ok":true}"#),
4290        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4291    }
4292}
4293
4294#[cfg(feature = "git")]
4295async fn handle_git_commit(
4296    path: web::Path<String>,
4297    req: HttpRequest,
4298    state: web::Data<AppState>,
4299    body: web::Bytes,
4300) -> HttpResponse {
4301    let pubkey = path.into_inner();
4302    if require_pod_owner(&req, &pubkey).await.is_none() {
4303        return git_json_err("Authentication required", 401);
4304    }
4305    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4306        return git_json_err("Git not available (no FS backend)", 501);
4307    };
4308    let parsed: GitCommitBody = match serde_json::from_slice(&body) {
4309        Ok(v) => v,
4310        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4311    };
4312    let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
4313    let author_email = parsed
4314        .author_email
4315        .as_deref()
4316        .unwrap_or("pod@dreamlab-ai.com");
4317    match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email).await
4318    {
4319        Ok(result) => HttpResponse::Ok()
4320            .content_type("application/json")
4321            .body(serde_json::to_string(&result).unwrap_or_default()),
4322        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4323    }
4324}
4325
4326#[cfg(feature = "git")]
4327async fn handle_git_branches(
4328    path: web::Path<String>,
4329    req: HttpRequest,
4330    state: web::Data<AppState>,
4331) -> HttpResponse {
4332    let pubkey = path.into_inner();
4333    if require_pod_owner(&req, &pubkey).await.is_none() {
4334        return git_json_err("Authentication required", 401);
4335    }
4336    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4337        return git_json_err("Git not available (no FS backend)", 501);
4338    };
4339    match solid_pod_rs_git::api::git_branches(&repo).await {
4340        Ok(info) => HttpResponse::Ok()
4341            .content_type("application/json")
4342            .body(serde_json::to_string(&info).unwrap_or_default()),
4343        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4344    }
4345}
4346
4347#[cfg(feature = "git")]
4348async fn handle_git_create_branch(
4349    path: web::Path<String>,
4350    req: HttpRequest,
4351    state: web::Data<AppState>,
4352    body: web::Bytes,
4353) -> HttpResponse {
4354    let pubkey = path.into_inner();
4355    if require_pod_owner(&req, &pubkey).await.is_none() {
4356        return git_json_err("Authentication required", 401);
4357    }
4358    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4359        return git_json_err("Git not available (no FS backend)", 501);
4360    };
4361    let parsed: GitBranchBody = match serde_json::from_slice(&body) {
4362        Ok(v) => v,
4363        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4364    };
4365    match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
4366        Ok(()) => HttpResponse::Ok()
4367            .content_type("application/json")
4368            .body(r#"{"ok":true}"#),
4369        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4370    }
4371}
4372
4373#[cfg(feature = "git")]
4374async fn handle_git_discard(
4375    path: web::Path<String>,
4376    req: HttpRequest,
4377    state: web::Data<AppState>,
4378    body: web::Bytes,
4379) -> HttpResponse {
4380    let pubkey = path.into_inner();
4381    if require_pod_owner(&req, &pubkey).await.is_none() {
4382        return git_json_err("Authentication required", 401);
4383    }
4384    let Some(repo) = pod_repo_path(&state, &pubkey) else {
4385        return git_json_err("Git not available (no FS backend)", 501);
4386    };
4387    let parsed: GitStageBody = match serde_json::from_slice(&body) {
4388        Ok(v) => v,
4389        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
4390    };
4391    let paths = parsed.paths.unwrap_or_default();
4392    match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
4393        Ok(()) => HttpResponse::Ok()
4394            .content_type("application/json")
4395            .body(r#"{"ok":true}"#),
4396        Err(e) => git_json_err(&e.to_string(), e.status_code()),
4397    }
4398}
4399
4400// ---------------------------------------------------------------------------
4401// OPTIONS preflight for /_git/{pubkey}/{tail:.*} — alpha.15
4402// ---------------------------------------------------------------------------
4403
4404/// Handles CORS preflight (OPTIONS) requests for the `/_git/` REST API
4405/// namespace. Returns 204 with full CORS headers, respecting the
4406/// `allowed_origins` allowlist from `AppState`.
4407async fn handle_git_panel_options(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
4408    let origin = req
4409        .headers()
4410        .get(header::ORIGIN)
4411        .and_then(|v| v.to_str().ok())
4412        .map(str::to_string);
4413
4414    let mut rsp = HttpResponse::NoContent().finish();
4415    add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
4416    rsp
4417}
4418
4419// ---------------------------------------------------------------------------
4420// POST /_admin/provision/{pubkey} — alpha.15
4421// ---------------------------------------------------------------------------
4422
4423/// PSK-gated endpoint that provisions a pod for a given Nostr pubkey. Used by
4424/// the forum auth-worker to create native pods on behalf of users when the
4425/// "native pods" admin panel action is triggered.
4426///
4427/// On-disk layout (must match the `/pods/{pk}/` URL namespace the LDP
4428/// handlers serve, and the `podUrl` this endpoint returns):
4429/// * pod container: `data_root/pods/{pk}/`
4430/// * owner WAC ACL (sibling, the location the resolver reads):
4431///   `data_root/pods/{pk}.acl` (plus a courtesy inner `pods/{pk}/.acl`)
4432///
4433/// Protection: `X-Pod-Admin-Key` header must match `state.admin_key`.
4434/// When `state.admin_key` is `None` the endpoint always returns 403.
4435async fn handle_admin_provision(
4436    req: HttpRequest,
4437    state: web::Data<AppState>,
4438    path: web::Path<String>,
4439) -> HttpResponse {
4440    // --- PSK check -------------------------------------------------------
4441    let expected = match &state.admin_key {
4442        Some(k) => k.clone(),
4443        None => {
4444            return HttpResponse::Forbidden().json(serde_json::json!({
4445                "error": "admin key not configured on this server"
4446            }));
4447        }
4448    };
4449    let provided = req
4450        .headers()
4451        .get("x-pod-admin-key")
4452        .and_then(|v| v.to_str().ok())
4453        .unwrap_or("");
4454    // Constant-time comparison so the provisioning PSK cannot be
4455    // recovered via a response-timing side-channel. `ct_eq` returns a
4456    // `subtle::Choice`; differing lengths short-circuit to a `false`
4457    // choice without leaking the length via early return.
4458    use subtle::ConstantTimeEq;
4459    let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
4460    if !bool::from(key_match) {
4461        return HttpResponse::Forbidden().json(serde_json::json!({"error": "invalid admin key"}));
4462    }
4463
4464    // --- Pubkey validation -----------------------------------------------
4465    let pubkey = path.into_inner();
4466    if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
4467        return HttpResponse::BadRequest()
4468            .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
4469    }
4470
4471    // --- Locate FS root --------------------------------------------------
4472    let data_root = match &state.data_root {
4473        Some(r) => r.clone(),
4474        None => {
4475            return HttpResponse::InternalServerError().json(serde_json::json!({
4476                "error": "server has no fs-backend storage configured"
4477            }));
4478        }
4479    };
4480
4481    // --- BUG 1: pod must live in the `/pods/{pk}/` URL namespace ----------
4482    // The LDP handlers (`handle_get`/`handle_put`) use `req.uri().path()`
4483    // verbatim as the storage key, and the FS backend maps that key under
4484    // `data_root` (stripping the leading '/'). A pod served at
4485    // `/pods/{pk}/…` therefore lives on disk at `data_root/pods/{pk}/…`, and
4486    // the `podUrl` this endpoint returns is `{base}/pods/{pk}/`. Provisioning
4487    // previously wrote the pod to `data_root/{pk}` (bare, no `pods/` segment),
4488    // so the freshly-provisioned pod was invisible to its own advertised URL:
4489    // every authenticated write to `/pods/{pk}/…` 403'd because no resource
4490    // (and, per BUG 2, no resolvable ACL) existed under that key. Provision
4491    // into `data_root/pods/{pk}` so the on-disk layout matches the contract
4492    // the response returns. `create_dir_all` creates the `pods/` parent too.
4493    let pods_root = data_root.join("pods");
4494    let pod_dir = pods_root.join(&pubkey);
4495
4496    // --- Create directory (idempotent) -----------------------------------
4497    if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
4498        tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
4499        return HttpResponse::InternalServerError()
4500            .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
4501    }
4502
4503    // --- Write owner-only WAC ACL ----------------------------------------
4504    // Grant the pod owner (`did:nostr:{pubkey}`) full Read/Write/Control over
4505    // the pod container and its whole subtree. Use the CONCRETE container
4506    // path `/pods/{pk}/` rather than a relative `<./>`: the WAC evaluator
4507    // normalises `./` to `/` (the server root), which — while incidentally
4508    // scoped by the resolver's walk-up — is semantically an over-grant. The
4509    // absolute path matches both `acl:accessTo` (container + direct children)
4510    // and `acl:default` (deep descendants) precisely, and mirrors the ACL
4511    // convention every other authz path/test in this crate uses.
4512    let acl_content = format!(
4513        "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
4514         <#owner> a acl:Authorization ;\n\
4515             acl:agent <did:nostr:{pubkey}> ;\n\
4516             acl:accessTo </pods/{pubkey}/> ;\n\
4517             acl:default </pods/{pubkey}/> ;\n\
4518             acl:mode acl:Read, acl:Write, acl:Control .\n"
4519    );
4520
4521    // --- BUG 2: write the ACL where the resolver actually reads it --------
4522    // `wac::resolver::find_effective_acl` walks a resource path up to the
4523    // root probing SIBLING sidecars: for container `/pods/{pk}/` it reads
4524    // `/pods/{pk}.acl` (trailing slash trimmed, then `.acl` appended), and
4525    // any descendant `/pods/{pk}/…` reaches the same sibling as the walk
4526    // climbs. It NEVER reads an inner `/pods/{pk}/.acl`. Provisioning
4527    // previously wrote ONLY that inner copy, so the owner grant was never
4528    // resolved and authenticated writes 403'd. Write the authoritative ACL
4529    // to the sibling location the resolver reads: `data_root/pods/{pk}.acl`.
4530    let sibling_acl_path = pods_root.join(format!("{pubkey}.acl"));
4531    if !sibling_acl_path.exists() {
4532        if let Err(e) = tokio::fs::write(&sibling_acl_path, acl_content.as_bytes()).await {
4533            tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write sibling .acl failed");
4534            return HttpResponse::InternalServerError()
4535                .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
4536        }
4537    }
4538
4539    // Courtesy inner copy at `{pod_dir}/.acl`. This server's walk-up resolver
4540    // never reads it (it probes the sibling `{container}.acl`), but the
4541    // node-solid-server (NSS) convention stores a container's ACL INSIDE the
4542    // container as `<container>/.acl`; writing both keeps interop with tooling
4543    // that expects the inner convention. Harmless here — the sibling above is
4544    // authoritative — so a failure to write it is non-fatal.
4545    let inner_acl_path = pod_dir.join(".acl");
4546    if !inner_acl_path.exists() {
4547        if let Err(e) = tokio::fs::write(&inner_acl_path, acl_content.as_bytes()).await {
4548            tracing::warn!(pubkey = %pubkey, error = %e, "/_admin/provision: write inner .acl failed (non-fatal; sibling ACL governs)");
4549        }
4550    }
4551
4552    // --- Git init (feature-gated) ----------------------------------------
4553    #[cfg(feature = "git")]
4554    {
4555        // Only init if .git does not yet exist (idempotent).
4556        if !pod_dir.join(".git").exists() {
4557            let init_out = crate::hardened_git::hardened_git_async()
4558                .args(["init", "-b", "main", pod_dir.to_str().unwrap_or(".")])
4559                .output()
4560                .await;
4561
4562            match init_out {
4563                Ok(out) if out.status.success() => {}
4564                Ok(out) => {
4565                    let stderr = String::from_utf8_lossy(&out.stderr);
4566                    tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
4567                }
4568                Err(e) => {
4569                    tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
4570                }
4571            }
4572
4573            // Configure receive.denyCurrentBranch=updateInstead so the forum
4574            // client can push directly into the working tree.
4575            let cfg_out = crate::hardened_git::hardened_git_async()
4576                .args([
4577                    "-C",
4578                    pod_dir.to_str().unwrap_or("."),
4579                    "config",
4580                    "receive.denyCurrentBranch",
4581                    "updateInstead",
4582                ])
4583                .output()
4584                .await;
4585
4586            if let Err(e) = cfg_out {
4587                tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
4588            }
4589        }
4590    }
4591
4592    // --- Build response --------------------------------------------------
4593    let base_url = state.nodeinfo.base_url.trim_end_matches('/');
4594    HttpResponse::Ok().json(serde_json::json!({
4595        "podUrl": format!("{base_url}/pods/{pubkey}/"),
4596        "ok": true,
4597    }))
4598}
4599
4600// ---------------------------------------------------------------------------
4601// /.well-known/apps  (JSS #464 Phase 2 — public app discovery)
4602// ---------------------------------------------------------------------------
4603
4604async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
4605    let Some(ref data_root) = state.data_root else {
4606        return HttpResponse::Ok()
4607            .content_type("application/json")
4608            .json(serde_json::json!({"apps": [], "count": 0}));
4609    };
4610
4611    let server_url = state.nodeinfo.base_url.clone();
4612
4613    // Collect pod directories (up to 1000).
4614    let mut read_dir = match tokio::fs::read_dir(data_root).await {
4615        Ok(rd) => rd,
4616        Err(_) => {
4617            return HttpResponse::Ok()
4618                .content_type("application/json")
4619                .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
4620        }
4621    };
4622
4623    let mut apps: Vec<serde_json::Value> = Vec::new();
4624    let mut scanned = 0usize;
4625
4626    while scanned < 1000 {
4627        let entry = match read_dir.next_entry().await {
4628            Ok(Some(e)) => e,
4629            Ok(None) => break,
4630            Err(_) => break,
4631        };
4632
4633        let file_type = match entry.file_type().await {
4634            Ok(ft) => ft,
4635            Err(_) => continue,
4636        };
4637        if !file_type.is_dir() {
4638            continue;
4639        }
4640
4641        scanned += 1;
4642
4643        let manifest_path = entry.path().join("apps").join("manifest.json");
4644        let contents = match tokio::fs::read(&manifest_path).await {
4645            Ok(c) => c,
4646            Err(_) => continue,
4647        };
4648
4649        let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
4650            Ok(v) => v,
4651            Err(_) => continue,
4652        };
4653
4654        // Inject podOwner from the directory name (pubkey).
4655        if let Some(pod_name) = entry.file_name().to_str() {
4656            if manifest.get("podOwner").is_none() {
4657                manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
4658            }
4659        }
4660
4661        apps.push(manifest);
4662    }
4663
4664    let count = apps.len();
4665    HttpResponse::Ok()
4666        .content_type("application/json")
4667        .json(serde_json::json!({
4668            "apps": apps,
4669            "serverUrl": server_url,
4670            "count": count,
4671        }))
4672}
4673
4674// ---------------------------------------------------------------------------
4675// Git HTTP backend handler (JSS #466/#469/#471, feature = "git")
4676// ---------------------------------------------------------------------------
4677
4678/// Returns `true` if `path` is a git smart-HTTP protocol request.
4679///
4680/// Mirrors JSS `src/handlers/git.js` `isGitRequest`:
4681/// ```text
4682/// return urlPath.includes('/info/refs') ||
4683///   urlPath.includes('/git-upload-pack') ||
4684///   urlPath.includes('/git-receive-pack');
4685/// ```
4686#[allow(dead_code)]
4687fn is_git_request(path: &str) -> bool {
4688    path.contains("/info/refs")
4689        || path.contains("/git-upload-pack")
4690        || path.contains("/git-receive-pack")
4691}
4692
4693/// Returns `true` if `path` targets `.git/` internals directly — always
4694/// blocked (security, matches JSS lines 52-68).
4695#[allow(dead_code)]
4696fn is_dot_git_path(path: &str) -> bool {
4697    path.contains("/.git/") || path.ends_with("/.git")
4698}
4699
4700#[cfg(feature = "git")]
4701async fn handle_git(
4702    req: HttpRequest,
4703    body: web::Bytes,
4704    state: web::Data<AppState>,
4705) -> HttpResponse {
4706    use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
4707    use solid_pod_rs_git::service::GitRequest;
4708
4709    let path = req.uri().path().to_string();
4710
4711    // Locate the pod's FS root: the first path segment after "/" is the
4712    // pod name (username/pubkey). The FS root is data_root/{pod_name}/.
4713    let pod_name = path
4714        .trim_start_matches('/')
4715        .split('/')
4716        .next()
4717        .unwrap_or("")
4718        .to_string();
4719    let Some(ref data_root) = state.data_root else {
4720        return HttpResponse::NotImplemented().json(serde_json::json!({
4721            "error": "git requires fs-backend storage",
4722            "reason": "data_root_not_configured"
4723        }));
4724    };
4725    let repo_root = data_root.join(&pod_name);
4726    if !repo_root.exists() {
4727        return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
4728    }
4729
4730    let query = req.uri().query().unwrap_or("").to_string();
4731    let host_url = Some(scheme::request_origin(&req));
4732    let headers: Vec<(String, String)> = req
4733        .headers()
4734        .iter()
4735        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
4736        .collect();
4737
4738    let git_req = GitRequest {
4739        method: req.method().as_str().to_string(),
4740        path,
4741        query,
4742        headers,
4743        body,
4744        host_url,
4745    };
4746
4747    // Push is not served: `git-receive-pack` and its `info/refs` discovery
4748    // are refused for every caller, before any credential is read. The path
4749    // never worked (the service had no auth provider, so every push was
4750    // refused), and making it work would have needed per-path WAC on every
4751    // pushed file; the owner chose to delete it instead (ER-28).
4752    if git_req.is_write() {
4753        let mut builder = HttpResponse::Forbidden();
4754        for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4755            builder.insert_header((k, v));
4756        }
4757        return builder.json(serde_json::json!({
4758            "error": "git push is not served by this server",
4759            "reason": "push_not_served"
4760        }));
4761    }
4762
4763    // WAC gate (mirrors JSS `server.js` checkAccess before git, ~498-530).
4764    // Git requests were previously handed straight to the CGI with no
4765    // authorisation: a private pod's history was anonymously clonable and
4766    // pushes were anonymous (R5 "No WAC" finding / ADR-059 D6). Resolve the
4767    // caller's `did:nostr` from the git `Basic nostr:`/`Nostr` NIP-98
4768    // credential — an absent or invalid credential resolves to anonymous,
4769    // and WAC then decides, fail-closed. Enforce Read for clone/fetch
4770    // against the pod-root container ACL; push was refused above.
4771    // `enforce_read` grants public pods to anonymous callers and replies 401
4772    // on a private pod so the git client knows to retry with credentials.
4773    let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
4774        Ok(pk) => Some(format!("did:nostr:{pk}")),
4775        Err(_) => None,
4776    };
4777    let wac_path = format!("/{pod_name}/");
4778    let origin = req_origin(&req);
4779    // Only the denial matters on this path; discard the read gate's
4780    // cache-audience classification (ADR-2002), which is a response concern
4781    // the git transport does not have.
4782    let wac = enforce_read_ctx(&state, &wac_path, agent.as_deref(), origin)
4783        .await
4784        .map(|_audience| ());
4785    if let Err(e) = wac {
4786        // JSS #548: the WAC gate's 401/402/403 must carry the git CORS
4787        // headers, or browser-based git clients see a generic CORS/network
4788        // error instead of the status and `WWW-Authenticate` challenge.
4789        let mut resp = e.error_response();
4790        for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4791            if let (Ok(name), Ok(value)) = (
4792                actix_web::http::header::HeaderName::from_bytes(k.as_bytes()),
4793                actix_web::http::header::HeaderValue::from_str(v),
4794            ) {
4795                resp.headers_mut().insert(name, value);
4796            }
4797        }
4798        return resp;
4799    }
4800
4801    let service = solid_pod_rs_git::service::GitHttpService::new(repo_root);
4802    match service.handle(git_req).await {
4803        Ok(git_resp) => {
4804            let mut builder = HttpResponse::build(
4805                actix_web::http::StatusCode::from_u16(git_resp.status)
4806                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4807            );
4808            for (k, v) in &git_resp.headers {
4809                builder.insert_header((k.as_str(), v.as_str()));
4810            }
4811            builder.body(git_resp.body)
4812        }
4813        Err(e) => {
4814            let status = e.status_code();
4815            let mut builder = HttpResponse::build(
4816                actix_web::http::StatusCode::from_u16(status)
4817                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
4818            );
4819            for (k, v) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
4820                builder.insert_header((k, v));
4821            }
4822            builder.json(serde_json::json!({"error": e.to_string()}))
4823        }
4824    }
4825}
4826
4827// ---------------------------------------------------------------------------
4828// Git forge (JSS `forge` plugin port) — mounted behind `feature = "forge"`.
4829// ---------------------------------------------------------------------------
4830
4831/// Derive the forge plugin data directory from the pod storage root. The
4832/// forge keeps its repos/spine/hosted/marks under a `.forge` sibling dir
4833/// so it never mixes with pod containers. Returns `None` when no
4834/// filesystem `data_root` is configured (in-memory/cloud storage).
4835#[cfg(feature = "forge")]
4836fn forge_plugin_dir(state: &AppState) -> Option<PathBuf> {
4837    state.data_root.as_ref().map(|r| r.join(".forge"))
4838}
4839
4840/// Reqwest-backed [`LoopbackFetch`](solid_pod_rs_forge::LoopbackFetch) for
4841/// verifying/re-fetching pod-hosted forge bodies. The forge's own-area URL
4842/// guard pins every fetch to the request's own origin, so this only ever
4843/// performs a same-origin loopback GET — the SSRF surface is closed at the
4844/// guard, not here.
4845#[cfg(feature = "forge")]
4846struct ServerLoopback {
4847    client: reqwest::Client,
4848}
4849
4850#[cfg(feature = "forge")]
4851#[async_trait::async_trait]
4852impl solid_pod_rs_forge::LoopbackFetch for ServerLoopback {
4853    async fn get(
4854        &self,
4855        url: &str,
4856        max_bytes: usize,
4857        timeout_secs: u64,
4858    ) -> solid_pod_rs_forge::bodies::FetchResult {
4859        use solid_pod_rs_forge::bodies::FetchResult;
4860        let resp = match self
4861            .client
4862            .get(url)
4863            .timeout(Duration::from_secs(timeout_secs.max(1)))
4864            .send()
4865            .await
4866        {
4867            Ok(r) => r,
4868            Err(e) => return FetchResult::Error(e.to_string()),
4869        };
4870        let code = resp.status().as_u16();
4871        if code == 404 || code == 410 {
4872            return FetchResult::Removed;
4873        }
4874        if !resp.status().is_success() {
4875            return FetchResult::Error(format!("status {code}"));
4876        }
4877        if resp
4878            .content_length()
4879            .is_some_and(|length| length > max_bytes as u64)
4880        {
4881            return FetchResult::TooLarge;
4882        }
4883        let mut stream = resp.bytes_stream();
4884        let mut body = Vec::with_capacity(max_bytes.min(16 * 1024));
4885        while let Some(chunk) = stream.next().await {
4886            match chunk {
4887                Ok(chunk) if body.len().saturating_add(chunk.len()) <= max_bytes => {
4888                    body.extend_from_slice(&chunk);
4889                }
4890                Ok(_) => return FetchResult::TooLarge,
4891                Err(error) => return FetchResult::Error(error.to_string()),
4892            }
4893        }
4894        FetchResult::Body(body)
4895    }
4896}
4897
4898/// A comma-separated environment list, trimmed, empties dropped.
4899#[cfg(feature = "forge")]
4900fn env_list(name: &str) -> Vec<String> {
4901    std::env::var(name)
4902        .ok()
4903        .map(|v| {
4904            v.split(',')
4905                .map(str::trim)
4906                .filter(|s| !s.is_empty())
4907                .map(str::to_string)
4908                .collect()
4909        })
4910        .unwrap_or_default()
4911}
4912
4913/// The forge configuration: the `FORGE_CONFIG` JSON file (if any) under the
4914/// `FORGE_*` environment overrides. Everything is optional; with nothing set
4915/// the defaults hold (testnet chain, mainnet refused, no announce relays,
4916/// no private repos).
4917#[cfg(feature = "forge")]
4918fn forge_config() -> solid_pod_rs_forge::ForgeConfig {
4919    use serde_json::{json, Map, Value};
4920    let mut o = Map::new();
4921    let flag = |n: &str| {
4922        std::env::var(n)
4923            .ok()
4924            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
4925    };
4926    if let Some(b) = flag("FORGE_PRIVATE_REPOS") {
4927        o.insert("privateRepos".into(), json!(b));
4928    }
4929    if let Some(b) = flag("FORGE_ALLOW_MAINNET") {
4930        o.insert("allowMainnet".into(), json!(b));
4931    }
4932    if let Some(b) = flag("FORGE_OPEN_EDIT") {
4933        o.insert("openEdit".into(), json!(b));
4934    }
4935    if let Some(b) = flag("FORGE_ANCHORING_UI") {
4936        o.insert("anchoringUi".into(), json!(b));
4937    }
4938    if let Some(b) = flag("FORGE_SPARSE_MARKS") {
4939        o.insert("sparseMarks".into(), json!(b));
4940    }
4941    if let Ok(c) = std::env::var("FORGE_CHAIN") {
4942        if !c.trim().is_empty() {
4943            o.insert("chain".into(), Value::String(c.trim().to_string()));
4944        }
4945    }
4946    if let Ok(u) = std::env::var("FORGE_BASE_URL") {
4947        if !u.trim().is_empty() {
4948            o.insert("baseUrl".into(), Value::String(u.trim().to_string()));
4949        }
4950    }
4951    let relays = env_list("FORGE_ANNOUNCE_RELAYS");
4952    if !relays.is_empty() {
4953        o.insert("announceRelays".into(), json!(relays));
4954    }
4955    solid_pod_rs_forge::ForgeConfig::from_env(Value::Object(o))
4956}
4957
4958/// The mirror configuration from `FORGE_MIRROR_RELAYS` (comma-separated
4959/// `ws(s)://` URLs), `FORGE_MIRROR_FOLLOW` (comma-separated
4960/// `<64-hex pubkey>/<repo>`) and `FORGE_MIRROR_MIN_INTERVAL_MS`. `None` (the
4961/// mirror stays off) unless at least one relay and one valid entry survive.
4962#[cfg(feature = "forge-mirror")]
4963fn mirror_config() -> Option<solid_pod_rs_forge::mirror::MirrorConfig> {
4964    use serde_json::json;
4965    let relays = env_list("FORGE_MIRROR_RELAYS");
4966    let follow: Vec<serde_json::Value> = env_list("FORGE_MIRROR_FOLLOW")
4967        .iter()
4968        .filter_map(|e| e.split_once('/'))
4969        .map(|(pk, name)| json!({"pubkey": pk.trim(), "name": name.trim()}))
4970        .collect();
4971    let mut cfg = json!({"relays": relays, "follow": follow});
4972    if let Some(ms) = std::env::var("FORGE_MIRROR_MIN_INTERVAL_MS")
4973        .ok()
4974        .and_then(|v| v.trim().parse::<u64>().ok())
4975    {
4976        cfg["minIntervalMs"] = json!(ms);
4977    }
4978    solid_pod_rs_forge::mirror::parse_mirror_config(&cfg)
4979}
4980
4981/// A forge service plus the running mirror (kept alive with it).
4982#[cfg(feature = "forge")]
4983struct ForgeRuntime {
4984    service: Arc<solid_pod_rs_forge::ForgeService>,
4985    #[cfg(feature = "forge-mirror")]
4986    _mirror: Option<solid_pod_rs_forge::mirror::MirrorHandle>,
4987}
4988
4989/// One long-lived forge service per forge data directory. It owns state that
4990/// must outlive a request (the per-repository locks, the mirror's relay
4991/// sessions and its inbox), so it is built once and shared.
4992#[cfg(feature = "forge")]
4993fn forge_service(
4994    plugin_dir: PathBuf,
4995) -> Result<Arc<solid_pod_rs_forge::ForgeService>, solid_pod_rs_forge::ForgeError> {
4996    use std::collections::HashMap;
4997    use std::sync::{Mutex, OnceLock};
4998    static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<ForgeRuntime>>>> = OnceLock::new();
4999    let reg = REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
5000    let mut reg = reg
5001        .lock()
5002        .unwrap_or_else(std::sync::PoisonError::into_inner);
5003    if let Some(rt) = reg.get(&plugin_dir) {
5004        return Ok(Arc::clone(&rt.service));
5005    }
5006    let cfg = forge_config();
5007    #[cfg(feature = "forge-announce")]
5008    let relay_policy: Arc<dyn solid_pod_rs_forge::publish::SsrfCheck> = Arc::new(
5009        solid_pod_rs_forge::publish::OperatorRelays::new(&cfg.announce_relays),
5010    );
5011    let loopback: Arc<dyn solid_pod_rs_forge::LoopbackFetch> = Arc::new(ServerLoopback {
5012        client: reqwest::Client::new(),
5013    });
5014    let svc =
5015        solid_pod_rs_forge::ForgeService::new(cfg, plugin_dir.clone())?.with_loopback(loopback);
5016    #[cfg(feature = "forge-announce")]
5017    let svc = svc.with_announce_ssrf(relay_policy);
5018    let service = Arc::new(svc);
5019    #[cfg(feature = "forge-mirror")]
5020    let mirror = mirror_config().map(|mcfg| {
5021        use solid_pod_rs_forge::mirror::{GitCli, Mirror, MirrorStore};
5022        let git = Arc::new(GitCli::new(plugin_dir.join("repos")));
5023        let engine = Arc::new(Mirror::new(mcfg, MirrorStore::new(&plugin_dir), git));
5024        tracing::info!("forge: mirror on kind-30618 started");
5025        engine.start()
5026    });
5027    reg.insert(
5028        plugin_dir,
5029        Arc::new(ForgeRuntime {
5030            service: Arc::clone(&service),
5031            #[cfg(feature = "forge-mirror")]
5032            _mirror: mirror,
5033        }),
5034    );
5035    Ok(service)
5036}
5037
5038/// Start the forge (and, with `forge-mirror`, its relay sessions) at boot
5039/// rather than on the first request, so a mirror follows its repositories
5040/// from the moment the server is up. A no-op without a filesystem data root.
5041#[cfg(feature = "forge")]
5042pub fn start_forge(state: &AppState) {
5043    if let Some(dir) = forge_plugin_dir(state) {
5044        match forge_service(dir) {
5045            Ok(_) => tracing::info!("forge: started"),
5046            Err(e) => tracing::error!("forge: could not start: {e}"),
5047        }
5048    }
5049}
5050
5051/// Single actix handler for every `/forge/*` path. Translates actix →
5052/// `ForgeRequest`, resolves the caller identity, and dispatches to the
5053/// forge service. WAC/namespace authorization is enforced inside the
5054/// service (namespace-write guard) and, for git push, by the git CGI's
5055/// own auth provider (wired in the tokens phase).
5056#[cfg(feature = "forge")]
5057async fn handle_forge(
5058    req: HttpRequest,
5059    body: web::Bytes,
5060    state: web::Data<AppState>,
5061) -> HttpResponse {
5062    use solid_pod_rs_forge::ForgeRequest;
5063
5064    let Some(plugin_dir) = forge_plugin_dir(&state) else {
5065        return HttpResponse::NotImplemented().json(serde_json::json!({
5066            "error": "forge requires fs-backend storage",
5067            "reason": "data_root_not_configured"
5068        }));
5069    };
5070
5071    let service = match forge_service(plugin_dir) {
5072        Ok(s) => s,
5073        Err(e) => {
5074            return HttpResponse::InternalServerError()
5075                .json(serde_json::json!({"error": e.to_string()}));
5076        }
5077    };
5078
5079    let path = req.uri().path().to_string();
5080    let query = req.uri().query().unwrap_or("").to_string();
5081    let host_url = Some(scheme::request_origin(&req));
5082    let headers: Vec<(String, String)> = req
5083        .headers()
5084        .iter()
5085        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
5086        .collect();
5087
5088    let forge_req = ForgeRequest {
5089        method: req.method().as_str().to_string(),
5090        path,
5091        query,
5092        headers,
5093        raw_body: body,
5094        host_url,
5095    };
5096
5097    // Identity resolution: the forge's own resolver handles the forge push
5098    // token (`Bearer f1.…`) and NIP-98 (`Nostr …`). An unresolved caller
5099    // is Anonymous and the service's guards decide, fail-closed. (A pod
5100    // session, when present, would be injected as a `Pod` agent instead.)
5101    let agent = service.resolve_agent(&forge_req).await;
5102
5103    match service.handle(forge_req, agent).await {
5104        Ok(resp) => {
5105            let mut builder = HttpResponse::build(
5106                StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5107            );
5108            for (k, v) in &resp.headers {
5109                builder.insert_header((k.as_str(), v.as_str()));
5110            }
5111            builder.body(resp.body)
5112        }
5113        Err(e) => {
5114            let status = e.status_code();
5115            HttpResponse::build(
5116                StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
5117            )
5118            .json(serde_json::json!({"error": e.to_string()}))
5119        }
5120    }
5121}
5122
5123// ---------------------------------------------------------------------------
5124// Public app builder
5125// ---------------------------------------------------------------------------
5126
5127/// Build the complete actix `App` for the Solid Pod server. Both the
5128/// binary (`main.rs`) and the workspace integration tests call this.
5129///
5130/// The returned `App` is fully-configured: route table, normaliser,
5131/// path-traversal guard, dotfile allowlist, body cap, CORS middleware
5132/// (when available), rate-limit middleware (when available), and WAC
5133/// enforcement.
5134pub fn build_app(
5135    state: AppState,
5136) -> App<
5137    impl actix_web::dev::ServiceFactory<
5138        ServiceRequest,
5139        Config = (),
5140        Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
5141        Error = ActixError,
5142        InitError = (),
5143    >,
5144> {
5145    let body_cap = state.body_cap;
5146    let dotfiles = state.dotfiles.clone();
5147    let allowed_origins = Arc::new(state.allowed_origins.clone());
5148    let allowed_hosts = state.allowed_hosts.clone();
5149
5150    let mut app = App::new()
5151        .app_data(web::Data::new(state.clone()))
5152        .app_data(web::PayloadConfig::new(body_cap))
5153        // Sprint 11 (row 158): outermost layer so it observes every
5154        // response — including those that short-circuited in inner
5155        // guards. Wrapping first means `wrap()` applies it last in
5156        // actix's stack order.
5157        .wrap(ErrorLoggingMiddleware)
5158        .wrap(CorsHeaders { allowed_origins })
5159        // `MergeOnly` collapses duplicate slashes (//a → /a) without
5160        // stripping the trailing slash, which is the container/resource
5161        // discriminator in LDP.
5162        .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
5163        .wrap(PathTraversalGuard)
5164        .wrap(DotfileGuard::new(dotfiles).with_host_policy(HostPolicy {
5165            allowed: allowed_hosts,
5166            trust_proxy: state.trust_proxy,
5167        }));
5168
5169    // CORS / rate-limit: middleware is driven by the library types from
5170    // S7-A. We register pass-through headers when the env-driven policy
5171    // permits. The middleware is a no-op today beyond emitting the
5172    // policy's `response_headers` on every response; full preflight
5173    // handling lives in the sibling S7-A work.
5174    app = app
5175        .route("/.well-known/solid", web::get().to(handle_well_known_solid))
5176        .route(
5177            "/.well-known/webfinger",
5178            web::get().to(handle_well_known_webfinger),
5179        )
5180        .route(
5181            "/.well-known/nodeinfo",
5182            web::get().to(handle_well_known_nodeinfo),
5183        )
5184        .route(
5185            "/.well-known/nodeinfo/2.1",
5186            web::get().to(handle_well_known_nodeinfo_2_1),
5187        );
5188
5189    #[cfg(feature = "did-nostr")]
5190    {
5191        app = app.route(
5192            "/.well-known/did/nostr/{pubkey}.json",
5193            web::get().to(handle_well_known_did_nostr),
5194        );
5195    }
5196
5197    // JSS v0.0.190 Phase 1 port (issue #437), parity row 197.
5198    // Pod-resident NIP-05 endpoint. `handle_well_known_nip05` is
5199    // implemented and routed (bodies landed in 0.4.0-alpha.11; no
5200    // `todo!()`). Feature `nip05-endpoint` (default-off).
5201    #[cfg(feature = "nip05-endpoint")]
5202    {
5203        app = app.route(
5204            "/.well-known/nostr.json",
5205            web::get().to(handle_well_known_nip05),
5206        );
5207    }
5208
5209    // JSS v0.0.190 Phase 1 port (issue #437), parity row 198. JSON-LD
5210    // time-chain pod export. Native-only (`export-jsonld`, default-off);
5211    // owner-WAC-gated inside the handler. Registered before the LDP
5212    // catch-all so `/api/exports/all` is never treated as a pod resource.
5213    #[cfg(feature = "export-jsonld")]
5214    {
5215        app = app.route("/api/exports/all", web::get().to(handle_export_all));
5216    }
5217
5218    // App discovery endpoint (JSS #464 Phase 2 — public, no auth required).
5219    app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
5220
5221    // Payment endpoint (JSS parity: GET /pay/.info).
5222    app = app.route("/pay/.info", web::get().to(handle_pay_info));
5223
5224    // Phase 0 payment routing (master-plan §"Phase 0"): wire the orphaned
5225    // order-book / AMM / Web-Ledger logic. Registered with the SAME gating
5226    // as `/pay/.info` above — always-on, no payments feature flag — so the
5227    // whole `/pay/*` surface is consistent.
5228    app = app.configure(handlers::pay::register);
5229
5230    // WAC-gated CORS proxy endpoint.
5231    app = app.route("/proxy", web::get().to(handle_proxy));
5232
5233    // MCP (Model Context Protocol) endpoint — opt-in tool surface for
5234    // agents (JSS #490). Registered before the LDP catch-all so `/mcp` is
5235    // never treated as a pod resource. OFF unless `--mcp` / `JSS_MCP`.
5236    if state.mcp_enabled {
5237        app = app.route("/mcp", web::post().to(mcp::handle_mcp)).route(
5238            "/mcp",
5239            web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options),
5240        );
5241    }
5242
5243    // Admin provisioning endpoint (alpha.15). Must be before the LDP
5244    // catch-all so `_admin` is never treated as a pod name.
5245    app = app.route(
5246        "/_admin/provision/{pubkey}",
5247        web::post().to(handle_admin_provision),
5248    );
5249
5250    // Pod management API (JSS parity: /api/accounts/*)
5251    app = app
5252        .route("/.pods", web::post().to(handle_create_pod))
5253        .route("/api/accounts/new", web::post().to(handle_create_account))
5254        .route("/pods/check/{name}", web::get().to(handle_pod_check))
5255        .route("/login/password", web::post().to(handle_login_password))
5256        .route(
5257            "/account/password/reset",
5258            web::post().to(handle_password_reset_request),
5259        )
5260        .route(
5261            "/account/password/change",
5262            web::post().to(handle_password_change),
5263        );
5264
5265    // Git forge routes (JSS `forge` plugin port). Registered BEFORE the
5266    // pod-git smart-HTTP catch-all so `/forge/<o>/<n>.git/info/refs` is
5267    // handled by the forge (which forwards to its own CGI service) rather
5268    // than the pod-git handler. Gated by `feature = "forge"`.
5269    #[cfg(feature = "forge")]
5270    {
5271        app = app
5272            .route("/forge", web::route().to(handle_forge))
5273            .route("/forge/{tail:.*}", web::route().to(handle_forge));
5274    }
5275
5276    // Git smart-HTTP protocol routes (JSS #466/#469/#471).
5277    // Must be registered before the LDP catch-all. Direct .git/ access is
5278    // always blocked (security). Smart-HTTP paths are served by
5279    // GitHttpService when the `git` feature is enabled; otherwise 501.
5280    app = app
5281        .route(
5282            // Block direct .git/ access (JSS: "BLOCK: Direct access to .git contents")
5283            "/{tail:.*}/.git",
5284            web::route().to(|| async {
5285                HttpResponse::Forbidden()
5286                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5287            }),
5288        )
5289        .route(
5290            "/{tail:.*}/.git/{rest:.*}",
5291            web::route().to(|| async {
5292                HttpResponse::Forbidden()
5293                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
5294            }),
5295        );
5296
5297    // OPTIONS preflight for /_git panel REST API (alpha.15). Registered
5298    // unconditionally (before the feature block) so browsers get a valid
5299    // CORS response regardless of whether the git feature is compiled in.
5300    app = app.route(
5301        "/pods/{pk}/_git/{tail:.*}",
5302        web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
5303    );
5304
5305    #[cfg(feature = "git")]
5306    {
5307        // Git smart-HTTP: info/refs discovery + upload-pack (clone/fetch).
5308        // `git-receive-pack` stays routed here only so `handle_git` refuses
5309        // it: unrouted, the POST would fall through to the LDP catch-all and
5310        // create a resource named `git-receive-pack`. Push is not served
5311        // (estate register ER-28, owner decision 2026-10-18); agentbox serves
5312        // pod git push through its own owner-only route.
5313        app = app
5314            .route("/{tail:.*}/info/refs", web::get().to(handle_git))
5315            .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
5316            .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
5317
5318        // Git control panel REST API. Routes registered before the LDP
5319        // catch-all so `_git` segments are never treated as LDP resources.
5320        app = app
5321            .route(
5322                "/pods/{pubkey}/_git/status",
5323                web::get().to(handle_git_status),
5324            )
5325            .route("/pods/{pubkey}/_git/log", web::get().to(handle_git_log))
5326            .route("/pods/{pubkey}/_git/diff", web::get().to(handle_git_diff))
5327            .route(
5328                "/pods/{pubkey}/_git/stage",
5329                web::post().to(handle_git_stage),
5330            )
5331            .route(
5332                "/pods/{pubkey}/_git/unstage",
5333                web::post().to(handle_git_unstage),
5334            )
5335            .route(
5336                "/pods/{pubkey}/_git/commit",
5337                web::post().to(handle_git_commit),
5338            )
5339            .route(
5340                "/pods/{pubkey}/_git/branches",
5341                web::get().to(handle_git_branches),
5342            )
5343            .route(
5344                "/pods/{pubkey}/_git/branch",
5345                web::post().to(handle_git_create_branch),
5346            )
5347            .route(
5348                "/pods/{pubkey}/_git/discard",
5349                web::post().to(handle_git_discard),
5350            );
5351
5352        // Provenance `_prov` API (ADR-059 Phase 5, master-plan §2.4):
5353        // resolve a git-mark commit SHA, and the explicit (payment-gated)
5354        // git-mark → Bitcoin-anchor upgrade. Registered before the LDP
5355        // catch-all so `_prov` segments are never treated as pod resources.
5356        // The `.prov.ttl` sidecar GET is served by the ordinary LDP read path
5357        // (it is a stored resource).
5358        app = app.configure(handlers::prov::register);
5359    }
5360    #[cfg(not(feature = "git"))]
5361    {
5362        // Without the git feature: return 501 for git protocol paths so
5363        // callers get a clear "not compiled in" signal rather than falling
5364        // through to LDP.
5365        let git_501 = || async {
5366            HttpResponse::NotImplemented()
5367                .json(serde_json::json!({"error": "git feature not enabled in this build"}))
5368        };
5369        app = app
5370            .route("/{tail:.*}/info/refs", web::get().to(git_501))
5371            .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
5372            .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
5373    }
5374
5375    // Container POST and PUT (trailing slash) must register before the
5376    // catch-all so the trailing-slash variant wins.
5377    app.route("/{tail:.*}/", web::post().to(handle_post))
5378        .route("/{tail:.*}/", web::put().to(handle_put))
5379        .route("/{tail:.*}", web::get().to(handle_get))
5380        .route("/{tail:.*}", web::head().to(handle_get))
5381        .route("/{tail:.*}", web::put().to(handle_put))
5382        .route("/{tail:.*}", web::patch().to(handle_patch))
5383        .route("/{tail:.*}", web::delete().to(handle_delete))
5384        .route(
5385            "/{tail:.*}",
5386            web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
5387        )
5388        .route(
5389            "/{tail:.*}",
5390            web::method(actix_web::http::Method::OPTIONS).to(handle_options),
5391        )
5392}
5393
5394// ---------------------------------------------------------------------------
5395// Tests — sat-gating loop closure (PaymentCondition wired to real ledger)
5396// ---------------------------------------------------------------------------
5397
5398#[cfg(test)]
5399mod payment_gating_tests {
5400    use super::*;
5401    use solid_pod_rs::payments::WebLedger;
5402    use solid_pod_rs::storage::memory::MemoryBackend;
5403
5404    /// The paying principal's DID, spelt once so the ACL fixtures below
5405    /// cannot drift from [`PRINCIPAL`]. Teller's `accountOf` only accepts a
5406    /// `did:nostr:<64 hex>` account, so every ledger-seeding fixture uses one.
5407    macro_rules! principal {
5408        () => {
5409            "did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
5410        };
5411    }
5412
5413    const PRINCIPAL: &str = principal!();
5414
5415    /// A second, valid `did:nostr:<64 hex>` account that is not `PRINCIPAL`.
5416    const OTHER_PRINCIPAL: &str =
5417        "did:nostr:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
5418
5419    /// Turtle ACL granting `PRINCIPAL` Write on `/premium/inbox`
5420    /// only when a `PaymentCondition` of 100 sats is satisfied.
5421    const PAID_WRITE_ACL: &str = concat!(
5422        r#"
5423@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5424
5425<#paid-write> a acl:Authorization ;
5426    acl:agent <"#,
5427        principal!(),
5428        r#"> ;
5429    acl:accessTo </premium/inbox> ;
5430    acl:mode acl:Write ;
5431    acl:condition [
5432        a acl:PaymentCondition ;
5433        acl:costSats 100
5434    ] .
5435"#
5436    );
5437
5438    async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
5439        let mut ledger = WebLedger::new("Test Pod Credits");
5440        if sats > 0 {
5441            ledger
5442                .credit_by_outpoint(did, "satoshi", &"ab".repeat(32), 0, sats)
5443                .unwrap();
5444        }
5445        let body = serde_json::to_vec(&ledger).unwrap();
5446        storage
5447            .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
5448            .await
5449            .unwrap();
5450    }
5451
5452    async fn seed_acl(storage: &dyn Storage) {
5453        storage
5454            .put(
5455                "/premium/inbox.acl",
5456                Bytes::from(PAID_WRITE_ACL),
5457                "text/turtle",
5458            )
5459            .await
5460            .unwrap();
5461    }
5462
5463    /// The resolver reads the principal's balance from the seeded ledger.
5464    #[actix_web::test]
5465    async fn resolve_balance_reads_ledger_entry() {
5466        let storage = MemoryBackend::new();
5467        seed_ledger(&storage, PRINCIPAL, 250).await;
5468        assert_eq!(
5469            resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5470            Some(250)
5471        );
5472    }
5473
5474    /// No ledger entry → authenticated principal resolves to zero balance.
5475    #[actix_web::test]
5476    async fn resolve_balance_zero_when_no_entry() {
5477        let storage = MemoryBackend::new();
5478        seed_ledger(&storage, OTHER_PRINCIPAL, 500).await;
5479        assert_eq!(
5480            resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
5481            Some(0)
5482        );
5483    }
5484
5485    /// Regression guard for the fixtures themselves: every account the
5486    /// ledger is seeded with must be one teller's `accountOf` accepts
5487    /// (`did:nostr:<64 hex>`), and every ACL fixture that names the paying
5488    /// principal must name exactly `PRINCIPAL`. A short `did:nostr:bob`
5489    /// seed or an ACL left on `did:nostr:alice` panics or silently denies.
5490    #[test]
5491    fn fixtures_use_teller_accounts_and_grant_the_principal() {
5492        use solid_pod_rs::payments::account_of;
5493        for did in [PRINCIPAL, OTHER_PRINCIPAL] {
5494            assert_eq!(account_of(did).unwrap(), did, "{did} must be canonical");
5495        }
5496        assert_ne!(PRINCIPAL, OTHER_PRINCIPAL);
5497        assert!(account_of("did:nostr:bob").is_err());
5498        let agent = format!("acl:agent <{PRINCIPAL}>");
5499        for (name, acl) in [
5500            ("PAID_WRITE_ACL", PAID_WRITE_ACL),
5501            ("WRITE_NOT_CONTROL_ACL", WRITE_NOT_CONTROL_ACL),
5502        ] {
5503            assert!(acl.contains(&agent), "{name} must grant PRINCIPAL");
5504        }
5505        assert!(!WRITE_NOT_CONTROL_ACL.contains("did:nostr:alice"));
5506    }
5507
5508    /// A pay-per-read resource is debited once by a plain read, but refused
5509    /// (and never debited) by the uncharged check glob GET runs per child.
5510    #[actix_web::test]
5511    async fn uncharged_read_skips_pay_per_read_resource() {
5512        let storage = Arc::new(MemoryBackend::new());
5513        let acl = format!(
5514            "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
5515             <#paid> a acl:Authorization ; acl:agent <{PRINCIPAL}> ;\n\
5516               acl:accessTo </premium/doc> ; acl:mode acl:Read ;\n\
5517               acl:condition [ a acl:PaymentCondition ; acl:costSats 100 ] .\n"
5518        );
5519        storage
5520            .put("/premium/doc.acl", Bytes::from(acl), "text/turtle")
5521            .await
5522            .unwrap();
5523        seed_ledger(storage.as_ref(), PRINCIPAL, 500).await;
5524        let state = AppState::new(storage.clone());
5525
5526        let skipped =
5527            enforce_read_inner(&state, "/premium/doc", Some(PRINCIPAL), None, false).await;
5528        assert!(skipped.is_err(), "glob must not serve a pay-per-read child");
5529        assert_eq!(
5530            resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5531            Some(500)
5532        );
5533
5534        enforce_read_ctx(&state, "/premium/doc", Some(PRINCIPAL), None)
5535            .await
5536            .expect("a direct read pays and is served");
5537        assert_eq!(
5538            resolve_balance_sats(&*storage, Some(PRINCIPAL)).await,
5539            Some(400)
5540        );
5541    }
5542
5543    /// Anonymous (no principal) → `None`, so a PaymentCondition fails closed.
5544    #[actix_web::test]
5545    async fn resolve_balance_none_when_anonymous() {
5546        let storage = MemoryBackend::new();
5547        seed_ledger(&storage, PRINCIPAL, 1_000).await;
5548        assert_eq!(resolve_balance_sats(&storage, None).await, None);
5549    }
5550
5551    /// End-to-end: a sat-priced resource is DENIED below balance.
5552    #[actix_web::test]
5553    async fn paid_write_denied_below_balance() {
5554        let storage = Arc::new(MemoryBackend::new());
5555        seed_acl(storage.as_ref()).await;
5556        seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; // < 100 cost
5557        let state = AppState::new(storage);
5558
5559        let result =
5560            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5561        assert!(
5562            result.is_err(),
5563            "balance 50 < cost 100 must be denied — sat-gating loop closed"
5564        );
5565    }
5566
5567    /// End-to-end: a sat-priced resource is ALLOWED at the balance threshold.
5568    #[actix_web::test]
5569    async fn paid_write_allowed_at_balance() {
5570        let storage = Arc::new(MemoryBackend::new());
5571        seed_acl(storage.as_ref()).await;
5572        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; // == 100 cost
5573        let state = AppState::new(storage);
5574
5575        let result =
5576            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5577        assert!(
5578            result.is_ok(),
5579            "balance 100 >= cost 100 must be granted — sat-gating loop closed"
5580        );
5581    }
5582
5583    /// End-to-end: a sat-priced resource is ALLOWED above the threshold.
5584    #[actix_web::test]
5585    async fn paid_write_allowed_above_balance() {
5586        let storage = Arc::new(MemoryBackend::new());
5587        seed_acl(storage.as_ref()).await;
5588        seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5589        let state = AppState::new(storage);
5590
5591        let result =
5592            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5593        assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
5594    }
5595
5596    /// Regression guard: before this fix `payment_balance_sats` was
5597    /// hardcoded `None`, so even an over-funded principal was denied.
5598    /// An anonymous caller (no principal) must still be denied.
5599    #[actix_web::test]
5600    async fn paid_write_anonymous_denied() {
5601        let storage = Arc::new(MemoryBackend::new());
5602        seed_acl(storage.as_ref()).await;
5603        seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
5604        let state = AppState::new(storage);
5605
5606        let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
5607        assert!(
5608            result.is_err(),
5609            "anonymous caller has no ledger principal — PaymentCondition fails closed"
5610        );
5611    }
5612
5613    // -----------------------------------------------------------------
5614    // R-04: sat-gating is a DEBIT, not just a balance check. A granted
5615    // payment-gated request must consume the matched rule's cost from the
5616    // caller's Web Ledger exactly once.
5617    // -----------------------------------------------------------------
5618
5619    async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
5620        let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
5621        let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
5622        ledger.get_balance(did)
5623    }
5624
5625    /// A granted paid WRITE debits the cost from the ledger.
5626    #[actix_web::test]
5627    async fn paid_write_debits_ledger() {
5628        let storage = Arc::new(MemoryBackend::new());
5629        seed_acl(storage.as_ref()).await;
5630        seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; // cost 100
5631        let state = AppState::new(storage.clone());
5632
5633        let result =
5634            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5635        assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
5636        assert_eq!(
5637            read_balance(storage.as_ref(), PRINCIPAL).await,
5638            150,
5639            "250 - 100 cost: the grant must debit exactly the matched rule's cost"
5640        );
5641    }
5642
5643    /// A second granted paid WRITE debits again (no free re-read of the
5644    /// same resource once the balance is consumed).
5645    #[actix_web::test]
5646    async fn paid_write_debits_each_grant() {
5647        let storage = Arc::new(MemoryBackend::new());
5648        seed_acl(storage.as_ref()).await;
5649        seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; // cost 100
5650        let state = AppState::new(storage.clone());
5651
5652        enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5653            .await
5654            .unwrap();
5655        enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
5656            .await
5657            .unwrap();
5658        assert_eq!(
5659            read_balance(storage.as_ref(), PRINCIPAL).await,
5660            50,
5661            "250 - 2*100: each granted request debits, no unmetered re-use"
5662        );
5663
5664        // Third request: 50 < 100 — gate denies, balance unchanged.
5665        let third =
5666            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
5667        assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
5668        assert_eq!(
5669            read_balance(storage.as_ref(), PRINCIPAL).await,
5670            50,
5671            "a denied request must not debit"
5672        );
5673    }
5674
5675    /// A granted paid READ debits the cost from the ledger.
5676    #[actix_web::test]
5677    async fn paid_read_debits_ledger() {
5678        const PAID_READ_ACL: &str = r#"
5679@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5680
5681<#paid-read> a acl:Authorization ;
5682    acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5683    acl:accessTo </premium/feed> ;
5684    acl:mode acl:Read ;
5685    acl:condition [
5686        a acl:PaymentCondition ;
5687        acl:costSats 30
5688    ] .
5689"#;
5690        let storage = Arc::new(MemoryBackend::new());
5691        storage
5692            .put(
5693                "/premium/feed.acl",
5694                Bytes::from(PAID_READ_ACL),
5695                "text/turtle",
5696            )
5697            .await
5698            .unwrap();
5699        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5700        let state = AppState::new(storage.clone());
5701
5702        let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
5703        assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
5704        assert_eq!(
5705            read_balance(storage.as_ref(), PRINCIPAL).await,
5706            70,
5707            "100 - 30 cost: a granted paid read must debit"
5708        );
5709    }
5710
5711    /// A granted FREE read (no PaymentCondition) leaves the ledger
5712    /// untouched.
5713    #[actix_web::test]
5714    async fn free_read_does_not_debit() {
5715        let storage = Arc::new(MemoryBackend::new());
5716        seed_private_read_acl(storage.as_ref()).await; // no PaymentCondition
5717        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
5718        let state = AppState::new(storage.clone());
5719
5720        enforce_read(&state, "/private/secret", Some(PRINCIPAL))
5721            .await
5722            .unwrap();
5723        assert_eq!(
5724            read_balance(storage.as_ref(), PRINCIPAL).await,
5725            100,
5726            "a grant with no PaymentCondition must not debit"
5727        );
5728    }
5729
5730    // -----------------------------------------------------------------
5731    // P0-1: WAC read enforcement (enforce_read)
5732    // -----------------------------------------------------------------
5733
5734    /// ACL granting `alice` Read on `/private/` but NO public/`bob` read.
5735    const ALICE_ONLY_READ_ACL: &str = r#"
5736@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5737
5738<#alice> a acl:Authorization ;
5739    acl:agent <did:nostr:4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa> ;
5740    acl:accessTo </private/secret> ;
5741    acl:default </private/> ;
5742    acl:mode acl:Read, acl:Write, acl:Control .
5743"#;
5744
5745    async fn seed_private_read_acl(storage: &dyn Storage) {
5746        // The resolver walks up from `/private/secret` and probes the
5747        // container sidecar at `/private.acl` (it trims the trailing
5748        // slash before appending `.acl`). The grant inherits down via
5749        // `acl:default </private/>`.
5750        storage
5751            .put(
5752                "/private.acl",
5753                Bytes::from(ALICE_ONLY_READ_ACL),
5754                "text/turtle",
5755            )
5756            .await
5757            .unwrap();
5758    }
5759
5760    /// Before the P0-1 fix `handle_get` served `storage.get()` verbatim
5761    /// with no read-authz, so any resource was world-readable. The owner
5762    /// must be granted Read…
5763    #[actix_web::test]
5764    async fn enforce_read_grants_owner() {
5765        let storage = Arc::new(MemoryBackend::new());
5766        seed_private_read_acl(storage.as_ref()).await;
5767        let state = AppState::new(storage);
5768        let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
5769        assert!(result.is_ok(), "owner alice must be granted Read");
5770    }
5771
5772    /// …and an unrelated authenticated principal must be DENIED Read on a
5773    /// private resource (no world-readable leak).
5774    #[actix_web::test]
5775    async fn enforce_read_denies_other_principal() {
5776        let storage = Arc::new(MemoryBackend::new());
5777        seed_private_read_acl(storage.as_ref()).await;
5778        let state = AppState::new(storage);
5779        let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
5780        assert!(
5781            result.is_err(),
5782            "bob has no Read grant — private resource must not be world-readable"
5783        );
5784    }
5785
5786    /// An anonymous reader is also denied (deny-by-default; no ACL grants
5787    /// public/foaf:Agent Read).
5788    #[actix_web::test]
5789    async fn enforce_read_denies_anonymous() {
5790        let storage = Arc::new(MemoryBackend::new());
5791        seed_private_read_acl(storage.as_ref()).await;
5792        let state = AppState::new(storage);
5793        let result = enforce_read(&state, "/private/secret", None).await;
5794        assert!(result.is_err(), "anonymous Read must be denied");
5795    }
5796
5797    // -----------------------------------------------------------------
5798    // P0-2: `.acl` write requires acl:Control on the protected resource
5799    // -----------------------------------------------------------------
5800
5801    /// ACL granting `writer` Write (but NOT Control) on `/shared/`, and
5802    /// the owner (`PRINCIPAL`) full Control. A Write-only principal must
5803    /// not be able to rewrite the ACL (privilege escalation).
5804    const WRITE_NOT_CONTROL_ACL: &str = concat!(
5805        r#"
5806@prefix acl: <http://www.w3.org/ns/auth/acl#> .
5807
5808<#owner> a acl:Authorization ;
5809    acl:agent <"#,
5810        principal!(),
5811        r#"> ;
5812    acl:accessTo </shared/doc> ;
5813    acl:default </shared/> ;
5814    acl:mode acl:Read, acl:Write, acl:Control .
5815
5816<#writer> a acl:Authorization ;
5817    acl:agent <did:nostr:writer> ;
5818    acl:accessTo </shared/doc> ;
5819    acl:default </shared/> ;
5820    acl:mode acl:Read, acl:Write .
5821"#
5822    );
5823
5824    async fn seed_shared_acl(storage: &dyn Storage) {
5825        // P0-2 resolves the protected resource `/shared/` and the
5826        // resolver probes its sidecar at `/shared.acl` (trailing slash
5827        // trimmed before `.acl`). Seed there so the Control evaluation
5828        // finds the grant.
5829        storage
5830            .put(
5831                "/shared.acl",
5832                Bytes::from(WRITE_NOT_CONTROL_ACL),
5833                "text/turtle",
5834            )
5835            .await
5836            .unwrap();
5837    }
5838
5839    /// A principal with Write but NOT Control on a container is denied PUT
5840    /// on its `.acl` — the check is elevated to acl:Control on the
5841    /// protected resource, closing the privilege-escalation path.
5842    #[actix_web::test]
5843    async fn acl_put_denied_for_writer_without_control() {
5844        let storage = Arc::new(MemoryBackend::new());
5845        seed_shared_acl(storage.as_ref()).await;
5846        let state = AppState::new(storage);
5847        // The request path is the `.acl` sidecar; before the fix this was
5848        // checked as Write on the sidecar (granted). Now it requires
5849        // Control on `/shared/`.
5850        let result = enforce_write(
5851            &state,
5852            "/shared/.acl",
5853            AccessMode::Write,
5854            Some("did:nostr:writer"),
5855        )
5856        .await;
5857        assert!(
5858            result.is_err(),
5859            "writer lacks Control — must not be able to PUT /shared/.acl"
5860        );
5861    }
5862
5863    /// The Control holder (owner) is still allowed to PUT the `.acl`.
5864    #[actix_web::test]
5865    async fn acl_put_allowed_for_control_holder() {
5866        let storage = Arc::new(MemoryBackend::new());
5867        seed_shared_acl(storage.as_ref()).await;
5868        let state = AppState::new(storage);
5869        let result =
5870            enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
5871        assert!(
5872            result.is_ok(),
5873            "the owner holds Control — must be allowed to PUT /shared/.acl"
5874        );
5875    }
5876
5877    /// The same elevation applies to `.meta` sidecars.
5878    #[actix_web::test]
5879    async fn meta_put_denied_for_writer_without_control() {
5880        let storage = Arc::new(MemoryBackend::new());
5881        seed_shared_acl(storage.as_ref()).await;
5882        let state = AppState::new(storage);
5883        let result = enforce_write(
5884            &state,
5885            "/shared/doc.meta",
5886            AccessMode::Write,
5887            Some("did:nostr:writer"),
5888        )
5889        .await;
5890        assert!(
5891            result.is_err(),
5892            "writer lacks Control — must not be able to PUT a .meta sidecar"
5893        );
5894    }
5895
5896    /// Trail secrets are Control-gated; their sidecars map to Control on the
5897    /// trail file; the public `blocktrails.json` and ordinary paths are not.
5898    #[test]
5899    fn canonical_pod_path_vectors() {
5900        let ok = [
5901            ("a/b", "/a/b"),
5902            ("", "/"),
5903            ("/", "/"),
5904            ("//a//b/", "/a/b/"),
5905            ("/%2ewell-known/x", "/.well-known/x"),
5906            ("/a%2fb", "/a/b"),
5907            ("/a%2520b", "/a%20b"),
5908        ];
5909        for (raw, want) in ok {
5910            assert_eq!(canonical_pod_path(raw).as_deref(), Some(want), "{raw}");
5911        }
5912        for bad in [
5913            "/a/./b",
5914            "/a/..",
5915            "../a",
5916            "/a\\b",
5917            "/a%00b",
5918            "/a%ffb",
5919            "/%2e%2e/x",
5920        ] {
5921            assert_eq!(canonical_pod_path(bad), None, "{bad}");
5922        }
5923    }
5924
5925    #[test]
5926    fn guarded_acl_target_vectors() {
5927        use AccessMode::{Control, Read, Write};
5928        let c = |p: &str, m| guarded_acl_target(p, m);
5929        assert_eq!(
5930            c("/.well-known/token/x.json", Read),
5931            ("/.well-known/token/x.json".into(), Control)
5932        );
5933        assert_eq!(
5934            c("/.well-known/gitmark/p.json", Write),
5935            ("/.well-known/gitmark/p.json".into(), Control)
5936        );
5937        assert_eq!(c("/.well-known/%74oken/x.json", Read).1, Control);
5938        assert_eq!(
5939            c("/.well-known/token/x.json.acl", Read),
5940            ("/.well-known/token/x.json".into(), Control)
5941        );
5942        assert_eq!(
5943            c("/.well-known/gitmark/p/blocktrails.json", Read),
5944            ("/.well-known/gitmark/p/blocktrails.json".into(), Read)
5945        );
5946        assert_eq!(
5947            c("/.well-known/webledgers/webledgers.json", Write),
5948            ("/.well-known/webledgers/webledgers.json".into(), Control)
5949        );
5950        assert_eq!(c("/.well-known/%57ebledgers/state.json", Read).1, Control);
5951        assert_eq!(c("/.well-known/webledgersx/y", Read).1, Read);
5952        assert_eq!(c("/pub/x", Read), ("/pub/x".into(), Read));
5953    }
5954
5955    /// Unit cover for the suffix-stripping helper.
5956    #[test]
5957    fn protected_resource_for_acl_strips_suffixes() {
5958        assert_eq!(
5959            protected_resource_for_acl("/victim/.acl").as_deref(),
5960            Some("/victim/")
5961        );
5962        assert_eq!(
5963            protected_resource_for_acl("/a/b.acl").as_deref(),
5964            Some("/a/b")
5965        );
5966        assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
5967        assert_eq!(
5968            protected_resource_for_acl("/a/b.meta").as_deref(),
5969            Some("/a/b")
5970        );
5971        assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
5972    }
5973}
5974
5975#[cfg(all(test, feature = "forge-mirror"))]
5976mod forge_env_tests {
5977    use super::*;
5978
5979    /// One test owns the `FORGE_MIRROR_*` variables, so there is no race.
5980    #[test]
5981    fn mirror_config_comes_from_the_environment() {
5982        let pk = "ab".repeat(32);
5983        std::env::remove_var("FORGE_MIRROR_RELAYS");
5984        std::env::remove_var("FORGE_MIRROR_FOLLOW");
5985        assert!(mirror_config().is_none(), "nothing configured: mirror off");
5986
5987        std::env::set_var(
5988            "FORGE_MIRROR_RELAYS",
5989            "ws://127.0.0.1:7777, wss://r.example , nope",
5990        );
5991        assert!(mirror_config().is_none(), "relays alone follow nothing");
5992
5993        std::env::set_var(
5994            "FORGE_MIRROR_FOLLOW",
5995            format!("{pk}/agent, bad/entry, {pk}/../x"),
5996        );
5997        std::env::set_var("FORGE_MIRROR_MIN_INTERVAL_MS", "1000");
5998        let cfg = mirror_config().expect("one relay and one valid entry");
5999        assert_eq!(cfg.relays, ["ws://127.0.0.1:7777", "wss://r.example"]);
6000        assert_eq!(cfg.follow.len(), 1);
6001        assert_eq!(cfg.follow[0].name, "agent");
6002        assert_eq!(cfg.min_interval_ms, 1000);
6003
6004        std::env::remove_var("FORGE_MIRROR_RELAYS");
6005        std::env::remove_var("FORGE_MIRROR_FOLLOW");
6006        std::env::remove_var("FORGE_MIRROR_MIN_INTERVAL_MS");
6007    }
6008
6009    #[test]
6010    fn the_forge_service_is_built_once_per_data_directory() {
6011        let td = tempfile::tempdir().unwrap();
6012        let a = forge_service(td.path().join(".forge")).unwrap();
6013        let b = forge_service(td.path().join(".forge")).unwrap();
6014        assert!(Arc::ptr_eq(&a, &b));
6015        let other = tempfile::tempdir().unwrap();
6016        let c = forge_service(other.path().join(".forge")).unwrap();
6017        assert!(!Arc::ptr_eq(&a, &c));
6018    }
6019}