import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' import type { DatabaseInput } from '../db/binding.js' import { emitHookfishEvent, type HookfishEventHandler } from '../events.js' import { assertCanDelegate, assertRootAccess, DEFAULT_ACCESS_TOKEN_TTL_SECONDS, MAX_ACCESS_TOKEN_TTL_SECONDS, mintAccessToken, normalizeTokenName, } from '../oauth/access-token.js' import { requireBrokerApiKey, resolveBrokerConfig } from '../oauth/config.js' import { BrokerError, isBrokerError } from '../oauth/errors.js' import { type BrokerContext, requireApiKey, withDatabase, } from '../oauth/middleware.js' import { MAX_RESOURCE_PATH_LENGTH } from '../oauth/resource-path.js' const brokerAuth = [{ brokerApiKey: [] }] const errorSchema = z.object({ error: z.object({ code: z.string(), message: z.string() }), }) const errors = { 400: { description: 'Invalid request', content: { 'application/json': { schema: errorSchema } }, }, 401: { description: 'Missing or invalid broker credential', content: { 'application/json': { schema: errorSchema } }, }, 403: { description: 'The credential cannot perform this administrative action', content: { 'application/json': { schema: errorSchema } }, }, 409: { description: 'The token name is already in use', content: { 'application/json': { schema: errorSchema } }, }, 500: { description: 'The broker is misconfigured', content: { 'application/json': { schema: errorSchema } }, }, } const mintTokenRoute = createRoute({ method: 'post', path: '/tokens', operationId: 'admin.tokens.create', summary: 'Mint a named broker access token', description: 'Creates an expiring named credential for exact resource paths or explicit `namespace/**` subtrees. Tokens may delegate only grants they already hold, within their own dot-delimited name namespace, and may not create a token that outlives them.', security: brokerAuth, request: { body: { content: { 'application/json': { schema: z.object({ name: z .string() .min(1) .max(128) .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) .openapi({ example: 'team-worker' }), scopes: z .array(z.string().min(1).max(MAX_RESOURCE_PATH_LENGTH)) .min(1) .max(32) .openapi({ description: 'Exact resource paths or explicit namespace subtrees ending in `/**`.', example: ['team/payments/github', 'team/support/**'], }), expires_in: z .number() .int() .min(60) .max(MAX_ACCESS_TOKEN_TTL_SECONDS) .optional() .openapi({ description: 'Lifetime in seconds (default: 3600; max: 30 days).', example: DEFAULT_ACCESS_TOKEN_TTL_SECONDS, }), }), }, }, }, }, responses: { 200: { description: 'Named broker access token', content: { 'application/json': { schema: z.object({ name: z.string(), access_token: z.string(), token_type: z.literal('Bearer'), scopes: z.array(z.string()), expires_at: z.string(), }), }, }, }, ...errors, }, }) const listTokensRoute = createRoute({ method: 'get', path: '/tokens', operationId: 'admin.tokens.list', summary: 'List active broker access token names', description: 'Requires root access. Returns names only; bearer values, scopes, and expiration metadata are never included.', security: brokerAuth, responses: { 200: { description: 'Active token names', content: { 'application/json': { schema: z.object({ tokens: z.array(z.string()) }), }, }, }, ...errors, }, }) const revokeTokenRoute = createRoute({ method: 'delete', path: '/tokens/{name}', operationId: 'admin.tokens.revoke', summary: 'Revoke a named broker access token', description: 'Requires root access. Deletes the persisted token record, immediately invalidating its bearer credential.', security: brokerAuth, request: { params: z.object({ name: z .string() .min(1) .max(128) .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) .openapi({ param: { name: 'name', in: 'path' }, example: 'team-worker', }), }), }, responses: { 200: { description: 'Revocation result', content: { 'application/json': { schema: z.object({ name: z.string(), revoked: z.boolean() }), }, }, }, ...errors, }, }) async function purgeExpiredTokenNames( db: BrokerContext['Variables']['db'], now: Date, ): Promise { await db.purgeExpiredBrokerAccessTokens(now) } export function createAdminRoutes( database: DatabaseInput, onEvent?: HookfishEventHandler, ) { const adminRoutes = new OpenAPIHono>() const authenticate = requireApiKey() const connectDatabase = withDatabase(database) adminRoutes.use('/tokens', connectDatabase, authenticate) adminRoutes.use('/tokens/*', connectDatabase, authenticate) const mintApi = adminRoutes.openapi(mintTokenRoute, async (c) => { const body = c.req.valid('json') const name = normalizeTokenName(body.name) const expiresIn = body.expires_in ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS const nowMs = Date.now() const now = new Date(nowMs) const expiresAtSeconds = Math.floor(nowMs / 1000) + expiresIn const scopes = assertCanDelegate( c.get('accessGrant'), name, body.scopes, expiresAtSeconds, ) const minted = await mintAccessToken( requireBrokerApiKey(resolveBrokerConfig(c.env)), { name, scopes, expiresIn }, nowMs, ) await purgeExpiredTokenNames(c.get('db'), now) const inserted = await c.get('db').createBrokerAccessToken({ name: minted.name, tokenIdHash: minted.tokenIdHash, scopes: minted.scopes, expiresAt: new Date(minted.expiresAt * 1000), }) if (!inserted) { throw new BrokerError( 409, 'token_name_in_use', `An active broker access token named "${name}" already exists.`, ) } c.header('Cache-Control', 'no-store') c.header('Pragma', 'no-cache') await emitHookfishEvent(onEvent, { type: 'broker_token.created', occurredAt: new Date(), tokenName: minted.name, }) return c.json( { name: minted.name, access_token: minted.token, token_type: 'Bearer' as const, scopes: minted.scopes, expires_at: new Date(minted.expiresAt * 1000).toISOString(), }, 200, ) }) const routes = mintApi.openapi(listTokensRoute, async (c) => { assertRootAccess(c.get('accessGrant')) await purgeExpiredTokenNames(c.get('db'), new Date()) const tokens = await c.get('db').listBrokerAccessTokenNames() c.header('Cache-Control', 'no-store') return c.json({ tokens }, 200) }) const revokeApi = routes.openapi(revokeTokenRoute, async (c) => { assertRootAccess(c.get('accessGrant')) const name = normalizeTokenName(c.req.valid('param').name) const deleted = await c.get('db').deleteBrokerAccessToken(name) if (deleted) { await emitHookfishEvent(onEvent, { type: 'broker_token.revoked', occurredAt: new Date(), tokenName: name, }) } c.header('Cache-Control', 'no-store') return c.json({ name, revoked: deleted }, 200) }) revokeApi.onError((error, c) => { if (isBrokerError(error)) { return c.json( { error: { code: error.code, message: error.message } }, error.status, ) } console.error('broker admin error', error) return c.json( { error: { code: 'internal_error', message: 'Unexpected broker error.' }, }, 500, ) }) return revokeApi }