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