Skip to content

Commit 4afb194

Browse files
authored
ai gateway managed keys (#164)
* ai gateway managed keys * remove debugging * stronger encryption * fix new * re-fetch teams * simplify * prefetch * fix avatar * better avatars * fixes * improvements * fixes
1 parent 8b2f075 commit 4afb194

29 files changed

+2175
-81
lines changed
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { and, eq } from "drizzle-orm";
2+
import { isAiGatewayManagedKeysEnabled } from "@/lib/ai-gateway/config";
3+
import { auth } from "@/lib/auth";
4+
import { db } from "@/lib/db";
5+
import { decrypt, encrypt } from "@/lib/db/integrations";
6+
import { accounts, integrations } from "@/lib/db/schema";
7+
import { generateId } from "@/lib/utils/id";
8+
9+
const API_KEY_PURPOSE = "ai-gateway";
10+
const API_KEY_NAME = "Workflow Builder Gateway Key";
11+
12+
/**
13+
* Get team ID from Vercel API
14+
* First tries /v2/teams, then falls back to userinfo endpoint
15+
*/
16+
async function getTeamId(accessToken: string): Promise<string | null> {
17+
// First, try to get teams the user has granted access to
18+
const teamsResponse = await fetch("https://api.vercel.com/v2/teams", {
19+
headers: { Authorization: `Bearer ${accessToken}` },
20+
});
21+
22+
if (teamsResponse.ok) {
23+
const teamsData = await teamsResponse.json();
24+
// biome-ignore lint/suspicious/noExplicitAny: API response type
25+
const accessibleTeam = teamsData.teams?.find((t: any) => !t.limited);
26+
if (accessibleTeam) {
27+
return accessibleTeam.id;
28+
}
29+
}
30+
31+
// Fallback: get user ID from userinfo endpoint
32+
const userinfoResponse = await fetch(
33+
"https://api.vercel.com/login/oauth/userinfo",
34+
{ headers: { Authorization: `Bearer ${accessToken}` } }
35+
);
36+
37+
if (!userinfoResponse.ok) {
38+
return null;
39+
}
40+
41+
const userinfo = await userinfoResponse.json();
42+
return userinfo.sub;
43+
}
44+
45+
/**
46+
* Create or exchange API key on Vercel
47+
*/
48+
async function createVercelApiKey(
49+
accessToken: string,
50+
teamId: string
51+
): Promise<{ token: string; id: string } | null> {
52+
const response = await fetch(
53+
`https://api.vercel.com/v1/api-keys?teamId=${teamId}`,
54+
{
55+
method: "POST",
56+
headers: {
57+
Authorization: `Bearer ${accessToken}`,
58+
"Content-Type": "application/json",
59+
},
60+
body: JSON.stringify({
61+
purpose: API_KEY_PURPOSE,
62+
name: API_KEY_NAME,
63+
exchange: true,
64+
}),
65+
}
66+
);
67+
68+
if (!response.ok) {
69+
console.error(
70+
"[ai-gateway] Failed to create API key:",
71+
await response.text()
72+
);
73+
return null;
74+
}
75+
76+
const newKey = await response.json();
77+
if (!newKey.apiKeyString) {
78+
return null;
79+
}
80+
81+
return { token: newKey.apiKeyString, id: newKey.apiKey?.id };
82+
}
83+
84+
type SaveIntegrationParams = {
85+
userId: string;
86+
apiKey: string;
87+
apiKeyId: string;
88+
teamId: string;
89+
teamName: string;
90+
};
91+
92+
/**
93+
* Save managed integration in database
94+
* Each team gets its own managed integration - always creates a new one
95+
* The apiKeyId and teamId are stored in config for later deletion
96+
*/
97+
async function saveIntegration(params: SaveIntegrationParams): Promise<string> {
98+
const { userId, apiKey, apiKeyId, teamId, teamName } = params;
99+
100+
// Config contains the API key plus metadata for managing the key
101+
const configData = { apiKey, managedKeyId: apiKeyId, teamId };
102+
// Encrypt the entire config for storage (consistent with other integrations)
103+
const encryptedConfig = encrypt(JSON.stringify(configData));
104+
105+
// Always create a new integration - users can have multiple managed keys for different teams
106+
const integrationId = generateId();
107+
await db.insert(integrations).values({
108+
id: integrationId,
109+
userId,
110+
name: teamName,
111+
type: "ai-gateway",
112+
config: encryptedConfig,
113+
isManaged: true,
114+
});
115+
return integrationId;
116+
}
117+
118+
/**
119+
* Delete API key from Vercel
120+
*/
121+
async function deleteVercelApiKey(
122+
accessToken: string,
123+
apiKeyId: string,
124+
teamId: string
125+
): Promise<void> {
126+
await fetch(
127+
`https://api.vercel.com/v1/api-keys/${apiKeyId}?teamId=${teamId}`,
128+
{
129+
method: "DELETE",
130+
headers: { Authorization: `Bearer ${accessToken}` },
131+
}
132+
);
133+
}
134+
135+
/**
136+
* POST /api/ai-gateway/consent
137+
* Record consent and create API key on user's Vercel account
138+
*/
139+
export async function POST(request: Request) {
140+
if (!isAiGatewayManagedKeysEnabled()) {
141+
return Response.json({ error: "Feature not enabled" }, { status: 403 });
142+
}
143+
144+
const session = await auth.api.getSession({ headers: request.headers });
145+
if (!session?.user?.id) {
146+
return Response.json({ error: "Not authenticated" }, { status: 401 });
147+
}
148+
149+
const account = await db.query.accounts.findFirst({
150+
where: eq(accounts.userId, session.user.id),
151+
});
152+
153+
if (!account?.accessToken || account.providerId !== "vercel") {
154+
return Response.json(
155+
{ error: "No Vercel account linked" },
156+
{ status: 400 }
157+
);
158+
}
159+
160+
// Get teamId and teamName from request body
161+
let teamId: string | null = null;
162+
let teamName: string | null = null;
163+
try {
164+
const body = await request.json();
165+
teamId = body.teamId;
166+
teamName = body.teamName;
167+
} catch {
168+
// If no body, try to auto-detect
169+
}
170+
171+
// If no teamId provided, try to auto-detect
172+
if (!teamId) {
173+
teamId = await getTeamId(account.accessToken);
174+
}
175+
176+
if (!teamId) {
177+
return Response.json(
178+
{ error: "Could not determine user's team" },
179+
{ status: 500 }
180+
);
181+
}
182+
183+
try {
184+
const vercelApiKey = await createVercelApiKey(account.accessToken, teamId);
185+
if (!vercelApiKey) {
186+
return Response.json(
187+
{ error: "Failed to create API key" },
188+
{ status: 500 }
189+
);
190+
}
191+
192+
const integrationId = await saveIntegration({
193+
userId: session.user.id,
194+
apiKey: vercelApiKey.token,
195+
apiKeyId: vercelApiKey.id,
196+
teamId,
197+
teamName: teamName || "AI Gateway",
198+
});
199+
200+
return Response.json({
201+
success: true,
202+
hasManagedKey: true,
203+
managedIntegrationId: integrationId,
204+
});
205+
} catch (e) {
206+
console.error("[ai-gateway] Error creating API key:", e);
207+
return Response.json(
208+
{ error: "Failed to create API key" },
209+
{ status: 500 }
210+
);
211+
}
212+
}
213+
214+
/**
215+
* DELETE /api/ai-gateway/consent?integrationId=xxx
216+
* Revoke consent and delete the API key
217+
* Requires integrationId query parameter to specify which integration to delete
218+
*/
219+
export async function DELETE(request: Request) {
220+
if (!isAiGatewayManagedKeysEnabled()) {
221+
return Response.json({ error: "Feature not enabled" }, { status: 403 });
222+
}
223+
224+
const session = await auth.api.getSession({ headers: request.headers });
225+
if (!session?.user?.id) {
226+
return Response.json({ error: "Not authenticated" }, { status: 401 });
227+
}
228+
229+
const { searchParams } = new URL(request.url);
230+
const integrationId = searchParams.get("integrationId");
231+
232+
if (!integrationId) {
233+
return Response.json(
234+
{ error: "integrationId query parameter is required" },
235+
{ status: 400 }
236+
);
237+
}
238+
239+
const managedIntegration = await db.query.integrations.findFirst({
240+
where: and(
241+
eq(integrations.id, integrationId),
242+
eq(integrations.userId, session.user.id),
243+
eq(integrations.type, "ai-gateway"),
244+
eq(integrations.isManaged, true)
245+
),
246+
});
247+
248+
if (!managedIntegration) {
249+
return Response.json({ error: "Integration not found" }, { status: 404 });
250+
}
251+
252+
// Get managedKeyId and teamId from config (decrypt it first since it's stored encrypted)
253+
let config: { managedKeyId?: string; teamId?: string } | null = null;
254+
if (managedIntegration?.config) {
255+
try {
256+
const decrypted = decrypt(managedIntegration.config as string);
257+
config = JSON.parse(decrypted);
258+
} catch (e) {
259+
console.error("[ai-gateway] Failed to decrypt config:", e);
260+
}
261+
}
262+
263+
if (config?.managedKeyId && config?.teamId) {
264+
const account = await db.query.accounts.findFirst({
265+
where: eq(accounts.userId, session.user.id),
266+
});
267+
268+
if (account?.accessToken) {
269+
try {
270+
await deleteVercelApiKey(
271+
account.accessToken,
272+
config.managedKeyId,
273+
config.teamId
274+
);
275+
} catch (e) {
276+
console.error("[ai-gateway] Failed to delete API key from Vercel:", e);
277+
}
278+
}
279+
}
280+
281+
await db
282+
.delete(integrations)
283+
.where(eq(integrations.id, managedIntegration.id));
284+
285+
return Response.json({ success: true, hasManagedKey: false });
286+
}

app/api/ai-gateway/status/route.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { and, eq } from "drizzle-orm";
2+
import { isAiGatewayManagedKeysEnabled } from "@/lib/ai-gateway/config";
3+
import { auth } from "@/lib/auth";
4+
import { db } from "@/lib/db";
5+
import { accounts, integrations } from "@/lib/db/schema";
6+
7+
/**
8+
* GET /api/ai-gateway/status
9+
* Returns user's AI Gateway status including whether they can use managed keys
10+
*/
11+
export async function GET(request: Request) {
12+
const enabled = isAiGatewayManagedKeysEnabled();
13+
14+
// If feature is not enabled, return minimal response
15+
if (!enabled) {
16+
return Response.json({
17+
enabled: false,
18+
signedIn: false,
19+
isVercelUser: false,
20+
hasManagedKey: false,
21+
});
22+
}
23+
24+
const session = await auth.api.getSession({
25+
headers: request.headers,
26+
});
27+
28+
if (!session?.user?.id) {
29+
return Response.json({
30+
enabled: true,
31+
signedIn: false,
32+
isVercelUser: false,
33+
hasManagedKey: false,
34+
});
35+
}
36+
37+
// Check if user signed in with Vercel
38+
const account = await db.query.accounts.findFirst({
39+
where: eq(accounts.userId, session.user.id),
40+
});
41+
42+
const isVercelUser = account?.providerId === "vercel";
43+
44+
// Check if user has a managed AI Gateway integration
45+
const managedIntegration = await db.query.integrations.findFirst({
46+
where: and(
47+
eq(integrations.userId, session.user.id),
48+
eq(integrations.type, "ai-gateway"),
49+
eq(integrations.isManaged, true)
50+
),
51+
});
52+
53+
return Response.json({
54+
enabled: true,
55+
signedIn: true,
56+
isVercelUser,
57+
hasManagedKey: !!managedIntegration,
58+
managedIntegrationId: managedIntegration?.id,
59+
});
60+
}

0 commit comments

Comments
 (0)