Ctrl K
ComunidadGuías…

src/server/api-key-routes.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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;
}