Skip to main content

memstead_cli/commands/
admin.rs

1//! `memstead admin` — admin-only moderation over the registry's admin
2//! endpoints. There is deliberately no web admin panel: moderation runs
3//! from the terminal, gated server-side by the `MEMSTEAD_ADMINS`
4//! allowlist (the CLI just sends the caller's token; a non-admin gets a
5//! 403). Every action is recorded in the registry's append-only audit
6//! log. Like `unpublish`, these destructive calls never auto-trigger the
7//! Device Flow — authenticate first with `memstead login`.
8
9use clap::{Parser, Subcommand};
10use serde_json::json;
11
12use crate::CliError;
13use crate::auth::resolve_token;
14use crate::output::{ExitKind, print_json, print_markdown};
15use crate::registry::{self, ApiErrorBody, PublishError};
16use crate::setup::CliContext;
17
18#[derive(Subcommand, Debug)]
19pub enum AdminAction {
20    /// Take down a published mem (admin-only): deny-list its bytes,
21    /// tombstone every version, and burn the `<scope>/<name>` so neither
22    /// the bytes nor the name can be re-published. The notice reference
23    /// is recorded as the DSA statement-of-reasons in the audit log.
24    Takedown(TakedownArgs),
25
26    /// Add a canonical-bytes SHA-256 to the content deny-list (admin-only)
27    /// so a publish of exactly those bytes is refused — even before they
28    /// are ever uploaded.
29    Denylist(DenylistArgs),
30}
31
32#[derive(Parser, Debug)]
33pub struct TakedownArgs {
34    /// `<scope>/<name>` of the mem to take down (e.g. `github:alice/my-mem`).
35    #[arg(value_name = "SCOPE/NAME")]
36    pub target: String,
37
38    /// Statement-of-reasons / notice reference recorded with the action
39    /// (e.g. an abuse-ticket id or legal-notice ref). Required so the
40    /// audit log can justify the takedown.
41    #[arg(long, value_name = "REF")]
42    pub notice: String,
43
44    /// Explicit token override. Takes precedence over `MEMSTEAD_TOKEN`
45    /// and stored credentials.
46    #[arg(long, value_name = "TOKEN")]
47    pub token: Option<String>,
48
49    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
50    #[arg(long, value_name = "URL")]
51    pub registry: Option<String>,
52}
53
54#[derive(Parser, Debug)]
55pub struct DenylistArgs {
56    /// Canonical-bytes SHA-256 (64 hex chars) to block.
57    #[arg(value_name = "SHA256")]
58    pub sha256: String,
59
60    /// Free-text reason recorded on the deny-list row.
61    #[arg(long, value_name = "TEXT")]
62    pub reason: Option<String>,
63
64    /// Explicit token override. Takes precedence over `MEMSTEAD_TOKEN`
65    /// and stored credentials.
66    #[arg(long, value_name = "TOKEN")]
67    pub token: Option<String>,
68
69    /// Registry URL (overrides `MEMSTEAD_REGISTRY`; defaults to https://memstead.io).
70    #[arg(long, value_name = "URL")]
71    pub registry: Option<String>,
72}
73
74pub fn run_takedown(ctx: &CliContext, args: TakedownArgs) -> anyhow::Result<()> {
75    let (scope, name) = registry::parse_ref(&args.target).ok_or_else(|| {
76        CliError::new(
77            ExitKind::Generic,
78            "INVALID_INPUT",
79            format!("expected `<scope>/<name>`, got `{}`", args.target),
80        )
81    })?;
82    if args.notice.trim().is_empty() {
83        return Err(CliError::new(
84            ExitKind::Validation,
85            "INVALID_INPUT",
86            "--notice must be a non-empty statement-of-reasons reference",
87        )
88        .into());
89    }
90
91    let base = registry::registry_base(args.registry.as_deref());
92    let host = registry::registry_host(&base);
93    let client = registry::build_http()?;
94    let token = resolve_admin_token(&host, args.token.as_deref())?;
95
96    match registry::admin_takedown(&client, &base, &scope, &name, &args.notice, &token) {
97        Ok(resp) => {
98            if ctx.json {
99                print_json(&json!({
100                    "ok": true,
101                    "action": "takedown",
102                    "scope": resp.scope,
103                    "name": resp.name,
104                    "notice": args.notice,
105                }))?;
106            } else {
107                print_markdown(&format!(
108                    "# Took down {}/{}\n\n- Bytes deny-listed, every version tombstoned, name burned.\n- The `{}/{}` name can no longer be re-published.\n- Notice: {}",
109                    resp.scope, resp.name, resp.scope, resp.name, args.notice,
110                ));
111            }
112            Ok(())
113        }
114        Err(e) => Err(map_admin_error(e).into()),
115    }
116}
117
118pub fn run_denylist(ctx: &CliContext, args: DenylistArgs) -> anyhow::Result<()> {
119    let sha = args.sha256.trim().to_ascii_lowercase();
120    if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
121        return Err(CliError::new(
122            ExitKind::Validation,
123            "INVALID_INPUT",
124            format!("expected a 64-hex-char SHA-256, got `{}`", args.sha256),
125        )
126        .into());
127    }
128
129    let base = registry::registry_base(args.registry.as_deref());
130    let host = registry::registry_host(&base);
131    let client = registry::build_http()?;
132    let token = resolve_admin_token(&host, args.token.as_deref())?;
133
134    match registry::admin_denylist(&client, &base, &sha, args.reason.as_deref(), &token) {
135        Ok(resp) => {
136            if ctx.json {
137                print_json(&json!({
138                    "ok": true,
139                    "action": "denylist",
140                    "content_sha256": resp.content_sha256,
141                }))?;
142            } else {
143                print_markdown(&format!(
144                    "# Deny-listed {}\n\n- A publish of these exact bytes is now refused.",
145                    resp.content_sha256,
146                ));
147            }
148            Ok(())
149        }
150        Err(e) => Err(map_admin_error(e).into()),
151    }
152}
153
154/// Admin actions are destructive — resolve the token without the
155/// Device-Flow fallback (same posture as `unpublish`).
156fn resolve_admin_token(host: &str, flag: Option<&str>) -> anyhow::Result<String> {
157    match resolve_token(host, flag)? {
158        Some(r) => Ok(r.token),
159        None => Err(CliError::new(
160            ExitKind::Generic,
161            "NOT_AUTHENTICATED",
162            "not logged in — run `memstead login` or set MEMSTEAD_TOKEN \
163             (admin actions do not auto-trigger Device Flow)",
164        )
165        .into()),
166    }
167}
168
169fn map_admin_error(err: PublishError) -> CliError {
170    match err {
171        PublishError::Io(e) => {
172            CliError::new(ExitKind::Generic, crate::INTERNAL_CODE, format!("io: {e}"))
173        }
174        PublishError::Network(e) => CliError::new(
175            ExitKind::Generic,
176            "NETWORK_ERROR",
177            format!("network error: {e}"),
178        ),
179        PublishError::Malformed(e) => CliError::new(
180            ExitKind::Generic,
181            "REGISTRY_MALFORMED_RESPONSE",
182            format!("registry sent an unparseable success response: {e}"),
183        ),
184        PublishError::Raw { status, text } => CliError::new(
185            ExitKind::Generic,
186            "REGISTRY_ERROR",
187            format!("registry returned {status}: {text}"),
188        ),
189        PublishError::Api { status, envelope } => map_admin_api_error(status, envelope),
190    }
191}
192
193fn map_admin_api_error(status: reqwest::StatusCode, envelope: ApiErrorBody) -> CliError {
194    let (kind, code): (ExitKind, &'static str) = match status.as_u16() {
195        401 => (ExitKind::Generic, "NOT_AUTHENTICATED"),
196        403 => (ExitKind::Generic, "FORBIDDEN"),
197        404 => (ExitKind::NotFound, "REGISTRY_NOT_FOUND"),
198        _ => (ExitKind::Generic, "REGISTRY_ERROR"),
199    };
200    let mut msg = match status.as_u16() {
201        401 => {
202            "unauthorized — set MEMSTEAD_TOKEN, run `memstead login`, or pass --token".to_string()
203        }
204        403 => {
205            "forbidden — this is an admin-only action; your GitHub login is not in MEMSTEAD_ADMINS"
206                .to_string()
207        }
208        404 => "no such mem on the registry".to_string(),
209        _ => envelope
210            .detail
211            .clone()
212            .unwrap_or_else(|| format!("registry returned {status}")),
213    };
214    if !envelope.error.is_empty() && !msg.to_ascii_lowercase().contains(&envelope.error) {
215        msg = format!("{msg} [{}]", envelope.error);
216    }
217    CliError::new(kind, code, msg)
218}