fn flag_value<'a>(rest: &'a [String], flag: &str) -> Option<&'a str> {
let prefix = format!("--{flag}=");
rest.iter().find_map(|a| a.strip_prefix(prefix.as_str()))
}
fn flag_values<'a>(rest: &'a [String], flag: &str) -> Vec<&'a str> {
let prefix = format!("--{flag}=");
rest.iter()
.filter_map(|a| a.strip_prefix(prefix.as_str()))
.collect()
}
fn parse_port(rest: &[String], flag: &str, default: u16) -> u16 {
flag_value(rest, flag)
.and_then(|p| p.parse().ok())
.unwrap_or(default)
}
pub(crate) fn cmd_gateway(rest: &[String]) {
let sub = rest.first().map_or("help", std::string::String::as_str);
match sub {
"serve" => serve(rest),
"init" => init(&rest[1..]),
"keys" => keys(&rest[1..]),
"doctor" => doctor(&rest[1..]),
"report" => report(&rest[1..]),
"gdpr" => gdpr(&rest[1..]),
"evidence" => evidence(&rest[1..]),
_ => help(),
}
}
fn require_pool() -> (String, deadpool_postgres::Pool) {
let Ok(database_url) = std::env::var(crate::gateway_server::serve::DATABASE_URL_ENV) else {
eprintln!(
"\x1b[31m✗\x1b[0m {} not set — this command reads usage_events (Postgres).",
crate::gateway_server::serve::DATABASE_URL_ENV
);
eprintln!(
" Compose instance: export $(grep DATABASE_URL .env) and replace host with 127.0.0.1"
);
std::process::exit(2);
};
match crate::gateway_server::store::pool_from_database_url(&database_url) {
Ok(pool) => (database_url, pool),
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m invalid DATABASE_URL: {e:#}");
std::process::exit(2);
}
}
}
fn gdpr(rest: &[String]) {
let action = rest.first().map_or("", std::string::String::as_str);
let Some(person) = flag_value(rest, "person") else {
eprintln!(
"Usage: lean-ctx gateway gdpr <export|delete> --person=<email> [--out=..] [--yes]"
);
std::process::exit(2);
};
let keys = crate::proxy::pii::person_match_keys(person);
let (_url, pool) = require_pool();
match action {
"export" => {
let rows = match crate::cli::dispatch::run_async(async move {
let llm = crate::gateway_server::store::person_events(&pool, &keys).await?;
let mcp = crate::gateway_server::mcp::store::person_events(&pool, &keys)
.await
.unwrap_or_default();
anyhow::Ok((llm, mcp))
}) {
Ok(rows) => rows,
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m export failed: {e:#}");
std::process::exit(1);
}
};
let (llm_rows, mcp_rows) = rows;
let jsonl: String =
llm_rows
.iter()
.chain(mcp_rows.iter())
.fold(String::new(), |mut acc, r| {
acc.push_str(&r.to_string());
acc.push('\n');
acc
});
match flag_value(rest, "out") {
Some(path) => {
if let Err(e) = std::fs::write(path, &jsonl) {
eprintln!("\x1b[31m✗\x1b[0m write {path}: {e}");
std::process::exit(1);
}
println!(
"\x1b[32m✓\x1b[0m {} LLM + {} MCP events of {person} written to {path} (JSONL)",
llm_rows.len(),
mcp_rows.len()
);
}
None => print!("{jsonl}"),
}
}
"delete" => {
if !rest.iter().any(|a| a == "--yes") {
eprintln!("This permanently deletes ALL usage_events and mcp_events of {person}.");
eprintln!("Re-run with --yes to confirm (GDPR Art. 17 erasure).");
std::process::exit(2);
}
match crate::cli::dispatch::run_async(async move {
let llm = crate::gateway_server::store::delete_person_events(&pool, &keys).await?;
let mcp = crate::gateway_server::mcp::store::delete_person_events(&pool, &keys)
.await
.unwrap_or(0);
anyhow::Ok((llm, mcp))
}) {
Ok((llm, mcp)) => {
println!(
"\x1b[32m✓\x1b[0m Deleted {llm} usage events and {mcp} MCP events of {person}."
);
println!(
" Also revoke their key: lean-ctx gateway keys revoke --person={person}"
);
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m delete failed: {e:#}");
std::process::exit(1);
}
}
}
_ => {
eprintln!(
"Usage: lean-ctx gateway gdpr <export|delete> --person=<email> [--out=..] [--yes]"
);
std::process::exit(2);
}
}
}
fn evidence(rest: &[String]) {
if rest.first().map(std::string::String::as_str) == Some("verify") {
let Some(path) = flag_value(rest, "file") else {
eprintln!("Usage: lean-ctx gateway evidence verify --file=evidence.json");
std::process::exit(2);
};
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m read {path}: {e}");
std::process::exit(1);
}
};
let artifact: crate::gateway_server::evidence::EvidenceExportV1 =
match serde_json::from_str(&raw) {
Ok(a) => a,
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m not an evidence artifact: {e}");
std::process::exit(1);
}
};
let result = artifact.verify();
println!(
" digest: {}",
if result.digest_valid {
"\x1b[32mvalid\x1b[0m"
} else {
"\x1b[31mINVALID\x1b[0m"
}
);
println!(
" signature: {}",
if result.signature_valid {
"\x1b[32mvalid\x1b[0m"
} else {
"\x1b[31mINVALID\x1b[0m"
}
);
if let Some(pk) = &result.signer_public_key {
println!(" signer: {pk}");
}
if let Some(err) = &result.error {
println!(" error: {err}");
}
if !(result.digest_valid && result.signature_valid) {
std::process::exit(1);
}
println!(
"\x1b[32m✓\x1b[0m Evidence artifact verified: {} rows, {} → {}",
artifact.row_count, artifact.from, artifact.to
);
return;
}
let to = flag_value(rest, "to")
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or_else(chrono::Utc::now, |d| d.with_timezone(&chrono::Utc));
let from = flag_value(rest, "from")
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or_else(
|| to - chrono::Duration::days(30),
|d| d.with_timezone(&chrono::Utc),
);
let out_path = flag_value(rest, "out")
.unwrap_or("leanctx-evidence.json")
.to_string();
let (_url, pool) = require_pool();
match crate::cli::dispatch::run_async(async move {
crate::gateway_server::evidence::generate(&pool, from, to).await
}) {
Ok(artifact) => {
let json = serde_json::to_string_pretty(&artifact).unwrap_or_default();
if let Err(e) = std::fs::write(&out_path, json) {
eprintln!("\x1b[31m✗\x1b[0m write {out_path}: {e}");
std::process::exit(1);
}
println!("\x1b[32m✓\x1b[0m Signed evidence written: {out_path}");
println!(" Window: {} → {}", artifact.from, artifact.to);
println!(
" Rows: {} daily aggregates, digest {}",
artifact.row_count,
&artifact.rows_digest_blake3[..16]
);
println!(" Verify: lean-ctx gateway evidence verify --file={out_path}");
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m evidence export failed: {e:#}");
std::process::exit(1);
}
}
}
fn serve(rest: &[String]) {
let port = rest
.iter()
.find_map(|p| p.strip_prefix("--port="))
.and_then(|p| p.parse().ok())
.unwrap_or_else(crate::proxy_setup::default_port);
let admin_port: Option<u16> = flag_value(rest, "admin-port").and_then(|p| p.parse().ok());
let opts = crate::gateway_server::serve::ServeOptions { port, admin_port };
if let Err(e) = crate::cli::dispatch::run_async(crate::gateway_server::serve::serve(opts)) {
tracing::error!("Gateway error: {e}");
std::process::exit(1);
}
}
fn init(rest: &[String]) {
let dir = rest
.iter()
.find(|a| !a.starts_with("--"))
.map_or_else(|| std::path::PathBuf::from("."), std::path::PathBuf::from);
let opts = crate::gateway_server::init::InitOptions {
org_label: flag_value(rest, "org").unwrap_or_default().to_string(),
seats: flag_value(rest, "seats").and_then(|s| s.parse().ok()),
reference_model: flag_value(rest, "reference-model").map(str::to_string),
persons: flag_values(rest, "person")
.into_iter()
.map(str::to_string)
.collect(),
proxy_port: parse_port(rest, "port", 8484),
admin_port: parse_port(rest, "admin-port", 8485),
};
match crate::gateway_server::init::run(&dir, &opts) {
Ok(outcome) => {
println!(
"\x1b[32m✓\x1b[0m Gateway instance created in {}",
dir.display()
);
println!();
for f in &outcome.files {
println!(" {f}");
}
if !outcome.person_keys.is_empty() {
println!();
println!(
" Personal keys (shown ONCE — hand them out now, only hashes are stored):"
);
for (person, key) in &outcome.person_keys {
println!(" {person:<32} {key}");
}
}
println!();
println!(" Next steps:");
println!(" cd {} && docker compose up -d", dir.display());
println!(" lean-ctx gateway doctor --dir {}", dir.display());
println!(
" open http://127.0.0.1:{}/ (admin console — token in .env)",
opts.admin_port
);
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m gateway init failed: {e:#}");
std::process::exit(1);
}
}
}
fn keys_file(rest: &[String]) -> std::path::PathBuf {
flag_value(rest, "file").map_or_else(
crate::proxy::gateway_identity::GatewayKeys::default_path,
std::path::PathBuf::from,
)
}
fn keys(rest: &[String]) {
let action = rest.first().map_or("list", std::string::String::as_str);
let path = keys_file(rest);
match action {
"add" => {
let Some(person) = flag_value(rest, "person") else {
eprintln!(
"Usage: lean-ctx gateway keys add --person=<id> [--team=..] [--project=..] [--file=..] [--allow-multiple]"
);
std::process::exit(2);
};
let allow_multiple = rest.iter().any(|a| a == "--allow-multiple");
match crate::gateway_server::keys_cli::add_key(
&path,
person,
flag_value(rest, "team"),
flag_value(rest, "project"),
allow_multiple,
) {
Ok(key) => {
println!(
"\x1b[32m✓\x1b[0m Key created for {person} in {}",
path.display()
);
println!();
println!(" {key}");
println!();
println!(" Shown ONCE — only the SHA-256 hash is stored.");
println!(" Client setup: ANTHROPIC_AUTH_TOKEN={key}");
println!(" Reload a running gateway: docker compose restart gateway");
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m {e:#}");
std::process::exit(1);
}
}
}
"list" => match crate::gateway_server::keys_cli::list_keys(&path) {
Ok(entries) if entries.is_empty() => {
println!("No keys in {} — add one:", path.display());
println!(" lean-ctx gateway keys add --person=alice@example.com");
}
Ok(entries) => {
println!("{} identities in {}:", entries.len(), path.display());
for e in entries {
println!(
" {:<32} team={:<14} project={:<16} sha256:{}…",
e.person,
e.team.as_deref().unwrap_or("—"),
e.default_project.as_deref().unwrap_or("—"),
e.sha_prefix
);
}
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m {e:#}");
std::process::exit(1);
}
},
"revoke" => {
let Some(person) = flag_value(rest, "person") else {
eprintln!("Usage: lean-ctx gateway keys revoke --person=<id> [--file=..]");
std::process::exit(2);
};
match crate::gateway_server::keys_cli::revoke_keys(&path, person) {
Ok(0) => println!("No keys for {person} in {}.", path.display()),
Ok(removed) => {
println!("\x1b[32m✓\x1b[0m Revoked {removed} key(s) of {person}.");
println!(" Reload a running gateway: docker compose restart gateway");
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m {e:#}");
std::process::exit(1);
}
}
}
"rotate" => {
let Some(person) = flag_value(rest, "person") else {
eprintln!("Usage: lean-ctx gateway keys rotate --person=<id> [--file=..]");
std::process::exit(2);
};
match crate::gateway_server::keys_cli::rotate_key(&path, person) {
Ok(rotated) => {
println!(
"\x1b[32m✓\x1b[0m Rotated {} key(s) of {person} in {} (team/project kept)",
rotated.replaced,
path.display()
);
println!();
println!(" {}", rotated.key);
println!();
println!(" Shown ONCE — only the SHA-256 hash is stored.");
println!(" Old key(s) stop working on the next gateway reload:");
println!(" docker compose restart gateway");
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m {e:#}");
std::process::exit(1);
}
}
}
_ => {
println!(
"Usage: lean-ctx gateway keys <add|list|rotate|revoke> [--person=..] [--team=..] [--project=..] [--file=..]"
);
}
}
}
fn doctor(rest: &[String]) {
let dir = flag_value(rest, "dir")
.map_or_else(|| std::path::PathBuf::from("."), std::path::PathBuf::from);
let proxy_port = parse_port(rest, "port", 8484);
let admin_port = parse_port(rest, "admin-port", 8485);
let results = match crate::cli::dispatch::run_async(async {
anyhow::Ok(crate::gateway_server::doctor::run_checks(&dir, proxy_port, admin_port).await)
}) {
Ok(r) => r,
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m doctor failed: {e:#}");
std::process::exit(1);
}
};
println!("lean-ctx gateway doctor — {}", dir.display());
println!();
for r in &results {
println!("{r}");
}
println!();
if crate::gateway_server::doctor::has_failures(&results) {
println!("\x1b[31mNot go-live ready — fix the FAIL items above.\x1b[0m");
std::process::exit(1);
}
println!("\x1b[32mGo-live ready (warnings are optional improvements).\x1b[0m");
}
fn report(rest: &[String]) {
let Ok(database_url) = std::env::var(crate::gateway_server::serve::DATABASE_URL_ENV) else {
eprintln!(
"\x1b[31m✗\x1b[0m {} not set — the report reads usage_events (Postgres).",
crate::gateway_server::serve::DATABASE_URL_ENV
);
eprintln!(
" Compose instance: export $(grep DATABASE_URL .env) and replace host with 127.0.0.1"
);
std::process::exit(2);
};
let to = flag_value(rest, "to")
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or_else(chrono::Utc::now, |d| d.with_timezone(&chrono::Utc));
let from = flag_value(rest, "from")
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or_else(
|| to - chrono::Duration::days(30),
|d| d.with_timezone(&chrono::Utc),
);
let out_path = flag_value(rest, "out")
.unwrap_or("lean-ctx-report.html")
.to_string();
let cfg = crate::core::config::Config::load();
let meta = crate::gateway_server::report::ReportMeta {
org_label: cfg.gateway_server.org_label.clone(),
seats: cfg.gateway_server.seats,
reference_model: cfg.proxy.baseline.reference_model.clone(),
};
let html = crate::cli::dispatch::run_async(async move {
let pool = crate::gateway_server::store::pool_from_database_url(&database_url)?;
crate::gateway_server::report::generate(&pool, from, to, &meta).await
});
match html {
Ok(html) => {
if let Err(e) = std::fs::write(&out_path, html) {
eprintln!("\x1b[31m✗\x1b[0m write {out_path}: {e}");
std::process::exit(1);
}
println!("\x1b[32m✓\x1b[0m Report written: {out_path}");
println!(" Window: {} → {}", from.to_rfc3339(), to.to_rfc3339());
println!(" Print to PDF from any browser for the CTO deck.");
}
Err(e) => {
eprintln!("\x1b[31m✗\x1b[0m report failed: {e:#}");
std::process::exit(1);
}
}
}
fn help() {
use crate::gateway_server::serve::{ADMIN_TOKEN_ENV, DATABASE_URL_ENV};
println!("lean-ctx gateway — self-hosted org gateway (proxy + usage store + admin console)");
println!();
println!("Usage:");
println!(" lean-ctx gateway serve [--port=8484] [--admin-port=8485]");
println!(" lean-ctx gateway init [dir] [--org=\"Acme AG\"] [--seats=800]");
println!(" [--reference-model=claude-opus-4.5] [--person=a@x …]");
println!(
" lean-ctx gateway keys <add|list|rotate|revoke> [--person=..] [--team=..] [--project=..] [--file=..]"
);
println!(" lean-ctx gateway doctor [--dir=.] [--port=8484] [--admin-port=8485]");
println!(" lean-ctx gateway report [--from=ISO] [--to=ISO] [--out=report.html]");
println!(" lean-ctx gateway evidence [--from=ISO] [--to=ISO] [--out=evidence.json]");
println!(" lean-ctx gateway evidence verify --file=evidence.json");
println!(" lean-ctx gateway gdpr <export|delete> --person=<email> [--out=..] [--yes]");
println!();
println!("Environment:");
println!(
" {DATABASE_URL_ENV} Postgres for usage_events (off → metering disabled, fail-open)"
);
println!(" {ADMIN_TOKEN_ENV} Bearer token of the admin console (off → admin disabled)");
println!(" LEAN_CTX_PROXY_TOKEN proxy Bearer token (else session token)");
println!();
println!("Config ([gateway_server] in config.toml): seats, org_label, admin_url.");
println!(
"Bind host/allowlist/rate limit: proxy_bind_host, proxy_allowed_hosts, proxy_max_rps."
);
}