Add API tokens, CLI server API and shared core crate

- Personal API tokens (full/read scope, expiry, revoke) and CLI password login
- Cargo workspace: codecraft-core (types, providers, permission rules) shared by
  server and the upcoming terminal client; server re-exports the old paths
- CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools),
  /llm/stream model gateway with budgets and usage per user
- Theme/language preferences mirrored to the server; personal memory endpoint
- Statistics: provider and mode tables stacked; CLI usage labelled
- cli/: work in progress (terminal client is being rewritten as a local TUI)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+92 -2
View File
@@ -133,12 +133,57 @@ fn record_failure(app: &App, keys: &[String]) {
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
let token = token_from_headers(headers)?;
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
let hash = sha256_hex(&token);
if token.starts_with(API_TOKEN_PREFIX) {
return app.store.user_by_api_token(&hash).ok().flatten();
}
app.store.user_by_token(&hash).ok().flatten()
}
/// Prefix of personal API tokens (CLI, scripts).
pub const API_TOKEN_PREFIX: &str = "cc_";
#[derive(Deserialize)]
pub struct NewApiToken {
name: String,
/// Days until it expires; None = never.
days: Option<u32>,
/// "full" (default) or "read" (GET requests only).
scope: Option<String>,
}
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
}
/// Create a token; the plain value is returned once and only stored hashed.
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
let name = b.name.trim();
if name.is_empty() || name.len() > 80 {
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
}
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
let scope = match b.scope.as_deref() {
Some("read") => "read",
None | Some("full") => "full",
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
};
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
}
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
let ok = app.store.api_token_delete(&user.id, &id)?;
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
Ok(Json(serde_json::json!({"ok": ok})))
}
/// Paths reachable without a login.
fn is_public(path: &str) -> bool {
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
.iter()
.any(|p| path.ends_with(p))
}
@@ -149,6 +194,17 @@ pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: N
}
match current_user(&app, req.headers()) {
Some(user) => {
// Read-only API tokens: no state-changing requests.
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
}
// Tokens cannot manage tokens.
if req.uri().path().contains("/account/tokens") {
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
}
}
req.extensions_mut().insert(user);
next.run(req).await
}
@@ -232,6 +288,40 @@ pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
}
#[derive(Deserialize)]
pub struct CliLoginBody {
username: String,
password: String,
/// Shown in the token name ("CLI on <hostname>").
hostname: Option<String>,
}
/// Command line login: checks the password (same rate limit as the web login)
/// and creates a personal API token for this machine.
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
let ip = client_ip(&headers);
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
if locked(&app, &keys) {
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
}
let found = app.store.user_by_name(body.username.trim())?;
let hash = found.as_ref().and_then(|(_, h)| h.clone());
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
record_failure(&app, &keys);
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
};
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
let prefix: String = token.chars().take(10).collect();
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
}
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
if let Some(t) = token_from_headers(&headers) {
let _ = app.store.delete_token(&sha256_hex(&t));