import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { ROLE_RANK, ROLES, type AgentKey } from '@wellformed/shared'; import { badRequest, forbidden, notFound } from '../errors.js'; import { createAgentKey, getAgentKey, listAgentKeys, revokeAgentKey, } from '../control/agent-keys.js'; import { recordAudit } from '../engine/audit.js'; /** * Managing the keys an assistant signs in with. * * These routes are inside `humanOnly`, so a key cannot mint another key. That * is not a general suspicion of assistants — they are trusted with schema * changes two files over — it is that a key able to issue keys is a chain * nobody can see the end of, and revoking the one you know about would not * necessarily revoke the ones it made. * * A key is always your own. You cannot make one for somebody else, because a * key acts as its holder and handing one out would be handing out an identity. * An admin can *see* and *revoke* every key in their workspace, because who is * reaching the workspace is a workspace admin's business. */ const createSchema = z.object({ label: z.string().trim().min(1).max(60), ceiling: z.enum(ROLES).default('viewer'), /** Null or absent means it lives until revoked. */ expiresInDays: z.number().int().min(1).max(365).nullable().optional(), }); const idParams = z.object({ id: z.string().min(1).max(64) }); export async function keyRoutes(app: FastifyInstance): Promise { /** Your keys here; an admin sees the whole workspace's. */ app.get('/keys', async (request): Promise<{ keys: AgentKey[] }> => { const account = request.ws.require('viewer'); if (!account) throw forbidden('Sign in to manage agent keys.'); const keys = listAgentKeys(app.control, { workspaceId: request.ws.workspace.id, accountId: request.ws.role === 'admin' ? undefined : account.id, }); return { keys }; }); app.post('/keys', async (request, reply): Promise<{ key: AgentKey }> => { const account = request.ws.require('viewer'); if (!account) throw forbidden('Sign in to create an agent key.'); const input = createSchema.parse(request.body); /** * A ceiling above your own role would be a promotion you granted yourself. * It would also be inert — the effective role is the lower of the two on * every request — so refusing here is about not handing back a key whose * label lies about what it can do. */ if (ROLE_RANK[input.ceiling] > ROLE_RANK[request.ws.role]) { throw badRequest( `You are a ${request.ws.role} in this workspace, so a key you make cannot have the ${input.ceiling} ceiling.`, ); } const issued = createAgentKey(app.control, { accountId: account.id, workspaceId: request.ws.workspace.id, label: input.label, ceiling: input.ceiling, expiresInDays: input.expiresInDays ?? null, }); // Creating a key changes who can reach the data, which is exactly the kind // of thing the trail is for. The secret is not in it. recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', column: 'agent key', nw: `${issued.record.label} (${issued.record.ceiling})`, }); reply.code(201); return { key: { id: issued.record.id, label: issued.record.label, ceiling: issued.record.ceiling, createdAt: issued.record.createdAt, lastUsedAt: null, expiresAt: issued.record.expiresAt, accountId: account.id, accountName: account.name, // The one time it is ever returned. secret: issued.secret, }, }; }); app.delete('/keys/:id', async (request) => { const account = request.ws.require('viewer'); if (!account) throw forbidden('Sign in to revoke an agent key.'); const { id } = idParams.parse(request.params); const key = getAgentKey(app.control, id); // A key in another workspace is not this workspace's to talk about. if (!key || key.workspaceId !== request.ws.workspace.id) throw notFound('No such agent key.'); const mine = key.accountId === account.id; if (!mine && request.ws.role !== 'admin') { throw forbidden('That key belongs to someone else. Only a workspace admin can revoke it.'); } if (revokeAgentKey(app.control, id)) { recordAudit(request.ws.db, request.ws.actor(), { kind: 'auth', column: 'agent key', old: `${key.label} (${key.ceiling})`, }); } return { ok: true }; }); }