Skip to main content

solid_pod_rs_server/
lib.rs

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