memstead_cli/commands/
domain.rs1use 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 Keygen(KeygenArgs),
29 Manifest(ManifestArgs),
31}
32
33#[derive(Parser, Debug)]
34pub struct KeygenArgs {
35 #[arg(long, value_name = "DOMAIN")]
38 pub domain: String,
39
40 #[arg(long = "contact", value_name = "EMAIL_OR_URI", required = true)]
43 pub contacts: Vec<String>,
44
45 #[arg(long)]
48 pub force: bool,
49}
50
51#[derive(Parser, Debug)]
52pub struct ManifestArgs {
53 #[arg(long, value_name = "DOMAIN")]
55 pub domain: String,
56
57 #[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
85fn 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
126fn 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}