Skip to content

Commit 326490a

Browse files
committed
ai gateway managed keys
1 parent 8b2f075 commit 326490a

File tree

30 files changed

+2243
-77
lines changed

30 files changed

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

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)