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:
bbe
2026-06-23 09:29:30 +02:00
co-authored by Cursor
parent 70e3e0633d
commit a6a2b113ee
17 changed files with 836 additions and 100 deletions
+150
View File
@@ -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;
+5 -2
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { normalizePubkey } from '../services/pubkey';
const router = Router();
@@ -37,10 +38,12 @@ router.post('/verify', async (req: Request, res: Response) => {
// Ensure a user row exists, but never overwrite an existing role on login.
// The role is managed only through role assignment, and SuperAdmin is env-sourced.
// Store the pubkey as hex so the same identity is never duplicated as npub.
const hexPubkey = normalizePubkey(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
where: { pubkey: hexPubkey },
update: {},
create: { pubkey },
create: { pubkey: hexPubkey },
});
const access = await authService.resolveAccess(pubkey);
+82 -8
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { isSuperadmin } from '../services/auth';
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
import {
ROLE_RANK,
isAssignableRole,
@@ -42,6 +43,12 @@ function validateUsername(
return null;
}
function pickHigherRole(a: string | null, b: string | null): string | null {
const rankOf = (r: string | null): number =>
r && isAssignableRole(r) ? ROLE_RANK[r as EffectiveRole] : 0;
return rankOf(a) >= rankOf(b) ? a : b;
}
const router = Router();
router.get(
@@ -53,9 +60,36 @@ router.get(
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
// The same person can have been stored both as hex and as npub (no
// normalization existed historically). Collapse those into a single
// identity keyed by hex so the list never shows a duplicate row.
const byHex = new Map<string, (typeof users)[number]>();
for (const u of users) {
const hex = toHexPubkey(u.pubkey) ?? u.pubkey;
const existing = byHex.get(hex);
if (!existing) {
byHex.set(hex, { ...u, pubkey: hex });
continue;
}
// Merge: prefer a stored role over none, the higher-ranked role on
// conflict, and the first available username. Keep the earliest join.
const mergedRole = pickHigherRole(existing.role, u.role);
const mergedUsername = existing.username ?? u.username;
const earliest = existing.createdAt <= u.createdAt ? existing : u;
byHex.set(hex, {
...existing,
role: mergedRole,
username: mergedUsername,
displayName: existing.displayName ?? u.displayName,
createdAt: earliest.createdAt,
pubkey: hex,
});
}
// Flag env-sourced SuperAdmins so the UI can render them locked. Their
// effective role is reported as superadmin regardless of any stored value.
const result = users.map((u) => {
const result = [...byHex.values()].map((u) => {
const superAdmin = isSuperadmin(u.pubkey);
return {
...u,
@@ -71,6 +105,41 @@ router.get(
}
);
// Creates a user row from an npub or hex pubkey so admins can pre-register
// people before they have logged in. The pubkey is normalized to hex.
router.post(
'/',
requireAuth,
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
if (!rawPubkey || typeof rawPubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = toHexPubkey(rawPubkey);
if (!hex) {
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
return;
}
const existing = await prisma.user.findUnique({ where: { pubkey: hex } });
if (existing) {
res.status(409).json({ error: 'User already exists' });
return;
}
const user = await prisma.user.create({ data: { pubkey: hex } });
res.status(201).json({ ...user, role: user.role ?? null, isSuperAdmin: isSuperadmin(hex) });
} catch (err) {
console.error('Create user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the
// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller
// cannot assign a role at or above their own or modify anyone at or above them.
@@ -81,12 +150,13 @@ router.put(
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const pubkey = normalizePubkey(pubkeyParam);
const { role } = req.body as { role?: string | null };
@@ -222,12 +292,13 @@ router.patch(
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = normalizePubkey(pubkeyParam);
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
@@ -238,7 +309,10 @@ router.patch(
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
// Tolerate legacy rows still stored as npub by matching either form.
const target = await prisma.user.findFirst({
where: { OR: [{ pubkey: hex }, { pubkey: pubkeyParam }] },
});
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
@@ -247,7 +321,7 @@ router.patch(
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
NOT: { pubkey: target.pubkey },
},
});
@@ -257,7 +331,7 @@ router.patch(
}
const user = await prisma.user.update({
where: { pubkey },
where: { pubkey: target.pubkey },
data: { username: normalized },
});