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