diff --git a/apps/sim/app/api/cli/auth/token/route.test.ts b/apps/sim/app/api/cli/auth/token/route.test.ts index d90d9387ed..6c094ae3a5 100644 --- a/apps/sim/app/api/cli/auth/token/route.test.ts +++ b/apps/sim/app/api/cli/auth/token/route.test.ts @@ -16,7 +16,7 @@ vi.mock('@/lib/cli-auth/code-store', () => ({ consumeAuthCode: mockConsumeAuthCode, })) -vi.mock('@/lib/copilot/server/generate-api-key', () => ({ +vi.mock('@/lib/copilot/server/api-keys', () => ({ generateCopilotApiKey: mockGenerateCopilotApiKey, CopilotApiKeyError: class extends Error {}, })) diff --git a/apps/sim/app/api/cli/auth/token/route.ts b/apps/sim/app/api/cli/auth/token/route.ts index 89c5a53590..4ad53c0359 100644 --- a/apps/sim/app/api/cli/auth/token/route.ts +++ b/apps/sim/app/api/cli/auth/token/route.ts @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { exchangeCliAuthCodeContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { consumeAuthCode } from '@/lib/cli-auth/code-store' -import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/generate-api-key' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 11ccc0c74b..3fe5e1d7db 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -2,21 +2,20 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateCopilotApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/generate-api-key' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const POST = withRouteHandler(async (req: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(generateCopilotApiKeyContract, req, {}) + if (!parsed.success) return parsed.response + try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(generateCopilotApiKeyContract, req, {}) - if (!parsed.success) return parsed.response - const key = await generateCopilotApiKey(session.user.id, parsed.data.body.name) - return NextResponse.json({ success: true, key }, { status: 201 }) } catch (error) { const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index b2c0399a53..a26b4cad06 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,105 +1,50 @@ import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' +import { + CopilotApiKeyError, + deleteCopilotApiKey, + listCopilotApiKeys, +} from '@/lib/copilot/server/api-keys' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -export const GET = withRouteHandler(async (request: NextRequest) => { +function errorResponse(error: unknown, fallback: string): NextResponse { + const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined + const message = error instanceof CopilotApiKeyError ? error.message : fallback + return NextResponse.json({ error: message }, { status: status ?? 500 }) +} + +export const GET = withRouteHandler(async () => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId }), - spanName: 'sim → go /api/validate-key/get-api-keys', - operation: 'get_api_keys', - attributes: { [TraceAttr.UserId]: userId }, - }) - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 }) - } - - const apiKeys = (await res.json().catch(() => null)) as - | { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[] - | null - - if (!Array.isArray(apiKeys)) { - return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 }) - } - - const keys = apiKeys.map((k) => { - const value = typeof k.apiKey === 'string' ? k.apiKey : '' - const last6 = value.slice(-6) - const displayKey = `•••••${last6}` - return { - id: k.id, - displayKey, - name: k.name || null, - createdAt: k.createdAt || null, - lastUsed: k.lastUsed || null, - } - }) - + const keys = await listCopilotApiKeys(session.user.id) return NextResponse.json({ keys }, { status: 200 }) } catch (error) { - return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 }) + return errorResponse(error, 'Failed to get keys') } }) export const DELETE = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const queryResult = deleteCopilotApiKeyQuerySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams) + ) + if (!queryResult.success) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const queryResult = deleteCopilotApiKeyQuerySchema.safeParse( - Object.fromEntries(new URL(request.url).searchParams) - ) - if (!queryResult.success) { - return NextResponse.json({ error: 'id is required' }, { status: 400 }) - } - const { id } = queryResult.data - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId, apiKeyId: id }), - spanName: 'sim → go /api/validate-key/delete', - operation: 'delete_api_key', - attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ApiKeyId]: id }, - }) - - if (!res.ok) { - return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 }) - } - - const data = (await res.json().catch(() => null)) as { success?: boolean } | null - if (!data?.success) { - return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 }) - } - + await deleteCopilotApiKey(session.user.id, queryResult.data.id) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { - return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 }) + return errorResponse(error, 'Failed to delete key') } }) diff --git a/apps/sim/lib/copilot/server/api-keys.ts b/apps/sim/lib/copilot/server/api-keys.ts new file mode 100644 index 0000000000..d1935edcb7 --- /dev/null +++ b/apps/sim/lib/copilot/server/api-keys.ts @@ -0,0 +1,140 @@ +import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' +import { env } from '@/lib/core/config/env' + +/** + * Chat API key operations against the Sim Agent's `/api/validate-key/*` + * endpoints. + * + * The single issuer and reader for every caller: the settings UI, which + * authenticates by session, and the CLI token exchange, which authenticates by + * a redeemed authorization code. Routes stay thin so the surface survives the + * settings page being retired. + */ + +export interface CopilotApiKeySummary { + id: string + /** Masked — the full key is only ever returned at creation. */ + displayKey: string + name: string | null + createdAt: string | null + lastUsed: string | null +} + +export interface GeneratedCopilotApiKey { + id: string + apiKey: string +} + +/** Carries the upstream status so callers can mirror it instead of flattening to 500. */ +export class CopilotApiKeyError extends Error { + constructor( + message: string, + readonly upstreamStatus?: number + ) { + super(message) + this.name = 'CopilotApiKeyError' + } +} + +interface ValidateKeyCall { + userId: string + path: string + operation: string + body: Record + failure: string + attributes?: Record +} + +/** + * Shared request envelope. Every `/api/validate-key/*` endpoint is a POST + * carrying `userId`, authenticated with the service key when one is configured + * and traced under the same span naming. + */ +async function callValidateKey({ + userId, + path, + operation, + body, + failure, + attributes, +}: ValidateKeyCall): Promise { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + + const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + }, + body: JSON.stringify({ userId, ...body }), + spanName: `sim → go /api/validate-key/${path}`, + operation, + attributes: { [TraceAttr.UserId]: userId, ...attributes }, + }) + + if (!res.ok) { + throw new CopilotApiKeyError(failure, res.status || 500) + } + + return res.json().catch(() => null) +} + +export async function listCopilotApiKeys(userId: string): Promise { + const data = (await callValidateKey({ + userId, + path: 'get-api-keys', + operation: 'get_api_keys', + body: {}, + failure: 'Failed to get keys', + })) as + | { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[] + | null + + if (!Array.isArray(data)) { + throw new CopilotApiKeyError('Invalid response from Sim Agent', 500) + } + + return data.map((key) => ({ + id: key.id, + displayKey: `•••••${(typeof key.apiKey === 'string' ? key.apiKey : '').slice(-6)}`, + name: key.name || null, + createdAt: key.createdAt || null, + lastUsed: key.lastUsed || null, + })) +} + +export async function generateCopilotApiKey( + userId: string, + name: string +): Promise { + const data = (await callValidateKey({ + userId, + path: 'generate', + operation: 'generate_api_key', + body: { name }, + failure: 'Failed to generate copilot API key', + })) as { apiKey?: string; id?: string } | null + + if (!data?.apiKey) { + throw new CopilotApiKeyError('Invalid response from Sim Agent', 500) + } + + return { id: data.id || 'new', apiKey: data.apiKey } +} + +export async function deleteCopilotApiKey(userId: string, apiKeyId: string): Promise { + const data = (await callValidateKey({ + userId, + path: 'delete', + operation: 'delete_api_key', + body: { apiKeyId }, + failure: 'Failed to delete key', + attributes: { [TraceAttr.ApiKeyId]: apiKeyId }, + })) as { success?: boolean } | null + + if (!data?.success) { + throw new CopilotApiKeyError('Invalid response from Sim Agent', 500) + } +} diff --git a/apps/sim/lib/copilot/server/generate-api-key.ts b/apps/sim/lib/copilot/server/generate-api-key.ts deleted file mode 100644 index cf2b92d2db..0000000000 --- a/apps/sim/lib/copilot/server/generate-api-key.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' - -export interface GeneratedCopilotApiKey { - id: string - apiKey: string -} - -/** Carries the upstream status so callers can mirror it instead of flattening to 500. */ -export class CopilotApiKeyError extends Error { - constructor( - message: string, - readonly upstreamStatus?: number - ) { - super(message) - this.name = 'CopilotApiKeyError' - } -} - -/** - * The single issuer for both callers: the settings UI, which authenticates by - * session, and the CLI token exchange, which authenticates by a redeemed - * authorization code. Throws so callers decide the status code. - */ -export async function generateCopilotApiKey( - userId: string, - name: string -): Promise { - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - - const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ userId, name }), - spanName: 'sim → go /api/validate-key/generate', - operation: 'generate_api_key', - attributes: { [TraceAttr.UserId]: userId }, - }) - - if (!res.ok) { - throw new CopilotApiKeyError('Failed to generate copilot API key', res.status || 500) - } - - const data = (await res.json().catch(() => null)) as { apiKey?: string; id?: string } | null - if (!data?.apiKey) { - throw new CopilotApiKeyError('Invalid response from Sim Agent', 500) - } - - return { id: data.id || 'new', apiKey: data.apiKey } -}