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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user