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