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