Skip to main content

solid_pod_rs_server/
lib.rs

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