Skip to main content

solid_pod_rs_server/
lib.rs

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