Skip to main content

solid_pod_rs_forge/
lib.rs

1//! # solid-pod-rs-forge
2//!
3//! A clean-room Rust reimplementation of the JavaScriptSolidServer
4//! `forge` plugin — a zero-build, server-rendered Git forge (a
5//! Gogs/Gitea slice) composed almost entirely from primitives
6//! [`solid-pod-rs`](solid_pod_rs) already ships: git smart-HTTP
7//! ([`solid_pod_rs_git`]), write-as-commit provenance and Bitcoin
8//! anchoring ([`solid_pod_rs::provenance`], [`solid_pod_rs::mrc20`]),
9//! NIP-98 auth ([`solid_pod_rs::auth::nip98`]), `did:nostr` identity
10//! ([`solid_pod_rs::did_nostr_types`]), and WAC gating (enforced by the
11//! embedding server before [`ForgeService::handle`] is reached).
12//!
13//! ## IP posture
14//!
15//! Everything here is derived from the forge plugin's *published
16//! behaviour* and expressed as fresh Rust on solid-pod-rs's own
17//! primitives. JSS is cited by function/section name only — no upstream
18//! JavaScript is transcribed. This mirrors how `solid_pod_rs::bitcoin_tx`
19//! and [`solid_pod_rs::auth::nip98`] cite JSS while carrying an
20//! independent implementation.
21//!
22//! ## Architecture rule — "words in pods, metadata in the forge"
23//!
24//! Issue/PR/comment *bodies* live in the author's own pod (WAC-governed,
25//! author-owned); the forge keeps only a **spine index** of pointers.
26//! Bodies are re-fetched at read time, bounded. Podless `did:nostr`
27//! agents store bodies in forge-hosted storage instead.
28//!
29//! ## Native-only
30//!
31//! The forge shells to `git`/`git-http-backend`, touches the
32//! filesystem, and runs a loopback HTTP client. It is never part of a
33//! `core`/wasm build and adds zero dependencies to the core crate.
34//!
35//! ## Entry point
36//!
37//! The crate is framework-agnostic: [`ForgeService::handle`] consumes a
38//! [`ForgeRequest`] and produces a [`ForgeResponse`]; the embedding
39//! server (actix/axum/hyper) translates its native types at the edge and
40//! WAC-gates the forge scope *before* dispatch — exactly as the server's
41//! `handle_git` gates before invoking [`solid_pod_rs_git::GitHttpService`].
42
43#![forbid(unsafe_code)]
44#![warn(missing_docs)]
45#![warn(rust_2018_idioms)]
46
47pub mod api;
48pub mod api_issues;
49pub mod auth;
50pub mod bodies;
51pub mod compare;
52pub mod config;
53pub mod config_file;
54pub mod diff;
55pub mod error;
56pub mod forks;
57pub mod gitmark;
58pub mod gitrun;
59pub mod guard;
60pub mod hosted;
61pub mod hosted_edit;
62pub mod html;
63pub mod labels;
64pub mod locks;
65pub mod markdown;
66#[cfg(feature = "anchoring")]
67pub mod marks_ui;
68pub mod merge;
69#[cfg(feature = "mirror")]
70pub mod mirror;
71#[cfg(feature = "announce")]
72pub mod nip34;
73pub mod ownership;
74pub mod preview;
75mod prgit;
76mod privacy;
77#[cfg(feature = "announce")]
78pub mod publish;
79pub mod pulls;
80pub mod releases;
81pub mod repo;
82pub mod request;
83pub mod router;
84pub mod search;
85mod sizecap;
86pub mod spine;
87pub mod token;
88#[cfg(feature = "anchoring")]
89pub mod trail;
90pub mod web_edit;
91pub mod xlogin;
92
93/// Internal response header a handler sets to mark a page that loads the
94/// vendored login widget, so [`ForgeService::handle`] gives it the widget
95/// Content-Security-Policy. It never reaches the client.
96pub const WIDGET_PAGE_HEADER: &str = "x-forge-csp";
97
98#[cfg(test)]
99mod readfeat_tests;
100
101use std::path::{Path, PathBuf};
102use std::sync::Arc;
103use std::time::{SystemTime, UNIX_EPOCH};
104
105use solid_pod_rs_git::service::{GitHttpService, GitRequest};
106
107pub use bodies::{HostedReader, LoopbackFetch};
108pub use config::ForgeConfig;
109pub use error::ForgeError;
110pub use hosted::HostedStore;
111pub use ownership::ForgeAgent;
112pub use request::{esc, ForgeRequest, ForgeResponse};
113pub use router::{parse_route, Route};
114pub use spine::{FsSpineStore, SpineStore};
115pub use token::TokenError;
116
117/// Convenience import surface.
118pub mod prelude {
119    pub use crate::config::ForgeConfig;
120    pub use crate::error::ForgeError;
121    pub use crate::ownership::ForgeAgent;
122    pub use crate::request::{ForgeRequest, ForgeResponse};
123    pub use crate::ForgeService;
124}
125
126/// Current wall-clock time in Unix seconds.
127fn now_secs() -> u64 {
128    SystemTime::now()
129        .duration_since(UNIX_EPOCH)
130        .map(|d| d.as_secs())
131        .unwrap_or(0)
132}
133
134/// Load the persisted forge instance HMAC key, or generate + persist a
135/// fresh one (`0600`). The key is 32 bytes of OS randomness (two v4
136/// UUIDs, each 122 bits of entropy from the platform RNG). Persisting it
137/// keeps minted tokens valid across restarts.
138fn load_or_create_token_key(plugin_dir: &Path) -> Result<[u8; 32], ForgeError> {
139    let path = plugin_dir.join(".forge-token-key");
140    if let Ok(bytes) = std::fs::read(&path) {
141        if bytes.len() == 32 {
142            let mut key = [0u8; 32];
143            key.copy_from_slice(&bytes);
144            return Ok(key);
145        }
146    }
147    let mut key = [0u8; 32];
148    key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
149    key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
150    std::fs::write(&path, key)?;
151    #[cfg(unix)]
152    {
153        use std::os::unix::fs::PermissionsExt;
154        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
155    }
156    Ok(key)
157}
158
159/// The forge service. One instance is built at server startup and shared
160/// (`Arc`) across requests. All durable state lives under `plugin_dir`.
161#[derive(Clone)]
162pub struct ForgeService {
163    cfg: ForgeConfig,
164    plugin_dir: PathBuf,
165    repo_root: PathBuf,
166    git: Arc<GitHttpService>,
167    spine: Arc<dyn SpineStore>,
168    /// Loopback GET for verifying/re-fetching pod-hosted bodies. `None`
169    /// until the server injects its reqwest-backed client; without it,
170    /// pod-hosted issue writes are refused (fail-closed).
171    loopback: Option<Arc<dyn LoopbackFetch>>,
172    /// Forge-hosted body store for podless `did:nostr` agents (always
173    /// available — it is local `0600` storage under the plugin dir).
174    hosted: Arc<HostedStore>,
175    /// Forge instance HMAC key for push tokens (persisted `0600`).
176    token_key: Arc<[u8; 32]>,
177    /// Per-service single-use guard for NIP-98 event ids.
178    nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache,
179    /// NIP-34 identity, opted-in relays and publish policies.
180    #[cfg(feature = "announce")]
181    announcer: Arc<nip34::Announcer>,
182    /// Forge-held Blocktrails trails (feature `anchoring`).
183    #[cfg(feature = "anchoring")]
184    trails: Arc<trail::TrailStore>,
185    /// Fork, compare and pull-request runtime (merge hook, labels, git gate).
186    pr: pulls::PrExt,
187    /// Per-repo serialisation locks for spine writes (JSS `serializeOn`).
188    locks: Arc<locks::ForgeLocks>,
189}
190
191impl std::fmt::Debug for ForgeService {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.debug_struct("ForgeService")
194            .field("prefix", &self.cfg.normalized_prefix())
195            .field("plugin_dir", &self.plugin_dir)
196            .finish()
197    }
198}
199
200impl ForgeService {
201    /// Build a forge rooted at `plugin_dir`. Creates the on-disk layout
202    /// (`repos/`, `issues/`, `pulls/`, `hosted/`, `marks/`) if absent.
203    /// The git CGI service is rooted at `plugin_dir/repos`.
204    pub fn new(cfg: ForgeConfig, plugin_dir: impl Into<PathBuf>) -> Result<Self, ForgeError> {
205        // The chain guard runs first, before any other work (JSS `activate`).
206        cfg.validate_chain()?;
207        let plugin_dir = plugin_dir.into();
208        let repo_root = plugin_dir.join("repos");
209        for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
210            std::fs::create_dir_all(plugin_dir.join(sub))?;
211        }
212        let git = Arc::new(GitHttpService::new(repo_root.clone()));
213        let spine: Arc<dyn SpineStore> = Arc::new(FsSpineStore::new(plugin_dir.clone()));
214        let hosted = Arc::new(HostedStore::new(plugin_dir.clone()));
215        let token_key = Arc::new(load_or_create_token_key(&plugin_dir)?);
216        // JSS `activate`: the open web-edit relaxation is warned about, loudly, once.
217        if cfg.open_edit {
218            tracing::warn!(
219                "forge: config.open_edit is ON: anyone can edit repos via the web-edit endpoint \
220                 (no owner signature required). DEMO ONLY; never enable on a real forge."
221            );
222        }
223        #[cfg(feature = "announce")]
224        let announcer = Arc::new(nip34::Announcer::new(&cfg, &plugin_dir)?);
225        #[cfg(feature = "anchoring")]
226        let trails = Arc::new(trail::TrailStore::new(&cfg, &plugin_dir)?);
227        Ok(Self {
228            cfg,
229            plugin_dir,
230            repo_root,
231            git,
232            spine,
233            loopback: None,
234            hosted,
235            token_key,
236            nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache::from_env(),
237            #[cfg(feature = "announce")]
238            announcer,
239            #[cfg(feature = "anchoring")]
240            trails,
241            pr: pulls::PrExt::default(),
242            locks: Arc::new(locks::ForgeLocks::default()),
243        })
244    }
245
246    /// The per-repository spine locks. Hold an issue lock across the
247    /// load-modify-save of `issues/<owner>/<repo>.json` and a pull lock across
248    /// the same for the pulls index and merges; when both are needed take the
249    /// issue lock first. See [`locks`].
250    #[must_use]
251    pub fn locks(&self) -> &locks::ForgeLocks {
252        &self.locks
253    }
254
255    /// The service configuration.
256    #[must_use]
257    pub fn config(&self) -> &ForgeConfig {
258        &self.cfg
259    }
260
261    /// Inject the loopback fetcher (the server's reqwest client) used to
262    /// verify and re-fetch pod-hosted bodies.
263    #[must_use]
264    pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
265        self.loopback = Some(lb);
266        self
267    }
268
269    /// Override the spine store (tests inject an in-memory store).
270    #[must_use]
271    pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
272        self.spine = spine;
273        self
274    }
275
276    /// The forge instance HMAC key (for token minting/verification).
277    #[must_use]
278    pub fn token_key(&self) -> &[u8; 32] {
279        &self.token_key
280    }
281
282    /// Resolve the caller identity from `req`'s `Authorization` header
283    /// (forge token or NIP-98). The server may instead pass a
284    /// [`ForgeAgent::Pod`] directly to [`Self::handle`] when it has a pod
285    /// session; this helper is for the token/NIP-98 schemes.
286    pub async fn resolve_agent(&self, req: &ForgeRequest) -> ForgeAgent {
287        // The URL a NIP-98 event is checked against comes from the configured
288        // origin when there is one, never from a forged `host_url`.
289        let pinned = self.pinned_host_url(req.host_url.as_deref());
290        if pinned.as_deref() != req.host_url.as_deref() {
291            let mut r = req.clone();
292            r.host_url = pinned;
293            return auth::resolve_agent(
294                &r,
295                self.token_key.as_ref(),
296                &self.nip98_replay,
297                now_secs(),
298            )
299            .await;
300        }
301        auth::resolve_agent(req, self.token_key.as_ref(), &self.nip98_replay, now_secs()).await
302    }
303
304    /// The request origin the forge trusts: the origin of the configured
305    /// `base_url` (scheme and authority, no path) when set, otherwise the
306    /// embedder-supplied `host_url` unchanged (security review M1).
307    fn pinned_host_url(&self, host_url: Option<&str>) -> Option<String> {
308        match self.cfg.base_url.as_deref() {
309            Some(b) => {
310                let b = b.trim_end_matches('/');
311                let end = b
312                    .find("://")
313                    .map(|i| i + 3)
314                    .and_then(|s| b[s..].find('/').map(|j| s + j))
315                    .unwrap_or(b.len());
316                Some(b[..end].to_string())
317            }
318            None => host_url.map(str::to_string),
319        }
320    }
321
322    /// The configured, normalised URL prefix (`/forge`).
323    #[must_use]
324    pub fn prefix(&self) -> String {
325        self.cfg.normalized_prefix()
326    }
327
328    /// The plugin data directory.
329    #[must_use]
330    pub fn plugin_dir(&self) -> &Path {
331        &self.plugin_dir
332    }
333
334    /// Single entry point. The embedding server maps its native request
335    /// to a [`ForgeRequest`], WAC-gates the scope, and calls this.
336    pub async fn handle(
337        &self,
338        mut req: ForgeRequest,
339        agent: ForgeAgent,
340    ) -> Result<ForgeResponse, ForgeError> {
341        req.host_url = self.pinned_host_url(req.host_url.as_deref());
342        // CORS preflight.
343        if req.method.eq_ignore_ascii_case("OPTIONS") {
344            return Ok(ForgeResponse {
345                status: 204,
346                headers: vec![
347                    ("access-control-allow-origin".into(), "*".into()),
348                    (
349                        "access-control-allow-methods".into(),
350                        "GET, POST, PUT, PATCH, DELETE, OPTIONS".into(),
351                    ),
352                    (
353                        "access-control-allow-headers".into(),
354                        "Content-Type, Authorization".into(),
355                    ),
356                ],
357                body: bytes::Bytes::new(),
358            });
359        }
360
361        let prefix = self.cfg.normalized_prefix();
362        let Some(rel) = router::strip_prefix(&prefix, &req.path) else {
363            return Ok(ForgeResponse::error(404, "not under forge prefix"));
364        };
365        // Percent-forms of . / \ NUL never name a real object (JSS dispatcher).
366        if let Err(e) = guard::percent_guard(&rel) {
367            return Ok(self.finish_page(e.to_response()));
368        }
369        let route = parse_route(&rel);
370        if self.cfg.private_repos {
371            if let Some(denied) = self.private_gate(&rel, &route, &agent) {
372                return Ok(self.finish_page(denied));
373            }
374        }
375
376        #[cfg(feature = "anchoring")]
377        let scope = route
378            .repo_page_scope()
379            .map(|(o, r)| (o.to_string(), r.to_string()));
380        let result = self.dispatch(route, &req, &agent).await;
381        #[cfg(feature = "anchoring")]
382        let result = match (scope, result) {
383            (Some((o, r)), Ok(resp)) => Ok(self.decorate_repo_page(&o, &r, resp).await),
384            (_, other) => other,
385        };
386        Ok(self.finish_page(result.unwrap_or_else(|e| e.to_response())))
387    }
388
389    /// Attach the page `Content-Security-Policy` and `nosniff` to HTML
390    /// responses (JSS `sendHtml`).
391    fn finish_page(&self, mut resp: ForgeResponse) -> ForgeResponse {
392        let is_html = resp
393            .headers
394            .iter()
395            .any(|(k, v)| k.eq_ignore_ascii_case("content-type") && v.starts_with("text/html"));
396        if is_html {
397            // Only a page that loads the vendored widget keeps the weaker
398            // widget policy; every other page gets a strict nonce policy.
399            // The marker is an internal header only a handler can set; page
400            // content (which an attacker can influence) never selects the
401            // weaker policy. It is stripped before the response leaves.
402            let widget = resp
403                .headers
404                .iter()
405                .any(|(k, v)| k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER) && v == "widget");
406            resp.headers
407                .retain(|(k, _)| !k.eq_ignore_ascii_case(WIDGET_PAGE_HEADER));
408            let csp = if widget {
409                xlogin::build_csp(&self.cfg.csp_connect)
410            } else {
411                let nonce = uuid::Uuid::new_v4().simple().to_string();
412                xlogin::build_strict_csp(&self.cfg.csp_connect, &nonce)
413            };
414            resp.headers.push(("content-security-policy".into(), csp));
415            if !resp
416                .headers
417                .iter()
418                .any(|(k, _)| k.eq_ignore_ascii_case("x-content-type-options"))
419            {
420                resp.headers
421                    .push(("x-content-type-options".into(), "nosniff".into()));
422            }
423        }
424        resp
425    }
426
427    /// Route → handler. Handlers return `Result` and errors are mapped to
428    /// responses by the caller.
429    async fn dispatch(
430        &self,
431        route: Route,
432        req: &ForgeRequest,
433        _agent: &ForgeAgent,
434    ) -> Result<ForgeResponse, ForgeError> {
435        match route {
436            Route::Index => self.h_index_filtered(req, _agent).await,
437            Route::OwnerIndex { owner } => self.h_owner(&owner).await,
438            Route::GitSmart { rel_path } => self.h_git_smart(req, _agent, &rel_path).await,
439            Route::RepoOverview { owner, repo } => self.h_overview(&owner, &repo).await,
440            Route::Tree {
441                owner,
442                repo,
443                rev,
444                path,
445            } => self.h_tree(&owner, &repo, &rev, &path).await,
446            Route::Blob {
447                owner,
448                repo,
449                rev,
450                path,
451            } => self.h_blob(&owner, &repo, &rev, &path).await,
452            Route::Raw {
453                owner,
454                repo,
455                rev,
456                path,
457            } => self.h_raw(&owner, &repo, &rev, &path).await,
458            Route::Commits { owner, repo, rev } => self.h_commits(req, &owner, &repo, &rev).await,
459            Route::Commit { owner, repo, sha } => self.h_commit(&owner, &repo, &sha).await,
460            Route::Branches { owner, repo } => self.h_branches(&owner, &repo).await,
461            Route::Tags { owner, repo } => self.h_tags(&owner, &repo).await,
462            Route::BlocktrailsJson { owner, repo } => self.h_blocktrails_json(&owner, &repo).await,
463            #[cfg(feature = "anchoring")]
464            Route::ApiMarks { owner, repo, tail } => {
465                self.h_api_marks(req, _agent, &owner, &repo, &tail).await
466            }
467            #[cfg(feature = "anchoring")]
468            Route::Marks { owner, repo } => self.h_marks_page(req, &owner, &repo).await,
469            #[cfg(not(feature = "anchoring"))]
470            Route::ApiMarks { .. } | Route::Marks { .. } => Err(ForgeError::Unsupported(
471                "anchoring is not compiled into this build".into(),
472            )),
473            Route::ApiMirror { owner, repo } => Ok(self.h_api_mirror(&owner, &repo)),
474            Route::ApiNostr { owner, repo } => self.h_api_nostr(req, &owner, &repo).await,
475            Route::ApiAnnounce { owner, repo } => {
476                self.h_api_announce(req, _agent, &owner, &repo).await
477            }
478            Route::Issues { owner, repo } => {
479                if req.method.eq_ignore_ascii_case("POST") {
480                    self.h_issue_create(req, _agent, &owner, &repo).await
481                } else {
482                    self.h_issues_list(req, &owner, &repo).await
483                }
484            }
485            Route::IssueNew { owner, repo } => self.h_issue_new_form(&owner, &repo).await,
486            Route::IssueDetail { owner, repo, num } => {
487                if req.method.eq_ignore_ascii_case("POST") {
488                    self.h_issue_comment(req, _agent, &owner, &repo, num).await
489                } else {
490                    self.h_issue_detail(&owner, &repo, num).await
491                }
492            }
493            Route::ApiReposList => self.h_api_repo_list(req, _agent, None).await,
494            Route::ApiToken => self.h_api_token(req, _agent).await,
495            Route::ApiHosted { hex, id } => self.h_api_hosted(req, _agent, &hex, &id).await,
496            Route::ApiFork { owner, repo } => self.h_api_fork(req, _agent, &owner, &repo).await,
497            Route::ApiCompare { owner, repo, spec } => {
498                self.h_api_compare(_agent, &owner, &repo, &spec).await
499            }
500            Route::ApiPulls { owner, repo, tail } => {
501                self.h_api_pulls(req, _agent, &owner, &repo, &tail).await
502            }
503            Route::Compare { owner, repo, spec } => {
504                self.h_compare_page(_agent, &owner, &repo, &spec).await
505            }
506            Route::Pulls { owner, repo, tail } => {
507                self.h_pulls_page(req, _agent, &owner, &repo, &tail).await
508            }
509            // Read features (readfeat): JSON API, search, releases, xlogin.
510            Route::Xlogin => Ok(xlogin::xlogin_response(&req.method)),
511            Route::ApiOwnerRepos { owner } => self.h_api_repo_list(req, _agent, Some(&owner)).await,
512            Route::ApiRepo { owner, repo } => self.h_api_repo(req, _agent, &owner, &repo).await,
513            Route::ApiRepoAction {
514                owner,
515                repo,
516                action,
517                tail,
518            } => {
519                self.h_api_repo_action(req, _agent, &owner, &repo, &action, &tail)
520                    .await
521            }
522            Route::RepoSearch { owner, repo } => self.h_search_page(req, &owner, &repo).await,
523            Route::RepoReleases { owner, repo } => self.h_releases_page(&owner, &repo).await,
524            Route::RepoArchive { owner, repo, spec } => self.h_archive(&owner, &repo, &spec).await,
525            Route::ApiEdit { owner, repo } => Ok(self.h_api_edit(req, _agent, &owner, &repo).await),
526            Route::ApiPreview { owner, repo } => {
527                Ok(self.h_api_preview(req, _agent, &owner, &repo).await)
528            }
529            // The remaining routes are implemented in later phases.
530            _ => Err(ForgeError::NotFound("not implemented".into())),
531        }
532    }
533
534    /// Resolve `<owner>/<repo>` to its bare git-dir, 404ing when the repo
535    /// does not exist. Validates both segments against traversal.
536    async fn resolve_repo(&self, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
537        let dir = repo::repo_git_dir(&self.repo_root, owner, repo)?;
538        if !tokio::fs::metadata(&dir)
539            .await
540            .map(|m| m.is_dir())
541            .unwrap_or(false)
542        {
543            return Err(ForgeError::NotFound(format!("repo {owner}/{repo}")));
544        }
545        Ok(dir)
546    }
547
548    // ---- Tier 1: index + git smart-HTTP ---------------------------------
549
550    async fn h_owner(&self, owner: &str) -> Result<ForgeResponse, ForgeError> {
551        let repos = repo::list_owner(&self.repo_root, owner).await;
552        if repos.is_empty() {
553            return Err(ForgeError::NotFound(format!("owner {owner}")));
554        }
555        let page = html::owner_page(&self.prefix(), owner, &repos);
556        let pairs: Vec<(String, String)> = repos
557            .iter()
558            .map(|r| (owner.to_string(), r.clone()))
559            .collect();
560        Ok(ForgeResponse::html(
561            200,
562            self.decorate_listing(&page, &pairs),
563        ))
564    }
565
566    /// Forward a git smart-HTTP request to the reused CGI service. The
567    /// server has already WAC-gated the scope; auth for the CGI itself is
568    /// the pod's existing `Basic nostr:`/NIP-98 path inside the git crate.
569    async fn h_git_smart(
570        &self,
571        req: &ForgeRequest,
572        agent: &ForgeAgent,
573        rel_path: &str,
574    ) -> Result<ForgeResponse, ForgeError> {
575        let git_req = GitRequest {
576            method: req.method.clone(),
577            path: rel_path.to_string(),
578            query: req.query.clone(),
579            headers: req.headers.clone(),
580            body: req.raw_body.clone(),
581            host_url: req.host_url.clone(),
582        };
583        // Namespace push guard: a push (receive-pack) may only target the
584        // caller's own namespace. Pushing into another owner's namespace
585        // is `403` (JSS forge namespace guard). Reads are unaffected here;
586        // the server's WAC gate governs private-repo read access.
587        if git_req.is_write() {
588            let owner = rel_path
589                .trim_start_matches('/')
590                .split('/')
591                .next()
592                .unwrap_or("");
593            if !agent.can_write_namespace(owner) {
594                return Err(ForgeError::Forbidden(format!(
595                    "cannot push into namespace '{owner}'"
596                )));
597            }
598            // A mirrored repository is written by the mirror alone: a push
599            // could race its ref updates and would be overwritten by the next
600            // state anyway.
601            #[cfg(feature = "mirror")]
602            {
603                let name = rel_path
604                    .trim_start_matches('/')
605                    .split('/')
606                    .nth(1)
607                    .map_or("", |n| n.strip_suffix(".git").unwrap_or(n));
608                if mirror::MirrorStore::new(&self.plugin_dir)
609                    .read(owner, name)
610                    .is_some()
611                {
612                    return Err(ForgeError::Forbidden(format!(
613                        "'{owner}/{name}' is a mirror and read-only"
614                    )));
615                }
616            }
617        }
618        // A completed push to a default branch advances an anchored trail
619        // (JSS `recordTip` on receive-pack exit). The CGI response is fully
620        // buffered, so by the time `handle` returns the refs are updated.
621        #[cfg(feature = "anchoring")]
622        let pushed = git_req.method.eq_ignore_ascii_case("POST")
623            && rel_path
624                .trim_end_matches('/')
625                .ends_with("/git-receive-pack");
626        let resp = self.git.handle(git_req).await?;
627        #[cfg(feature = "anchoring")]
628        if pushed && resp.status < 300 {
629            let mut parts = rel_path.trim_start_matches('/').split('/');
630            if let (Some(owner), Some(name)) = (parts.next(), parts.next()) {
631                let name = name.strip_suffix(".git").unwrap_or(name);
632                self.record_pushed_tip(owner, name).await;
633            }
634        }
635        Ok(ForgeResponse {
636            status: resp.status,
637            headers: resp.headers,
638            body: resp.body,
639        })
640    }
641
642    // ---- Tier 1: browse -------------------------------------------------
643
644    async fn h_overview(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
645        let dir = self.resolve_repo(owner, repo).await?;
646        let prefix = self.prefix();
647        // Description, search box and open-issue badge under the header.
648        let open = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo)
649            .await
650            .map(|i| i.count(spine::issues::IssueState::Open))
651            .unwrap_or(0);
652        let description = api::repo_description(&dir).await;
653        let extras = html::repo_extras(&prefix, owner, repo, &description, open);
654        if !repo::browse::has_commits(&dir).await {
655            let page = html::repo_overview_page(&prefix, owner, repo, "main", &[], None);
656            let page = self.decorate_overview(page, owner, repo).await;
657            return Ok(ForgeResponse::html(
658                200,
659                html::inject_after_header(&page, &extras),
660            ));
661        }
662        let branch = repo::browse::default_branch(&dir).await;
663        let entries = repo::browse::list_tree(&dir, &branch, "").await?;
664        // README detection (case-insensitive common names).
665        let readme = self.find_readme(&dir, &branch, &entries).await;
666        let page =
667            html::repo_overview_page(&prefix, owner, repo, &branch, &entries, readme.as_deref());
668        let page = self.decorate_overview(page, owner, repo).await;
669        Ok(ForgeResponse::html(
670            200,
671            html::inject_after_header(&page, &extras),
672        ))
673    }
674
675    /// Read a bounded, textual README from the root tree if one exists.
676    async fn find_readme(
677        &self,
678        dir: &Path,
679        rev: &str,
680        entries: &[repo::browse::TreeEntry],
681    ) -> Option<String> {
682        let candidate = entries.iter().find(|e| {
683            e.kind == repo::browse::EntryKind::File && {
684                let n = e.name.to_ascii_lowercase();
685                n == "readme" || n == "readme.md" || n == "readme.txt"
686            }
687        })?;
688        let bytes =
689            repo::browse::read_blob(dir, rev, &candidate.name, self.cfg.max_body_bytes as u64)
690                .await
691                .ok()?;
692        if request::looks_textual(&bytes) {
693            Some(String::from_utf8_lossy(&bytes).into_owned())
694        } else {
695            None
696        }
697    }
698
699    async fn h_tree(
700        &self,
701        owner: &str,
702        repo: &str,
703        rev: &str,
704        path: &str,
705    ) -> Result<ForgeResponse, ForgeError> {
706        let dir = self.resolve_repo(owner, repo).await?;
707        let entries = repo::browse::list_tree(&dir, rev, path).await?;
708        Ok(ForgeResponse::html(
709            200,
710            html::tree_page(&self.prefix(), owner, repo, rev, path, &entries),
711        ))
712    }
713
714    async fn h_blob(
715        &self,
716        owner: &str,
717        repo: &str,
718        rev: &str,
719        path: &str,
720    ) -> Result<ForgeResponse, ForgeError> {
721        let dir = self.resolve_repo(owner, repo).await?;
722        // Cap the in-browser render; oversized/binary blobs get a download
723        // link instead of buffering into the page.
724        const BLOB_VIEW_MAX: u64 = 1024 * 1024;
725        let content = match repo::browse::read_blob(&dir, rev, path, BLOB_VIEW_MAX).await {
726            Ok(bytes) => {
727                if request::looks_textual(&bytes) {
728                    Some(String::from_utf8_lossy(&bytes).into_owned())
729                } else {
730                    None
731                }
732            }
733            // Too large to inline — offer the raw download.
734            Err(ForgeError::BadRequest(_)) => None,
735            Err(e) => return Err(e),
736        };
737        Ok(ForgeResponse::html(
738            200,
739            html::blob_page(&self.prefix(), owner, repo, rev, path, content.as_deref()),
740        ))
741    }
742
743    async fn h_raw(
744        &self,
745        owner: &str,
746        repo: &str,
747        rev: &str,
748        path: &str,
749    ) -> Result<ForgeResponse, ForgeError> {
750        let dir = self.resolve_repo(owner, repo).await?;
751        // Generous cap for raw downloads; still bounded to avoid OOM.
752        const RAW_MAX: u64 = 25 * 1024 * 1024;
753        let bytes = repo::browse::read_blob(&dir, rev, path, RAW_MAX).await?;
754        let is_text = request::looks_textual(&bytes);
755        let filename = path.rsplit('/').next().unwrap_or("download");
756        Ok(ForgeResponse::raw_bytes(
757            bytes::Bytes::from(bytes),
758            is_text,
759            filename,
760        ))
761    }
762
763    async fn h_commits(
764        &self,
765        req: &ForgeRequest,
766        owner: &str,
767        repo: &str,
768        rev: &str,
769    ) -> Result<ForgeResponse, ForgeError> {
770        let dir = self.resolve_repo(owner, repo).await?;
771        let page: u32 = req
772            .query_param("page")
773            .and_then(|p| p.parse().ok())
774            .unwrap_or(1)
775            .max(1);
776        let (commits, has_next) = repo::browse::commit_log(&dir, rev, page, 50).await?;
777        Ok(ForgeResponse::html(
778            200,
779            html::commits_page(&self.prefix(), owner, repo, rev, &commits, page, has_next),
780        ))
781    }
782
783    async fn h_commit(
784        &self,
785        owner: &str,
786        repo: &str,
787        sha: &str,
788    ) -> Result<ForgeResponse, ForgeError> {
789        let dir = self.resolve_repo(owner, repo).await?;
790        let meta = solid_pod_rs_git::api::resolve_commit(&dir, sha).await?;
791        let patch = repo::browse::commit_patch(&dir, &meta.hash).await?;
792        Ok(ForgeResponse::html(
793            200,
794            html::commit_page(&self.prefix(), owner, repo, &meta, &patch),
795        ))
796    }
797
798    async fn h_branches(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
799        let dir = self.resolve_repo(owner, repo).await?;
800        let info = solid_pod_rs_git::api::git_branches(&dir).await?;
801        Ok(ForgeResponse::html(
802            200,
803            html::refs_page(
804                &self.prefix(),
805                owner,
806                repo,
807                "Branches",
808                Some(&info.current),
809                &info.local,
810                "commits",
811            ),
812        ))
813    }
814
815    /// `api/repos/<owner>/<repo>/mirror`: the mirror record, or 404 when the
816    /// repository is not a mirror. Served without a repo-exists check: a
817    /// followed repository has a record before it has a bare repository
818    /// (no clone URL yet, a failing remote), and the record is the diagnosis
819    /// the operator needs. Without the `mirror` feature there are no records.
820    #[cfg(feature = "mirror")]
821    fn h_api_mirror(&self, owner: &str, repo: &str) -> ForgeResponse {
822        match mirror::MirrorStore::new(&self.plugin_dir).read(owner, repo) {
823            Some(rec) => ForgeResponse::json(
824                200,
825                &serde_json::to_value(&rec).unwrap_or(serde_json::Value::Null),
826            ),
827            None => ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"})),
828        }
829    }
830
831    /// Without the `announce` feature there is no NIP-34 emission (501).
832    #[cfg(not(feature = "announce"))]
833    #[allow(clippy::unused_self, clippy::unused_async)]
834    async fn h_api_nostr(
835        &self,
836        _req: &ForgeRequest,
837        _owner: &str,
838        _repo: &str,
839    ) -> Result<ForgeResponse, ForgeError> {
840        Err(ForgeError::Unsupported(
841            "NIP-34 emission needs the `announce` feature".into(),
842        ))
843    }
844
845    /// Without the `announce` feature there is no NIP-34 emission (501).
846    #[cfg(not(feature = "announce"))]
847    #[allow(clippy::unused_self, clippy::unused_async)]
848    async fn h_api_announce(
849        &self,
850        _req: &ForgeRequest,
851        _agent: &ForgeAgent,
852        _owner: &str,
853        _repo: &str,
854    ) -> Result<ForgeResponse, ForgeError> {
855        Err(ForgeError::Unsupported(
856            "NIP-34 emission needs the `announce` feature".into(),
857        ))
858    }
859
860    /// Without the `mirror` feature no repository is a mirror.
861    #[cfg(not(feature = "mirror"))]
862    #[allow(clippy::unused_self)]
863    fn h_api_mirror(&self, _owner: &str, _repo: &str) -> ForgeResponse {
864        ForgeResponse::json(404, &serde_json::json!({"error": "not a mirror"}))
865    }
866
867    /// `blocktrails.json` for a repo-held git-mark trail: the committed
868    /// document itself, re-serialised so only JSON goes out, with the
869    /// wide-open CORS the hosted verifier needs. A repository without a trail
870    /// answers 404 in the same shape and with the same CORS, so a browser
871    /// client can tell "no trail" from "blocked".
872    async fn h_blocktrails_json(
873        &self,
874        owner: &str,
875        repo: &str,
876    ) -> Result<ForgeResponse, ForgeError> {
877        // A forge-held trail wins; its pending marks ride additively.
878        #[cfg(feature = "anchoring")]
879        if let Some(doc) = self.forge_blocktrails_json(owner, repo)? {
880            let mut resp = ForgeResponse::with_type(
881                200,
882                "application/json; charset=utf-8",
883                bytes::Bytes::from(doc),
884            );
885            resp.headers
886                .push(("access-control-allow-origin".into(), "*".into()));
887            resp.headers
888                .push(("x-content-type-options".into(), "nosniff".into()));
889            return Ok(resp);
890        }
891        let dir = self.resolve_repo(owner, repo).await?;
892        let branch = repo::browse::default_branch(&dir).await;
893        let (status, body) = match gitmark::read_repo_trail(&dir, &branch).await {
894            Some(trail) => (200, trail.document_json()),
895            None => (
896                404,
897                serde_json::json!({"error": "anchoring is not enabled for this repository"})
898                    .to_string(),
899            ),
900        };
901        let mut resp = ForgeResponse::with_type(
902            status,
903            "application/json; charset=utf-8",
904            bytes::Bytes::from(body),
905        );
906        resp.headers
907            .push(("access-control-allow-origin".into(), "*".into()));
908        resp.headers
909            .push(("x-content-type-options".into(), "nosniff".into()));
910        Ok(resp)
911    }
912
913    async fn h_tags(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
914        let dir = self.resolve_repo(owner, repo).await?;
915        let tags = repo::browse::list_tags(&dir).await?;
916        let page = html::refs_page(&self.prefix(), owner, repo, "Tags", None, &tags, "tree");
917        Ok(ForgeResponse::html(
918            200,
919            html::inject_after_header(&page, &html::releases_link(&self.prefix(), owner, repo)),
920        ))
921    }
922
923    // ---- Tier 2: issues + spine ----------------------------------------
924
925    async fn h_issues_list(
926        &self,
927        req: &ForgeRequest,
928        owner: &str,
929        repo: &str,
930    ) -> Result<ForgeResponse, ForgeError> {
931        self.resolve_repo(owner, repo).await?;
932        let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
933        let filter = match req.query_param("state").as_deref() {
934            Some("closed") => spine::issues::IssueState::Closed,
935            _ => spine::issues::IssueState::Open,
936        };
937        let label = req
938            .query_param("label")
939            .map(|l| request::percent_decode(&l))
940            .filter(|l| !l.is_empty());
941        let mut issues = idx.by_state(filter);
942        if let Some(l) = &label {
943            issues.retain(|i| i.labels.iter().any(|x| x == l));
944        }
945        let defs = labels::effective_labels(&idx);
946        Ok(ForgeResponse::html(
947            200,
948            html::issues_list_page(
949                &self.prefix(),
950                owner,
951                repo,
952                filter,
953                idx.count(spine::issues::IssueState::Open),
954                idx.count(spine::issues::IssueState::Closed),
955                &issues,
956                &defs,
957            ),
958        ))
959    }
960
961    async fn h_issue_new_form(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
962        self.resolve_repo(owner, repo).await?;
963        Ok(ForgeResponse::html(
964            200,
965            html::issue_new_page(&self.prefix(), owner, repo),
966        ))
967    }
968
969    async fn h_issue_detail(
970        &self,
971        owner: &str,
972        repo: &str,
973        num: u64,
974    ) -> Result<ForgeResponse, ForgeError> {
975        self.resolve_repo(owner, repo).await?;
976        let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
977        let entry = idx
978            .issues
979            .get(&num)
980            .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
981        let truncated = entry.thread.len() > self.cfg.thread_cap;
982        let hosted: Arc<dyn HostedReader> = self.hosted.clone();
983        let threads = bodies::render_thread(
984            &entry.thread,
985            self.loopback.clone(),
986            Some(hosted),
987            &self.cfg,
988        )
989        .await;
990        Ok(ForgeResponse::html(
991            200,
992            html::issue_detail_page(
993                &self.prefix(),
994                owner,
995                repo,
996                entry,
997                &threads,
998                truncated,
999                &labels::effective_labels(&idx),
1000            ),
1001        ))
1002    }
1003
1004    /// Parse `{title?, resourceUrl?, body?}` from a JSON or form-encoded
1005    /// body. `resourceUrl` is the pod-hosted pointer (pod agents); `body`
1006    /// is the inline content (podless `did:nostr` agents).
1007    fn parse_issue_body(req: &ForgeRequest) -> (Option<String>, Option<String>, Option<String>) {
1008        let ct = req.header("content-type").unwrap_or("");
1009        let raw = String::from_utf8_lossy(&req.raw_body);
1010        let field_json = |v: &serde_json::Value, k: &str| {
1011            v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string())
1012        };
1013        if ct.contains("application/json") {
1014            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
1015                return (
1016                    field_json(&v, "title"),
1017                    field_json(&v, "resourceUrl"),
1018                    field_json(&v, "body"),
1019                );
1020            }
1021        }
1022        let pairs = request::parse_form(&raw);
1023        let field = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
1024        (field("title"), field("resourceUrl"), field("body"))
1025    }
1026
1027    /// Build a verified [`ThreadPointer`] for `agent`'s contribution:
1028    /// - **Pod agent** → validate + loopback-verify the pod `resource_url`.
1029    /// - **Nostr agent** → store `body` inline in forge-hosted storage.
1030    /// - **Anonymous** → `401`.
1031    async fn make_pointer(
1032        &self,
1033        req: &ForgeRequest,
1034        agent: &ForgeAgent,
1035        owner: &str,
1036        repo: &str,
1037        resource_url: Option<&str>,
1038        body: Option<&str>,
1039    ) -> Result<spine::issues::ThreadPointer, ForgeError> {
1040        match agent {
1041            ForgeAgent::Anonymous => {
1042                Err(ForgeError::Unauthorised("authentication required".into()))
1043            }
1044            ForgeAgent::Nostr { pubkey_hex } => {
1045                // Podless: the body is submitted inline and hosted 0600.
1046                let body = body
1047                    .map(str::trim)
1048                    .filter(|b| !b.is_empty())
1049                    .ok_or_else(|| ForgeError::BadRequest("body required".into()))?;
1050                let doc = hosted_edit::hosted_doc(
1051                    "ForgeComment",
1052                    owner,
1053                    repo,
1054                    None,
1055                    None,
1056                    body,
1057                    &agent.author_id(),
1058                    now_secs(),
1059                );
1060                let rref = self.hosted.write_doc(pubkey_hex, &doc).await?;
1061                Ok(spine::issues::ThreadPointer {
1062                    author: agent.author_id(),
1063                    resource_url: rref,
1064                    at: now_secs(),
1065                    hosted: true,
1066                })
1067            }
1068            ForgeAgent::Pod { .. } => {
1069                let url = resource_url
1070                    .ok_or_else(|| ForgeError::BadRequest("resourceUrl required".into()))?;
1071                self.verify_pod_pointer(req, agent, owner, repo, url).await
1072            }
1073        }
1074    }
1075
1076    /// Verify a pod-hosted body pointer: the URL must be in the caller's
1077    /// own forge area (SSRF guard) and unauthenticated-readable. Returns
1078    /// the validated `ThreadPointer` on success.
1079    async fn verify_pod_pointer(
1080        &self,
1081        req: &ForgeRequest,
1082        agent: &ForgeAgent,
1083        repo_owner: &str,
1084        repo: &str,
1085        resource_url: &str,
1086    ) -> Result<spine::issues::ThreadPointer, ForgeError> {
1087        let caller = agent
1088            .owner()
1089            .ok_or_else(|| ForgeError::Unauthorised("authentication required".into()))?;
1090        let host = req
1091            .host_url
1092            .as_deref()
1093            .ok_or_else(|| ForgeError::BadRequest("missing host".into()))?;
1094        // SSRF + own-area guard.
1095        bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
1096        // Confirm public readability with a single loopback GET.
1097        let lb = self
1098            .loopback
1099            .as_ref()
1100            .ok_or_else(|| ForgeError::Unsupported("pod body verification unavailable".into()))?;
1101        match lb
1102            .get(
1103                resource_url,
1104                self.cfg.max_body_bytes,
1105                self.cfg.fetch_timeout_secs,
1106            )
1107            .await
1108        {
1109            bodies::FetchResult::Body(_) => Ok(spine::issues::ThreadPointer {
1110                author: agent.author_id(),
1111                resource_url: resource_url.to_string(),
1112                at: now_secs(),
1113                hosted: false,
1114            }),
1115            bodies::FetchResult::Removed => Err(ForgeError::BadRequest(
1116                "pod body is not publicly readable".into(),
1117            )),
1118            bodies::FetchResult::TooLarge => {
1119                Err(ForgeError::BadRequest("pod body too large".into()))
1120            }
1121            bodies::FetchResult::Error(e) => {
1122                Err(ForgeError::Backend(format!("pod body fetch failed: {e}")))
1123            }
1124        }
1125    }
1126
1127    async fn h_issue_create(
1128        &self,
1129        req: &ForgeRequest,
1130        agent: &ForgeAgent,
1131        owner: &str,
1132        repo: &str,
1133    ) -> Result<ForgeResponse, ForgeError> {
1134        self.resolve_repo(owner, repo).await?;
1135        let (title, resource_url, body) = Self::parse_issue_body(req);
1136        let title = title
1137            .map(|t| t.trim().to_string())
1138            .filter(|t| !t.is_empty())
1139            .ok_or_else(|| ForgeError::BadRequest("title required".into()))?;
1140
1141        // Pod agents supply a pod pointer; podless nostr agents supply an
1142        // inline body stored in forge-hosted storage.
1143        let pointer = self
1144            .make_pointer(
1145                req,
1146                agent,
1147                owner,
1148                repo,
1149                resource_url.as_deref(),
1150                body.as_deref(),
1151            )
1152            .await?;
1153
1154        let _guard = self.locks.issues.lock(owner, repo).await;
1155        let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
1156        let entry = spine::issues::IssueEntry {
1157            number: 0,
1158            title,
1159            state: spine::issues::IssueState::Open,
1160            author: agent.author_id(),
1161            created_at: now_secs(),
1162            thread: vec![pointer],
1163            labels: Vec::new(),
1164        };
1165        let num = idx.allocate(entry);
1166        spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
1167
1168        let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
1169        Ok(ForgeResponse::redirect(303, &location))
1170    }
1171
1172    async fn h_issue_comment(
1173        &self,
1174        req: &ForgeRequest,
1175        agent: &ForgeAgent,
1176        owner: &str,
1177        repo: &str,
1178        num: u64,
1179    ) -> Result<ForgeResponse, ForgeError> {
1180        self.resolve_repo(owner, repo).await?;
1181        let (_title, resource_url, body) = Self::parse_issue_body(req);
1182        let pointer = self
1183            .make_pointer(
1184                req,
1185                agent,
1186                owner,
1187                repo,
1188                resource_url.as_deref(),
1189                body.as_deref(),
1190            )
1191            .await?;
1192
1193        let _guard = self.locks.issues.lock(owner, repo).await;
1194        let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
1195        let entry = idx
1196            .issues
1197            .get_mut(&num)
1198            .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
1199        entry.thread.push(pointer);
1200        spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
1201
1202        let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
1203        Ok(ForgeResponse::redirect(303, &location))
1204    }
1205
1206    // ---- Tier 2.5: tokens + hosted storage -----------------------------
1207
1208    /// `POST /api/token` — mint a forge push token for the (already
1209    /// authenticated) caller. The server resolves the caller via NIP-98 /
1210    /// pod session before calling `handle`; an anonymous caller is `401`.
1211    async fn h_api_token(
1212        &self,
1213        req: &ForgeRequest,
1214        agent: &ForgeAgent,
1215    ) -> Result<ForgeResponse, ForgeError> {
1216        if !req.method.eq_ignore_ascii_case("POST") {
1217            return Err(ForgeError::NotFound("token requires POST".into()));
1218        }
1219        let iat = now_secs();
1220        let ttl = self.cfg.token_ttl_secs;
1221        let token = token::mint(self.token_key.as_ref(), agent, iat, ttl).ok_or_else(|| {
1222            ForgeError::Unauthorised("authentication required to mint a token".into())
1223        })?;
1224        let v = serde_json::json!({
1225            "token": token,
1226            "tokenType": "Bearer",
1227            "expiresIn": ttl,
1228            "exp": iat + ttl,
1229            "agent": agent.author_id(),
1230        });
1231        Ok(ForgeResponse::json(200, &v))
1232    }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use bytes::Bytes;
1239    use tempfile::TempDir;
1240
1241    fn req(method: &str, path: &str) -> ForgeRequest {
1242        ForgeRequest {
1243            method: method.into(),
1244            path: path.into(),
1245            query: String::new(),
1246            headers: vec![],
1247            raw_body: Bytes::new(),
1248            host_url: Some("https://pod.example".into()),
1249        }
1250    }
1251
1252    /// M4: a page that loads the widget keeps the widget policy; any other
1253    /// HTML page gets the strict nonce policy with a fresh nonce.
1254    #[test]
1255    fn widget_pages_keep_the_widget_csp_and_others_get_a_nonce() {
1256        let (_td, svc) = service();
1257        let csp = |r: &ForgeResponse| {
1258            r.headers
1259                .iter()
1260                .find(|(k, _)| k == "content-security-policy")
1261                .map(|(_, v)| v.clone())
1262                .unwrap_or_default()
1263        };
1264        let plain = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
1265        let widget = svc.finish_page(
1266            ForgeResponse::html(200, "<p>w</p>".to_string())
1267                .with_header(WIDGET_PAGE_HEADER, "widget"),
1268        );
1269        // Text that merely mentions the widget (injected markup, a file
1270        // named xlogin.js) must not select the weak policy.
1271        let mention = svc.finish_page(ForgeResponse::html(
1272            200,
1273            "<p>&lt;script src=\"/forge/xlogin.js\"&gt;</p>".to_string(),
1274        ));
1275        assert!(csp(&mention).contains("script-src 'self' 'nonce-"));
1276        assert!(widget.headers.iter().all(|(k, _)| k != WIDGET_PAGE_HEADER));
1277        assert!(csp(&plain).contains("script-src 'self' 'nonce-"));
1278        assert!(!csp(&plain).contains("unsafe-inline' 'self'"));
1279        assert!(csp(&widget).contains("script-src 'unsafe-inline' 'self' https://esm.sh"));
1280        let again = svc.finish_page(ForgeResponse::html(200, "<p>hi</p>".to_string()));
1281        assert_ne!(csp(&plain), csp(&again), "the nonce is fresh per response");
1282    }
1283
1284    /// M1: with `base_url` configured, a forged `host_url` cannot make an
1285    /// event signed for the attacker's host satisfy the NIP-98 URL match.
1286    #[tokio::test]
1287    async fn nip98_url_comes_from_base_url_not_a_forged_host() {
1288        use solid_pod_rs::auth::nip98;
1289        const SK: &str = "5555555555555555555555555555555555555555555555555555555555555555";
1290        let td = TempDir::new().unwrap();
1291        let cfg = ForgeConfig {
1292            base_url: Some("https://pod.example/".into()),
1293            ..ForgeConfig::default()
1294        };
1295        let svc = ForgeService::new(cfg, td.path()).unwrap();
1296        let signed_for = |url: &str, t: u64| {
1297            let tok = nip98::mint(url, "GET", SK, t).unwrap();
1298            let mut r = req("GET", "/forge/x");
1299            r.headers = vec![("authorization".into(), format!("Nostr {tok}"))];
1300            r.host_url = Some("https://evil.example".into());
1301            r
1302        };
1303        let now = now_secs();
1304        let replayed = signed_for("https://evil.example/forge/x", now);
1305        assert_eq!(svc.resolve_agent(&replayed).await, ForgeAgent::Anonymous);
1306        let honest = signed_for("https://pod.example/forge/x", now + 1);
1307        assert!(matches!(
1308            svc.resolve_agent(&honest).await,
1309            ForgeAgent::Nostr { .. }
1310        ));
1311    }
1312
1313    fn service() -> (TempDir, ForgeService) {
1314        let td = TempDir::new().unwrap();
1315        let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
1316        (td, svc)
1317    }
1318
1319    #[tokio::test]
1320    async fn new_creates_layout() {
1321        let (td, _svc) = service();
1322        for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
1323            assert!(td.path().join(sub).is_dir(), "{sub} dir must exist");
1324        }
1325    }
1326
1327    #[tokio::test]
1328    async fn index_renders_empty() {
1329        let (_td, svc) = service();
1330        let r = svc
1331            .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
1332            .await
1333            .unwrap();
1334        assert_eq!(r.status, 200);
1335        let body = String::from_utf8(r.body.to_vec()).unwrap();
1336        assert!(body.contains("No repositories yet"));
1337    }
1338
1339    #[tokio::test]
1340    async fn index_lists_pushed_repo() {
1341        let (td, svc) = service();
1342        tokio::fs::create_dir_all(td.path().join("repos/alice/demo.git"))
1343            .await
1344            .unwrap();
1345        let r = svc
1346            .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
1347            .await
1348            .unwrap();
1349        let body = String::from_utf8(r.body.to_vec()).unwrap();
1350        assert!(body.contains("/forge/alice/demo"));
1351
1352        // Owner page too.
1353        let r2 = svc
1354            .handle(req("GET", "/forge/alice"), ForgeAgent::Anonymous)
1355            .await
1356            .unwrap();
1357        assert_eq!(r2.status, 200);
1358    }
1359
1360    #[tokio::test]
1361    async fn unknown_owner_is_404() {
1362        let (_td, svc) = service();
1363        let r = svc
1364            .handle(req("GET", "/forge/ghost"), ForgeAgent::Anonymous)
1365            .await
1366            .unwrap();
1367        assert_eq!(r.status, 404);
1368    }
1369
1370    #[tokio::test]
1371    async fn options_preflight() {
1372        let (_td, svc) = service();
1373        let r = svc
1374            .handle(req("OPTIONS", "/forge/x"), ForgeAgent::Anonymous)
1375            .await
1376            .unwrap();
1377        assert_eq!(r.status, 204);
1378        assert!(r
1379            .headers
1380            .iter()
1381            .any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-methods")));
1382    }
1383
1384    #[tokio::test]
1385    async fn outside_prefix_is_404() {
1386        let (_td, svc) = service();
1387        let r = svc
1388            .handle(req("GET", "/other/thing"), ForgeAgent::Anonymous)
1389            .await
1390            .unwrap();
1391        assert_eq!(r.status, 404);
1392    }
1393
1394    #[tokio::test]
1395    async fn unimplemented_route_is_404_for_now() {
1396        let (_td, svc) = service();
1397        let r = svc
1398            .handle(
1399                req("GET", "/forge/alice/repo/issues"),
1400                ForgeAgent::Anonymous,
1401            )
1402            .await
1403            .unwrap();
1404        // Issues is implemented in Phase 2; before that it is a 404. Once
1405        // Phase 2 lands, an anonymous GET on a missing repo still 404s.
1406        assert_eq!(r.status, 404);
1407    }
1408
1409    // ---- Phase 1 browse integration (needs the `git` binary) -----------
1410
1411    fn git_available() -> bool {
1412        std::process::Command::new("git")
1413            .arg("--version")
1414            .stdout(std::process::Stdio::null())
1415            .stderr(std::process::Stdio::null())
1416            .status()
1417            .map(|s| s.success())
1418            .unwrap_or(false)
1419    }
1420
1421    fn run_git(dir: &std::path::Path, args: &[&str]) {
1422        let ok = std::process::Command::new("git")
1423            .args(args)
1424            .current_dir(dir)
1425            .env("GIT_CONFIG_NOSYSTEM", "1")
1426            .stdout(std::process::Stdio::null())
1427            .stderr(std::process::Stdio::null())
1428            .status()
1429            .map(|s| s.success())
1430            .unwrap_or(false);
1431        assert!(ok, "git {args:?} failed in {}", dir.display());
1432    }
1433
1434    /// Create `repos/<owner>/<name>.git` as a bare repo containing a
1435    /// README, a `src/lib.rs`, and a `v1.0` tag, via a working clone.
1436    fn seed_repo(td: &TempDir, owner: &str, name: &str) {
1437        let work = td.path().join(format!("work-{owner}-{name}"));
1438        std::fs::create_dir_all(&work).unwrap();
1439        run_git(&work, &["init", "-b", "main"]);
1440        std::fs::write(work.join("README.md"), "# Demo\nhello forge\n").unwrap();
1441        std::fs::create_dir_all(work.join("src")).unwrap();
1442        std::fs::write(work.join("src/lib.rs"), "pub fn f() -> u8 { 42 }\n").unwrap();
1443        run_git(&work, &["add", "-A"]);
1444        run_git(
1445            &work,
1446            &[
1447                "-c",
1448                "user.email=t@e.st",
1449                "-c",
1450                "user.name=Tester",
1451                "commit",
1452                "-m",
1453                "initial commit",
1454            ],
1455        );
1456        run_git(&work, &["tag", "v1.0"]);
1457        let bare = td
1458            .path()
1459            .join("repos")
1460            .join(owner)
1461            .join(format!("{name}.git"));
1462        std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
1463        run_git(
1464            td.path(),
1465            &[
1466                "clone",
1467                "--bare",
1468                work.to_str().unwrap(),
1469                bare.to_str().unwrap(),
1470            ],
1471        );
1472    }
1473
1474    async fn body(svc: &ForgeService, path: &str) -> (u16, String) {
1475        let r = svc
1476            .handle(req("GET", path), ForgeAgent::Anonymous)
1477            .await
1478            .unwrap();
1479        (r.status, String::from_utf8_lossy(&r.body).into_owned())
1480    }
1481
1482    #[tokio::test]
1483    async fn overview_lists_tree_and_readme() {
1484        if !git_available() {
1485            return;
1486        }
1487        let (td, svc) = service();
1488        seed_repo(&td, "alice", "demo");
1489        let (status, html) = body(&svc, "/forge/alice/demo").await;
1490        assert_eq!(status, 200);
1491        assert!(html.contains("alice/demo"));
1492        // Root tree entries.
1493        assert!(html.contains("README.md"));
1494        assert!(html.contains("src"));
1495        // README rendered.
1496        assert!(html.contains("hello forge"));
1497    }
1498
1499    #[tokio::test]
1500    async fn blocktrails_json_serves_a_repo_held_trail_with_cors() {
1501        if !git_available() {
1502            return;
1503        }
1504        let (td, svc) = service();
1505        seed_repo(&td, "casey", "agent");
1506        seed_repo(&td, "casey", "demo");
1507        let header = |r: &ForgeResponse, k: &str| {
1508            r.headers
1509                .iter()
1510                .find(|(n, _)| n == k)
1511                .map(|(_, v)| v.clone())
1512        };
1513
1514        // No trail file: 404, still CORS-readable, same JSON shape.
1515        let none = svc
1516            .handle(
1517                req("GET", "/forge/casey/demo/blocktrails.json"),
1518                ForgeAgent::Anonymous,
1519            )
1520            .await
1521            .unwrap();
1522        assert_eq!(none.status, 404);
1523        assert_eq!(
1524            header(&none, "access-control-allow-origin").as_deref(),
1525            Some("*")
1526        );
1527        assert!(String::from_utf8_lossy(&none.body).contains("anchoring is not enabled"));
1528
1529        // Commit the trail the way the git-mark CLI writes it, push it, serve it.
1530        let (c0, c1) = ("1".repeat(40), "2".repeat(40));
1531        let doc = serde_json::json!({
1532            "@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
1533            "pubkeyBase": "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa",
1534            "chain": "txbt4", "states": [c0, c1],
1535            "txo": [
1536                format!("txo:txbt4:{}:0?amount=9700&commit={c0}", "ab".repeat(32)),
1537                format!("txo:txbt4:{}:0?amount=9400&commit={c1}", "cd".repeat(32)),
1538            ],
1539        });
1540        let work = td.path().join("work-casey-agent");
1541        std::fs::write(work.join("blocktrails.json"), doc.to_string()).unwrap();
1542        run_git(&work, &["add", "-A"]);
1543        run_git(
1544            &work,
1545            &[
1546                "-c",
1547                "user.email=t@e.st",
1548                "-c",
1549                "user.name=Tester",
1550                "commit",
1551                "-m",
1552                "trail",
1553            ],
1554        );
1555        let bare = td.path().join("repos").join("casey").join("agent.git");
1556        run_git(&work, &["push", bare.to_str().unwrap(), "main"]);
1557
1558        let held = svc
1559            .handle(
1560                req("GET", "/forge/casey/agent/blocktrails.json"),
1561                ForgeAgent::Anonymous,
1562            )
1563            .await
1564            .unwrap();
1565        assert_eq!(held.status, 200);
1566        assert_eq!(
1567            header(&held, "access-control-allow-origin").as_deref(),
1568            Some("*")
1569        );
1570        assert_eq!(
1571            header(&held, "x-content-type-options").as_deref(),
1572            Some("nosniff")
1573        );
1574        let served: serde_json::Value = serde_json::from_slice(&held.body).unwrap();
1575        assert_eq!(served["profile"], "gitmark");
1576        assert_eq!(served["states"], serde_json::json!([c0, c1]));
1577        assert_eq!(served["pubkeyBase"], doc["pubkeyBase"]);
1578    }
1579
1580    #[tokio::test]
1581    async fn api_mirror_is_404_for_a_repo_that_is_not_a_mirror() {
1582        let (_td, svc) = service();
1583        let r = svc
1584            .handle(
1585                req(
1586                    "GET",
1587                    &format!("/forge/api/repos/{}/agent/mirror", "ab".repeat(32)),
1588                ),
1589                ForgeAgent::Anonymous,
1590            )
1591            .await
1592            .unwrap();
1593        assert_eq!(r.status, 404);
1594        assert!(String::from_utf8_lossy(&r.body).contains("not a mirror"));
1595    }
1596
1597    /// A followed repository has a record before it has a bare repository
1598    /// (JSS plugins bd55eda): the diagnosis is served ahead of the
1599    /// repo-exists guard.
1600    #[cfg(feature = "mirror")]
1601    #[tokio::test]
1602    async fn api_mirror_serves_the_record_before_the_repo_exists() {
1603        let (td, svc) = service();
1604        let pubkey = "ab".repeat(32);
1605        let dir = td.path().join("mirror").join(&pubkey);
1606        std::fs::create_dir_all(&dir).unwrap();
1607        std::fs::write(
1608            dir.join("agent.json"),
1609            serde_json::json!({
1610                "pubkey": pubkey, "name": "agent", "clone": null, "events": 1,
1611                "lastSeen": null, "lastEvent": null, "lastFetchAt": null,
1612                "lastFetch": null, "announceAt": null, "clones": []
1613            })
1614            .to_string(),
1615        )
1616        .unwrap();
1617        assert!(!td
1618            .path()
1619            .join("repos")
1620            .join(&pubkey)
1621            .join("agent.git")
1622            .exists());
1623        let r = svc
1624            .handle(
1625                req("GET", &format!("/forge/api/repos/{pubkey}/agent/mirror")),
1626                ForgeAgent::Anonymous,
1627            )
1628            .await
1629            .unwrap();
1630        assert_eq!(r.status, 200);
1631        let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
1632        assert_eq!(v["name"], "agent");
1633        assert_eq!(v["events"], 1);
1634    }
1635
1636    #[tokio::test]
1637    async fn tree_blob_raw_roundtrip() {
1638        if !git_available() {
1639            return;
1640        }
1641        let (td, svc) = service();
1642        seed_repo(&td, "alice", "demo");
1643
1644        // Subdirectory listing.
1645        let (s1, tree) = body(&svc, "/forge/alice/demo/tree/main/src").await;
1646        assert_eq!(s1, 200);
1647        assert!(tree.contains("lib.rs"));
1648
1649        // Blob view renders textual content (escaped).
1650        let (s2, blob) = body(&svc, "/forge/alice/demo/blob/main/src/lib.rs").await;
1651        assert_eq!(s2, 200);
1652        assert!(blob.contains("pub fn f"));
1653
1654        // Raw serves text/plain with nosniff.
1655        let r = svc
1656            .handle(
1657                req("GET", "/forge/alice/demo/raw/main/src/lib.rs"),
1658                ForgeAgent::Anonymous,
1659            )
1660            .await
1661            .unwrap();
1662        assert_eq!(r.status, 200);
1663        let ct = r
1664            .headers
1665            .iter()
1666            .find(|(k, _)| k == "content-type")
1667            .map(|(_, v)| v.clone())
1668            .unwrap();
1669        assert!(ct.starts_with("text/plain"));
1670        assert_eq!(
1671            String::from_utf8_lossy(&r.body),
1672            "pub fn f() -> u8 { 42 }\n"
1673        );
1674    }
1675
1676    #[tokio::test]
1677    async fn commits_branches_tags_commit_view() {
1678        if !git_available() {
1679            return;
1680        }
1681        let (td, svc) = service();
1682        seed_repo(&td, "alice", "demo");
1683
1684        let (s1, commits) = body(&svc, "/forge/alice/demo/commits/main").await;
1685        assert_eq!(s1, 200);
1686        assert!(commits.contains("initial commit"));
1687
1688        let (s2, branches) = body(&svc, "/forge/alice/demo/branches").await;
1689        assert_eq!(s2, 200);
1690        assert!(branches.contains("main"));
1691        assert!(branches.contains("default"));
1692
1693        let (s3, tags) = body(&svc, "/forge/alice/demo/tags").await;
1694        assert_eq!(s3, 200);
1695        assert!(tags.contains("v1.0"));
1696
1697        // Resolve the HEAD sha, then view that commit.
1698        let dir = td.path().join("repos/alice/demo.git");
1699        let out = std::process::Command::new("git")
1700            .args(["rev-parse", "HEAD"])
1701            .current_dir(&dir)
1702            .output()
1703            .unwrap();
1704        let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
1705        let (s4, commit) = body(&svc, &format!("/forge/alice/demo/commit/{sha}")).await;
1706        assert_eq!(s4, 200);
1707        assert!(commit.contains("initial commit"));
1708        assert!(commit.contains("README.md"));
1709    }
1710
1711    #[tokio::test]
1712    async fn browse_rejects_bad_ref_and_missing_repo() {
1713        if !git_available() {
1714            return;
1715        }
1716        let (td, svc) = service();
1717        seed_repo(&td, "alice", "demo");
1718
1719        // Missing repo → 404.
1720        let (s1, _) = body(&svc, "/forge/alice/ghost").await;
1721        assert_eq!(s1, 404);
1722
1723        // Flag-injection ref → 400.
1724        let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
1725        assert_eq!(s2, 400);
1726
1727        // Unknown path in a valid ref → 404.
1728        let (s3, _) = body(&svc, "/forge/alice/demo/blob/main/nope.txt").await;
1729        assert_eq!(s3, 404);
1730    }
1731
1732    #[tokio::test]
1733    async fn empty_repo_overview_renders() {
1734        if !git_available() {
1735            return;
1736        }
1737        let (td, svc) = service();
1738        // A bare repo with no commits.
1739        let bare = td.path().join("repos/bob/empty.git");
1740        std::fs::create_dir_all(&bare).unwrap();
1741        run_git(
1742            td.path(),
1743            &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
1744        );
1745        let (status, html) = body(&svc, "/forge/bob/empty").await;
1746        assert_eq!(status, 200);
1747        assert!(html.contains("empty"));
1748    }
1749
1750    // ---- Phase 2 issues + spine ----------------------------------------
1751
1752    use std::collections::HashMap;
1753    use std::sync::Mutex;
1754
1755    struct MockLb {
1756        present: Mutex<HashMap<String, Vec<u8>>>,
1757    }
1758    impl MockLb {
1759        fn new() -> Self {
1760            Self {
1761                present: Mutex::new(HashMap::new()),
1762            }
1763        }
1764        fn put(&self, url: &str, body: &[u8]) {
1765            self.present
1766                .lock()
1767                .unwrap()
1768                .insert(url.into(), body.to_vec());
1769        }
1770        fn remove(&self, url: &str) {
1771            self.present.lock().unwrap().remove(url);
1772        }
1773    }
1774    #[async_trait::async_trait]
1775    impl bodies::LoopbackFetch for MockLb {
1776        async fn get(&self, url: &str, _max: usize, _to: u64) -> bodies::FetchResult {
1777            match self.present.lock().unwrap().get(url) {
1778                Some(b) => bodies::FetchResult::Body(b.clone()),
1779                None => bodies::FetchResult::Removed,
1780            }
1781        }
1782    }
1783
1784    fn pod_agent(user: &str) -> ForgeAgent {
1785        ForgeAgent::Pod {
1786            webid: format!("https://pod.example/{user}/profile/card#me"),
1787            username: user.to_string(),
1788        }
1789    }
1790
1791    fn post_form(path: &str, form: &str) -> ForgeRequest {
1792        ForgeRequest {
1793            method: "POST".into(),
1794            path: path.into(),
1795            query: String::new(),
1796            headers: vec![(
1797                "content-type".into(),
1798                "application/x-www-form-urlencoded".into(),
1799            )],
1800            raw_body: Bytes::from(form.to_string()),
1801            host_url: Some("https://pod.example".into()),
1802        }
1803    }
1804
1805    /// Build a git-seeded service with a mock loopback injected.
1806    fn issues_service() -> (TempDir, ForgeService, Arc<MockLb>) {
1807        let td = TempDir::new().unwrap();
1808        let mock = Arc::new(MockLb::new());
1809        let svc = ForgeService::new(ForgeConfig::default(), td.path())
1810            .unwrap()
1811            .with_loopback(mock.clone() as Arc<dyn bodies::LoopbackFetch>);
1812        (td, svc, mock)
1813    }
1814
1815    #[tokio::test]
1816    async fn issue_create_list_detail_and_deletion() {
1817        if !git_available() {
1818            return;
1819        }
1820        let (td, svc, mock) = issues_service();
1821        seed_repo(&td, "alice", "demo");
1822
1823        let url = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
1824        mock.put(url, b"the issue body text");
1825
1826        // Create.
1827        let r = svc
1828            .handle(
1829                post_form(
1830                    "/forge/alice/demo/issues",
1831                    &format!(
1832                        "title=First+bug&resourceUrl={}",
1833                        url.replace(':', "%3A").replace('/', "%2F")
1834                    ),
1835                ),
1836                pod_agent("alice"),
1837            )
1838            .await
1839            .unwrap();
1840        assert_eq!(r.status, 303);
1841        let loc = r
1842            .headers
1843            .iter()
1844            .find(|(k, _)| k.eq_ignore_ascii_case("location"))
1845            .map(|(_, v)| v.clone())
1846            .unwrap();
1847        assert_eq!(loc, "/forge/alice/demo/issues/1");
1848
1849        // List shows it.
1850        let (s, list) = body(&svc, "/forge/alice/demo/issues").await;
1851        assert_eq!(s, 200);
1852        assert!(list.contains("First bug"));
1853        assert!(list.contains("1 open"));
1854
1855        // Detail re-fetches the body.
1856        let (s2, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1857        assert_eq!(s2, 200);
1858        assert!(detail.contains("the issue body text"));
1859
1860        // Author deletes the pod body → detail shows the removed notice.
1861        mock.remove(url);
1862        let (_s3, detail2) = body(&svc, "/forge/alice/demo/issues/1").await;
1863        assert!(detail2.contains("content removed by its author"));
1864    }
1865
1866    #[tokio::test]
1867    async fn issue_create_rejects_foreign_area_and_ssrf() {
1868        if !git_available() {
1869            return;
1870        }
1871        let (td, svc, mock) = issues_service();
1872        seed_repo(&td, "alice", "demo");
1873        mock.put(
1874            "https://pod.example/bob/public/forge/alice--demo/x.jsonld",
1875            b"body",
1876        );
1877
1878        // alice pointing at bob's pod area → 403 (own-area guard).
1879        let bad = "https://pod.example/bob/public/forge/alice--demo/x.jsonld";
1880        let r = svc
1881            .handle(
1882                post_form(
1883                    "/forge/alice/demo/issues",
1884                    &format!(
1885                        "title=x&resourceUrl={}",
1886                        bad.replace(':', "%3A").replace('/', "%2F")
1887                    ),
1888                ),
1889                pod_agent("alice"),
1890            )
1891            .await
1892            .unwrap();
1893        assert_eq!(r.status, 403);
1894
1895        // A cross-origin SSRF target → rejected before any fetch.
1896        let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
1897        let r2 = svc
1898            .handle(
1899                post_form(
1900                    "/forge/alice/demo/issues",
1901                    &format!(
1902                        "title=x&resourceUrl={}",
1903                        ssrf.replace(':', "%3A").replace('/', "%2F")
1904                    ),
1905                ),
1906                pod_agent("alice"),
1907            )
1908            .await
1909            .unwrap();
1910        assert!(r2.status == 403 || r2.status == 400);
1911    }
1912
1913    #[tokio::test]
1914    async fn comment_appends_to_thread() {
1915        if !git_available() {
1916            return;
1917        }
1918        let (td, svc, mock) = issues_service();
1919        seed_repo(&td, "alice", "demo");
1920
1921        let open = "https://pod.example/alice/public/forge/alice--demo/i1.jsonld";
1922        mock.put(open, b"opening");
1923        svc.handle(
1924            post_form(
1925                "/forge/alice/demo/issues",
1926                &format!(
1927                    "title=t&resourceUrl={}",
1928                    open.replace(':', "%3A").replace('/', "%2F")
1929                ),
1930            ),
1931            pod_agent("alice"),
1932        )
1933        .await
1934        .unwrap();
1935
1936        // bob comments — his body lives in HIS pod, namespaced by the repo.
1937        let c = "https://pod.example/bob/public/forge/alice--demo/c1.jsonld";
1938        mock.put(c, b"a helpful comment");
1939        let r = svc
1940            .handle(
1941                post_form(
1942                    "/forge/alice/demo/issues/1",
1943                    &format!("resourceUrl={}", c.replace(':', "%3A").replace('/', "%2F")),
1944                ),
1945                pod_agent("bob"),
1946            )
1947            .await
1948            .unwrap();
1949        assert_eq!(r.status, 303);
1950
1951        let (_s, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1952        assert!(detail.contains("opening"));
1953        assert!(detail.contains("a helpful comment"));
1954        assert!(detail.contains("commented"));
1955    }
1956
1957    #[tokio::test]
1958    async fn issue_create_without_loopback_fails_closed() {
1959        if !git_available() {
1960            return;
1961        }
1962        // No loopback injected → pod-body verification unavailable (501).
1963        let (td, svc) = service();
1964        seed_repo(&td, "alice", "demo");
1965        let url = "https://pod.example/alice/public/forge/alice--demo/x.jsonld";
1966        let r = svc
1967            .handle(
1968                post_form(
1969                    "/forge/alice/demo/issues",
1970                    &format!(
1971                        "title=x&resourceUrl={}",
1972                        url.replace(':', "%3A").replace('/', "%2F")
1973                    ),
1974                ),
1975                pod_agent("alice"),
1976            )
1977            .await
1978            .unwrap();
1979        assert_eq!(r.status, 501);
1980    }
1981
1982    // ---- Phase 3 tokens + hosted + namespace guard ---------------------
1983
1984    fn hex64() -> String {
1985        "d".repeat(64)
1986    }
1987
1988    fn nostr_agent(hex: &str) -> ForgeAgent {
1989        ForgeAgent::Nostr {
1990            pubkey_hex: hex.to_string(),
1991        }
1992    }
1993
1994    fn post_json(path: &str, json: &str) -> ForgeRequest {
1995        ForgeRequest {
1996            method: "POST".into(),
1997            path: path.into(),
1998            query: String::new(),
1999            headers: vec![("content-type".into(), "application/json".into())],
2000            raw_body: Bytes::from(json.to_string()),
2001            host_url: Some("https://pod.example".into()),
2002        }
2003    }
2004
2005    #[tokio::test]
2006    async fn token_mint_then_resolves() {
2007        let (_td, svc) = service();
2008        // Mint a token for a NIP-98-authenticated nostr caller.
2009        let agent = nostr_agent(&hex64());
2010        let r = svc
2011            .handle(post_form("/forge/api/token", ""), agent.clone())
2012            .await
2013            .unwrap();
2014        assert_eq!(r.status, 200);
2015        let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
2016        let token = v["token"].as_str().unwrap().to_string();
2017        assert!(token.starts_with("f1."));
2018
2019        // Present the token on a later request → same identity resolves.
2020        let follow = ForgeRequest {
2021            method: "GET".into(),
2022            path: "/forge/x".into(),
2023            query: String::new(),
2024            headers: vec![("authorization".into(), format!("Bearer {token}"))],
2025            raw_body: Bytes::new(),
2026            host_url: Some("https://pod.example".into()),
2027        };
2028        assert_eq!(svc.resolve_agent(&follow).await, agent);
2029    }
2030
2031    #[tokio::test]
2032    async fn token_mint_anonymous_is_401() {
2033        let (_td, svc) = service();
2034        let r = svc
2035            .handle(post_form("/forge/api/token", ""), ForgeAgent::Anonymous)
2036            .await
2037            .unwrap();
2038        assert_eq!(r.status, 401);
2039    }
2040
2041    #[tokio::test]
2042    async fn podless_nostr_issue_uses_hosted_store() {
2043        if !git_available() {
2044            return;
2045        }
2046        let (td, svc) = service();
2047        let hex = hex64();
2048        seed_repo(&td, &hex, "proj");
2049
2050        // No pod: submit the body inline; the forge hosts it.
2051        let r = svc
2052            .handle(
2053                post_json(
2054                    &format!("/forge/{hex}/proj/issues"),
2055                    "{\"title\":\"podless bug\",\"body\":\"no pod here\"}",
2056                ),
2057                nostr_agent(&hex),
2058            )
2059            .await
2060            .unwrap();
2061        assert_eq!(r.status, 303, "podless issue create should redirect");
2062
2063        // Detail re-fetches from the hosted store (no loopback needed).
2064        let (s, detail) = body(&svc, &format!("/forge/{hex}/proj/issues/1")).await;
2065        assert_eq!(s, 200);
2066        assert!(detail.contains("no pod here"));
2067        assert!(detail.contains("podless bug"));
2068    }
2069
2070    #[tokio::test]
2071    async fn hosted_api_get_and_owner_only_delete() {
2072        let (td, svc) = service();
2073        let hex = hex64();
2074        // Write a hosted body directly through a store on the same dir.
2075        let store = HostedStore::new(td.path());
2076        let rref = store.write(&hex, b"{\"body\":\"hi\"}").await.unwrap();
2077        let (h, u) = HostedStore::parse_ref(&rref).unwrap();
2078
2079        // Public GET.
2080        let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
2081        assert_eq!(s, 200);
2082        assert!(got.contains("hi"));
2083
2084        // A non-owner DELETE is 403.
2085        let del_other = ForgeRequest {
2086            method: "DELETE".into(),
2087            path: format!("/forge/api/hosted/{h}/{u}"),
2088            query: String::new(),
2089            headers: vec![],
2090            raw_body: Bytes::new(),
2091            host_url: Some("https://pod.example".into()),
2092        };
2093        let r1 = svc
2094            .handle(del_other.clone(), nostr_agent(&"e".repeat(64)))
2095            .await
2096            .unwrap();
2097        assert_eq!(r1.status, 403);
2098
2099        // The owner DELETE succeeds (200 {"deleted":true}, as JSS), then the body is gone (404).
2100        let r2 = svc.handle(del_other, nostr_agent(&hex)).await.unwrap();
2101        assert_eq!(r2.status, 200);
2102        let (s2, _) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
2103        assert_eq!(s2, 404);
2104    }
2105
2106    #[tokio::test]
2107    async fn namespace_push_guard_blocks_foreign_push() {
2108        let (_td, svc) = service();
2109        // A receive-pack (push) into alice's namespace by bob → 403,
2110        // before the CGI is ever invoked.
2111        let push = ForgeRequest {
2112            method: "POST".into(),
2113            path: "/forge/alice/demo.git/git-receive-pack".into(),
2114            query: String::new(),
2115            headers: vec![],
2116            raw_body: Bytes::new(),
2117            host_url: Some("https://pod.example".into()),
2118        };
2119        let r = svc
2120            .handle(push.clone(), nostr_agent(&hex64()))
2121            .await
2122            .unwrap();
2123        assert_eq!(r.status, 403);
2124
2125        // Anonymous push is also blocked.
2126        let r_anon = svc.handle(push, ForgeAgent::Anonymous).await.unwrap();
2127        assert_eq!(r_anon.status, 403);
2128    }
2129
2130    #[tokio::test]
2131    async fn namespace_push_guard_allows_own_push() {
2132        // A push into the caller's OWN namespace passes the guard (it then
2133        // reaches the CGI, which is absent here → a non-403 backend error).
2134        let (_td, svc) = service();
2135        let hex = hex64();
2136        let push = ForgeRequest {
2137            method: "POST".into(),
2138            path: format!("/forge/{hex}/demo.git/git-receive-pack"),
2139            query: String::new(),
2140            headers: vec![],
2141            raw_body: Bytes::new(),
2142            host_url: Some("https://pod.example".into()),
2143        };
2144        let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
2145        assert_ne!(r.status, 403, "own-namespace push must pass the guard");
2146    }
2147
2148    #[cfg(feature = "mirror")]
2149    #[tokio::test]
2150    async fn a_mirrored_repository_refuses_even_its_owners_push() {
2151        let (td, svc) = service();
2152        let hex = hex64();
2153        let dir = td.path().join("mirror").join(&hex);
2154        std::fs::create_dir_all(&dir).unwrap();
2155        std::fs::write(
2156            dir.join("demo.json"),
2157            serde_json::json!({
2158                "pubkey": hex, "name": "demo", "clone": null, "events": 1,
2159                "lastSeen": null, "lastEvent": null, "lastFetchAt": null,
2160                "lastFetch": null, "announceAt": null, "clones": []
2161            })
2162            .to_string(),
2163        )
2164        .unwrap();
2165        let push = ForgeRequest {
2166            method: "POST".into(),
2167            path: format!("/forge/{hex}/demo.git/git-receive-pack"),
2168            query: String::new(),
2169            headers: vec![],
2170            raw_body: Bytes::new(),
2171            host_url: Some("https://pod.example".into()),
2172        };
2173        let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
2174        assert_eq!(r.status, 403);
2175        assert!(String::from_utf8_lossy(&r.body).contains("read-only"));
2176    }
2177}