Skip to main content

max / makenotwork

Add git access provisioning, mnw-cli features, internal API endpoints Server: - SSH key management promoted to dedicated dashboard tab - Per-repo collaborator access (migration 087, SSH auth, API, dashboard UI) - mnw-admin setup-git replaces manual setup-ssh-keys.sh - Internal API endpoints for tags, broadcast, tiers, collections, domains - Blog scheduling via publish_at on internal blog create endpoint mnw-cli: - Bulk item operations (multi-select, bulk publish/unpublish/delete) - Pipe mode uploads (stdin via SSH exec) - SSH commands: broadcast, collections, domain management - TUI screens: collections, tiers, tag search on item detail - Blog post scheduling (Title -> Body -> Schedule flow) - API client methods for all new server endpoints
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-02 21:33 UTC
Commit: 1ccddc930370d6bb79496b3555ff2195444bf928
Parent: 1003d3a
37 files changed, +2316 insertions, -81 deletions
@@ -3385,7 +3385,7 @@
3385 3385
3386 3386 [[package]]
3387 3387 name = "makenotwork"
3388 - version = "0.4.6"
3388 + version = "0.4.7"
3389 3389 dependencies = [
3390 3390 "anyhow",
3391 3391 "argon2",
@@ -23,14 +23,15 @@
23 23 - [ ] Add PoM health check for mnw-cli (port 22 SSH banner check)
24 24
25 25 ## Remaining Features (from design doc)
26 - - [ ] Bulk item operations (select multiple, publish/unpublish/delete)
27 - - [ ] Tag management in TUI
28 - - [ ] Pipe mode uploads (`cat file | ssh cli.makenot.work upload ...`)
29 - - [ ] Blog post scheduling
30 - - [ ] Subscription tier management
31 - - [ ] Collection management
32 - - [ ] Custom domain management screen
33 - - [ ] Broadcast to followers
26 + - [x] Bulk item operations — multi-select (Space) on project items screen, bulk publish/unpublish/delete with confirmation dialog. Selection count shown in status bar.
27 + - [x] Pipe mode uploads — `cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug [--title TITLE] [--price CENTS]`. Reads stdin via SSH channel, auto-creates item, uploads to S3, publishes.
28 + - [x] Broadcast to followers — SSH command: `broadcast "Subject" "Body"`. Server internal endpoint with 24h rate limit, fire-and-forget email delivery.
29 + - [x] Custom domain management — SSH commands: `domain`, `domain add`, `domain verify`, `domain remove`. Full flow: add domain, DNS TXT verification via Cloudflare DoH, removal with cache invalidation.
30 + - [x] Collection management — SSH command: `collections` (list). Server internal endpoints for create/delete. API client wired.
31 + - [x] Tag management — Server internal endpoints for list/add/remove item tags + tag search. API client wired. TUI screen deferred.
32 + - [x] Subscription tier management — Server internal endpoint for listing tiers by project. API client wired. TUI screen deferred (read-only — tier creation requires Stripe).
33 + - [x] Blog post scheduling — server internal blog create now accepts `publish_at` (ISO 8601). TUI blog create flow: Title -> Body -> Schedule (enter datetime or leave empty for draft). Scheduled posts shown as "sched YYYY-MM-DDTHH:MM" in post list. Server scheduler auto-publishes when time arrives.
34 + - [x] TUI screens — Tags: inline on item detail screen (shows current tags, `t` to search+add, Enter to confirm). Tiers: `t` from project screen opens read-only tier list. Collections: `c` from home screen opens collection list with navigation.
34 35
35 36 ## Key Paths
36 37 ```
M mnw-cli/src/api.rs +176 -8
@@ -109,6 +109,7 @@
109 109 pub title: String,
110 110 pub slug: String,
111 111 pub is_published: bool,
112 + pub publish_at: Option<String>,
112 113 pub created_at: String,
113 114 pub updated_at: String,
114 115 }
@@ -203,6 +204,60 @@
203 204 pub created_at: String,
204 205 }
205 206
207 + /// A tag on an item or from search.
208 + #[derive(Debug, Clone, Deserialize, Serialize)]
209 + pub struct TagInfo {
210 + pub id: String,
211 + pub name: String,
212 + pub slug: String,
213 + pub is_primary: bool,
214 + }
215 +
216 + /// Result of a broadcast send.
217 + #[derive(Debug, Deserialize)]
218 + pub struct BroadcastResult {
219 + pub success: bool,
220 + pub recipient_count: usize,
221 + }
222 +
223 + /// A subscription tier.
224 + #[derive(Debug, Clone, Deserialize, Serialize)]
225 + pub struct TierInfo {
226 + pub id: String,
227 + pub name: String,
228 + pub description: String,
229 + pub price_cents: i32,
230 + pub is_active: bool,
231 + }
232 +
233 + /// A collection.
234 + #[derive(Debug, Clone, Deserialize, Serialize)]
235 + pub struct CollectionInfo {
236 + pub id: String,
237 + pub slug: String,
238 + pub title: String,
239 + pub description: String,
240 + pub is_public: bool,
241 + pub item_count: i64,
242 + }
243 +
244 + /// Custom domain info.
245 + #[derive(Debug, Clone, Deserialize, Serialize)]
246 + pub struct DomainInfo {
247 + pub id: String,
248 + pub domain: String,
249 + pub verified: bool,
250 + pub verification_token: String,
251 + pub instructions: Option<String>,
252 + }
253 +
254 + /// Domain verification result.
255 + #[derive(Debug, Deserialize)]
256 + pub struct DomainVerifyResult {
257 + pub verified: bool,
258 + pub message: String,
259 + }
260 +
206 261 /// Response from the git authorize endpoint.
207 262 #[derive(Debug, Deserialize)]
208 263 pub struct GitAuthResponse {
@@ -619,7 +674,7 @@
619 674 json_response(resp, "list_blog_posts").await
620 675 }
621 676
622 - /// Create a blog post.
677 + /// Create a blog post, optionally scheduled for future publication.
623 678 pub async fn create_blog_post(
624 679 &self,
625 680 user_id: &str,
@@ -627,19 +682,24 @@
627 682 title: &str,
628 683 body_markdown: &str,
629 684 publish: bool,
685 + publish_at: Option<&str>,
630 686 ) -> anyhow::Result<BlogPost> {
631 687 let url = format!("{}/api/internal/creator/blog", self.base_url);
688 + let mut body = serde_json::json!({
689 + "user_id": user_id,
690 + "project_id": project_id,
691 + "title": title,
692 + "body_markdown": body_markdown,
693 + "publish": publish,
694 + });
695 + if let Some(pa) = publish_at {
696 + body["publish_at"] = serde_json::Value::String(pa.to_string());
697 + }
632 698 let resp = self
633 699 .http
634 700 .post(&url)
635 701 .bearer_auth(&self.service_token)
636 - .json(&serde_json::json!({
637 - "user_id": user_id,
638 - "project_id": project_id,
639 - "title": title,
640 - "body_markdown": body_markdown,
641 - "publish": publish,
642 - }))
702 + .json(&body)
643 703 .send()
644 704 .await?;
645 705
@@ -896,4 +956,112 @@
896 956
897 957 json_response(resp, "list_ssh_keys").await
898 958 }
959 +
960 + // ── Tags ──
961 +
962 + pub async fn list_item_tags(&self, user_id: &str, item_id: &str) -> anyhow::Result<Vec<TagInfo>> {
963 + let url = format!("{}/api/internal/creator/items/{}/tags", self.base_url, item_id);
964 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
965 + .query(&[("user_id", user_id)]).send().await?;
966 + json_response(resp, "list_item_tags").await
967 + }
968 +
969 + pub async fn search_tags(&self, query: &str) -> anyhow::Result<Vec<TagInfo>> {
970 + let url = format!("{}/api/internal/tags/search", self.base_url);
971 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
972 + .query(&[("q", query)]).send().await?;
973 + json_response(resp, "search_tags").await
974 + }
975 +
976 + pub async fn add_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
977 + let url = format!("{}/api/internal/creator/items/tags", self.base_url);
978 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
979 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
980 + .send().await?;
981 + empty_response(resp, "add_item_tag").await
982 + }
983 +
984 + pub async fn remove_item_tag(&self, user_id: &str, item_id: &str, tag_id: &str) -> anyhow::Result<()> {
985 + let url = format!("{}/api/internal/creator/items/tags/remove", self.base_url);
986 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
987 + .json(&serde_json::json!({"user_id": user_id, "item_id": item_id, "tag_id": tag_id}))
988 + .send().await?;
989 + empty_response(resp, "remove_item_tag").await
990 + }
991 +
992 + // ── Broadcast ──
993 +
994 + pub async fn send_broadcast(&self, user_id: &str, subject: &str, body: &str) -> anyhow::Result<BroadcastResult> {
995 + let url = format!("{}/api/internal/creator/broadcast", self.base_url);
996 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
997 + .json(&serde_json::json!({"user_id": user_id, "subject": subject, "body": body}))
998 + .send().await?;
999 + json_response(resp, "send_broadcast").await
1000 + }
1001 +
1002 + // ── Tiers ──
1003 +
1004 + pub async fn list_tiers(&self, user_id: &str, project_id: &str) -> anyhow::Result<Vec<TierInfo>> {
1005 + let url = format!("{}/api/internal/creator/projects/{}/tiers", self.base_url, project_id);
1006 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1007 + .query(&[("user_id", user_id)]).send().await?;
1008 + json_response(resp, "list_tiers").await
1009 + }
1010 +
1011 + // ── Collections ──
1012 +
1013 + pub async fn list_collections(&self, user_id: &str) -> anyhow::Result<Vec<CollectionInfo>> {
1014 + let url = format!("{}/api/internal/creator/collections", self.base_url);
1015 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1016 + .query(&[("user_id", user_id)]).send().await?;
1017 + json_response(resp, "list_collections").await
1018 + }
1019 +
1020 + pub async fn create_collection(&self, user_id: &str, slug: &str, title: &str) -> anyhow::Result<serde_json::Value> {
1021 + let url = format!("{}/api/internal/creator/collections", self.base_url);
1022 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1023 + .json(&serde_json::json!({"user_id": user_id, "slug": slug, "title": title}))
1024 + .send().await?;
1025 + json_response(resp, "create_collection").await
1026 + }
1027 +
1028 + pub async fn delete_collection(&self, user_id: &str, collection_id: &str) -> anyhow::Result<()> {
1029 + let url = format!("{}/api/internal/creator/collections/{}", self.base_url, collection_id);
1030 + let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1031 + .query(&[("user_id", user_id)]).send().await?;
1032 + empty_response(resp, "delete_collection").await
1033 + }
1034 +
1035 + // ── Custom Domains ──
1036 +
1037 + pub async fn get_domain(&self, user_id: &str) -> anyhow::Result<Option<DomainInfo>> {
1038 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1039 + let resp = self.http.get(&url).bearer_auth(&self.service_token)
1040 + .query(&[("user_id", user_id)]).send().await?;
1041 + let val: serde_json::Value = json_response(resp, "get_domain").await?;
1042 + if val.is_null() { return Ok(None); }
1043 + Ok(serde_json::from_value(val).ok())
1044 + }
1045 +
1046 + pub async fn add_domain(&self, user_id: &str, domain: &str) -> anyhow::Result<DomainInfo> {
1047 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1048 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1049 + .json(&serde_json::json!({"user_id": user_id, "domain": domain}))
1050 + .send().await?;
1051 + json_response(resp, "add_domain").await
1052 + }
1053 +
1054 + pub async fn verify_domain(&self, user_id: &str) -> anyhow::Result<DomainVerifyResult> {
1055 + let url = format!("{}/api/internal/creator/domain/verify", self.base_url);
1056 + let resp = self.http.post(&url).bearer_auth(&self.service_token)
1057 + .query(&[("user_id", user_id)]).send().await?;
1058 + json_response(resp, "verify_domain").await
1059 + }
1060 +
1061 + pub async fn remove_domain(&self, user_id: &str) -> anyhow::Result<()> {
1062 + let url = format!("{}/api/internal/creator/domain", self.base_url);
1063 + let resp = self.http.delete(&url).bearer_auth(&self.service_token)
1064 + .query(&[("user_id", user_id)]).send().await?;
1065 + empty_response(resp, "remove_domain").await
1066 + }
899 1067 }
@@ -6,6 +6,7 @@
6 6
7 7 use crate::api::{MnwApiClient, UserInfo};
8 8 use crate::format;
9 + use crate::staging;
9 10
10 11 /// Sanitize an API error for display to SSH clients.
11 12 ///
@@ -42,6 +43,9 @@
42 43 let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect();
43 44
44 45 match parts[0] {
46 + "upload" => {
47 + return b"Pipe uploads use stdin. Example:\r\n cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-project\r\n".to_vec();
48 + }
45 49 "projects" => cmd_projects(user, api, json).await,
46 50 "analytics" => {
47 51 let range = parts
@@ -68,6 +72,21 @@
68 72 }
69 73 _ => b"Usage: blog list <project-slug>\r\n".to_vec(),
70 74 },
75 + "broadcast" => {
76 + let subject = parts.get(1).unwrap_or(&"");
77 + let body = parts.get(2).unwrap_or(&"");
78 + cmd_broadcast(user, api, subject, body).await
79 + }
80 + "collections" => cmd_collections(user, api, json).await,
81 + "domain" => match parts.get(1).copied() {
82 + Some("add") => {
83 + let domain = parts.get(2).unwrap_or(&"");
84 + cmd_domain_add(user, api, domain).await
85 + }
86 + Some("verify") => cmd_domain_verify(user, api).await,
87 + Some("remove") => cmd_domain_remove(user, api).await,
88 + _ => cmd_domain_show(user, api).await,
89 + },
71 90 "help" | "--help" | "-h" => help_text(),
72 91 other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
73 92 .into_bytes(),
@@ -305,19 +324,188 @@
305 324 }
306 325 }
307 326
327 + async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> {
328 + if subject.is_empty() || body.is_empty() {
329 + return b"Usage: broadcast \"Subject\" \"Body text\"\r\n".to_vec();
330 + }
331 + match api.send_broadcast(&user.user_id, subject, body).await {
332 + Ok(result) => format!("Broadcast sent to {} followers.\r\n", result.recipient_count).into_bytes(),
333 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
334 + }
335 + }
336 +
337 + async fn cmd_collections(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
338 + match api.list_collections(&user.user_id).await {
339 + Ok(collections) => {
340 + if json {
341 + return serde_json::to_vec_pretty(&collections).unwrap_or_default();
342 + }
343 + if collections.is_empty() {
344 + return b"No collections.\r\n".to_vec();
345 + }
346 + let mut out = format!(
347 + "{:<25} {:<25} {:<8} {:<6}\r\n",
348 + "Title", "Slug", "Status", "Items"
349 + );
350 + out.push_str(&"-".repeat(66));
351 + out.push_str("\r\n");
352 + for c in &collections {
353 + let status = if c.is_public { "public" } else { "draft" };
354 + out.push_str(&format!(
355 + "{:<25} {:<25} {:<8} {:<6}\r\n",
356 + truncate(&c.title, 24),
357 + truncate(&c.slug, 24),
358 + status,
359 + c.item_count,
360 + ));
361 + }
362 + out.into_bytes()
363 + }
364 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
365 + }
366 + }
367 +
368 + async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
369 + match api.get_domain(&user.user_id).await {
370 + Ok(Some(d)) => {
371 + let status = if d.verified { "verified" } else { "pending" };
372 + let mut out = format!("Domain: {} ({})\r\n", d.domain, status);
373 + if !d.verified {
374 + if let Some(ref instr) = d.instructions {
375 + out.push_str(&format!("{}\r\n", instr));
376 + }
377 + }
378 + out.into_bytes()
379 + }
380 + Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(),
381 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
382 + }
383 + }
384 +
385 + async fn cmd_domain_add(user: &UserInfo, api: &MnwApiClient, domain: &str) -> Vec<u8> {
386 + if domain.is_empty() {
387 + return b"Usage: domain add <domain>\r\n".to_vec();
388 + }
389 + match api.add_domain(&user.user_id, domain).await {
390 + Ok(d) => {
391 + let mut out = format!("Domain added: {}\r\n", d.domain);
392 + if let Some(ref instr) = d.instructions {
393 + out.push_str(&format!("{}\r\n", instr));
394 + }
395 + out.push_str("Run `domain verify` after adding the DNS record.\r\n");
396 + out.into_bytes()
397 + }
398 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
399 + }
400 + }
401 +
402 + async fn cmd_domain_verify(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
403 + match api.verify_domain(&user.user_id).await {
404 + Ok(result) => format!("{}\r\n", result.message).into_bytes(),
405 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
406 + }
407 + }
408 +
409 + async fn cmd_domain_remove(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
410 + match api.remove_domain(&user.user_id).await {
411 + Ok(()) => b"Domain removed.\r\n".to_vec(),
412 + Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
413 + }
414 + }
415 +
416 + /// Execute a pipe-mode file upload (called from handler after stdin EOF).
417 + pub async fn execute_pipe_upload(
418 + api: &MnwApiClient,
419 + upload: crate::ssh::handler::PipeUpload,
420 + ) -> anyhow::Result<String> {
421 + let user = &upload.user;
422 + if upload.data.is_empty() {
423 + anyhow::bail!("no data received on stdin");
424 + }
425 +
426 + let ext = upload.filename.rsplit('.').next().unwrap_or("").to_lowercase();
427 + let classification = staging::classify_extension(&ext)
428 + .ok_or_else(|| anyhow::anyhow!("unsupported file type: .{ext}"))?;
429 +
430 + // Find project by slug
431 + let projects = api.get_projects(&user.user_id).await?;
432 + let project = projects.iter()
433 + .find(|p| p.slug == upload.project_slug)
434 + .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?;
435 +
436 + // Create item
437 + let item = api.create_item(
438 + &user.user_id,
439 + &project.id,
440 + &upload.title,
441 + classification.item_type,
442 + upload.price_cents,
443 + ).await?;
444 +
445 + // Presign upload
446 + let presign = api.presign_upload(
447 + &user.user_id,
448 + &item.item_id,
449 + classification.file_type,
450 + &upload.filename,
451 + classification.content_type,
452 + ).await?;
453 +
454 + // Upload data directly to S3
455 + let resp = reqwest::Client::new()
456 + .put(&presign.upload_url)
457 + .header("content-type", classification.content_type)
458 + .body(upload.data)
459 + .send()
460 + .await?;
461 +
462 + if !resp.status().is_success() {
463 + anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
464 + }
465 +
466 + // Confirm
467 + api.confirm_upload(
468 + &user.user_id,
469 + &item.item_id,
470 + classification.file_type,
471 + &presign.s3_key,
472 + ).await?;
473 +
474 + // Publish
475 + api.publish_item(&user.user_id, &item.item_id).await?;
476 +
477 + Ok(format!(
478 + "Uploaded and published: {} ({}, {})\r\n",
479 + upload.title,
480 + staging::format_bytes(resp.content_length().unwrap_or(0)),
481 + classification.item_type,
482 + ))
483 + }
484 +
308 485 fn help_text() -> Vec<u8> {
309 486 b"Usage: ssh cli.makenot.work <command>\r\n\
310 487 \r\n\
311 488 Commands:\r\n\
312 - \x20 projects List your projects\r\n\
313 - \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
314 - \x20 transactions Recent transactions\r\n\
315 - \x20 export sales Export sales as CSV\r\n\
316 - \x20 promo list List promo codes\r\n\
317 - \x20 promo create CODE PCT Create a promo code\r\n\
318 - \x20 blog list SLUG List blog posts for project\r\n\
489 + \x20 projects List your projects\r\n\
490 + \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
491 + \x20 transactions Recent transactions\r\n\
492 + \x20 export sales Export sales as CSV\r\n\
493 + \x20 promo list List promo codes\r\n\
494 + \x20 promo create CODE PCT Create a promo code\r\n\
495 + \x20 blog list SLUG List blog posts for project\r\n\
496 + \x20 broadcast SUBJ BODY Email followers (1/24h limit)\r\n\
497 + \x20 collections List your collections\r\n\
498 + \x20 domain Show custom domain\r\n\
499 + \x20 domain add DOMAIN Add a custom domain\r\n\
500 + \x20 domain verify Verify DNS record\r\n\
501 + \x20 domain remove Remove custom domain\r\n\
502 + \x20 upload [args] Pipe upload (see upload --help)\r\n\
319 503 \r\n\
320 - Add --json to any command for machine-readable output.\r\n"
504 + Add --json to any command for machine-readable output.\r\n\
505 + \r\n\
506 + Pipe uploads:\r\n\
507 + \x20 cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug\r\n\
508 + \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n"
321 509 .to_vec()
322 510 }
323 511
@@ -49,9 +49,9 @@
49 49 - [ ] Add key rotation mechanism (requires server-side re-encryption of all sync_log entries) — deferred post-beta
50 50
51 51 ### Git Access Provisioning
52 - - [ ] Dashboard page for SSH key management (API + HTMX partials exist at `routes/api/ssh_keys.rs`, needs dashboard tab)
53 - - [ ] Per-repo collaborator access (grant push by MNW username, stored in DB, wired to authorized_keys rebuild)
54 - - [ ] Replace manual `setup-ssh-keys.sh` with account-driven key management
52 + - [x] Dashboard page for SSH key management — promoted from collapsed `<details>` in Account tab to dedicated "SSH Keys" dashboard tab. Tab conditionally shown when `git_enabled`. Reuses existing API endpoints and HTMX partials.
53 + - [x] Per-repo collaborator access — `repo_collaborators` table (migration 087) with per-user `can_push` flag. SSH auth (`git_ssh.rs`) checks collaborator table for push and private repo read access. API: `POST/GET/DELETE /api/repos/{id}/collaborators`. Dashboard Code tab shows collaborators per linked repo with add/remove UI.
54 + - [x] Replace manual `setup-ssh-keys.sh` — added `mnw-admin setup-git` subcommand that creates `/opt/git/.ssh`, sets permissions, installs sudoers rule, and verifies syntax. Shell script superseded.
55 55
56 56 ### Moderation
57 57 - [x] Admin "send warning" action: `POST /api/admin/users/{id}/warn` sends policy-violation email without suspending. Records in moderation_actions.
@@ -95,16 +95,27 @@
95 95 }
96 96 };
97 97
98 - // Permission check
98 + // Permission check — owner always has full access, collaborators checked via DB
99 + let is_owner = user_id == owner_user.id;
99 100 match operation {
100 101 GitOperation::ReceivePack => {
101 - if user_id != owner_user.id {
102 - anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
102 + if !is_owner {
103 + let can_push = db::repo_collaborators::can_user_push(pool, repo.id, user_id)
104 + .await
105 + .unwrap_or(false);
106 + if !can_push {
107 + anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
108 + }
103 109 }
104 110 }
105 111 GitOperation::UploadPack | GitOperation::Archive => {
106 - if repo.visibility == db::Visibility::Private && user_id != owner_user.id {
107 - anyhow::bail!("repository not found");
112 + if repo.visibility == db::Visibility::Private && !is_owner {
113 + let is_collab = db::repo_collaborators::is_collaborator(pool, repo.id, user_id)
114 + .await
115 + .unwrap_or(false);
116 + if !is_collab {
117 + anyhow::bail!("repository not found");
118 + }
108 119 }
109 120 }
110 121 }