Skip to main content

memstead_cli/commands/
domain.rs

1//! `memstead domain …` — manage the keys that authorise publishing under a
2//! `<domain>:<handle>` scope.
3//!
4//! Domain publishing needs no Memstead account: a publisher proves control of a
5//! domain by hosting a small signed-key manifest at
6//! `https://<domain>/.well-known/memstead-publishing.json` and signing each
7//! publish with the matching private key. This command produces both halves:
8//!
9//! - **`keygen`** generates a signing keypair, stores the private key locally,
10//!   and prints the manifest JSON to host.
11//! - **`manifest`** re-prints the manifest for an existing key (e.g. to change
12//!   the abuse contacts) without rotating the key.
13//!
14//! `memstead publish --scope <domain>:<handle>` then signs transparently using
15//! the stored key.
16
17use clap::{Parser, Subcommand};
18use serde_json::json;
19
20use crate::CliError;
21use crate::auth::domain_key;
22use crate::output::{ExitKind, print_json, print_markdown};
23use crate::setup::CliContext;
24
25#[derive(Subcommand, Debug)]
26pub enum DomainAction {
27    /// Generate a signing keypair for a domain and print the manifest to host.
28    Keygen(KeygenArgs),
29    /// Re-print the `.well-known` manifest for a domain's existing key.
30    Manifest(ManifestArgs),
31}
32
33#[derive(Parser, Debug)]
34pub struct KeygenArgs {
35    /// The domain you control, e.g. `acme.com`. Mems publish under
36    /// `<domain>:<handle>`.
37    #[arg(long, value_name = "DOMAIN")]
38    pub domain: String,
39
40    /// Abuse / ownership contact (email or URI). Repeatable; at least one is
41    /// required — a takedown notice must be able to reach you.
42    #[arg(long = "contact", value_name = "EMAIL_OR_URI", required = true)]
43    pub contacts: Vec<String>,
44
45    /// Replace an existing key for this domain (rotation). The hosted manifest
46    /// must then be updated to the new public key.
47    #[arg(long)]
48    pub force: bool,
49}
50
51#[derive(Parser, Debug)]
52pub struct ManifestArgs {
53    /// The domain whose stored key to render a manifest for.
54    #[arg(long, value_name = "DOMAIN")]
55    pub domain: String,
56
57    /// Abuse / ownership contact (email or URI). Repeatable; at least one is
58    /// required.
59    #[arg(long = "contact", value_name = "EMAIL_OR_URI", required = true)]
60    pub contacts: Vec<String>,
61}
62
63pub fn run(ctx: &CliContext, action: DomainAction) -> anyhow::Result<()> {
64    match action {
65        DomainAction::Keygen(args) => keygen(ctx, args),
66        DomainAction::Manifest(args) => manifest(ctx, args),
67    }
68}
69
70fn keygen(ctx: &CliContext, args: KeygenArgs) -> anyhow::Result<()> {
71    let domain = normalize_domain(&args.domain)?;
72    let public_key = domain_key::generate(&domain, args.force)
73        .map_err(|e| CliError::new(ExitKind::Generic, "DOMAIN_KEYGEN_FAILED", e.to_string()))?;
74    emit_manifest(ctx, &domain, &public_key, &args.contacts, true)
75}
76
77fn manifest(ctx: &CliContext, args: ManifestArgs) -> anyhow::Result<()> {
78    let domain = normalize_domain(&args.domain)?;
79    let signing = domain_key::load(&domain)
80        .map_err(|e| CliError::new(ExitKind::NotFound, "DOMAIN_KEY_NOT_FOUND", e.to_string()))?;
81    let public_key = domain_key::public_key_string(&signing);
82    emit_manifest(ctx, &domain, &public_key, &args.contacts, false)
83}
84
85/// Print the manifest to host plus where to host it. `generated` toggles the
86/// "new key" framing vs the re-print framing.
87fn emit_manifest(
88    ctx: &CliContext,
89    domain: &str,
90    public_key: &str,
91    contacts: &[String],
92    generated: bool,
93) -> anyhow::Result<()> {
94    let manifest =
95        domain_key::manifest_json(std::slice::from_ref(&public_key.to_string()), contacts);
96    let url = format!("https://{domain}/.well-known/memstead-publishing.json");
97    if ctx.json {
98        print_json(&json!({
99            "domain": domain,
100            "public_key": public_key,
101            "manifest_url": url,
102            "manifest": manifest,
103            "generated": generated,
104        }))?;
105    } else {
106        let pretty = serde_json::to_string_pretty(&manifest).unwrap_or_default();
107        let lead = if generated {
108            format!(
109                "# Domain signing key for `{domain}`\n\nA new keypair was generated and the private key stored locally."
110            )
111        } else {
112            format!("# Manifest for `{domain}`")
113        };
114        print_markdown(&format!(
115            "{lead}\n\n\
116             Host this exact file at:\n\n    {url}\n\n\
117             ```json\n{pretty}\n```\n\n\
118             Then publish with `memstead publish --scope {domain}:<handle>` — \
119             the CLI signs each publish with the stored key. Remove the key from \
120             the manifest (or take the manifest down) to revoke."
121        ));
122    }
123    Ok(())
124}
125
126/// Lowercase + validate the domain as a publishable scope domain (dot-separated
127/// labels, no scheme, no path, no `:`). Mirrors the registry's domain grammar
128/// closely enough to fail fast on obvious mistakes.
129fn normalize_domain(raw: &str) -> anyhow::Result<String> {
130    let d = raw.trim().to_ascii_lowercase();
131    let looks_like_domain = !d.is_empty()
132        && d.len() <= 253
133        && d.contains('.')
134        && !d.contains('/')
135        && !d.contains(':')
136        && d.split('.').all(|label| {
137            !label.is_empty()
138                && label.len() <= 63
139                && !label.starts_with('-')
140                && !label.ends_with('-')
141                && label
142                    .bytes()
143                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
144        });
145    if !looks_like_domain {
146        return Err(CliError::new(
147            ExitKind::Validation,
148            "INVALID_DOMAIN",
149            format!("{raw:?} is not a valid domain (expected e.g. `acme.com`, no scheme or path)"),
150        )
151        .into());
152    }
153    Ok(d)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn normalizes_and_validates_domains() {
162        assert_eq!(normalize_domain("Acme.COM").unwrap(), "acme.com");
163        assert_eq!(
164            normalize_domain(" sub.acme.co.uk ").unwrap(),
165            "sub.acme.co.uk"
166        );
167        for bad in [
168            "nodot",
169            "has space.com",
170            "https://acme.com",
171            "acme.com:demo",
172            "acme.com/x",
173        ] {
174            assert!(normalize_domain(bad).is_err(), "{bad} should be rejected");
175        }
176    }
177}