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/POST | `/{pod}/info/refs` `…/git-{upload,receive}-pack` | Git smart-HTTP (WAC-gated) |
52//!
53//! The `/pay/*` HTTP-402 economy routes (`handlers::pay`) wire the
54//! `solid-pod-rs` Web-Ledger / order-book / AMM core onto actix; the `_prov`
55//! routes (`handlers::prov`, `--features git`) expose the git-mark +
56//! block-trail provenance API (ADR-059). Block-trail anchor verification and
57//! broadcast go through the native [`mempool`] client (mempool.space testnet4
58//! by default), with trail persistence via [`trail_store`]. Every LDP
59//! `PUT`/`POST`/`PATCH` to a git-backed pod additionally fires the always-on
60//! git-mark write hook (`git_mark_write`, when built with `--features git`).
61//!
62//! ## Middleware stack (applied in order)
63//!
64//! 1. `NormalizePath` -- collapse `//` and decode %-encoded segments.
65//! 2. `PathTraversalGuard` -- defence-in-depth `..` re-check.
66//! 3. `DotfileGuard` -- rejects `.env` etc unless on the allowlist.
67//! 4. `PayloadConfig` -- enforces `JSS_MAX_REQUEST_BODY` body cap.
68//! 5. `ErrorLoggingMiddleware` -- structured 5xx logging.
69//! 6. WAC-on-write -- PUT/POST/PATCH/DELETE require a write/append grant.
70
71#![doc = include_str!("../README.md")]
72#![deny(unsafe_code)]
73#![warn(rust_2018_idioms)]
74
75/// CLI argument definitions (clap derive structs).
76pub mod cli;
77
78/// HTTP request handlers grouped by domain. Currently hosts the payment
79/// routing layer ([`handlers::pay`]) which wires the orphaned
80/// `solid-pod-rs` order-book / AMM / Web-Ledger logic onto actix routes
81/// with JSS-parity JSON.
82mod handlers;
83
84/// MCP (Model Context Protocol) server subsystem — `POST /mcp`, mounted
85/// only when [`AppState::mcp_enabled`] (`--mcp` / `JSS_MCP`, JSS #490).
86mod mcp;
87
88/// Native mempool.space REST client (provenance-upgrade Phase 3). Concrete
89/// [`solid_pod_rs::mrc20::MempoolLookup`] + the verify-side
90/// [`solid_pod_rs::provenance::BlockAnchorer`]. Server-side only (builds a
91/// `reqwest::Client`); wasm consumers implement the trait over `fetch`.
92pub mod mempool;
93
94/// MRC20 trail persistence (provenance-upgrade Phase 4). Loads/saves a
95/// token's Bitcoin-anchored state chain at `/.well-known/token/{ticker}.json`
96/// via the pod's [`solid_pod_rs::storage::Storage`] backend (JSS
97/// `token.js:189-208`). Native-only; holds the issuer secret off the public
98/// [`solid_pod_rs::mrc20::Mrc20Trail`] type.
99pub mod trail_store;
100
101use std::collections::HashMap;
102use std::net::{IpAddr, Ipv4Addr};
103use std::path::{Path, PathBuf};
104use std::sync::{Arc, Mutex};
105use std::time::{Duration, Instant};
106
107use actix_web::body::{BoxBody, EitherBody};
108use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
109use actix_web::http::{header, StatusCode};
110use actix_web::middleware::{NormalizePath, TrailingSlash};
111use actix_web::{web, App, Error as ActixError, HttpRequest, HttpResponse};
112use bytes::Bytes;
113use futures_util::future::{ready, LocalBoxFuture, Ready};
114use percent_encoding::percent_decode_str;
115use serde::Deserialize;
116use solid_pod_rs::{
117    auth::nip98,
118    config::sources::parse_size,
119    interop,
120    ldp::{self, LdpContainerOps, PatchCreateOutcome},
121    mashlib::{self, MashlibConfig},
122    provision,
123    security::DotfileAllowlist,
124    storage::Storage,
125    wac::{
126        self, conditions::RequestContext, parse_jsonld_acl, parser::parse_turtle_acl, AccessMode,
127    },
128    PodError,
129};
130
131// ---------------------------------------------------------------------------
132// Shared app state
133// ---------------------------------------------------------------------------
134
135/// Actix-web shared state.
136#[derive(Clone)]
137pub struct AppState {
138    pub storage: Arc<dyn Storage>,
139    pub dotfiles: Arc<DotfileAllowlist>,
140    pub body_cap: usize,
141    pub nodeinfo: NodeInfoMeta,
142    pub mashlib: MashlibConfig,
143    /// Legacy alias — reads from `mashlib.mode` when `Cdn`.  Deprecated;
144    /// use `mashlib` directly.
145    pub mashlib_cdn: Option<String>,
146    /// Payment configuration — drives `/pay/.info` and the `X-Balance` /
147    /// `X-Cost` / `X-Pay-Currency` response headers on paid resources.
148    pub pay_config: solid_pod_rs::payments::PayConfig,
149    /// Absolute filesystem root of the pod storage tree. `Some` when the
150    /// backend is `FsBackend`; `None` for in-memory or cloud-backed
151    /// storage. Required by the `git` feature to locate pod directories
152    /// for `GitAutoInit` (provisioning) and `GitHttpService` (serving).
153    pub data_root: Option<PathBuf>,
154    /// JSS-compatible pod creation limiter: one `POST /.pods` per IP per day.
155    pub pod_create_limiter: Arc<PodCreateLimiter>,
156    /// When non-empty, CORS responses are only reflected for origins in this
157    /// list. Origins not in the list receive no `Access-Control-Allow-Origin`
158    /// header. When empty (the default), the request `Origin` is echoed back
159    /// (wildcard-equivalent behaviour, suitable for local dev).
160    ///
161    /// Configured via `--allowed-origins` / `SOLID_ALLOWED_ORIGINS` (comma-separated).
162    pub allowed_origins: Vec<String>,
163    /// Pre-shared key for the `POST /_admin/provision/{pubkey}` endpoint.
164    /// When `None`, the endpoint returns 403 unconditionally.
165    ///
166    /// Configured via `--admin-key` / `SOLID_ADMIN_KEY`.
167    pub admin_key: Option<String>,
168    /// When true, the MCP (Model Context Protocol) server is mounted at
169    /// `POST /mcp`, exposing the pod as a tool surface for agents. OFF by
170    /// default — keys-on-disk and agent write access are an opt-in
171    /// security tradeoff. Configured via `--mcp` / `JSS_MCP` (JSS #490).
172    pub mcp_enabled: bool,
173    /// Optional override for the mempool REST base URL used by the MRC20
174    /// `/pay/.deposit` anchor verification (provenance-upgrade Phase 3).
175    /// `None` ⇒ the handler reads `JSS_PAY_MEMPOOL_URL` (default testnet4).
176    /// Tests point this at a local fixture server so they never reach
177    /// mempool.space; production leaves it `None`.
178    pub mempool_url: Option<String>,
179}
180
181/// NodeInfo 2.1 body inputs. Kept here so tests can override them.
182#[derive(Clone, Debug)]
183pub struct NodeInfoMeta {
184    pub software_name: String,
185    pub software_version: String,
186    pub open_registrations: bool,
187    pub total_users: u64,
188    pub base_url: String,
189}
190
191impl Default for NodeInfoMeta {
192    fn default() -> Self {
193        Self {
194            software_name: "solid-pod-rs-server".to_string(),
195            software_version: env!("CARGO_PKG_VERSION").to_string(),
196            open_registrations: false,
197            total_users: 0,
198            base_url: "http://localhost".to_string(),
199        }
200    }
201}
202
203/// Discover the body cap from the environment. Accepts values like
204/// `50MB`, `1.5GB`, or a bare integer (bytes). Falls back to 50 MiB.
205pub const DEFAULT_BODY_CAP: usize = 50 * 1024 * 1024;
206
207/// Read `JSS_MAX_REQUEST_BODY` and parse via [`parse_size`]. On any
208/// failure, returns [`DEFAULT_BODY_CAP`].
209pub fn body_cap_from_env() -> usize {
210    match std::env::var("JSS_MAX_REQUEST_BODY") {
211        Ok(v) => parse_size(&v)
212            .map(|u| u as usize)
213            .unwrap_or(DEFAULT_BODY_CAP),
214        Err(_) => DEFAULT_BODY_CAP,
215    }
216}
217
218impl AppState {
219    /// Convenience constructor for tests and the binary. Callers may
220    /// replace fields after creation since `AppState` is a plain struct.
221    pub fn new(storage: Arc<dyn Storage>) -> Self {
222        Self {
223            storage,
224            dotfiles: Arc::new(DotfileAllowlist::from_env()),
225            body_cap: body_cap_from_env(),
226            nodeinfo: NodeInfoMeta::default(),
227            mashlib: MashlibConfig::default(),
228            mashlib_cdn: None,
229            pay_config: solid_pod_rs::payments::PayConfig::default(),
230            data_root: None,
231            pod_create_limiter: Arc::new(PodCreateLimiter::default()),
232            allowed_origins: Vec::new(),
233            admin_key: None,
234            mcp_enabled: false,
235            mempool_url: None,
236        }
237    }
238}
239
240/// In-process sliding-window limiter for JSS-compatible `POST /.pods`.
241#[derive(Debug)]
242pub struct PodCreateLimiter {
243    hits: Mutex<HashMap<IpAddr, Instant>>,
244    window: Duration,
245}
246
247impl Default for PodCreateLimiter {
248    fn default() -> Self {
249        Self {
250            hits: Mutex::new(HashMap::new()),
251            window: Duration::from_secs(24 * 60 * 60),
252        }
253    }
254}
255
256impl PodCreateLimiter {
257    fn check(&self, ip: IpAddr) -> Result<(), u64> {
258        let now = Instant::now();
259        let mut hits = self.hits.lock().unwrap();
260        if let Some(last) = hits.get(&ip).copied() {
261            let elapsed = now.saturating_duration_since(last);
262            if elapsed < self.window {
263                return Err(self.window.saturating_sub(elapsed).as_secs().max(1));
264            }
265        }
266        hits.insert(ip, now);
267        Ok(())
268    }
269}
270
271// ---------------------------------------------------------------------------
272// Error translation
273// ---------------------------------------------------------------------------
274
275pub(crate) fn to_actix(e: PodError) -> ActixError {
276    match e {
277        PodError::NotFound(_) => actix_web::error::ErrorNotFound(e.to_string()),
278        PodError::BadRequest(_) => actix_web::error::ErrorBadRequest(e.to_string()),
279        PodError::Unsupported(_) => actix_web::error::ErrorUnsupportedMediaType(e.to_string()),
280        PodError::Forbidden => actix_web::error::ErrorForbidden(e.to_string()),
281        PodError::Unauthenticated => actix_web::error::ErrorUnauthorized(e.to_string()),
282        PodError::PreconditionFailed(_) => actix_web::error::ErrorPreconditionFailed(e.to_string()),
283        _ => actix_web::error::ErrorInternalServerError(e.to_string()),
284    }
285}
286
287// ---------------------------------------------------------------------------
288// Auth helper — shared across handlers
289// ---------------------------------------------------------------------------
290
291/// Attempt NIP-98 bearer verification; returns the pubkey on success.
292pub(crate) async fn extract_pubkey(req: &HttpRequest) -> Option<String> {
293    let header_val = req
294        .headers()
295        .get(header::AUTHORIZATION)
296        .and_then(|v| v.to_str().ok())?;
297    // Reconstruct the request URL the NIP-98 event was signed over. The
298    // scheme must reflect the externally-visible scheme (honouring
299    // `X-Forwarded-Proto` via actix `connection_info`) — a pod behind TLS
300    // or a federation reverse proxy is reached at `https://`, and the agent
301    // signs that URL. Hardcoding `http://` would break URL matching for
302    // every TLS-fronted deployment. Mirrors the base-URI construction used
303    // elsewhere in this file (see `conn.scheme()` call sites).
304    let conn = req.connection_info();
305    let url = format!("{}://{}{}", conn.scheme(), conn.host(), req.uri().path());
306    nip98::verify(header_val, &url, req.method().as_str(), None)
307        .await
308        .ok()
309}
310
311pub(crate) fn agent_uri(pubkey: Option<&String>) -> Option<String> {
312    pubkey.map(|pk| format!("did:nostr:{pk}"))
313}
314
315/// Canonical pod-relative path of the Web Ledger document. The
316/// `acl:PaymentCondition` evaluator is fed the requesting principal's
317/// satoshi balance read from this resource.
318pub(crate) const WEBLEDGER_PATH: &str = "/.well-known/webledgers/webledgers.json";
319
320/// Resolve the requesting principal's satoshi balance from the pod's
321/// Web Ledger so the WAC `acl:PaymentCondition` evaluator receives a
322/// concrete value instead of `None`.
323///
324/// Returns:
325/// * `None` when there is no authenticated principal (anonymous request)
326///   — a `PaymentCondition` then fails closed (402/403);
327/// * `Some(0)` when the principal is authenticated but has no ledger
328///   entry (or no ledger exists yet) — sufficient to satisfy only a
329///   zero-cost condition;
330/// * `Some(balance)` resolved from the ledger entry keyed by the
331///   principal's `did:nostr` URI otherwise.
332///
333/// The lookup is keyed by the authenticated principal's WebID, which for
334/// a NIP-98 caller is `did:nostr:<hex-pubkey>` — the same key the
335/// `/pay/.deposit` credit path writes into the ledger.
336async fn resolve_balance_sats(storage: &dyn Storage, agent_uri: Option<&str>) -> Option<u64> {
337    let did = agent_uri?;
338    let balance = match storage.get(WEBLEDGER_PATH).await {
339        Ok((bytes, _meta)) => {
340            match serde_json::from_slice::<solid_pod_rs::payments::WebLedger>(&bytes) {
341                Ok(ledger) => ledger.get_balance(did),
342                // A malformed ledger document must not crash the auth
343                // path; treat it as an empty balance (fail-closed for
344                // any non-zero PaymentCondition).
345                Err(_) => 0,
346            }
347        }
348        // No ledger provisioned yet: authenticated principal with zero
349        // balance.
350        Err(_) => 0,
351    };
352    Some(balance)
353}
354
355/// Return `true` when the `Accept` header includes `text/html`.
356///
357/// Used for container `index.html` content negotiation: if a browser
358/// requests `text/html` on a container URL and that container contains
359/// an `index.html` resource, the server serves the HTML file instead of
360/// the RDF container listing. Solid clients that send `Accept: text/turtle`
361/// or `application/ld+json` skip this path entirely.
362fn accept_includes_html(accept: &str) -> bool {
363    accept.split(',').any(|entry| {
364        let mime = entry.split(';').next().unwrap_or("").trim();
365        mime.eq_ignore_ascii_case("text/html")
366    })
367}
368
369// ---------------------------------------------------------------------------
370// WAC enforcement for writes (PUT / POST / PATCH / DELETE)
371// ---------------------------------------------------------------------------
372
373/// Resolve the effective ACL and evaluate whether the given WebID may
374/// perform `mode` on `path`.
375///
376/// Returns `Ok(())` on grant. On deny, returns an `actix_web::Error`:
377/// * `401` when the request had no authenticated agent (so the client
378///   knows retrying with credentials might work);
379/// * `403` when authenticated but the ACL does not grant the mode.
380/// Strip a `.acl` / `.meta` suffix from `path`, returning the protected
381/// resource the sidecar governs. `/victim/.acl` → `/victim/`,
382/// `/a/b.acl` → `/a/b`, `/.acl` → `/`. Returns `None` when `path` is not
383/// an ACL/meta sidecar.
384fn protected_resource_for_acl(path: &str) -> Option<String> {
385    for suffix in [".acl", ".meta"] {
386        if let Some(stripped) = path.strip_suffix(suffix) {
387            // `/.acl` and `/dir/.acl` strip to `/` and `/dir/`
388            // respectively (container ACLs); `/a/b.acl` strips to the
389            // resource `/a/b`.
390            if stripped.is_empty() {
391                return Some("/".to_string());
392            }
393            return Some(stripped.to_string());
394        }
395    }
396    None
397}
398
399/// P0-2 lockout guard (mirrors `mcp/tools.rs:511-552`). Parse a proposed
400/// `.acl` document body and confirm at least one authorization still
401/// grants `acl:Control` to `caller` (by exact WebID, `foaf:Agent`, or —
402/// for an authenticated caller — `acl:AuthenticatedAgent`). Returns
403/// `true` when the proposed ACL is unparseable (the storage layer will
404/// reject malformed bodies; the guard only fires on a parseable ACL that
405/// would strip the caller's Control) or when Control is preserved.
406fn proposed_acl_keeps_caller_control(body: &[u8], content_type: &str, caller: Option<&str>) -> bool {
407    let doc = match parse_jsonld_acl(body) {
408        Ok(d) => Some(d),
409        Err(_) => {
410            let ct = content_type.to_ascii_lowercase();
411            let text = std::str::from_utf8(body).unwrap_or("");
412            let looks_turtle = ct.starts_with("text/turtle")
413                || ct.starts_with("application/turtle")
414                || ct.starts_with("application/x-turtle")
415                || text.contains("@prefix")
416                || text.contains("acl:Authorization");
417            if looks_turtle {
418                parse_turtle_acl(text).ok()
419            } else {
420                None
421            }
422        }
423    };
424    let Some(doc) = doc else {
425        // Unparseable as an ACL — not our concern; let storage reject it.
426        return true;
427    };
428    let Some(graph) = doc.graph.as_ref() else {
429        return false;
430    };
431    graph.iter().any(|auth| {
432        let grants_control = ids_of_acl_field(&auth.mode)
433            .iter()
434            .any(|m| *m == "acl:Control" || *m == "http://www.w3.org/ns/auth/acl#Control");
435        if !grants_control {
436            return false;
437        }
438        let agents = ids_of_acl_field(&auth.agent);
439        if let Some(web_id) = caller {
440            if agents.iter().any(|a| *a == web_id) {
441                return true;
442            }
443        }
444        let classes = ids_of_acl_field(&auth.agent_class);
445        if classes
446            .iter()
447            .any(|c| *c == "http://xmlns.com/foaf/0.1/Agent" || *c == "foaf:Agent")
448        {
449            return true;
450        }
451        if caller.is_some()
452            && classes.iter().any(|c| {
453                *c == "http://www.w3.org/ns/auth/acl#AuthenticatedAgent"
454                    || *c == "acl:AuthenticatedAgent"
455            })
456        {
457            return true;
458        }
459        false
460    })
461}
462
463/// Flatten an optional `IdOrIds` ACL field into a `Vec<&str>` of IRIs.
464fn ids_of_acl_field(field: &Option<wac::IdOrIds>) -> Vec<&str> {
465    match field {
466        None => Vec::new(),
467        Some(wac::IdOrIds::Single(r)) => vec![r.id.as_str()],
468        Some(wac::IdOrIds::Multiple(v)) => v.iter().map(|r| r.id.as_str()).collect(),
469    }
470}
471
472async fn enforce_write(
473    state: &AppState,
474    path: &str,
475    mode: AccessMode,
476    agent_uri: Option<&str>,
477) -> Result<(), ActixError> {
478    // P0-2: an `.acl`/`.meta` sidecar governs *another* resource's
479    // permissions. Authorising its mutation as plain `Write` on the
480    // sidecar path lets any writer rewrite the ACL and self-escalate
481    // (privilege escalation). WAC §4.3.5 requires `acl:Control` on the
482    // PROTECTED resource. Elevate the check accordingly, mirroring the
483    // MCP `write_acl` path (mcp/tools.rs:505), and apply the same
484    // lockout guard so a Control holder cannot strip every other
485    // principal's Control in a single write.
486    if let Some(protected) = protected_resource_for_acl(path) {
487        let control_acl = match find_effective_acl_dyn(&*state.storage, &protected).await {
488            Ok(doc) => doc,
489            Err(e) => return Err(to_actix(e)),
490        };
491        let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
492        let ctx = RequestContext {
493            web_id: agent_uri,
494            client_id: None,
495            issuer: None,
496            payment_balance_sats,
497        };
498        let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
499        let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
500        let has_control = wac::evaluate_access_ctx_with_registry(
501            control_acl.as_ref(),
502            &ctx,
503            &protected,
504            AccessMode::Control,
505            None,
506            &groups,
507            &registry,
508        );
509        if !has_control {
510            return Err(acl_denial(control_acl.as_ref(), agent_uri, &protected));
511        }
512        return Ok(());
513    }
514
515    // `StorageAclResolver` is generic over a concrete backend. `state`
516    // holds an `Arc<dyn Storage>`; wrap it in a trait-object-friendly
517    // adapter (`DynStorage`) that forwards each trait method so the
518    // resolver can be constructed with a concrete type.
519    let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
520        Ok(doc) => doc,
521        Err(e) => return Err(to_actix(e)),
522    };
523
524    // Resolve the principal's satoshi balance from the Web Ledger so a
525    // sat-priced resource (`acl:PaymentCondition`) is actually gated.
526    // `None` only for anonymous callers (no `did:nostr` principal), in
527    // which case any PaymentCondition fails closed.
528    let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
529
530    let ctx = RequestContext {
531        web_id: agent_uri,
532        client_id: None,
533        issuer: None,
534        payment_balance_sats,
535    };
536    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
537    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
538    let granted = wac::evaluate_access_ctx_with_registry(
539        acl_doc.as_ref(),
540        &ctx,
541        path,
542        mode,
543        None,
544        &groups,
545        &registry,
546    );
547    if granted {
548        // Sat-gating consumption for the write path: identical to the
549        // read path (see `enforce_read`). A granted write whose
550        // authorising rule carried an `acl:PaymentCondition` debits the
551        // caller's Web Ledger by the matched rule's cost. The WAC gate
552        // above already proved `balance >= cost`, so a debit failure can
553        // only mean a concurrent spend raced the balance below cost —
554        // fail closed, never serve an unpaid write.
555        if let Err(e) =
556            charge_granted_payment(state, acl_doc.as_ref(), &ctx, path, mode, &groups, &registry)
557                .await
558        {
559            return Err(e);
560        }
561        return Ok(());
562    }
563
564    Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
565}
566
567/// Apply the `acl:PaymentCondition` debit for a request the WAC gate has
568/// already granted. Computes the cost of the single granting rule via
569/// [`wac::granted_payment_cost`] and, when that cost is non-zero and the
570/// caller is an authenticated principal, debits their Web Ledger exactly
571/// once. A zero cost (no PaymentCondition on the granting rule) is a
572/// no-op. A debit failure (insufficient balance after a concurrent
573/// spend, or ledger I/O error) is surfaced as the same WAC denial the
574/// caller would have received, so the request is never served unpaid.
575async fn charge_granted_payment(
576    state: &AppState,
577    acl_doc: Option<&wac::AclDocument>,
578    ctx: &RequestContext<'_>,
579    path: &str,
580    mode: AccessMode,
581    groups: &wac::StaticGroupMembership,
582    registry: &wac::conditions::ConditionRegistry,
583) -> Result<(), ActixError> {
584    let cost = wac::granted_payment_cost(acl_doc, ctx, path, mode, groups, registry);
585    if cost == 0 {
586        return Ok(());
587    }
588    if let Some(did) = ctx.web_id {
589        if debit_ledger(&*state.storage, did, cost).await.is_err() {
590            return Err(acl_denial(acl_doc, ctx.web_id, path));
591        }
592    }
593    Ok(())
594}
595
596/// Build the WAC denial `actix_web::Error` shared by the read and write
597/// enforcement paths: `401` (with a `WWW-Authenticate` challenge) for an
598/// unauthenticated caller so a retry with credentials is signalled, or
599/// `403` for an authenticated caller the ACL does not grant. Both carry
600/// the advisory `WAC-Allow` header describing the effective permissions.
601fn acl_denial(
602    acl_doc: Option<&wac::AclDocument>,
603    agent_uri: Option<&str>,
604    path: &str,
605) -> ActixError {
606    let allow_header = wac::wac_allow_header(acl_doc, agent_uri, path);
607    let (status, body, unauthenticated) = if agent_uri.is_none() {
608        (StatusCode::UNAUTHORIZED, "authentication required", true)
609    } else {
610        (StatusCode::FORBIDDEN, "access forbidden", false)
611    };
612    let mut rsp = HttpResponse::new(status);
613    rsp.headers_mut().insert(
614        header::HeaderName::from_static("wac-allow"),
615        header::HeaderValue::from_str(&allow_header)
616            .unwrap_or(header::HeaderValue::from_static("")),
617    );
618    if unauthenticated {
619        // Advertise every auth scheme the pod accepts so an
620        // unauthenticated agent knows how to retry. `extract_pubkey` verifies
621        // NIP-98 (`Authorization: Nostr <base64(kind-27235 event)>`), which is
622        // how a `did:nostr` agent authenticates against the pod — without the
623        // `Nostr` challenge an agent has no protocol signal that NIP-98 is
624        // accepted. DPoP/Bearer remain advertised for OIDC/DPoP clients.
625        rsp.headers_mut().insert(
626            header::WWW_AUTHENTICATE,
627            header::HeaderValue::from_static(
628                "Nostr realm=\"Solid\", DPoP realm=\"Solid\", Bearer realm=\"Solid\"",
629            ),
630        );
631    }
632    actix_web::error::InternalError::from_response(body, rsp).into()
633}
634
635/// P0-1: WAC `acl:Read` enforcement for GET / HEAD / container listing.
636///
637/// Mirror of [`enforce_write`] for the `Read` mode. Before this guard the
638/// GET path resolved an advisory `WAC-Allow` header but returned the
639/// resource body verbatim with no read-authz check, so every private
640/// resource was world-readable. Returns `Ok(())` on grant; on deny a
641/// `401`/`403` matching the write path's denial shape.
642async fn enforce_read(
643    state: &AppState,
644    path: &str,
645    agent_uri: Option<&str>,
646) -> Result<(), ActixError> {
647    let acl_doc = match find_effective_acl_dyn(&*state.storage, path).await {
648        Ok(doc) => doc,
649        Err(e) => return Err(to_actix(e)),
650    };
651    let payment_balance_sats = resolve_balance_sats(&*state.storage, agent_uri).await;
652    let ctx = RequestContext {
653        web_id: agent_uri,
654        client_id: None,
655        issuer: None,
656        payment_balance_sats,
657    };
658    let registry = wac::conditions::ConditionRegistry::default_with_client_and_issuer();
659    let groups: wac::StaticGroupMembership = wac::StaticGroupMembership::default();
660    let granted = wac::evaluate_access_ctx_with_registry(
661        acl_doc.as_ref(),
662        &ctx,
663        path,
664        AccessMode::Read,
665        None,
666        &groups,
667        &registry,
668    );
669    if granted {
670        // Sat-gating consumption: a granted read whose authorising rule
671        // carried an `acl:PaymentCondition` debits the caller's Web
672        // Ledger by the matched rule's cost (fail-closed on a raced
673        // balance). See `charge_granted_payment`.
674        charge_granted_payment(
675            state,
676            acl_doc.as_ref(),
677            &ctx,
678            path,
679            AccessMode::Read,
680            &groups,
681            &registry,
682        )
683        .await?;
684        return Ok(());
685    }
686    Err(acl_denial(acl_doc.as_ref(), agent_uri, path))
687}
688
689/// Debit `cost` satoshis from `did`'s Web Ledger entry and persist the
690/// updated ledger document, deducting exactly once for a granted
691/// payment-gated request.
692///
693/// Reads [`WEBLEDGER_PATH`], applies [`WebLedger::debit`] (which fails
694/// closed on an insufficient or missing balance), and writes the ledger
695/// back. A read, debit, or write failure returns `Err` so the caller can
696/// deny the request rather than serve it unpaid.
697async fn debit_ledger(
698    storage: &dyn Storage,
699    did: &str,
700    cost: u64,
701) -> Result<(), solid_pod_rs::payments::PaymentError> {
702    use solid_pod_rs::payments::{PaymentError, WebLedger};
703
704    let (bytes, _meta) = storage
705        .get(WEBLEDGER_PATH)
706        .await
707        .map_err(|e| PaymentError::Store(e.to_string()))?;
708    let mut ledger: WebLedger = serde_json::from_slice(&bytes)
709        .map_err(|e| PaymentError::Store(format!("malformed ledger: {e}")))?;
710    ledger.debit(did, cost)?;
711    let body = serde_json::to_vec(&ledger)
712        .map_err(|e| PaymentError::Store(format!("serialise ledger: {e}")))?;
713    storage
714        .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
715        .await
716        .map_err(|e| PaymentError::Store(e.to_string()))?;
717    Ok(())
718}
719
720// ---------------------------------------------------------------------------
721// Handlers
722// ---------------------------------------------------------------------------
723
724fn set_link_headers(rsp: &mut HttpResponse, path: &str) {
725    let links = ldp::link_headers(path).join(", ");
726    if let Ok(value) = header::HeaderValue::from_str(&links) {
727        rsp.headers_mut()
728            .insert(header::HeaderName::from_static("link"), value);
729    }
730}
731
732fn set_wac_allow(rsp: &mut HttpResponse, header_value: &str) {
733    if let Ok(v) = header::HeaderValue::from_str(header_value) {
734        rsp.headers_mut()
735            .insert(header::HeaderName::from_static("wac-allow"), v);
736    }
737}
738
739fn set_updates_via(rsp: &mut HttpResponse, base_url: &str) {
740    let ws_base = base_url
741        .replacen("https://", "wss://", 1)
742        .replacen("http://", "ws://", 1);
743    let ws_url = format!("{}/.notifications", ws_base.trim_end_matches('/'));
744    if let Ok(v) = header::HeaderValue::from_str(&ws_url) {
745        rsp.headers_mut()
746            .insert(header::HeaderName::from_static("updates-via"), v);
747    }
748}
749
750async fn handle_get(
751    req: HttpRequest,
752    state: web::Data<AppState>,
753) -> Result<HttpResponse, ActixError> {
754    let path = req.uri().path().to_string();
755
756    if path.contains('*') {
757        return handle_glob_get(req, state).await;
758    }
759
760    let auth_pk = extract_pubkey(&req).await;
761    let agent = agent_uri(auth_pk.as_ref());
762
763    // P0-1: enforce WAC `acl:Read` before serving any bytes. This guards
764    // both resource GETs and the RDF container listing below, and — since
765    // HEAD is routed to this same handler — HEAD requests too. Without it
766    // a private resource is world-readable.
767    enforce_read(&state, &path, agent.as_deref()).await?;
768
769    let wac_allow = wac::wac_allow_header(None, agent.as_deref(), &path);
770
771    if ldp::is_container(&path) {
772        let accept = req
773            .headers()
774            .get(header::ACCEPT)
775            .and_then(|v| v.to_str().ok())
776            .unwrap_or("");
777
778        // Content negotiation: when a browser requests text/html, check
779        // whether the container has an index.html child resource. If so,
780        // serve it directly instead of the RDF container listing. This is
781        // standard HTTP content negotiation — browsers get HTML, Solid
782        // clients get RDF.
783        if accept_includes_html(accept) {
784            let index_path = format!("{}index.html", &path);
785            if let Ok((body, _meta)) = state.storage.get(&index_path).await {
786                let mut rsp = HttpResponse::Ok()
787                    .content_type("text/html; charset=utf-8")
788                    .body(body.to_vec());
789                set_wac_allow(&mut rsp, &wac_allow);
790                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
791                set_link_headers(&mut rsp, &path);
792                return Ok(rsp);
793            }
794        }
795
796        let v = state
797            .storage
798            .container_representation(&path)
799            .await
800            .map_err(to_actix)?;
801
802        // Mashlib: serve HTML wrapper for browser navigation.
803        let sec_fetch_dest = req
804            .headers()
805            .get("sec-fetch-dest")
806            .and_then(|v| v.to_str().ok());
807        if mashlib::should_serve(
808            accept,
809            sec_fetch_dest,
810            "application/ld+json",
811            state.mashlib.enabled,
812        ) {
813            let json_ld = serde_json::to_string(&v).ok();
814            let html = mashlib::generate_html(&path, &state.mashlib, json_ld.as_deref());
815            let mut rsp = HttpResponse::Ok()
816                .content_type("text/html; charset=utf-8")
817                .insert_header(("X-Frame-Options", "DENY"))
818                .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
819                .insert_header(("Cache-Control", "no-store"))
820                .body(html);
821            set_wac_allow(&mut rsp, &wac_allow);
822            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
823            set_link_headers(&mut rsp, &path);
824            return Ok(rsp);
825        }
826
827        let mut rsp = HttpResponse::Ok().json(v);
828        rsp.headers_mut().insert(
829            header::CONTENT_TYPE,
830            header::HeaderValue::from_static("application/ld+json"),
831        );
832        set_wac_allow(&mut rsp, &wac_allow);
833        set_updates_via(&mut rsp, &state.nodeinfo.base_url);
834        set_link_headers(&mut rsp, &path);
835        return Ok(rsp);
836    }
837
838    match state.storage.get(&path).await {
839        Ok((body, meta)) => {
840            // Mashlib: serve HTML wrapper for browser navigation to RDF resources.
841            let accept = req
842                .headers()
843                .get(header::ACCEPT)
844                .and_then(|v| v.to_str().ok())
845                .unwrap_or("");
846            let sec_fetch_dest = req
847                .headers()
848                .get("sec-fetch-dest")
849                .and_then(|v| v.to_str().ok());
850            if mashlib::should_serve(
851                accept,
852                sec_fetch_dest,
853                &meta.content_type,
854                state.mashlib.enabled,
855            ) {
856                let embed = if body.len() <= state.mashlib.data_island_max_bytes {
857                    std::str::from_utf8(&body).ok().map(|s| s.to_string())
858                } else {
859                    None
860                };
861                let html = mashlib::generate_html(&path, &state.mashlib, embed.as_deref());
862                let mut rsp = HttpResponse::Ok()
863                    .content_type("text/html; charset=utf-8")
864                    .insert_header(("X-Frame-Options", "DENY"))
865                    .insert_header(("Content-Security-Policy", "frame-ancestors 'none'"))
866                    .insert_header(("Cache-Control", "no-store"))
867                    .body(html);
868                set_wac_allow(&mut rsp, &wac_allow);
869                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
870                set_link_headers(&mut rsp, &path);
871                return Ok(rsp);
872            }
873
874            // RDF content negotiation: when the client explicitly asks for
875            // a concrete RDF serialisation that differs from how the
876            // resource is stored, transcode it. KG resources persist as
877            // N-Triples (see the PATCH handler), so an agent or extractor
878            // can GET the same graph as Turtle, N-Triples, or JSON-LD on
879            // demand (PRD-014 Seam C / C4). Non-RDF resources, unparseable
880            // bodies, and wildcard/`*/*` Accepts fall through to verbatim.
881            if let Some((negotiated_body, negotiated_ct)) =
882                rdf_content_negotiate(&body, &meta.content_type, accept)
883            {
884                let mut rsp = HttpResponse::Ok().body(negotiated_body);
885                rsp.headers_mut().insert(
886                    header::CONTENT_TYPE,
887                    header::HeaderValue::from_str(negotiated_ct)
888                        .unwrap_or_else(|_| header::HeaderValue::from_static("text/turtle")),
889                );
890                rsp.headers_mut()
891                    .insert(header::VARY, header::HeaderValue::from_static("Accept"));
892                if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
893                    rsp.headers_mut().insert(header::ETAG, etag);
894                }
895                set_wac_allow(&mut rsp, &wac_allow);
896                set_updates_via(&mut rsp, &state.nodeinfo.base_url);
897                set_link_headers(&mut rsp, &path);
898                return Ok(rsp);
899            }
900
901            let mut rsp = HttpResponse::Ok().body(body.to_vec());
902            rsp.headers_mut().insert(
903                header::CONTENT_TYPE,
904                header::HeaderValue::from_str(&meta.content_type).unwrap_or_else(|_| {
905                    header::HeaderValue::from_static("application/octet-stream")
906                }),
907            );
908            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
909                rsp.headers_mut().insert(header::ETAG, etag);
910            }
911            set_wac_allow(&mut rsp, &wac_allow);
912            set_updates_via(&mut rsp, &state.nodeinfo.base_url);
913            set_link_headers(&mut rsp, &path);
914            Ok(rsp)
915        }
916        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
917        Err(e) => Err(to_actix(e)),
918    }
919}
920
921fn has_basic_container_link(req: &HttpRequest) -> bool {
922    req.headers()
923        .get_all(header::LINK)
924        .filter_map(|v| v.to_str().ok())
925        .any(|v| {
926            v.contains("http://www.w3.org/ns/ldp#BasicContainer") && v.contains("rel=\"type\"")
927        })
928}
929
930async fn handle_put(
931    req: HttpRequest,
932    body: web::Bytes,
933    state: web::Data<AppState>,
934) -> Result<HttpResponse, ActixError> {
935    let path = req.uri().path().to_string();
936
937    if ldp::is_container(&path) {
938        if has_basic_container_link(&req) {
939            let auth_pk = extract_pubkey(&req).await;
940            let agent = agent_uri(auth_pk.as_ref());
941            enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
942            let meta = state
943                .storage
944                .create_container(&path)
945                .await
946                .map_err(to_actix)?;
947            let mut rsp = HttpResponse::Created().finish();
948            if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
949                rsp.headers_mut().insert(header::ETAG, etag);
950            }
951            set_link_headers(&mut rsp, &path);
952            return Ok(rsp);
953        }
954        return Ok(HttpResponse::MethodNotAllowed().body("cannot PUT to a container"));
955    }
956
957    let auth_pk = extract_pubkey(&req).await;
958    let agent = agent_uri(auth_pk.as_ref());
959    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
960
961    let ct = req
962        .headers()
963        .get(header::CONTENT_TYPE)
964        .and_then(|v| v.to_str().ok())
965        .unwrap_or("application/octet-stream");
966
967    // P0-2 lockout guard: when writing an `.acl`/`.meta` sidecar, refuse a
968    // proposed ACL that would strip the caller's own Control — the same
969    // footgun the MCP `write_acl` path blocks (mcp/tools.rs:511). Without
970    // this a Control holder could lock themselves (and everyone) out.
971    if protected_resource_for_acl(&path).is_some()
972        && !proposed_acl_keeps_caller_control(&body, ct, agent.as_deref())
973    {
974        return Ok(HttpResponse::Conflict().body(
975            "refused: the proposed ACL would not grant Control to the caller \
976             (use an absolute WebID, foaf:Agent, or acl:AuthenticatedAgent)",
977        ));
978    }
979
980    let meta = state
981        .storage
982        .put(&path, Bytes::from(body.to_vec()), ct)
983        .await
984        .map_err(to_actix)?;
985    // git-mark (Phase 2): commit + PROV-O sidecar on git-backed pods. Runs
986    // AFTER the write succeeded; additive + best-effort (errors swallowed),
987    // git-backed-only, never changes the response.
988    git_mark_write(&state, &path, agent.as_deref(), "PUT").await;
989    let mut rsp = HttpResponse::Created().finish();
990    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
991        rsp.headers_mut().insert(header::ETAG, etag);
992    }
993    set_link_headers(&mut rsp, &path);
994    Ok(rsp)
995}
996
997async fn handle_post(
998    req: HttpRequest,
999    body: web::Bytes,
1000    state: web::Data<AppState>,
1001) -> Result<HttpResponse, ActixError> {
1002    let path = req.uri().path().to_string();
1003    // POST route only matches container paths (trailing slash) via the
1004    // `POST /{tail:.*}/` registration.
1005    let auth_pk = extract_pubkey(&req).await;
1006    let agent = agent_uri(auth_pk.as_ref());
1007    enforce_write(&state, &path, AccessMode::Append, agent.as_deref()).await?;
1008
1009    let slug = req
1010        .headers()
1011        .get(header::HeaderName::from_static("slug"))
1012        .and_then(|v| v.to_str().ok());
1013    let target = match ldp::resolve_slug(&path, slug) {
1014        Ok(p) => p,
1015        Err(e) => return Err(to_actix(e)),
1016    };
1017    let ct = req
1018        .headers()
1019        .get(header::CONTENT_TYPE)
1020        .and_then(|v| v.to_str().ok())
1021        .unwrap_or("application/octet-stream");
1022    let meta = state
1023        .storage
1024        .put(&target, Bytes::from(body.to_vec()), ct)
1025        .await
1026        .map_err(to_actix)?;
1027    // git-mark (Phase 2): commit + PROV-O sidecar for the newly-created child.
1028    // Additive + best-effort, git-backed-only.
1029    git_mark_write(&state, &target, agent.as_deref(), "POST").await;
1030    let mut rsp = HttpResponse::Created().finish();
1031    if let Ok(loc) = header::HeaderValue::from_str(&target) {
1032        rsp.headers_mut().insert(header::LOCATION, loc);
1033    }
1034    if let Ok(etag) = header::HeaderValue::from_str(&format!("\"{}\"", meta.etag)) {
1035        rsp.headers_mut().insert(header::ETAG, etag);
1036    }
1037    set_link_headers(&mut rsp, &target);
1038    Ok(rsp)
1039}
1040
1041async fn handle_patch(
1042    req: HttpRequest,
1043    body: web::Bytes,
1044    state: web::Data<AppState>,
1045) -> Result<HttpResponse, ActixError> {
1046    let path = req.uri().path().to_string();
1047    if ldp::is_container(&path) {
1048        return Ok(HttpResponse::MethodNotAllowed().body("cannot PATCH a container"));
1049    }
1050    let auth_pk = extract_pubkey(&req).await;
1051    let agent = agent_uri(auth_pk.as_ref());
1052    // PATCH can modify or delete data (e.g. N3 Patch with solid:deletes),
1053    // so it requires full Write permission — not just Append. Only POST
1054    // (which creates new child resources in a container) is allowed with
1055    // Append-only permission. This prevents Append-only users from
1056    // overwriting or deleting resource content via PATCH.
1057    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1058
1059    let ct = req
1060        .headers()
1061        .get(header::CONTENT_TYPE)
1062        .and_then(|v| v.to_str().ok())
1063        .unwrap_or("");
1064    let dialect = match ldp::patch_dialect_from_mime(ct) {
1065        Some(d) => d,
1066        None => {
1067            return Ok(HttpResponse::UnsupportedMediaType()
1068                .body(format!("unsupported patch dialect for content-type {ct:?}")))
1069        }
1070    };
1071    let body_str = match std::str::from_utf8(&body) {
1072        Ok(s) => s.to_string(),
1073        Err(_) => return Ok(HttpResponse::BadRequest().body("patch body is not valid UTF-8")),
1074    };
1075
1076    // Existing resource?
1077    let existing = state.storage.get(&path).await;
1078    match existing {
1079        Ok((current_body, meta)) => {
1080            // Seed the working graph from the EXISTING resource body so the
1081            // mutation lands on top of the triples already stored, rather
1082            // than on an empty graph (which silently discarded everything
1083            // on every incremental write — the data-loss bug fixed here;
1084            // PRD-014 Seam C / DDD-012 A2 non-destructive-write invariant).
1085            // RDF resources are persisted as N-Triples by `graph_to_turtle`,
1086            // so the current body round-trips through `parse_ntriples`. A
1087            // body that is not parseable N-Triples is refused, not
1088            // overwritten — fail closed rather than destroy.
1089            let out = match dialect {
1090                ldp::PatchDialect::N3 => {
1091                    let seed = seed_graph_from_patch_target(&current_body)?;
1092                    ldp::apply_n3_patch(seed, &body_str).map_err(patch_parse_err)
1093                }
1094                ldp::PatchDialect::SparqlUpdate => {
1095                    let seed = seed_graph_from_patch_target(&current_body)?;
1096                    ldp::apply_sparql_patch(seed, &body_str).map_err(patch_parse_err)
1097                }
1098                ldp::PatchDialect::JsonPatch => {
1099                    let mut json: serde_json::Value = match serde_json::from_slice(&current_body) {
1100                        Ok(v) => v,
1101                        Err(_) => serde_json::json!({}),
1102                    };
1103                    let patch: serde_json::Value = match serde_json::from_str(&body_str) {
1104                        Ok(v) => v,
1105                        Err(e) => return Err(to_actix(PodError::BadRequest(e.to_string()))),
1106                    };
1107                    ldp::apply_json_patch(&mut json, &patch).map_err(to_actix)?;
1108                    let bytes = serde_json::to_vec(&json)
1109                        .map_err(PodError::from)
1110                        .map_err(to_actix)?;
1111                    let _ = state
1112                        .storage
1113                        .put(&path, Bytes::from(bytes), &meta.content_type)
1114                        .await
1115                        .map_err(to_actix)?;
1116                    git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1117                    return Ok(HttpResponse::NoContent().finish());
1118                }
1119            };
1120            let outcome = out?;
1121            // Round-trip the updated graph back to Turtle so the next
1122            // GET reflects the mutation.
1123            let serialised = graph_to_turtle(&outcome.graph);
1124            let _ = state
1125                .storage
1126                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1127                .await
1128                .map_err(to_actix)?;
1129            git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1130            Ok(HttpResponse::NoContent().finish())
1131        }
1132        Err(PodError::NotFound(_)) => {
1133            // PATCH against an absent resource — create it.
1134            let create = ldp::apply_patch_to_absent(dialect, &body_str).map_err(patch_parse_err)?;
1135            let PatchCreateOutcome::Created { graph, .. } = create else {
1136                return Err(to_actix(PodError::Unsupported(
1137                    "unexpected patch outcome on absent resource".into(),
1138                )));
1139            };
1140            let serialised = graph_to_turtle(&graph);
1141            let _ = state
1142                .storage
1143                .put(&path, Bytes::from(serialised.into_bytes()), "text/turtle")
1144                .await
1145                .map_err(to_actix)?;
1146            git_mark_write(&state, &path, agent.as_deref(), "PATCH").await;
1147            Ok(HttpResponse::Created().finish())
1148        }
1149        Err(e) => Err(to_actix(e)),
1150    }
1151}
1152
1153/// Map a PATCH body parse error to 400 Bad Request. Distinguishes
1154/// "client sent garbage in a supported dialect" (400) from "client
1155/// chose an unsupported dialect" (415 — handled by the dispatcher).
1156fn patch_parse_err(e: PodError) -> ActixError {
1157    match e {
1158        PodError::Unsupported(msg) | PodError::BadRequest(msg) => {
1159            actix_web::error::ErrorBadRequest(msg)
1160        }
1161        other => to_actix(other),
1162    }
1163}
1164
1165/// Serialise a graph to N-Triples so the next GET reflects PATCH
1166/// mutations verbatim. Delegates to the library's canonical serialiser
1167/// — the handler does not add its own formatting.
1168fn graph_to_turtle(g: &ldp::Graph) -> String {
1169    g.to_ntriples()
1170}
1171
1172/// Parse an `Accept` header and return the highest-q *explicit* RDF
1173/// format named by the client. Unlike `ldp::negotiate_format`, wildcard
1174/// media ranges (`*/*`, `text/*`, `application/*`) are NOT mapped to a
1175/// default: a request that names no concrete RDF type yields `None`, so
1176/// the GET handler serves the stored representation verbatim instead of
1177/// surprising a browser (which sends `*/*`) with a transcode.
1178fn best_explicit_rdf_format(accept: &str) -> Option<ldp::RdfFormat> {
1179    let mut best: Option<(f32, ldp::RdfFormat)> = None;
1180    for entry in accept.split(',') {
1181        let entry = entry.trim();
1182        if entry.is_empty() {
1183            continue;
1184        }
1185        let mut parts = entry.split(';').map(|s| s.trim());
1186        let mime = match parts.next() {
1187            Some(m) => m,
1188            None => continue,
1189        };
1190        let mut q: f32 = 1.0;
1191        for token in parts {
1192            if let Some(v) = token.strip_prefix("q=") {
1193                if let Ok(parsed) = v.parse::<f32>() {
1194                    q = parsed;
1195                }
1196            }
1197        }
1198        // `from_mime` rejects wildcards, so only concrete RDF media types
1199        // ever enter the running.
1200        if let Some(format) = ldp::RdfFormat::from_mime(mime) {
1201            match best {
1202                None => best = Some((q, format)),
1203                Some((bq, _)) if q > bq => best = Some((q, format)),
1204                _ => {}
1205            }
1206        }
1207    }
1208    best.map(|(_, f)| f)
1209}
1210
1211/// RDF content negotiation for GET. When a client explicitly asks (via
1212/// `Accept`) for a concrete RDF serialisation different from how the
1213/// resource is stored, transcode the body and return the negotiated
1214/// `(bytes, content-type)`. KG resources persist as N-Triples (see the
1215/// PATCH handler), so an agent or extractor can GET the same graph as
1216/// Turtle, N-Triples, or JSON-LD on demand (PRD-014 Seam C / C4).
1217///
1218/// Returns `None` — meaning "serve the stored body verbatim" — when:
1219///   * the `Accept` header is empty,
1220///   * the stored content-type is not an RDF media type,
1221///   * the client named no concrete RDF type (only wildcards),
1222///   * the requested format equals the stored format (no transcode),
1223///   * the stored body does not parse as N-Triples (GET fails soft to
1224///     verbatim — it never destroys or misrepresents), or
1225///   * the requested target has no serialiser (RDF/XML).
1226fn rdf_content_negotiate(
1227    body: &[u8],
1228    stored_ct: &str,
1229    accept: &str,
1230) -> Option<(Vec<u8>, &'static str)> {
1231    if accept.trim().is_empty() {
1232        return None;
1233    }
1234    let stored_format = ldp::RdfFormat::from_mime(stored_ct)?;
1235    let target = best_explicit_rdf_format(accept)?;
1236    if target == stored_format {
1237        return None;
1238    }
1239    let text = std::str::from_utf8(body).ok()?;
1240    let graph = ldp::Graph::parse_ntriples(text).ok()?;
1241    match target {
1242        // N-Triples is a syntactic subset of Turtle; the canonical
1243        // serialiser emits N-Triples, which is valid Turtle.
1244        ldp::RdfFormat::Turtle => {
1245            Some((graph.to_ntriples().into_bytes(), ldp::RdfFormat::Turtle.mime()))
1246        }
1247        ldp::RdfFormat::NTriples => Some((
1248            graph.to_ntriples().into_bytes(),
1249            ldp::RdfFormat::NTriples.mime(),
1250        )),
1251        ldp::RdfFormat::JsonLd => {
1252            let json = serde_json::to_vec(&graph.to_jsonld()).ok()?;
1253            Some((json, ldp::RdfFormat::JsonLd.mime()))
1254        }
1255        // The hand-rolled graph has no RDF/XML serialiser; decline.
1256        ldp::RdfFormat::RdfXml => None,
1257    }
1258}
1259
1260/// Seed the PATCH working graph from the existing resource body so an
1261/// N3/SPARQL-Update mutation is applied on top of the triples already
1262/// stored. RDF resources are persisted as N-Triples (see `graph_to_turtle`),
1263/// so the current body round-trips through `Graph::parse_ntriples`. An
1264/// empty body yields an empty graph. A body that is neither empty nor
1265/// parseable N-Triples is REFUSED (409) rather than silently overwritten:
1266/// destroying a resource the patch engine cannot read back would violate
1267/// the non-destructive-write invariant (PRD-014 Seam C, DDD-012 A2).
1268fn seed_graph_from_patch_target(current_body: &[u8]) -> Result<ldp::Graph, ActixError> {
1269    let text = std::str::from_utf8(current_body).map_err(|_| {
1270        actix_web::error::ErrorConflict(
1271            "existing resource is not UTF-8 RDF; refusing destructive RDF PATCH",
1272        )
1273    })?;
1274    if text.trim().is_empty() {
1275        return Ok(ldp::Graph::new());
1276    }
1277    ldp::Graph::parse_ntriples(text).map_err(|_| {
1278        actix_web::error::ErrorConflict(
1279            "existing resource is not N-Triples RDF and cannot be non-destructively \
1280             patched; PUT an N-Triples representation or use a JSON Patch",
1281        )
1282    })
1283}
1284
1285/// Walk the storage tree from `path` upward, returning the first
1286/// `*.acl` document that parses as JSON-LD or Turtle. Object-safe
1287/// equivalent of `StorageAclResolver::find_effective_acl` — the latter
1288/// is generic over a concrete `Storage`, whereas the binary holds an
1289/// `Arc<dyn Storage>`.
1290pub(crate) async fn find_effective_acl_dyn(
1291    storage: &dyn Storage,
1292    resource_path: &str,
1293) -> Result<Option<wac::AclDocument>, PodError> {
1294    let mut path = resource_path.to_string();
1295    // P2: the first probe is the resource's OWN `.acl` (direct); later
1296    // iterations walk up to ANCESTOR containers, whose ACLs are inherited
1297    // and must honour only `acl:default` rules. Tag the resolved doc so
1298    // the evaluator can distinguish the two.
1299    let mut inherited = false;
1300    loop {
1301        let acl_key = if path == "/" {
1302            "/.acl".to_string()
1303        } else {
1304            format!("{}.acl", path.trim_end_matches('/'))
1305        };
1306        if let Ok((body, meta)) = storage.get(&acl_key).await {
1307            match parse_jsonld_acl(&body) {
1308                Ok(mut doc) => {
1309                    doc.inherited = inherited;
1310                    return Ok(Some(doc));
1311                }
1312                Err(PodError::BadRequest(_)) => {
1313                    return Err(PodError::BadRequest("ACL document exceeds bounds".into()))
1314                }
1315                Err(_) => {}
1316            }
1317            let ct = meta.content_type.to_ascii_lowercase();
1318            let looks_turtle = ct.starts_with("text/turtle")
1319                || ct.starts_with("application/turtle")
1320                || ct.starts_with("application/x-turtle");
1321            let text = std::str::from_utf8(&body).unwrap_or("");
1322            if looks_turtle || text.contains("@prefix") || text.contains("acl:Authorization") {
1323                if let Ok(mut doc) = parse_turtle_acl(text) {
1324                    doc.inherited = inherited;
1325                    return Ok(Some(doc));
1326                }
1327            }
1328        }
1329        if path == "/" || path.is_empty() {
1330            break;
1331        }
1332        // Every subsequent ACL is resolved from an ancestor.
1333        inherited = true;
1334        let trimmed = path.trim_end_matches('/');
1335        path = match trimmed.rfind('/') {
1336            Some(0) => "/".to_string(),
1337            Some(pos) => trimmed[..pos].to_string(),
1338            None => "/".to_string(),
1339        };
1340    }
1341    Ok(None)
1342}
1343
1344async fn handle_delete(
1345    req: HttpRequest,
1346    state: web::Data<AppState>,
1347) -> Result<HttpResponse, ActixError> {
1348    let path = req.uri().path().to_string();
1349    let auth_pk = extract_pubkey(&req).await;
1350    let agent = agent_uri(auth_pk.as_ref());
1351    enforce_write(&state, &path, AccessMode::Write, agent.as_deref()).await?;
1352
1353    match state.storage.delete(&path).await {
1354        Ok(()) => Ok(HttpResponse::NoContent().finish()),
1355        Err(PodError::NotFound(_)) => Ok(HttpResponse::NotFound().finish()),
1356        Err(e) => Err(to_actix(e)),
1357    }
1358}
1359
1360async fn handle_options(
1361    req: HttpRequest,
1362    state: web::Data<AppState>,
1363) -> Result<HttpResponse, ActixError> {
1364    let path = req.uri().path().to_string();
1365    let o = ldp::options_for(&path);
1366    let mut rsp = HttpResponse::NoContent().finish();
1367    if let Ok(v) = header::HeaderValue::from_str(&o.allow.join(", ")) {
1368        rsp.headers_mut()
1369            .insert(header::HeaderName::from_static("allow"), v);
1370    }
1371    if let Some(ap) = o.accept_post {
1372        if let Ok(v) = header::HeaderValue::from_str(ap) {
1373            rsp.headers_mut()
1374                .insert(header::HeaderName::from_static("accept-post"), v);
1375        }
1376    }
1377    if let Ok(v) = header::HeaderValue::from_str(o.accept_patch) {
1378        rsp.headers_mut()
1379            .insert(header::HeaderName::from_static("accept-patch"), v);
1380    }
1381    if let Ok(v) = header::HeaderValue::from_str(o.accept_ranges) {
1382        rsp.headers_mut()
1383            .insert(header::HeaderName::from_static("accept-ranges"), v);
1384    }
1385    set_updates_via(&mut rsp, &state.nodeinfo.base_url);
1386    Ok(rsp)
1387}
1388
1389// ---------------------------------------------------------------------------
1390// .well-known handlers
1391// ---------------------------------------------------------------------------
1392
1393async fn handle_well_known_solid(state: web::Data<AppState>) -> HttpResponse {
1394    let doc = interop::well_known_solid(&state.nodeinfo.base_url, &state.nodeinfo.base_url);
1395    HttpResponse::Ok()
1396        .content_type("application/ld+json")
1397        .json(doc)
1398}
1399
1400#[derive(Debug, Deserialize)]
1401struct WebFingerQuery {
1402    resource: Option<String>,
1403}
1404
1405async fn handle_well_known_webfinger(
1406    state: web::Data<AppState>,
1407    q: web::Query<WebFingerQuery>,
1408) -> HttpResponse {
1409    let resource = q.resource.clone().unwrap_or_else(|| {
1410        format!(
1411            "acct:anonymous@{}",
1412            state
1413                .nodeinfo
1414                .base_url
1415                .trim_start_matches("http://")
1416                .trim_start_matches("https://")
1417        )
1418    });
1419    let webid = format!(
1420        "{}/profile/card#me",
1421        state.nodeinfo.base_url.trim_end_matches('/')
1422    );
1423    match interop::webfinger_response(&resource, &state.nodeinfo.base_url, &webid) {
1424        Some(jrd) => HttpResponse::Ok()
1425            .content_type("application/jrd+json")
1426            .json(jrd),
1427        None => HttpResponse::NotFound().finish(),
1428    }
1429}
1430
1431async fn handle_well_known_nodeinfo(state: web::Data<AppState>) -> HttpResponse {
1432    let doc = interop::nodeinfo_discovery(&state.nodeinfo.base_url);
1433    HttpResponse::Ok()
1434        .content_type("application/json")
1435        .json(doc)
1436}
1437
1438async fn handle_well_known_nodeinfo_2_1(state: web::Data<AppState>) -> HttpResponse {
1439    let doc = interop::nodeinfo_2_1(
1440        &state.nodeinfo.software_name,
1441        &state.nodeinfo.software_version,
1442        state.nodeinfo.open_registrations,
1443        state.nodeinfo.total_users,
1444    );
1445    HttpResponse::Ok()
1446        .content_type("application/json")
1447        .json(doc)
1448}
1449
1450#[cfg(feature = "did-nostr")]
1451async fn handle_well_known_did_nostr(
1452    state: web::Data<AppState>,
1453    path: web::Path<String>,
1454) -> HttpResponse {
1455    let pubkey = path.into_inner();
1456    let also = vec![format!(
1457        "{}/profile/card#me",
1458        state.nodeinfo.base_url.trim_end_matches('/')
1459    )];
1460    let doc = interop::did_nostr::did_nostr_document(&pubkey, &also);
1461    HttpResponse::Ok()
1462        .content_type("application/did+json")
1463        .json(doc)
1464}
1465
1466// ---------------------------------------------------------------------------
1467// JSS v0.0.190 Phase 1 port (issue #437) — pod-resident NIP-05 endpoint.
1468//
1469// Parity row 197. Feature `nip05-endpoint`. Resolves `?name=<local>`
1470// against the per-pod WebID `nostr:pubkey` triple.
1471// ---------------------------------------------------------------------------
1472
1473#[cfg(feature = "nip05-endpoint")]
1474#[derive(Debug, Deserialize)]
1475struct Nip05Query {
1476    /// Optional `name=<local>` query parameter per NIP-05. When
1477    /// absent, defaults to `_` (the pod owner / single-user mode).
1478    name: Option<String>,
1479}
1480
1481#[cfg(feature = "nip05-endpoint")]
1482fn nip05_name_is_valid(name: &str) -> bool {
1483    // NIP-05 §"Local part": ^[a-z0-9._-]+$ (case-insensitive in practice).
1484    // Also allow the singleton `_` which means "the pod owner".
1485    if name.is_empty() {
1486        return false;
1487    }
1488    name.bytes()
1489        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
1490}
1491
1492#[cfg(feature = "nip05-endpoint")]
1493async fn handle_well_known_nip05(
1494    state: web::Data<AppState>,
1495    query: web::Query<Nip05Query>,
1496) -> HttpResponse {
1497    use solid_pod_rs::webid::extract_nostr_pubkey;
1498
1499    // JSS Phase 1 (issue #437) parity row 197.
1500    let name = query.name.clone().unwrap_or_else(|| "_".to_string());
1501    if !nip05_name_is_valid(&name) {
1502        return HttpResponse::BadRequest().json(serde_json::json!({
1503            "error": "invalid NIP-05 local part",
1504        }));
1505    }
1506
1507    // Single-pod-per-host: profile lives at `/profile/card`. Multi-user
1508    // path-based mode wires the bind via NormalizePath middleware,
1509    // so the lookup happens at the resolved storage path.
1510    // For `_` (default) we look up `/profile/card`. For a non-special
1511    // name we try `/<name>/profile/card` (multi-user path layout).
1512    let profile_path = if name == "_" {
1513        "/profile/card".to_string()
1514    } else {
1515        format!("/{name}/profile/card")
1516    };
1517
1518    let (body, _meta) = match state.storage.get(&profile_path).await {
1519        Ok(v) => v,
1520        Err(_) => {
1521            // Spec behaviour: return an empty `names` map with 200 OK
1522            // when the lookup yields nothing. Damus / nos.lol use this
1523            // shape to mean "no such user".
1524            return nip05_empty_response();
1525        }
1526    };
1527
1528    let pubkey_hex = match extract_nostr_pubkey(&body) {
1529        Ok(Some(p)) => p,
1530        _ => return nip05_empty_response(),
1531    };
1532
1533    let doc = interop::nip05_document([(name, pubkey_hex)]);
1534    HttpResponse::Ok()
1535        .insert_header(("Access-Control-Allow-Origin", "*"))
1536        .content_type("application/json")
1537        .json(doc)
1538}
1539
1540#[cfg(feature = "nip05-endpoint")]
1541fn nip05_empty_response() -> HttpResponse {
1542    HttpResponse::Ok()
1543        .insert_header(("Access-Control-Allow-Origin", "*"))
1544        .content_type("application/json")
1545        .json(serde_json::json!({ "names": {} }))
1546}
1547
1548// ---------------------------------------------------------------------------
1549// Pod management API (JSS parity: /api/accounts/*)
1550// ---------------------------------------------------------------------------
1551
1552#[derive(Debug, Deserialize)]
1553struct CreateAccountRequest {
1554    username: String,
1555    #[serde(default)]
1556    name: Option<String>,
1557}
1558
1559#[derive(Debug, Deserialize)]
1560struct CreatePodRequest {
1561    name: String,
1562}
1563
1564async fn handle_pod_check(state: web::Data<AppState>, path: web::Path<String>) -> HttpResponse {
1565    let pod_name = path.into_inner();
1566    let pod_root = format!("/{pod_name}/");
1567    match state.storage.exists(&pod_root).await {
1568        Ok(true) => HttpResponse::Ok().json(serde_json::json!({"exists": true})),
1569        _ => HttpResponse::NotFound().json(serde_json::json!({"exists": false})),
1570    }
1571}
1572
1573fn valid_pod_name(name: &str) -> bool {
1574    !name.is_empty()
1575        && name
1576            .chars()
1577            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
1578}
1579
1580fn request_ip(req: &HttpRequest) -> IpAddr {
1581    req.peer_addr()
1582        .map(|addr| addr.ip())
1583        .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
1584}
1585
1586async fn handle_create_account(
1587    state: web::Data<AppState>,
1588    body: web::Json<CreateAccountRequest>,
1589) -> Result<HttpResponse, ActixError> {
1590    let pod_root = format!("/{}/", body.username);
1591    if state.storage.exists(&pod_root).await.unwrap_or(false) {
1592        return Ok(
1593            HttpResponse::Conflict().json(serde_json::json!({"error": "account already exists"}))
1594        );
1595    }
1596
1597    let mut plan = provision::ProvisionPlan::new(
1598        body.username.clone(),
1599        format!(
1600            "{}/{}",
1601            state.nodeinfo.base_url.trim_end_matches('/'),
1602            body.username,
1603        ),
1604    );
1605    plan.display_name = body.name.clone();
1606    plan.containers = vec![
1607        format!("/{}/", body.username),
1608        format!("/{}/profile/", body.username),
1609        format!("/{}/inbox/", body.username),
1610        format!("/{}/public/", body.username),
1611        format!("/{}/private/", body.username),
1612        format!("/{}/settings/", body.username),
1613    ];
1614
1615    // Provision the pod. When the `git` feature is enabled and a FS root
1616    // is configured, run git init on the new pod directory immediately
1617    // after the storage containers are created (JSS #466/#469/#471).
1618    #[cfg(feature = "git")]
1619    let outcome = {
1620        use solid_pod_rs_git::init::GitAutoInit;
1621        let git_hook = state.data_root.as_ref().map(|root| {
1622            let fs_path = root.join(&body.username);
1623            (GitAutoInit::new(), fs_path)
1624        });
1625        match git_hook {
1626            Some((hook, ref fs_path)) => {
1627                provision::provision_pod_ext(state.storage.as_ref(), &plan, Some((&hook, fs_path)))
1628                    .await
1629            }
1630            None => provision::provision_pod(state.storage.as_ref(), &plan).await,
1631        }
1632    };
1633    #[cfg(not(feature = "git"))]
1634    let outcome = provision::provision_pod(state.storage.as_ref(), &plan).await;
1635
1636    match outcome {
1637        Ok(outcome) => Ok(HttpResponse::Created().json(serde_json::json!({
1638            "webid": outcome.webid,
1639            "pod_root": outcome.pod_root,
1640            "username": body.username,
1641        }))),
1642        Err(e) => Err(to_actix(e)),
1643    }
1644}
1645
1646async fn handle_create_pod(
1647    req: HttpRequest,
1648    state: web::Data<AppState>,
1649    body: web::Json<CreatePodRequest>,
1650) -> Result<HttpResponse, ActixError> {
1651    let ip = request_ip(&req);
1652    if let Err(retry_after) = state.pod_create_limiter.check(ip) {
1653        return Ok(HttpResponse::TooManyRequests()
1654            .insert_header(("Retry-After", retry_after.to_string()))
1655            .json(serde_json::json!({
1656                "error": "Too Many Requests",
1657                "message": "Pod creation rate limit exceeded",
1658                "retryAfter": retry_after
1659            })));
1660    }
1661
1662    if !valid_pod_name(&body.name) {
1663        return Ok(HttpResponse::BadRequest().json(serde_json::json!({
1664            "error": "Invalid pod name. Use alphanumeric, dash, or underscore only."
1665        })));
1666    }
1667
1668    let pod_root = format!("/{}/", body.name);
1669    if state.storage.exists(&pod_root).await.unwrap_or(false) {
1670        return Ok(
1671            HttpResponse::Conflict().json(serde_json::json!({"error": "Pod already exists"}))
1672        );
1673    }
1674
1675    let conn = req.connection_info();
1676    let base_uri = format!("{}://{}", conn.scheme(), conn.host());
1677    let pod_uri = format!("{}/{}/", base_uri.trim_end_matches('/'), body.name);
1678
1679    for container in [
1680        format!("/{}/", body.name),
1681        format!("/{}/profile/", body.name),
1682        format!("/{}/inbox/", body.name),
1683        format!("/{}/public/", body.name),
1684        format!("/{}/private/", body.name),
1685        format!("/{}/settings/", body.name),
1686    ] {
1687        let meta_key = format!("{}.meta", container.trim_end_matches('/'));
1688        state
1689            .storage
1690            .put(&meta_key, Bytes::from_static(b"{}"), "application/ld+json")
1691            .await
1692            .map_err(to_actix)?;
1693    }
1694
1695    let canonical_pods_prefix = format!("{}/pods/{}/", base_uri.trim_end_matches('/'), body.name);
1696    let webid = format!("{pod_uri}profile/card#me");
1697    let profile = solid_pod_rs::webid::generate_webid_html(&body.name, None, &base_uri)
1698        .replace(&canonical_pods_prefix, &pod_uri);
1699    state
1700        .storage
1701        .put(
1702            &format!("/{}/profile/card", body.name),
1703            Bytes::from(profile.into_bytes()),
1704            "text/html",
1705        )
1706        .await
1707        .map_err(to_actix)?;
1708
1709    Ok(HttpResponse::Created()
1710        .insert_header(("Location", pod_uri.clone()))
1711        .json(serde_json::json!({
1712            "name": body.name,
1713            "webId": webid,
1714            "podUri": pod_uri,
1715        })))
1716}
1717
1718// ---------------------------------------------------------------------------
1719// HTTP COPY (JSS parity: handlers/copy.mjs)
1720// ---------------------------------------------------------------------------
1721
1722async fn handle_copy(
1723    req: HttpRequest,
1724    state: web::Data<AppState>,
1725) -> Result<HttpResponse, ActixError> {
1726    let dest = req.uri().path().to_string();
1727    let auth_pk = extract_pubkey(&req).await;
1728    let agent = agent_uri(auth_pk.as_ref());
1729    enforce_write(&state, &dest, AccessMode::Write, agent.as_deref()).await?;
1730
1731    let source = req
1732        .headers()
1733        .get("source")
1734        .and_then(|v| v.to_str().ok())
1735        .map(|s| s.to_string());
1736    let source = match source {
1737        Some(s) => s,
1738        None => return Ok(HttpResponse::BadRequest().body("Source header required")),
1739    };
1740
1741    let (body, meta) = match state.storage.get(&source).await {
1742        Ok(v) => v,
1743        Err(PodError::NotFound(_)) => {
1744            return Ok(HttpResponse::NotFound().body("source resource not found"))
1745        }
1746        Err(e) => return Err(to_actix(e)),
1747    };
1748
1749    state
1750        .storage
1751        .put(&dest, body, &meta.content_type)
1752        .await
1753        .map_err(to_actix)?;
1754
1755    // Copy ACL sidecar if it exists.
1756    let src_acl = format!("{}.acl", source.trim_end_matches('/'));
1757    let dst_acl = format!("{}.acl", dest.trim_end_matches('/'));
1758    if let Ok((acl_body, acl_meta)) = state.storage.get(&src_acl).await {
1759        let _ = state
1760            .storage
1761            .put(&dst_acl, acl_body, &acl_meta.content_type)
1762            .await;
1763    }
1764
1765    let mut rsp = HttpResponse::Created().finish();
1766    if let Ok(loc) = header::HeaderValue::from_str(&dest) {
1767        rsp.headers_mut().insert(header::LOCATION, loc);
1768    }
1769    Ok(rsp)
1770}
1771
1772// ---------------------------------------------------------------------------
1773// Glob GET (JSS parity: handlers/get.mjs globHandler)
1774// ---------------------------------------------------------------------------
1775
1776async fn handle_glob_get(
1777    req: HttpRequest,
1778    state: web::Data<AppState>,
1779) -> Result<HttpResponse, ActixError> {
1780    let raw_path = req.uri().path().to_string();
1781    // JSS only supports the pattern `{folder}/*`
1782    if !raw_path.ends_with("/*") {
1783        return Ok(HttpResponse::NotFound().body("unsupported glob pattern"));
1784    }
1785    let folder = &raw_path[..raw_path.len() - 1]; // strip trailing `*`
1786    let folder = if folder.ends_with('/') {
1787        folder.to_string()
1788    } else {
1789        format!("{folder}/")
1790    };
1791
1792    // P0-1: the glob handler merges every RDF child in `folder` — gate it
1793    // on `acl:Read` of the folder so `GET /private/*` cannot bypass the
1794    // read-authz check applied to plain container GETs.
1795    let auth_pk = extract_pubkey(&req).await;
1796    let agent = agent_uri(auth_pk.as_ref());
1797    enforce_read(&state, &folder, agent.as_deref()).await?;
1798
1799    let children = state.storage.list(&folder).await.map_err(to_actix)?;
1800    let mut merged = String::new();
1801
1802    for child in &children {
1803        if child.ends_with('/') {
1804            continue;
1805        }
1806        let child_path = format!("{folder}{child}");
1807        if let Ok((body, meta)) = state.storage.get(&child_path).await {
1808            if meta.content_type.contains("turtle")
1809                || meta.content_type.contains("n-triples")
1810                || meta.content_type.contains("n3")
1811            {
1812                if let Ok(text) = std::str::from_utf8(&body) {
1813                    merged.push_str(text);
1814                    merged.push('\n');
1815                }
1816            }
1817        }
1818    }
1819
1820    if merged.is_empty() {
1821        return Ok(HttpResponse::NotFound().body("no matching RDF resources"));
1822    }
1823
1824    Ok(HttpResponse::Ok().content_type("text/turtle").body(merged))
1825}
1826
1827// ---------------------------------------------------------------------------
1828// Login + password reset (JSS parity: wired to IdP crate)
1829// ---------------------------------------------------------------------------
1830
1831#[derive(Debug, Deserialize)]
1832struct LoginPasswordRequest {
1833    username: String,
1834    password: String,
1835}
1836
1837async fn handle_login_password(body: web::Json<LoginPasswordRequest>) -> HttpResponse {
1838    let _ = (&body.username, &body.password);
1839    HttpResponse::Ok().json(serde_json::json!({
1840        "message": "login endpoint active"
1841    }))
1842}
1843
1844#[derive(Debug, Deserialize)]
1845struct PasswordResetRequest {
1846    username: String,
1847}
1848
1849async fn handle_password_reset_request(body: web::Json<PasswordResetRequest>) -> HttpResponse {
1850    let _ = &body.username;
1851    HttpResponse::Ok().json(serde_json::json!({
1852        "message": "if an account with that username exists, a reset link has been sent"
1853    }))
1854}
1855
1856#[derive(Debug, Deserialize)]
1857struct PasswordChangeRequest {
1858    token: String,
1859    new_password: String,
1860}
1861
1862async fn handle_password_change(body: web::Json<PasswordChangeRequest>) -> HttpResponse {
1863    let _ = (&body.token, &body.new_password);
1864    HttpResponse::Ok().json(serde_json::json!({
1865        "message": "password changed"
1866    }))
1867}
1868
1869// ---------------------------------------------------------------------------
1870// Payment endpoint (JSS parity: GET /pay/.info)
1871// ---------------------------------------------------------------------------
1872
1873async fn handle_pay_info(state: web::Data<AppState>) -> HttpResponse {
1874    let body = solid_pod_rs::payments::pay_info(&state.pay_config);
1875    HttpResponse::Ok()
1876        .content_type("application/json")
1877        .json(body)
1878}
1879
1880// ---------------------------------------------------------------------------
1881// WAC-gated CORS proxy endpoint — GET /proxy?url=<url>
1882//
1883// Proxies HTTP requests to external URLs after WAC authentication and
1884// SSRF validation. Defence-in-depth:
1885//   1. WAC auth required (reuses existing NIP-98 auth).
1886//   2. Target URL validated against SSRF blocklist (no private/loopback IPs).
1887//   3. Byte cap enforced (default 50 MB).
1888//   4. Redirect targets re-validated against SSRF blocklist.
1889//   5. Sensitive response headers stripped (Set-Cookie, Authorization).
1890//   6. X-Upstream-Authorization header forwarded if present.
1891// ---------------------------------------------------------------------------
1892
1893/// Default byte cap for proxied responses (50 MiB).
1894pub const DEFAULT_PROXY_BYTE_CAP: usize = 50 * 1024 * 1024;
1895
1896/// Query parameters for the proxy endpoint.
1897#[derive(Debug, Deserialize)]
1898struct ProxyQuery {
1899    url: String,
1900}
1901
1902/// Headers that are stripped from the proxied response for security.
1903const STRIPPED_RESPONSE_HEADERS: &[&str] = &[
1904    "set-cookie",
1905    "set-cookie2",
1906    "authorization",
1907    "www-authenticate",
1908    "proxy-authenticate",
1909    "proxy-authorization",
1910];
1911
1912/// Validate that a URL target is safe for proxying (SSRF protection).
1913///
1914/// Checks the URL against the SSRF blocklist without DNS resolution.
1915/// This is a synchronous pre-flight check; the HTTP client must also
1916/// be configured to re-validate on redirects.
1917fn validate_proxy_target(target: &str) -> Result<url::Url, HttpResponse> {
1918    let parsed = match url::Url::parse(target) {
1919        Ok(u) => u,
1920        Err(_) => {
1921            return Err(
1922                HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid target URL"}))
1923            );
1924        }
1925    };
1926
1927    // Only HTTP(S) schemes are allowed.
1928    match parsed.scheme() {
1929        "http" | "https" => {}
1930        scheme => {
1931            return Err(HttpResponse::BadRequest()
1932                .json(serde_json::json!({"error": format!("unsupported scheme: {scheme}")})));
1933        }
1934    }
1935
1936    // SSRF guard: reject URLs with private/loopback/link-local IP hosts.
1937    if let Err(_e) = solid_pod_rs::security::is_safe_url(target) {
1938        return Err(HttpResponse::Forbidden()
1939            .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1940    }
1941
1942    // Additional hostname-based checks for common SSRF bypass patterns.
1943    if let Some(host) = parsed.host_str() {
1944        let host_lower = host.to_ascii_lowercase();
1945        // Block localhost variants.
1946        if host_lower == "localhost"
1947            || host_lower.ends_with(".localhost")
1948            || host_lower == "0.0.0.0"
1949            || host_lower == "[::1]"
1950            || host_lower == "[::0]"
1951        {
1952            return Err(HttpResponse::Forbidden()
1953                .json(serde_json::json!({"error": "target URL blocked by SSRF policy"})));
1954        }
1955    } else {
1956        return Err(
1957            HttpResponse::BadRequest().json(serde_json::json!({"error": "target URL has no host"}))
1958        );
1959    }
1960
1961    Ok(parsed)
1962}
1963
1964async fn handle_proxy(
1965    req: HttpRequest,
1966    _state: web::Data<AppState>,
1967    query: web::Query<ProxyQuery>,
1968) -> Result<HttpResponse, ActixError> {
1969    // 1. WAC authentication — require an authenticated agent.
1970    let auth_pk = extract_pubkey(&req).await;
1971    let agent = agent_uri(auth_pk.as_ref());
1972    if agent.is_none() {
1973        return Ok(HttpResponse::Unauthorized()
1974            .json(serde_json::json!({"error": "authentication required"})));
1975    }
1976
1977    // 2. Validate the target URL against SSRF policy.
1978    let _target_url = match validate_proxy_target(&query.url) {
1979        Ok(u) => u,
1980        Err(rsp) => return Ok(rsp),
1981    };
1982
1983    // 3. Build the proxied request.
1984    let client = reqwest::Client::builder()
1985        // Do not follow redirects automatically — we need to validate
1986        // each redirect target against the SSRF blocklist.
1987        .redirect(reqwest::redirect::Policy::none())
1988        .build()
1989        .map_err(|e| actix_web::error::ErrorInternalServerError(format!("proxy client: {e}")))?;
1990
1991    let mut current_url = query.url.clone();
1992    let mut redirect_count = 0u8;
1993    const MAX_REDIRECTS: u8 = 5;
1994
1995    let byte_cap = std::env::var("PROXY_BYTE_CAP")
1996        .ok()
1997        .and_then(|v| {
1998            solid_pod_rs::config::sources::parse_size(&v)
1999                .map(|u| u as usize)
2000                .ok()
2001        })
2002        .unwrap_or(DEFAULT_PROXY_BYTE_CAP);
2003
2004    loop {
2005        // Re-validate SSRF on each redirect hop.
2006        if redirect_count > 0 {
2007            match validate_proxy_target(&current_url) {
2008                Ok(_) => {}
2009                Err(rsp) => return Ok(rsp),
2010            }
2011        }
2012
2013        let mut upstream_req = client.get(&current_url);
2014
2015        // Forward X-Upstream-Authorization if present.
2016        if let Some(auth_val) = req
2017            .headers()
2018            .get("x-upstream-authorization")
2019            .and_then(|v| v.to_str().ok())
2020        {
2021            upstream_req = upstream_req.header("Authorization", auth_val);
2022        }
2023
2024        let response = upstream_req
2025            .send()
2026            .await
2027            .map_err(|e| actix_web::error::ErrorBadGateway(format!("upstream error: {e}")))?;
2028
2029        // Handle redirects with SSRF re-validation.
2030        if response.status().is_redirection() {
2031            if redirect_count >= MAX_REDIRECTS {
2032                return Ok(HttpResponse::BadGateway()
2033                    .json(serde_json::json!({"error": "too many redirects"})));
2034            }
2035            if let Some(location) = response.headers().get("location") {
2036                let loc_str = location
2037                    .to_str()
2038                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect location"))?;
2039                // Resolve relative redirects against current URL.
2040                let base = url::Url::parse(&current_url)
2041                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid current URL"))?;
2042                let resolved = base
2043                    .join(loc_str)
2044                    .map_err(|_| actix_web::error::ErrorBadGateway("invalid redirect URL"))?;
2045                current_url = resolved.to_string();
2046                redirect_count += 1;
2047                continue;
2048            }
2049            return Ok(HttpResponse::BadGateway()
2050                .json(serde_json::json!({"error": "redirect without location"})));
2051        }
2052
2053        // Read the response body with byte cap enforcement.
2054        let upstream_status = response.status().as_u16();
2055        let upstream_content_type = response
2056            .headers()
2057            .get("content-type")
2058            .and_then(|v| v.to_str().ok())
2059            .unwrap_or("application/octet-stream")
2060            .to_string();
2061
2062        // Collect response headers, stripping sensitive ones.
2063        let mut forwarded_headers: Vec<(String, String)> = Vec::new();
2064        for (name, value) in response.headers() {
2065            let name_lower = name.as_str().to_ascii_lowercase();
2066            if STRIPPED_RESPONSE_HEADERS.contains(&name_lower.as_str()) {
2067                continue;
2068            }
2069            // Skip hop-by-hop headers.
2070            if matches!(
2071                name_lower.as_str(),
2072                "transfer-encoding" | "connection" | "keep-alive" | "trailer" | "upgrade"
2073            ) {
2074                continue;
2075            }
2076            if let Ok(val_str) = value.to_str() {
2077                forwarded_headers.push((name_lower, val_str.to_string()));
2078            }
2079        }
2080
2081        let body_bytes = response
2082            .bytes()
2083            .await
2084            .map_err(|e| actix_web::error::ErrorBadGateway(format!("body read: {e}")))?;
2085
2086        if body_bytes.len() > byte_cap {
2087            return Ok(HttpResponse::PayloadTooLarge().json(serde_json::json!({
2088                "error": "proxied response exceeds byte cap",
2089                "limit": byte_cap
2090            })));
2091        }
2092
2093        // Build the response.
2094        let mut rsp = HttpResponse::build(
2095            StatusCode::from_u16(upstream_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2096        );
2097        rsp.insert_header(("Content-Type", upstream_content_type.as_str()));
2098        rsp.insert_header(("X-Proxy-Status", upstream_status.to_string()));
2099
2100        // Forward non-sensitive headers.
2101        for (name, value) in &forwarded_headers {
2102            if let Ok(hname) = header::HeaderName::from_bytes(name.as_bytes()) {
2103                if let Ok(hval) = header::HeaderValue::from_str(value) {
2104                    rsp.insert_header((hname, hval));
2105                }
2106            }
2107        }
2108
2109        return Ok(rsp.body(body_bytes.to_vec()));
2110    }
2111}
2112
2113// ---------------------------------------------------------------------------
2114// Percent-decode + dotdot re-check middleware
2115// ---------------------------------------------------------------------------
2116
2117/// Actix middleware that rejects requests containing `..` path-traversal sequences.
2118pub struct PathTraversalGuard;
2119
2120impl<S, B> Transform<S, ServiceRequest> for PathTraversalGuard
2121where
2122    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2123    B: 'static,
2124{
2125    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2126    type Error = ActixError;
2127    type InitError = ();
2128    type Transform = PathTraversalGuardMiddleware<S>;
2129    type Future = Ready<Result<Self::Transform, Self::InitError>>;
2130
2131    fn new_transform(&self, service: S) -> Self::Future {
2132        ready(Ok(PathTraversalGuardMiddleware { service }))
2133    }
2134}
2135
2136/// Per-request service instance produced by [`PathTraversalGuard`].
2137pub struct PathTraversalGuardMiddleware<S> {
2138    service: S,
2139}
2140
2141impl<S, B> Service<ServiceRequest> for PathTraversalGuardMiddleware<S>
2142where
2143    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2144    B: 'static,
2145{
2146    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2147    type Error = ActixError;
2148    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2149
2150    actix_web::dev::forward_ready!(service);
2151
2152    fn call(&self, req: ServiceRequest) -> Self::Future {
2153        // Decode the raw path twice so that `%252e%252e` → `%2e%2e` →
2154        // `..` can be caught even though NormalizePath already ran once.
2155        let raw = req.path().to_string();
2156        if path_is_traversal(&raw) {
2157            let rsp = HttpResponse::BadRequest().body("invalid path: traversal rejected");
2158            let sr = req.into_response(rsp.map_into_boxed_body());
2159            return Box::pin(async move { Ok(sr.map_into_right_body()) });
2160        }
2161        let fut = self.service.call(req);
2162        Box::pin(async move {
2163            let resp = fut.await?;
2164            Ok(resp.map_into_left_body())
2165        })
2166    }
2167}
2168
2169fn path_is_traversal(path: &str) -> bool {
2170    // Two passes of percent-decode catches double-encoding.
2171    let once: String = percent_decode_str(path).decode_utf8_lossy().into_owned();
2172    let twice: String = percent_decode_str(&once).decode_utf8_lossy().into_owned();
2173    for seg in once.split('/').chain(twice.split('/')) {
2174        if seg == ".." || seg == "." {
2175            return true;
2176        }
2177    }
2178    // Also flag any raw escape sequences that decode to a traversal
2179    // segment even when buried inside a component (e.g. `foo%2f..%2fbar`).
2180    if twice.contains("/../") || twice.starts_with("../") || twice.ends_with("/..") {
2181        return true;
2182    }
2183    false
2184}
2185
2186// ---------------------------------------------------------------------------
2187// JSS-compatible CORS response headers
2188// ---------------------------------------------------------------------------
2189
2190/// Adds the same CORS envelope JSS emits from its global `onRequest` hook.
2191///
2192/// When `allowed_origins` is non-empty, the `Access-Control-Allow-Origin`
2193/// header is only reflected for origins in the list; requests from other
2194/// origins receive no ACAO header. When the list is empty (default), the
2195/// request `Origin` is echoed back (wildcard-equivalent, suitable for local dev).
2196pub struct CorsHeaders {
2197    pub allowed_origins: Arc<Vec<String>>,
2198}
2199
2200impl<S, B> Transform<S, ServiceRequest> for CorsHeaders
2201where
2202    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2203    B: 'static,
2204{
2205    type Response = ServiceResponse<B>;
2206    type Error = ActixError;
2207    type InitError = ();
2208    type Transform = CorsHeadersMiddleware<S>;
2209    type Future = Ready<Result<Self::Transform, Self::InitError>>;
2210
2211    fn new_transform(&self, service: S) -> Self::Future {
2212        ready(Ok(CorsHeadersMiddleware {
2213            service,
2214            allowed_origins: self.allowed_origins.clone(),
2215        }))
2216    }
2217}
2218
2219/// Per-request service instance produced by [`CorsHeaders`].
2220pub struct CorsHeadersMiddleware<S> {
2221    service: S,
2222    allowed_origins: Arc<Vec<String>>,
2223}
2224
2225impl<S, B> Service<ServiceRequest> for CorsHeadersMiddleware<S>
2226where
2227    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2228    B: 'static,
2229{
2230    type Response = ServiceResponse<B>;
2231    type Error = ActixError;
2232    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2233
2234    actix_web::dev::forward_ready!(service);
2235
2236    fn call(&self, req: ServiceRequest) -> Self::Future {
2237        let origin = req
2238            .headers()
2239            .get(header::ORIGIN)
2240            .and_then(|v| v.to_str().ok())
2241            .map(str::to_string);
2242        let allowed = self.allowed_origins.clone();
2243        let fut = self.service.call(req);
2244        Box::pin(async move {
2245            let mut resp = fut.await?;
2246            add_cors_headers(resp.headers_mut(), origin.as_deref(), &allowed);
2247            Ok(resp)
2248        })
2249    }
2250}
2251
2252fn add_cors_headers(headers: &mut header::HeaderMap, origin: Option<&str>, allowed: &[String]) {
2253    // Determine the effective ACAO value, respecting the allowlist.
2254    let effective_origin: Option<String> = if allowed.is_empty() {
2255        // No allowlist — echo back the request origin or fall back to "*".
2256        Some(origin.unwrap_or("*").to_string())
2257    } else {
2258        // Allowlist set — only reflect recognised origins.
2259        origin
2260            .filter(|o| allowed.iter().any(|a| a == *o))
2261            .map(str::to_string)
2262    };
2263
2264    // If the origin is blocked (allowlist non-empty and origin not in list),
2265    // skip setting any CORS headers so the browser's CORS preflight fails.
2266    let origin_value = match effective_origin {
2267        Some(ref v) => v.as_str(),
2268        None => return,
2269    };
2270
2271    let pairs = [
2272        ("access-control-allow-origin", origin_value),
2273        (
2274            "access-control-allow-methods",
2275            "GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS",
2276        ),
2277        (
2278            "access-control-allow-headers",
2279            "Accept, Authorization, Content-Type, DPoP, If-Match, If-None-Match, Link, Range, Slug, Origin",
2280        ),
2281        (
2282            "access-control-expose-headers",
2283            "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",
2284        ),
2285        ("access-control-allow-credentials", "true"),
2286        ("access-control-max-age", "86400"),
2287    ];
2288
2289    for (name, value) in pairs {
2290        if let (Ok(name), Ok(value)) = (
2291            header::HeaderName::from_lowercase(name.as_bytes()),
2292            header::HeaderValue::from_str(value),
2293        ) {
2294            headers.insert(name, value);
2295        }
2296    }
2297}
2298
2299// ---------------------------------------------------------------------------
2300// Sprint 11 (row 158): top-level 5xx logging middleware.
2301//
2302// JSS ref: commit 5b34d72 (#312) — "Top-level Fastify error handler,
2303// full stack on 5xx". Mirror the behaviour in actix: intercept any
2304// response whose status is 5xx, emit a structured `tracing::error!`
2305// with the method, path, status, error chain, and (when
2306// `RUST_BACKTRACE=1`) a captured backtrace. The response body is not
2307// altered; we only observe.
2308// ---------------------------------------------------------------------------
2309
2310/// Observes outbound responses and logs 5xx results with the full
2311/// error chain. Pass-through on 2xx/3xx/4xx. Shaped as an actix
2312/// [`Transform`] so it slots into the middleware stack in
2313/// [`build_app`].
2314pub struct ErrorLoggingMiddleware;
2315
2316impl<S, B> Transform<S, ServiceRequest> for ErrorLoggingMiddleware
2317where
2318    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2319    B: 'static,
2320{
2321    type Response = ServiceResponse<B>;
2322    type Error = ActixError;
2323    type InitError = ();
2324    type Transform = ErrorLoggingMiddlewareService<S>;
2325    type Future = Ready<Result<Self::Transform, Self::InitError>>;
2326
2327    fn new_transform(&self, service: S) -> Self::Future {
2328        ready(Ok(ErrorLoggingMiddlewareService { service }))
2329    }
2330}
2331
2332/// Per-request service instance produced by [`ErrorLoggingMiddleware`].
2333pub struct ErrorLoggingMiddlewareService<S> {
2334    service: S,
2335}
2336
2337impl<S, B> Service<ServiceRequest> for ErrorLoggingMiddlewareService<S>
2338where
2339    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2340    B: 'static,
2341{
2342    type Response = ServiceResponse<B>;
2343    type Error = ActixError;
2344    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2345
2346    actix_web::dev::forward_ready!(service);
2347
2348    fn call(&self, req: ServiceRequest) -> Self::Future {
2349        // Snapshot fields we need for the log line before the request
2350        // moves into the inner service.
2351        let method = req.method().as_str().to_string();
2352        let path = req.path().to_string();
2353
2354        let fut = self.service.call(req);
2355        Box::pin(async move {
2356            let response = fut.await?;
2357            let status = response.status();
2358            if status.is_server_error() {
2359                log_5xx(&method, &path, status, response.response().error());
2360            }
2361            Ok(response)
2362        })
2363    }
2364}
2365
2366/// Emit the structured 5xx log line. Captures a backtrace only when
2367/// `RUST_BACKTRACE=1` is set so production logs don't bloat unless the
2368/// operator opted in.
2369fn log_5xx(method: &str, path: &str, status: StatusCode, error: Option<&actix_web::Error>) {
2370    // Full error chain — include `source()` walk so downstream
2371    // `PodError` variants surface instead of being swallowed by
2372    // actix's top-level wrapper.
2373    let chain = match error {
2374        Some(e) => format_error_chain(e),
2375        None => "<no error attached to response>".to_string(),
2376    };
2377
2378    let backtrace = if std::env::var("RUST_BACKTRACE").ok().as_deref() == Some("1") {
2379        Some(std::backtrace::Backtrace::force_capture().to_string())
2380    } else {
2381        None
2382    };
2383
2384    tracing::error!(
2385        target: "solid_pod_rs_server::http",
2386        method = %method,
2387        path = %path,
2388        status = %status.as_u16(),
2389        error.chain = %chain,
2390        backtrace = backtrace.as_deref().unwrap_or(""),
2391        "5xx response"
2392    );
2393}
2394
2395/// Walk an actix `Error` + its `source()` chain into a single
2396/// human-readable string (one segment per cause, separated by ` -> `).
2397///
2398/// `actix_web::Error` does not expose a stable `source()` accessor,
2399/// and `ResponseError` in actix-web 4 does not extend
2400/// [`std::error::Error`]. We surface the `Display` form of the
2401/// response error (which captures the message operators care about
2402/// on 5xx) and append the actix `Debug` dump for deep diagnosis —
2403/// the dump already includes the inner cause chain that actix-http
2404/// preserves internally.
2405fn format_error_chain(e: &actix_web::Error) -> String {
2406    let summary = format!("{}", e.as_response_error());
2407    let debug = format!("{e:?}");
2408    if debug == summary || debug.is_empty() {
2409        summary
2410    } else {
2411        format!("{summary} -> {debug}")
2412    }
2413}
2414
2415// ---------------------------------------------------------------------------
2416// Dotfile allowlist middleware
2417// ---------------------------------------------------------------------------
2418
2419/// Actix middleware that blocks dotfile paths unless they appear on the allowlist.
2420pub struct DotfileGuard {
2421    allow: Arc<DotfileAllowlist>,
2422}
2423
2424impl DotfileGuard {
2425    pub fn new(allow: Arc<DotfileAllowlist>) -> Self {
2426        Self { allow }
2427    }
2428}
2429
2430impl<S, B> Transform<S, ServiceRequest> for DotfileGuard
2431where
2432    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2433    B: 'static,
2434{
2435    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2436    type Error = ActixError;
2437    type InitError = ();
2438    type Transform = DotfileGuardMiddleware<S>;
2439    type Future = Ready<Result<Self::Transform, Self::InitError>>;
2440
2441    fn new_transform(&self, service: S) -> Self::Future {
2442        ready(Ok(DotfileGuardMiddleware {
2443            service,
2444            allow: self.allow.clone(),
2445        }))
2446    }
2447}
2448
2449/// Per-request service instance produced by [`DotfileGuard`].
2450pub struct DotfileGuardMiddleware<S> {
2451    service: S,
2452    allow: Arc<DotfileAllowlist>,
2453}
2454
2455impl<S, B> Service<ServiceRequest> for DotfileGuardMiddleware<S>
2456where
2457    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
2458    B: 'static,
2459{
2460    type Response = ServiceResponse<EitherBody<B, BoxBody>>;
2461    type Error = ActixError;
2462    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
2463
2464    actix_web::dev::forward_ready!(service);
2465
2466    fn call(&self, req: ServiceRequest) -> Self::Future {
2467        let path = req.path().to_string();
2468        // Whitelist the well-known discovery paths even though they
2469        // contain a dotfile component — they are part of Solid's stable
2470        // interop surface. `/pay/.*` is the same case: the payment control
2471        // surface (`.info`, `.balance`, `.deposit`, `.offers`, `.sell`,
2472        // `.swap`, `.pool`) is dot-prefixed protocol endpoints, not pod
2473        // dotfiles, so the dotfile allowlist must not shadow them.
2474        let allow_system_route =
2475            path.starts_with("/.well-known/") || path == "/.pods" || path.starts_with("/pay/");
2476        if !allow_system_route {
2477            let pb = PathBuf::from(&path);
2478            if !self.allow.is_allowed(Path::new(&pb)) {
2479                let rsp = HttpResponse::Forbidden().body("dotfile path denied by allowlist");
2480                let sr = req.into_response(rsp.map_into_boxed_body());
2481                return Box::pin(async move { Ok(sr.map_into_right_body()) });
2482            }
2483        }
2484        let fut = self.service.call(req);
2485        Box::pin(async move {
2486            let resp = fut.await?;
2487            Ok(resp.map_into_left_body())
2488        })
2489    }
2490}
2491
2492// ---------------------------------------------------------------------------
2493// Git control panel API helpers (feature = "git")
2494// ---------------------------------------------------------------------------
2495
2496#[cfg(feature = "git")]
2497pub(crate) fn pod_repo_path(state: &AppState, pubkey: &str) -> Option<PathBuf> {
2498    if pubkey.len() != 64 || !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
2499        return None;
2500    }
2501    state.data_root.as_ref().map(|root| root.join(pubkey))
2502}
2503
2504/// Provenance composition hook (ADR-059 Phase 5): after a SUCCESSFUL LDP write
2505/// to a **git-backed** pod, record the write through the single canonical
2506/// [`ProvenanceLog::record`] path — the cheap, always-on git-mark **always**,
2507/// plus the expensive Bitcoin block-trail anchor **opt-in** when the resource's
2508/// ACL carries a `ProvenanceAnchor` condition. A PROV-O sidecar is persisted
2509/// at `<resource>.prov.ttl`.
2510///
2511/// **Single path — no parallel mark call.** This composes via
2512/// [`ProvenanceLog`]; it does *not* call `ShellGitMarker::mark_write` directly.
2513/// `ProvenanceLog::record` runs the git-mark, then conditionally the anchor
2514/// (per the resolved [`AnchorPolicy`]), binding the anchor's `state_hash` to
2515/// the git commit SHA (master-plan §2.3). The `Epoch` policy batches the SHA
2516/// into the per-pod epoch ([`handlers::prov::epoch_push_and_maybe_anchor`]) so
2517/// one Bitcoin tx notarises many commits (ADR-059 D5).
2518///
2519/// **Additive and best-effort by contract.** The LDP write has *already*
2520/// succeeded and the HTTP response is already determined when this runs. Every
2521/// failure — no git binary, a commit error, an anchor error, a sidecar-write
2522/// error — is logged at `warn` and swallowed: a provenance failure must NEVER
2523/// change the write's response status. A failed *anchor* never fails the write
2524/// and never suppresses the git-mark sidecar.
2525///
2526/// **git-backed-only.** A mark is produced only when `data_root` is configured
2527/// AND a git repository exists at `data_root/{pod}/.git`. Non-git / in-memory /
2528/// cloud-backed pods are skipped silently.
2529///
2530/// **No recursive marking.** Writes to ACL/meta/provenance sidecars
2531/// (`*.acl`, `*.meta`, `*.prov.ttl`) are skipped — marking a `.prov.ttl` would
2532/// recurse, and ACL/meta writes are control-plane, not content.
2533#[cfg(feature = "git")]
2534async fn git_mark_write(state: &AppState, resource_path: &str, agent: Option<&str>, message: &str) {
2535    use solid_pod_rs::provenance::{prov_ttl, AnchorPolicy, ProvenanceLog};
2536    use solid_pod_rs_git::mark::ShellGitMarker;
2537
2538    // Skip control-plane / provenance sidecars — never mark these, and never
2539    // recurse on our own `.prov.ttl` output.
2540    if resource_path.ends_with(".acl")
2541        || resource_path.ends_with(".meta")
2542        || resource_path.ends_with(".prov.ttl")
2543    {
2544        return;
2545    }
2546    // Containers (trailing slash) are not file writes — nothing to commit.
2547    if resource_path.ends_with('/') {
2548        return;
2549    }
2550
2551    // data_root is required to locate the pod repo on disk.
2552    let Some(data_root) = state.data_root.as_ref() else {
2553        return;
2554    };
2555
2556    // The pod is the first path segment; the repo lives at data_root/{pod}.
2557    let trimmed = resource_path.trim_start_matches('/');
2558    let mut segments = trimmed.splitn(2, '/');
2559    let pod = segments.next().unwrap_or("");
2560    let rel = segments.next().unwrap_or("");
2561    if pod.is_empty() || rel.is_empty() {
2562        return;
2563    }
2564    let repo = data_root.join(pod);
2565
2566    // git-backed check: a `.git` dir must exist at the pod root. Non-git pods
2567    // are skipped silently — this is the runtime guard that keeps memory /
2568    // cloud pods unaffected even when the `git` feature is compiled in.
2569    if !repo.join(".git").is_dir() {
2570        return;
2571    }
2572
2573    let agent_did = agent.unwrap_or("urn:solid:anonymous");
2574    let created = std::time::SystemTime::now()
2575        .duration_since(std::time::UNIX_EPOCH)
2576        .map(|d| d.as_secs())
2577        .unwrap_or(0);
2578
2579    // Resolve this resource's anchor policy from its effective ACL (the
2580    // `ProvenanceAnchor` condition → HighValue/Epoch; absent → Never).
2581    let (policy, ticker_override) =
2582        handlers::prov::resolve_anchor_policy(state, resource_path).await;
2583
2584    // Build the composition log: cheap git-marker ALWAYS; expensive anchorer
2585    // ONLY when the policy wants it AND the pod is configured for anchoring.
2586    // For `Epoch` the anchorer is still needed (to anchor the batch root on
2587    // close), so build it for any anchoring policy.
2588    let marker = std::sync::Arc::new(ShellGitMarker::new());
2589    let anchorer_bundle = if matches!(policy, AnchorPolicy::Never) {
2590        None
2591    } else {
2592        handlers::prov::build_anchorer(state, ticker_override.as_deref()).await
2593    };
2594    let (log, ticker, network) = match &anchorer_bundle {
2595        Some((anchorer, ticker, network)) => (
2596            ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone()),
2597            ticker.clone(),
2598            network.clone(),
2599        ),
2600        // No anchorer available (or policy Never): git-mark-only log.
2601        None => (ProvenanceLog::new(marker.clone()), String::new(), String::new()),
2602    };
2603
2604    // `record()` anchors INLINE only for HighValue (high_value=true). Epoch
2605    // defers to the accumulator below, so we pass it as Never to `record` and
2606    // batch the SHA ourselves; HighValue/Never flow straight through.
2607    let record_policy = match policy {
2608        AnchorPolicy::Epoch => AnchorPolicy::Never,
2609        other => other,
2610    };
2611    let high_value = matches!(policy, AnchorPolicy::HighValue) && anchorer_bundle.is_some();
2612
2613    // SINGLE canonical path: compose via ProvenanceLog::record (git-mark always,
2614    // anchor opt-in). A git-mark failure is the only hard error (the write
2615    // already succeeded, so we just log + return).
2616    let write_record = solid_pod_rs::provenance::WriteRecord {
2617        repo: &repo,
2618        path: rel,
2619        agent_did,
2620        message,
2621        policy: record_policy,
2622        high_value,
2623        ticker: &ticker,
2624        network: &network,
2625        created,
2626    };
2627    let mut mark = match log.record(write_record).await {
2628        Ok(m) => m,
2629        Err(e) => {
2630            tracing::warn!(
2631                target: "solid_pod_rs_server::git_mark",
2632                resource = %resource_path,
2633                "provenance record failed (swallowed, write already succeeded): {e}"
2634            );
2635            return;
2636        }
2637    };
2638    // `record` only sees the repo-relative path; restore the full pod-relative
2639    // resource path (`/{pod}/{rel}`) for the PROV-O sidecar + notification.
2640    mark.resource = resource_path.to_string();
2641
2642    // Epoch policy: batch the freshly-produced commit SHA; anchor the batch
2643    // root once when the epoch fills (best-effort — a failed batch anchor never
2644    // fails the write nor the git-mark).
2645    if matches!(policy, AnchorPolicy::Epoch) {
2646        if let Some((anchorer, _, _)) = &anchorer_bundle {
2647            match handlers::prov::epoch_push_and_maybe_anchor(
2648                state,
2649                anchorer,
2650                &ticker,
2651                &network,
2652                &mark.git.commit_sha,
2653            )
2654            .await
2655            {
2656                Ok(Some(closed)) => tracing::debug!(
2657                    target: "solid_pod_rs_server::git_mark",
2658                    root = %closed.root,
2659                    n = closed.commits.len(),
2660                    "epoch anchored (one tx notarises {} commits)", closed.commits.len()
2661                ),
2662                Ok(None) => {}
2663                Err(e) => tracing::warn!(
2664                    target: "solid_pod_rs_server::git_mark",
2665                    "epoch batch/anchor failed (swallowed): {e}"
2666                ),
2667            }
2668        }
2669    }
2670
2671    // Persist the PROV-O sidecar at <resource>.prov.ttl. This write also fires
2672    // the FS-watch StorageEvent the `Updates-via` notification stream relays,
2673    // so subscribers see the new mark. It ends in `.prov.ttl`, so the skip
2674    // guard above prevents any recursion.
2675    let ttl = prov_ttl(&mark);
2676    let sidecar = format!("{resource_path}.prov.ttl");
2677    if let Err(e) = state
2678        .storage
2679        .put(&sidecar, Bytes::from(ttl.into_bytes()), "text/turtle")
2680        .await
2681    {
2682        tracing::warn!(
2683            target: "solid_pod_rs_server::git_mark",
2684            sidecar = %sidecar,
2685            "provenance sidecar write failed (swallowed): {e}"
2686        );
2687        return;
2688    }
2689
2690    tracing::debug!(
2691        target: "solid_pod_rs_server::git_mark",
2692        resource = %resource_path,
2693        commit = %mark.git.commit_sha,
2694        anchored = mark.anchor.is_some(),
2695        "provenance recorded"
2696    );
2697}
2698
2699/// No-op shim when the `git` feature is disabled, so the write handlers can
2700/// call `git_mark_write(...)` unconditionally without per-call-site `cfg`.
2701#[cfg(not(feature = "git"))]
2702#[inline]
2703async fn git_mark_write(_state: &AppState, _resource_path: &str, _agent: Option<&str>, _message: &str) {}
2704
2705#[cfg(feature = "git")]
2706pub(crate) async fn require_pod_owner(req: &HttpRequest, pod_pubkey: &str) -> Option<String> {
2707    let caller = extract_pubkey(req).await?;
2708    if caller != pod_pubkey {
2709        return None;
2710    }
2711    Some(caller)
2712}
2713
2714#[cfg(feature = "git")]
2715fn git_json_err(msg: &str, status: u16) -> HttpResponse {
2716    HttpResponse::build(
2717        StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
2718    )
2719    .content_type("application/json")
2720    .body(format!(r#"{{"error":"{}"}}"#, msg.replace('"', "\\\"")))
2721}
2722
2723// Request body types for git control panel endpoints.
2724#[cfg(feature = "git")]
2725#[derive(serde::Deserialize)]
2726struct GitStageBody {
2727    paths: Option<Vec<String>>,
2728    all: Option<bool>,
2729}
2730
2731#[cfg(feature = "git")]
2732#[derive(serde::Deserialize)]
2733struct GitCommitBody {
2734    message: String,
2735    author_name: Option<String>,
2736    author_email: Option<String>,
2737}
2738
2739#[cfg(feature = "git")]
2740#[derive(serde::Deserialize)]
2741struct GitBranchBody {
2742    name: String,
2743}
2744
2745// ── Control panel handlers ──────────────────────────────────────────────────
2746
2747#[cfg(feature = "git")]
2748async fn handle_git_status(
2749    path: web::Path<String>,
2750    req: HttpRequest,
2751    state: web::Data<AppState>,
2752) -> HttpResponse {
2753    let pubkey = path.into_inner();
2754    if require_pod_owner(&req, &pubkey).await.is_none() {
2755        return git_json_err("Authentication required", 401);
2756    }
2757    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2758        return git_json_err("Git not available (no FS backend)", 501);
2759    };
2760    match solid_pod_rs_git::api::git_status(&repo).await {
2761        Ok(s) => HttpResponse::Ok()
2762            .content_type("application/json")
2763            .body(serde_json::to_string(&s).unwrap_or_default()),
2764        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2765    }
2766}
2767
2768#[cfg(feature = "git")]
2769async fn handle_git_log(
2770    path: web::Path<String>,
2771    req: HttpRequest,
2772    state: web::Data<AppState>,
2773    query: web::Query<std::collections::HashMap<String, String>>,
2774) -> HttpResponse {
2775    let pubkey = path.into_inner();
2776    if require_pod_owner(&req, &pubkey).await.is_none() {
2777        return git_json_err("Authentication required", 401);
2778    }
2779    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2780        return git_json_err("Git not available (no FS backend)", 501);
2781    };
2782    let limit: u32 = query
2783        .get("limit")
2784        .and_then(|v| v.parse().ok())
2785        .unwrap_or(20);
2786    match solid_pod_rs_git::api::git_log(&repo, limit).await {
2787        Ok(entries) => HttpResponse::Ok()
2788            .content_type("application/json")
2789            .body(serde_json::to_string(&entries).unwrap_or_default()),
2790        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2791    }
2792}
2793
2794#[cfg(feature = "git")]
2795async fn handle_git_diff(
2796    path: web::Path<String>,
2797    req: HttpRequest,
2798    state: web::Data<AppState>,
2799    query: web::Query<std::collections::HashMap<String, String>>,
2800) -> HttpResponse {
2801    let pubkey = path.into_inner();
2802    if require_pod_owner(&req, &pubkey).await.is_none() {
2803        return git_json_err("Authentication required", 401);
2804    }
2805    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2806        return git_json_err("Git not available (no FS backend)", 501);
2807    };
2808    let file_path = query.get("path").map(String::as_str);
2809    let staged = query
2810        .get("staged")
2811        .map(|v| v == "true" || v == "1")
2812        .unwrap_or(false);
2813    match solid_pod_rs_git::api::git_diff(&repo, file_path, staged).await {
2814        Ok(diff) => HttpResponse::Ok()
2815            .content_type("text/plain")
2816            .body(diff),
2817        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2818    }
2819}
2820
2821#[cfg(feature = "git")]
2822async fn handle_git_stage(
2823    path: web::Path<String>,
2824    req: HttpRequest,
2825    state: web::Data<AppState>,
2826    body: web::Bytes,
2827) -> HttpResponse {
2828    let pubkey = path.into_inner();
2829    if require_pod_owner(&req, &pubkey).await.is_none() {
2830        return git_json_err("Authentication required", 401);
2831    }
2832    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2833        return git_json_err("Git not available (no FS backend)", 501);
2834    };
2835    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2836        Ok(v) => v,
2837        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2838    };
2839    let paths = parsed.paths.unwrap_or_default();
2840    let all = parsed.all.unwrap_or(false);
2841    match solid_pod_rs_git::api::git_add(&repo, &paths, all).await {
2842        Ok(()) => HttpResponse::Ok()
2843            .content_type("application/json")
2844            .body(r#"{"ok":true}"#),
2845        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2846    }
2847}
2848
2849#[cfg(feature = "git")]
2850async fn handle_git_unstage(
2851    path: web::Path<String>,
2852    req: HttpRequest,
2853    state: web::Data<AppState>,
2854    body: web::Bytes,
2855) -> HttpResponse {
2856    let pubkey = path.into_inner();
2857    if require_pod_owner(&req, &pubkey).await.is_none() {
2858        return git_json_err("Authentication required", 401);
2859    }
2860    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2861        return git_json_err("Git not available (no FS backend)", 501);
2862    };
2863    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2864        Ok(v) => v,
2865        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2866    };
2867    let paths = parsed.paths.unwrap_or_default();
2868    let all = parsed.all.unwrap_or(false);
2869    match solid_pod_rs_git::api::git_unstage(&repo, &paths, all).await {
2870        Ok(()) => HttpResponse::Ok()
2871            .content_type("application/json")
2872            .body(r#"{"ok":true}"#),
2873        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2874    }
2875}
2876
2877#[cfg(feature = "git")]
2878async fn handle_git_commit(
2879    path: web::Path<String>,
2880    req: HttpRequest,
2881    state: web::Data<AppState>,
2882    body: web::Bytes,
2883) -> HttpResponse {
2884    let pubkey = path.into_inner();
2885    if require_pod_owner(&req, &pubkey).await.is_none() {
2886        return git_json_err("Authentication required", 401);
2887    }
2888    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2889        return git_json_err("Git not available (no FS backend)", 501);
2890    };
2891    let parsed: GitCommitBody = match serde_json::from_slice(&body) {
2892        Ok(v) => v,
2893        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2894    };
2895    let author_name = parsed.author_name.as_deref().unwrap_or("Pod Owner");
2896    let author_email = parsed
2897        .author_email
2898        .as_deref()
2899        .unwrap_or("pod@dreamlab-ai.com");
2900    match solid_pod_rs_git::api::git_commit(&repo, &parsed.message, author_name, author_email)
2901        .await
2902    {
2903        Ok(result) => HttpResponse::Ok()
2904            .content_type("application/json")
2905            .body(serde_json::to_string(&result).unwrap_or_default()),
2906        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2907    }
2908}
2909
2910#[cfg(feature = "git")]
2911async fn handle_git_branches(
2912    path: web::Path<String>,
2913    req: HttpRequest,
2914    state: web::Data<AppState>,
2915) -> HttpResponse {
2916    let pubkey = path.into_inner();
2917    if require_pod_owner(&req, &pubkey).await.is_none() {
2918        return git_json_err("Authentication required", 401);
2919    }
2920    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2921        return git_json_err("Git not available (no FS backend)", 501);
2922    };
2923    match solid_pod_rs_git::api::git_branches(&repo).await {
2924        Ok(info) => HttpResponse::Ok()
2925            .content_type("application/json")
2926            .body(serde_json::to_string(&info).unwrap_or_default()),
2927        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2928    }
2929}
2930
2931#[cfg(feature = "git")]
2932async fn handle_git_create_branch(
2933    path: web::Path<String>,
2934    req: HttpRequest,
2935    state: web::Data<AppState>,
2936    body: web::Bytes,
2937) -> HttpResponse {
2938    let pubkey = path.into_inner();
2939    if require_pod_owner(&req, &pubkey).await.is_none() {
2940        return git_json_err("Authentication required", 401);
2941    }
2942    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2943        return git_json_err("Git not available (no FS backend)", 501);
2944    };
2945    let parsed: GitBranchBody = match serde_json::from_slice(&body) {
2946        Ok(v) => v,
2947        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2948    };
2949    match solid_pod_rs_git::api::git_create_branch(&repo, &parsed.name).await {
2950        Ok(()) => HttpResponse::Ok()
2951            .content_type("application/json")
2952            .body(r#"{"ok":true}"#),
2953        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2954    }
2955}
2956
2957#[cfg(feature = "git")]
2958async fn handle_git_discard(
2959    path: web::Path<String>,
2960    req: HttpRequest,
2961    state: web::Data<AppState>,
2962    body: web::Bytes,
2963) -> HttpResponse {
2964    let pubkey = path.into_inner();
2965    if require_pod_owner(&req, &pubkey).await.is_none() {
2966        return git_json_err("Authentication required", 401);
2967    }
2968    let Some(repo) = pod_repo_path(&state, &pubkey) else {
2969        return git_json_err("Git not available (no FS backend)", 501);
2970    };
2971    let parsed: GitStageBody = match serde_json::from_slice(&body) {
2972        Ok(v) => v,
2973        Err(e) => return git_json_err(&format!("bad request: {e}"), 400),
2974    };
2975    let paths = parsed.paths.unwrap_or_default();
2976    match solid_pod_rs_git::api::git_discard(&repo, &paths).await {
2977        Ok(()) => HttpResponse::Ok()
2978            .content_type("application/json")
2979            .body(r#"{"ok":true}"#),
2980        Err(e) => git_json_err(&e.to_string(), e.status_code()),
2981    }
2982}
2983
2984// ---------------------------------------------------------------------------
2985// OPTIONS preflight for /_git/{pubkey}/{tail:.*} — alpha.15
2986// ---------------------------------------------------------------------------
2987
2988/// Handles CORS preflight (OPTIONS) requests for the `/_git/` REST API
2989/// namespace. Returns 204 with full CORS headers, respecting the
2990/// `allowed_origins` allowlist from `AppState`.
2991async fn handle_git_panel_options(
2992    req: HttpRequest,
2993    state: web::Data<AppState>,
2994) -> HttpResponse {
2995    let origin = req
2996        .headers()
2997        .get(header::ORIGIN)
2998        .and_then(|v| v.to_str().ok())
2999        .map(str::to_string);
3000
3001    let mut rsp = HttpResponse::NoContent().finish();
3002    add_cors_headers(rsp.headers_mut(), origin.as_deref(), &state.allowed_origins);
3003    rsp
3004}
3005
3006// ---------------------------------------------------------------------------
3007// POST /_admin/provision/{pubkey} — alpha.15
3008// ---------------------------------------------------------------------------
3009
3010/// PSK-gated endpoint that provisions a bare pod directory for a given
3011/// Nostr pubkey. Used by the forum auth-worker to create native pods on
3012/// behalf of users when the "native pods" admin panel action is triggered.
3013///
3014/// Protection: `X-Pod-Admin-Key` header must match `state.admin_key`.
3015/// When `state.admin_key` is `None` the endpoint always returns 403.
3016async fn handle_admin_provision(
3017    req: HttpRequest,
3018    state: web::Data<AppState>,
3019    path: web::Path<String>,
3020) -> HttpResponse {
3021    // --- PSK check -------------------------------------------------------
3022    let expected = match &state.admin_key {
3023        Some(k) => k.clone(),
3024        None => {
3025            return HttpResponse::Forbidden().json(serde_json::json!({
3026                "error": "admin key not configured on this server"
3027            }));
3028        }
3029    };
3030    let provided = req
3031        .headers()
3032        .get("x-pod-admin-key")
3033        .and_then(|v| v.to_str().ok())
3034        .unwrap_or("");
3035    // Constant-time comparison so the provisioning PSK cannot be
3036    // recovered via a response-timing side-channel. `ct_eq` returns a
3037    // `subtle::Choice`; differing lengths short-circuit to a `false`
3038    // choice without leaking the length via early return.
3039    use subtle::ConstantTimeEq;
3040    let key_match = provided.as_bytes().ct_eq(expected.as_bytes());
3041    if !bool::from(key_match) {
3042        return HttpResponse::Forbidden()
3043            .json(serde_json::json!({"error": "invalid admin key"}));
3044    }
3045
3046    // --- Pubkey validation -----------------------------------------------
3047    let pubkey = path.into_inner();
3048    if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
3049        return HttpResponse::BadRequest()
3050            .json(serde_json::json!({"error": "pubkey must be 64 lowercase hex characters"}));
3051    }
3052
3053    // --- Locate FS root --------------------------------------------------
3054    let data_root = match &state.data_root {
3055        Some(r) => r.clone(),
3056        None => {
3057            return HttpResponse::InternalServerError().json(serde_json::json!({
3058                "error": "server has no fs-backend storage configured"
3059            }));
3060        }
3061    };
3062
3063    let pod_dir = data_root.join(&pubkey);
3064
3065    // --- Create directory (idempotent) -----------------------------------
3066    if let Err(e) = tokio::fs::create_dir_all(&pod_dir).await {
3067        tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: create_dir_all failed");
3068        return HttpResponse::InternalServerError()
3069            .json(serde_json::json!({"error": format!("failed to create pod directory: {e}")}));
3070    }
3071
3072    // --- Write owner-only WAC ACL ----------------------------------------
3073    let acl_content = format!(
3074        "@prefix acl: <http://www.w3.org/ns/auth/acl#> .\n\
3075         <#owner> a acl:Authorization ;\n\
3076             acl:agent <did:nostr:{pubkey}> ;\n\
3077             acl:accessTo <./> ;\n\
3078             acl:default <./> ;\n\
3079             acl:mode acl:Read, acl:Write, acl:Control .\n"
3080    );
3081    let acl_path = pod_dir.join(".acl");
3082    if !acl_path.exists() {
3083        if let Err(e) = tokio::fs::write(&acl_path, acl_content.as_bytes()).await {
3084            tracing::error!(pubkey = %pubkey, error = %e, "/_admin/provision: write .acl failed");
3085            return HttpResponse::InternalServerError()
3086                .json(serde_json::json!({"error": format!("failed to write .acl: {e}")}));
3087        }
3088    }
3089
3090    // --- Git init (feature-gated) ----------------------------------------
3091    #[cfg(feature = "git")]
3092    {
3093        use tokio::process::Command;
3094
3095        // Only init if .git does not yet exist (idempotent).
3096        if !pod_dir.join(".git").exists() {
3097            let init_out = Command::new("git")
3098                .args([
3099                    "init",
3100                    "-b",
3101                    "main",
3102                    pod_dir.to_str().unwrap_or("."),
3103                ])
3104                .output()
3105                .await;
3106
3107            match init_out {
3108                Ok(out) if out.status.success() => {}
3109                Ok(out) => {
3110                    let stderr = String::from_utf8_lossy(&out.stderr);
3111                    tracing::warn!(pubkey = %pubkey, stderr = %stderr, "git init returned non-zero");
3112                }
3113                Err(e) => {
3114                    tracing::warn!(pubkey = %pubkey, error = %e, "git init failed (git not in PATH?)");
3115                }
3116            }
3117
3118            // Configure receive.denyCurrentBranch=updateInstead so the forum
3119            // client can push directly into the working tree.
3120            let cfg_out = Command::new("git")
3121                .args([
3122                    "-C",
3123                    pod_dir.to_str().unwrap_or("."),
3124                    "config",
3125                    "receive.denyCurrentBranch",
3126                    "updateInstead",
3127                ])
3128                .output()
3129                .await;
3130
3131            if let Err(e) = cfg_out {
3132                tracing::warn!(pubkey = %pubkey, error = %e, "git config receive.denyCurrentBranch failed");
3133            }
3134        }
3135    }
3136
3137    // --- Build response --------------------------------------------------
3138    let base_url = state.nodeinfo.base_url.trim_end_matches('/');
3139    HttpResponse::Ok().json(serde_json::json!({
3140        "podUrl": format!("{base_url}/pods/{pubkey}/"),
3141        "ok": true,
3142    }))
3143}
3144
3145// ---------------------------------------------------------------------------
3146// /.well-known/apps  (JSS #464 Phase 2 — public app discovery)
3147// ---------------------------------------------------------------------------
3148
3149async fn handle_well_known_apps(state: web::Data<AppState>) -> HttpResponse {
3150    let Some(ref data_root) = state.data_root else {
3151        return HttpResponse::Ok()
3152            .content_type("application/json")
3153            .json(serde_json::json!({"apps": [], "count": 0}));
3154    };
3155
3156    let server_url = state.nodeinfo.base_url.clone();
3157
3158    // Collect pod directories (up to 1000).
3159    let mut read_dir = match tokio::fs::read_dir(data_root).await {
3160        Ok(rd) => rd,
3161        Err(_) => {
3162            return HttpResponse::Ok()
3163                .content_type("application/json")
3164                .json(serde_json::json!({"apps": [], "serverUrl": server_url, "count": 0}));
3165        }
3166    };
3167
3168    let mut apps: Vec<serde_json::Value> = Vec::new();
3169    let mut scanned = 0usize;
3170
3171    while scanned < 1000 {
3172        let entry = match read_dir.next_entry().await {
3173            Ok(Some(e)) => e,
3174            Ok(None) => break,
3175            Err(_) => break,
3176        };
3177
3178        let file_type = match entry.file_type().await {
3179            Ok(ft) => ft,
3180            Err(_) => continue,
3181        };
3182        if !file_type.is_dir() {
3183            continue;
3184        }
3185
3186        scanned += 1;
3187
3188        let manifest_path = entry.path().join("apps").join("manifest.json");
3189        let contents = match tokio::fs::read(&manifest_path).await {
3190            Ok(c) => c,
3191            Err(_) => continue,
3192        };
3193
3194        let mut manifest: serde_json::Value = match serde_json::from_slice(&contents) {
3195            Ok(v) => v,
3196            Err(_) => continue,
3197        };
3198
3199        // Inject podOwner from the directory name (pubkey).
3200        if let Some(pod_name) = entry.file_name().to_str() {
3201            if manifest.get("podOwner").is_none() {
3202                manifest["podOwner"] = serde_json::Value::String(pod_name.to_string());
3203            }
3204        }
3205
3206        apps.push(manifest);
3207    }
3208
3209    let count = apps.len();
3210    HttpResponse::Ok()
3211        .content_type("application/json")
3212        .json(serde_json::json!({
3213            "apps": apps,
3214            "serverUrl": server_url,
3215            "count": count,
3216        }))
3217}
3218
3219// ---------------------------------------------------------------------------
3220// Git HTTP backend handler (JSS #466/#469/#471, feature = "git")
3221// ---------------------------------------------------------------------------
3222
3223/// Returns `true` if `path` is a git smart-HTTP protocol request.
3224///
3225/// Mirrors JSS `src/handlers/git.js` `isGitRequest`:
3226/// ```text
3227/// return urlPath.includes('/info/refs') ||
3228///   urlPath.includes('/git-upload-pack') ||
3229///   urlPath.includes('/git-receive-pack');
3230/// ```
3231#[allow(dead_code)]
3232fn is_git_request(path: &str) -> bool {
3233    path.contains("/info/refs")
3234        || path.contains("/git-upload-pack")
3235        || path.contains("/git-receive-pack")
3236}
3237
3238/// Returns `true` if `path` targets `.git/` internals directly — always
3239/// blocked (security, matches JSS lines 52-68).
3240#[allow(dead_code)]
3241fn is_dot_git_path(path: &str) -> bool {
3242    path.contains("/.git/") || path.ends_with("/.git")
3243}
3244
3245#[cfg(feature = "git")]
3246async fn handle_git(
3247    req: HttpRequest,
3248    body: web::Bytes,
3249    state: web::Data<AppState>,
3250) -> HttpResponse {
3251    use solid_pod_rs_git::auth::{BasicNostrExtractor, GitAuth};
3252    use solid_pod_rs_git::service::{GitHttpService, GitRequest};
3253
3254    let path = req.uri().path().to_string();
3255
3256    // Locate the pod's FS root: the first path segment after "/" is the
3257    // pod name (username/pubkey). The FS root is data_root/{pod_name}/.
3258    let pod_name = path
3259        .trim_start_matches('/')
3260        .split('/')
3261        .next()
3262        .unwrap_or("")
3263        .to_string();
3264    let Some(ref data_root) = state.data_root else {
3265        return HttpResponse::NotImplemented().json(serde_json::json!({
3266            "error": "git requires fs-backend storage",
3267            "reason": "data_root_not_configured"
3268        }));
3269    };
3270    let repo_root = data_root.join(&pod_name);
3271    if !repo_root.exists() {
3272        return HttpResponse::NotFound().json(serde_json::json!({"error": "pod not found"}));
3273    }
3274
3275    let query = req.uri().query().unwrap_or("").to_string();
3276    let host_url = {
3277        let conn = req.connection_info();
3278        Some(format!("{}://{}", conn.scheme(), conn.host()))
3279    };
3280    let headers: Vec<(String, String)> = req
3281        .headers()
3282        .iter()
3283        .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
3284        .collect();
3285
3286    let git_req = GitRequest {
3287        method: req.method().as_str().to_string(),
3288        path,
3289        query,
3290        headers,
3291        body: body.into(),
3292        host_url,
3293    };
3294
3295    // WAC gate (mirrors JSS `server.js` checkAccess before git, ~498-530).
3296    // Git requests were previously handed straight to the CGI with no
3297    // authorisation: a private pod's history was anonymously clonable and
3298    // pushes were anonymous (R5 "No WAC" finding / ADR-059 D6). Resolve the
3299    // caller's `did:nostr` from the git `Basic nostr:`/`Nostr` NIP-98
3300    // credential — an absent or invalid credential resolves to anonymous,
3301    // and WAC then decides, fail-closed. Enforce Read for clone/fetch and
3302    // Write for push against the pod-root container ACL. `enforce_read`
3303    // grants public pods to anonymous callers and replies 401 on a private
3304    // pod so the git client knows to retry with credentials; `enforce_write`
3305    // denies anonymous/unauthorised push.
3306    let is_write = git_req.is_write();
3307    let agent = match BasicNostrExtractor::new().authorise(&git_req).await {
3308        Ok(pk) => Some(format!("did:nostr:{pk}")),
3309        Err(_) => None,
3310    };
3311    let wac_path = format!("/{pod_name}/");
3312    let wac = if is_write {
3313        enforce_write(&state, &wac_path, AccessMode::Write, agent.as_deref()).await
3314    } else {
3315        enforce_read(&state, &wac_path, agent.as_deref()).await
3316    };
3317    if let Err(e) = wac {
3318        return e.error_response();
3319    }
3320
3321    let service = GitHttpService::new(repo_root);
3322    match service.handle(git_req).await {
3323        Ok(git_resp) => {
3324            let mut builder = HttpResponse::build(
3325                actix_web::http::StatusCode::from_u16(git_resp.status)
3326                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3327            );
3328            for (k, v) in &git_resp.headers {
3329                builder.insert_header((k.as_str(), v.as_str()));
3330            }
3331            builder.body(git_resp.body)
3332        }
3333        Err(e) => {
3334            let status = e.status_code();
3335            HttpResponse::build(
3336                actix_web::http::StatusCode::from_u16(status)
3337                    .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR),
3338            )
3339            .json(serde_json::json!({"error": e.to_string()}))
3340        }
3341    }
3342}
3343
3344// ---------------------------------------------------------------------------
3345// Public app builder
3346// ---------------------------------------------------------------------------
3347
3348/// Build the complete actix `App` for the Solid Pod server. Both the
3349/// binary (`main.rs`) and the workspace integration tests call this.
3350///
3351/// The returned `App` is fully-configured: route table, normaliser,
3352/// path-traversal guard, dotfile allowlist, body cap, CORS middleware
3353/// (when available), rate-limit middleware (when available), and WAC
3354/// enforcement.
3355pub fn build_app(
3356    state: AppState,
3357) -> App<
3358    impl actix_web::dev::ServiceFactory<
3359        ServiceRequest,
3360        Config = (),
3361        Response = ServiceResponse<EitherBody<EitherBody<BoxBody>>>,
3362        Error = ActixError,
3363        InitError = (),
3364    >,
3365> {
3366    let body_cap = state.body_cap;
3367    let dotfiles = state.dotfiles.clone();
3368    let allowed_origins = Arc::new(state.allowed_origins.clone());
3369
3370    let mut app = App::new()
3371        .app_data(web::Data::new(state.clone()))
3372        .app_data(web::PayloadConfig::new(body_cap))
3373        // Sprint 11 (row 158): outermost layer so it observes every
3374        // response — including those that short-circuited in inner
3375        // guards. Wrapping first means `wrap()` applies it last in
3376        // actix's stack order.
3377        .wrap(ErrorLoggingMiddleware)
3378        .wrap(CorsHeaders { allowed_origins })
3379        // `MergeOnly` collapses duplicate slashes (//a → /a) without
3380        // stripping the trailing slash, which is the container/resource
3381        // discriminator in LDP.
3382        .wrap(NormalizePath::new(TrailingSlash::MergeOnly))
3383        .wrap(PathTraversalGuard)
3384        .wrap(DotfileGuard::new(dotfiles));
3385
3386    // CORS / rate-limit: middleware is driven by the library types from
3387    // S7-A. We register pass-through headers when the env-driven policy
3388    // permits. The middleware is a no-op today beyond emitting the
3389    // policy's `response_headers` on every response; full preflight
3390    // handling lives in the sibling S7-A work.
3391    app = app
3392        .route("/.well-known/solid", web::get().to(handle_well_known_solid))
3393        .route(
3394            "/.well-known/webfinger",
3395            web::get().to(handle_well_known_webfinger),
3396        )
3397        .route(
3398            "/.well-known/nodeinfo",
3399            web::get().to(handle_well_known_nodeinfo),
3400        )
3401        .route(
3402            "/.well-known/nodeinfo/2.1",
3403            web::get().to(handle_well_known_nodeinfo_2_1),
3404        );
3405
3406    #[cfg(feature = "did-nostr")]
3407    {
3408        app = app.route(
3409            "/.well-known/did/nostr/{pubkey}.json",
3410            web::get().to(handle_well_known_did_nostr),
3411        );
3412    }
3413
3414    // JSS v0.0.190 Phase 1 port (issue #437), parity row 197.
3415    // Pod-resident NIP-05 endpoint. Scaffold only — handler body
3416    // is `todo!()`. Feature `nip05-endpoint` (default-off).
3417    #[cfg(feature = "nip05-endpoint")]
3418    {
3419        app = app.route(
3420            "/.well-known/nostr.json",
3421            web::get().to(handle_well_known_nip05),
3422        );
3423    }
3424
3425    // App discovery endpoint (JSS #464 Phase 2 — public, no auth required).
3426    app = app.route("/.well-known/apps", web::get().to(handle_well_known_apps));
3427
3428    // Payment endpoint (JSS parity: GET /pay/.info).
3429    app = app.route("/pay/.info", web::get().to(handle_pay_info));
3430
3431    // Phase 0 payment routing (master-plan §"Phase 0"): wire the orphaned
3432    // order-book / AMM / Web-Ledger logic. Registered with the SAME gating
3433    // as `/pay/.info` above — always-on, no payments feature flag — so the
3434    // whole `/pay/*` surface is consistent.
3435    app = app.configure(handlers::pay::register);
3436
3437    // WAC-gated CORS proxy endpoint.
3438    app = app.route("/proxy", web::get().to(handle_proxy));
3439
3440    // MCP (Model Context Protocol) endpoint — opt-in tool surface for
3441    // agents (JSS #490). Registered before the LDP catch-all so `/mcp` is
3442    // never treated as a pod resource. OFF unless `--mcp` / `JSS_MCP`.
3443    if state.mcp_enabled {
3444        app = app
3445            .route("/mcp", web::post().to(mcp::handle_mcp))
3446            .route("/mcp", web::method(actix_web::http::Method::OPTIONS).to(mcp::handle_mcp_options));
3447    }
3448
3449    // Admin provisioning endpoint (alpha.15). Must be before the LDP
3450    // catch-all so `_admin` is never treated as a pod name.
3451    app = app.route(
3452        "/_admin/provision/{pubkey}",
3453        web::post().to(handle_admin_provision),
3454    );
3455
3456    // Pod management API (JSS parity: /api/accounts/*)
3457    app = app
3458        .route("/.pods", web::post().to(handle_create_pod))
3459        .route("/api/accounts/new", web::post().to(handle_create_account))
3460        .route("/pods/check/{name}", web::get().to(handle_pod_check))
3461        .route("/login/password", web::post().to(handle_login_password))
3462        .route(
3463            "/account/password/reset",
3464            web::post().to(handle_password_reset_request),
3465        )
3466        .route(
3467            "/account/password/change",
3468            web::post().to(handle_password_change),
3469        );
3470
3471    // Git smart-HTTP protocol routes (JSS #466/#469/#471).
3472    // Must be registered before the LDP catch-all. Direct .git/ access is
3473    // always blocked (security). Smart-HTTP paths are served by
3474    // GitHttpService when the `git` feature is enabled; otherwise 501.
3475    app = app
3476        .route(
3477            // Block direct .git/ access (JSS: "BLOCK: Direct access to .git contents")
3478            "/{tail:.*}/.git",
3479            web::route().to(|| async {
3480                HttpResponse::Forbidden()
3481                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3482            }),
3483        )
3484        .route(
3485            "/{tail:.*}/.git/{rest:.*}",
3486            web::route().to(|| async {
3487                HttpResponse::Forbidden()
3488                    .json(serde_json::json!({"error": "direct .git access is forbidden"}))
3489            }),
3490        );
3491
3492    // OPTIONS preflight for /_git panel REST API (alpha.15). Registered
3493    // unconditionally (before the feature block) so browsers get a valid
3494    // CORS response regardless of whether the git feature is compiled in.
3495    app = app.route(
3496        "/pods/{pk}/_git/{tail:.*}",
3497        web::method(actix_web::http::Method::OPTIONS).to(handle_git_panel_options),
3498    );
3499
3500    #[cfg(feature = "git")]
3501    {
3502        // Git smart-HTTP: info/refs discovery + upload/receive pack.
3503        app = app
3504            .route("/{tail:.*}/info/refs", web::get().to(handle_git))
3505            .route("/{tail:.*}/git-upload-pack", web::post().to(handle_git))
3506            .route("/{tail:.*}/git-receive-pack", web::post().to(handle_git));
3507
3508        // Git control panel REST API. Routes registered before the LDP
3509        // catch-all so `_git` segments are never treated as LDP resources.
3510        app = app
3511            .route(
3512                "/pods/{pubkey}/_git/status",
3513                web::get().to(handle_git_status),
3514            )
3515            .route(
3516                "/pods/{pubkey}/_git/log",
3517                web::get().to(handle_git_log),
3518            )
3519            .route(
3520                "/pods/{pubkey}/_git/diff",
3521                web::get().to(handle_git_diff),
3522            )
3523            .route(
3524                "/pods/{pubkey}/_git/stage",
3525                web::post().to(handle_git_stage),
3526            )
3527            .route(
3528                "/pods/{pubkey}/_git/unstage",
3529                web::post().to(handle_git_unstage),
3530            )
3531            .route(
3532                "/pods/{pubkey}/_git/commit",
3533                web::post().to(handle_git_commit),
3534            )
3535            .route(
3536                "/pods/{pubkey}/_git/branches",
3537                web::get().to(handle_git_branches),
3538            )
3539            .route(
3540                "/pods/{pubkey}/_git/branch",
3541                web::post().to(handle_git_create_branch),
3542            )
3543            .route(
3544                "/pods/{pubkey}/_git/discard",
3545                web::post().to(handle_git_discard),
3546            );
3547
3548        // Provenance `_prov` API (ADR-059 Phase 5, master-plan §2.4):
3549        // resolve a git-mark commit SHA, and the explicit (payment-gated)
3550        // git-mark → Bitcoin-anchor upgrade. Registered before the LDP
3551        // catch-all so `_prov` segments are never treated as pod resources.
3552        // The `.prov.ttl` sidecar GET is served by the ordinary LDP read path
3553        // (it is a stored resource).
3554        app = app.configure(handlers::prov::register);
3555    }
3556    #[cfg(not(feature = "git"))]
3557    {
3558        // Without the git feature: return 501 for git protocol paths so
3559        // callers get a clear "not compiled in" signal rather than falling
3560        // through to LDP.
3561        let git_501 = || async {
3562            HttpResponse::NotImplemented()
3563                .json(serde_json::json!({"error": "git feature not enabled in this build"}))
3564        };
3565        app = app
3566            .route("/{tail:.*}/info/refs", web::get().to(git_501))
3567            .route("/{tail:.*}/git-upload-pack", web::post().to(git_501))
3568            .route("/{tail:.*}/git-receive-pack", web::post().to(git_501));
3569    }
3570
3571    // Container POST and PUT (trailing slash) must register before the
3572    // catch-all so the trailing-slash variant wins.
3573    app.route("/{tail:.*}/", web::post().to(handle_post))
3574        .route("/{tail:.*}/", web::put().to(handle_put))
3575        .route("/{tail:.*}", web::get().to(handle_get))
3576        .route("/{tail:.*}", web::head().to(handle_get))
3577        .route("/{tail:.*}", web::put().to(handle_put))
3578        .route("/{tail:.*}", web::patch().to(handle_patch))
3579        .route("/{tail:.*}", web::delete().to(handle_delete))
3580        .route(
3581            "/{tail:.*}",
3582            web::method(actix_web::http::Method::from_bytes(b"COPY").unwrap()).to(handle_copy),
3583        )
3584        .route(
3585            "/{tail:.*}",
3586            web::method(actix_web::http::Method::OPTIONS).to(handle_options),
3587        )
3588}
3589
3590// ---------------------------------------------------------------------------
3591// Tests — sat-gating loop closure (PaymentCondition wired to real ledger)
3592// ---------------------------------------------------------------------------
3593
3594#[cfg(test)]
3595mod payment_gating_tests {
3596    use super::*;
3597    use solid_pod_rs::payments::WebLedger;
3598    use solid_pod_rs::storage::memory::MemoryBackend;
3599
3600    const PRINCIPAL: &str = "did:nostr:alice";
3601
3602    /// Turtle ACL granting `did:nostr:alice` Write on `/premium/inbox`
3603    /// only when a `PaymentCondition` of 100 sats is satisfied.
3604    const PAID_WRITE_ACL: &str = r#"
3605@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3606
3607<#paid-write> a acl:Authorization ;
3608    acl:agent <did:nostr:alice> ;
3609    acl:accessTo </premium/inbox> ;
3610    acl:mode acl:Write ;
3611    acl:condition [
3612        a acl:PaymentCondition ;
3613        acl:costSats 100
3614    ] .
3615"#;
3616
3617    async fn seed_ledger(storage: &dyn Storage, did: &str, sats: u64) {
3618        let mut ledger = WebLedger::new("Test Pod Credits");
3619        if sats > 0 {
3620            ledger.credit(did, sats);
3621        }
3622        let body = serde_json::to_vec(&ledger).unwrap();
3623        storage
3624            .put(WEBLEDGER_PATH, Bytes::from(body), "application/json")
3625            .await
3626            .unwrap();
3627    }
3628
3629    async fn seed_acl(storage: &dyn Storage) {
3630        storage
3631            .put(
3632                "/premium/inbox.acl",
3633                Bytes::from(PAID_WRITE_ACL),
3634                "text/turtle",
3635            )
3636            .await
3637            .unwrap();
3638    }
3639
3640    /// The resolver reads the principal's balance from the seeded ledger.
3641    #[actix_web::test]
3642    async fn resolve_balance_reads_ledger_entry() {
3643        let storage = MemoryBackend::new();
3644        seed_ledger(&storage, PRINCIPAL, 250).await;
3645        assert_eq!(
3646            resolve_balance_sats(&storage, Some(PRINCIPAL)).await,
3647            Some(250)
3648        );
3649    }
3650
3651    /// No ledger entry → authenticated principal resolves to zero balance.
3652    #[actix_web::test]
3653    async fn resolve_balance_zero_when_no_entry() {
3654        let storage = MemoryBackend::new();
3655        seed_ledger(&storage, "did:nostr:bob", 500).await;
3656        assert_eq!(resolve_balance_sats(&storage, Some(PRINCIPAL)).await, Some(0));
3657    }
3658
3659    /// Anonymous (no principal) → `None`, so a PaymentCondition fails closed.
3660    #[actix_web::test]
3661    async fn resolve_balance_none_when_anonymous() {
3662        let storage = MemoryBackend::new();
3663        seed_ledger(&storage, PRINCIPAL, 1_000).await;
3664        assert_eq!(resolve_balance_sats(&storage, None).await, None);
3665    }
3666
3667    /// End-to-end: a sat-priced resource is DENIED below balance.
3668    #[actix_web::test]
3669    async fn paid_write_denied_below_balance() {
3670        let storage = Arc::new(MemoryBackend::new());
3671        seed_acl(storage.as_ref()).await;
3672        seed_ledger(storage.as_ref(), PRINCIPAL, 50).await; // < 100 cost
3673        let state = AppState::new(storage);
3674
3675        let result =
3676            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3677        assert!(
3678            result.is_err(),
3679            "balance 50 < cost 100 must be denied — sat-gating loop closed"
3680        );
3681    }
3682
3683    /// End-to-end: a sat-priced resource is ALLOWED at the balance threshold.
3684    #[actix_web::test]
3685    async fn paid_write_allowed_at_balance() {
3686        let storage = Arc::new(MemoryBackend::new());
3687        seed_acl(storage.as_ref()).await;
3688        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await; // == 100 cost
3689        let state = AppState::new(storage);
3690
3691        let result =
3692            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3693        assert!(
3694            result.is_ok(),
3695            "balance 100 >= cost 100 must be granted — sat-gating loop closed"
3696        );
3697    }
3698
3699    /// End-to-end: a sat-priced resource is ALLOWED above the threshold.
3700    #[actix_web::test]
3701    async fn paid_write_allowed_above_balance() {
3702        let storage = Arc::new(MemoryBackend::new());
3703        seed_acl(storage.as_ref()).await;
3704        seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3705        let state = AppState::new(storage);
3706
3707        let result =
3708            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3709        assert!(result.is_ok(), "balance 5000 >= cost 100 must be granted");
3710    }
3711
3712    /// Regression guard: before this fix `payment_balance_sats` was
3713    /// hardcoded `None`, so even an over-funded principal was denied.
3714    /// An anonymous caller (no principal) must still be denied.
3715    #[actix_web::test]
3716    async fn paid_write_anonymous_denied() {
3717        let storage = Arc::new(MemoryBackend::new());
3718        seed_acl(storage.as_ref()).await;
3719        seed_ledger(storage.as_ref(), PRINCIPAL, 5_000).await;
3720        let state = AppState::new(storage);
3721
3722        let result = enforce_write(&state, "/premium/inbox", AccessMode::Write, None).await;
3723        assert!(
3724            result.is_err(),
3725            "anonymous caller has no ledger principal — PaymentCondition fails closed"
3726        );
3727    }
3728
3729    // -----------------------------------------------------------------
3730    // R-04: sat-gating is a DEBIT, not just a balance check. A granted
3731    // payment-gated request must consume the matched rule's cost from the
3732    // caller's Web Ledger exactly once.
3733    // -----------------------------------------------------------------
3734
3735    async fn read_balance(storage: &dyn Storage, did: &str) -> u64 {
3736        let (bytes, _) = storage.get(WEBLEDGER_PATH).await.unwrap();
3737        let ledger: WebLedger = serde_json::from_slice(&bytes).unwrap();
3738        ledger.get_balance(did)
3739    }
3740
3741    /// A granted paid WRITE debits the cost from the ledger.
3742    #[actix_web::test]
3743    async fn paid_write_debits_ledger() {
3744        let storage = Arc::new(MemoryBackend::new());
3745        seed_acl(storage.as_ref()).await;
3746        seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; // cost 100
3747        let state = AppState::new(storage.clone());
3748
3749        let result =
3750            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3751        assert!(result.is_ok(), "balance 250 >= cost 100 must be granted");
3752        assert_eq!(
3753            read_balance(storage.as_ref(), PRINCIPAL).await,
3754            150,
3755            "250 - 100 cost: the grant must debit exactly the matched rule's cost"
3756        );
3757    }
3758
3759    /// A second granted paid WRITE debits again (no free re-read of the
3760    /// same resource once the balance is consumed).
3761    #[actix_web::test]
3762    async fn paid_write_debits_each_grant() {
3763        let storage = Arc::new(MemoryBackend::new());
3764        seed_acl(storage.as_ref()).await;
3765        seed_ledger(storage.as_ref(), PRINCIPAL, 250).await; // cost 100
3766        let state = AppState::new(storage.clone());
3767
3768        enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3769            .await
3770            .unwrap();
3771        enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL))
3772            .await
3773            .unwrap();
3774        assert_eq!(
3775            read_balance(storage.as_ref(), PRINCIPAL).await,
3776            50,
3777            "250 - 2*100: each granted request debits, no unmetered re-use"
3778        );
3779
3780        // Third request: 50 < 100 — gate denies, balance unchanged.
3781        let third =
3782            enforce_write(&state, "/premium/inbox", AccessMode::Write, Some(PRINCIPAL)).await;
3783        assert!(third.is_err(), "balance 50 < cost 100 must now be denied");
3784        assert_eq!(
3785            read_balance(storage.as_ref(), PRINCIPAL).await,
3786            50,
3787            "a denied request must not debit"
3788        );
3789    }
3790
3791    /// A granted paid READ debits the cost from the ledger.
3792    #[actix_web::test]
3793    async fn paid_read_debits_ledger() {
3794        const PAID_READ_ACL: &str = r#"
3795@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3796
3797<#paid-read> a acl:Authorization ;
3798    acl:agent <did:nostr:alice> ;
3799    acl:accessTo </premium/feed> ;
3800    acl:mode acl:Read ;
3801    acl:condition [
3802        a acl:PaymentCondition ;
3803        acl:costSats 30
3804    ] .
3805"#;
3806        let storage = Arc::new(MemoryBackend::new());
3807        storage
3808            .put("/premium/feed.acl", Bytes::from(PAID_READ_ACL), "text/turtle")
3809            .await
3810            .unwrap();
3811        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3812        let state = AppState::new(storage.clone());
3813
3814        let result = enforce_read(&state, "/premium/feed", Some(PRINCIPAL)).await;
3815        assert!(result.is_ok(), "balance 100 >= cost 30 must be granted");
3816        assert_eq!(
3817            read_balance(storage.as_ref(), PRINCIPAL).await,
3818            70,
3819            "100 - 30 cost: a granted paid read must debit"
3820        );
3821    }
3822
3823    /// A granted FREE read (no PaymentCondition) leaves the ledger
3824    /// untouched.
3825    #[actix_web::test]
3826    async fn free_read_does_not_debit() {
3827        let storage = Arc::new(MemoryBackend::new());
3828        seed_private_read_acl(storage.as_ref()).await; // no PaymentCondition
3829        seed_ledger(storage.as_ref(), PRINCIPAL, 100).await;
3830        let state = AppState::new(storage.clone());
3831
3832        enforce_read(&state, "/private/secret", Some(PRINCIPAL))
3833            .await
3834            .unwrap();
3835        assert_eq!(
3836            read_balance(storage.as_ref(), PRINCIPAL).await,
3837            100,
3838            "a grant with no PaymentCondition must not debit"
3839        );
3840    }
3841
3842    // -----------------------------------------------------------------
3843    // P0-1: WAC read enforcement (enforce_read)
3844    // -----------------------------------------------------------------
3845
3846    /// ACL granting `alice` Read on `/private/` but NO public/`bob` read.
3847    const ALICE_ONLY_READ_ACL: &str = r#"
3848@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3849
3850<#alice> a acl:Authorization ;
3851    acl:agent <did:nostr:alice> ;
3852    acl:accessTo </private/secret> ;
3853    acl:default </private/> ;
3854    acl:mode acl:Read, acl:Write, acl:Control .
3855"#;
3856
3857    async fn seed_private_read_acl(storage: &dyn Storage) {
3858        // The resolver walks up from `/private/secret` and probes the
3859        // container sidecar at `/private.acl` (it trims the trailing
3860        // slash before appending `.acl`). The grant inherits down via
3861        // `acl:default </private/>`.
3862        storage
3863            .put(
3864                "/private.acl",
3865                Bytes::from(ALICE_ONLY_READ_ACL),
3866                "text/turtle",
3867            )
3868            .await
3869            .unwrap();
3870    }
3871
3872    /// Before the P0-1 fix `handle_get` served `storage.get()` verbatim
3873    /// with no read-authz, so any resource was world-readable. The owner
3874    /// must be granted Read…
3875    #[actix_web::test]
3876    async fn enforce_read_grants_owner() {
3877        let storage = Arc::new(MemoryBackend::new());
3878        seed_private_read_acl(storage.as_ref()).await;
3879        let state = AppState::new(storage);
3880        let result = enforce_read(&state, "/private/secret", Some(PRINCIPAL)).await;
3881        assert!(result.is_ok(), "owner alice must be granted Read");
3882    }
3883
3884    /// …and an unrelated authenticated principal must be DENIED Read on a
3885    /// private resource (no world-readable leak).
3886    #[actix_web::test]
3887    async fn enforce_read_denies_other_principal() {
3888        let storage = Arc::new(MemoryBackend::new());
3889        seed_private_read_acl(storage.as_ref()).await;
3890        let state = AppState::new(storage);
3891        let result = enforce_read(&state, "/private/secret", Some("did:nostr:bob")).await;
3892        assert!(
3893            result.is_err(),
3894            "bob has no Read grant — private resource must not be world-readable"
3895        );
3896    }
3897
3898    /// An anonymous reader is also denied (deny-by-default; no ACL grants
3899    /// public/foaf:Agent Read).
3900    #[actix_web::test]
3901    async fn enforce_read_denies_anonymous() {
3902        let storage = Arc::new(MemoryBackend::new());
3903        seed_private_read_acl(storage.as_ref()).await;
3904        let state = AppState::new(storage);
3905        let result = enforce_read(&state, "/private/secret", None).await;
3906        assert!(result.is_err(), "anonymous Read must be denied");
3907    }
3908
3909    // -----------------------------------------------------------------
3910    // P0-2: `.acl` write requires acl:Control on the protected resource
3911    // -----------------------------------------------------------------
3912
3913    /// ACL granting `writer` Write (but NOT Control) on `/shared/`, and
3914    /// the owner `alice` full Control. A Write-only principal must not be
3915    /// able to rewrite the ACL (privilege escalation).
3916    const WRITE_NOT_CONTROL_ACL: &str = r#"
3917@prefix acl: <http://www.w3.org/ns/auth/acl#> .
3918
3919<#owner> a acl:Authorization ;
3920    acl:agent <did:nostr:alice> ;
3921    acl:accessTo </shared/doc> ;
3922    acl:default </shared/> ;
3923    acl:mode acl:Read, acl:Write, acl:Control .
3924
3925<#writer> a acl:Authorization ;
3926    acl:agent <did:nostr:writer> ;
3927    acl:accessTo </shared/doc> ;
3928    acl:default </shared/> ;
3929    acl:mode acl:Read, acl:Write .
3930"#;
3931
3932    async fn seed_shared_acl(storage: &dyn Storage) {
3933        // P0-2 resolves the protected resource `/shared/` and the
3934        // resolver probes its sidecar at `/shared.acl` (trailing slash
3935        // trimmed before `.acl`). Seed there so the Control evaluation
3936        // finds the grant.
3937        storage
3938            .put(
3939                "/shared.acl",
3940                Bytes::from(WRITE_NOT_CONTROL_ACL),
3941                "text/turtle",
3942            )
3943            .await
3944            .unwrap();
3945    }
3946
3947    /// A principal with Write but NOT Control on a container is denied PUT
3948    /// on its `.acl` — the check is elevated to acl:Control on the
3949    /// protected resource, closing the privilege-escalation path.
3950    #[actix_web::test]
3951    async fn acl_put_denied_for_writer_without_control() {
3952        let storage = Arc::new(MemoryBackend::new());
3953        seed_shared_acl(storage.as_ref()).await;
3954        let state = AppState::new(storage);
3955        // The request path is the `.acl` sidecar; before the fix this was
3956        // checked as Write on the sidecar (granted). Now it requires
3957        // Control on `/shared/`.
3958        let result =
3959            enforce_write(&state, "/shared/.acl", AccessMode::Write, Some("did:nostr:writer")).await;
3960        assert!(
3961            result.is_err(),
3962            "writer lacks Control — must not be able to PUT /shared/.acl"
3963        );
3964    }
3965
3966    /// The Control holder (owner) is still allowed to PUT the `.acl`.
3967    #[actix_web::test]
3968    async fn acl_put_allowed_for_control_holder() {
3969        let storage = Arc::new(MemoryBackend::new());
3970        seed_shared_acl(storage.as_ref()).await;
3971        let state = AppState::new(storage);
3972        let result =
3973            enforce_write(&state, "/shared/.acl", AccessMode::Write, Some(PRINCIPAL)).await;
3974        assert!(
3975            result.is_ok(),
3976            "alice holds Control — must be allowed to PUT /shared/.acl"
3977        );
3978    }
3979
3980    /// The same elevation applies to `.meta` sidecars.
3981    #[actix_web::test]
3982    async fn meta_put_denied_for_writer_without_control() {
3983        let storage = Arc::new(MemoryBackend::new());
3984        seed_shared_acl(storage.as_ref()).await;
3985        let state = AppState::new(storage);
3986        let result = enforce_write(
3987            &state,
3988            "/shared/doc.meta",
3989            AccessMode::Write,
3990            Some("did:nostr:writer"),
3991        )
3992        .await;
3993        assert!(
3994            result.is_err(),
3995            "writer lacks Control — must not be able to PUT a .meta sidecar"
3996        );
3997    }
3998
3999    /// Unit cover for the suffix-stripping helper.
4000    #[test]
4001    fn protected_resource_for_acl_strips_suffixes() {
4002        assert_eq!(protected_resource_for_acl("/victim/.acl").as_deref(), Some("/victim/"));
4003        assert_eq!(protected_resource_for_acl("/a/b.acl").as_deref(), Some("/a/b"));
4004        assert_eq!(protected_resource_for_acl("/.acl").as_deref(), Some("/"));
4005        assert_eq!(protected_resource_for_acl("/a/b.meta").as_deref(), Some("/a/b"));
4006        assert_eq!(protected_resource_for_acl("/a/b").as_deref(), None);
4007    }
4008}