import { Router, Request, Response } from 'express'; import { prisma } from '../db/prisma'; import { requireAuth, requires } from '../middleware/auth'; import { generateApiKey } from '../services/apiKeys'; import { isValidPermissionKey } from '../constants/permissions'; const router = Router(); function serialize(key: { id: string; name: string; prefix: string; permissions: string; createdByPubkey: string; lastUsedAt: Date | null; revokedAt: Date | null; createdAt: Date; }) { let permissions: string[] = []; try { const parsed = JSON.parse(key.permissions); if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string'); } catch { permissions = []; } return { id: key.id, name: key.name, prefix: key.prefix, permissions, createdByPubkey: key.createdByPubkey, lastUsedAt: key.lastUsedAt, revokedAt: key.revokedAt, createdAt: key.createdAt, }; } router.get( '/', requireAuth, requires('api_keys.manage'), async (_req: Request, res: Response) => { try { const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' } }); res.json(keys.map(serialize)); } catch (err) { console.error('List API keys error:', err); res.status(500).json({ error: 'Internal server error' }); } } ); router.post( '/', requireAuth, requires('api_keys.manage'), async (req: Request, res: Response) => { try { const { name, permissions } = req.body as { name?: string; permissions?: unknown }; const trimmedName = typeof name === 'string' ? name.trim() : ''; if (!trimmedName) { res.status(400).json({ error: 'name is required' }); return; } if (trimmedName.length > 100) { res.status(400).json({ error: 'name must be 100 characters or fewer' }); return; } if (!Array.isArray(permissions) || permissions.length === 0) { res.status(400).json({ error: 'At least one permission is required' }); return; } const requested = [...new Set(permissions.filter((p): p is string => typeof p === 'string'))]; const invalid = requested.filter((p) => !isValidPermissionKey(p)); if (invalid.length > 0) { res.status(400).json({ error: `Unknown permissions: ${invalid.join(', ')}` }); return; } // A key can never grant more than its creator holds. SuperAdmins hold all. const caller = req.access!; if (!caller.isSuperAdmin) { const exceeded = requested.filter((p) => !caller.permissions.has(p)); if (exceeded.length > 0) { res .status(403) .json({ error: `You cannot grant permissions you do not hold: ${exceeded.join(', ')}` }); return; } } const { rawKey, prefix, keyHash } = generateApiKey(); const created = await prisma.apiKey.create({ data: { name: trimmedName, prefix, keyHash, permissions: JSON.stringify(requested), createdByPubkey: req.user!.pubkey, }, }); // The raw key is returned exactly once and never stored in plaintext. res.status(201).json({ ...serialize(created), key: rawKey }); } catch (err) { console.error('Create API key error:', err); res.status(500).json({ error: 'Internal server error' }); } } ); router.delete( '/:id', requireAuth, requires('api_keys.manage'), async (req: Request, res: Response) => { try { const idRaw = req.params.id; const id = typeof idRaw === 'string' ? idRaw : Array.isArray(idRaw) ? idRaw[0] : ''; if (!id) { res.status(400).json({ error: 'id is required' }); return; } const existing = await prisma.apiKey.findUnique({ where: { id } }); if (!existing) { res.status(404).json({ error: 'API key not found' }); return; } if (existing.revokedAt) { res.json(serialize(existing)); return; } const revoked = await prisma.apiKey.update({ where: { id }, data: { revokedAt: new Date() }, }); res.json(serialize(revoked)); } catch (err) { console.error('Revoke API key error:', err); res.status(500).json({ error: 'Internal server error' }); } } ); export default router;