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