Skip to content
2 changes: 2 additions & 0 deletions apps/web/src/app/api/docs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ describe('GET /api/docs', () => {
expect(body).toContain('.swagger-ui .auth-wrapper');
expect(body).toContain('.swagger-ui .authorization__btn');
expect(body).toContain('display: none !important');
expect(body).toContain('Swagger UI generated from the Kilo Code OpenAPI document.');
expect(body).not.toContain('tRPC OpenAPI document');
});
});
2 changes: 1 addition & 1 deletion apps/web/src/app/api/docs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function swaggerUiHtml(nonce: string) {
<header class="kilo-docs-header">
<div>
<h1 class="kilo-docs-title">Kilo Code API Docs</h1>
<p class="kilo-docs-subtitle">Swagger UI generated from the allowlisted tRPC OpenAPI document.</p>
<p class="kilo-docs-subtitle">Swagger UI generated from the Kilo Code OpenAPI document.</p>
</div>
<a class="kilo-docs-link" href="/api/openapi.json">Open JSON</a>
</header>
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/app/api/v1/organizations/[id]/members/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeAll, describe, expect, it, jest } from '@jest/globals';
import { NextRequest } from 'next/server';
import type { handleTRPCRequest } from '@/lib/trpc-route-handler';
import type { GET as routeGET } from './route';

jest.mock('@/lib/trpc-route-handler', () => ({
handleTRPCRequest: jest.fn(),
}));

const { handleTRPCRequest: mockedHandleTRPCRequest } = jest.requireMock(
'@/lib/trpc-route-handler'
) as {
handleTRPCRequest: jest.MockedFunction<typeof handleTRPCRequest>;
};

let GET: typeof routeGET;

beforeAll(async () => {
({ GET } = await import('./route'));
});

describe('GET /api/v1/organizations/[id]/members', () => {
it('returns public organization members without invited-member invite fields', async () => {
const invitedMember = {
email: 'invited@example.com',
role: 'member' as const,
inviteDate: '2026-06-22T00:00:00.000Z',
inviteToken: 'secret-token',
inviteId: 'invite-id',
status: 'invited' as const,
inviteUrl: 'https://example.com/users/accept-invite/secret-token',
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
};
const withMembers = jest.fn(async (_input: { organizationId: string }) => ({
id: 'org-id',
name: 'Test Org',
members: [
{
id: 'user-id',
name: 'Active User',
email: 'active@example.com',
role: 'owner' as const,
status: 'active' as const,
inviteDate: null,
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
},
invitedMember,
],
}));
const caller = {
organizations: {
withMembers,
},
};

mockedHandleTRPCRequest.mockImplementationOnce(async (_request, handler) => {
const result = await handler(caller as never);
return Response.json(result) as never;
});

const response = await GET(new NextRequest('http://localhost:3000'), {
params: Promise.resolve({ id: 'org-id' }),
});

expect(withMembers).toHaveBeenCalledWith({ organizationId: 'org-id' });
expect(invitedMember).toHaveProperty('inviteToken', 'secret-token');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual([
{
id: 'user-id',
name: 'Active User',
email: 'active@example.com',
role: 'owner',
status: 'active',
inviteDate: null,
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
},
{
email: 'invited@example.com',
role: 'member',
inviteDate: '2026-06-22T00:00:00.000Z',
status: 'invited',
dailyUsageLimitUsd: null,
currentDailyUsageUsd: null,
},
]);
});
});
12 changes: 12 additions & 0 deletions apps/web/src/app/api/v1/organizations/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { NextRequest } from 'next/server';
import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types';
import { handleTRPCRequest } from '@/lib/trpc-route-handler';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const organizationId = (await params).id;

return handleTRPCRequest(request, async caller => {
const org = await caller.organizations.withMembers({ organizationId });
return PublicOrganizationMembersSchema.parse(org.members);
});
}
61 changes: 58 additions & 3 deletions apps/web/src/lib/openapi/trpc-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { publicRestOpenApiRoutes, publicTrpcOpenApiProcedures } from '@/lib/openapi/trpc-registry';
import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';
import {
TrpcErrorResponseSchema,
Expand Down Expand Up @@ -41,11 +41,14 @@ async function callVerificationProcedure(path: string, input?: unknown): Promise
}

describe('generateTrpcOpenApiDocument', () => {
it('documents only the allowlisted tRPC procedures', () => {
it('documents only the allowlisted API paths', () => {
const document = generateTrpcOpenApiDocument();

expect(Object.keys(document.paths).sort()).toEqual(
publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`).sort()
[
...publicTrpcOpenApiProcedures.map(procedure => `/api/trpc/${procedure.procedurePath}`),
...publicRestOpenApiRoutes.map(route => route.path),
].sort()
);
expect(document.paths['/api/trpc/usageAnalytics.getTable']?.get).toMatchObject({
operationId: 'usageAnalytics_getTable',
Expand All @@ -55,6 +58,58 @@ describe('generateTrpcOpenApiDocument', () => {
expect(document.paths).not.toHaveProperty('/api/trpc/admin');
});

it('generates the REST organization members endpoint', () => {
const document = generateTrpcOpenApiDocument();
const operation = document.paths['/api/v1/organizations/{id}/members']?.get as {
responses: Record<string, { content: Record<string, { schema: Record<string, any> }> }>;
};
const responseSchema = operation?.responses['200'].content['application/json'].schema;

expect(document.info.title).toBe('Kilo Code API');
expect(operation).toMatchObject({
operationId: 'organizations_getMembers',
summary: 'Return organization members',
tags: ['Organizations'],
parameters: [
{
name: 'id',
in: 'path',
required: true,
schema: { type: 'string' },
},
],
responses: {
'200': {
content: {
'application/json': {
schema: {
type: 'array',
},
},
},
},
},
});
expect(responseSchema.items.oneOf).toEqual(
expect.arrayContaining([
expect.objectContaining({
required: expect.arrayContaining(['id', 'name', 'email', 'status']),
properties: expect.objectContaining({
status: { type: 'string', const: 'active' },
}),
}),
expect.objectContaining({
required: expect.arrayContaining(['email', 'status']),
properties: expect.objectContaining({
status: { type: 'string', const: 'invited' },
}),
}),
])
);
expect(JSON.stringify(responseSchema)).not.toContain('inviteToken');
expect(JSON.stringify(responseSchema)).not.toContain('inviteUrl');
});

it('documents bearer auth metadata for protected procedures', () => {
const document = generateTrpcOpenApiDocument();

Expand Down
88 changes: 87 additions & 1 deletion apps/web/src/lib/openapi/trpc-openapi.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import * as z from 'zod';
import {
publicRestOpenApiRoutes,
publicTrpcOpenApiProcedures,
type RestOpenApiRoute,
type TrpcOpenApiProcedure,
} from '@/lib/openapi/trpc-registry';
import { TrpcErrorResponseSchema, trpcSuccessResponseJsonSchema } from '@/lib/trpc/transport';

const RestErrorResponseSchema = z.object({
error: z.string(),
message: z.string().optional(),
});

type JsonSchema = Record<string, unknown>;

type OpenApiDocument = {
Expand Down Expand Up @@ -38,6 +45,10 @@ function successResponseSchema(data: JsonSchema): JsonSchema {
return trpcSuccessResponseJsonSchema(data);
}

function jsonResponseSchema(data: JsonSchema): JsonSchema {
return data;
}

function errorResponse(description: string) {
return {
description,
Expand Down Expand Up @@ -82,6 +93,20 @@ function requestShapeForProcedure(procedure: TrpcOpenApiProcedure) {
};
}

function pathParametersForRoute(route: RestOpenApiRoute) {
if (!route.pathParameters || route.pathParameters.length === 0) return {};

return {
parameters: route.pathParameters.map(parameter => ({
name: parameter.name,
in: 'path',
required: true,
description: parameter.description,
schema: zodToJsonSchema(parameter.schema),
})),
};
}

function operationForProcedure(procedure: TrpcOpenApiProcedure) {
return {
operationId: procedure.procedurePath.replaceAll('.', '_'),
Expand All @@ -107,6 +132,59 @@ function operationForProcedure(procedure: TrpcOpenApiProcedure) {
};
}

function operationForRoute(route: RestOpenApiRoute) {
return {
operationId: route.operationId,
tags: route.tags,
summary: route.summary,
description: route.description,
security: [{ bearerAuth: [] }],
...pathParametersForRoute(route),
responses: {
'200': {
description: 'Successful response',
content: {
'application/json': {
schema: jsonResponseSchema(zodToJsonSchema(route.output)),
},
},
},
'400': {
...errorResponse('Invalid request'),
content: {
'application/json': {
schema: zodToJsonSchema(RestErrorResponseSchema),
},
},
},
'401': {
...errorResponse('Authentication required'),
content: {
'application/json': {
schema: zodToJsonSchema(RestErrorResponseSchema),
},
},
},
'403': {
...errorResponse('Access denied'),
content: {
'application/json': {
schema: zodToJsonSchema(RestErrorResponseSchema),
},
},
},
'500': {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low, Documentation: The shared REST operation documents the common error responses, but this members endpoint can also return HTTP 404. organizations.withMembers raises NOT_FOUND when the organization does not exist, and handleTRPCRequest preserves that status. Without a documented 404, generated clients and API consumers may treat a valid endpoint response as unexpected. Could the REST route metadata support route specific responses and add a 404 using RestErrorResponseSchema for this endpoint? An OpenAPI assertion for the members operation would keep the contract in sync.

...errorResponse('Unexpected server error'),
content: {
'application/json': {
schema: zodToJsonSchema(RestErrorResponseSchema),
},
},
},
},
};
}

export function generateTrpcOpenApiDocument(): OpenApiDocument {
const paths: OpenApiDocument['paths'] = {};
const tagNames = new Set<string>();
Expand All @@ -120,10 +198,18 @@ export function generateTrpcOpenApiDocument(): OpenApiDocument {
};
}

for (const route of publicRestOpenApiRoutes) {
for (const tag of route.tags) tagNames.add(tag);
paths[route.path] = {
...paths[route.path],
[route.method]: operationForRoute(route),
};
}

return {
openapi: '3.1.0',
info: {
title: 'Kilo Code tRPC API',
title: 'Kilo Code API',
version: '1.0.0',
},
servers: [{ url: '/' }],
Expand Down
38 changes: 37 additions & 1 deletion apps/web/src/lib/openapi/trpc-registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { inferRouterInputs } from '@trpc/server';
import type * as z from 'zod';
import * as z from 'zod';
import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types';
import type { usageAnalyticsRouter } from '@/routers/usage-analytics-router';
import {
BreakdownInputSchema,
Expand All @@ -22,6 +23,21 @@ export type TrpcOpenApiProcedure = {
output: z.ZodType;
};

export type RestOpenApiRoute = {
path: string;
method: 'get' | 'post';
operationId: string;
tags: string[];
summary: string;
description?: string;
pathParameters?: Array<{
name: string;
description: string;
schema: z.ZodType;
}>;
output: z.ZodType;
};

type UsageAnalyticsProcedureKey = Extract<
keyof inferRouterInputs<typeof usageAnalyticsRouter>,
string
Expand Down Expand Up @@ -79,3 +95,23 @@ export const publicTrpcOpenApiProcedures = [
output: TableOutputSchema,
}),
];

export const publicRestOpenApiRoutes = [
{
path: '/api/v1/organizations/{id}/members',
method: 'get',
operationId: 'organizations_getMembers',
tags: ['Organizations'],
summary: 'Return organization members',
description:
'Returns active and invited members for an organization the authenticated user can access. Invite tokens and invite URLs are omitted from the response.',
pathParameters: [
{
name: 'id',
description: 'Organization ID.',
schema: z.string(),
},
],
output: PublicOrganizationMembersSchema,
},
] satisfies RestOpenApiRoute[];
Loading