From d9adbeaa7d1e4c1fdad0884ff1c27cfcfdb06dd1 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 11:15:57 -0600 Subject: [PATCH 1/9] feat(openapi): add usage analytics docs --- apps/web/src/app/api/docs/route.ts | 93 ++++++++ apps/web/src/app/api/openapi.json/route.ts | 5 + apps/web/src/lib/openapi/trpc-openapi.test.ts | 61 ++++++ apps/web/src/lib/openapi/trpc-openapi.ts | 124 +++++++++++ apps/web/src/lib/openapi/trpc-registry.ts | 72 +++++++ .../web/src/routers/usage-analytics-router.ts | 199 ++++-------------- .../src/routers/usage-analytics-schemas.ts | 136 ++++++++++++ 7 files changed, 530 insertions(+), 160 deletions(-) create mode 100644 apps/web/src/app/api/docs/route.ts create mode 100644 apps/web/src/app/api/openapi.json/route.ts create mode 100644 apps/web/src/lib/openapi/trpc-openapi.test.ts create mode 100644 apps/web/src/lib/openapi/trpc-openapi.ts create mode 100644 apps/web/src/lib/openapi/trpc-registry.ts create mode 100644 apps/web/src/routers/usage-analytics-schemas.ts diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts new file mode 100644 index 0000000000..ba9469965a --- /dev/null +++ b/apps/web/src/app/api/docs/route.ts @@ -0,0 +1,93 @@ +const swaggerUiHtml = ` + + + + + Kilo Code API Docs + + + + +
+
+

Kilo Code API Docs

+

Swagger UI generated from the allowlisted tRPC OpenAPI document.

+
+ Open JSON +
+
+ + + +`; + +export function GET() { + return new Response(swaggerUiHtml, { + headers: { + 'content-type': 'text/html; charset=utf-8', + }, + }); +} diff --git a/apps/web/src/app/api/openapi.json/route.ts b/apps/web/src/app/api/openapi.json/route.ts new file mode 100644 index 0000000000..0350aa3dee --- /dev/null +++ b/apps/web/src/app/api/openapi.json/route.ts @@ -0,0 +1,5 @@ +import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi'; + +export function GET() { + return Response.json(generateTrpcOpenApiDocument()); +} diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts new file mode 100644 index 0000000000..8faa5f9bc7 --- /dev/null +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from '@jest/globals'; +import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry'; +import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi'; + +describe('generateTrpcOpenApiDocument', () => { + it('documents only the allowlisted tRPC procedures', () => { + const document = generateTrpcOpenApiDocument(); + + expect(Object.keys(document.paths).sort()).toEqual( + publicTrpcOpenApiProcedures.map(procedure => procedure.path).sort() + ); + expect(document.paths['/api/trpc/usageAnalytics.getTable']?.post).toMatchObject({ + operationId: 'usageAnalytics_getTable', + summary: 'Get tabular usage analytics', + tags: ['Usage Analytics'], + }); + expect(document.paths).not.toHaveProperty('/api/trpc/admin'); + }); + + it('generates request and response schemas for usageAnalytics.getTable', () => { + const document = generateTrpcOpenApiDocument(); + const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.post; + + expect(operation).toMatchObject({ + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: expect.arrayContaining(['startDate', 'endDate', 'granularity', 'groupBy']), + properties: { + groupBy: { + type: 'array', + items: { enum: ['feature', 'model', 'mode', 'user', 'provider', 'project'] }, + }, + limit: { default: 1000 }, + }, + }, + }, + }, + }, + responses: { + '200': { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['rows', 'effectiveGranularity'], + properties: { + rows: { type: 'array' }, + effectiveGranularity: { enum: ['hour', 'day', 'week', 'month'] }, + }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts new file mode 100644 index 0000000000..6c6a89fc6f --- /dev/null +++ b/apps/web/src/lib/openapi/trpc-openapi.ts @@ -0,0 +1,124 @@ +import * as z from 'zod'; +import { + publicTrpcOpenApiProcedures, + type TrpcOpenApiProcedure, +} from '@/lib/openapi/trpc-registry'; + +type JsonSchema = Record; + +type OpenApiDocument = { + openapi: '3.1.0'; + info: { + title: string; + version: string; + }; + servers: { url: string }[]; + tags: { name: string }[]; + components: { + securitySchemes: { + apiKey: { + type: 'http'; + scheme: 'bearer'; + }; + }; + }; + paths: Record>; +}; + +function zodToJsonSchema(schema: z.ZodType): JsonSchema { + return z.toJSONSchema(schema, { target: 'draft-7' }) as JsonSchema; +} + +function errorResponse(description: string) { + return { + description, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { type: 'string' }, + code: { type: 'number' }, + data: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['message', 'code'], + additionalProperties: true, + }, + }, + required: ['error'], + additionalProperties: true, + }, + }, + }, + }; +} + +function operationForProcedure(procedure: TrpcOpenApiProcedure) { + return { + operationId: procedure.procedurePath.replaceAll('.', '_'), + tags: procedure.tags, + summary: procedure.summary, + description: procedure.description, + security: procedure.security === 'apiKey' ? [{ apiKey: [] }] : undefined, + requestBody: { + required: true, + content: { + 'application/json': { + schema: zodToJsonSchema(procedure.input), + }, + }, + }, + responses: { + '200': { + description: 'Successful tRPC response', + content: { + 'application/json': { + schema: zodToJsonSchema(procedure.output), + }, + }, + }, + '400': errorResponse('Invalid request'), + '401': errorResponse('Authentication required'), + '403': errorResponse('Access denied'), + '500': errorResponse('Unexpected server error'), + }, + }; +} + +export function generateTrpcOpenApiDocument(): OpenApiDocument { + const paths: OpenApiDocument['paths'] = {}; + const tagNames = new Set(); + + for (const procedure of publicTrpcOpenApiProcedures) { + for (const tag of procedure.tags) tagNames.add(tag); + paths[procedure.path] = { + ...paths[procedure.path], + [procedure.method]: operationForProcedure(procedure), + }; + } + + return { + openapi: '3.1.0', + info: { + title: 'Kilo Code tRPC API', + version: '1.0.0', + }, + servers: [{ url: '/' }], + tags: [...tagNames].sort().map(name => ({ name })), + components: { + securitySchemes: { + apiKey: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + paths, + }; +} diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts new file mode 100644 index 0000000000..149fd3da1e --- /dev/null +++ b/apps/web/src/lib/openapi/trpc-registry.ts @@ -0,0 +1,72 @@ +import type * as z from 'zod'; +import { + BreakdownInputSchema, + BreakdownOutputSchema, + SummaryOutputSchema, + TableInputSchema, + TableOutputSchema, + TimeseriesInputSchema, + TimeseriesOutputSchema, + UsageAnalyticsFiltersSchema, +} from '@/routers/usage-analytics-schemas'; + +export type TrpcOpenApiProcedure = { + procedurePath: string; + method: 'post'; + path: `/api/trpc/${string}`; + tags: string[]; + summary: string; + description?: string; + input: z.ZodType; + output: z.ZodType; + security: 'apiKey'; +}; + +export const publicTrpcOpenApiProcedures = [ + { + procedurePath: 'usageAnalytics.getSummary', + method: 'post', + path: '/api/trpc/usageAnalytics.getSummary', + tags: ['Usage Analytics'], + summary: 'Get aggregate usage metrics', + description: + 'Returns aggregate usage metrics for the authenticated user or an accessible organization.', + input: UsageAnalyticsFiltersSchema, + output: SummaryOutputSchema, + security: 'apiKey', + }, + { + procedurePath: 'usageAnalytics.getTimeseries', + method: 'post', + path: '/api/trpc/usageAnalytics.getTimeseries', + tags: ['Usage Analytics'], + summary: 'Get usage over time', + description: + 'Returns usage analytics grouped into time buckets for the authenticated user or organization.', + input: TimeseriesInputSchema, + output: TimeseriesOutputSchema, + security: 'apiKey', + }, + { + procedurePath: 'usageAnalytics.getBreakdown', + method: 'post', + path: '/api/trpc/usageAnalytics.getBreakdown', + tags: ['Usage Analytics'], + summary: 'Get usage breakdown', + description: 'Returns top usage values grouped by a selected dimension.', + input: BreakdownInputSchema, + output: BreakdownOutputSchema, + security: 'apiKey', + }, + { + procedurePath: 'usageAnalytics.getTable', + method: 'post', + path: '/api/trpc/usageAnalytics.getTable', + tags: ['Usage Analytics'], + summary: 'Get tabular usage analytics', + description: 'Returns usage analytics rows grouped by up to three dimensions.', + input: TableInputSchema, + output: TableOutputSchema, + security: 'apiKey', + }, +] satisfies TrpcOpenApiProcedure[]; diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 6804827e20..35858b2b13 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -12,68 +12,45 @@ import { import { kilocode_users, organization_memberships, user_auth_provider } from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; - -export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']); -export type Granularity = z.infer; - -export const CostSourceSchema = z.enum(['cost', 'market']); -export type CostSource = z.infer; - -export const DimensionSchema = z.enum(['feature', 'model', 'mode', 'user', 'provider', 'project']); -export type Dimension = z.infer; - -export const MetricSchema = z.enum([ - 'cost', - 'requests', - 'tokens', - 'inputTokens', - 'outputTokens', - 'errorRate', - 'avgLatencyMs', - 'avgGenerationTimeMs', - 'costPerRequest', - 'tokensPerRequest', - 'cacheHitRatio', - 'outputInputRatio', -]); -export type Metric = z.infer; - -const FiltersShape = { - startDate: z.iso.datetime(), - endDate: z.iso.datetime(), - granularity: GranularitySchema, - costSource: CostSourceSchema.default('cost'), - organizationId: z.uuid().optional(), - /** - * Personal-scope narrowing: - * - 'personal-only' (default) → organization_id = '' in Snowflake rollups - * - 'include-orgs' → any organization (including personal) - * Ignored when `organizationId` is set (org scope always filters by that org). - */ - personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'), - /** - * Org-scope narrowing when `organizationId` is set: - * - 'self' (default) → restricts to ctx.user.id within the organization - * - 'org-wide' → all users in the org; requires owner/billing_manager - * Ignored when `organizationId` is not set. - */ - viewAs: z.enum(['self', 'org-wide']).default('self'), - features: z.array(z.string()).optional(), - models: z.array(z.string()).optional(), - modes: z.array(z.string()).optional(), - userIds: z.array(z.string()).optional(), - providers: z.array(z.string()).optional(), - projects: z.array(z.string()).optional(), - excludedFeatures: z.array(z.string()).optional(), - excludedModels: z.array(z.string()).optional(), - excludedModes: z.array(z.string()).optional(), - excludedUserIds: z.array(z.string()).optional(), - excludedProviders: z.array(z.string()).optional(), - excludedProjects: z.array(z.string()).optional(), -} as const; - -export const UsageAnalyticsFiltersSchema = z.object(FiltersShape); -export type UsageAnalyticsFilters = z.infer; +import { + BreakdownInputSchema, + BreakdownOutputSchema, + SummaryOutputSchema, + TableInputSchema, + TableOutputSchema, + TimeseriesInputSchema, + TimeseriesOutputSchema, + UsageAnalyticsFiltersSchema, + type CostSource, + type Dimension, + type Granularity, + type Metric, + type SummaryOutput, + type UsageAnalyticsFilters, +} from '@/routers/usage-analytics-schemas'; + +export { + BreakdownInputSchema, + BreakdownOutputSchema, + CostSourceSchema, + DimensionSchema, + GranularitySchema, + MetricSchema, + SummaryOutputSchema, + TableInputSchema, + TableOutputSchema, + TimeseriesInputSchema, + TimeseriesOutputSchema, + UsageAnalyticsFiltersSchema, +} from '@/routers/usage-analytics-schemas'; +export type { + CostSource, + Dimension, + Granularity, + Metric, + SummaryOutput, + UsageAnalyticsFilters, +} from '@/routers/usage-analytics-schemas'; // --------------------------------------------------------------------------- // Table / tier resolution @@ -532,35 +509,6 @@ async function timedSnowflakeQuery( // getSummary // --------------------------------------------------------------------------- -const SummaryOutputSchema = z.object({ - costMicrodollars: z.number(), - requestCount: z.number(), - inputTokens: z.number(), - outputTokens: z.number(), - cacheWriteTokens: z.number(), - cacheHitTokens: z.number(), - errorCount: z.number(), - cancelledCount: z.number(), - freeRequestCount: z.number(), - byokRequestCount: z.number(), - totalLatencyMs: z.number(), - totalGenerationTimeMs: z.number(), - latencyCount: z.number(), - generationTimeCount: z.number(), - totalTokens: z.number(), - distinctUsers: z.number(), - errorRate: z.number(), - avgLatencyMs: z.number(), - avgGenerationTimeMs: z.number(), - costPerRequest: z.number(), - tokensPerRequest: z.number(), - cacheHitRatio: z.number(), - outputInputRatio: z.number(), - effectiveGranularity: GranularitySchema, -}); - -type SummaryOutput = z.infer; - function ratioSafe(numerator: number, denominator: number): number { if (denominator === 0) return 0; return numerator / denominator; @@ -582,75 +530,6 @@ function toSafeNumber(value: unknown): number { return n; } -// --------------------------------------------------------------------------- -// Timeseries -// --------------------------------------------------------------------------- - -const TimeseriesInputSchema = UsageAnalyticsFiltersSchema.extend({ - metric: MetricSchema, - splitBy: DimensionSchema.optional(), -}); - -const TimeseriesPointSchema = z.object({ - datetime: z.string(), - value: z.number(), - label: z.string().optional(), -}); - -const TimeseriesOutputSchema = z.object({ - timeseries: z.array(TimeseriesPointSchema), - effectiveGranularity: GranularitySchema, -}); - -// --------------------------------------------------------------------------- -// Breakdown -// --------------------------------------------------------------------------- - -const BreakdownInputSchema = UsageAnalyticsFiltersSchema.extend({ - dimension: DimensionSchema, - metric: z.enum(['cost', 'requests', 'tokens']), - limit: z.number().int().min(1).max(100).default(15), -}); - -const BreakdownItemSchema = z.object({ - key: z.string(), - label: z.string(), - value: z.number(), - percentage: z.number(), -}); - -const BreakdownOutputSchema = z.object({ - breakdown: z.array(BreakdownItemSchema), - totalValue: z.number(), - effectiveGranularity: GranularitySchema, -}); - -// --------------------------------------------------------------------------- -// Table -// --------------------------------------------------------------------------- - -const TableInputSchema = UsageAnalyticsFiltersSchema.extend({ - groupBy: z.array(DimensionSchema).max(3), - limit: z.number().int().min(1).max(10_000).default(1000), -}); - -const TableRowSchema = z.object({ - datetime: z.string(), - dimensions: z.record(z.string(), z.string()), - costMicrodollars: z.number(), - requestCount: z.number(), - inputTokens: z.number(), - outputTokens: z.number(), - cacheWriteTokens: z.number(), - cacheHitTokens: z.number(), - errorCount: z.number(), -}); - -const TableOutputSchema = z.object({ - rows: z.array(TableRowSchema), - effectiveGranularity: GranularitySchema, -}); - // --------------------------------------------------------------------------- // User list (for org context) // --------------------------------------------------------------------------- diff --git a/apps/web/src/routers/usage-analytics-schemas.ts b/apps/web/src/routers/usage-analytics-schemas.ts new file mode 100644 index 0000000000..0c38fb1f06 --- /dev/null +++ b/apps/web/src/routers/usage-analytics-schemas.ts @@ -0,0 +1,136 @@ +import * as z from 'zod'; + +export const GranularitySchema = z.enum(['hour', 'day', 'week', 'month']); +export type Granularity = z.infer; + +export const CostSourceSchema = z.enum(['cost', 'market']); +export type CostSource = z.infer; + +export const DimensionSchema = z.enum(['feature', 'model', 'mode', 'user', 'provider', 'project']); +export type Dimension = z.infer; + +export const MetricSchema = z.enum([ + 'cost', + 'requests', + 'tokens', + 'inputTokens', + 'outputTokens', + 'errorRate', + 'avgLatencyMs', + 'avgGenerationTimeMs', + 'costPerRequest', + 'tokensPerRequest', + 'cacheHitRatio', + 'outputInputRatio', +]); +export type Metric = z.infer; + +const FiltersShape = { + startDate: z.iso.datetime(), + endDate: z.iso.datetime(), + granularity: GranularitySchema, + costSource: CostSourceSchema.default('cost'), + organizationId: z.uuid().optional(), + personalScope: z.enum(['personal-only', 'include-orgs']).default('personal-only'), + viewAs: z.enum(['self', 'org-wide']).default('self'), + features: z.array(z.string()).optional(), + models: z.array(z.string()).optional(), + modes: z.array(z.string()).optional(), + userIds: z.array(z.string()).optional(), + providers: z.array(z.string()).optional(), + projects: z.array(z.string()).optional(), + excludedFeatures: z.array(z.string()).optional(), + excludedModels: z.array(z.string()).optional(), + excludedModes: z.array(z.string()).optional(), + excludedUserIds: z.array(z.string()).optional(), + excludedProviders: z.array(z.string()).optional(), + excludedProjects: z.array(z.string()).optional(), +} as const; + +export const UsageAnalyticsFiltersSchema = z.object(FiltersShape); +export type UsageAnalyticsFilters = z.infer; + +export const SummaryOutputSchema = z.object({ + costMicrodollars: z.number(), + requestCount: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheWriteTokens: z.number(), + cacheHitTokens: z.number(), + errorCount: z.number(), + cancelledCount: z.number(), + freeRequestCount: z.number(), + byokRequestCount: z.number(), + totalLatencyMs: z.number(), + totalGenerationTimeMs: z.number(), + latencyCount: z.number(), + generationTimeCount: z.number(), + totalTokens: z.number(), + distinctUsers: z.number(), + errorRate: z.number(), + avgLatencyMs: z.number(), + avgGenerationTimeMs: z.number(), + costPerRequest: z.number(), + tokensPerRequest: z.number(), + cacheHitRatio: z.number(), + outputInputRatio: z.number(), + effectiveGranularity: GranularitySchema, +}); +export type SummaryOutput = z.infer; + +export const TimeseriesInputSchema = UsageAnalyticsFiltersSchema.extend({ + metric: MetricSchema, + splitBy: DimensionSchema.optional(), +}); + +const TimeseriesPointSchema = z.object({ + datetime: z.string(), + value: z.number(), + label: z.string().optional(), +}); + +export const TimeseriesOutputSchema = z.object({ + timeseries: z.array(TimeseriesPointSchema), + effectiveGranularity: GranularitySchema, +}); + +export const BreakdownInputSchema = UsageAnalyticsFiltersSchema.extend({ + dimension: DimensionSchema, + metric: z.enum(['cost', 'requests', 'tokens']), + limit: z.number().int().min(1).max(100).default(15), +}); + +const BreakdownItemSchema = z.object({ + key: z.string(), + label: z.string(), + value: z.number(), + percentage: z.number(), +}); + +export const BreakdownOutputSchema = z.object({ + breakdown: z.array(BreakdownItemSchema), + totalValue: z.number(), + effectiveGranularity: GranularitySchema, +}); + +export const TableInputSchema = UsageAnalyticsFiltersSchema.extend({ + groupBy: z.array(DimensionSchema).max(3), + limit: z.number().int().min(1).max(10_000).default(1000), +}); + +const TableRowSchema = z.object({ + datetime: z.string(), + dimensions: z.record(z.string(), z.string()), + costMicrodollars: z.number(), + requestCount: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheWriteTokens: z.number(), + cacheHitTokens: z.number(), + errorCount: z.number(), +}); + +export const TableOutputSchema = z.object({ + rows: z.array(TableRowSchema), + effectiveGranularity: GranularitySchema, +}); From 9cf3d35ffd19534405dd19607fab0ff9dfe5bdac Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 11:37:36 -0600 Subject: [PATCH 2/9] fix(openapi): harden swagger docs --- apps/web/package.json | 1 + apps/web/src/app/api/docs/[asset]/route.ts | 35 +++++++++++++++++++ apps/web/src/app/api/docs/route.ts | 34 ++++++++++++++---- apps/web/src/app/api/openapi.json/route.ts | 8 ++++- apps/web/src/lib/openapi/trpc-openapi.test.ts | 20 ++++++++--- apps/web/src/lib/openapi/trpc-openapi.ts | 29 +++++++++++++-- apps/web/src/lib/openapi/trpc-registry.ts | 5 --- pnpm-lock.yaml | 15 ++++++++ pnpm-workspace.yaml | 1 + 9 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/api/docs/[asset]/route.ts diff --git a/apps/web/package.json b/apps/web/package.json index 98005b3306..ef579fcad6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -162,6 +162,7 @@ "sonner": "2.0.7", "stripe": "catalog:", "stytch": "12.43.1", + "swagger-ui-dist": "5.32.6", "tailwind-merge": "3.5.0", "tldts": "7.0.30", "ulid": "catalog:", diff --git a/apps/web/src/app/api/docs/[asset]/route.ts b/apps/web/src/app/api/docs/[asset]/route.ts new file mode 100644 index 0000000000..c15059909c --- /dev/null +++ b/apps/web/src/app/api/docs/[asset]/route.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +const require = createRequire(import.meta.url); +const swaggerUiDist = require('swagger-ui-dist') as { getAbsoluteFSPath: () => string }; + +const assetContentTypes = { + 'swagger-ui-bundle.js': 'text/javascript; charset=utf-8', + 'swagger-ui.css': 'text/css; charset=utf-8', +} as const; + +function contentTypeForAsset(asset: string) { + if (asset === 'swagger-ui-bundle.js') return assetContentTypes[asset]; + if (asset === 'swagger-ui.css') return assetContentTypes[asset]; + return null; +} + +export async function GET(_request: Request, { params }: { params: Promise<{ asset: string }> }) { + const { asset } = await params; + const contentType = contentTypeForAsset(asset); + + if (!contentType) { + return new Response('Not found', { status: 404 }); + } + + const content = await readFile(join(swaggerUiDist.getAbsoluteFSPath(), asset)); + + return new Response(content, { + headers: { + 'cache-control': 'public, max-age=31536000, immutable', + 'content-type': contentType, + }, + }); +} diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts index ba9469965a..202fb18616 100644 --- a/apps/web/src/app/api/docs/route.ts +++ b/apps/web/src/app/api/docs/route.ts @@ -1,11 +1,14 @@ -const swaggerUiHtml = ` +import { randomBytes } from 'node:crypto'; + +function swaggerUiHtml(nonce: string) { + return ` Kilo Code API Docs - - @@ -83,6 +87,7 @@ function swaggerUiHtml(nonce: string) { dom_id: '#swagger-ui', deepLinking: true, persistAuthorization: false, + supportedSubmitMethods: [], tryItOutEnabled: false, }); From 581fd16943d08ab7df000c7bf0262db707b7bd6c Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 14:14:53 -0600 Subject: [PATCH 6/9] Update short descs --- apps/web/src/lib/openapi/trpc-openapi.test.ts | 2 +- apps/web/src/lib/openapi/trpc-registry.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index 301c4a13fb..278b9c1bba 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -11,7 +11,7 @@ describe('generateTrpcOpenApiDocument', () => { ); expect(document.paths['/api/trpc/usageAnalytics.getTable']?.post).toMatchObject({ operationId: 'usageAnalytics_getTable', - summary: 'Get tabular usage analytics', + summary: 'Return aggregated tabular usage rows', tags: ['Usage Analytics'], }); expect(document.paths).not.toHaveProperty('/api/trpc/admin'); diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts index 6a7a7aa42b..32ce51e79c 100644 --- a/apps/web/src/lib/openapi/trpc-registry.ts +++ b/apps/web/src/lib/openapi/trpc-registry.ts @@ -43,7 +43,7 @@ export const publicTrpcOpenApiProcedures = [ procedurePath: 'usageAnalytics.getSummary', method: 'post', tags: ['Usage Analytics'], - summary: 'Get aggregate usage metrics', + summary: 'Return aggregate KPI metrics', description: 'Returns aggregate KPI metrics for the authenticated user or an accessible organization. Use this for summary cards and high-level totals.', input: UsageAnalyticsFiltersSchema, @@ -54,7 +54,7 @@ export const publicTrpcOpenApiProcedures = [ procedurePath: 'usageAnalytics.getTimeseries', method: 'post', tags: ['Usage Analytics'], - summary: 'Get usage over time', + summary: 'Return usage analytics grouped into time buckets', description: 'Returns usage analytics grouped into time buckets. Use this for trend charts and optional split-by series views.', input: TimeseriesInputSchema, @@ -65,7 +65,7 @@ export const publicTrpcOpenApiProcedures = [ procedurePath: 'usageAnalytics.getBreakdown', method: 'post', tags: ['Usage Analytics'], - summary: 'Get usage breakdown', + summary: 'Return top usage values grouped by dimension', description: 'Returns top usage values grouped by a single selected dimension. Use this for dedicated breakdown charts such as features, models, projects, or users.', input: BreakdownInputSchema, @@ -76,7 +76,7 @@ export const publicTrpcOpenApiProcedures = [ procedurePath: 'usageAnalytics.getTable', method: 'post', tags: ['Usage Analytics'], - summary: 'Get tabular usage analytics', + summary: 'Return aggregated tabular usage rows', description: 'Returns aggregated tabular usage rows grouped by time bucket and optional dimensions. Use this for the detailed breakdown table and CSV export view.', input: TableInputSchema, From 2c9e71c8c589fa93189d8d02b79fa43ccbbccd9f Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 14:25:44 -0600 Subject: [PATCH 7/9] fix(openapi): remove auth metadata from docs --- apps/web/src/app/api/docs/route.test.ts | 4 ++-- apps/web/src/app/api/docs/route.ts | 3 --- apps/web/src/lib/openapi/trpc-openapi.test.ts | 12 ++++++++++++ apps/web/src/lib/openapi/trpc-openapi.ts | 17 ----------------- apps/web/src/lib/openapi/trpc-registry.ts | 5 ----- 5 files changed, 14 insertions(+), 27 deletions(-) diff --git a/apps/web/src/app/api/docs/route.test.ts b/apps/web/src/app/api/docs/route.test.ts index ad78836bf3..c2d197d913 100644 --- a/apps/web/src/app/api/docs/route.test.ts +++ b/apps/web/src/app/api/docs/route.test.ts @@ -10,7 +10,7 @@ describe('GET /api/docs', () => { expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); expect(body).toContain('supportedSubmitMethods: []'); expect(body).toContain('tryItOutEnabled: false'); - expect(body).toContain('.swagger-ui .auth-wrapper'); - expect(body).toContain('display: none'); + expect(body).not.toContain('.swagger-ui .auth-wrapper'); + expect(body).not.toContain('.swagger-ui .authorization__btn'); }); }); diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts index 23e0764c1d..64b2789efd 100644 --- a/apps/web/src/app/api/docs/route.ts +++ b/apps/web/src/app/api/docs/route.ts @@ -66,9 +66,6 @@ function swaggerUiHtml(nonce: string) { min-height: calc(100vh - 73px); } - .swagger-ui .auth-wrapper { - display: none; - } diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index 278b9c1bba..c406c793e4 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -17,6 +17,18 @@ describe('generateTrpcOpenApiDocument', () => { expect(document.paths).not.toHaveProperty('/api/trpc/admin'); }); + it('omits auth metadata so Swagger UI cannot offer token entry', () => { + const document = generateTrpcOpenApiDocument(); + + expect(document).not.toHaveProperty('components.securitySchemes'); + + for (const pathItem of Object.values(document.paths)) { + for (const operation of Object.values(pathItem)) { + expect(operation).not.toHaveProperty('security'); + } + } + }); + it('generates request and response schemas for usageAnalytics.getTable', () => { const document = generateTrpcOpenApiDocument(); const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.post; diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts index 5c5f5f2850..3624704d72 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.ts @@ -14,14 +14,6 @@ type OpenApiDocument = { }; servers: { url: string }[]; tags: { name: string }[]; - components: { - securitySchemes: { - apiKey: { - type: 'http'; - scheme: 'bearer'; - }; - }; - }; paths: Record>; }; @@ -87,7 +79,6 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) { tags: procedure.tags, summary: procedure.summary, description: procedure.description, - security: procedure.security === 'apiKey' ? [{ apiKey: [] }] : undefined, requestBody: { required: true, content: { @@ -134,14 +125,6 @@ export function generateTrpcOpenApiDocument(): OpenApiDocument { }, servers: [{ url: '/' }], tags: [...tagNames].sort().map(name => ({ name })), - components: { - securitySchemes: { - apiKey: { - type: 'http', - scheme: 'bearer', - }, - }, - }, paths, }; } diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts index 32ce51e79c..ede35e5378 100644 --- a/apps/web/src/lib/openapi/trpc-registry.ts +++ b/apps/web/src/lib/openapi/trpc-registry.ts @@ -20,7 +20,6 @@ export type TrpcOpenApiProcedure = { description?: string; input: z.ZodType; output: z.ZodType; - security: 'apiKey'; }; type UsageAnalyticsProcedureKey = Extract< @@ -48,7 +47,6 @@ export const publicTrpcOpenApiProcedures = [ 'Returns aggregate KPI metrics for the authenticated user or an accessible organization. Use this for summary cards and high-level totals.', input: UsageAnalyticsFiltersSchema, output: SummaryOutputSchema, - security: 'apiKey', }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getTimeseries', @@ -59,7 +57,6 @@ export const publicTrpcOpenApiProcedures = [ 'Returns usage analytics grouped into time buckets. Use this for trend charts and optional split-by series views.', input: TimeseriesInputSchema, output: TimeseriesOutputSchema, - security: 'apiKey', }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getBreakdown', @@ -70,7 +67,6 @@ export const publicTrpcOpenApiProcedures = [ 'Returns top usage values grouped by a single selected dimension. Use this for dedicated breakdown charts such as features, models, projects, or users.', input: BreakdownInputSchema, output: BreakdownOutputSchema, - security: 'apiKey', }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getTable', @@ -81,6 +77,5 @@ export const publicTrpcOpenApiProcedures = [ 'Returns aggregated tabular usage rows grouped by time bucket and optional dimensions. Use this for the detailed breakdown table and CSV export view.', input: TableInputSchema, output: TableOutputSchema, - security: 'apiKey', }), ]; From 6ac7944b0ddcc933ddebb22cf3f42087eac060fa Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 14:46:39 -0600 Subject: [PATCH 8/9] Bearer auth --- apps/web/src/app/api/docs/route.test.ts | 7 ++++--- apps/web/src/app/api/docs/route.ts | 5 +++++ apps/web/src/lib/openapi/trpc-openapi.test.ts | 9 ++++++--- apps/web/src/lib/openapi/trpc-openapi.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/docs/route.test.ts b/apps/web/src/app/api/docs/route.test.ts index c2d197d913..e0081f7c0d 100644 --- a/apps/web/src/app/api/docs/route.test.ts +++ b/apps/web/src/app/api/docs/route.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from '@jest/globals'; import { GET } from './route'; describe('GET /api/docs', () => { - test('renders Swagger UI as read-only without auth controls', async () => { + test('renders Swagger UI as read-only without submit controls', async () => { const response = GET(); const body = await response.text(); @@ -10,7 +10,8 @@ describe('GET /api/docs', () => { expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); expect(body).toContain('supportedSubmitMethods: []'); expect(body).toContain('tryItOutEnabled: false'); - expect(body).not.toContain('.swagger-ui .auth-wrapper'); - expect(body).not.toContain('.swagger-ui .authorization__btn'); + expect(body).toContain('.swagger-ui .auth-wrapper'); + expect(body).toContain('.swagger-ui .authorization__btn'); + expect(body).toContain('display: none !important'); }); }); diff --git a/apps/web/src/app/api/docs/route.ts b/apps/web/src/app/api/docs/route.ts index 64b2789efd..6e98255c98 100644 --- a/apps/web/src/app/api/docs/route.ts +++ b/apps/web/src/app/api/docs/route.ts @@ -66,6 +66,11 @@ function swaggerUiHtml(nonce: string) { min-height: calc(100vh - 73px); } + .swagger-ui .auth-wrapper, + .swagger-ui .authorization__btn { + display: none !important; + } + diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index c406c793e4..20d3c5a0c4 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -17,14 +17,17 @@ describe('generateTrpcOpenApiDocument', () => { expect(document.paths).not.toHaveProperty('/api/trpc/admin'); }); - it('omits auth metadata so Swagger UI cannot offer token entry', () => { + it('documents bearer auth metadata for protected procedures', () => { const document = generateTrpcOpenApiDocument(); - expect(document).not.toHaveProperty('components.securitySchemes'); + expect(document.components.securitySchemes.bearerAuth).toEqual({ + type: 'http', + scheme: 'bearer', + }); for (const pathItem of Object.values(document.paths)) { for (const operation of Object.values(pathItem)) { - expect(operation).not.toHaveProperty('security'); + expect(operation).toHaveProperty('security', [{ bearerAuth: [] }]); } } }); diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts index 3624704d72..6a58e2bce4 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.ts @@ -14,6 +14,14 @@ type OpenApiDocument = { }; servers: { url: string }[]; tags: { name: string }[]; + components: { + securitySchemes: { + bearerAuth: { + type: 'http'; + scheme: 'bearer'; + }; + }; + }; paths: Record>; }; @@ -79,6 +87,7 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) { tags: procedure.tags, summary: procedure.summary, description: procedure.description, + security: [{ bearerAuth: [] }], requestBody: { required: true, content: { @@ -125,6 +134,14 @@ export function generateTrpcOpenApiDocument(): OpenApiDocument { }, servers: [{ url: '/' }], tags: [...tagNames].sort().map(name => ({ name })), + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, paths, }; } From 006f75ef539fe520eb68e537f94923c86b661408 Mon Sep 17 00:00:00 2001 From: Evan Jacobson Date: Mon, 22 Jun 2026 15:12:36 -0600 Subject: [PATCH 9/9] fix(openapi): share tRPC transport schemas --- apps/web/src/lib/openapi/trpc-openapi.test.ts | 129 ++++++++++++++++-- apps/web/src/lib/openapi/trpc-openapi.ts | 74 +++++----- apps/web/src/lib/openapi/trpc-registry.ts | 10 +- apps/web/src/lib/trpc/init.ts | 27 +--- apps/web/src/lib/trpc/transport.ts | 83 +++++++++++ 5 files changed, 240 insertions(+), 83 deletions(-) create mode 100644 apps/web/src/lib/trpc/transport.ts diff --git a/apps/web/src/lib/openapi/trpc-openapi.test.ts b/apps/web/src/lib/openapi/trpc-openapi.test.ts index 20d3c5a0c4..55da2672f2 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.test.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.test.ts @@ -1,6 +1,44 @@ import { describe, expect, it } from '@jest/globals'; +import { TRPCError, initTRPC } from '@trpc/server'; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import * as z from 'zod'; import { publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry'; import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi'; +import { + TrpcErrorResponseSchema, + UpstreamApiError, + trpcErrorFormatter, + trpcSuccessResponseSchema, +} from '@/lib/trpc/transport'; + +const t = initTRPC.create({ errorFormatter: trpcErrorFormatter }); + +const verificationRouter = t.router({ + greeting: t.procedure + .input(z.object({ name: z.string() })) + .query(({ input }) => ({ greeting: `Hello, ${input.name}` })), + upstreamFailure: t.procedure.query(() => { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Config was modified', + cause: new UpstreamApiError('etag_mismatch'), + }); + }), +}); + +async function callVerificationProcedure(path: string, input?: unknown): Promise { + const url = new URL(`http://localhost/api/trpc/${path}`); + if (input !== undefined) url.searchParams.set('input', JSON.stringify(input)); + + const response = await fetchRequestHandler({ + endpoint: '/api/trpc', + req: new Request(url, { method: 'GET' }), + router: verificationRouter, + createContext: async () => ({}), + }); + + return response.json() as Promise; +} describe('generateTrpcOpenApiDocument', () => { it('documents only the allowlisted tRPC procedures', () => { @@ -9,7 +47,7 @@ describe('generateTrpcOpenApiDocument', () => { expect(Object.keys(document.paths).sort()).toEqual( publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`).sort() ); - expect(document.paths['/api/trpc/usageAnalytics.getTable']?.post).toMatchObject({ + expect(document.paths['/api/trpc/usageAnalytics.getTable']?.get).toMatchObject({ operationId: 'usageAnalytics_getTable', summary: 'Return aggregated tabular usage rows', tags: ['Usage Analytics'], @@ -34,27 +72,36 @@ describe('generateTrpcOpenApiDocument', () => { it('generates request and response schemas for usageAnalytics.getTable', () => { const document = generateTrpcOpenApiDocument(); - const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.post; + const operation = document.paths['/api/trpc/usageAnalytics.getTable']?.get; expect(operation).toMatchObject({ - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - required: expect.arrayContaining(['startDate', 'endDate', 'granularity', 'groupBy']), - properties: { - groupBy: { - type: 'array', - items: { enum: ['feature', 'model', 'mode', 'user', 'provider', 'project'] }, + parameters: [ + { + name: 'input', + in: 'query', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: expect.arrayContaining([ + 'startDate', + 'endDate', + 'granularity', + 'groupBy', + ]), + properties: { + groupBy: { + type: 'array', + items: { enum: ['feature', 'model', 'mode', 'user', 'provider', 'project'] }, + }, + limit: { default: 1000 }, }, - limit: { default: 1000 }, }, }, }, }, - }, + ], responses: { '200': { content: { @@ -82,7 +129,59 @@ describe('generateTrpcOpenApiDocument', () => { }, }, }, + '400': { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['error'], + properties: { + error: { + type: 'object', + required: ['message', 'code', 'data'], + properties: { + data: { + type: 'object', + required: expect.arrayContaining(['code', 'httpStatus', 'zodError']), + properties: { + code: { type: 'string' }, + httpStatus: { type: 'number' }, + zodError: { + anyOf: expect.arrayContaining([{ type: 'null' }]), + }, + upstreamCode: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }, }); }); + + it('matches the actual tRPC success and error transport envelopes', async () => { + const success = trpcSuccessResponseSchema(z.object({ greeting: z.string() })).parse( + await callVerificationProcedure('greeting', { name: 'Ada' }) + ); + expect(success.result.data).toEqual({ greeting: 'Hello, Ada' }); + + const badInput = TrpcErrorResponseSchema.parse( + await callVerificationProcedure('greeting', { name: 123 }) + ); + expect(badInput.error.data.code).toBe('BAD_REQUEST'); + expect(badInput.error.data.httpStatus).toBe(400); + expect(badInput.error.data.zodError?.fieldErrors).toHaveProperty('name'); + + const upstreamFailure = TrpcErrorResponseSchema.parse( + await callVerificationProcedure('upstreamFailure') + ); + expect(upstreamFailure.error.data.code).toBe('CONFLICT'); + expect(upstreamFailure.error.data.httpStatus).toBe(409); + expect(upstreamFailure.error.data.zodError).toBeNull(); + expect(upstreamFailure.error.data.upstreamCode).toBe('etag_mismatch'); + }); }); diff --git a/apps/web/src/lib/openapi/trpc-openapi.ts b/apps/web/src/lib/openapi/trpc-openapi.ts index 6a58e2bce4..66ddcc20b1 100644 --- a/apps/web/src/lib/openapi/trpc-openapi.ts +++ b/apps/web/src/lib/openapi/trpc-openapi.ts @@ -3,6 +3,7 @@ import { publicTrpcOpenApiProcedures, type TrpcOpenApiProcedure, } from '@/lib/openapi/trpc-registry'; +import { TrpcErrorResponseSchema, trpcSuccessResponseJsonSchema } from '@/lib/trpc/transport'; type JsonSchema = Record; @@ -34,21 +35,7 @@ function pathForProcedure(procedure: TrpcOpenApiProcedure): `/api/trpc/${string} } function successResponseSchema(data: JsonSchema): JsonSchema { - return { - type: 'object', - properties: { - result: { - type: 'object', - properties: { - data, - }, - required: ['data'], - additionalProperties: true, - }, - }, - required: ['result'], - additionalProperties: true, - }; + return trpcSuccessResponseJsonSchema(data); } function errorResponse(description: string) { @@ -56,25 +43,39 @@ function errorResponse(description: string) { description, content: { 'application/json': { - schema: { - type: 'object', - properties: { - error: { - type: 'object', - properties: { - message: { type: 'string' }, - code: { type: 'number' }, - data: { - type: 'object', - additionalProperties: true, - }, - }, - required: ['message', 'code'], - additionalProperties: true, + schema: zodToJsonSchema(TrpcErrorResponseSchema), + }, + }, + }; +} + +function requestShapeForProcedure(procedure: TrpcOpenApiProcedure) { + const schema = zodToJsonSchema(procedure.input); + + if (procedure.method === 'get') { + return { + parameters: [ + { + name: 'input', + in: 'query', + required: true, + description: 'URL-encoded JSON tRPC input payload.', + content: { + 'application/json': { + schema, }, }, - required: ['error'], - additionalProperties: true, + }, + ], + }; + } + + return { + requestBody: { + required: true, + content: { + 'application/json': { + schema, }, }, }, @@ -88,14 +89,7 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) { summary: procedure.summary, description: procedure.description, security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: zodToJsonSchema(procedure.input), - }, - }, - }, + ...requestShapeForProcedure(procedure), responses: { '200': { description: 'Successful tRPC response', diff --git a/apps/web/src/lib/openapi/trpc-registry.ts b/apps/web/src/lib/openapi/trpc-registry.ts index ede35e5378..0770b36ee7 100644 --- a/apps/web/src/lib/openapi/trpc-registry.ts +++ b/apps/web/src/lib/openapi/trpc-registry.ts @@ -14,7 +14,7 @@ import { export type TrpcOpenApiProcedure = { procedurePath: string; - method: 'post'; + method: 'get' | 'post'; tags: string[]; summary: string; description?: string; @@ -40,7 +40,7 @@ function usageAnalyticsProcedure( export const publicTrpcOpenApiProcedures = [ usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getSummary', - method: 'post', + method: 'get', tags: ['Usage Analytics'], summary: 'Return aggregate KPI metrics', description: @@ -50,7 +50,7 @@ export const publicTrpcOpenApiProcedures = [ }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getTimeseries', - method: 'post', + method: 'get', tags: ['Usage Analytics'], summary: 'Return usage analytics grouped into time buckets', description: @@ -60,7 +60,7 @@ export const publicTrpcOpenApiProcedures = [ }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getBreakdown', - method: 'post', + method: 'get', tags: ['Usage Analytics'], summary: 'Return top usage values grouped by dimension', description: @@ -70,7 +70,7 @@ export const publicTrpcOpenApiProcedures = [ }), usageAnalyticsProcedure({ procedurePath: 'usageAnalytics.getTable', - method: 'post', + method: 'get', tags: ['Usage Analytics'], summary: 'Return aggregated tabular usage rows', description: diff --git a/apps/web/src/lib/trpc/init.ts b/apps/web/src/lib/trpc/init.ts index 2b1a40c523..a13329eb08 100644 --- a/apps/web/src/lib/trpc/init.ts +++ b/apps/web/src/lib/trpc/init.ts @@ -2,9 +2,11 @@ import 'server-only'; import { getUserFromAuth } from '@/lib/user/server'; import { initTRPC, TRPCError } from '@trpc/server'; import type { User } from '@kilocode/db/schema'; -import * as z from 'zod'; import { setTag, trpcMiddleware } from '@sentry/nextjs'; import { userCanManageCredits } from '@/lib/admin/credit-management'; +import { trpcErrorFormatter } from '@/lib/trpc/transport'; + +export { UpstreamApiError } from '@/lib/trpc/transport'; // Define the context type export type TRPCContext = { user: User; @@ -44,29 +46,8 @@ export const createTRPCContext = async (): Promise => { * * The client then sees `err.data.upstreamCode === 'etag_mismatch'`. */ -export class UpstreamApiError extends Error { - constructor(public readonly upstreamCode: string) { - super(upstreamCode); - this.name = 'UpstreamApiError'; - } -} - const t = initTRPC.context().create({ - errorFormatter(opts) { - const { shape, error } = opts; - return { - ...shape, - data: { - ...shape.data, - zodError: - error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError - ? z.flattenError(error.cause) - : null, - upstreamCode: - error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined, - }, - }; - }, + errorFormatter: trpcErrorFormatter, }); const sentryMiddleware = t.middleware( diff --git a/apps/web/src/lib/trpc/transport.ts b/apps/web/src/lib/trpc/transport.ts new file mode 100644 index 0000000000..64fb533086 --- /dev/null +++ b/apps/web/src/lib/trpc/transport.ts @@ -0,0 +1,83 @@ +import type { TRPCDefaultErrorShape, TRPCErrorFormatter } from '@trpc/server'; +import * as z from 'zod'; + +type JsonSchema = Record; + +export class UpstreamApiError extends Error { + constructor(public readonly upstreamCode: string) { + super(upstreamCode); + this.name = 'UpstreamApiError'; + } +} + +export const TrpcZodFlattenedErrorSchema = z.object({ + formErrors: z.array(z.string()), + fieldErrors: z.record(z.string(), z.array(z.string())), +}); + +export const TrpcErrorDataSchema = z + .object({ + code: z.string(), + httpStatus: z.number(), + stack: z.string().optional(), + path: z.string().optional(), + zodError: TrpcZodFlattenedErrorSchema.nullable(), + upstreamCode: z.string().optional(), + }) + .passthrough(); + +export const TrpcErrorResponseSchema = z.object({ + error: z.object({ + message: z.string(), + code: z.number(), + data: TrpcErrorDataSchema, + }), +}); + +export function trpcSuccessResponseSchema(dataSchema: DataSchema) { + return z + .object({ + result: z + .object({ + data: dataSchema, + }) + .passthrough(), + }) + .passthrough(); +} + +export function trpcSuccessResponseJsonSchema(dataSchema: JsonSchema): JsonSchema { + return { + type: 'object', + properties: { + result: { + type: 'object', + properties: { + data: dataSchema, + }, + required: ['data'], + additionalProperties: true, + }, + }, + required: ['result'], + additionalProperties: true, + }; +} + +export type KiloTrpcErrorData = TRPCDefaultErrorShape['data'] & z.infer; + +export type KiloTrpcErrorShape = Omit & { + data: KiloTrpcErrorData; +}; + +export const trpcErrorFormatter = (({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: + error.code === 'BAD_REQUEST' && error.cause instanceof z.ZodError + ? z.flattenError(error.cause) + : null, + upstreamCode: error.cause instanceof UpstreamApiError ? error.cause.upstreamCode : undefined, + }, +})) satisfies TRPCErrorFormatter;