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