1#![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
76pub 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
85fn now_secs() -> u64 {
87 SystemTime::now()
88 .duration_since(UNIX_EPOCH)
89 .map(|d| d.as_secs())
90 .unwrap_or(0)
91}
92
93fn 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#[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: Option<Arc<dyn LoopbackFetch>>,
131 hosted: Arc<HostedStore>,
134 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 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 #[must_use]
176 pub fn with_loopback(mut self, lb: Arc<dyn LoopbackFetch>) -> Self {
177 self.loopback = Some(lb);
178 self
179 }
180
181 #[must_use]
183 pub fn with_spine(mut self, spine: Arc<dyn SpineStore>) -> Self {
184 self.spine = spine;
185 self
186 }
187
188 #[must_use]
190 pub fn token_key(&self) -> &[u8; 32] {
191 &self.token_key
192 }
193
194 #[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 #[must_use]
205 pub fn prefix(&self) -> String {
206 self.cfg.normalized_prefix()
207 }
208
209 #[must_use]
211 pub fn plugin_dir(&self) -> &Path {
212 &self.plugin_dir
213 }
214
215 pub async fn handle(
218 &self,
219 req: ForgeRequest,
220 agent: ForgeAgent,
221 ) -> Result<ForgeResponse, ForgeError> {
222 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 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 _ => Err(ForgeError::NotFound("not implemented".into())),
305 }
306 }
307
308 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 bodies::own_area_ok(resource_url, host, caller, repo_owner, repo)?;
700 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 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 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 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 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 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 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 assert_eq!(r.status, 404);
990 }
991
992 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 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 assert!(html.contains("README.md"));
1077 assert!(html.contains("src"));
1078 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 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 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 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 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 let (s1, _) = body(&svc, "/forge/alice/ghost").await;
1167 assert_eq!(s1, 404);
1168
1169 let (s2, _) = body(&svc, "/forge/alice/demo/tree/--upload-pack/x").await;
1171 assert_eq!(s2, 400);
1172
1173 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let (s, got) = body(&svc, &format!("/forge/api/hosted/{h}/{u}")).await;
1527 assert_eq!(s, 200);
1528 assert!(got.contains("hi"));
1529
1530 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 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 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 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 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}