feat: add scoped API keys for programmatic site access
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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user