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

+69 -3
View File
@@ -97,6 +97,17 @@ impl Store {
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER,
expires_at INTEGER,
scope TEXT NOT NULL DEFAULT 'full'
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
@@ -317,6 +328,8 @@ pub struct UsageRecord {
pub duration_ms: u64,
pub cost: f64,
pub error: bool,
/// User to bill when there is no session (CLI requests); else the session owner.
pub user_id: Option<String>,
}
/// Aggregation SQL shared by all statistics queries.
@@ -364,11 +377,11 @@ impl Store {
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
ttft_ms, duration_ms, cost, error, user_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
(SELECT owner_id FROM sessions WHERE id = ?3))",
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
params![
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
],
)?;
Ok(())
@@ -474,7 +487,7 @@ impl Store {
// Most expensive chats in the range.
let top_sessions = {
let mut stmt = conn.prepare(&format!(
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), CASE WHEN u.root_session_id LIKE 'cli:%' THEN 'CLI (local)' ELSE '(deleted)' END), {AGG}
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
@@ -623,6 +636,59 @@ impl Store {
Ok(())
}
// --- personal API tokens ------------------------------------------------
pub fn api_token_add(&self, user_id: &str, name: &str, token_hash: &str, prefix: &str, expires_at: Option<i64>, scope: &str) -> Result<String> {
let id = crate::types::new_id();
self.conn.lock().unwrap().execute(
"INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![id, user_id, name, token_hash, prefix, now_ms(), expires_at, scope],
)?;
Ok(id)
}
pub fn api_tokens(&self, user_id: &str) -> Result<Vec<serde_json::Value>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, name, prefix, created_at, last_used, expires_at, scope FROM api_tokens WHERE user_id = ?1 ORDER BY created_at")?;
let rows = stmt.query_map([user_id], |r| {
Ok(serde_json::json!({
"id": r.get::<_, String>(0)?, "name": r.get::<_, String>(1)?, "prefix": r.get::<_, String>(2)?,
"created_at": r.get::<_, i64>(3)?, "last_used": r.get::<_, Option<i64>>(4)?, "expires_at": r.get::<_, Option<i64>>(5)?, "scope": r.get::<_, String>(6)?,
}))
})?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn api_token_delete(&self, user_id: &str, id: &str) -> Result<bool> {
Ok(self.conn.lock().unwrap().execute("DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2", [id, user_id])? > 0)
}
/// Scope ("full" / "read") of a personal API token.
pub fn api_token_scope(&self, token_hash: &str) -> Option<String> {
self.conn.lock().unwrap().query_row("SELECT scope FROM api_tokens WHERE token_hash = ?1", [token_hash], |r| r.get(0)).optional().ok().flatten()
}
/// The (enabled) user behind a valid personal API token.
pub fn user_by_api_token(&self, token_hash: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();
let user = conn
.query_row(
&format!(
"SELECT {} FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > ?2) AND u.disabled = 0",
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
),
params![token_hash, now_ms()],
user_row,
)
.optional()?;
if user.is_some() {
// At most one write per minute per token.
conn.execute("UPDATE api_tokens SET last_used = ?2 WHERE token_hash = ?1 AND (last_used IS NULL OR last_used < ?2 - 60000)", params![token_hash, now_ms()])?;
}
Ok(user)
}
/// The (enabled) user behind a valid login token.
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
let conn = self.conn.lock().unwrap();