Introduce ApiKey model, CRUD endpoints, and admin UI so agents can authenticate with permission-scoped keys. Normalize pubkeys to hex on login, dedupe legacy npub/hex user rows, and ignore .cursor in git. Co-authored-by: Cursor <cursoragent@cursor.com>
151 lines
4.3 KiB
TypeScript
151 lines
4.3 KiB
TypeScript
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;
|