src/server/api-key-routes.ts
Rama main · réplica de lectura del repositorio original.
// API key list/create/delete, ONE implementation for both mounts: `/api/api-keys` (self) and `/api/owner/api-keys`
// (owner console). Gates run per route, so mounting the router never gates unrelated requests.
// Audit actor is always the caller's principal id. Store refusals (validation 400, PASSIVE 409) map to their status.
import { Router, type Request, type RequestHandler, type Response } from 'express';
import type { AuthUser } from './auth.ts';
import { apiKeyStore, ApiKeyStoreError } from './api-keys.ts';
import { security } from './security/runtime.ts';
export class ApiKeyRoutesOptions {
base: string = '/api/api-keys';
gate: RequestHandler[] = [];
/** Extra gates for create/delete (after `gate`). */
writeGate: RequestHandler[] = [];
/** Owner console: sees/deletes every key and may set `role`/`expiresAt`. Self: own keys (all when the caller is an
* owner, as before) and label only. */
asOwner: boolean = false;
}
const userOf = (req: Request) => (req as any).user as AuthUser;
function fail(res: Response, e: any): void {
const status = e instanceof ApiKeyStoreError ? e.status : 400;
console.warn(`[api-keys] ${e?.message ?? e}`);
res.status(status).json({ error: e?.message ?? String(e) });
}
export function apiKeyRouter(options?: Partial<ApiKeyRoutesOptions>): Router {
const { base, gate, writeGate, asOwner } = { ...new ApiKeyRoutesOptions(), ...options };
const router = Router();
const isOwner = (u: AuthUser) => asOwner || u.isOwner;
router.get(base, ...gate, (req, res) => {
const user = userOf(req);
res.json({ keys: apiKeyStore().list({ uid: user.uid, isOwner: isOwner(user) }) });
});
/** Body: { label?, role?, expiresAt? (epoch ms) } — role/expiresAt honored on the owner mount only. Plaintext returned once. */
router.post(base, ...gate, ...writeGate, (req, res) => {
const user = userOf(req);
// Minting a key is minting a credential AS this identity — only an interactive login may (same rule as OAuth
// consent). Otherwise a role-capped or expiring key could mint itself an uncapped, non-expiring one.
const kind = user.principal?.kind ?? 'unknown';
if (kind !== 'user') {
console.warn(`[api-keys] create refused: non-interactive credential (${kind})`);
security()?.authDeny(`http:${base}`, `non-interactive:${kind}`, req.ip);
return void res.status(403).json({ error: 'Creating an API key requires an interactive login' });
}
const { label, role, expiresAt } = (req.body ?? {}) as { label?: string; role?: string; expiresAt?: number };
try {
const opts = asOwner ? { role, expiresAt } : {};
res.json(apiKeyStore().create(user.uid, user.email, label || 'Unnamed', { ...opts, actor: user.principal.id }));
} catch (e: any) { fail(res, e); }
});
router.delete(`${base}/:id`, ...gate, ...writeGate, (req: Request<{ id: string }>, res) => {
const user = userOf(req);
try {
const r = apiKeyStore().delete(req.params.id, user.uid, isOwner(user), user.principal.id);
if (r === 'not_found') return void res.status(404).json({ error: 'Key not found' });
if (r === 'forbidden') return void res.status(403).json({ error: 'Cannot delete another user\'s key' });
res.json({ ok: true });
} catch (e: any) { fail(res, e); }
});
return router;
}