import { Hono } from 'hono' import * as z from 'zod/mini' import * as core_ApiKey from '../../ApiKey.js' import * as ApiKeys from '../../ApiKeys.js' import * as Db from '../../db/Db.js' import * as Organizations from '../../db/tables/organizations.js' import * as Projects from '../../db/tables/projects.js' import * as Users from '../../db/tables/users.js' import * as OpenApi from '../../internal/OpenApi.js' import * as Response from '../../internal/Response.js' import * as Schema from '../../internal/Schema.js' import * as Scope from '../../Scope.js' import type * as App from '../App.js' /** Zod schemas owned by the admin API-keys handler. */ export namespace schema { // Leave time for validation and database lookups before KV writes its minimum 60-second TTL. const minimumExpiryLeadMs = 2 * 60_000 const Name = z .pipe( z.string(), z.transform((value) => value.trim()), ) .check( z.minLength(1), z.maxLength(100), z.describe('Human-readable purpose of the key.'), z.meta({ examples: ['Explorer testnet'] }), ) const ExpiresAt = z.optional(z.iso.datetime()).check( z.refine( (value) => value === undefined || Date.parse(value) >= Date.now() + minimumExpiryLeadMs, { error: 'Expiry must be at least two minutes in the future.', }, ), z.describe('ISO 8601 expiry timestamp. Omit for a non-expiring key.'), z.meta({ examples: ['2100-01-01T00:00:00.000Z'] }), ) const ApiKey = Schema.describe( z.extend(ApiKeys.schema.Record, { createdByLabel: z .nullable(z.string()) .check( z.describe('Resolved creator identity, or null when unavailable.'), z.meta({ examples: ['alex@tempo.xyz'] }), ), organizationName: z .nullable(z.string()) .check( z.describe('Resolved organization name, or null when unattributed or unavailable.'), z.meta({ examples: ['Tempo'] }), ), projectName: z .nullable(z.string()) .check( z.describe('Resolved project name, or null when unattributed or unavailable.'), z.meta({ examples: ['Explorer'] }), ), }), 'An API key with resolved operational attribution.', ) /** Schemas for the createApiKey operation. */ export namespace createApiKey { /** * Request body for minting a named key. The server sets `createdBy` from * the verified identity so it cannot be spoofed. */ export const Body = Schema.describe( z.extend(z.omit(ApiKeys.schema.MintInput, { createdBy: true, name: true }), { expiresAt: ExpiresAt, name: Name, scopes: z .readonly( z.array( z.union([ z.enum([...Scope.catalog.map((entry) => entry.scope), Scope.wildcard]), Scope.schema.Zone, ]), ), ) .check(z.describe("Granted scopes. '*' grants all scopes.")), }), 'Fields accepted when minting an API key.', ) /** Response: the record plus the one-time plaintext token. */ export const Response = Schema.describe( z.extend(ApiKeys.schema.Record, { token: z .string() .check(z.describe('The plaintext token, shown once and unrecoverable afterward.')), }), 'A minted API key, including its one-time token.', ) } /** Schemas for the listApiKeys operation. */ export namespace listApiKeys { /** Query parameters for listing keys. */ export const Query = z .object({ orgId: z .optional(z.string()) .check(z.describe('Restrict results to keys owned by this organization.')), }) .check(z.describe('Query parameters for listing API keys.')) /** Response body: a list of key records (metadata only — never tokens). */ export const Response = Schema.describe( z.object({ data: z .array(ApiKey) .check(z.describe('API-key records, newest first (metadata only — never tokens).')), }), 'A list of API-key records.', ) } /** Schemas for the updateApiKey operation. */ export namespace updateApiKey { /** Request body updating a key; omitted fields keep their value. */ export const Body = Schema.describe( z.object({ allowedIps: z .optional(core_ApiKey.schema.AllowedIps) .check(z.describe('Replacement client IP/CIDR allowlist. An empty list clears it.')), name: z.optional(Name).check(z.describe('New human-readable key name.')), orgId: z .optional(z.string()) .check(z.describe('Owning organization id to assign to an unattributed key (`org_…`).')), projectId: z.optional(z.string()).check(z.describe('New attributed project id (`prj_…`).')), }), 'Metadata to apply to an API key.', ) /** Path parameters for updating a key. */ export const Params = z .object({ id: z.string().check(z.describe('Stable id of the key to update.')), }) .check(z.describe('Path parameters for updating an API key.')) } /** Schemas for the backfillApiKeys operation. */ export namespace backfillApiKeys { /** Result counts from the index rebuild. */ export const Response = Schema.describe( z.object({ indexed: z.number().check(z.describe('Records whose index entries were written.')), scanned: z.number().check(z.describe('Stored records scanned.')), }), 'Result of rebuilding the API-key indexes.', ) } /** Schemas for the revokeApiKey operation. */ export namespace revokeApiKey { /** Path parameters for revoking a key. */ export const Params = z .object({ id: z.string().check(z.describe('Stable id of the key to revoke.')), }) .check(z.describe('Path parameters for revoking an API key.')) /** Response body for a successful revocation. */ export const Response = z .object({ id: z.string().check(z.describe('Revoked key id.')) }) .check(z.describe('API-key revocation result.')) } /** Schemas for the rotateApiKey operation. */ export namespace rotateApiKey { /** Path parameters for rotating a key. */ export const Params = z .object({ id: z .string() .check( z.describe('Stable id of the key to rotate.'), z.meta({ examples: ['key_1a2b3c4d5e6f7g8h9j0k1m2n'] }), ), }) .check(z.describe('Path parameters for rotating an API key.')) /** Response: the replacement record plus its one-time plaintext token. */ export const Response = createApiKey.Response } } /** * Provisioning routes for API keys, backed by the app's KV state store: * * - `POST /` mints a key, recording `createdBy` from the verified identity and * returning the one-time token. * - `GET /` lists key records (metadata only), optionally filtered by `orgId`. * - `DELETE /:id` revokes a key by id (`404` if absent). * - `PATCH /:id` updates a key's allowlist, name, or project, or assigns its first organization. * - `POST /:id/rotate` mints a replacement with the source key's access and attribution. * - `POST /backfill` rebuilds the id and org index entries for every record. */ export function apiKeys() { return new Hono() .post( '/backfill', OpenApi.describeRoute({ description: 'Rebuild the id and org index entries for every stored key record. Records are never modified, so existing tokens keep resolving.', // prettier-ignore operationId: 'backfillApiKeys', responses: OpenApi.responses({ success: { description: 'Scanned and indexed record counts.', schema: schema.backfillApiKeys.Response, }, }), summary: 'Backfill key indexes', tags: ['API keys'], }), async (c) => { const result = await ApiKeys.backfill(c.get('kv').store) return c.json(Response.validated(schema.backfillApiKeys.Response, result)) }, ) .post( '/', OpenApi.validate('json', schema.createApiKey.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ operationId: 'createApiKey', responses: OpenApi.responses({ errors: { 404: 'Organization or project not found.' }, success: { description: 'Minted key (includes the one-time token).', schema: schema.createApiKey.Response, }, }), summary: 'Create API key', tags: ['API keys'], }), async (c) => { const input = c.req.valid('json') const db = Db.get(c.get('db')) try { const [organization, project] = await Promise.all([ input.orgId === undefined ? undefined : Organizations.get(db, input.orgId), input.projectId === undefined ? undefined : Projects.get(db, input.projectId), ]) if (input.orgId !== undefined && !organization) return Response.error(c, { code: 'not_found', message: 'Organization not found.', status: 404, }) if (input.projectId !== undefined && !project) return Response.error(c, { code: 'not_found', message: 'Project not found.', status: 404, }) if (project && input.orgId !== undefined && project.orgId !== input.orgId) return Response.error(c, { code: 'not_found', message: 'Project not found in this organization.', status: 404, }) const orgId = input.orgId ?? project?.orgId // `createdBy` is the verified admin email, never client-supplied. // `requireAuth` guarantees identity on this gated route. const { record, token } = await ApiKeys.mint(c.get('kv').store, { ...input, createdBy: c.get('identity')!.email, ...(orgId === undefined ? {} : { orgId }), }) return c.json(Response.validated(schema.createApiKey.Response, { ...record, token })) } catch (cause) { return Response.upstream(c, cause) } }, ) .get( '/', OpenApi.validate('query', schema.listApiKeys.Query, { code: 'query_invalid', message: 'Invalid query parameters', }), OpenApi.describeRoute({ operationId: 'listApiKeys', responses: OpenApi.responses({ success: { description: 'API-key records.', schema: schema.listApiKeys.Response }, }), summary: 'List API keys', tags: ['API keys'], }), async (c) => { const { orgId } = c.req.valid('query') const records = orgId ? await ApiKeys.listByOrg(c.get('kv').store, orgId) : await ApiKeys.list(c.get('kv').store) const data = await enrichKeys(Db.get(c.get('db')), records) return c.json(Response.validated(schema.listApiKeys.Response, { data })) }, ) .delete( '/:id', OpenApi.validate('param', schema.revokeApiKey.Params, { code: 'param_invalid', message: 'Invalid path parameters', }), OpenApi.describeRoute({ operationId: 'revokeApiKey', responses: OpenApi.responses({ errors: { 404: 'API key not found.' }, success: { description: 'Key revoked.', schema: schema.revokeApiKey.Response }, }), summary: 'Revoke API key', tags: ['API keys'], }), async (c) => { const { id } = c.req.valid('param') const revoked = await ApiKeys.revoke(c.get('kv').store, id) if (!revoked) return Response.error(c, { code: 'not_found', message: 'API key not found.', status: 404, }) return c.json(Response.validated(schema.revokeApiKey.Response, { id })) }, ) .post( '/:id/rotate', OpenApi.validate('param', schema.rotateApiKey.Params, { code: 'param_invalid', message: 'Invalid path parameters', }), OpenApi.describeRoute({ description: 'Create a replacement that preserves the source key access and attribution. The source key remains active.', // prettier-ignore operationId: 'rotateAdminApiKey', responses: OpenApi.responses({ errors: { 404: 'API key not found.' }, success: { description: 'Replacement key (includes the one-time token).', schema: schema.rotateApiKey.Response, }, }), summary: 'Rotate API key', tags: ['API keys'], }), async (c) => { const { id } = c.req.valid('param') const store = c.get('kv').store const source = await ApiKeys.get(store, id) if (!source) return Response.error(c, { code: 'not_found', message: 'API key not found.', status: 404, }) const { record, token } = await ApiKeys.mint(store, { allowedIps: source.allowedIps, createdBy: c.get('identity')!.email, environment: source.environment, scopes: source.scopes, ...(source.billingActive === undefined ? {} : { billingActive: source.billingActive }), ...(source.expiresAt === undefined ? {} : { expiresAt: source.expiresAt }), ...(source.name === undefined ? {} : { name: source.name }), ...(source.orgId === source.id ? {} : { orgId: source.orgId }), ...(source.projectId === undefined ? {} : { projectId: source.projectId }), ...(source.rateLimits === undefined ? {} : { rateLimits: source.rateLimits }), }) return c.json(Response.validated(schema.rotateApiKey.Response, { ...record, token }), 200) }, ) .patch( '/:id', OpenApi.validate('param', schema.updateApiKey.Params, { code: 'param_invalid', message: 'Invalid path parameters', }), OpenApi.validate('json', schema.updateApiKey.Body, { code: 'body_invalid', message: 'Invalid request body', }), OpenApi.describeRoute({ description: 'Update a key allowlist, name, or project, or assign its first organization. The token is unchanged.', // prettier-ignore operationId: 'updateApiKey', responses: OpenApi.responses({ errors: { 404: 'API key or organization not found.', 409: 'API key already belongs to another organization.', }, success: { description: 'The updated key record.', schema: ApiKeys.schema.Record }, }), summary: 'Update API key', tags: ['API keys'], }), async (c) => { const { id } = c.req.valid('param') const input = c.req.valid('json') const store = c.get('kv').store const record = await ApiKeys.get(store, id) if (!record) return Response.error(c, { code: 'not_found', message: 'API key not found.', status: 404, }) // One-off keys use their key id as `orgId`. if (input.orgId !== undefined && record.orgId !== record.id && input.orgId !== record.orgId) return Response.error(c, { code: 'organization_already_assigned', message: 'API key already belongs to an organization.', status: 409, }) if ( input.orgId !== undefined && !(await Organizations.get(Db.get(c.get('db')), input.orgId)) ) return Response.error(c, { code: 'not_found', message: 'Organization not found.', status: 404, }) const updated = await ApiKeys.update(store, id, input) if (!updated) return Response.error(c, { code: 'not_found', message: 'API key not found.', status: 404, }) return c.json(Response.validated(ApiKeys.schema.Record, updated)) }, ) } /** Resolves display labels without changing the authoritative key record. */ async function enrichKeys(db: Db.Db, records: readonly ApiKeys.Record[]) { const organizationIds = [ ...new Set(records.map(({ orgId }) => orgId).filter((id) => id.startsWith('org_'))), ] const projectIds = [ ...new Set(records.flatMap(({ projectId }) => (projectId ? [projectId] : []))), ] const userIds = [ ...new Set( records.flatMap(({ createdBy }) => (createdBy?.startsWith('usr_') ? [createdBy] : [])), ), ] const [organizations, projects, users] = await Promise.all([ Organizations.listByIds(db, organizationIds), Projects.listByIds(db, projectIds), Users.listByIds(db, userIds), ]) const organizationNames = new Map(organizations.map(({ id, name }) => [id, name])) const projectNames = new Map(projects.map(({ id, name }) => [id, name])) const userLabels = new Map(users.map(({ address, email, id }) => [id, email ?? address ?? id])) return records.map((record) => ({ ...record, createdByLabel: record.createdBy ? (userLabels.get(record.createdBy) ?? record.createdBy) : null, organizationName: organizationNames.get(record.orgId) ?? null, projectName: record.projectId ? (projectNames.get(record.projectId) ?? null) : null, })) }