Skip to main content

solid_pod_rs_server/
lib.rs

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