Skip to main content

max / makenotwork

Give the CLI the repo and key verbs, and stop publishing on push repo list|info|delete|set-visibility|set-description and key list|rm existed, worked, were tested, and could not be run. They lived in git_ssh.rs, which sshd reaches through the command= prefix in authorized_keys; but the git transport had already moved to mnw-cli's russh server, which serves cli.makenot.work and knows nothing about them. So the door people knock on answered "Unknown command: repo" while the implementation sat behind a door nothing knocks on. Orphaned by a half-finished migration rather than unfinished: the transport moved and the management verbs did not follow. This moves them the rest of the way. Six internal endpoints, and git_ssh.rs keeps only the transport and the authorized_keys writer. The endpoints key on repo NAME where the browser API keys on GitRepoId. A page has already loaded the row it acts on; a person at a terminal has the name, and making them find a UUID first is what sends them back to the web UI. Push-create now lands private (migration 182). Pushing to a name that did not exist both created a repository and published it, explore-listed, in one action git reports as an ordinary push, with no confirmation and -- until this commit -- no supported way back. Creating and publishing are two acts now, and the second is `repo set-visibility <name> public`. Existing rows are untouched: a column default only affects inserts, so nothing already published is silently withdrawn. The migration moved two tests rather than the other way round. repo_settings_denied_for_non_owner now publishes its repo first, because it is an authorization test -- the repo exists, is visible, and you still may not open its settings -- and left private the answer is 404, which is the right answer to a different question. git_explore_page_shows_public_repos was leaning on the default to get a public repo and now says so. New tests cover what the port actually risks: every verb reaching another user's repo (delete removes a directory tree), and a traversal name. `unquote` replaces the quote-aware tokenizer that left with git_ssh.rs, so `repo set-description r "some prose"` does not store the quotes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 19:24 UTC
Signed with PGP, not checked
Commit: f6163703afe8c80a2a65d093ed2cce28b5512f87
Parent: 22c1aa0
9 files changed, +911 insertions, -466 deletions
@@ -2,6 +2,34 @@
2 2
3 3 use serde::{Deserialize, Serialize};
4 4
5 + /// A repository as `repo list` renders it.
6 + #[derive(Debug, Clone, Deserialize, Serialize)]
7 + pub(crate) struct CliRepo {
8 + pub name: String,
9 + pub visibility: String,
10 + pub description: String,
11 + pub created_at: String,
12 + }
13 +
14 + /// A repository plus its issue counts, for `repo info`.
15 + #[derive(Debug, Clone, Deserialize, Serialize)]
16 + pub(crate) struct CliRepoInfo {
17 + pub name: String,
18 + pub visibility: String,
19 + pub description: String,
20 + pub created_at: String,
21 + pub open_issues: i64,
22 + pub closed_issues: i64,
23 + }
24 +
25 + /// An SSH key as `key list` renders it.
26 + #[derive(Debug, Clone, Deserialize, Serialize)]
27 + pub(crate) struct CliSshKey {
28 + pub fingerprint: String,
29 + pub label: String,
30 + pub created_at: String,
31 + }
32 +
5 33 /// User info returned from the SSH key lookup endpoint.
6 34 #[derive(Debug, Clone, Deserialize, Serialize)]
7 35 pub(crate) struct UserInfo {
@@ -1614,4 +1642,122 @@
1614 1642 .await?;
1615 1643 empty_response(resp, "remove_domain").await
1616 1644 }
1645 +
1646 + // ── Git repositories and SSH keys ──
1647 + //
1648 + // Addressed by repo NAME, matching the CLI's own vocabulary. The browser
1649 + // API keys on the repo id because a page has the row loaded; a person at a
1650 + // terminal does not.
1651 +
1652 + pub(crate) async fn repo_list(&self, user_id: &str) -> anyhow::Result<Vec<CliRepo>> {
1653 + let url = format!("{}/api/internal/creator/repos", self.base_url);
1654 + let resp = self
1655 + .http
1656 + .get(&url)
1657 + .bearer_auth(&self.service_token)
1658 + .header("X-MNW-Actor", self.actor_header())
1659 + .query(&[("user_id", user_id)])
1660 + .send()
1661 + .await?;
1662 + json_response(resp, "repo_list").await
1663 + }
1664 +
1665 + pub(crate) async fn repo_info(&self, user_id: &str, name: &str) -> anyhow::Result<CliRepoInfo> {
1666 + let url = format!("{}/api/internal/creator/repos/{name}", self.base_url);
1667 + let resp = self
1668 + .http
1669 + .get(&url)
1670 + .bearer_auth(&self.service_token)
1671 + .header("X-MNW-Actor", self.actor_header())
1672 + .query(&[("user_id", user_id)])
1673 + .send()
1674 + .await?;
1675 + json_response(resp, "repo_info").await
1676 + }
1677 +
1678 + pub(crate) async fn repo_set_visibility(
1679 + &self,
1680 + user_id: &str,
1681 + name: &str,
1682 + visibility: &str,
1683 + ) -> anyhow::Result<()> {
1684 + let url = format!(
1685 + "{}/api/internal/creator/repos/{name}/visibility",
1686 + self.base_url
1687 + );
1688 + let resp = self
1689 + .http
1690 + .put(&url)
1691 + .bearer_auth(&self.service_token)
1692 + .header("X-MNW-Actor", self.actor_header())
1693 + .query(&[("user_id", user_id)])
1694 + .json(&serde_json::json!({ "visibility": visibility }))
1695 + .send()
1696 + .await?;
1697 + empty_response(resp, "repo_set_visibility").await
1698 + }
1699 +
1700 + pub(crate) async fn repo_set_description(
1701 + &self,
1702 + user_id: &str,
1703 + name: &str,
1704 + description: &str,
1705 + ) -> anyhow::Result<()> {
1706 + let url = format!(
1707 + "{}/api/internal/creator/repos/{name}/description",
1708 + self.base_url
1709 + );
1710 + let resp = self
1711 + .http
1712 + .put(&url)
1713 + .bearer_auth(&self.service_token)
1714 + .header("X-MNW-Actor", self.actor_header())
1715 + .query(&[("user_id", user_id)])
1716 + .json(&serde_json::json!({ "description": description }))
1717 + .send()
1718 + .await?;
1719 + empty_response(resp, "repo_set_description").await
1720 + }
1721 +
1722 + pub(crate) async fn repo_delete(&self, user_id: &str, name: &str) -> anyhow::Result<()> {
1723 + let url = format!("{}/api/internal/creator/repos/{name}", self.base_url);
1724 + let resp = self
1725 + .http
1726 + .delete(&url)
1727 + .bearer_auth(&self.service_token)
1728 + .header("X-MNW-Actor", self.actor_header())
1729 + .query(&[("user_id", user_id)])
1730 + .send()
1731 + .await?;
1732 + empty_response(resp, "repo_delete").await
1733 + }
1734 +
1735 + pub(crate) async fn key_list(&self, user_id: &str) -> anyhow::Result<Vec<CliSshKey>> {
1736 + let url = format!("{}/api/internal/creator/ssh-keys", self.base_url);
1737 + let resp = self
1738 + .http
1739 + .get(&url)
1740 + .bearer_auth(&self.service_token)
1741 + .header("X-MNW-Actor", self.actor_header())
1742 + .query(&[("user_id", user_id)])
1743 + .send()
1744 + .await?;
1745 + json_response(resp, "key_list").await
1746 + }
1747 +
1748 + pub(crate) async fn key_remove(&self, user_id: &str, fingerprint: &str) -> anyhow::Result<()> {
1749 + let url = format!(
1750 + "{}/api/internal/creator/ssh-keys/{fingerprint}",
1751 + self.base_url
1752 + );
1753 + let resp = self
1754 + .http
1755 + .delete(&url)
1756 + .bearer_auth(&self.service_token)
1757 + .header("X-MNW-Actor", self.actor_header())
1758 + .query(&[("user_id", user_id)])
1759 + .send()
1760 + .await?;
1761 + empty_response(resp, "key_remove").await
1762 + }
1617 1763 }
@@ -94,6 +94,48 @@
94 94 Some("remove") => cmd_domain_remove(user, api).await,
95 95 _ => cmd_domain_show(user, api).await,
96 96 },
97 + "repo" => match parts.get(1).copied() {
98 + Some("list") => cmd_repo_list(user, api, json).await,
99 + Some("info") => cmd_repo_info(user, api, parts.get(2).unwrap_or(&""), json).await,
100 + Some("set-visibility") => {
101 + cmd_repo_set_visibility(
102 + user,
103 + api,
104 + parts.get(2).unwrap_or(&""),
105 + parts.get(3).unwrap_or(&""),
106 + )
107 + .await
108 + }
109 + // The description is the rest of the line, so it is rejoined rather
110 + // than read from one slot: a description is prose and almost always
111 + // has a space in it.
112 + Some("set-description") => {
113 + let desc = parts.get(3..).map(|r| r.join(" ")).unwrap_or_default();
114 + cmd_repo_set_description(
115 + user,
116 + api,
117 + parts.get(2).unwrap_or(&""),
118 + unquote(&desc),
119 + )
120 + .await
121 + }
122 + // --confirm is required and deliberately not inferable. This is the
123 + // only verb here that destroys anything.
124 + Some("delete") => {
125 + if parts.contains(&"--confirm") {
126 + cmd_repo_delete(user, api, parts.get(2).unwrap_or(&"")).await
127 + } else {
128 + b"repo delete requires --confirm\r\nUsage: repo delete <name> --confirm\r\n"
129 + .to_vec()
130 + }
131 + }
132 + _ => b"Usage: repo list | info NAME | set-visibility NAME public|unlisted|private | set-description NAME TEXT | delete NAME --confirm\r\n".to_vec(),
133 + },
134 + "key" => match parts.get(1).copied() {
135 + Some("list") => cmd_key_list(user, api, json).await,
136 + Some("rm") => cmd_key_remove(user, api, parts.get(2).unwrap_or(&"")).await,
137 + _ => b"Usage: key list | key rm FINGERPRINT\r\n".to_vec(),
138 + },
97 139 "help" | "--help" | "-h" => help_text(),
98 140 other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
99 141 .into_bytes(),
@@ -536,6 +578,158 @@
536 578 ))
537 579 }
538 580
581 + // ── Git repositories and SSH keys ──
582 + //
583 + // These verbs existed in the server's git_ssh.rs, reachable only through the
584 + // sshd `command=` path that the live SSH front door does not use. They are
585 + // here now because this is the door people actually knock on.
586 +
587 + async fn cmd_repo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
588 + match api.repo_list(&user.user_id).await {
589 + Ok(repos) => {
590 + if json {
591 + return serde_json::to_vec_pretty(&repos).unwrap_or_default();
592 + }
593 + if repos.is_empty() {
594 + return b"No repositories.\r\n".to_vec();
595 + }
596 + let mut out = format!("{:<30} {:<10} {}\r\n", "Name", "Visibility", "Description");
597 + out.push_str(&"-".repeat(70));
598 + out.push_str("\r\n");
599 + for r in &repos {
600 + let desc = if r.description.is_empty() {
601 + "-"
602 + } else {
603 + truncate(&r.description, 28)
604 + };
605 + write!(
606 + out,
607 + "{:<30} {:<10} {}\r\n",
608 + truncate(&r.name, 29),
609 + r.visibility,
610 + desc
611 + )
612 + .unwrap();
613 + }
614 + write!(out, "\r\n{} repository(ies).\r\n", repos.len()).unwrap();
615 + out.into_bytes()
616 + }
617 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
618 + }
619 + }
620 +
621 + async fn cmd_repo_info(user: &UserInfo, api: &MnwApiClient, name: &str, json: bool) -> Vec<u8> {
622 + if name.is_empty() {
623 + return b"Usage: repo info <name>\r\n".to_vec();
624 + }
625 + match api.repo_info(&user.user_id, name).await {
626 + Ok(r) => {
627 + if json {
628 + return serde_json::to_vec_pretty(&r).unwrap_or_default();
629 + }
630 + let desc = if r.description.is_empty() {
631 + "-"
632 + } else {
633 + &r.description
634 + };
635 + format!(
636 + "Name: {}\r\nVisibility: {}\r\nDescription: {}\r\nCreated: {}\r\nIssues: {} open, {} closed\r\n",
637 + r.name, r.visibility, desc, r.created_at, r.open_issues, r.closed_issues,
638 + )
639 + .into_bytes()
640 + }
641 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
642 + }
643 + }
644 +
645 + async fn cmd_repo_set_visibility(
646 + user: &UserInfo,
647 + api: &MnwApiClient,
648 + name: &str,
649 + visibility: &str,
650 + ) -> Vec<u8> {
651 + if name.is_empty() || visibility.is_empty() {
652 + return b"Usage: repo set-visibility <name> <public|unlisted|private>\r\n".to_vec();
653 + }
654 + if !matches!(visibility, "public" | "unlisted" | "private") {
655 + return b"Visibility must be public, unlisted, or private.\r\n".to_vec();
656 + }
657 + match api
658 + .repo_set_visibility(&user.user_id, name, visibility)
659 + .await
660 + {
661 + Ok(()) => format!("Set visibility of '{name}' to '{visibility}'.\r\n").into_bytes(),
662 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
663 + }
664 + }
665 +
666 + async fn cmd_repo_set_description(
667 + user: &UserInfo,
668 + api: &MnwApiClient,
669 + name: &str,
670 + description: &str,
671 + ) -> Vec<u8> {
672 + if name.is_empty() {
673 + return b"Usage: repo set-description <name> <description>\r\n".to_vec();
674 + }
675 + match api
676 + .repo_set_description(&user.user_id, name, description)
677 + .await
678 + {
679 + Ok(()) => format!("Updated description of '{name}'.\r\n").into_bytes(),
680 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
681 + }
682 + }
683 +
684 + async fn cmd_repo_delete(user: &UserInfo, api: &MnwApiClient, name: &str) -> Vec<u8> {
685 + if name.is_empty() {
686 + return b"Usage: repo delete <name> --confirm\r\n".to_vec();
687 + }
688 + match api.repo_delete(&user.user_id, name).await {
689 + Ok(()) => format!("Deleted repository '{name}'.\r\n").into_bytes(),
690 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
691 + }
692 + }
693 +
694 + async fn cmd_key_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
695 + match api.key_list(&user.user_id).await {
696 + Ok(keys) => {
697 + if json {
698 + return serde_json::to_vec_pretty(&keys).unwrap_or_default();
699 + }
700 + if keys.is_empty() {
701 + return b"No SSH keys.\r\n".to_vec();
702 + }
703 + let mut out = format!("{:<50} {:<20} {}\r\n", "Fingerprint", "Label", "Added");
704 + out.push_str(&"-".repeat(80));
705 + out.push_str("\r\n");
706 + for k in &keys {
707 + let label = if k.label.is_empty() {
708 + "-"
709 + } else {
710 + truncate(&k.label, 20)
711 + };
712 + // The shared endpoint returns RFC3339; the table wants a date.
713 + let added = k.created_at.get(..10).unwrap_or(&k.created_at);
714 + write!(out, "{:<50} {:<20} {}\r\n", k.fingerprint, label, added).unwrap();
715 + }
716 + write!(out, "\r\n{} key(s).\r\n", keys.len()).unwrap();
717 + out.into_bytes()
718 + }
719 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
720 + }
721 + }
722 +
723 + async fn cmd_key_remove(user: &UserInfo, api: &MnwApiClient, fingerprint: &str) -> Vec<u8> {
724 + if fingerprint.is_empty() {
725 + return b"Usage: key rm <fingerprint>\r\n".to_vec();
726 + }
727 + match api.key_remove(&user.user_id, fingerprint).await {
728 + Ok(()) => format!("Removed SSH key '{fingerprint}'.\r\n").into_bytes(),
729 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
730 + }
731 + }
732 +
539 733 pub(crate) fn help_text() -> Vec<u8> {
540 734 b"Usage: ssh cli.makenot.work <command>\r\n\
541 735 \r\n\
@@ -555,6 +749,13 @@
555 749 \x20 domain verify Verify DNS record\r\n\
556 750 \x20 domain remove Remove custom domain\r\n\
557 751 \x20 upload [args] Pipe upload (see below)\r\n\
752 + \x20 repo list List your git repositories\r\n\
753 + \x20 repo info NAME Show one repository\r\n\
754 + \x20 repo set-visibility NAME public|unlisted|private\r\n\
755 + \x20 repo set-description NAME TEXT\r\n\
756 + \x20 repo delete NAME --confirm Delete a repository\r\n\
757 + \x20 key list List your SSH keys\r\n\
758 + \x20 key rm FINGERPRINT Remove an SSH key\r\n\
558 759 \r\n\
559 760 Add --json to any command for machine-readable output.\r\n\
560 761 \r\n\
@@ -603,6 +804,26 @@
603 804 None
604 805 }
605 806
807 + /// Strip one layer of matching surrounding quotes.
808 + ///
809 + /// The command line is split on whitespace, so a quoted argument arrives as
810 + /// several parts and is rejoined by the caller, which puts the quote characters
811 + /// back into the middle of the value: `set-description r "A cool project"`
812 + /// would otherwise store the description with the quotes attached. The old
813 + /// sshd-side parser tokenized with quote awareness and this is what replaces
814 + /// that, at the one place where an argument is prose rather than an identifier.
815 + fn unquote(s: &str) -> &str {
816 + let bytes = s.as_bytes();
817 + if bytes.len() >= 2
818 + && (bytes[0] == b'"' || bytes[0] == b'\'')
819 + && bytes[bytes.len() - 1] == bytes[0]
820 + {
821 + &s[1..s.len() - 1]
822 + } else {
823 + s
824 + }
825 + }
826 +
606 827 fn truncate(s: &str, max_len: usize) -> &str {
607 828 if s.len() <= max_len {
608 829 s
@@ -615,6 +836,33 @@
615 836 mod tests {
616 837 use super::*;
617 838
839 + #[test]
840 + fn unquote_strips_matching_double_quotes() {
841 + assert_eq!(unquote("\"A cool project\""), "A cool project");
842 + }
843 +
844 + #[test]
845 + fn unquote_strips_matching_single_quotes() {
846 + assert_eq!(unquote("'A cool project'"), "A cool project");
847 + }
848 +
849 + #[test]
850 + fn unquote_leaves_unquoted_text_alone() {
851 + assert_eq!(unquote("A cool project"), "A cool project");
852 + }
853 +
854 + #[test]
855 + fn unquote_leaves_mismatched_quotes_alone() {
856 + // A lone quote is part of the description, not a delimiter.
857 + assert_eq!(unquote("\"unterminated"), "\"unterminated");
858 + assert_eq!(unquote("it's"), "it's");
859 + }
860 +
861 + #[test]
862 + fn unquote_handles_the_empty_quoted_string() {
863 + assert_eq!(unquote("\"\""), "");
864 + }
865 +
618 866 #[test]
619 867 fn truncate_short_string() {
620 868 assert_eq!(truncate("hello", 10), "hello");
@@ -77,11 +77,21 @@
77 77 anyhow::bail!("account is deactivated");
78 78 }
79 79
80 - // Dispatch: git operations start with "git-", everything else is a management command
80 + // Only git transport is served here. The management verbs (repo list,
81 + // key rm, ...) moved to mnw-cli on 2026-07-31 and are reached through
82 + // cli.makenot.work, the SSH front door that is actually live. They used to
83 + // be implemented in this file and dispatched below, where nothing could
84 + // reach them: the git transport had already migrated to mnw-cli's russh
85 + // server and these did not follow. So `repo set-visibility` existed, worked
86 + // and was unreachable, which is how a repo came to be published with no
87 + // supported way to unpublish it.
88 + let _ = &ssh_username;
81 89 if original_cmd.starts_with("git-") {
82 90 exec_git_operation(pool, user_id, &original_cmd).await
83 91 } else {
84 - exec_management_command(pool, user_id, &ssh_username, &original_cmd).await
92 + anyhow::bail!(
93 + "management commands have moved; run `ssh cli.makenot.work repo list` (or `help`)"
94 + )
85 95 }
86 96 }
87 97
@@ -281,306 +291,12 @@
281 291 Ok(())
282 292 }
283 293
284 - // ── SSH management commands ──
285 -
286 - #[derive(Debug, PartialEq)]
287 - enum ManagementCommand {
288 - RepoList,
289 - RepoInfo {
290 - name: String,
291 - },
292 - RepoDelete {
293 - name: String,
294 - },
295 - RepoSetVisibility {
296 - name: String,
297 - visibility: db::Visibility,
298 - },
299 - RepoSetDescription {
300 - name: String,
301 - description: String,
302 - },
303 - KeyList,
304 - KeyRemove {
305 - fingerprint: String,
306 - },
307 - }
308 -
309 - /// Split a command string on whitespace, respecting double-quoted segments.
310 - fn shell_tokenize(input: &str) -> Vec<String> {
311 - let mut tokens = Vec::new();
312 - let mut current = String::new();
313 - let mut in_quotes = false;
314 -
315 - for ch in input.chars() {
316 - if in_quotes {
317 - if ch == '"' {
318 - in_quotes = false;
319 - } else {
320 - current.push(ch);
321 - }
322 - } else if ch == '"' {
323 - in_quotes = true;
324 - } else if ch.is_ascii_whitespace() {
325 - if !current.is_empty() {
326 - tokens.push(std::mem::take(&mut current));
327 - }
328 - } else {
329 - current.push(ch);
330 - }
331 - }
332 -
333 - if !current.is_empty() {
334 - tokens.push(current);
335 - }
336 -
337 - tokens
338 - }
339 -
340 - fn parse_management_command(tokens: &[String]) -> anyhow::Result<ManagementCommand> {
341 - let strs: Vec<&str> = tokens.iter().map(std::string::String::as_str).collect();
342 -
343 - match strs.as_slice() {
344 - ["repo", "list"] => Ok(ManagementCommand::RepoList),
345 - ["repo", "info", name] => Ok(ManagementCommand::RepoInfo {
346 - name: name.to_string(),
347 - }),
348 - ["repo", "delete", name, "--confirm"] => Ok(ManagementCommand::RepoDelete {
349 - name: name.to_string(),
350 - }),
351 - ["repo", "delete", _, ..] => anyhow::bail!("repo delete requires --confirm flag"),
352 - ["repo", "set-visibility", name, vis] => {
353 - let visibility: db::Visibility = vis
354 - .parse()
355 - .map_err(|_| anyhow::anyhow!("visibility must be public, private, or unlisted"))?;
356 - Ok(ManagementCommand::RepoSetVisibility {
357 - name: name.to_string(),
358 - visibility,
359 - })
360 - }
361 - ["repo", "set-description", name, desc] => Ok(ManagementCommand::RepoSetDescription {
362 - name: name.to_string(),
363 - description: desc.to_string(),
364 - }),
365 - ["key", "list"] => Ok(ManagementCommand::KeyList),
366 - ["key", "rm", fingerprint] => Ok(ManagementCommand::KeyRemove {
367 - fingerprint: fingerprint.to_string(),
368 - }),
369 - _ => anyhow::bail!(
370 - "unknown command; available: repo list|info|delete|set-visibility|set-description, key list|rm"
371 - ),
372 - }
373 - }
374 -
375 - async fn exec_management_command(
376 - pool: &PgPool,
377 - user_id: UserId,
378 - username: &str,
379 - original_cmd: &str,
380 - ) -> anyhow::Result<()> {
381 - let tokens = shell_tokenize(original_cmd);
382 - let cmd = parse_management_command(&tokens)?;
383 -
384 - match cmd {
385 - ManagementCommand::RepoList => cmd_ssh_repo_list(pool, user_id).await,
386 - ManagementCommand::RepoInfo { name } => cmd_ssh_repo_info(pool, user_id, &name).await,
387 - ManagementCommand::RepoDelete { name } => {
388 - cmd_ssh_repo_delete(pool, user_id, username, &name).await
389 - }
390 - ManagementCommand::RepoSetVisibility { name, visibility } => {
391 - cmd_ssh_repo_set_visibility(pool, user_id, &name, visibility).await
392 - }
393 - ManagementCommand::RepoSetDescription { name, description } => {
394 - cmd_ssh_repo_set_description(pool, user_id, &name, &description).await
395 - }
396 - ManagementCommand::KeyList => cmd_ssh_key_list(pool, user_id).await,
397 - ManagementCommand::KeyRemove { fingerprint } => {
398 - cmd_ssh_key_remove(pool, user_id, &fingerprint).await
399 - }
400 - }
401 - }
402 -
403 - /// Render a value for a fixed-width table column: "-" if empty, ellipsized if
404 - /// wider than `max_width` (chars), otherwise the value unchanged.
405 - fn display_with_ellipsis(value: &str, max_width: usize) -> String {
406 - if value.is_empty() {
407 - "-".to_string()
408 - } else if value.chars().count() > max_width {
409 - let truncated: String = value.chars().take(max_width.saturating_sub(3)).collect();
410 - format!("{truncated}...")
411 - } else {
412 - value.to_string()
413 - }
414 - }
415 -
416 - async fn cmd_ssh_repo_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
417 - let repos = db::git_repos::get_repos_by_user(pool, user_id).await?;
418 -
419 - if repos.is_empty() {
420 - println!("No repositories.");
421 - return Ok(());
422 - }
423 -
424 - println!("{:<30} {:<10} Description", "Name", "Visibility");
425 - println!("{}", "-".repeat(70));
426 -
427 - for repo in &repos {
428 - let desc = display_with_ellipsis(&repo.description, 28);
429 - println!("{:<30} {:<10} {}", repo.name, repo.visibility, desc);
430 - }
431 -
432 - println!("\n{} repo(s).", repos.len());
433 - Ok(())
434 - }
435 -
436 - async fn cmd_ssh_repo_info(pool: &PgPool, user_id: UserId, name: &str) -> anyhow::Result<()> {
437 - // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
438 - // name and the delete path canonicalize-guards the FS path, but reject
439 - // traversal/control characters before building any path) (ultra-fuzz Sec M2).
440 - validate_git_repo_name(name)?;
441 -
442 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
443 - .await?
444 - .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
445 -
446 - let (open_issues, closed_issues) = db::issues::get_issue_counts(pool, repo.id).await?;
447 -
448 - println!("Name: {}", repo.name);
449 - println!("Visibility: {}", repo.visibility);
450 - println!(
451 - "Description: {}",
452 - if repo.description.is_empty() {
453 - "-"
454 - } else {
455 - &repo.description
456 - }
457 - );
458 - println!(
459 - "Created: {}",
460 - repo.created_at.format("%Y-%m-%d %H:%M UTC")
461 - );
462 - println!("Issues: {open_issues} open, {closed_issues} closed");
463 -
464 - Ok(())
465 - }
466 -
467 - async fn cmd_ssh_repo_delete(
468 - pool: &PgPool,
469 - user_id: UserId,
470 - username: &str,
471 - name: &str,
472 - ) -> anyhow::Result<()> {
473 - // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
474 - // name and the delete path canonicalize-guards the FS path, but reject
475 - // traversal/control characters before building any path) (ultra-fuzz Sec M2).
476 - validate_git_repo_name(name)?;
477 -
478 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
479 - .await?
480 - .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
481 -
482 - db::git_repos::delete_repo(pool, repo.id).await?;
483 -
484 - let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
485 - let git_root_path = std::path::Path::new(&git_root);
486 - let repo_dir = git_root_path.join(username).join(format!("{name}.git"));
487 -
488 - if repo_dir.exists() {
489 - let canonical = repo_dir.canonicalize()?;
490 - let canonical_root = git_root_path.canonicalize()?;
491 - if !canonical.starts_with(&canonical_root) {
492 - anyhow::bail!("repo path escapes git root");
493 - }
494 - std::fs::remove_dir_all(&canonical)?;
495 - }
496 -
497 - println!("Deleted repository '{name}'.");
498 - Ok(())
499 - }
500 -
501 - async fn cmd_ssh_repo_set_visibility(
502 - pool: &PgPool,
503 - user_id: UserId,
504 - name: &str,
505 - visibility: db::Visibility,
506 - ) -> anyhow::Result<()> {
507 - // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
508 - // name and the delete path canonicalize-guards the FS path, but reject
509 - // traversal/control characters before building any path) (ultra-fuzz Sec M2).
510 - validate_git_repo_name(name)?;
511 -
512 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
513 - .await?
514 - .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
515 -
516 - db::git_repos::update_visibility(pool, repo.id, visibility).await?;
517 -
518 - println!("Set visibility of '{name}' to '{visibility}'.");
519 - Ok(())
520 - }
521 -
522 - async fn cmd_ssh_repo_set_description(
523 - pool: &PgPool,
524 - user_id: UserId,
525 - name: &str,
526 - description: &str,
527 - ) -> anyhow::Result<()> {
528 - // Validate the name up front (defense-in-depth: the DB lookup 404s on a bogus
529 - // name and the delete path canonicalize-guards the FS path, but reject
530 - // traversal/control characters before building any path) (ultra-fuzz Sec M2).
531 - validate_git_repo_name(name)?;
532 -
533 - let repo = db::git_repos::get_repo_by_user_and_name(pool, user_id, name)
534 - .await?
535 - .ok_or_else(|| anyhow::anyhow!("repository '{name}' not found"))?;
536 -
537 - db::git_repos::update_repo_settings(pool, repo.id, description, repo.visibility).await?;
538 -
539 - println!("Updated description of '{name}'.");
540 - Ok(())
541 - }
542 -
543 - async fn cmd_ssh_key_list(pool: &PgPool, user_id: UserId) -> anyhow::Result<()> {
544 - let keys = db::ssh_keys::list_keys_by_user(pool, user_id).await?;
545 -
546 - if keys.is_empty() {
547 - println!("No SSH keys.");
548 - return Ok(());
549 - }
550 -
551 - println!("{:<50} {:<20} Added", "Fingerprint", "Label");
552 - println!("{}", "-".repeat(80));
553 -
554 - for key in &keys {
555 - let label = display_with_ellipsis(&key.label, 20);
556 - println!(
557 - "{:<50} {:<20} {}",
558 - key.fingerprint,
559 - label,
560 - key.created_at.format("%Y-%m-%d"),
561 - );
562 - }
563 -
564 - println!("\n{} key(s).", keys.len());
565 - Ok(())
566 - }
567 -
568 - async fn cmd_ssh_key_remove(
569 - pool: &PgPool,
570 - user_id: UserId,
571 - fingerprint: &str,
572 - ) -> anyhow::Result<()> {
573 - let deleted = db::ssh_keys::delete_key_by_fingerprint(pool, user_id, fingerprint).await?;
574 -
575 - if !deleted {
576 - anyhow::bail!("SSH key with fingerprint '{fingerprint}' not found");
577 - }
578 -
579 - write_authorized_keys(pool, true).await?;
580 -
581 - println!("Removed SSH key '{fingerprint}'.");
582 - Ok(())
583 - }
294 + // ── authorized_keys ──
295 + //
296 + // All that remains of the management half. mnw-cli authenticates from the
297 + // database rather than this file, so it is written for whatever still consults
298 + // sshd: a key removed from one door but not the other is a key the user
299 + // believes is gone.
584 300
585 301 /// Write the authorized_keys file from all DB keys. Optionally set git:git ownership.
586 302 pub async fn write_authorized_keys(pool: &PgPool, set_ownership: bool) -> anyhow::Result<()> {
@@ -626,51 +342,6 @@
626 342 mod tests {
627 343 use super::*;
628 344
629 - // ── shell_tokenize ──
630 -
631 - #[test]
632 - fn tokenize_simple() {
633 - assert_eq!(shell_tokenize("repo list"), vec!["repo", "list"]);
634 - }
635 -
636 - #[test]
637 - fn tokenize_extra_whitespace() {
638 - assert_eq!(
639 - shell_tokenize(" repo info myrepo "),
640 - vec!["repo", "info", "myrepo"],
641 - );
642 - }
643 -
644 - #[test]
645 - fn tokenize_quoted_string() {
646 - assert_eq!(
647 - shell_tokenize(r#"repo set-description myrepo "A cool project""#),
648 - vec!["repo", "set-description", "myrepo", "A cool project"],
649 - );
650 - }
651 -
652 - #[test]
653 - fn tokenize_empty_quotes() {
654 - assert_eq!(
655 - shell_tokenize(r#"repo set-description myrepo """#),
656 - vec!["repo", "set-description", "myrepo"],
657 - );
658 - }
659 -
660 - #[test]
661 - fn tokenize_unterminated_quote() {
662 - assert_eq!(
663 - shell_tokenize(r#"repo set-description myrepo "unterminated"#),
664 - vec!["repo", "set-description", "myrepo", "unterminated"],
665 - );
666 - }
667 -
668 - #[test]
669 - fn tokenize_empty_input() {
670 - assert!(shell_tokenize("").is_empty());
671 - assert!(shell_tokenize(" ").is_empty());
672 - }
673 -
674 345 // ── parse_ssh_command ──
675 346
676 347 #[test]
@@ -753,123 +424,4 @@
753 424 fn parse_repo_path_bare_git_suffix_only() {
754 425 assert!(parse_repo_path("/owner/.git").is_err());
755 426 }
756 -
757 - // ── parse_management_command ──
758 -
759 - #[test]
760 - fn parse_repo_list() {
761 - let tokens: Vec<String> = vec!["repo".into(), "list".into()];
762 - assert_eq!(
763 - parse_management_command(&tokens).unwrap(),
764 - ManagementCommand::RepoList
765 - );
766 - }
767 -
768 - #[test]
769 - fn parse_repo_info() {
770 - let tokens: Vec<String> = vec!["repo".into(), "info".into(), "docengine".into()];
771 - assert_eq!(
772 - parse_management_command(&tokens).unwrap(),
773 - ManagementCommand::RepoInfo {
774 - name: "docengine".into()
775 - },
776 - );
777 - }
778 -
779 - #[test]
780 - fn parse_repo_delete_with_confirm() {
781 - let tokens: Vec<String> = vec![
782 - "repo".into(),
783 - "delete".into(),
784 - "old".into(),
785 - "--confirm".into(),
786 - ];
787 - assert_eq!(
788 - parse_management_command(&tokens).unwrap(),
789 - ManagementCommand::RepoDelete { name: "old".into() },
790 - );
791 - }
792 -
793 - #[test]
794 - fn parse_repo_delete_without_confirm_fails() {
795 - let tokens: Vec<String> = vec!["repo".into(), "delete".into(), "old".into()];
796 - assert!(parse_management_command(&tokens).is_err());
797 - }
798 -
799 - #[test]
800 - fn parse_repo_set_visibility() {
801 - let tokens: Vec<String> = vec![
802 - "repo".into(),
803 - "set-visibility".into(),
804 - "myrepo".into(),
805 - "private".into(),
806 - ];
807 - assert_eq!(
808 - parse_management_command(&tokens).unwrap(),
809 - ManagementCommand::RepoSetVisibility {
810 - name: "myrepo".into(),
811 - visibility: db::Visibility::Private
812 - },
813 - );
814 - }
815 -
816 - #[test]
817 - fn parse_repo_set_visibility_invalid() {
818 - let tokens: Vec<String> = vec![
819 - "repo".into(),
820 - "set-visibility".into(),
821 - "myrepo".into(),
822 - "secret".into(),
823 - ];
824 - assert!(parse_management_command(&tokens).is_err());
825 - }
826 -
827 - #[test]
828 - fn parse_repo_set_description() {
829 - let tokens: Vec<String> = vec![
830 - "repo".into(),
831 - "set-description".into(),
832 - "myrepo".into(),
833 - "A new description".into(),
834 - ];
835 - assert_eq!(
836 - parse_management_command(&tokens).unwrap(),
837 - ManagementCommand::RepoSetDescription {
838 - name: "myrepo".into(),
839 - description: "A new description".into()
840 - },
841 - );
842 - }
843 -
844 - #[test]
845 - fn parse_key_list() {
846 - let tokens: Vec<String> = vec!["key".into(), "list".into()];
847 - assert_eq!(
848 - parse_management_command(&tokens).unwrap(),
849 - ManagementCommand::KeyList
850 - );
851 - }
852 -
853 - #[test]
854 - fn parse_key_rm() {
855 - let tokens: Vec<String> = vec!["key".into(), "rm".into(), "SHA256:abc123".into()];
856 - assert_eq!(
857 - parse_management_command(&tokens).unwrap(),
858 - ManagementCommand::KeyRemove {
859 - fingerprint: "SHA256:abc123".into()
860 - },
861 - );
862 - }
863 -
864 - #[test]
865 - fn parse_invalid_command() {
866 - let tokens: Vec<String> = vec!["frobnicate".into()];
Lines truncated
@@ -615,10 +615,18 @@
615 615 make_test_repo(tmp.path());
616 616 let mut h = setup_git_harness(&tmp).await;
617 617
618 - // Visit repo to auto-register it (default visibility is public)
618 + // Visit repo to auto-register it. Auto-registration lands private since
619 + // migration 182, so publish it explicitly: what this test is about is the
620 + // explore page listing a public repo, not what the default happens to be.
621 + // Its sibling below covers the private case.
619 622 let resp = h.client.get("/git/testowner/testrepo").await;
620 623 assert!(resp.status.is_success());
621 624
625 + sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'")
626 + .execute(&h.db)
627 + .await
628 + .unwrap();
629 +
622 630 let resp = h.client.get("/git").await;
623 631 assert_eq!(resp.status, 200);
624 632 assert!(resp.text.contains("testowner"), "Should show owner name");
@@ -1043,6 +1043,15 @@
1043 1043 make_test_repo(tmp.path());
1044 1044 let mut h = setup(&tmp).await;
1045 1045
1046 + // Publish it first. This test is about authorization -- the repo exists and
1047 + // is visible, and you still may not open its settings -- so it needs a repo
1048 + // the other user can see. Left private (the default since migration 182)
1049 + // the answer is 404, which is the right answer to a different question.
1050 + sqlx::query("UPDATE git_repos SET visibility = 'public' WHERE name = 'testrepo'")
1051 + .execute(&h.db)
1052 + .await
1053 + .unwrap();
1054 +
1046 1055 h.client.post_form("/logout", "").await;
1047 1056 h.signup("otheruser", "other@example.com", "password123")
1048 1057 .await;
@@ -1,7 +1,9 @@
1 - //! SSH management command integration tests.
1 + //! Repo and SSH key management integration tests.
2 2 //!
3 - //! Tests repo CRUD (via DB functions used by the SSH handlers),
4 - //! SSH key delete-by-fingerprint, and issue counts after repo operations.
3 + //! Tests repo CRUD, SSH key delete-by-fingerprint, and issue counts after repo
4 + //! operations, at the DB layer the management commands sit on. Those commands
5 + //! moved from the sshd path (`git_ssh.rs`) to mnw-cli on 2026-07-31; this layer
6 + //! is shared by both and did not move.
5 7
6 8 use crate::harness::TestHarness;
7 9 use makenotwork::db;
@@ -27,7 +29,9 @@
27 29 .await
28 30 .unwrap();
29 31 assert_eq!(repo.name, "myproject");
30 - assert_eq!(repo.visibility, "public");
32 + // Private by default since migration 182: creating a repo and publishing it
33 + // are two acts, and push-create only does the first.
34 + assert_eq!(repo.visibility, db::Visibility::Private);
31 35
32 36 // List should show 1 repo
33 37 let repos = db::git_repos::get_repos_by_user(&h.db, user.id)
@@ -53,9 +57,11 @@
53 57 let repo = db::git_repos::create_repo(&h.db, user.id, "secret")
54 58 .await
55 59 .unwrap();
56 - assert_eq!(repo.visibility, db::Visibility::Public);
60 + assert_eq!(repo.visibility, db::Visibility::Private);
57 61
58 - db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Private)
62 + // The interesting direction now: private is where a repo starts, so
63 + // publishing is the transition worth asserting.
64 + db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Public)
59 65 .await
60 66 .unwrap();
61 67
@@ -63,7 +69,7 @@
63 69 .await
64 70 .unwrap()
65 71 .unwrap();
66 - assert_eq!(updated.visibility, db::Visibility::Private);
72 + assert_eq!(updated.visibility, db::Visibility::Public);
67 73
68 74 // Also test unlisted
69 75 db::git_repos::update_visibility(&h.db, repo.id, db::Visibility::Unlisted)
@@ -234,3 +240,196 @@
234 240 .unwrap();
235 241 assert!(!deleted);
236 242 }
243 +
244 + // ── The CLI's management endpoints ──
245 + //
246 + // The verbs above are exercised at the DB layer. These cover the HTTP surface
247 + // mnw-cli actually calls, which is the half that was missing: the old
248 + // implementation was reachable only through an sshd path the live front door
249 + // does not use, so `repo set-visibility` worked and could not be run.
250 +
251 + /// A harness with the internal API configured, as mnw-cli talks to it.
252 + ///
253 + /// `TestHarness::new` leaves `cli_service_token` unset, and without it every
254 + /// internal route answers 503 "Internal API not configured" rather than
255 + /// exercising the handler.
256 + async fn cli_harness() -> TestHarness {
257 + TestHarness::build(crate::harness::BuildOptions {
258 + cli_service_token: Some("test-cli-token".to_string()),
259 + git_repos_path: Some(
260 + tempfile::TempDir::new()
261 + .unwrap()
262 + .keep()
263 + .to_string_lossy()
264 + .into_owned(),
265 + ),
266 + ..Default::default()
267 + })
268 + .await
269 + }
270 +
271 + /// Authenticate the test client as `user_id` for the internal API.
272 + async fn as_cli_actor(h: &mut TestHarness, user_id: db::UserId) {
273 + h.client.set_bearer_token("test-cli-token");
274 + let actor = makenotwork::crypto::mint_internal_actor_token(
275 + user_id,
276 + chrono::Utc::now().timestamp() + 3600,
277 + "test-signing-secret-for-integration-tests",
278 + );
279 + h.client.set_actor_token(&actor);
280 + }
281 +
282 + #[tokio::test]
283 + async fn cli_repo_list_and_info_over_http() {
284 + let mut h = cli_harness().await;
285 + h.signup("carol", "carol@example.com", "password123").await;
286 + let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("carol".into()))
287 + .await
288 + .unwrap()
289 + .unwrap();
290 + db::git_repos::create_repo(&h.db, user.id, "myproject")
291 + .await
292 + .unwrap();
293 +
294 + as_cli_actor(&mut h, user.id).await;
295 +
296 + let resp = h.client.get("/api/internal/creator/repos").await;
297 + assert!(resp.status.is_success(), "repo list failed: {}", resp.text);
298 + assert!(resp.text.contains("myproject"));
299 + // Private is what push-create now produces, so the CLI must report it.
300 + assert!(resp.text.contains("private"));
301 +
302 + let resp = h.client.get("/api/internal/creator/repos/myproject").await;
303 + assert!(resp.status.is_success(), "repo info failed: {}", resp.text);
304 + assert!(resp.text.contains("open_issues"));
305 + }
306 +
307 + #[tokio::test]
308 + async fn cli_repo_set_visibility_over_http() {
309 + let mut h = cli_harness().await;
310 + h.signup("dave", "dave@example.com", "password123").await;
311 + let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("dave".into()))
312 + .await
313 + .unwrap()
314 + .unwrap();
315 + let repo = db::git_repos::create_repo(&h.db, user.id, "toolate")
316 + .await
317 + .unwrap();
318 +
319 + as_cli_actor(&mut h, user.id).await;
320 +
321 + // The whole point of the port: publishing, and unpublishing again, without
322 + // touching the web UI.
323 + let resp = h
324 + .client
325 + .put_json(
326 + "/api/internal/creator/repos/toolate/visibility",
327 + r#"{"visibility":"public"}"#,
328 + )
329 + .await;
330 + assert!(resp.status.is_success(), "set public failed: {}", resp.text);
331 + let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
332 + .await
333 + .unwrap()
334 + .unwrap();
335 + assert_eq!(updated.visibility, db::Visibility::Public);
336 +
337 + let resp = h
338 + .client
339 + .put_json(
340 + "/api/internal/creator/repos/toolate/visibility",
341 + r#"{"visibility":"private"}"#,
342 + )
343 + .await;
344 + assert!(
345 + resp.status.is_success(),
346 + "set private failed: {}",
347 + resp.text
348 + );
349 + let updated = db::git_repos::get_repo_by_id(&h.db, repo.id)
350 + .await
351 + .unwrap()
352 + .unwrap();
353 + assert_eq!(updated.visibility, db::Visibility::Private);
354 + }
355 +
356 + #[tokio::test]
357 + async fn cli_repo_endpoints_do_not_reach_another_users_repo() {
358 + let mut h = cli_harness().await;
359 + h.signup("erin", "erin@example.com", "password123").await;
360 + let owner = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("erin".into()))
361 + .await
362 + .unwrap()
363 + .unwrap();
364 + let repo = db::git_repos::create_repo(&h.db, owner.id, "private-thing")
365 + .await
366 + .unwrap();
367 +
368 + h.client.post_form("/logout", "").await;
369 + h.signup("frank", "frank@example.com", "password123").await;
370 + let other = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("frank".into()))
371 + .await
372 + .unwrap()
373 + .unwrap();
374 +
375 + // Every verb keys on (actor, name), so another user's repo is simply not
376 + // found rather than found-and-refused. The delete case is the one that
377 + // matters: it removes a directory tree.
378 + as_cli_actor(&mut h, other.id).await;
379 +
380 + let resp = h
381 + .client
382 + .get("/api/internal/creator/repos/private-thing")
383 + .await;
384 + assert_eq!(resp.status, 404, "info leaked another user's repo");
385 +
386 + let resp = h
387 + .client
388 + .put_json(
389 + "/api/internal/creator/repos/private-thing/visibility",
390 + r#"{"visibility":"public"}"#,
391 + )
392 + .await;
393 + assert_eq!(
394 + resp.status, 404,
395 + "set-visibility reached another user's repo"
396 + );
397 +
398 + let resp = h
399 + .client
400 + .delete("/api/internal/creator/repos/private-thing")
401 + .await;
402 + assert_eq!(resp.status, 404, "delete reached another user's repo");
403 +
404 + assert!(
405 + db::git_repos::get_repo_by_id(&h.db, repo.id)
406 + .await
407 + .unwrap()
408 + .is_some(),
409 + "the repo survived none of that"
410 + );
411 + }
412 +
413 + #[tokio::test]
414 + async fn cli_repo_name_traversal_is_rejected() {
415 + let mut h = cli_harness().await;
416 + h.signup("grace", "grace@example.com", "password123").await;
417 + let user = db::users::get_user_by_username(&h.db, &db::Username::from_trusted("grace".into()))
418 + .await
419 + .unwrap()
420 + .unwrap();
421 +
422 + as_cli_actor(&mut h, user.id).await;
423 +
424 + // Delete builds a filesystem path from this string, so the validation runs
425 + // before the lookup rather than after.
426 + let resp = h
427 + .client
428 + .delete("/api/internal/creator/repos/..%2F..%2Fetc")
429 + .await;
430 + assert!(
431 + resp.status == 404 || resp.status == 400,
432 + "traversal answered {}",
433 + resp.status
434 + );
435 + }
@@ -9,6 +9,7 @@
9 9 mod creators;
10 10 mod git;
11 11 mod items;
12 + mod repos;
12 13 mod synckit;
13 14 mod uploads;
14 15
@@ -46,6 +47,29 @@
46 47 get(creators::creator_project_items),
47 48 )
48 49 .route_get("/api/internal/creator/stats", get(creators::creator_stats))
50 + // Git repo + SSH key management for the CLI. Keyed by repo NAME, not
51 + // id: the caller is a person at a terminal who has the name and would
52 + // otherwise have to look a UUID up first. See internal/repos.rs.
53 + .route_get("/api/internal/creator/repos", get(repos::repo_list))
54 + .route(
55 + "/api/internal/creator/repos/{name}",
56 + with_csrf_skip(
57 + INTERNAL_SKIP,
58 + get(repos::repo_info).delete(repos::repo_delete),
59 + ),
60 + )
61 + .route(
62 + "/api/internal/creator/repos/{name}/visibility",
63 + put_csrf_skip(INTERNAL_SKIP, repos::repo_set_visibility),
64 + )
65 + .route(
66 + "/api/internal/creator/repos/{name}/description",
67 + put_csrf_skip(INTERNAL_SKIP, repos::repo_set_description),
68 + )
69 + .route(
70 + "/api/internal/creator/ssh-keys/{fingerprint}",
71 + delete_csrf_skip(INTERNAL_SKIP, repos::key_remove),
72 + )
49 73 .route(
50 74 "/api/internal/creator/items",
51 75 post_csrf_skip(INTERNAL_SKIP, items::create_item),
@@ -1,0 +1,17 @@
1 + -- Push-created repositories start private.
2 + --
3 + -- Migration 023 set this column's default to 'public', so the first push to a
4 + -- name that did not exist yet both created the repository and published it,
5 + -- listed on the /git explore page, in one action that git itself reports as an
6 + -- ordinary push. There was no confirmation and, until the CLI gained `repo
7 + -- set-visibility`, no supported way back: the management verbs lived behind an
8 + -- sshd path the live SSH front door does not use.
9 + --
10 + -- Publishing should be something a person does on purpose. Creating a repo and
11 + -- publishing it are now two acts rather than one, and the second is
12 + -- `repo set-visibility <name> public`.
13 + --
14 + -- Existing rows are deliberately untouched. A column default applies only to
15 + -- inserts, so every repository already published stays published; flipping
16 + -- those would silently unpublish work people have linked to.
17 + ALTER TABLE git_repos ALTER COLUMN visibility SET DEFAULT 'private';
@@ -1,0 +1,234 @@
1 + //! Internal git repository and SSH key management for the CLI.
2 + //!
3 + //! These verbs used to live only in [`crate::git_ssh`], reachable through
4 + //! sshd's `command=` prefix in `authorized_keys` calling `mnw-admin git-auth`.
5 + //! The git transport moved to mnw-cli's russh server and the management
6 + //! commands did not come with it, so `ssh cli.makenot.work repo list` answered
7 + //! "Unknown command: repo" while the implementation sat in the tree, working,
8 + //! behind a door nothing knocked on. That gap is how an accidental push
9 + //! published a repo with no supported way to unpublish it.
10 + //!
11 + //! Repos are addressed by NAME here rather than by id. The browser API keys on
12 + //! `GitRepoId` because a page has already loaded the row it is acting on; a
13 + //! person typing into a terminal has the name and nothing else, and making them
14 + //! look a UUID up first would be the kind of friction that sends them back to
15 + //! the web UI.
16 +
17 + use axum::{
18 + Json,
19 + extract::{Path, State},
20 + response::IntoResponse,
21 + };
22 + use serde::{Deserialize, Serialize};
23 + use sqlx::PgPool;
24 +
25 + use crate::{
26 + auth::{InternalActor, ServiceAuth},
27 + db::{self, UserId, Visibility},
28 + error::{AppError, Result},
29 + validation::validate_git_repo_name,
30 + };
31 +
32 + // ── Wire types ──
33 +
34 + /// One repository, as the CLI renders it in `repo list`.
35 + #[derive(Serialize)]
36 + pub(super) struct CliRepo {
37 + name: String,
38 + visibility: Visibility,
39 + description: String,
40 + created_at: String,
41 + }
42 +
43 + /// A single repository plus the counts `repo info` prints.
44 + #[derive(Serialize)]
45 + pub(super) struct CliRepoInfo {
46 + name: String,
47 + visibility: Visibility,
48 + description: String,
49 + created_at: String,
50 + open_issues: i64,
51 + closed_issues: i64,
52 + }
53 +
54 + #[derive(Deserialize)]
55 + pub(super) struct SetVisibilityRequest {
56 + pub visibility: Visibility,
57 + }
58 +
59 + #[derive(Deserialize)]
60 + pub(super) struct SetDescriptionRequest {
61 + pub description: String,
62 + }
63 +
64 + /// Resolve a repo by name for the acting user, or 404.
65 + ///
66 + /// The name is validated before the lookup rather than after. The query would
67 + /// simply miss on a bogus name, but `repo delete` builds a filesystem path from
68 + /// this same string, and rejecting traversal and control characters at the one
69 + /// place every verb passes through is cheaper than trusting each of them to
70 + /// remember (ultra-fuzz Sec M2).
71 + async fn resolve_repo(db: &PgPool, user_id: UserId, name: &str) -> Result<db::DbGitRepo> {
72 + validate_git_repo_name(name).map_err(|_| AppError::NotFound)?;
73 + db::git_repos::get_repo_by_user_and_name(db, user_id, name)
74 + .await?
75 + .ok_or(AppError::NotFound)
76 + }
77 +
78 + // ── Repositories ──
79 +
80 + /// GET /api/internal/creator/repos
81 + #[tracing::instrument(skip_all, name = "internal::cli_repo_list")]
82 + pub(super) async fn repo_list(
83 + State(db): State<PgPool>,
84 + actor: InternalActor,
85 + _auth: ServiceAuth,
86 + ) -> Result<impl IntoResponse> {
87 + let repos = db::git_repos::get_repos_by_user(&db, actor.user_id()).await?;
88 + let data: Vec<CliRepo> = repos
89 + .into_iter()
90 + .map(|r| CliRepo {
91 + name: r.name,
92 + visibility: r.visibility,
93 + description: r.description,
94 + created_at: r.created_at.format("%Y-%m-%d").to_string(),
95 + })
96 + .collect();
97 + Ok(Json(data))
98 + }
99 +
100 + /// GET /api/internal/creator/repos/{name}
101 + #[tracing::instrument(skip_all, name = "internal::cli_repo_info")]
102 + pub(super) async fn repo_info(
103 + State(db): State<PgPool>,
104 + actor: InternalActor,
105 + _auth: ServiceAuth,
106 + Path(name): Path<String>,
107 + ) -> Result<impl IntoResponse> {
108 + let repo = resolve_repo(&db, actor.user_id(), &name).await?;
109 + let (open_issues, closed_issues) = db::issues::get_issue_counts(&db, repo.id).await?;
110 +
111 + Ok(Json(CliRepoInfo {
112 + name: repo.name,
113 + visibility: repo.visibility,
114 + description: repo.description,
115 + created_at: repo.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
116 + open_issues,
117 + closed_issues,
118 + }))
119 + }
120 +
121 + /// PUT /api/internal/creator/repos/{name}/visibility
122 + #[tracing::instrument(skip_all, name = "internal::cli_repo_set_visibility")]
123 + pub(super) async fn repo_set_visibility(
124 + State(db): State<PgPool>,
125 + actor: InternalActor,
126 + _auth: ServiceAuth,
127 + Path(name): Path<String>,
128 + Json(req): Json<SetVisibilityRequest>,
129 + ) -> Result<impl IntoResponse> {
130 + let repo = resolve_repo(&db, actor.user_id(), &name).await?;
131 + db::git_repos::update_visibility(&db, repo.id, req.visibility).await?;
132 + Ok(Json(serde_json::json!({ "visibility": req.visibility })))
133 + }
134 +
135 + /// PUT /api/internal/creator/repos/{name}/description
136 + #[tracing::instrument(skip_all, name = "internal::cli_repo_set_description")]
137 + pub(super) async fn repo_set_description(
138 + State(db): State<PgPool>,
139 + actor: InternalActor,
140 + _auth: ServiceAuth,
141 + Path(name): Path<String>,
142 + Json(req): Json<SetDescriptionRequest>,
143 + ) -> Result<impl IntoResponse> {
144 + let repo = resolve_repo(&db, actor.user_id(), &name).await?;
145 + db::git_repos::update_repo_settings(&db, repo.id, &req.description, repo.visibility).await?;
146 + Ok(Json(serde_json::json!({ "description": req.description })))
147 + }
148 +
149 + /// DELETE /api/internal/creator/repos/{name}
150 + ///
151 + /// Drops the row and then the bare repo on disk. The row goes first: a failure
152 + /// to remove the directory leaves an orphaned directory, which is recoverable,
153 + /// where the other order could leave a row pointing at nothing, which reads as
154 + /// a repo that exists and cannot be served.
155 + #[tracing::instrument(skip_all, name = "internal::cli_repo_delete")]
156 + pub(super) async fn repo_delete(
157 + State(db): State<PgPool>,
158 + actor: InternalActor,
159 + _auth: ServiceAuth,
160 + Path(name): Path<String>,
161 + ) -> Result<impl IntoResponse> {
162 + let repo = resolve_repo(&db, actor.user_id(), &name).await?;
163 + let username = db::users::get_user_by_id(&db, actor.user_id())
164 + .await?
165 + .ok_or(AppError::NotFound)?
166 + .username;
167 +
168 + db::git_repos::delete_repo(&db, repo.id).await?;
169 +
170 + let git_root = std::env::var("GIT_REPOS_PATH").unwrap_or_else(|_| "/opt/git".to_string());
171 + let git_root_path = std::path::Path::new(&git_root);
172 + let repo_dir = git_root_path
173 + .join(username.as_str())
174 + .join(format!("{name}.git"));
175 +
176 + if repo_dir.exists() {
177 + // Canonicalize both sides and re-check containment. `name` is already
178 + // validated, so this is belt and braces, but the operation is a
179 + // recursive delete and the cost of being wrong is unbounded.
180 + let canonical = repo_dir
181 + .canonicalize()
182 + .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize repo path: {e}")))?;
183 + let canonical_root = git_root_path
184 + .canonicalize()
185 + .map_err(|e| AppError::Internal(anyhow::anyhow!("canonicalize git root: {e}")))?;
186 + if !canonical.starts_with(&canonical_root) {
187 + return Err(AppError::Internal(anyhow::anyhow!(
188 + "repo path escapes git root"
189 + )));
190 + }
191 + std::fs::remove_dir_all(&canonical)
192 + .map_err(|e| AppError::Internal(anyhow::anyhow!("remove repo directory: {e}")))?;
193 + }
194 +
195 + Ok(Json(serde_json::json!({ "deleted": name })))
196 + }
197 +
198 + // ── SSH keys ──
199 +
200 + // `key list` is served by the pre-existing `git::list_ssh_keys` on this same
201 + // path; adding a second handler for it collided at router build time. Only the
202 + // removal verb is new.
203 +
204 + /// DELETE /api/internal/creator/ssh-keys/{fingerprint}
205 + ///
206 + /// Rewrites `authorized_keys` after the row is gone, exactly as the old
207 + /// `key rm` did. mnw-cli authenticates from the database and does not read that
208 + /// file, so this is not what makes the removal take effect; it is there because
209 + /// the sshd path may still be wired on a host, and a key removed from one door
210 + /// but not the other is a key the user believes is gone. A failure to rewrite is
211 + /// logged rather than returned: the authoritative removal already succeeded, and
212 + /// reporting failure would invite a retry that cannot help.
213 + #[tracing::instrument(skip_all, name = "internal::cli_key_remove")]
214 + pub(super) async fn key_remove(
215 + State(db): State<PgPool>,
216 + actor: InternalActor,
217 + _auth: ServiceAuth,
218 + Path(fingerprint): Path<String>,
219 + ) -> Result<impl IntoResponse> {
220 + let deleted =
221 + db::ssh_keys::delete_key_by_fingerprint(&db, actor.user_id(), &fingerprint).await?;
222 + if !deleted {
223 + return Err(AppError::NotFound);
224 + }
225 +
226 + if let Err(e) = crate::git_ssh::write_authorized_keys(&db, false).await {
227 + tracing::warn!(
228 + error = %e,
229 + "SSH key removed from the database but authorized_keys could not be rewritten"
230 + );
231 + }
232 +
233 + Ok(Json(serde_json::json!({ "removed": fingerprint })))
234 + }