Skip to content

Commit 65e6ef1

Browse files
refactor(copilot): one module for Chat API key operations
list/generate/delete each repeated the same /api/validate-key envelope in their route. They now share callValidateKey in lib/copilot/server/api-keys.ts, which also keeps the display masking server-side so the full key can only ever leave at creation.
1 parent af8ad44 commit 65e6ef1

6 files changed

Lines changed: 181 additions & 152 deletions

File tree

apps/sim/app/api/cli/auth/token/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock('@/lib/cli-auth/code-store', () => ({
1616
consumeAuthCode: mockConsumeAuthCode,
1717
}))
1818

19-
vi.mock('@/lib/copilot/server/generate-api-key', () => ({
19+
vi.mock('@/lib/copilot/server/api-keys', () => ({
2020
generateCopilotApiKey: mockGenerateCopilotApiKey,
2121
CopilotApiKeyError: class extends Error {},
2222
}))

apps/sim/app/api/cli/auth/token/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { exchangeCliAuthCodeContract } from '@/lib/api/contracts'
44
import { parseRequest } from '@/lib/api/server'
55
import { consumeAuthCode } from '@/lib/cli-auth/code-store'
6-
import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/generate-api-key'
6+
import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys'
77
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99

apps/sim/app/api/copilot/api-keys/generate/route.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { generateCopilotApiKeyContract } from '@/lib/api/contracts'
33
import { parseRequest } from '@/lib/api/server'
44
import { getSession } from '@/lib/auth'
5-
import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/generate-api-key'
5+
import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77

88
export const POST = withRouteHandler(async (req: NextRequest) => {
9-
try {
10-
const session = await getSession()
11-
if (!session?.user?.id) {
12-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13-
}
9+
const session = await getSession()
10+
if (!session?.user?.id) {
11+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12+
}
1413

15-
const parsed = await parseRequest(generateCopilotApiKeyContract, req, {})
16-
if (!parsed.success) return parsed.response
14+
const parsed = await parseRequest(generateCopilotApiKeyContract, req, {})
15+
if (!parsed.success) return parsed.response
1716

17+
try {
1818
const key = await generateCopilotApiKey(session.user.id, parsed.data.body.name)
19-
2019
return NextResponse.json({ success: true, key }, { status: 201 })
2120
} catch (error) {
2221
const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined
Lines changed: 31 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,50 @@
11
import { type NextRequest, NextResponse } from 'next/server'
22
import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts'
33
import { getSession } from '@/lib/auth'
4-
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
5-
import { fetchGo } from '@/lib/copilot/request/go/fetch'
6-
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
7-
import { env } from '@/lib/core/config/env'
4+
import {
5+
CopilotApiKeyError,
6+
deleteCopilotApiKey,
7+
listCopilotApiKeys,
8+
} from '@/lib/copilot/server/api-keys'
89
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
910

10-
export const GET = withRouteHandler(async (request: NextRequest) => {
11-
try {
12-
const session = await getSession()
13-
if (!session?.user?.id) {
14-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
15-
}
16-
17-
const userId = session.user.id
18-
const mothershipBaseURL = await getMothershipBaseURL({ userId })
19-
20-
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/get-api-keys`, {
21-
method: 'POST',
22-
headers: {
23-
'Content-Type': 'application/json',
24-
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
25-
},
26-
body: JSON.stringify({ userId }),
27-
spanName: 'sim → go /api/validate-key/get-api-keys',
28-
operation: 'get_api_keys',
29-
attributes: { [TraceAttr.UserId]: userId },
30-
})
31-
32-
if (!res.ok) {
33-
return NextResponse.json({ error: 'Failed to get keys' }, { status: res.status || 500 })
34-
}
11+
function errorResponse(error: unknown, fallback: string): NextResponse {
12+
const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined
13+
const message = error instanceof CopilotApiKeyError ? error.message : fallback
14+
return NextResponse.json({ error: message }, { status: status ?? 500 })
15+
}
3516

36-
const apiKeys = (await res.json().catch(() => null)) as
37-
| { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[]
38-
| null
39-
40-
if (!Array.isArray(apiKeys)) {
41-
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
42-
}
43-
44-
const keys = apiKeys.map((k) => {
45-
const value = typeof k.apiKey === 'string' ? k.apiKey : ''
46-
const last6 = value.slice(-6)
47-
const displayKey = `•••••${last6}`
48-
return {
49-
id: k.id,
50-
displayKey,
51-
name: k.name || null,
52-
createdAt: k.createdAt || null,
53-
lastUsed: k.lastUsed || null,
54-
}
55-
})
17+
export const GET = withRouteHandler(async () => {
18+
const session = await getSession()
19+
if (!session?.user?.id) {
20+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21+
}
5622

23+
try {
24+
const keys = await listCopilotApiKeys(session.user.id)
5725
return NextResponse.json({ keys }, { status: 200 })
5826
} catch (error) {
59-
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
27+
return errorResponse(error, 'Failed to get keys')
6028
}
6129
})
6230

6331
export const DELETE = withRouteHandler(async (request: NextRequest) => {
64-
try {
65-
const session = await getSession()
66-
if (!session?.user?.id) {
67-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
68-
}
69-
70-
const userId = session.user.id
71-
const mothershipBaseURL = await getMothershipBaseURL({ userId })
72-
const queryResult = deleteCopilotApiKeyQuerySchema.safeParse(
73-
Object.fromEntries(new URL(request.url).searchParams)
74-
)
75-
if (!queryResult.success) {
76-
return NextResponse.json({ error: 'id is required' }, { status: 400 })
77-
}
78-
const { id } = queryResult.data
79-
80-
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/delete`, {
81-
method: 'POST',
82-
headers: {
83-
'Content-Type': 'application/json',
84-
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
85-
},
86-
body: JSON.stringify({ userId, apiKeyId: id }),
87-
spanName: 'sim → go /api/validate-key/delete',
88-
operation: 'delete_api_key',
89-
attributes: { [TraceAttr.UserId]: userId, [TraceAttr.ApiKeyId]: id },
90-
})
91-
92-
if (!res.ok) {
93-
return NextResponse.json({ error: 'Failed to delete key' }, { status: res.status || 500 })
94-
}
32+
const session = await getSession()
33+
if (!session?.user?.id) {
34+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
35+
}
9536

96-
const data = (await res.json().catch(() => null)) as { success?: boolean } | null
97-
if (!data?.success) {
98-
return NextResponse.json({ error: 'Invalid response from Sim Agent' }, { status: 500 })
99-
}
37+
const queryResult = deleteCopilotApiKeyQuerySchema.safeParse(
38+
Object.fromEntries(new URL(request.url).searchParams)
39+
)
40+
if (!queryResult.success) {
41+
return NextResponse.json({ error: 'id is required' }, { status: 400 })
42+
}
10043

44+
try {
45+
await deleteCopilotApiKey(session.user.id, queryResult.data.id)
10146
return NextResponse.json({ success: true }, { status: 200 })
10247
} catch (error) {
103-
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
48+
return errorResponse(error, 'Failed to delete key')
10449
}
10550
})
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
2+
import { fetchGo } from '@/lib/copilot/request/go/fetch'
3+
import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url'
4+
import { env } from '@/lib/core/config/env'
5+
6+
/**
7+
* Chat API key operations against the Sim Agent's `/api/validate-key/*`
8+
* endpoints.
9+
*
10+
* The single issuer and reader for every caller: the settings UI, which
11+
* authenticates by session, and the CLI token exchange, which authenticates by
12+
* a redeemed authorization code. Routes stay thin so the surface survives the
13+
* settings page being retired.
14+
*/
15+
16+
export interface CopilotApiKeySummary {
17+
id: string
18+
/** Masked — the full key is only ever returned at creation. */
19+
displayKey: string
20+
name: string | null
21+
createdAt: string | null
22+
lastUsed: string | null
23+
}
24+
25+
export interface GeneratedCopilotApiKey {
26+
id: string
27+
apiKey: string
28+
}
29+
30+
/** Carries the upstream status so callers can mirror it instead of flattening to 500. */
31+
export class CopilotApiKeyError extends Error {
32+
constructor(
33+
message: string,
34+
readonly upstreamStatus?: number
35+
) {
36+
super(message)
37+
this.name = 'CopilotApiKeyError'
38+
}
39+
}
40+
41+
interface ValidateKeyCall {
42+
userId: string
43+
path: string
44+
operation: string
45+
body: Record<string, unknown>
46+
failure: string
47+
attributes?: Record<string, string>
48+
}
49+
50+
/**
51+
* Shared request envelope. Every `/api/validate-key/*` endpoint is a POST
52+
* carrying `userId`, authenticated with the service key when one is configured
53+
* and traced under the same span naming.
54+
*/
55+
async function callValidateKey({
56+
userId,
57+
path,
58+
operation,
59+
body,
60+
failure,
61+
attributes,
62+
}: ValidateKeyCall): Promise<unknown> {
63+
const mothershipBaseURL = await getMothershipBaseURL({ userId })
64+
65+
const res = await fetchGo(`${mothershipBaseURL}/api/validate-key/${path}`, {
66+
method: 'POST',
67+
headers: {
68+
'Content-Type': 'application/json',
69+
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
70+
},
71+
body: JSON.stringify({ userId, ...body }),
72+
spanName: `sim → go /api/validate-key/${path}`,
73+
operation,
74+
attributes: { [TraceAttr.UserId]: userId, ...attributes },
75+
})
76+
77+
if (!res.ok) {
78+
throw new CopilotApiKeyError(failure, res.status || 500)
79+
}
80+
81+
return res.json().catch(() => null)
82+
}
83+
84+
export async function listCopilotApiKeys(userId: string): Promise<CopilotApiKeySummary[]> {
85+
const data = (await callValidateKey({
86+
userId,
87+
path: 'get-api-keys',
88+
operation: 'get_api_keys',
89+
body: {},
90+
failure: 'Failed to get keys',
91+
})) as
92+
| { id: string; apiKey: string; name?: string; createdAt?: string; lastUsed?: string }[]
93+
| null
94+
95+
if (!Array.isArray(data)) {
96+
throw new CopilotApiKeyError('Invalid response from Sim Agent', 500)
97+
}
98+
99+
return data.map((key) => ({
100+
id: key.id,
101+
displayKey: `•••••${(typeof key.apiKey === 'string' ? key.apiKey : '').slice(-6)}`,
102+
name: key.name || null,
103+
createdAt: key.createdAt || null,
104+
lastUsed: key.lastUsed || null,
105+
}))
106+
}
107+
108+
export async function generateCopilotApiKey(
109+
userId: string,
110+
name: string
111+
): Promise<GeneratedCopilotApiKey> {
112+
const data = (await callValidateKey({
113+
userId,
114+
path: 'generate',
115+
operation: 'generate_api_key',
116+
body: { name },
117+
failure: 'Failed to generate copilot API key',
118+
})) as { apiKey?: string; id?: string } | null
119+
120+
if (!data?.apiKey) {
121+
throw new CopilotApiKeyError('Invalid response from Sim Agent', 500)
122+
}
123+
124+
return { id: data.id || 'new', apiKey: data.apiKey }
125+
}
126+
127+
export async function deleteCopilotApiKey(userId: string, apiKeyId: string): Promise<void> {
128+
const data = (await callValidateKey({
129+
userId,
130+
path: 'delete',
131+
operation: 'delete_api_key',
132+
body: { apiKeyId },
133+
failure: 'Failed to delete key',
134+
attributes: { [TraceAttr.ApiKeyId]: apiKeyId },
135+
})) as { success?: boolean } | null
136+
137+
if (!data?.success) {
138+
throw new CopilotApiKeyError('Invalid response from Sim Agent', 500)
139+
}
140+
}

apps/sim/lib/copilot/server/generate-api-key.ts

Lines changed: 0 additions & 55 deletions
This file was deleted.

0 commit comments

Comments
 (0)