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