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