From 47c08ada8159b3ca1bae3a5a6ca1894e34732e27 Mon Sep 17 00:00:00 2001 From: "Stephen G. Pope" <1420454+stephengpope@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:08:08 +0000 Subject: [PATCH] feat: support multiple named API keys in /settings/secrets Replaces the single-key model with a named key manager. Users can now create multiple API keys with distinct names, view all keys in a list, and delete them individually. All active keys continue to authenticate external /api callers via x-api-key. Existing single keys (stored with key='api_key') are backward-compatible and appear in the list unchanged. - lib/db/api-keys.js: cache is now a Map; createApiKeyRecord accepts a name param and no longer deletes existing keys; getApiKeys() returns an array; deleteApiKeyById(id) replaces deleteApiKey(); verifyApiKey iterates the Map with timing-safe comparison - lib/chat/actions.js: createNewApiKey(name) validates name server-side; getApiKeys() calls the renamed DB function; deleteApiKey(id) accepts UUID - settings-secrets-page.jsx: ApiKeySection rewritten as a key manager with persistent "Add API key" button, inline create form, key list with per-row confirm-before-delete, and empty state; Regenerate removed Closes #197 Co-Authored-By: Claude Sonnet 4.6 --- lib/chat/actions.js | 33 ++- lib/chat/components/settings-secrets-page.jsx | 223 +++++++++++------- lib/db/api-keys.js | 115 ++++----- 3 files changed, 223 insertions(+), 148 deletions(-) diff --git a/lib/chat/actions.js b/lib/chat/actions.js index f4146bc6..d5b4ef94 100644 --- a/lib/chat/actions.js +++ b/lib/chat/actions.js @@ -222,14 +222,19 @@ export async function triggerUpgrade() { // ───────────────────────────────────────────────────────────────────────────── /** - * Create (or replace) the API key. + * Create a new named API key. + * @param {string} name - Display name (non-empty, max 64 chars) * @returns {Promise<{ key: string, record: object } | { error: string }>} */ -export async function createNewApiKey() { +export async function createNewApiKey(name) { const user = await requireAuth(); + if (!name || typeof name !== 'string') return { error: 'Name is required' }; + const trimmed = name.trim(); + if (!trimmed) return { error: 'Name is required' }; + if (trimmed.length > 64) return { error: 'Name must be 64 characters or fewer' }; try { const { createApiKeyRecord } = await import('../db/api-keys.js'); - return createApiKeyRecord(user.id); + return createApiKeyRecord(user.id, trimmed); } catch (err) { console.error('Failed to create API key:', err); return { error: 'Failed to create API key' }; @@ -237,29 +242,31 @@ export async function createNewApiKey() { } /** - * Get the current API key metadata (no hash). - * @returns {Promise} + * Get all API keys metadata (no hashes). + * @returns {Promise} */ export async function getApiKeys() { await requireAuth(); try { - const { getApiKey } = await import('../db/api-keys.js'); - return getApiKey(); + const { getApiKeys: dbGetApiKeys } = await import('../db/api-keys.js'); + return dbGetApiKeys(); } catch (err) { - console.error('Failed to get API key:', err); - return null; + console.error('Failed to get API keys:', err); + return []; } } /** - * Delete the API key. + * Delete an API key by its UUID. + * @param {string} id - UUID of the key to delete * @returns {Promise<{ success: boolean } | { error: string }>} */ -export async function deleteApiKey() { +export async function deleteApiKey(id) { await requireAuth(); + if (!id || typeof id !== 'string') return { error: 'Invalid key ID' }; try { - const mod = await import('../db/api-keys.js'); - mod.deleteApiKey(); + const { deleteApiKeyById } = await import('../db/api-keys.js'); + deleteApiKeyById(id); return { success: true }; } catch (err) { console.error('Failed to delete API key:', err); diff --git a/lib/chat/components/settings-secrets-page.jsx b/lib/chat/components/settings-secrets-page.jsx index 2141d731..576efc31 100644 --- a/lib/chat/components/settings-secrets-page.jsx +++ b/lib/chat/components/settings-secrets-page.jsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { KeyIcon, CopyIcon, CheckIcon, TrashIcon, RefreshIcon } from './icons.js'; +import { useState, useEffect, useRef } from 'react'; +import { KeyIcon, CopyIcon, CheckIcon, TrashIcon } from './icons.js'; import { createNewApiKey, getApiKeys, deleteApiKey } from '../actions.js'; function timeAgo(ts) { @@ -75,86 +75,157 @@ function Section({ title, description, children }) { } // ───────────────────────────────────────────────────────────────────────────── -// API Key section +// API Keys section // ───────────────────────────────────────────────────────────────────────────── function ApiKeySection() { - const [currentKey, setCurrentKey] = useState(null); + const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [nameInput, setNameInput] = useState(''); + const [formError, setFormError] = useState(null); const [creating, setCreating] = useState(false); const [newKey, setNewKey] = useState(null); - const [confirmDelete, setConfirmDelete] = useState(false); - const [confirmRegenerate, setConfirmRegenerate] = useState(false); - const [error, setError] = useState(null); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const confirmTimerRef = useRef(null); + const nameInputRef = useRef(null); - const loadKey = async () => { + const loadKeys = async () => { try { const result = await getApiKeys(); - setCurrentKey(result); + setKeys(Array.isArray(result) ? result : []); } catch { - // ignore + setKeys([]); } finally { setLoading(false); } }; useEffect(() => { - loadKey(); + loadKeys(); }, []); - const handleCreate = async () => { + // Auto-focus name input when form opens + useEffect(() => { + if (showForm && nameInputRef.current) { + nameInputRef.current.focus(); + } + }, [showForm]); + + const handleOpenForm = () => { + setShowForm(true); + setNameInput(''); + setFormError(null); + }; + + const handleCancelForm = () => { + setShowForm(false); + setNameInput(''); + setFormError(null); + }; + + const handleCreate = async (e) => { + e.preventDefault(); if (creating) return; + const trimmed = nameInput.trim(); + if (!trimmed) { + setFormError('Name is required'); + return; + } setCreating(true); - setError(null); - setConfirmRegenerate(false); + setFormError(null); try { - const result = await createNewApiKey(); + const result = await createNewApiKey(trimmed); if (result.error) { - setError(result.error); + setFormError(result.error); } else { setNewKey(result.key); - await loadKey(); + setShowForm(false); + setNameInput(''); + await loadKeys(); } } catch { - setError('Failed to create API key'); + setFormError('Failed to create API key'); } finally { setCreating(false); } }; - const handleDelete = async () => { - if (!confirmDelete) { - setConfirmDelete(true); - setTimeout(() => setConfirmDelete(false), 3000); + const handleDelete = async (id) => { + if (confirmDeleteId !== id) { + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + setConfirmDeleteId(id); + confirmTimerRef.current = setTimeout(() => setConfirmDeleteId(null), 3000); return; } + setConfirmDeleteId(null); try { - await deleteApiKey(); - setCurrentKey(null); - setNewKey(null); - setConfirmDelete(false); + await deleteApiKey(id); + setKeys((prev) => prev.filter((k) => k.id !== id)); + if (newKey && keys.find((k) => k.id === id)) { + setNewKey(null); + } } catch { // ignore } }; - const handleRegenerate = () => { - if (!confirmRegenerate) { - setConfirmRegenerate(true); - setTimeout(() => setConfirmRegenerate(false), 3000); - return; - } - handleCreate(); - }; - if (loading) { return
; } return (
- {error && ( -

{error}

+ {/* Header row: section action button */} +
+ +
+ + {/* Inline create form */} + {showForm && ( +
+
+
+ setNameInput(e.target.value)} + placeholder="e.g. production" + maxLength={64} + className="w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + {formError && ( +

{formError}

+ )} +
+
+ + +
+
+
)} {/* New key banner */} @@ -180,60 +251,50 @@ function ApiKeySection() {
)} - {currentKey ? ( -
-
-
-
- -
-
- {currentKey.keyPrefix}... -

- Created {formatDate(currentKey.createdAt)} - {currentKey.lastUsedAt && ( - · Last used {timeAgo(currentKey.lastUsedAt)} - )} -

+ {/* Key list */} + {keys.length > 0 ? ( +
+ {keys.map((apiKey) => ( +
+
+
+ +
+
+

{apiKey.name}

+
+ + {apiKey.keyPrefix}... + + + Created {formatDate(apiKey.createdAt)} + {apiKey.lastUsedAt + ? ` · Last used ${timeAgo(apiKey.lastUsedAt)}` + : ' · Never used'} + +
+
-
-
-
-
+ ))}
) : ( -
-

No API key configured

- +
+

No API keys — add one above

)}
@@ -248,7 +309,7 @@ export function SettingsSecretsPage() { return (
diff --git a/lib/db/api-keys.js b/lib/db/api-keys.js index b82d870f..977fc565 100644 --- a/lib/db/api-keys.js +++ b/lib/db/api-keys.js @@ -5,7 +5,7 @@ import { settings } from './schema.js'; const KEY_PREFIX = 'tpb_'; -// In-memory cache: { key_hash, id } or null +// In-memory cache: Map or null (not loaded) let _cache = null; /** @@ -26,23 +26,23 @@ export function hashApiKey(key) { } /** - * Lazy-load the API key hash into the in-memory cache. + * Lazy-load all API key hashes into the in-memory cache Map. + * @returns {Map} */ function _ensureCache() { if (_cache !== null) return _cache; const db = getDb(); - const row = db + const rows = db .select() .from(settings) .where(eq(settings.type, 'api_key')) - .get(); + .all(); - if (row) { + _cache = new Map(); + for (const row of rows) { const parsed = JSON.parse(row.value); - _cache = { keyHash: parsed.key_hash, id: row.id }; - } else { - _cache = false; // no key exists — distinguish from "not loaded yet" + _cache.set(parsed.key_hash, { id: row.id, name: row.key }); } return _cache; } @@ -55,16 +55,14 @@ export function invalidateApiKeyCache() { } /** - * Create (or replace) the API key. Deletes any existing key first. + * Create a new named API key. Does NOT delete existing keys. * @param {string} createdBy - User ID + * @param {string} name - Display name for the key (stored in settings.key column) * @returns {{ key: string, record: object }} */ -export function createApiKeyRecord(createdBy) { +export function createApiKeyRecord(createdBy, name) { const db = getDb(); - // Delete any existing API key - db.delete(settings).where(eq(settings.type, 'api_key')).run(); - const key = generateApiKey(); const keyHash = hashApiKey(key); const keyPrefix = key.slice(0, 8); // "tpb_" + first 4 hex chars @@ -73,7 +71,7 @@ export function createApiKeyRecord(createdBy) { const record = { id: randomUUID(), type: 'api_key', - key: 'api_key', + key: name, value: JSON.stringify({ key_prefix: keyPrefix, key_hash: keyHash, last_used_at: null }), createdBy, createdAt: now, @@ -87,6 +85,7 @@ export function createApiKeyRecord(createdBy) { key, record: { id: record.id, + name, keyPrefix, createdAt: now, lastUsedAt: null, @@ -95,39 +94,43 @@ export function createApiKeyRecord(createdBy) { } /** - * Get the current API key metadata (no hash). - * @returns {object|null} + * Get all API keys metadata (no hashes). + * Existing keys stored with key='api_key' are returned with name='api_key'. + * @returns {object[]} */ -export function getApiKey() { +export function getApiKeys() { const db = getDb(); - const row = db + const rows = db .select() .from(settings) .where(eq(settings.type, 'api_key')) - .get(); - - if (!row) return null; + .all(); - const parsed = JSON.parse(row.value); - return { - id: row.id, - keyPrefix: parsed.key_prefix, - createdAt: row.createdAt, - lastUsedAt: parsed.last_used_at, - }; + return rows.map((row) => { + const parsed = JSON.parse(row.value); + return { + id: row.id, + name: row.key, + keyPrefix: parsed.key_prefix, + createdAt: row.createdAt, + lastUsedAt: parsed.last_used_at, + }; + }); } /** - * Delete the API key. + * Delete an API key by its UUID primary key. + * @param {string} id - UUID of the settings row */ -export function deleteApiKey() { +export function deleteApiKeyById(id) { const db = getDb(); - db.delete(settings).where(eq(settings.type, 'api_key')).run(); + db.delete(settings).where(eq(settings.id, id)).run(); invalidateApiKeyCache(); } /** - * Verify a raw API key against the cached hash. + * Verify a raw API key against the cached hash Map. + * Timing-safe comparison applied per entry. * @param {string} rawKey - Raw API key from request header * @returns {object|null} Record if valid, null otherwise */ @@ -135,29 +138,33 @@ export function verifyApiKey(rawKey) { if (!rawKey || !rawKey.startsWith(KEY_PREFIX)) return null; const keyHash = hashApiKey(rawKey); - const cached = _ensureCache(); - - if (!cached) return null; - const a = Buffer.from(cached.keyHash, 'hex'); - const b = Buffer.from(keyHash, 'hex'); - if (a.length !== b.length || !timingSafeEqual(a, b)) return null; - - // Update last_used_at in background (non-blocking) - try { - const db = getDb(); - const now = Date.now(); - const row = db.select().from(settings).where(eq(settings.id, cached.id)).get(); - if (row) { - const parsed = JSON.parse(row.value); - parsed.last_used_at = now; - db.update(settings) - .set({ value: JSON.stringify(parsed), updatedAt: now }) - .where(eq(settings.id, cached.id)) - .run(); + const cache = _ensureCache(); + + if (cache.size === 0) return null; + + const a = Buffer.from(keyHash, 'hex'); + for (const [storedHash, entry] of cache) { + const b = Buffer.from(storedHash, 'hex'); + if (a.length === b.length && timingSafeEqual(a, b)) { + // Update last_used_at in background (non-blocking) + try { + const db = getDb(); + const now = Date.now(); + const row = db.select().from(settings).where(eq(settings.id, entry.id)).get(); + if (row) { + const parsed = JSON.parse(row.value); + parsed.last_used_at = now; + db.update(settings) + .set({ value: JSON.stringify(parsed), updatedAt: now }) + .where(eq(settings.id, entry.id)) + .run(); + } + } catch { + // Non-fatal: last_used_at is informational + } + return entry; } - } catch { - // Non-fatal: last_used_at is informational } - return cached; + return null; }