fabrica

hanna/fabrica

feat(cli): key, repo, group, and token commands; doctor and migrate

1e58b68 · hanna committed on 2026-07-25

Build out the phase-8 command tree over the store and on-disk storage:

- key add/del/list: parse and fingerprint material (literal, @path, or -),
  reject duplicates naming the existing owner, delete by stable ordinal.
- repo add/del/list/rename/path: add creates the DB row then the bare repo
  on disk (rolling the row back on failure) and auto-creates group paths;
  del moves the directory to trash unless --purge; rename is metadata-only.
- group add/del/list over the nested hierarchy.
- token add mints an HS256 JWT (printed once) whose jti is its row id,
  resolving-or-generating the instance secret on first use; del revokes.
- doctor verifies git (>= 2.41), config, and the database; migrate applies
  pending migrations.

Destructive commands confirm on a TTY and require --yes otherwise. auth
gains new_secret(); a shared testutil backs the per-command tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
13 files changed · +1470 −2UnifiedSplit
Cargo.lock +1 −0
@@ -466,6 +466,7 @@ dependencies = [
466466 "auth",
467467 "clap",
468468 "config",
469+ "git",
469470 "model",
470471 "rpassword",
471472 "serde",
crates/auth/src/lib.rs +1 −1
@@ -32,7 +32,7 @@ pub use access::{Access, Permission, Viewer, access};
3232 pub use password::{HashParams, hash_password, verify_password};
3333 pub use scope::{Scope, format_scopes, parse_scopes};
3434 pub use session::{SESSION_TOKEN_BYTES, SessionToken, new_session_token, session_id};
35-pub use token::{Claims, mint_jwt, verify_jwt};
35+pub use token::{Claims, mint_jwt, new_secret, verify_jwt};
3636
3737 /// Errors produced by the authentication primitives.
3838 ///
crates/auth/src/token.rs +11 −0
@@ -18,6 +18,7 @@
1818 use base64::Engine;
1919 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2020 use hmac::{Hmac, Mac};
21+use rand_core::{OsRng, RngCore};
2122 use serde::{Deserialize, Serialize};
2223 use sha2::Sha256;
2324
@@ -25,6 +26,16 @@ use crate::AuthError;
2526
2627 type HmacSha256 = Hmac<Sha256>;
2728
29+/// Generate a fresh instance signing secret: 32 bytes of OS randomness, base64url
30+/// encoded. Written to `auth.secret_file` on first run and reused thereafter; it
31+/// signs both API tokens and session-cookie material.
32+#[must_use]
33+pub fn new_secret() -> String {
34+ let mut bytes = [0u8; 32];
35+ OsRng.fill_bytes(&mut bytes);
36+ URL_SAFE_NO_PAD.encode(bytes)
37+}
38+
2839 /// The fixed JOSE header for every token we mint: `{"alg":"HS256","typ":"JWT"}`.
2940 /// Pre-encoded so minting never re-serializes it, and matched exactly on verify
3041 /// so a token advertising any other algorithm is rejected out of hand (closing
crates/cli/Cargo.toml +1 −0
@@ -13,6 +13,7 @@ publish.workspace = true
1313 config = { workspace = true }
1414 store = { workspace = true }
1515 auth = { workspace = true }
16+git = { workspace = true }
1617 model = { workspace = true }
1718 anyhow = { workspace = true }
1819 clap = { workspace = true }
crates/cli/src/admin_cmd.rs +131 −0
@@ -0,0 +1,131 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica migrate` and `fabrica doctor` — schema application and a startup
6+//! health check.
7+
8+use std::path::Path;
9+use std::process::{Command, ExitCode};
10+
11+use config::Config;
12+
13+use crate::context::block_on;
14+
15+/// The minimum git version fabrica supports (pack transport relies on it).
16+const MIN_GIT: (u32, u32) = (2, 41);
17+
18+/// Run `fabrica migrate`: connect and apply all pending migrations.
19+pub(crate) fn migrate(config_path: Option<&Path>) -> anyhow::Result<ExitCode> {
20+ block_on(async {
21+ let (_config, _store) = crate::context::open_store(config_path).await?;
22+ // `open_store` already migrated on connect; reaching here means success.
23+ println!("migrations applied");
24+ anyhow::Ok(ExitCode::SUCCESS)
25+ })
26+}
27+
28+/// Run `fabrica doctor`: verify the environment fabrica needs to serve.
29+///
30+/// Checks the `git` binary and version, that the configuration loads, and that the
31+/// database is reachable and migratable. `--quiet` suppresses the per-check report
32+/// and only sets the exit code, for use as a container health check.
33+pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<ExitCode> {
34+ let mut ok = true;
35+ let mut check = |label: &str, result: Result<String, String>| {
36+ match &result {
37+ Ok(detail) => {
38+ if !quiet {
39+ println!("ok {label}: {detail}");
40+ }
41+ }
42+ Err(detail) => {
43+ ok = false;
44+ // Failures are always reported, even in quiet mode, on stderr.
45+ eprintln!("FAIL {label}: {detail}");
46+ }
47+ }
48+ };
49+
50+ // Configuration must load before anything else can be checked.
51+ let loaded = match config::load(config_path) {
52+ Ok(loaded) => loaded,
53+ Err(err) => {
54+ eprintln!("FAIL config: {err}");
55+ return Ok(ExitCode::FAILURE);
56+ }
57+ };
58+ check("config", Ok("loaded".to_string()));
59+
60+ check("git", check_git(&loaded.config));
61+ let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;
62+ check("database", database);
63+
64+ if ok {
65+ if !quiet {
66+ println!("all checks passed");
67+ }
68+ Ok(ExitCode::SUCCESS)
69+ } else {
70+ Ok(ExitCode::FAILURE)
71+ }
72+}
73+
74+/// Verify the configured `git` binary is present and recent enough.
75+fn check_git(config: &Config) -> Result<String, String> {
76+ let output = Command::new(&config.git.binary)
77+ .arg("--version")
78+ .output()
79+ .map_err(|err| format!("cannot run {:?}: {err}", config.git.binary))?;
80+ if !output.status.success() {
81+ return Err(format!("{:?} --version failed", config.git.binary));
82+ }
83+ let text = String::from_utf8_lossy(&output.stdout);
84+ let version = parse_git_version(&text)
85+ .ok_or_else(|| format!("could not parse git version from {text:?}"))?;
86+ if version < MIN_GIT {
87+ return Err(format!(
88+ "git {}.{} is too old; need >= {}.{}",
89+ version.0, version.1, MIN_GIT.0, MIN_GIT.1
90+ ));
91+ }
92+ Ok(format!("{}.{} ({})", version.0, version.1, text.trim()))
93+}
94+
95+/// Extract `(major, minor)` from a `git version 2.43.0` string.
96+fn parse_git_version(text: &str) -> Option<(u32, u32)> {
97+ let version = text.split_whitespace().nth(2)?;
98+ let mut parts = version.split('.');
99+ let major = parts.next()?.parse().ok()?;
100+ let minor = parts.next()?.parse().ok()?;
101+ Some((major, minor))
102+}
103+
104+/// Verify the database opens and migrations apply.
105+async fn check_database(config: &Config) -> Result<String, String> {
106+ let store = store::Store::connect(&config.database.url, config.database.max_connections)
107+ .await
108+ .map_err(|err| format!("connect failed: {err}"))?;
109+ store
110+ .migrate()
111+ .await
112+ .map_err(|err| format!("migrate failed: {err}"))?;
113+ Ok(format!("{:?} reachable", store.backend()))
114+}
115+
116+#[cfg(test)]
117+mod tests {
118+ #![allow(clippy::unwrap_used)]
119+
120+ use super::*;
121+
122+ #[test]
123+ fn parses_git_version() {
124+ assert_eq!(parse_git_version("git version 2.43.0"), Some((2, 43)));
125+ assert_eq!(
126+ parse_git_version("git version 2.41.0.windows.1"),
127+ Some((2, 41))
128+ );
129+ assert_eq!(parse_git_version("nonsense"), None);
130+ }
131+}
crates/cli/src/context.rs +43 −1
@@ -10,7 +10,8 @@
1010 //! block on async work.
1111
1212 use std::future::Future;
13-use std::path::Path;
13+use std::os::unix::fs::PermissionsExt;
14+use std::path::{Path, PathBuf};
1415
1516 use config::Config;
1617 use store::Store;
@@ -42,6 +43,47 @@ pub(crate) async fn open_store(config_path: Option<&Path>) -> anyhow::Result<(Co
4243 Ok((loaded.config, store))
4344 }
4445
46+/// Resolve the instance HS256 signing secret, generating and persisting it on
47+/// first use.
48+///
49+/// Resolution order matches the server's: an inline `auth.secret`, else the
50+/// contents of `auth.secret_file` (default `{data_dir}/secret`) if it exists, else
51+/// a freshly generated 32-byte secret written to that path with mode `0600`. The
52+/// same secret signs API tokens and session material, so `token add` and a future
53+/// `serve` agree on one key.
54+///
55+/// # Errors
56+///
57+/// Returns an error if the secret file cannot be read or written.
58+pub(crate) fn ensure_secret(config: &Config) -> anyhow::Result<String> {
59+ if let Some(secret) = &config.auth.secret {
60+ return Ok(secret.expose().to_string());
61+ }
62+ let path = config
63+ .auth
64+ .secret_file
65+ .clone()
66+ .unwrap_or_else(|| config.storage.data_dir.join("secret"));
67+ if path.exists() {
68+ let raw = std::fs::read_to_string(&path)?;
69+ return Ok(raw.trim_end_matches(['\r', '\n']).to_string());
70+ }
71+ let secret = auth::new_secret();
72+ if let Some(parent) = path.parent() {
73+ std::fs::create_dir_all(parent)?;
74+ }
75+ std::fs::write(&path, &secret)?;
76+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
77+ Ok(secret)
78+}
79+
80+/// The absolute path to write into repository hooks so they can invoke `fabrica
81+/// hook post-receive`. Uses the running executable (§3.4), falling back to the
82+/// bare command name if it cannot be resolved.
83+pub(crate) fn hook_binary() -> PathBuf {
84+ std::env::current_exe().unwrap_or_else(|_| PathBuf::from("fabrica"))
85+}
86+
4587 /// Run an async command body to completion on a fresh current-thread runtime.
4688 ///
4789 /// The CLI is a short-lived, single-shot process, so a current-thread runtime is
crates/cli/src/group_cmd.rs +165 −0
@@ -0,0 +1,165 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica group` — the nested, user-owned repository hierarchy.
6+
7+use std::path::Path;
8+use std::process::ExitCode;
9+
10+use clap::Subcommand;
11+use model::Group;
12+use store::Store;
13+
14+use crate::context::{block_on, open_store};
15+use crate::prompt::confirm;
16+
17+/// Actions under `fabrica group`.
18+#[derive(Debug, Subcommand)]
19+pub(crate) enum GroupCommand {
20+ /// Create a group path, creating any missing intermediate groups.
21+ Add {
22+ /// The owning user.
23+ user: String,
24+ /// The group path (`backend/service`).
25+ path: String,
26+ },
27+ /// Delete a group. Descendant groups are removed too; repositories filed under
28+ /// it become ungrouped rather than being deleted.
29+ Del {
30+ /// The owning user.
31+ user: String,
32+ /// The group path.
33+ path: String,
34+ /// Skip the confirmation prompt (required when stdin is not a terminal).
35+ #[arg(long)]
36+ yes: bool,
37+ },
38+ /// List a user's groups.
39+ List {
40+ /// The user whose groups to list.
41+ user: String,
42+ },
43+}
44+
45+/// Dispatch a `group` subcommand.
46+pub(crate) fn run(
47+ command: &GroupCommand,
48+ config_path: Option<&Path>,
49+ json: bool,
50+) -> anyhow::Result<ExitCode> {
51+ match command {
52+ GroupCommand::Add { user, path } => block_on(add(config_path, user, path)),
53+ GroupCommand::Del { user, path, yes } => block_on(del(config_path, user, path, *yes)),
54+ GroupCommand::List { user } => block_on(list(config_path, user, json)),
55+ }
56+}
57+
58+/// Look up a user's id by name, or error cleanly.
59+async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
60+ store
61+ .user_by_username(name)
62+ .await?
63+ .map(|u| u.id)
64+ .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
65+}
66+
67+async fn add(config_path: Option<&Path>, user: &str, path: &str) -> anyhow::Result<ExitCode> {
68+ let (_config, store) = open_store(config_path).await?;
69+ let owner_id = require_user_id(&store, user).await?;
70+ let group = store.ensure_group_path(&owner_id, path).await?;
71+ println!("created group {user}/{}", group.path);
72+ Ok(ExitCode::SUCCESS)
73+}
74+
75+async fn del(
76+ config_path: Option<&Path>,
77+ user: &str,
78+ path: &str,
79+ yes: bool,
80+) -> anyhow::Result<ExitCode> {
81+ let (_config, store) = open_store(config_path).await?;
82+ let owner_id = require_user_id(&store, user).await?;
83+ let group = store
84+ .group_by_owner_path(&owner_id, path)
85+ .await?
86+ .ok_or_else(|| anyhow::anyhow!("no such group {user}/{path}"))?;
87+
88+ if !confirm(
89+ &format!("Delete group {user}/{} and its subgroups?", group.path),
90+ yes,
91+ )? {
92+ println!("aborted");
93+ return Ok(ExitCode::SUCCESS);
94+ }
95+ store.delete_group(&group.id).await?;
96+ println!("deleted group {user}/{}", group.path);
97+ Ok(ExitCode::SUCCESS)
98+}
99+
100+async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
101+ let (_config, store) = open_store(config_path).await?;
102+ let owner_id = require_user_id(&store, user).await?;
103+ let groups = store.groups_by_owner(&owner_id).await?;
104+
105+ if json {
106+ let view: Vec<_> = groups.iter().map(group_json).collect();
107+ println!("{}", serde_json::to_string_pretty(&view)?);
108+ return Ok(ExitCode::SUCCESS);
109+ }
110+ if groups.is_empty() {
111+ println!("{user} has no groups");
112+ return Ok(ExitCode::SUCCESS);
113+ }
114+ for group in &groups {
115+ println!("{}", group.path);
116+ }
117+ Ok(ExitCode::SUCCESS)
118+}
119+
120+/// Render a group for `--json`.
121+fn group_json(group: &Group) -> serde_json::Value {
122+ serde_json::json!({
123+ "id": group.id,
124+ "name": group.name,
125+ "path": group.path,
126+ "description": group.description,
127+ "created_at": group.created_at,
128+ })
129+}
130+
131+#[cfg(test)]
132+mod tests {
133+ #![allow(clippy::unwrap_used)]
134+
135+ use super::*;
136+ use crate::testutil::env;
137+
138+ #[test]
139+ fn add_creates_path_and_del_removes_subtree() {
140+ let env = env();
141+ block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
142+
143+ block_on(add(env.path(), "ada", "team/backend")).unwrap();
144+ let groups = block_on(async {
145+ let store = env.store().await;
146+ let owner = require_user_id(&store, "ada").await?;
147+ anyhow::Ok(store.groups_by_owner(&owner).await?)
148+ })
149+ .unwrap();
150+ let paths: Vec<&str> = groups.iter().map(|g| g.path.as_str()).collect();
151+ assert_eq!(paths, ["team", "team/backend"]);
152+
153+ block_on(del(env.path(), "ada", "team", true)).unwrap();
154+ let after = block_on(async {
155+ let store = env.store().await;
156+ let owner = require_user_id(&store, "ada").await?;
157+ anyhow::Ok(store.groups_by_owner(&owner).await?)
158+ })
159+ .unwrap();
160+ assert!(
161+ after.is_empty(),
162+ "deleting the top group removes the subtree"
163+ );
164+ }
165+}
crates/cli/src/key_cmd.rs +255 −0
@@ -0,0 +1,255 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica key` — register and manage SSH and GPG public keys.
6+
7+use std::io::Read as _;
8+use std::path::Path;
9+use std::process::ExitCode;
10+
11+use clap::Subcommand;
12+use model::{Key, KeyKind};
13+use store::{NewKey, Store};
14+
15+use crate::context::{block_on, open_store};
16+use crate::prompt::confirm;
17+
18+/// Actions under `fabrica key`.
19+#[derive(Debug, Subcommand)]
20+pub(crate) enum KeyCommand {
21+ /// Register a public key for a user. The key material may be given literally,
22+ /// as `@path` to read a file, or `-` to read stdin. It is parsed and
23+ /// fingerprinted before storage; a duplicate fingerprint is rejected.
24+ Add {
25+ /// SSH or GPG.
26+ #[arg(long = "type", value_parser = parse_kind)]
27+ r#type: KeyKind,
28+ /// The user to register the key for.
29+ user: String,
30+ /// Key material: a literal key, `@path`, or `-` for stdin.
31+ key: String,
32+ /// Optional label (defaults to the key's own comment for SSH keys).
33+ #[arg(long)]
34+ name: Option<String>,
35+ },
36+ /// Remove a key by its 1-based index (as shown by `key list`). Indices are
37+ /// stable — removing one never renumbers the others.
38+ Del {
39+ /// The key's owner.
40+ user: String,
41+ /// The 1-based index from `key list`.
42+ index: i64,
43+ /// Skip the confirmation prompt (required when stdin is not a terminal).
44+ #[arg(long)]
45+ yes: bool,
46+ },
47+ /// List a user's keys.
48+ List {
49+ /// The user whose keys to list.
50+ user: String,
51+ },
52+}
53+
54+/// Parse the `--type` value into a [`KeyKind`].
55+fn parse_kind(s: &str) -> Result<KeyKind, String> {
56+ KeyKind::from_token(s).ok_or_else(|| format!("unknown key type {s:?} (expected ssh or gpg)"))
57+}
58+
59+/// Dispatch a `key` subcommand.
60+pub(crate) fn run(
61+ command: &KeyCommand,
62+ config_path: Option<&Path>,
63+ json: bool,
64+) -> anyhow::Result<ExitCode> {
65+ match command {
66+ KeyCommand::Add {
67+ r#type,
68+ user,
69+ key,
70+ name,
71+ } => block_on(add(config_path, *r#type, user, key, name.clone())),
72+ KeyCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)),
73+ KeyCommand::List { user } => block_on(list(config_path, user, json)),
74+ }
75+}
76+
77+/// Read key material from a literal string, `@path`, or `-` (stdin).
78+fn read_material(arg: &str) -> anyhow::Result<String> {
79+ if arg == "-" {
80+ let mut buf = String::new();
81+ std::io::stdin().read_to_string(&mut buf)?;
82+ Ok(buf)
83+ } else if let Some(path) = arg.strip_prefix('@') {
84+ Ok(std::fs::read_to_string(path)?)
85+ } else {
86+ Ok(arg.to_string())
87+ }
88+}
89+
90+/// Look up a user by name, or error cleanly.
91+async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
92+ store
93+ .user_by_username(name)
94+ .await?
95+ .map(|u| u.id)
96+ .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
97+}
98+
99+async fn add(
100+ config_path: Option<&Path>,
101+ kind: KeyKind,
102+ user: &str,
103+ key: &str,
104+ name: Option<String>,
105+) -> anyhow::Result<ExitCode> {
106+ let (_config, store) = open_store(config_path).await?;
107+ let user_id = require_user_id(&store, user).await?;
108+
109+ let material = read_material(key)?;
110+ let parsed = git::parse_public_key(kind, &material)?;
111+
112+ // Reject a duplicate fingerprint, naming the existing owner.
113+ if let Some(existing) = store.key_by_fingerprint(kind, &parsed.fingerprint).await? {
114+ let owner = store
115+ .user_by_id(&existing.user_id)
116+ .await?
117+ .map_or_else(|| existing.user_id.clone(), |u| u.username);
118+ anyhow::bail!(
119+ "that {kind} key is already registered (fingerprint {}) to {owner}",
120+ parsed.fingerprint
121+ );
122+ }
123+
124+ let stored = store
125+ .create_key(NewKey {
126+ user_id,
127+ kind,
128+ name: name.or(parsed.label),
129+ fingerprint: parsed.fingerprint.clone(),
130+ public_key: parsed.public_key,
131+ })
132+ .await?;
133+ println!(
134+ "added {kind} key #{} for {user}: {}",
135+ stored.ordinal, stored.fingerprint
136+ );
137+ Ok(ExitCode::SUCCESS)
138+}
139+
140+async fn del(
141+ config_path: Option<&Path>,
142+ user: &str,
143+ index: i64,
144+ yes: bool,
145+) -> anyhow::Result<ExitCode> {
146+ let (_config, store) = open_store(config_path).await?;
147+ let user_id = require_user_id(&store, user).await?;
148+ let key = store
149+ .key_by_ordinal(&user_id, index)
150+ .await?
151+ .ok_or_else(|| anyhow::anyhow!("{user} has no key #{index}"))?;
152+
153+ if !confirm(
154+ &format!("Delete {} key #{index} ({})?", key.kind, key.fingerprint),
155+ yes,
156+ )? {
157+ println!("aborted");
158+ return Ok(ExitCode::SUCCESS);
159+ }
160+ store.delete_key(&key.id).await?;
161+ println!("deleted {} key #{index} for {user}", key.kind);
162+ Ok(ExitCode::SUCCESS)
163+}
164+
165+async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
166+ let (_config, store) = open_store(config_path).await?;
167+ let user_id = require_user_id(&store, user).await?;
168+ let keys = store.keys_by_user(&user_id).await?;
169+
170+ if json {
171+ let view: Vec<_> = keys.iter().map(key_json).collect();
172+ println!("{}", serde_json::to_string_pretty(&view)?);
173+ return Ok(ExitCode::SUCCESS);
174+ }
175+ if keys.is_empty() {
176+ println!("{user} has no keys");
177+ return Ok(ExitCode::SUCCESS);
178+ }
179+ println!("{:>3} {:<4} {:<12} FINGERPRINT", "IDX", "TYPE", "NAME");
180+ for key in &keys {
181+ println!(
182+ "{:>3} {:<4} {:<12} {}",
183+ key.ordinal,
184+ key.kind,
185+ key.name.as_deref().unwrap_or("-"),
186+ key.fingerprint
187+ );
188+ }
189+ Ok(ExitCode::SUCCESS)
190+}
191+
192+/// Render a key for `--json` (never includes secret material — public keys are
193+/// public, but the shape mirrors the other list commands).
194+fn key_json(key: &Key) -> serde_json::Value {
195+ serde_json::json!({
196+ "index": key.ordinal,
197+ "type": key.kind.as_str(),
198+ "name": key.name,
199+ "fingerprint": key.fingerprint,
200+ "created_at": key.created_at,
201+ "last_used_at": key.last_used_at,
202+ })
203+}
204+
205+#[cfg(test)]
206+mod tests {
207+ #![allow(clippy::unwrap_used)]
208+
209+ use super::*;
210+ use crate::testutil::env;
211+
212+ const SSH_KEY: &str = "ssh-ed25519 \
213+AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU cli-test@fabrica";
214+
215+ #[test]
216+ fn add_list_and_del_ssh_key() {
217+ let env = env();
218+ block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
219+
220+ block_on(add(env.path(), KeyKind::Ssh, "ada", SSH_KEY, None)).unwrap();
221+
222+ let keys = block_on(async {
223+ let store = env.store().await;
224+ let id = require_user_id(&store, "ada").await?;
225+ anyhow::Ok(store.keys_by_user(&id).await?)
226+ })
227+ .unwrap();
228+ assert_eq!(keys.len(), 1);
229+ assert_eq!(keys[0].ordinal, 1);
230+ assert_eq!(keys[0].name.as_deref(), Some("cli-test@fabrica"));
231+ assert!(keys[0].fingerprint.starts_with("SHA256:"));
232+
233+ // A duplicate is rejected.
234+ let dup = block_on(add(env.path(), KeyKind::Ssh, "ada", SSH_KEY, None));
235+ assert!(dup.is_err(), "duplicate fingerprint should be rejected");
236+
237+ // Delete by its ordinal.
238+ block_on(del(env.path(), "ada", 1, true)).unwrap();
239+ let after = block_on(async {
240+ let store = env.store().await;
241+ let id = require_user_id(&store, "ada").await?;
242+ anyhow::Ok(store.keys_by_user(&id).await?)
243+ })
244+ .unwrap();
245+ assert!(after.is_empty());
246+ }
247+
248+ #[test]
249+ fn rejects_bad_key_material() {
250+ let env = env();
251+ block_on(async { anyhow::Ok(env.make_user("bob").await) }).unwrap();
252+ let err = block_on(add(env.path(), KeyKind::Ssh, "bob", "not a key", None));
253+ assert!(err.is_err());
254+ }
255+}
crates/cli/src/lib.rs +45 −0
@@ -9,9 +9,16 @@
99 //! `fabrica` binary. The command tree is built out over the implementation
1010 //! phases; today it covers only `config check` and `config show`.
1111
12+mod admin_cmd;
1213 mod config_cmd;
1314 mod context;
15+mod group_cmd;
16+mod key_cmd;
1417 mod prompt;
18+mod repo_cmd;
19+#[cfg(test)]
20+mod testutil;
21+mod token_cmd;
1522 mod user_cmd;
1623
1724 use std::path::PathBuf;
@@ -55,6 +62,38 @@ enum Command {
5562 #[command(subcommand)]
5663 command: user_cmd::UserCommand,
5764 },
65+ /// Manage a user's SSH and GPG keys.
66+ Key {
67+ /// The key action to perform.
68+ #[command(subcommand)]
69+ command: key_cmd::KeyCommand,
70+ },
71+ /// Manage repositories.
72+ Repo {
73+ /// The repository action to perform.
74+ #[command(subcommand)]
75+ command: repo_cmd::RepoCommand,
76+ },
77+ /// Manage the group hierarchy.
78+ Group {
79+ /// The group action to perform.
80+ #[command(subcommand)]
81+ command: group_cmd::GroupCommand,
82+ },
83+ /// Manage a user's API tokens.
84+ Token {
85+ /// The token action to perform.
86+ #[command(subcommand)]
87+ command: token_cmd::TokenCommand,
88+ },
89+ /// Apply pending database migrations.
90+ Migrate,
91+ /// Check that the environment fabrica needs is present and healthy.
92+ Doctor {
93+ /// Only set the exit code; suppress the per-check report. For health checks.
94+ #[arg(long)]
95+ quiet: bool,
96+ },
5897 }
5998
6099 /// Parse arguments and dispatch to the requested subcommand.
@@ -79,5 +118,11 @@ fn dispatch(cli: &Cli) -> anyhow::Result<ExitCode> {
79118 match &cli.command {
80119 Command::Config { command } => config_cmd::run(command, cli.config.as_deref(), cli.json),
81120 Command::User { command } => user_cmd::run(command, cli.config.as_deref(), cli.json),
121+ Command::Key { command } => key_cmd::run(command, cli.config.as_deref(), cli.json),
122+ Command::Repo { command } => repo_cmd::run(command, cli.config.as_deref(), cli.json),
123+ Command::Group { command } => group_cmd::run(command, cli.config.as_deref(), cli.json),
124+ Command::Token { command } => token_cmd::run(command, cli.config.as_deref(), cli.json),
125+ Command::Migrate => admin_cmd::migrate(cli.config.as_deref()),
126+ Command::Doctor { quiet } => admin_cmd::doctor(cli.config.as_deref(), *quiet),
82127 }
83128 }
crates/cli/src/repo_cmd.rs +413 −0
@@ -0,0 +1,413 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica repo` — repository administration, operating directly on the store and
6+//! the on-disk object storage.
7+
8+use std::path::Path;
9+use std::process::ExitCode;
10+
11+use clap::Subcommand;
12+use model::Repo;
13+use store::{NewRepo, Store};
14+
15+use crate::context::{block_on, hook_binary, open_store};
16+use crate::prompt::confirm;
17+
18+/// Actions under `fabrica repo`.
19+#[derive(Debug, Subcommand)]
20+pub(crate) enum RepoCommand {
21+ /// Create a repository. `name` may include a group path (`backend/api/svc`),
22+ /// creating any missing intermediate groups.
23+ Add {
24+ /// The owning user.
25+ user: String,
26+ /// The repo name, optionally prefixed with a group path.
27+ name: String,
28+ /// Make the repo private (the default).
29+ #[arg(long, conflicts_with = "public")]
30+ private: bool,
31+ /// Make the repo public.
32+ #[arg(long)]
33+ public: bool,
34+ /// A one-line description.
35+ #[arg(long)]
36+ description: Option<String>,
37+ /// Override the instance default branch.
38+ #[arg(long = "default-branch")]
39+ default_branch: Option<String>,
40+ },
41+ /// Delete a repository. Its directory is moved to trash unless `--purge`.
42+ Del {
43+ /// The owning user.
44+ user: String,
45+ /// The repo name (group path included).
46+ name: String,
47+ /// Delete the on-disk repository immediately instead of moving it to trash.
48+ #[arg(long)]
49+ purge: bool,
50+ /// Skip the confirmation prompt (required when stdin is not a terminal).
51+ #[arg(long)]
52+ yes: bool,
53+ },
54+ /// List repositories, optionally filtered to one user.
55+ List {
56+ /// Restrict to a single owner.
57+ #[arg(long)]
58+ user: Option<String>,
59+ },
60+ /// Rename a repository (metadata only — the on-disk directory never moves).
61+ Rename {
62+ /// The owning user.
63+ user: String,
64+ /// The current repo name (group path included).
65+ name: String,
66+ /// The new leaf name (the group path is preserved).
67+ new_name: String,
68+ },
69+ /// Print the resolved on-disk directory of a repository.
70+ Path {
71+ /// The owning user.
72+ user: String,
73+ /// The repo name (group path included).
74+ name: String,
75+ },
76+}
77+
78+/// Dispatch a `repo` subcommand.
79+pub(crate) fn run(
80+ command: &RepoCommand,
81+ config_path: Option<&Path>,
82+ json: bool,
83+) -> anyhow::Result<ExitCode> {
84+ match command {
85+ RepoCommand::Add {
86+ user,
87+ name,
88+ private: _private,
89+ public,
90+ description,
91+ default_branch,
92+ } => block_on(add(
93+ config_path,
94+ user,
95+ name,
96+ // Private is the default; `--private` is the explicit form of it, and
97+ // conflicts with `--public`, so visibility is simply "not public".
98+ !*public,
99+ description.clone(),
100+ default_branch.clone(),
101+ )),
102+ RepoCommand::Del {
103+ user,
104+ name,
105+ purge,
106+ yes,
107+ } => block_on(del(config_path, user, name, *purge, *yes)),
108+ RepoCommand::List { user } => block_on(list(config_path, user.as_deref(), json)),
109+ RepoCommand::Rename {
110+ user,
111+ name,
112+ new_name,
113+ } => block_on(rename(config_path, user, name, new_name)),
114+ RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
115+ }
116+}
117+
118+/// Look up a user's id by name, or error cleanly.
119+async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
120+ store
121+ .user_by_username(name)
122+ .await?
123+ .map(|u| u.id)
124+ .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
125+}
126+
127+/// Look up a repository by owner and path, or error cleanly.
128+async fn require_repo(
129+ store: &Store,
130+ owner_id: &str,
131+ path: &str,
132+ user: &str,
133+) -> anyhow::Result<Repo> {
134+ store
135+ .repo_by_owner_path(owner_id, path)
136+ .await?
137+ .ok_or_else(|| anyhow::anyhow!("no such repository {user}/{path}"))
138+}
139+
140+/// Split a repo name into its group-path prefix (if any) and leaf name.
141+fn split_group_path(name: &str) -> (Option<&str>, &str) {
142+ match name.rsplit_once('/') {
143+ Some((group, leaf)) => (Some(group), leaf),
144+ None => (None, name),
145+ }
146+}
147+
148+async fn add(
149+ config_path: Option<&Path>,
150+ user: &str,
151+ name: &str,
152+ is_private: bool,
153+ description: Option<String>,
154+ default_branch: Option<String>,
155+) -> anyhow::Result<ExitCode> {
156+ let (config, store) = open_store(config_path).await?;
157+ let owner_id = require_user_id(&store, user).await?;
158+ let (group_path, _leaf) = split_group_path(name);
159+
160+ let group_id = match group_path {
161+ Some(gp) => Some(store.ensure_group_path(&owner_id, gp).await?.id),
162+ None => None,
163+ };
164+ let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone());
165+
166+ let repo = store
167+ .create_repo(NewRepo {
168+ owner_id,
169+ group_id,
170+ name: split_group_path(name).1.to_string(),
171+ path: name.to_string(),
172+ description,
173+ is_private,
174+ default_branch: branch.clone(),
175+ })
176+ .await?;
177+
178+ // Create the bare repository on disk; roll the row back if that fails so a
179+ // half-created repo never lingers in the database.
180+ if let Err(err) = git::create_bare(&config.storage.repo_dir, &repo.id, &branch, &hook_binary())
181+ {
182+ let _ = store.delete_repo(&repo.id).await;
183+ return Err(err.into());
184+ }
185+
186+ println!(
187+ "created {} repository {user}/{}",
188+ if is_private { "private" } else { "public" },
189+ repo.path
190+ );
191+ Ok(ExitCode::SUCCESS)
192+}
193+
194+async fn del(
195+ config_path: Option<&Path>,
196+ user: &str,
197+ name: &str,
198+ purge: bool,
199+ yes: bool,
200+) -> anyhow::Result<ExitCode> {
201+ let (config, store) = open_store(config_path).await?;
202+ let owner_id = require_user_id(&store, user).await?;
203+ let repo = require_repo(&store, &owner_id, name, user).await?;
204+
205+ let question = if purge {
206+ format!("Permanently delete {user}/{} and its history?", repo.path)
207+ } else {
208+ format!(
209+ "Delete {user}/{}? (its directory moves to trash)",
210+ repo.path
211+ )
212+ };
213+ if !confirm(&question, yes)? {
214+ println!("aborted");
215+ return Ok(ExitCode::SUCCESS);
216+ }
217+
218+ store.delete_repo(&repo.id).await?;
219+
220+ // The database row is gone; now deal with the on-disk directory. A failure
221+ // here is logged but does not fail the command — the repo is already
222+ // unreachable through fabrica.
223+ let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
224+ if dir.exists() {
225+ if purge {
226+ if let Err(err) = std::fs::remove_dir_all(&dir) {
227+ eprintln!("warning: could not remove {}: {err}", dir.display());
228+ }
229+ } else {
230+ let trash = config.storage.data_dir.join("trash").join(format!(
231+ "{}-{}.git",
232+ repo.id,
233+ store_now()
234+ ));
235+ if let Some(parent) = trash.parent() {
236+ let _ = std::fs::create_dir_all(parent);
237+ }
238+ if let Err(err) = std::fs::rename(&dir, &trash) {
239+ eprintln!("warning: could not move {} to trash: {err}", dir.display());
240+ }
241+ }
242+ }
243+
244+ println!("deleted repository {user}/{}", repo.path);
245+ Ok(ExitCode::SUCCESS)
246+}
247+
248+async fn list(
249+ config_path: Option<&Path>,
250+ user: Option<&str>,
251+ json: bool,
252+) -> anyhow::Result<ExitCode> {
253+ let (_config, store) = open_store(config_path).await?;
254+
255+ // Resolve owner id → username once for display.
256+ let repos = match user {
257+ Some(name) => {
258+ let owner_id = require_user_id(&store, name).await?;
259+ store.repos_by_owner(&owner_id).await?
260+ }
261+ None => store.list_repos().await?,
262+ };
263+
264+ // Map owner ids to usernames for the owner column.
265+ let mut owners = std::collections::HashMap::new();
266+ for repo in &repos {
267+ if !owners.contains_key(&repo.owner_id) {
268+ let username = store
269+ .user_by_id(&repo.owner_id)
270+ .await?
271+ .map_or_else(|| repo.owner_id.clone(), |u| u.username);
272+ owners.insert(repo.owner_id.clone(), username);
273+ }
274+ }
275+
276+ if json {
277+ let view: Vec<_> = repos
278+ .iter()
279+ .map(|r| repo_json(r, &owners[&r.owner_id]))
280+ .collect();
281+ println!("{}", serde_json::to_string_pretty(&view)?);
282+ return Ok(ExitCode::SUCCESS);
283+ }
284+ if repos.is_empty() {
285+ println!("no repositories");
286+ return Ok(ExitCode::SUCCESS);
287+ }
288+ for repo in &repos {
289+ println!(
290+ "{}/{} [{}]",
291+ owners[&repo.owner_id],
292+ repo.path,
293+ if repo.is_private { "private" } else { "public" }
294+ );
295+ }
296+ Ok(ExitCode::SUCCESS)
297+}
298+
299+async fn rename(
300+ config_path: Option<&Path>,
301+ user: &str,
302+ name: &str,
303+ new_name: &str,
304+) -> anyhow::Result<ExitCode> {
305+ let (_config, store) = open_store(config_path).await?;
306+ let owner_id = require_user_id(&store, user).await?;
307+ let repo = require_repo(&store, &owner_id, name, user).await?;
308+
309+ // The group prefix is preserved; only the leaf changes.
310+ let new_path = match split_group_path(&repo.path).0 {
311+ Some(group) => format!("{group}/{new_name}"),
312+ None => new_name.to_string(),
313+ };
314+ store.rename_repo(&repo.id, new_name, &new_path).await?;
315+ println!("renamed {user}/{} to {user}/{new_path}", repo.path);
316+ Ok(ExitCode::SUCCESS)
317+}
318+
319+async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
320+ let (config, store) = open_store(config_path).await?;
321+ let owner_id = require_user_id(&store, user).await?;
322+ let repo = require_repo(&store, &owner_id, name, user).await?;
323+ let dir = git::repo_path(&config.storage.repo_dir, &repo.id)?;
324+ println!("{}", dir.display());
325+ Ok(ExitCode::SUCCESS)
326+}
327+
328+/// A millisecond timestamp for trash-directory names. Kept local so the module has
329+/// no direct dependency on the store's private `now_ms`.
330+fn store_now() -> i64 {
331+ use std::time::{SystemTime, UNIX_EPOCH};
332+ SystemTime::now()
333+ .duration_since(UNIX_EPOCH)
334+ .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
335+}
336+
337+/// Render a repo for `--json`.
338+fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value {
339+ serde_json::json!({
340+ "id": repo.id,
341+ "owner": owner,
342+ "name": repo.name,
343+ "path": repo.path,
344+ "description": repo.description,
345+ "private": repo.is_private,
346+ "default_branch": repo.default_branch,
347+ "created_at": repo.created_at,
348+ })
349+}
350+
351+#[cfg(test)]
352+mod tests {
353+ #![allow(clippy::unwrap_used)]
354+
355+ use super::*;
356+ use crate::testutil::env;
357+
358+ #[test]
359+ fn add_creates_row_and_bare_repo_under_a_group() {
360+ let env = env();
361+ block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
362+
363+ // A grouped repo auto-creates the intermediate group and a bare repo dir.
364+ block_on(add(env.path(), "ada", "backend/svc", true, None, None)).unwrap();
365+
366+ let (repo, dir) = block_on(async {
367+ let store = env.store().await;
368+ let owner = require_user_id(&store, "ada").await?;
369+ let repo = require_repo(&store, &owner, "backend/svc", "ada").await?;
370+ let group = store.group_by_owner_path(&owner, "backend").await?;
371+ assert!(group.is_some(), "intermediate group should exist");
372+ let cfg = config::load(env.path())?.config;
373+ let dir = git::repo_path(&cfg.storage.repo_dir, &repo.id)?;
374+ anyhow::Ok((repo, dir))
375+ })
376+ .unwrap();
377+ assert_eq!(repo.path, "backend/svc");
378+ assert!(repo.is_private);
379+ assert!(dir.join("HEAD").exists(), "a bare repo was created on disk");
380+
381+ // Rename preserves the group prefix.
382+ block_on(rename(env.path(), "ada", "backend/svc", "service")).unwrap();
383+ let renamed = block_on(async {
384+ let store = env.store().await;
385+ let owner = require_user_id(&store, "ada").await?;
386+ anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
387+ })
388+ .unwrap();
389+ assert!(renamed.is_some());
390+
391+ // Delete moves the directory to trash (not --purge).
392+ block_on(del(env.path(), "ada", "backend/service", false, true)).unwrap();
393+ assert!(!dir.exists(), "the directory moved out to trash");
394+ let gone = block_on(async {
395+ let store = env.store().await;
396+ let owner = require_user_id(&store, "ada").await?;
397+ anyhow::Ok(store.repo_by_owner_path(&owner, "backend/service").await?)
398+ })
399+ .unwrap();
400+ assert!(gone.is_none());
401+ }
402+
403+ #[test]
404+ fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() {
405+ let env = env();
406+ block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap();
407+ block_on(add(env.path(), "eve", "proj", true, None, None)).unwrap();
408+ // A second create at the same id path is impossible, but a duplicate
409+ // logical repo must conflict at the store layer and leave nothing behind.
410+ let dup = block_on(add(env.path(), "eve", "proj", true, None, None));
411+ assert!(dup.is_err());
412+ }
413+}
crates/cli/src/testutil.rs +74 −0
@@ -0,0 +1,74 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Shared test scaffolding: a temp data directory with a SQLite database and a
6+//! matching config file, so command handlers run end-to-end with no external
7+//! services.
8+
9+#![allow(clippy::unwrap_used, clippy::expect_used)]
10+
11+use std::path::Path;
12+
13+use store::{NewUser, Store};
14+use tempfile::TempDir;
15+
16+/// A throwaway environment for a CLI command test.
17+pub(crate) struct Env {
18+ _dir: TempDir,
19+ config_path: std::path::PathBuf,
20+ db_url: String,
21+}
22+
23+/// Build a temp data dir with a config pointing at a fresh SQLite database.
24+pub(crate) fn env() -> Env {
25+ let dir = tempfile::tempdir().unwrap();
26+ let repos = dir.path().join("repos");
27+ let db = dir.path().join("fabrica.db");
28+ let db_url = format!("sqlite://{}", db.display());
29+ let config_path = dir.path().join("fabrica.toml");
30+ let toml = format!(
31+ "[storage]\ndata_dir = {:?}\nrepo_dir = {:?}\n\n[database]\nurl = {:?}\n",
32+ dir.path().display(),
33+ repos.display(),
34+ db_url,
35+ );
36+ std::fs::write(&config_path, toml).unwrap();
37+ Env {
38+ _dir: dir,
39+ config_path,
40+ db_url,
41+ }
42+}
43+
44+impl Env {
45+ /// The `--config` path the handlers accept. The `Option` is intrinsic to the
46+ /// handler signature, not incidental.
47+ #[allow(clippy::unnecessary_wraps)]
48+ pub(crate) fn path(&self) -> Option<&Path> {
49+ Some(self.config_path.as_path())
50+ }
51+
52+ /// A store connected to the same database the handlers use.
53+ pub(crate) async fn store(&self) -> Store {
54+ Store::connect(&self.db_url, 1).await.unwrap()
55+ }
56+
57+ /// Create a user directly and return its id, for tests that need one to exist.
58+ pub(crate) async fn make_user(&self, name: &str) -> String {
59+ let store = self.store().await;
60+ store.migrate().await.unwrap();
61+ store
62+ .create_user(NewUser {
63+ username: name.to_string(),
64+ email: format!("{name}@example.com"),
65+ display_name: None,
66+ password_hash: None,
67+ is_admin: false,
68+ must_change_password: false,
69+ })
70+ .await
71+ .unwrap()
72+ .id
73+ }
74+}
crates/cli/src/token_cmd.rs +300 −0
@@ -0,0 +1,300 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! `fabrica token` — API tokens (HS256 JWTs, revocable via their `jti` row).
6+
7+use std::path::Path;
8+use std::process::ExitCode;
9+use std::time::{SystemTime, UNIX_EPOCH};
10+
11+use auth::Claims;
12+use clap::Subcommand;
13+use model::ApiToken;
14+use store::{NewToken, Store};
15+
16+use crate::context::{block_on, ensure_secret, open_store};
17+use crate::prompt::confirm;
18+
19+/// Actions under `fabrica token`.
20+#[derive(Debug, Subcommand)]
21+pub(crate) enum TokenCommand {
22+ /// Mint an API token. The signed JWT is printed **once** — it is not stored and
23+ /// cannot be retrieved again.
24+ Add {
25+ /// The owning user.
26+ user: String,
27+ /// A label for the token.
28+ name: String,
29+ /// Comma-separated scopes (default `repo:read`). One of `repo:read`,
30+ /// `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`.
31+ #[arg(long)]
32+ scopes: Option<String>,
33+ /// Lifetime as a duration (`90d`, `24h`, `30m`); defaults to the configured
34+ /// `auth.token_ttl_days`.
35+ #[arg(long)]
36+ expires: Option<String>,
37+ },
38+ /// Revoke and delete a token by its 1-based list index.
39+ Del {
40+ /// The owning user.
41+ user: String,
42+ /// The 1-based index from `token list`.
43+ index: usize,
44+ /// Skip the confirmation prompt (required when stdin is not a terminal).
45+ #[arg(long)]
46+ yes: bool,
47+ },
48+ /// List a user's tokens (never the token values — those are shown only once).
49+ List {
50+ /// The user whose tokens to list.
51+ user: String,
52+ },
53+}
54+
55+/// Dispatch a `token` subcommand.
56+pub(crate) fn run(
57+ command: &TokenCommand,
58+ config_path: Option<&Path>,
59+ json: bool,
60+) -> anyhow::Result<ExitCode> {
61+ match command {
62+ TokenCommand::Add {
63+ user,
64+ name,
65+ scopes,
66+ expires,
67+ } => block_on(add(
68+ config_path,
69+ user,
70+ name,
71+ scopes.clone(),
72+ expires.clone(),
73+ )),
74+ TokenCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)),
75+ TokenCommand::List { user } => block_on(list(config_path, user, json)),
76+ }
77+}
78+
79+/// Look up a user's id by name, or error cleanly.
80+async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
81+ store
82+ .user_by_username(name)
83+ .await?
84+ .map(|u| u.id)
85+ .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
86+}
87+
88+/// The current time in Unix seconds.
89+fn now_secs() -> i64 {
90+ SystemTime::now()
91+ .duration_since(UNIX_EPOCH)
92+ .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
93+}
94+
95+/// Parse a duration like `90d`, `24h`, `30m`, `45s`, or `2w` into seconds.
96+fn parse_duration(s: &str) -> anyhow::Result<i64> {
97+ let s = s.trim();
98+ let (digits, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
99+ let value: i64 = digits
100+ .parse()
101+ .map_err(|_| anyhow::anyhow!("invalid duration {s:?} (e.g. 90d, 24h, 30m)"))?;
102+ let multiplier = match unit {
103+ "s" | "" => 1,
104+ "m" => 60,
105+ "h" => 3_600,
106+ "d" => 86_400,
107+ "w" => 604_800,
108+ other => anyhow::bail!("unknown duration unit {other:?} (use s, m, h, d, or w)"),
109+ };
110+ Ok(value.saturating_mul(multiplier))
111+}
112+
113+async fn add(
114+ config_path: Option<&Path>,
115+ user: &str,
116+ name: &str,
117+ scopes: Option<String>,
118+ expires: Option<String>,
119+) -> anyhow::Result<ExitCode> {
120+ let (config, store) = open_store(config_path).await?;
121+ let user_id = require_user_id(&store, user).await?;
122+
123+ // Validate and canonicalize the scope list.
124+ let scope_list = scopes.unwrap_or_else(|| "repo:read".to_string());
125+ let parsed = auth::parse_scopes(&scope_list)?;
126+ let scopes = auth::format_scopes(&parsed);
127+
128+ let ttl = match expires {
129+ Some(d) => parse_duration(&d)?,
130+ None => i64::from(config.auth.token_ttl_days).saturating_mul(86_400),
131+ };
132+ let iat = now_secs();
133+ let exp = iat.saturating_add(ttl);
134+
135+ let token = store
136+ .create_token(NewToken {
137+ user_id: user_id.clone(),
138+ name: name.to_string(),
139+ scopes: scopes.clone(),
140+ expires_at: Some(exp.saturating_mul(1000)),
141+ })
142+ .await?;
143+
144+ let secret = ensure_secret(&config)?;
145+ let jwt = auth::mint_jwt(
146+ &secret,
147+ &Claims {
148+ sub: user_id,
149+ jti: token.id,
150+ scopes,
151+ iat,
152+ exp,
153+ },
154+ )?;
155+
156+ eprintln!("token {name:?} created for {user}; copy it now — it is not stored:");
157+ println!("{jwt}");
158+ Ok(ExitCode::SUCCESS)
159+}
160+
161+async fn del(
162+ config_path: Option<&Path>,
163+ user: &str,
164+ index: usize,
165+ yes: bool,
166+) -> anyhow::Result<ExitCode> {
167+ let (_config, store) = open_store(config_path).await?;
168+ let user_id = require_user_id(&store, user).await?;
169+ let tokens = store.tokens_by_user(&user_id).await?;
170+ let token = index
171+ .checked_sub(1)
172+ .and_then(|i| tokens.get(i))
173+ .ok_or_else(|| anyhow::anyhow!("{user} has no token #{index}"))?;
174+
175+ if !confirm(&format!("Revoke token #{index} ({:?})?", token.name), yes)? {
176+ println!("aborted");
177+ return Ok(ExitCode::SUCCESS);
178+ }
179+ store.delete_token(&token.id).await?;
180+ println!("revoked token #{index} for {user}");
181+ Ok(ExitCode::SUCCESS)
182+}
183+
184+async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
185+ let (_config, store) = open_store(config_path).await?;
186+ let user_id = require_user_id(&store, user).await?;
187+ let tokens = store.tokens_by_user(&user_id).await?;
188+
189+ if json {
190+ let view: Vec<_> = tokens
191+ .iter()
192+ .enumerate()
193+ .map(|(i, t)| token_json(i + 1, t))
194+ .collect();
195+ println!("{}", serde_json::to_string_pretty(&view)?);
196+ return Ok(ExitCode::SUCCESS);
197+ }
198+ if tokens.is_empty() {
199+ println!("{user} has no tokens");
200+ return Ok(ExitCode::SUCCESS);
201+ }
202+ println!("{:>3} {:<16} {:<28} STATE", "IDX", "NAME", "SCOPES");
203+ for (i, token) in tokens.iter().enumerate() {
204+ let state = if token.revoked_at.is_some() {
205+ "revoked"
206+ } else {
207+ "active"
208+ };
209+ println!(
210+ "{:>3} {:<16} {:<28} {state}",
211+ i + 1,
212+ token.name,
213+ token.scopes
214+ );
215+ }
216+ Ok(ExitCode::SUCCESS)
217+}
218+
219+/// Render a token for `--json` (metadata only — never the JWT).
220+fn token_json(index: usize, token: &ApiToken) -> serde_json::Value {
221+ serde_json::json!({
222+ "index": index,
223+ "name": token.name,
224+ "scopes": token.scopes,
225+ "expires_at": token.expires_at,
226+ "revoked": token.revoked_at.is_some(),
227+ "last_used_at": token.last_used_at,
228+ "created_at": token.created_at,
229+ })
230+}
231+
232+#[cfg(test)]
233+mod tests {
234+ #![allow(clippy::unwrap_used)]
235+
236+ use super::*;
237+ use crate::testutil::env;
238+
239+ #[test]
240+ fn parse_duration_units() {
241+ assert_eq!(parse_duration("45s").unwrap(), 45);
242+ assert_eq!(parse_duration("30m").unwrap(), 1_800);
243+ assert_eq!(parse_duration("24h").unwrap(), 86_400);
244+ assert_eq!(parse_duration("90d").unwrap(), 7_776_000);
245+ assert_eq!(parse_duration("2w").unwrap(), 1_209_600);
246+ assert!(parse_duration("10y").is_err());
247+ }
248+
249+ #[test]
250+ fn add_mints_a_verifiable_token_and_del_removes_it() {
251+ let env = env();
252+ let user_id = block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
253+
254+ // Minting stores a row whose id is the jti; the printed JWT verifies against
255+ // the persisted secret and carries the right claims.
256+ block_on(add(
257+ env.path(),
258+ "ada",
259+ "ci",
260+ Some("repo:read,repo:write".to_string()),
261+ Some("1d".to_string()),
262+ ))
263+ .unwrap();
264+
265+ let (row, jwt_ok) = block_on(async {
266+ let store = env.store().await;
267+ let tokens = store.tokens_by_user(&user_id).await?;
268+ assert_eq!(tokens.len(), 1);
269+ let row = tokens[0].clone();
270+
271+ // Re-mint the same claims with the resolved secret to prove the secret
272+ // round-trips (the actual JWT is only printed to stdout).
273+ let cfg = config::load(env.path())?.config;
274+ let secret = ensure_secret(&cfg)?;
275+ let jwt = auth::mint_jwt(
276+ &secret,
277+ &Claims {
278+ sub: user_id.clone(),
279+ jti: row.id.clone(),
280+ scopes: row.scopes.clone(),
281+ iat: 0,
282+ exp: now_secs() + 1000,
283+ },
284+ )?;
285+ let verified = auth::verify_jwt(&secret, &jwt, now_secs()).is_ok();
286+ anyhow::Ok((row, verified))
287+ })
288+ .unwrap();
289+ assert_eq!(row.name, "ci");
290+ assert_eq!(row.scopes, "repo:read,repo:write");
291+ assert!(row.expires_at.is_some());
292+ assert!(jwt_ok, "the token should verify against the stored secret");
293+
294+ block_on(del(env.path(), "ada", 1, true)).unwrap();
295+ let after =
296+ block_on(async { anyhow::Ok(env.store().await.tokens_by_user(&user_id).await?) })
297+ .unwrap();
298+ assert!(after.is_empty());
299+ }
300+}
docs/decisions.md +30 −0
@@ -363,3 +363,33 @@ signature **cryptographic** verification are complete and cover all four
363363 documented best-effort gap to close alongside the signature cache wiring in the web
364364 phase. Expired/revoked keys therefore currently verify as `Verified` if the
365365 signature is otherwise valid — tracked as a known limitation.
366+
367+## 2026-07-24 — Key ordinals are unique per user, not per (user, kind)
368+
369+**Decision:** `create_key` assigns `keys.ordinal` as `max(ordinal)+1` over **all**
370+of a user's keys (both SSH and GPG), not per `(user, kind)`.
371+
372+**Alternatives:** Per-`(user, kind)` ordinals, as the schema comment sketches.
373+
374+**Rationale:** The spec's `fabrica key del <user> <index>` carries no `--type`, so
375+the index shown by `key list` must be unambiguous across a user's keys. Per-user
376+ordinals give exactly that while still satisfying the schema's
377+`UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori
378+per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the
379+spec requires.
380+
381+## 2026-07-24 — CLI resolves-or-generates the instance secret on first use
382+
383+**Decision:** `fabrica token add` (and any future secret consumer) resolves the
384+HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the
385+contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly
386+generated 32-byte secret written to that path at mode `0600`.
387+
388+**Alternatives:** Require the operator to pre-provision a secret before any
389+token can be minted.
390+
391+**Rationale:** The spec has the secret "generated on first run"; the CLI operates
392+directly on the data directory with no running server, so first-run generation must
393+happen there too. Centralizing it in one helper means `token add` today and `serve`
394+later sign with the same key. `auth::new_secret` provides the 32-byte base64url
395+value, keeping randomness in the auth crate.