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 auth;
48pub mod bodies;
49pub mod config;
50pub mod error;
51pub mod hosted;
52pub mod html;
53pub mod ownership;
54pub mod repo;
55pub mod request;
56pub mod router;
57pub mod spine;
58pub mod token;
59
60use std::path::{Path, PathBuf};
61use std::sync::Arc;
62use std::time::{SystemTime, UNIX_EPOCH};
63
64use solid_pod_rs_git::service::{GitHttpService, GitRequest};
65
66pub use bodies::{HostedReader, LoopbackFetch};
67pub use config::ForgeConfig;
68pub use error::ForgeError;
69pub use hosted::HostedStore;
70pub use ownership::ForgeAgent;
71pub use request::{esc, ForgeRequest, ForgeResponse};
72pub use router::{parse_route, Route};
73pub use spine::{FsSpineStore, SpineStore};
74pub use token::TokenError;
75
76/// Convenience import surface.
77pub mod prelude {
78    pub use crate::config::ForgeConfig;
79    pub use crate::error::ForgeError;
80    pub use crate::ownership::ForgeAgent;
81    pub use crate::request::{ForgeRequest, ForgeResponse};
82    pub use crate::ForgeService;
83}
84
85/// Current wall-clock time in Unix seconds.
86fn now_secs() -> u64 {
87    SystemTime::now()
88        .duration_since(UNIX_EPOCH)
89        .map(|d| d.as_secs())
90        .unwrap_or(0)
91}
92
93/// Load the persisted forge instance HMAC key, or generate + persist a
94/// fresh one (`0600`). The key is 32 bytes of OS randomness (two v4
95/// UUIDs, each 122 bits of entropy from the platform RNG). Persisting it
96/// keeps minted tokens valid across restarts.
97fn load_or_create_token_key(plugin_dir: &Path) -> Result<[u8; 32], ForgeError> {
98    let path = plugin_dir.join(".forge-token-key");
99    if let Ok(bytes) = std::fs::read(&path) {
100        if bytes.len() == 32 {
101            let mut key = [0u8; 32];
102            key.copy_from_slice(&bytes);
103            return Ok(key);
104        }
105    }
106    let mut key = [0u8; 32];
107    key[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
108    key[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
109    std::fs::write(&path, key)?;
110    #[cfg(unix)]
111    {
112        use std::os::unix::fs::PermissionsExt;
113        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
114    }
115    Ok(key)
116}
117
118/// The forge service. One instance is built at server startup and shared
119/// (`Arc`) across requests. All durable state lives under `plugin_dir`.
120#[derive(Clone)]
121pub struct ForgeService {
122    cfg: ForgeConfig,
123    plugin_dir: PathBuf,
124    repo_root: PathBuf,
125    git: Arc<GitHttpService>,
126    spine: Arc<dyn SpineStore>,
127    /// Loopback GET for verifying/re-fetching pod-hosted bodies. `None`
128    /// until the server injects its reqwest-backed client; without it,
129    /// pod-hosted issue writes are refused (fail-closed).
130    loopback: Option<Arc<dyn LoopbackFetch>>,
131    /// Forge-hosted body store for podless `did:nostr` agents (always
132    /// available — it is local `0600` storage under the plugin dir).
133    hosted: Arc<HostedStore>,
134    /// Forge instance HMAC key for push tokens (persisted `0600`).
135    token_key: Arc<[u8; 32]>,
136    /// Per-service single-use guard for NIP-98 event ids.
137    nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache,
138}
139
140impl std::fmt::Debug for ForgeService {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("ForgeService")
143            .field("prefix", &self.cfg.normalized_prefix())
144            .field("plugin_dir", &self.plugin_dir)
145            .finish()
146    }
147}
148
149impl ForgeService {
150    /// Build a forge rooted at `plugin_dir`. Creates the on-disk layout
151    /// (`repos/`, `issues/`, `pulls/`, `hosted/`, `marks/`) if absent.
152    /// The git CGI service is rooted at `plugin_dir/repos`.
153    pub fn new(cfg: ForgeConfig, plugin_dir: impl Into<PathBuf>) -> Result<Self, ForgeError> {
154        let plugin_dir = plugin_dir.into();
155        let repo_root = plugin_dir.join("repos");
156        for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
157            std::fs::create_dir_all(plugin_dir.join(sub))?;
158        }
159        let git = Arc::new(GitHttpService::new(repo_root.clone()));
160        let spine: Arc<dyn SpineStore> = Arc::new(FsSpineStore::new(plugin_dir.clone()));
161        let hosted = Arc::new(HostedStore::new(plugin_dir.clone()));
162        let token_key = Arc::new(load_or_create_token_key(&plugin_dir)?);
163        Ok(Self {
164            cfg,
165            plugin_dir,
166            repo_root,
167            git,
168            spine,
169            loopback: None,
170            hosted,
171            token_key,
172            nip98_replay: solid_pod_rs::auth::replay::Nip98ReplayCache::from_env(),
173        })
174    }
175
176    /// Inject the loopback fetcher (the server's reqwest client) used to
177    /// verify and re-fetch pod-hosted bodies.
178    #[must_use]
179    pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
180        self.loopback = Some(lb);
181        self
182    }
183
184    /// Override the spine store (tests inject an in-memory store).
185    #[must_use]
186    pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
187        self.spine = spine;
188        self
189    }
190
191    /// The forge instance HMAC key (for token minting/verification).
192    #[must_use]
193    pub fn token_key(&self) -> &[u8; 32] {
194        &self.token_key
195    }
196
197    /// Resolve the caller identity from `req`'s `Authorization` header
198    /// (forge token or NIP-98). The server may instead pass a
199    /// [`ForgeAgent::Pod`] directly to [`Self::handle`] when it has a pod
200    /// session; this helper is for the token/NIP-98 schemes.
201    pub async fn resolve_agent(&self, req: &ForgeRequest) -> ForgeAgent {
202        auth::resolve_agent(req, self.token_key.as_ref(), &self.nip98_replay, now_secs()).await
203    }
204
205    /// The configured, normalised URL prefix (`/forge`).
206    #[must_use]
207    pub fn prefix(&self) -> String {
208        self.cfg.normalized_prefix()
209    }
210
211    /// The plugin data directory.
212    #[must_use]
213    pub fn plugin_dir(&self) -> &Path {
214        &self.plugin_dir
215    }
216
217    /// Single entry point. The embedding server maps its native request
218    /// to a [`ForgeRequest`], WAC-gates the scope, and calls this.
219    pub async fn handle(
220        &self,
221        req: ForgeRequest,
222        agent: ForgeAgent,
223    ) -> Result<ForgeResponse, ForgeError> {
224        // CORS preflight.
225        if req.method.eq_ignore_ascii_case("OPTIONS") {
226            return Ok(ForgeResponse {
227                status: 204,
228                headers: vec![
229                    ("access-control-allow-origin".into(), "*".into()),
230                    (
231                        "access-control-allow-methods".into(),
232                        "GET, POST, PUT, DELETE, OPTIONS".into(),
233                    ),
234                    (
235                        "access-control-allow-headers".into(),
236                        "Content-Type, Authorization".into(),
237                    ),
238                ],
239                body: bytes::Bytes::new(),
240            });
241        }
242
243        let prefix = self.cfg.normalized_prefix();
244        let Some(rel) = router::strip_prefix(&prefix, &req.path) else {
245            return Ok(ForgeResponse::error(404, "not under forge prefix"));
246        };
247        let route = parse_route(&rel);
248
249        let result = self.dispatch(route, &req, &agent).await;
250        Ok(result.unwrap_or_else(|e| e.to_response()))
251    }
252
253    /// Route → handler. Handlers return `Result` and errors are mapped to
254    /// responses by the caller.
255    async fn dispatch(
256        &self,
257        route: Route,
258        req: &ForgeRequest,
259        _agent: &ForgeAgent,
260    ) -> Result<ForgeResponse, ForgeError> {
261        match route {
262            Route::Index => self.h_index().await,
263            Route::OwnerIndex { owner } => self.h_owner(&owner).await,
264            Route::GitSmart { rel_path } => self.h_git_smart(req, _agent, &rel_path).await,
265            Route::RepoOverview { owner, repo } => self.h_overview(&owner, &repo).await,
266            Route::Tree {
267                owner,
268                repo,
269                rev,
270                path,
271            } => self.h_tree(&owner, &repo, &rev, &path).await,
272            Route::Blob {
273                owner,
274                repo,
275                rev,
276                path,
277            } => self.h_blob(&owner, &repo, &rev, &path).await,
278            Route::Raw {
279                owner,
280                repo,
281                rev,
282                path,
283            } => self.h_raw(&owner, &repo, &rev, &path).await,
284            Route::Commits { owner, repo, rev } => self.h_commits(req, &owner, &repo, &rev).await,
285            Route::Commit { owner, repo, sha } => self.h_commit(&owner, &repo, &sha).await,
286            Route::Branches { owner, repo } => self.h_branches(&owner, &repo).await,
287            Route::Tags { owner, repo } => self.h_tags(&owner, &repo).await,
288            Route::Issues { owner, repo } => {
289                if req.method.eq_ignore_ascii_case("POST") {
290                    self.h_issue_create(req, _agent, &owner, &repo).await
291                } else {
292                    self.h_issues_list(req, &owner, &repo).await
293                }
294            }
295            Route::IssueNew { owner, repo } => self.h_issue_new_form(&owner, &repo).await,
296            Route::IssueDetail { owner, repo, num } => {
297                if req.method.eq_ignore_ascii_case("POST") {
298                    self.h_issue_comment(req, _agent, &owner, &repo, num).await
299                } else {
300                    self.h_issue_detail(&owner, &repo, num).await
301                }
302            }
303            Route::ApiToken => self.h_api_token(req, _agent).await,
304            Route::ApiHosted { hex, id } => self.h_api_hosted(req, _agent, &hex, &id).await,
305            // The remaining routes are implemented in later phases.
306            _ => Err(ForgeError::NotFound("not implemented".into())),
307        }
308    }
309
310    /// Resolve `<owner>/<repo>` to its bare git-dir, 404ing when the repo
311    /// does not exist. Validates both segments against traversal.
312    async fn resolve_repo(&self, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
313        let dir = repo::repo_git_dir(&self.repo_root, owner, repo)?;
314        if !tokio::fs::metadata(&dir)
315            .await
316            .map(|m| m.is_dir())
317            .unwrap_or(false)
318        {
319            return Err(ForgeError::NotFound(format!("repo {owner}/{repo}")));
320        }
321        Ok(dir)
322    }
323
324    // ---- Tier 1: index + git smart-HTTP ---------------------------------
325
326    async fn h_index(&self) -> Result<ForgeResponse, ForgeError> {
327        let repos = repo::list_all(&self.repo_root).await;
328        Ok(ForgeResponse::html(
329            200,
330            html::index_page(&self.prefix(), &repos),
331        ))
332    }
333
334    async fn h_owner(&self, owner: &str) -> Result<ForgeResponse, ForgeError> {
335        let repos = repo::list_owner(&self.repo_root, owner).await;
336        if repos.is_empty() {
337            return Err(ForgeError::NotFound(format!("owner {owner}")));
338        }
339        Ok(ForgeResponse::html(
340            200,
341            html::owner_page(&self.prefix(), owner, &repos),
342        ))
343    }
344
345    /// Forward a git smart-HTTP request to the reused CGI service. The
346    /// server has already WAC-gated the scope; auth for the CGI itself is
347    /// the pod's existing `Basic nostr:`/NIP-98 path inside the git crate.
348    async fn h_git_smart(
349        &self,
350        req: &ForgeRequest,
351        agent: &ForgeAgent,
352        rel_path: &str,
353    ) -> Result<ForgeResponse, ForgeError> {
354        let git_req = GitRequest {
355            method: req.method.clone(),
356            path: rel_path.to_string(),
357            query: req.query.clone(),
358            headers: req.headers.clone(),
359            body: req.raw_body.clone(),
360            host_url: req.host_url.clone(),
361        };
362        // Namespace push guard: a push (receive-pack) may only target the
363        // caller's own namespace. Pushing into another owner's namespace
364        // is `403` (JSS forge namespace guard). Reads are unaffected here;
365        // the server's WAC gate governs private-repo read access.
366        if git_req.is_write() {
367            let owner = rel_path
368                .trim_start_matches('/')
369                .split('/')
370                .next()
371                .unwrap_or("");
372            if !agent.can_write_namespace(owner) {
373                return Err(ForgeError::Forbidden(format!(
374                    "cannot push into namespace '{owner}'"
375                )));
376            }
377        }
378        let resp = self.git.handle(git_req).await?;
379        Ok(ForgeResponse {
380            status: resp.status,
381            headers: resp.headers,
382            body: resp.body,
383        })
384    }
385
386    // ---- Tier 1: browse -------------------------------------------------
387
388    async fn h_overview(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
389        let dir = self.resolve_repo(owner, repo).await?;
390        let prefix = self.prefix();
391        if !repo::browse::has_commits(&dir).await {
392            return Ok(ForgeResponse::html(
393                200,
394                html::repo_overview_page(&prefix, owner, repo, "main", &[], None),
395            ));
396        }
397        let branch = repo::browse::default_branch(&dir).await;
398        let entries = repo::browse::list_tree(&dir, &branch, "").await?;
399        // README detection (case-insensitive common names).
400        let readme = self.find_readme(&dir, &branch, &entries).await;
401        Ok(ForgeResponse::html(
402            200,
403            html::repo_overview_page(&prefix, owner, repo, &branch, &entries, readme.as_deref()),
404        ))
405    }
406
407    /// Read a bounded, textual README from the root tree if one exists.
408    async fn find_readme(
409        &self,
410        dir: &Path,
411        rev: &str,
412        entries: &[repo::browse::TreeEntry],
413    ) -> Option<String> {
414        let candidate = entries.iter().find(|e| {
415            e.kind == repo::browse::EntryKind::File && {
416                let n = e.name.to_ascii_lowercase();
417                n == "readme" || n == "readme.md" || n == "readme.txt"
418            }
419        })?;
420        let bytes =
421            repo::browse::read_blob(dir, rev, &candidate.name, self.cfg.max_body_bytes as u64)
422                .await
423                .ok()?;
424        if request::looks_textual(&bytes) {
425            Some(String::from_utf8_lossy(&bytes).into_owned())
426        } else {
427            None
428        }
429    }
430
431    async fn h_tree(
432        &self,
433        owner: &str,
434        repo: &str,
435        rev: &str,
436        path: &str,
437    ) -> Result<ForgeResponse, ForgeError> {
438        let dir = self.resolve_repo(owner, repo).await?;
439        let entries = repo::browse::list_tree(&dir, rev, path).await?;
440        Ok(ForgeResponse::html(
441            200,
442            html::tree_page(&self.prefix(), owner, repo, rev, path, &entries),
443        ))
444    }
445
446    async fn h_blob(
447        &self,
448        owner: &str,
449        repo: &str,
450        rev: &str,
451        path: &str,
452    ) -> Result<ForgeResponse, ForgeError> {
453        let dir = self.resolve_repo(owner, repo).await?;
454        // Cap the in-browser render; oversized/binary blobs get a download
455        // link instead of buffering into the page.
456        const BLOB_VIEW_MAX: u64 = 1024 * 1024;
457        let content = match repo::browse::read_blob(&dir, rev, path, BLOB_VIEW_MAX).await {
458            Ok(bytes) => {
459                if request::looks_textual(&bytes) {
460                    Some(String::from_utf8_lossy(&bytes).into_owned())
461                } else {
462                    None
463                }
464            }
465            // Too large to inline — offer the raw download.
466            Err(ForgeError::BadRequest(_)) => None,
467            Err(e) => return Err(e),
468        };
469        Ok(ForgeResponse::html(
470            200,
471            html::blob_page(&self.prefix(), owner, repo, rev, path, content.as_deref()),
472        ))
473    }
474
475    async fn h_raw(
476        &self,
477        owner: &str,
478        repo: &str,
479        rev: &str,
480        path: &str,
481    ) -> Result<ForgeResponse, ForgeError> {
482        let dir = self.resolve_repo(owner, repo).await?;
483        // Generous cap for raw downloads; still bounded to avoid OOM.
484        const RAW_MAX: u64 = 25 * 1024 * 1024;
485        let bytes = repo::browse::read_blob(&dir, rev, path, RAW_MAX).await?;
486        let is_text = request::looks_textual(&bytes);
487        let filename = path.rsplit('/').next().unwrap_or("download");
488        Ok(ForgeResponse::raw_bytes(
489            bytes::Bytes::from(bytes),
490            is_text,
491            filename,
492        ))
493    }
494
495    async fn h_commits(
496        &self,
497        req: &ForgeRequest,
498        owner: &str,
499        repo: &str,
500        rev: &str,
501    ) -> Result<ForgeResponse, ForgeError> {
502        let dir = self.resolve_repo(owner, repo).await?;
503        let page: u32 = req
504            .query_param("page")
505            .and_then(|p| p.parse().ok())
506            .unwrap_or(1)
507            .max(1);
508        let (commits, has_next) = repo::browse::commit_log(&dir, rev, page, 50).await?;
509        Ok(ForgeResponse::html(
510            200,
511            html::commits_page(&self.prefix(), owner, repo, rev, &commits, page, has_next),
512        ))
513    }
514
515    async fn h_commit(
516        &self,
517        owner: &str,
518        repo: &str,
519        sha: &str,
520    ) -> Result<ForgeResponse, ForgeError> {
521        let dir = self.resolve_repo(owner, repo).await?;
522        let meta = solid_pod_rs_git::api::resolve_commit(&dir, sha).await?;
523        let patch = repo::browse::commit_patch(&dir, &meta.hash).await?;
524        Ok(ForgeResponse::html(
525            200,
526            html::commit_page(&self.prefix(), owner, repo, &meta, &patch),
527        ))
528    }
529
530    async fn h_branches(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
531        let dir = self.resolve_repo(owner, repo).await?;
532        let info = solid_pod_rs_git::api::git_branches(&dir).await?;
533        Ok(ForgeResponse::html(
534            200,
535            html::refs_page(
536                &self.prefix(),
537                owner,
538                repo,
539                "Branches",
540                Some(&info.current),
541                &info.local,
542                "commits",
543            ),
544        ))
545    }
546
547    async fn h_tags(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
548        let dir = self.resolve_repo(owner, repo).await?;
549        let tags = repo::browse::list_tags(&dir).await?;
550        Ok(ForgeResponse::html(
551            200,
552            html::refs_page(&self.prefix(), owner, repo, "Tags", None, &tags, "tree"),
553        ))
554    }
555
556    // ---- Tier 2: issues + spine ----------------------------------------
557
558    async fn h_issues_list(
559        &self,
560        req: &ForgeRequest,
561        owner: &str,
562        repo: &str,
563    ) -> Result<ForgeResponse, ForgeError> {
564        self.resolve_repo(owner, repo).await?;
565        let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
566        let filter = match req.query_param("state").as_deref() {
567            Some("closed") => spine::issues::IssueState::Closed,
568            _ => spine::issues::IssueState::Open,
569        };
570        let issues = idx.by_state(filter);
571        Ok(ForgeResponse::html(
572            200,
573            html::issues_list_page(
574                &self.prefix(),
575                owner,
576                repo,
577                filter,
578                idx.count(spine::issues::IssueState::Open),
579                idx.count(spine::issues::IssueState::Closed),
580                &issues,
581            ),
582        ))
583    }
584
585    async fn h_issue_new_form(&self, owner: &str, repo: &str) -> Result<ForgeResponse, ForgeError> {
586        self.resolve_repo(owner, repo).await?;
587        Ok(ForgeResponse::html(
588            200,
589            html::issue_new_page(&self.prefix(), owner, repo),
590        ))
591    }
592
593    async fn h_issue_detail(
594        &self,
595        owner: &str,
596        repo: &str,
597        num: u64,
598    ) -> Result<ForgeResponse, ForgeError> {
599        self.resolve_repo(owner, repo).await?;
600        let idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
601        let entry = idx
602            .issues
603            .get(&num)
604            .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
605        let truncated = entry.thread.len() > self.cfg.thread_cap;
606        let hosted: Arc<dyn HostedReader> = self.hosted.clone();
607        let threads = bodies::render_thread(
608            &entry.thread,
609            self.loopback.clone(),
610            Some(hosted),
611            &self.cfg,
612        )
613        .await;
614        Ok(ForgeResponse::html(
615            200,
616            html::issue_detail_page(&self.prefix(), owner, repo, entry, &threads, truncated),
617        ))
618    }
619
620    /// Parse `{title?, resourceUrl?, body?}` from a JSON or form-encoded
621    /// body. `resourceUrl` is the pod-hosted pointer (pod agents); `body`
622    /// is the inline content (podless `did:nostr` agents).
623    fn parse_issue_body(req: &ForgeRequest) -> (Option<String>, Option<String>, Option<String>) {
624        let ct = req.header("content-type").unwrap_or("");
625        let raw = String::from_utf8_lossy(&req.raw_body);
626        let field_json = |v: &serde_json::Value, k: &str| {
627            v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string())
628        };
629        if ct.contains("application/json") {
630            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
631                return (
632                    field_json(&v, "title"),
633                    field_json(&v, "resourceUrl"),
634                    field_json(&v, "body"),
635                );
636            }
637        }
638        let pairs = request::parse_form(&raw);
639        let field = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
640        (field("title"), field("resourceUrl"), field("body"))
641    }
642
643    /// Build a verified [`ThreadPointer`] for `agent`'s contribution:
644    /// - **Pod agent** → validate + loopback-verify the pod `resource_url`.
645    /// - **Nostr agent** → store `body` inline in forge-hosted storage.
646    /// - **Anonymous** → `401`.
647    async fn make_pointer(
648        &self,
649        req: &ForgeRequest,
650        agent: &ForgeAgent,
651        owner: &str,
652        repo: &str,
653        resource_url: Option<&str>,
654        body: Option<&str>,
655    ) -> Result<spine::issues::ThreadPointer, ForgeError> {
656        match agent {
657            ForgeAgent::Anonymous => {
658                Err(ForgeError::Unauthorised("authentication required".into()))
659            }
660            ForgeAgent::Nostr { pubkey_hex } => {
661                // Podless: the body is submitted inline and hosted 0600.
662                let body = body
663                    .map(str::trim)
664                    .filter(|b| !b.is_empty())
665                    .ok_or_else(|| ForgeError::BadRequest("body required".into()))?;
666                let rref = self.hosted.write(pubkey_hex, body.as_bytes()).await?;
667                Ok(spine::issues::ThreadPointer {
668                    author: agent.author_id(),
669                    resource_url: rref,
670                    at: now_secs(),
671                    hosted: true,
672                })
673            }
674            ForgeAgent::Pod { .. } => {
675                let url = resource_url
676                    .ok_or_else(|| ForgeError::BadRequest("resourceUrl required".into()))?;
677                self.verify_pod_pointer(req, agent, owner, repo, url).await
678            }
679        }
680    }
681
682    /// Verify a pod-hosted body pointer: the URL must be in the caller's
683    /// own forge area (SSRF guard) and unauthenticated-readable. Returns
684    /// the validated `ThreadPointer` on success.
685    async fn verify_pod_pointer(
686        &self,
687        req: &ForgeRequest,
688        agent: &ForgeAgent,
689        repo_owner: &str,
690        repo: &str,
691        resource_url: &str,
692    ) -> Result<spine::issues::ThreadPointer, ForgeError> {
693        let caller = agent
694            .owner()
695            .ok_or_else(|| ForgeError::Unauthorised("authentication required".into()))?;
696        let host = req
697            .host_url
698            .as_deref()
699            .ok_or_else(|| ForgeError::BadRequest("missing host".into()))?;
700        // SSRF + own-area guard.
701        bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
702        // Confirm public readability with a single loopback GET.
703        let lb = self
704            .loopback
705            .as_ref()
706            .ok_or_else(|| ForgeError::Unsupported("pod body verification unavailable".into()))?;
707        match lb
708            .get(
709                resource_url,
710                self.cfg.max_body_bytes,
711                self.cfg.fetch_timeout_secs,
712            )
713            .await
714        {
715            bodies::FetchResult::Body(_) => Ok(spine::issues::ThreadPointer {
716                author: agent.author_id(),
717                resource_url: resource_url.to_string(),
718                at: now_secs(),
719                hosted: false,
720            }),
721            bodies::FetchResult::Removed => Err(ForgeError::BadRequest(
722                "pod body is not publicly readable".into(),
723            )),
724            bodies::FetchResult::TooLarge => {
725                Err(ForgeError::BadRequest("pod body too large".into()))
726            }
727            bodies::FetchResult::Error(e) => {
728                Err(ForgeError::Backend(format!("pod body fetch failed: {e}")))
729            }
730        }
731    }
732
733    async fn h_issue_create(
734        &self,
735        req: &ForgeRequest,
736        agent: &ForgeAgent,
737        owner: &str,
738        repo: &str,
739    ) -> Result<ForgeResponse, ForgeError> {
740        self.resolve_repo(owner, repo).await?;
741        let (title, resource_url, body) = Self::parse_issue_body(req);
742        let title = title
743            .map(|t| t.trim().to_string())
744            .filter(|t| !t.is_empty())
745            .ok_or_else(|| ForgeError::BadRequest("title required".into()))?;
746
747        // Pod agents supply a pod pointer; podless nostr agents supply an
748        // inline body stored in forge-hosted storage.
749        let pointer = self
750            .make_pointer(
751                req,
752                agent,
753                owner,
754                repo,
755                resource_url.as_deref(),
756                body.as_deref(),
757            )
758            .await?;
759
760        let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
761        let entry = spine::issues::IssueEntry {
762            number: 0,
763            title,
764            state: spine::issues::IssueState::Open,
765            author: agent.author_id(),
766            created_at: now_secs(),
767            thread: vec![pointer],
768        };
769        let num = idx.allocate(entry);
770        spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
771
772        let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
773        Ok(ForgeResponse::redirect(303, &location))
774    }
775
776    async fn h_issue_comment(
777        &self,
778        req: &ForgeRequest,
779        agent: &ForgeAgent,
780        owner: &str,
781        repo: &str,
782        num: u64,
783    ) -> Result<ForgeResponse, ForgeError> {
784        self.resolve_repo(owner, repo).await?;
785        let (_title, resource_url, body) = Self::parse_issue_body(req);
786        let pointer = self
787            .make_pointer(
788                req,
789                agent,
790                owner,
791                repo,
792                resource_url.as_deref(),
793                body.as_deref(),
794            )
795            .await?;
796
797        let mut idx = spine::issues::load_issue_index(self.spine.as_ref(), owner, repo).await?;
798        let entry = idx
799            .issues
800            .get_mut(&num)
801            .ok_or_else(|| ForgeError::NotFound(format!("issue #{num}")))?;
802        entry.thread.push(pointer);
803        spine::issues::save_issue_index(self.spine.as_ref(), owner, repo, &idx).await?;
804
805        let location = format!("{}/{}/{}/issues/{}", self.prefix(), owner, repo, num);
806        Ok(ForgeResponse::redirect(303, &location))
807    }
808
809    // ---- Tier 2.5: tokens + hosted storage -----------------------------
810
811    /// `POST /api/token` — mint a forge push token for the (already
812    /// authenticated) caller. The server resolves the caller via NIP-98 /
813    /// pod session before calling `handle`; an anonymous caller is `401`.
814    async fn h_api_token(
815        &self,
816        req: &ForgeRequest,
817        agent: &ForgeAgent,
818    ) -> Result<ForgeResponse, ForgeError> {
819        if !req.method.eq_ignore_ascii_case("POST") {
820            return Err(ForgeError::NotFound("token requires POST".into()));
821        }
822        let iat = now_secs();
823        let ttl = self.cfg.token_ttl_secs;
824        let token = token::mint(self.token_key.as_ref(), agent, iat, ttl).ok_or_else(|| {
825            ForgeError::Unauthorised("authentication required to mint a token".into())
826        })?;
827        let v = serde_json::json!({
828            "token": token,
829            "tokenType": "Bearer",
830            "expiresIn": ttl,
831            "exp": iat + ttl,
832            "agent": agent.author_id(),
833        });
834        Ok(ForgeResponse::json(200, &v))
835    }
836
837    /// `GET/DELETE /api/hosted/<hex>/<uuid>` — the podless body store.
838    /// GET is public (bodies are public by construction); DELETE is
839    /// owner-only (`agent` hex must equal `hex`).
840    async fn h_api_hosted(
841        &self,
842        req: &ForgeRequest,
843        agent: &ForgeAgent,
844        hex: &str,
845        id: &str,
846    ) -> Result<ForgeResponse, ForgeError> {
847        if req.method.eq_ignore_ascii_case("DELETE") {
848            // Only the owning nostr agent may delete their hosted body.
849            if agent.owner() != Some(hex) {
850                return Err(ForgeError::Forbidden(
851                    "only the owner may delete a hosted body".into(),
852                ));
853            }
854            let removed = self.hosted.delete(hex, id).await?;
855            if removed {
856                return Ok(ForgeResponse::with_type(
857                    204,
858                    "application/json",
859                    bytes::Bytes::new(),
860                ));
861            }
862            return Err(ForgeError::NotFound(format!("hosted {hex}/{id}")));
863        }
864
865        // GET (default).
866        match self.hosted.read(hex, id, self.cfg.max_body_bytes).await? {
867            Some(bytes) => Ok(ForgeResponse {
868                status: 200,
869                headers: vec![
870                    ("content-type".into(), "application/json".into()),
871                    ("x-content-type-options".into(), "nosniff".into()),
872                    ("access-control-allow-origin".into(), "*".into()),
873                ],
874                body: bytes::Bytes::from(bytes),
875            }),
876            None => Err(ForgeError::NotFound(format!("hosted {hex}/{id}"))),
877        }
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use bytes::Bytes;
885    use tempfile::TempDir;
886
887    fn req(method: &str, path: &str) -> ForgeRequest {
888        ForgeRequest {
889            method: method.into(),
890            path: path.into(),
891            query: String::new(),
892            headers: vec![],
893            raw_body: Bytes::new(),
894            host_url: Some("https://pod.example".into()),
895        }
896    }
897
898    fn service() -> (TempDir, ForgeService) {
899        let td = TempDir::new().unwrap();
900        let svc = ForgeService::new(ForgeConfig::default(), td.path()).unwrap();
901        (td, svc)
902    }
903
904    #[tokio::test]
905    async fn new_creates_layout() {
906        let (td, _svc) = service();
907        for sub in ["repos", "issues", "pulls", "hosted", "marks"] {
908            assert!(td.path().join(sub).is_dir(), "{sub} dir must exist");
909        }
910    }
911
912    #[tokio::test]
913    async fn index_renders_empty() {
914        let (_td, svc) = service();
915        let r = svc
916            .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
917            .await
918            .unwrap();
919        assert_eq!(r.status, 200);
920        let body = String::from_utf8(r.body.to_vec()).unwrap();
921        assert!(body.contains("No repositories yet"));
922    }
923
924    #[tokio::test]
925    async fn index_lists_pushed_repo() {
926        let (td, svc) = service();
927        tokio::fs::create_dir_all(td.path().join("repos/alice/demo.git"))
928            .await
929            .unwrap();
930        let r = svc
931            .handle(req("GET", "/forge"), ForgeAgent::Anonymous)
932            .await
933            .unwrap();
934        let body = String::from_utf8(r.body.to_vec()).unwrap();
935        assert!(body.contains("/forge/alice/demo"));
936
937        // Owner page too.
938        let r2 = svc
939            .handle(req("GET", "/forge/alice"), ForgeAgent::Anonymous)
940            .await
941            .unwrap();
942        assert_eq!(r2.status, 200);
943    }
944
945    #[tokio::test]
946    async fn unknown_owner_is_404() {
947        let (_td, svc) = service();
948        let r = svc
949            .handle(req("GET", "/forge/ghost"), ForgeAgent::Anonymous)
950            .await
951            .unwrap();
952        assert_eq!(r.status, 404);
953    }
954
955    #[tokio::test]
956    async fn options_preflight() {
957        let (_td, svc) = service();
958        let r = svc
959            .handle(req("OPTIONS", "/forge/x"), ForgeAgent::Anonymous)
960            .await
961            .unwrap();
962        assert_eq!(r.status, 204);
963        assert!(r
964            .headers
965            .iter()
966            .any(|(k, _)| k.eq_ignore_ascii_case("access-control-allow-methods")));
967    }
968
969    #[tokio::test]
970    async fn outside_prefix_is_404() {
971        let (_td, svc) = service();
972        let r = svc
973            .handle(req("GET", "/other/thing"), ForgeAgent::Anonymous)
974            .await
975            .unwrap();
976        assert_eq!(r.status, 404);
977    }
978
979    #[tokio::test]
980    async fn unimplemented_route_is_404_for_now() {
981        let (_td, svc) = service();
982        let r = svc
983            .handle(
984                req("GET", "/forge/alice/repo/issues"),
985                ForgeAgent::Anonymous,
986            )
987            .await
988            .unwrap();
989        // Issues is implemented in Phase 2; before that it is a 404. Once
990        // Phase 2 lands, an anonymous GET on a missing repo still 404s.
991        assert_eq!(r.status, 404);
992    }
993
994    // ---- Phase 1 browse integration (needs the `git` binary) -----------
995
996    fn git_available() -> bool {
997        std::process::Command::new("git")
998            .arg("--version")
999            .stdout(std::process::Stdio::null())
1000            .stderr(std::process::Stdio::null())
1001            .status()
1002            .map(|s| s.success())
1003            .unwrap_or(false)
1004    }
1005
1006    fn run_git(dir: &std::path::Path, args: &[&str]) {
1007        let ok = std::process::Command::new("git")
1008            .args(args)
1009            .current_dir(dir)
1010            .env("GIT_CONFIG_NOSYSTEM", "1")
1011            .stdout(std::process::Stdio::null())
1012            .stderr(std::process::Stdio::null())
1013            .status()
1014            .map(|s| s.success())
1015            .unwrap_or(false);
1016        assert!(ok, "git {args:?} failed in {}", dir.display());
1017    }
1018
1019    /// Create `repos/<owner>/<name>.git` as a bare repo containing a
1020    /// README, a `src/lib.rs`, and a `v1.0` tag, via a working clone.
1021    fn seed_repo(td: &TempDir, owner: &str, name: &str) {
1022        let work = td.path().join(format!("work-{owner}-{name}"));
1023        std::fs::create_dir_all(&work).unwrap();
1024        run_git(&work, &["init", "-b", "main"]);
1025        std::fs::write(work.join("README.md"), "# Demo\nhello forge\n").unwrap();
1026        std::fs::create_dir_all(work.join("src")).unwrap();
1027        std::fs::write(work.join("src/lib.rs"), "pub fn f() -> u8 { 42 }\n").unwrap();
1028        run_git(&work, &["add", "-A"]);
1029        run_git(
1030            &work,
1031            &[
1032                "-c",
1033                "user.email=t@e.st",
1034                "-c",
1035                "user.name=Tester",
1036                "commit",
1037                "-m",
1038                "initial commit",
1039            ],
1040        );
1041        run_git(&work, &["tag", "v1.0"]);
1042        let bare = td
1043            .path()
1044            .join("repos")
1045            .join(owner)
1046            .join(format!("{name}.git"));
1047        std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
1048        run_git(
1049            td.path(),
1050            &[
1051                "clone",
1052                "--bare",
1053                work.to_str().unwrap(),
1054                bare.to_str().unwrap(),
1055            ],
1056        );
1057    }
1058
1059    async fn body(svc: &ForgeService, path: &str) -> (u16, String) {
1060        let r = svc
1061            .handle(req("GET", path), ForgeAgent::Anonymous)
1062            .await
1063            .unwrap();
1064        (r.status, String::from_utf8_lossy(&r.body).into_owned())
1065    }
1066
1067    #[tokio::test]
1068    async fn overview_lists_tree_and_readme() {
1069        if !git_available() {
1070            return;
1071        }
1072        let (td, svc) = service();
1073        seed_repo(&td, "alice", "demo");
1074        let (status, html) = body(&svc, "/forge/alice/demo").await;
1075        assert_eq!(status, 200);
1076        assert!(html.contains("alice/demo"));
1077        // Root tree entries.
1078        assert!(html.contains("README.md"));
1079        assert!(html.contains("src"));
1080        // README rendered.
1081        assert!(html.contains("hello forge"));
1082    }
1083
1084    #[tokio::test]
1085    async fn tree_blob_raw_roundtrip() {
1086        if !git_available() {
1087            return;
1088        }
1089        let (td, svc) = service();
1090        seed_repo(&td, "alice", "demo");
1091
1092        // Subdirectory listing.
1093        let (s1, tree) = body(&svc, "/forge/alice/demo/tree/main/src").await;
1094        assert_eq!(s1, 200);
1095        assert!(tree.contains("lib.rs"));
1096
1097        // Blob view renders textual content (escaped).
1098        let (s2, blob) = body(&svc, "/forge/alice/demo/blob/main/src/lib.rs").await;
1099        assert_eq!(s2, 200);
1100        assert!(blob.contains("pub fn f"));
1101
1102        // Raw serves text/plain with nosniff.
1103        let r = svc
1104            .handle(
1105                req("GET", "/forge/alice/demo/raw/main/src/lib.rs"),
1106                ForgeAgent::Anonymous,
1107            )
1108            .await
1109            .unwrap();
1110        assert_eq!(r.status, 200);
1111        let ct = r
1112            .headers
1113            .iter()
1114            .find(|(k, _)| k == "content-type")
1115            .map(|(_, v)| v.clone())
1116            .unwrap();
1117        assert!(ct.starts_with("text/plain"));
1118        assert_eq!(
1119            String::from_utf8_lossy(&r.body),
1120            "pub fn f() -> u8 { 42 }\n"
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn commits_branches_tags_commit_view() {
1126        if !git_available() {
1127            return;
1128        }
1129        let (td, svc) = service();
1130        seed_repo(&td, "alice", "demo");
1131
1132        let (s1, commits) = body(&svc, "/forge/alice/demo/commits/main").await;
1133        assert_eq!(s1, 200);
1134        assert!(commits.contains("initial commit"));
1135
1136        let (s2, branches) = body(&svc, "/forge/alice/demo/branches").await;
1137        assert_eq!(s2, 200);
1138        assert!(branches.contains("main"));
1139        assert!(branches.contains("default"));
1140
1141        let (s3, tags) = body(&svc, "/forge/alice/demo/tags").await;
1142        assert_eq!(s3, 200);
1143        assert!(tags.contains("v1.0"));
1144
1145        // Resolve the HEAD sha, then view that commit.
1146        let dir = td.path().join("repos/alice/demo.git");
1147        let out = std::process::Command::new("git")
1148            .args(["rev-parse", "HEAD"])
1149            .current_dir(&dir)
1150            .output()
1151            .unwrap();
1152        let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
1153        let (s4, commit) = body(&svc, &format!("/forge/alice/demo/commit/{sha}")).await;
1154        assert_eq!(s4, 200);
1155        assert!(commit.contains("initial commit"));
1156        assert!(commit.contains("README.md"));
1157    }
1158
1159    #[tokio::test]
1160    async fn browse_rejects_bad_ref_and_missing_repo() {
1161        if !git_available() {
1162            return;
1163        }
1164        let (td, svc) = service();
1165        seed_repo(&td, "alice", "demo");
1166
1167        // Missing repo → 404.
1168        let (s1, _) = body(&svc, "/forge/alice/ghost").await;
1169        assert_eq!(s1, 404);
1170
1171        // Flag-injection ref → 400.
1172        let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
1173        assert_eq!(s2, 400);
1174
1175        // Unknown path in a valid ref → 404.
1176        let (s3, _) = body(&svc, "/forge/alice/demo/blob/main/nope.txt").await;
1177        assert_eq!(s3, 404);
1178    }
1179
1180    #[tokio::test]
1181    async fn empty_repo_overview_renders() {
1182        if !git_available() {
1183            return;
1184        }
1185        let (td, svc) = service();
1186        // A bare repo with no commits.
1187        let bare = td.path().join("repos/bob/empty.git");
1188        std::fs::create_dir_all(&bare).unwrap();
1189        run_git(
1190            td.path(),
1191            &["init", "--bare", "-b", "main", bare.to_str().unwrap()],
1192        );
1193        let (status, html) = body(&svc, "/forge/bob/empty").await;
1194        assert_eq!(status, 200);
1195        assert!(html.contains("empty"));
1196    }
1197
1198    // ---- Phase 2 issues + spine ----------------------------------------
1199
1200    use std::collections::HashMap;
1201    use std::sync::Mutex;
1202
1203    struct MockLb {
1204        present: Mutex<HashMap<String, Vec<u8>>>,
1205    }
1206    impl MockLb {
1207        fn new() -> Self {
1208            Self {
1209                present: Mutex::new(HashMap::new()),
1210            }
1211        }
1212        fn put(&self, url: &str, body: &[u8]) {
1213            self.present
1214                .lock()
1215                .unwrap()
1216                .insert(url.into(), body.to_vec());
1217        }
1218        fn remove(&self, url: &str) {
1219            self.present.lock().unwrap().remove(url);
1220        }
1221    }
1222    #[async_trait::async_trait]
1223    impl bodies::LoopbackFetch for MockLb {
1224        async fn get(&self, url: &str, _max: usize, _to: u64) -> bodies::FetchResult {
1225            match self.present.lock().unwrap().get(url) {
1226                Some(b) => bodies::FetchResult::Body(b.clone()),
1227                None => bodies::FetchResult::Removed,
1228            }
1229        }
1230    }
1231
1232    fn pod_agent(user: &str) -> ForgeAgent {
1233        ForgeAgent::Pod {
1234            webid: format!("https://pod.example/{user}/profile/card#me"),
1235            username: user.to_string(),
1236        }
1237    }
1238
1239    fn post_form(path: &str, form: &str) -> ForgeRequest {
1240        ForgeRequest {
1241            method: "POST".into(),
1242            path: path.into(),
1243            query: String::new(),
1244            headers: vec![(
1245                "content-type".into(),
1246                "application/x-www-form-urlencoded".into(),
1247            )],
1248            raw_body: Bytes::from(form.to_string()),
1249            host_url: Some("https://pod.example".into()),
1250        }
1251    }
1252
1253    /// Build a git-seeded service with a mock loopback injected.
1254    fn issues_service() -> (TempDir, ForgeService, Arc<MockLb>) {
1255        let td = TempDir::new().unwrap();
1256        let mock = Arc::new(MockLb::new());
1257        let svc = ForgeService::new(ForgeConfig::default(), td.path())
1258            .unwrap()
1259            .with_loopback(mock.clone() as Arc<dyn bodies::LoopbackFetch>);
1260        (td, svc, mock)
1261    }
1262
1263    #[tokio::test]
1264    async fn issue_create_list_detail_and_deletion() {
1265        if !git_available() {
1266            return;
1267        }
1268        let (td, svc, mock) = issues_service();
1269        seed_repo(&td, "alice", "demo");
1270
1271        let url = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
1272        mock.put(url, b"the issue body text");
1273
1274        // Create.
1275        let r = svc
1276            .handle(
1277                post_form(
1278                    "/forge/alice/demo/issues",
1279                    &format!(
1280                        "title=First+bug&resourceUrl={}",
1281                        url.replace(':', "%3A").replace('/', "%2F")
1282                    ),
1283                ),
1284                pod_agent("alice"),
1285            )
1286            .await
1287            .unwrap();
1288        assert_eq!(r.status, 303);
1289        let loc = r
1290            .headers
1291            .iter()
1292            .find(|(k, _)| k.eq_ignore_ascii_case("location"))
1293            .map(|(_, v)| v.clone())
1294            .unwrap();
1295        assert_eq!(loc, "/forge/alice/demo/issues/1");
1296
1297        // List shows it.
1298        let (s, list) = body(&svc, "/forge/alice/demo/issues").await;
1299        assert_eq!(s, 200);
1300        assert!(list.contains("First bug"));
1301        assert!(list.contains("1 open"));
1302
1303        // Detail re-fetches the body.
1304        let (s2, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1305        assert_eq!(s2, 200);
1306        assert!(detail.contains("the issue body text"));
1307
1308        // Author deletes the pod body → detail shows the removed notice.
1309        mock.remove(url);
1310        let (_s3, detail2) = body(&svc, "/forge/alice/demo/issues/1").await;
1311        assert!(detail2.contains("content removed by its author"));
1312    }
1313
1314    #[tokio::test]
1315    async fn issue_create_rejects_foreign_area_and_ssrf() {
1316        if !git_available() {
1317            return;
1318        }
1319        let (td, svc, mock) = issues_service();
1320        seed_repo(&td, "alice", "demo");
1321        mock.put(
1322            "https://pod.example/bob/public/forge/alice--demo/x.jsonld",
1323            b"body",
1324        );
1325
1326        // alice pointing at bob's pod area → 403 (own-area guard).
1327        let bad = "https://pod.example/bob/public/forge/alice--demo/x.jsonld";
1328        let r = svc
1329            .handle(
1330                post_form(
1331                    "/forge/alice/demo/issues",
1332                    &format!(
1333                        "title=x&resourceUrl={}",
1334                        bad.replace(':', "%3A").replace('/', "%2F")
1335                    ),
1336                ),
1337                pod_agent("alice"),
1338            )
1339            .await
1340            .unwrap();
1341        assert_eq!(r.status, 403);
1342
1343        // A cross-origin SSRF target → rejected before any fetch.
1344        let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
1345        let r2 = svc
1346            .handle(
1347                post_form(
1348                    "/forge/alice/demo/issues",
1349                    &format!(
1350                        "title=x&resourceUrl={}",
1351                        ssrf.replace(':', "%3A").replace('/', "%2F")
1352                    ),
1353                ),
1354                pod_agent("alice"),
1355            )
1356            .await
1357            .unwrap();
1358        assert!(r2.status == 403 || r2.status == 400);
1359    }
1360
1361    #[tokio::test]
1362    async fn comment_appends_to_thread() {
1363        if !git_available() {
1364            return;
1365        }
1366        let (td, svc, mock) = issues_service();
1367        seed_repo(&td, "alice", "demo");
1368
1369        let open = "https://pod.example/alice/public/forge/alice--demo/i1.jsonld";
1370        mock.put(open, b"opening");
1371        svc.handle(
1372            post_form(
1373                "/forge/alice/demo/issues",
1374                &format!(
1375                    "title=t&resourceUrl={}",
1376                    open.replace(':', "%3A").replace('/', "%2F")
1377                ),
1378            ),
1379            pod_agent("alice"),
1380        )
1381        .await
1382        .unwrap();
1383
1384        // bob comments — his body lives in HIS pod, namespaced by the repo.
1385        let c = "https://pod.example/bob/public/forge/alice--demo/c1.jsonld";
1386        mock.put(c, b"a helpful comment");
1387        let r = svc
1388            .handle(
1389                post_form(
1390                    "/forge/alice/demo/issues/1",
1391                    &format!("resourceUrl={}", c.replace(':', "%3A").replace('/', "%2F")),
1392                ),
1393                pod_agent("bob"),
1394            )
1395            .await
1396            .unwrap();
1397        assert_eq!(r.status, 303);
1398
1399        let (_s, detail) = body(&svc, "/forge/alice/demo/issues/1").await;
1400        assert!(detail.contains("opening"));
1401        assert!(detail.contains("a helpful comment"));
1402        assert!(detail.contains("commented"));
1403    }
1404
1405    #[tokio::test]
1406    async fn issue_create_without_loopback_fails_closed() {
1407        if !git_available() {
1408            return;
1409        }
1410        // No loopback injected → pod-body verification unavailable (501).
1411        let (td, svc) = service();
1412        seed_repo(&td, "alice", "demo");
1413        let url = "https://pod.example/alice/public/forge/alice--demo/x.jsonld";
1414        let r = svc
1415            .handle(
1416                post_form(
1417                    "/forge/alice/demo/issues",
1418                    &format!(
1419                        "title=x&resourceUrl={}",
1420                        url.replace(':', "%3A").replace('/', "%2F")
1421                    ),
1422                ),
1423                pod_agent("alice"),
1424            )
1425            .await
1426            .unwrap();
1427        assert_eq!(r.status, 501);
1428    }
1429
1430    // ---- Phase 3 tokens + hosted + namespace guard ---------------------
1431
1432    fn hex64() -> String {
1433        "d".repeat(64)
1434    }
1435
1436    fn nostr_agent(hex: &str) -> ForgeAgent {
1437        ForgeAgent::Nostr {
1438            pubkey_hex: hex.to_string(),
1439        }
1440    }
1441
1442    fn post_json(path: &str, json: &str) -> ForgeRequest {
1443        ForgeRequest {
1444            method: "POST".into(),
1445            path: path.into(),
1446            query: String::new(),
1447            headers: vec![("content-type".into(), "application/json".into())],
1448            raw_body: Bytes::from(json.to_string()),
1449            host_url: Some("https://pod.example".into()),
1450        }
1451    }
1452
1453    #[tokio::test]
1454    async fn token_mint_then_resolves() {
1455        let (_td, svc) = service();
1456        // Mint a token for a NIP-98-authenticated nostr caller.
1457        let agent = nostr_agent(&hex64());
1458        let r = svc
1459            .handle(post_form("/forge/api/token", ""), agent.clone())
1460            .await
1461            .unwrap();
1462        assert_eq!(r.status, 200);
1463        let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap();
1464        let token = v["token"].as_str().unwrap().to_string();
1465        assert!(token.starts_with("f1."));
1466
1467        // Present the token on a later request → same identity resolves.
1468        let follow = ForgeRequest {
1469            method: "GET".into(),
1470            path: "/forge/x".into(),
1471            query: String::new(),
1472            headers: vec![("authorization".into(), format!("Bearer {token}"))],
1473            raw_body: Bytes::new(),
1474            host_url: Some("https://pod.example".into()),
1475        };
1476        assert_eq!(svc.resolve_agent(&follow).await, agent);
1477    }
1478
1479    #[tokio::test]
1480    async fn token_mint_anonymous_is_401() {
1481        let (_td, svc) = service();
1482        let r = svc
1483            .handle(post_form("/forge/api/token", ""), ForgeAgent::Anonymous)
1484            .await
1485            .unwrap();
1486        assert_eq!(r.status, 401);
1487    }
1488
1489    #[tokio::test]
1490    async fn podless_nostr_issue_uses_hosted_store() {
1491        if !git_available() {
1492            return;
1493        }
1494        let (td, svc) = service();
1495        let hex = hex64();
1496        seed_repo(&td, &hex, "proj");
1497
1498        // No pod: submit the body inline; the forge hosts it.
1499        let r = svc
1500            .handle(
1501                post_json(
1502                    &format!("/forge/{hex}/proj/issues"),
1503                    "{\"title\":\"podless bug\",\"body\":\"no pod here\"}",
1504                ),
1505                nostr_agent(&hex),
1506            )
1507            .await
1508            .unwrap();
1509        assert_eq!(r.status, 303, "podless issue create should redirect");
1510
1511        // Detail re-fetches from the hosted store (no loopback needed).
1512        let (s, detail) = body(&svc, &format!("/forge/{hex}/proj/issues/1")).await;
1513        assert_eq!(s, 200);
1514        assert!(detail.contains("no pod here"));
1515        assert!(detail.contains("podless bug"));
1516    }
1517
1518    #[tokio::test]
1519    async fn hosted_api_get_and_owner_only_delete() {
1520        let (td, svc) = service();
1521        let hex = hex64();
1522        // Write a hosted body directly through a store on the same dir.
1523        let store = HostedStore::new(td.path());
1524        let rref = store.write(&hex, b"{\"body\":\"hi\"}").await.unwrap();
1525        let (h, u) = HostedStore::parse_ref(&rref).unwrap();
1526
1527        // Public GET.
1528        let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
1529        assert_eq!(s, 200);
1530        assert!(got.contains("hi"));
1531
1532        // A non-owner DELETE is 403.
1533        let del_other = ForgeRequest {
1534            method: "DELETE".into(),
1535            path: format!("/forge/api/hosted/{h}/{u}"),
1536            query: String::new(),
1537            headers: vec![],
1538            raw_body: Bytes::new(),
1539            host_url: Some("https://pod.example".into()),
1540        };
1541        let r1 = svc
1542            .handle(del_other.clone(), nostr_agent(&"e".repeat(64)))
1543            .await
1544            .unwrap();
1545        assert_eq!(r1.status, 403);
1546
1547        // The owner DELETE succeeds (204), then the body is gone (404).
1548        let r2 = svc.handle(del_other, nostr_agent(&hex)).await.unwrap();
1549        assert_eq!(r2.status, 204);
1550        let (s2, _) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
1551        assert_eq!(s2, 404);
1552    }
1553
1554    #[tokio::test]
1555    async fn namespace_push_guard_blocks_foreign_push() {
1556        let (_td, svc) = service();
1557        // A receive-pack (push) into alice's namespace by bob → 403,
1558        // before the CGI is ever invoked.
1559        let push = ForgeRequest {
1560            method: "POST".into(),
1561            path: "/forge/alice/demo.git/git-receive-pack".into(),
1562            query: String::new(),
1563            headers: vec![],
1564            raw_body: Bytes::new(),
1565            host_url: Some("https://pod.example".into()),
1566        };
1567        let r = svc
1568            .handle(push.clone(), nostr_agent(&hex64()))
1569            .await
1570            .unwrap();
1571        assert_eq!(r.status, 403);
1572
1573        // Anonymous push is also blocked.
1574        let r_anon = svc.handle(push, ForgeAgent::Anonymous).await.unwrap();
1575        assert_eq!(r_anon.status, 403);
1576    }
1577
1578    #[tokio::test]
1579    async fn namespace_push_guard_allows_own_push() {
1580        // A push into the caller's OWN namespace passes the guard (it then
1581        // reaches the CGI, which is absent here → a non-403 backend error).
1582        let (_td, svc) = service();
1583        let hex = hex64();
1584        let push = ForgeRequest {
1585            method: "POST".into(),
1586            path: format!("/forge/{hex}/demo.git/git-receive-pack"),
1587            query: String::new(),
1588            headers: vec![],
1589            raw_body: Bytes::new(),
1590            host_url: Some("https://pod.example".into()),
1591        };
1592        let r = svc.handle(push, nostr_agent(&hex)).await.unwrap();
1593        assert_ne!(r.status, 403, "own-namespace push must pass the guard");
1594    }
1595}