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