From 33a126877ada053bdb24801a5ee3bb2a603a1227 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:34:29 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20perf:=20async=20api-keys=20validati?= =?UTF-8?q?on=20&=20background=20saves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: scobru <1079164+scobru@users.noreply.github.com> --- relay/src/middleware/admin-or-api-key-auth.ts | 9 +- relay/src/routes/api-keys.ts | 14 +- relay/src/utils/api-keys-store.ts | 126 ++++++++++++------ 3 files changed, 97 insertions(+), 52 deletions(-) diff --git a/relay/src/middleware/admin-or-api-key-auth.ts b/relay/src/middleware/admin-or-api-key-auth.ts index 169d0b71..402da3fa 100644 --- a/relay/src/middleware/admin-or-api-key-auth.ts +++ b/relay/src/middleware/admin-or-api-key-auth.ts @@ -6,7 +6,12 @@ */ import { Request, Response, NextFunction } from "express"; -import { secureCompare, hashToken, validateAdminToken, isAdminPasswordConfigured } from "../utils/security"; +import { + secureCompare, + hashToken, + validateAdminToken, + isAdminPasswordConfigured, +} from "../utils/security"; import { authConfig } from "../config"; import { validateApiKey } from "../utils/api-keys-store"; import { loggers } from "../utils/logger"; @@ -59,7 +64,7 @@ export async function adminOrApiKeyAuthMiddleware( // If admin token fails, try API key authentication if (token.startsWith("shogun-api-")) { try { - const keyData = validateApiKey(token); + const keyData = await validateApiKey(token); if (keyData) { log.debug( { diff --git a/relay/src/routes/api-keys.ts b/relay/src/routes/api-keys.ts index 7b8a1856..58d3eb36 100644 --- a/relay/src/routes/api-keys.ts +++ b/relay/src/routes/api-keys.ts @@ -7,9 +7,9 @@ const router: Router = Router(); /** * List all API keys */ -router.get("/", (req: Request, res: Response) => { +router.get("/", async (req: Request, res: Response) => { try { - const keys = listApiKeys(); + const keys = await listApiKeys(); res.json({ success: true, keys }); } catch (error: any) { loggers.server.error({ error }, "Error listing API keys"); @@ -20,14 +20,14 @@ router.get("/", (req: Request, res: Response) => { /** * Generate a new API key */ -router.post("/generate", (req: Request, res: Response) => { +router.post("/generate", async (req: Request, res: Response) => { try { const { name } = req.body; if (!name) { return res.status(400).json({ success: false, error: "Name is required for the API key" }); } - const { token, data } = generateApiKey(name); + const { token, data } = await generateApiKey(name); res.json({ success: true, token, data }); } catch (error: any) { loggers.server.error({ error }, "Error generating API key"); @@ -38,11 +38,11 @@ router.post("/generate", (req: Request, res: Response) => { /** * Revoke an API key */ -router.delete("/:keyId", (req: Request, res: Response) => { +router.delete("/:keyId", async (req: Request, res: Response) => { try { const { keyId } = req.params; - const success = revokeApiKey(keyId as string); - + const success = await revokeApiKey(keyId as string); + if (success) { res.json({ success: true, message: "API key revoked successfully" }); } else { diff --git a/relay/src/utils/api-keys-store.ts b/relay/src/utils/api-keys-store.ts index 3399c1a0..f22db918 100644 --- a/relay/src/utils/api-keys-store.ts +++ b/relay/src/utils/api-keys-store.ts @@ -25,67 +25,104 @@ interface ApiKeyStore { // In-memory cache let storeCache: ApiKeyStore | null = null; +let loadPromise: Promise | null = null; +let savePromise: Promise | null = null; +let savePending = false; /** * Ensure data directory exists */ -function ensureDataDir() { - if (!fs.existsSync(DATA_DIR)) { - fs.mkdirSync(DATA_DIR, { recursive: true }); +async function ensureDataDir() { + try { + await fs.promises.access(DATA_DIR); + } catch { + await fs.promises.mkdir(DATA_DIR, { recursive: true }); } } -/** - * Load store from disk - */ -function loadStore(): ApiKeyStore { - if (storeCache) return storeCache; +async function doLoadStore(): Promise { + await ensureDataDir(); - ensureDataDir(); - - if (fs.existsSync(STORE_PATH)) { - try { - const data = fs.readFileSync(STORE_PATH, "utf-8"); - storeCache = JSON.parse(data); - return storeCache!; - } catch (error) { + try { + const data = await fs.promises.readFile(STORE_PATH, "utf-8"); + storeCache = JSON.parse(data); + return storeCache!; + } catch (error: any) { + if (error.code !== "ENOENT") { loggers.server.error({ error }, "Failed to read api-keys.json, initializing empty store"); } } // Init empty store storeCache = { keys: {} }; - saveStore(storeCache); + await saveStore(storeCache); return storeCache; } /** - * Save store to disk + * Load store from disk */ -function saveStore(store: ApiKeyStore) { +async function loadStore(): Promise { + if (storeCache) return storeCache; + if (loadPromise) return loadPromise; + + loadPromise = doLoadStore().finally(() => { + loadPromise = null; + }); + + return loadPromise; +} + +async function doSaveStore(store: ApiKeyStore) { try { - ensureDataDir(); - fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf-8"); + await ensureDataDir(); + const tempPath = STORE_PATH + ".tmp"; + await fs.promises.writeFile(tempPath, JSON.stringify(store, null, 2), "utf-8"); + await fs.promises.rename(tempPath, STORE_PATH); storeCache = store; } catch (error) { loggers.server.error({ error }, "Failed to write api-keys.json"); } } +/** + * Save store to disk + */ +async function saveStore(store: ApiKeyStore) { + savePending = true; + + if (savePromise) { + return savePromise; + } + + const startSaveLoop = async () => { + while (savePending) { + savePending = false; + await doSaveStore(storeCache || store); + } + }; + + savePromise = startSaveLoop().finally(() => { + savePromise = null; + }); + + return savePromise; +} + /** * Generate a new API key * Returns the full token (only once) and the stored data */ -export function generateApiKey(name: string): { token: string; data: ApiKeyData } { - const store = loadStore(); - +export async function generateApiKey(name: string): Promise<{ token: string; data: ApiKeyData }> { + const store = await loadStore(); + // Generate a random token const rawSecret = randomBytes(32).toString("base64url"); const token = `shogun-api-${rawSecret}`; - + const keyId = `key_${randomBytes(8).toString("hex")}`; const keyPrefix = token.substring(0, 16) + "..."; - + const data: ApiKeyData = { keyId, name, @@ -93,11 +130,11 @@ export function generateApiKey(name: string): { token: string; data: ApiKeyData createdAt: Date.now(), }; - // We use the token itself as the key in the store for fast lookup. - // In a highly secure environment, we would hash the token before storing, + // We use the token itself as the key in the store for fast lookup. + // In a highly secure environment, we would hash the token before storing, // but since adminPassword is also in cleartext in .env, this is acceptable for the node operator. store.keys[token] = data; - saveStore(store); + await saveStore(store); loggers.server.info({ keyId, name }, "Generated new API key"); @@ -107,49 +144,52 @@ export function generateApiKey(name: string): { token: string; data: ApiKeyData /** * List all API keys (without the full token) */ -export function listApiKeys(): ApiKeyData[] { - const store = loadStore(); - return Object.values(store.keys).map(k => ({ ...k })); +export async function listApiKeys(): Promise { + const store = await loadStore(); + return Object.values(store.keys).map((k) => ({ ...k })); } /** * Validate an API key token * Returns the key data if valid, null otherwise */ -export function validateApiKey(token: string): ApiKeyData | null { - const store = loadStore(); +export async function validateApiKey(token: string): Promise { + const store = await loadStore(); const data = store.keys[token]; - + if (data) { // Update last used data.lastUsed = Date.now(); - saveStore(store); + // Fire-and-forget save so we don't penalize API latency + saveStore(store).catch((err) => { + loggers.server.error({ error: err }, "Failed background save of api-keys.json"); + }); return { ...data }; } - + return null; } /** * Revoke an API key by ID */ -export function revokeApiKey(keyId: string): boolean { - const store = loadStore(); +export async function revokeApiKey(keyId: string): Promise { + const store = await loadStore(); let tokenToRemove: string | null = null; - + for (const [token, data] of Object.entries(store.keys)) { if (data.keyId === keyId) { tokenToRemove = token; break; } } - + if (tokenToRemove) { delete store.keys[tokenToRemove]; - saveStore(store); + await saveStore(store); loggers.server.info({ keyId }, "Revoked API key"); return true; } - + return false; }