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