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