@opengeni/api-router 2.6.4 → 2.8.1-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -1
- package/dist/app.js +3 -1
- package/dist/{chunk-XTLI3CBH.js → chunk-HHKQMVY7.js} +3009 -1699
- package/dist/chunk-HHKQMVY7.js.map +1 -0
- package/dist/github-access.d.ts +25 -2
- package/dist/index.js +14 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/personal-github-repositories.d.ts +41 -2
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- package/dist/model-catalog.d.ts +5 -31
- package/dist/routes/codex.d.ts +8 -1
- package/dist/routes/github.d.ts +2 -0
- package/dist/routes/organization-model-providers.d.ts +3 -0
- package/dist/workspace-deletion.d.ts +6 -0
- package/package.json +18 -18
- package/src/app.ts +55 -9
- package/src/auth/organization-user-setup.ts +3 -3
- package/src/github-access.ts +117 -1
- package/src/http/sse.ts +15 -7
- package/src/index.ts +13 -1
- package/src/integrations/personal-github-repositories.ts +238 -9
- package/src/integrations/slack-app-home.ts +2 -2
- package/src/integrations/slack-interactions.ts +77 -37
- package/src/mcp/company-brain-governed-writes.ts +2 -2
- package/src/mcp/company-profile-agent-admin.ts +1 -1
- package/src/mcp/remember.ts +1 -1
- package/src/mcp/server.ts +11 -2
- package/src/model-catalog.ts +45 -337
- package/src/routes/automations.ts +178 -19
- package/src/routes/codex.ts +136 -32
- package/src/routes/connections.ts +271 -16
- package/src/routes/github.ts +116 -15
- package/src/routes/organization-memberships.ts +50 -3
- package/src/routes/organization-model-providers.ts +231 -0
- package/src/routes/packs.ts +1 -0
- package/src/routes/personal-github.ts +39 -0
- package/src/routes/pr-review.ts +72 -4
- package/src/routes/scheduled-tasks.ts +40 -13
- package/src/routes/sessions.ts +88 -52
- package/src/routes/supergrok.ts +3 -2
- package/src/routes/workspaces.ts +405 -62
- package/src/workspace-deletion.ts +64 -0
- package/dist/chunk-XTLI3CBH.js.map +0 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
|
|
4
|
+
WORKSPACE_OPENROUTER_CONNECTION_ROLE,
|
|
5
|
+
VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
|
|
6
|
+
VERCEL_AI_GATEWAY_CONNECTION_ROLE,
|
|
7
|
+
} from "@opengeni/config";
|
|
2
8
|
import {
|
|
3
9
|
ATLASSIAN_PROVIDER_DOMAIN,
|
|
4
10
|
AtlassianConnectionMetadata,
|
|
@@ -21,11 +27,16 @@ import {
|
|
|
21
27
|
IntegrationClientMetadata,
|
|
22
28
|
ListSlackInstallationBindingsResponse,
|
|
23
29
|
ListConnectionsResponse,
|
|
30
|
+
OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
|
|
31
|
+
OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
|
|
24
32
|
OpenGeniSlackBotInstallRequest,
|
|
25
33
|
OpenGeniSlackBotInstallStart,
|
|
26
34
|
OAuthStartRequest,
|
|
27
35
|
OAuthStartResponse,
|
|
28
36
|
UpdateConnectionRequest,
|
|
37
|
+
VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
|
|
38
|
+
VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
|
|
39
|
+
stableJson,
|
|
29
40
|
} from "@opengeni/contracts";
|
|
30
41
|
import {
|
|
31
42
|
bindConnectorDocumentDestination,
|
|
@@ -69,11 +80,17 @@ import {
|
|
|
69
80
|
persistSlackBotInstallationWithSuccessAudit,
|
|
70
81
|
recordSlackBotInstallCallbackFailure,
|
|
71
82
|
revokeConnection,
|
|
83
|
+
revokeWorkspaceOpenRouterConnections,
|
|
84
|
+
revokeWorkspaceVercelAiGatewayConnections,
|
|
72
85
|
revokeConnectionWithSlackBotSuccessAudit,
|
|
86
|
+
rotateWorkspaceVercelAiGatewayConnection,
|
|
87
|
+
rotateWorkspaceOpenRouterConnection,
|
|
73
88
|
SlackBotLifecycleSuccessAuditError,
|
|
74
89
|
SlackInstallationBindingConflictError,
|
|
75
90
|
updateConnection,
|
|
76
91
|
updateSlackBotDocumentDestination,
|
|
92
|
+
upsertWorkspaceVercelAiGatewayConnection,
|
|
93
|
+
upsertWorkspaceOpenRouterConnection,
|
|
77
94
|
type SlackBotInstallCallbackFailureReason,
|
|
78
95
|
type SlackBotInstallCallbackFailureStage,
|
|
79
96
|
} from "@opengeni/db";
|
|
@@ -193,18 +210,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
193
210
|
assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
|
|
194
211
|
assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
|
|
195
212
|
assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
workspaceId,
|
|
213
|
+
const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
|
|
214
|
+
const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
|
|
199
215
|
subjectId,
|
|
200
216
|
providerDomain,
|
|
201
217
|
kind: payload.kind,
|
|
202
|
-
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
203
|
-
grantedScopes: payload.grantedScopes,
|
|
204
|
-
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
|
|
205
218
|
metadata: payload.metadata,
|
|
206
|
-
createdBySubjectId: grant.subjectId,
|
|
207
219
|
});
|
|
220
|
+
const connection = workspaceProviderKind
|
|
221
|
+
? await (async () => {
|
|
222
|
+
const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
|
|
223
|
+
if (!payload.operationId) {
|
|
224
|
+
throw new HTTPException(400, {
|
|
225
|
+
message: `connecting ${provider.label} requires an operationId`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
|
|
229
|
+
const metadata = workspaceProviderCredentialMetadata(
|
|
230
|
+
workspaceProviderKind,
|
|
231
|
+
payload.metadata,
|
|
232
|
+
);
|
|
233
|
+
const input = {
|
|
234
|
+
accountId: grant.accountId,
|
|
235
|
+
workspaceId,
|
|
236
|
+
operationId: payload.operationId,
|
|
237
|
+
requestDigest: workspaceProviderCredentialRequestDigest(key, {
|
|
238
|
+
action: "create",
|
|
239
|
+
providerDomain,
|
|
240
|
+
kind: payload.kind,
|
|
241
|
+
subjectId,
|
|
242
|
+
credential: payload.credential,
|
|
243
|
+
grantedScopes: payload.grantedScopes,
|
|
244
|
+
expiresAt: expiresAt?.toISOString() ?? null,
|
|
245
|
+
metadata,
|
|
246
|
+
}),
|
|
247
|
+
credentialEncrypted,
|
|
248
|
+
grantedScopes: payload.grantedScopes,
|
|
249
|
+
expiresAt,
|
|
250
|
+
metadata,
|
|
251
|
+
updatedBySubjectId: grant.subjectId,
|
|
252
|
+
};
|
|
253
|
+
const created =
|
|
254
|
+
workspaceProviderKind === "vercel_gateway"
|
|
255
|
+
? await upsertWorkspaceVercelAiGatewayConnection(db, input)
|
|
256
|
+
: await upsertWorkspaceOpenRouterConnection(db, input);
|
|
257
|
+
if (!created || created.status === "revoked") {
|
|
258
|
+
throw new HTTPException(409, {
|
|
259
|
+
message: `${provider.label} is already connected; reload before replacing its key`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return created;
|
|
263
|
+
})()
|
|
264
|
+
: await createConnection(db, {
|
|
265
|
+
accountId: grant.accountId,
|
|
266
|
+
workspaceId,
|
|
267
|
+
subjectId,
|
|
268
|
+
providerDomain,
|
|
269
|
+
kind: payload.kind,
|
|
270
|
+
credentialEncrypted,
|
|
271
|
+
grantedScopes: payload.grantedScopes,
|
|
272
|
+
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
|
|
273
|
+
metadata: payload.metadata,
|
|
274
|
+
createdBySubjectId: grant.subjectId,
|
|
275
|
+
});
|
|
208
276
|
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
209
277
|
});
|
|
210
278
|
|
|
@@ -794,6 +862,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
794
862
|
payload.providerDomain ?? existing.providerDomain,
|
|
795
863
|
);
|
|
796
864
|
const kind = payload.kind ?? existing.kind;
|
|
865
|
+
const subjectId =
|
|
866
|
+
payload.subjectId === undefined
|
|
867
|
+
? existing.subjectId
|
|
868
|
+
: writableSubjectId(payload.subjectId, grant.subjectId);
|
|
869
|
+
const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
|
|
870
|
+
subjectId,
|
|
871
|
+
providerDomain,
|
|
872
|
+
kind,
|
|
873
|
+
metadata: payload.metadata ?? existing.metadata,
|
|
874
|
+
});
|
|
875
|
+
if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
|
|
876
|
+
throw new HTTPException(422, {
|
|
877
|
+
message:
|
|
878
|
+
targetWorkspaceProviderKind === "vercel_gateway"
|
|
879
|
+
? "use the Vercel AI Gateway connect flow to create this connection"
|
|
880
|
+
: "use the OpenRouter connect flow to create this connection",
|
|
881
|
+
});
|
|
882
|
+
}
|
|
797
883
|
assertNotDirectPersonalSlackOAuth(providerDomain, kind);
|
|
798
884
|
assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
|
|
799
885
|
assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
|
|
@@ -819,6 +905,86 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
819
905
|
});
|
|
820
906
|
}
|
|
821
907
|
}
|
|
908
|
+
const existingWorkspaceProviderKind = existing
|
|
909
|
+
? workspaceProviderApiKeyConnectionKind(existing)
|
|
910
|
+
: null;
|
|
911
|
+
if (existing && existingWorkspaceProviderKind) {
|
|
912
|
+
const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
|
|
913
|
+
const providerDomain = canonicalProviderDomain(
|
|
914
|
+
payload.providerDomain ?? existing.providerDomain,
|
|
915
|
+
);
|
|
916
|
+
const subjectId =
|
|
917
|
+
payload.subjectId === undefined
|
|
918
|
+
? existing.subjectId
|
|
919
|
+
: writableSubjectId(payload.subjectId, grant.subjectId);
|
|
920
|
+
const kind = payload.kind ?? existing.kind;
|
|
921
|
+
if (
|
|
922
|
+
subjectId !== null ||
|
|
923
|
+
providerDomain !== provider.providerDomain ||
|
|
924
|
+
kind !== "api_key" ||
|
|
925
|
+
(payload.metadata?.credentialRole !== undefined &&
|
|
926
|
+
payload.metadata.credentialRole !== provider.credentialRole)
|
|
927
|
+
) {
|
|
928
|
+
throw new HTTPException(422, {
|
|
929
|
+
message: `${provider.label} connection identity cannot be changed`,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
if (payload.credential === undefined) {
|
|
933
|
+
throw new HTTPException(400, {
|
|
934
|
+
message: `updating a ${provider.label} connection requires a new credential`,
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
if (payload.expectedVersion === undefined || payload.operationId === undefined) {
|
|
938
|
+
throw new HTTPException(400, {
|
|
939
|
+
message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const key = requireEnvironmentEncryption(settings);
|
|
943
|
+
const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
|
|
944
|
+
const expiresAt =
|
|
945
|
+
payload.expiresAt !== undefined
|
|
946
|
+
? payload.expiresAt
|
|
947
|
+
? new Date(payload.expiresAt)
|
|
948
|
+
: null
|
|
949
|
+
: existing.expiresAt
|
|
950
|
+
? new Date(existing.expiresAt)
|
|
951
|
+
: null;
|
|
952
|
+
const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
|
|
953
|
+
...existing.metadata,
|
|
954
|
+
...(payload.metadata ?? {}),
|
|
955
|
+
});
|
|
956
|
+
const input = {
|
|
957
|
+
accountId: grant.accountId,
|
|
958
|
+
workspaceId,
|
|
959
|
+
connectionId: existing.id,
|
|
960
|
+
expectedVersion: payload.expectedVersion,
|
|
961
|
+
operationId: payload.operationId,
|
|
962
|
+
requestDigest: workspaceProviderCredentialRequestDigest(key, {
|
|
963
|
+
action: "rotate",
|
|
964
|
+
connectionId: existing.id,
|
|
965
|
+
expectedVersion: payload.expectedVersion,
|
|
966
|
+
credential: payload.credential,
|
|
967
|
+
grantedScopes,
|
|
968
|
+
expiresAt: expiresAt?.toISOString() ?? null,
|
|
969
|
+
metadata,
|
|
970
|
+
}),
|
|
971
|
+
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
972
|
+
grantedScopes,
|
|
973
|
+
expiresAt,
|
|
974
|
+
metadata,
|
|
975
|
+
updatedBySubjectId: grant.subjectId,
|
|
976
|
+
};
|
|
977
|
+
const connection =
|
|
978
|
+
existingWorkspaceProviderKind === "vercel_gateway"
|
|
979
|
+
? await rotateWorkspaceVercelAiGatewayConnection(db, input)
|
|
980
|
+
: await rotateWorkspaceOpenRouterConnection(db, input);
|
|
981
|
+
if (!connection) {
|
|
982
|
+
throw new HTTPException(409, {
|
|
983
|
+
message: `${provider.label} connection changed; reload before replacing its key`,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
987
|
+
}
|
|
822
988
|
const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
|
|
823
989
|
const subjectId =
|
|
824
990
|
payload.subjectId === undefined
|
|
@@ -879,6 +1045,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
879
1045
|
existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
|
|
880
1046
|
existing.kind === "oauth2" &&
|
|
881
1047
|
AtlassianConnectionMetadata.safeParse(existing.metadata).success;
|
|
1048
|
+
const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
|
|
882
1049
|
const disconnectPayload = await c.req.json().catch(() => null);
|
|
883
1050
|
const googleDriveDisconnect = isGoogleDrive
|
|
884
1051
|
? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
|
|
@@ -909,7 +1076,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
909
1076
|
"invalid personal GitHub disconnect request",
|
|
910
1077
|
});
|
|
911
1078
|
}
|
|
912
|
-
if (
|
|
1079
|
+
if (
|
|
1080
|
+
existing.status === "revoked" &&
|
|
1081
|
+
!isGoogleDrive &&
|
|
1082
|
+
!isAtlassian &&
|
|
1083
|
+
!isPersonalGitHub &&
|
|
1084
|
+
!workspaceProviderKind
|
|
1085
|
+
) {
|
|
913
1086
|
return c.json(ConnectionResponse.parse({ connection: existing }));
|
|
914
1087
|
}
|
|
915
1088
|
const connection = isGoogleDrive
|
|
@@ -944,13 +1117,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
944
1117
|
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
945
1118
|
slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
|
|
946
1119
|
})
|
|
947
|
-
:
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1120
|
+
: workspaceProviderKind
|
|
1121
|
+
? workspaceProviderKind === "vercel_gateway"
|
|
1122
|
+
? await revokeWorkspaceVercelAiGatewayConnections(db, {
|
|
1123
|
+
accountId: grant.accountId,
|
|
1124
|
+
workspaceId,
|
|
1125
|
+
connectionId,
|
|
1126
|
+
expectedVersion: existing.version,
|
|
1127
|
+
updatedBySubjectId: grant.subjectId,
|
|
1128
|
+
})
|
|
1129
|
+
: await revokeWorkspaceOpenRouterConnections(db, {
|
|
1130
|
+
accountId: grant.accountId,
|
|
1131
|
+
workspaceId,
|
|
1132
|
+
connectionId,
|
|
1133
|
+
expectedVersion: existing.version,
|
|
1134
|
+
updatedBySubjectId: grant.subjectId,
|
|
1135
|
+
})
|
|
1136
|
+
: await revokeConnection(
|
|
1137
|
+
db,
|
|
1138
|
+
workspaceId,
|
|
1139
|
+
connectionId,
|
|
1140
|
+
grant.subjectId,
|
|
1141
|
+
existing.version,
|
|
1142
|
+
);
|
|
954
1143
|
if (!connection) {
|
|
955
1144
|
throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
|
|
956
1145
|
}
|
|
@@ -1367,6 +1556,51 @@ function assertNotReservedPersonalGitHubMetadata(
|
|
|
1367
1556
|
}
|
|
1368
1557
|
}
|
|
1369
1558
|
|
|
1559
|
+
type WorkspaceProviderApiKeyConnectionKind = "vercel_gateway" | "openrouter";
|
|
1560
|
+
|
|
1561
|
+
function workspaceProviderApiKeyConnectionSpec(
|
|
1562
|
+
providerKind: WorkspaceProviderApiKeyConnectionKind,
|
|
1563
|
+
): {
|
|
1564
|
+
providerDomain: string;
|
|
1565
|
+
credentialRole: string;
|
|
1566
|
+
label: string;
|
|
1567
|
+
} {
|
|
1568
|
+
return providerKind === "vercel_gateway"
|
|
1569
|
+
? {
|
|
1570
|
+
providerDomain: VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
|
|
1571
|
+
credentialRole: VERCEL_AI_GATEWAY_CONNECTION_ROLE,
|
|
1572
|
+
label: "Vercel AI Gateway",
|
|
1573
|
+
}
|
|
1574
|
+
: {
|
|
1575
|
+
providerDomain: WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
|
|
1576
|
+
credentialRole: WORKSPACE_OPENROUTER_CONNECTION_ROLE,
|
|
1577
|
+
label: "OpenRouter",
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function workspaceProviderApiKeyConnectionKind(input: {
|
|
1582
|
+
subjectId?: string | null;
|
|
1583
|
+
providerDomain: string;
|
|
1584
|
+
kind: string;
|
|
1585
|
+
metadata?: Record<string, unknown>;
|
|
1586
|
+
}): WorkspaceProviderApiKeyConnectionKind | null {
|
|
1587
|
+
if (input.subjectId != null || input.kind !== "api_key") return null;
|
|
1588
|
+
const providerDomain = input.providerDomain.toLowerCase();
|
|
1589
|
+
if (
|
|
1590
|
+
providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
|
|
1591
|
+
input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
|
|
1592
|
+
) {
|
|
1593
|
+
return "vercel_gateway";
|
|
1594
|
+
}
|
|
1595
|
+
if (
|
|
1596
|
+
providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
|
|
1597
|
+
input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
|
|
1598
|
+
) {
|
|
1599
|
+
return "openrouter";
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1370
1604
|
function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
|
|
1371
1605
|
if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
|
|
1372
1606
|
throw new HTTPException(422, {
|
|
@@ -1426,6 +1660,27 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
|
|
|
1426
1660
|
return encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
1427
1661
|
}
|
|
1428
1662
|
|
|
1663
|
+
function workspaceProviderCredentialMetadata(
|
|
1664
|
+
providerKind: WorkspaceProviderApiKeyConnectionKind,
|
|
1665
|
+
metadata: Record<string, unknown> | undefined,
|
|
1666
|
+
): Record<string, unknown> {
|
|
1667
|
+
const {
|
|
1668
|
+
[OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
|
|
1669
|
+
[OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
|
|
1670
|
+
[VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
|
|
1671
|
+
[VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
|
|
1672
|
+
...effectiveMetadata
|
|
1673
|
+
} = metadata ?? {};
|
|
1674
|
+
return {
|
|
1675
|
+
...effectiveMetadata,
|
|
1676
|
+
credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
|
|
1681
|
+
return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1429
1684
|
function slackBotCredentialBundle(token: string): Record<string, unknown> {
|
|
1430
1685
|
const headerName = ["author", "ization"].join("");
|
|
1431
1686
|
const scheme = ["Bear", "er"].join("");
|
package/src/routes/github.ts
CHANGED
|
@@ -4,6 +4,11 @@ import {
|
|
|
4
4
|
type GitHubInstallationBindingCandidate,
|
|
5
5
|
type GitHubInstallationBindingProof,
|
|
6
6
|
} from "@opengeni/contracts";
|
|
7
|
+
import {
|
|
8
|
+
ListGitHubRepositoryBranchesQuery,
|
|
9
|
+
VerifyPublicGitHubRepositoryRefRequest,
|
|
10
|
+
} from "@opengeni/contracts/github-repository-contracts";
|
|
11
|
+
import { parseCanonicalGitHubRepositoryUrl } from "@opengeni/contracts/github-repository";
|
|
7
12
|
import {
|
|
8
13
|
bindAuthorizedGitHubInstallationRepositories,
|
|
9
14
|
deleteGitHubInstallationBinding,
|
|
@@ -19,12 +24,14 @@ import {
|
|
|
19
24
|
GitHubAppApiError,
|
|
20
25
|
GitHubAppConfigurationError,
|
|
21
26
|
GitHubInstallationAuthorityError,
|
|
27
|
+
GitHubPublicRepositoryVerificationError,
|
|
22
28
|
githubAppMissingSettings,
|
|
23
29
|
githubOAuthAuthorizeUrl,
|
|
24
30
|
organizationAppManifestUrl,
|
|
25
31
|
personalAppManifestUrl,
|
|
26
32
|
readSignedState,
|
|
27
33
|
stateMaxAgeSeconds,
|
|
34
|
+
verifyPublicGitHubRepositoryRef,
|
|
28
35
|
type GitHubSignedStatePayload,
|
|
29
36
|
verifySignedState,
|
|
30
37
|
} from "@opengeni/github";
|
|
@@ -42,7 +49,9 @@ import {
|
|
|
42
49
|
} from "../github-browser-flow";
|
|
43
50
|
import {
|
|
44
51
|
githubBindingStatus,
|
|
52
|
+
GitHubRepositoryBranchAuthorityError,
|
|
45
53
|
listWorkspaceGitHubInstallationBindings,
|
|
54
|
+
listWorkspaceGitHubRepositoryBranches,
|
|
46
55
|
listWorkspaceGitHubRepositories,
|
|
47
56
|
} from "../github-access";
|
|
48
57
|
|
|
@@ -177,6 +186,65 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
177
186
|
}
|
|
178
187
|
});
|
|
179
188
|
|
|
189
|
+
app.post("/v1/workspaces/:workspaceId/github/public-repositories/verify", async (c) => {
|
|
190
|
+
const workspaceId = c.req.param("workspaceId");
|
|
191
|
+
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
192
|
+
requirePublicGitHubRepositoryVerificationPermission(grant);
|
|
193
|
+
const request = VerifyPublicGitHubRepositoryRefRequest.parse(await c.req.json());
|
|
194
|
+
let repository: ReturnType<typeof parseCanonicalGitHubRepositoryUrl>;
|
|
195
|
+
try {
|
|
196
|
+
repository = parseCanonicalGitHubRepositoryUrl(request.url);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new HTTPException(422, {
|
|
199
|
+
message: error instanceof Error ? error.message : "GitHub repository URL is invalid.",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return c.json(
|
|
204
|
+
await verifyPublicGitHubRepositoryRef(
|
|
205
|
+
{ repository, ref: request.ref },
|
|
206
|
+
deps.githubAnonymousFetch ?? fetch,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof GitHubPublicRepositoryVerificationError) {
|
|
211
|
+
throw new HTTPException(error.code === "provider_unavailable" ? 503 : 422, {
|
|
212
|
+
message: error.message,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
throw new HTTPException(503, {
|
|
216
|
+
message: "GitHub public repository verification is temporarily unavailable.",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
app.get(
|
|
222
|
+
"/v1/workspaces/:workspaceId/github/installations/:installationId/repositories/:repositoryId/branches",
|
|
223
|
+
async (c) => {
|
|
224
|
+
const workspaceId = c.req.param("workspaceId");
|
|
225
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
226
|
+
const installationId = parsePositiveInteger(c.req.param("installationId"));
|
|
227
|
+
const repositoryId = parsePositiveInteger(c.req.param("repositoryId"));
|
|
228
|
+
if (installationId === null || repositoryId === null) {
|
|
229
|
+
throw new HTTPException(400, { message: "invalid GitHub repository identity" });
|
|
230
|
+
}
|
|
231
|
+
const query = ListGitHubRepositoryBranchesQuery.parse(c.req.query());
|
|
232
|
+
try {
|
|
233
|
+
return c.json(
|
|
234
|
+
await listWorkspaceGitHubRepositoryBranches(deps, {
|
|
235
|
+
accountId: grant.accountId,
|
|
236
|
+
workspaceId: grant.workspaceId,
|
|
237
|
+
installationId,
|
|
238
|
+
repositoryId,
|
|
239
|
+
query,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
throw githubRepositoryBranchesRouteError(error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
|
|
180
248
|
app.delete("/v1/workspaces/:workspaceId/github/installations/:installationId", async (c) => {
|
|
181
249
|
const workspaceId = c.req.param("workspaceId");
|
|
182
250
|
const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
|
|
@@ -184,14 +252,11 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
184
252
|
if (installationId === null) {
|
|
185
253
|
throw new HTTPException(400, { message: "invalid GitHub installation id" });
|
|
186
254
|
}
|
|
187
|
-
|
|
255
|
+
await deleteGitHubInstallationBinding(db, {
|
|
188
256
|
accountId: grant.accountId,
|
|
189
257
|
workspaceId: grant.workspaceId,
|
|
190
258
|
installationId,
|
|
191
259
|
});
|
|
192
|
-
if (!deleted) {
|
|
193
|
-
throw new HTTPException(404, { message: "GitHub installation binding not found" });
|
|
194
|
-
}
|
|
195
260
|
return c.body(null, 204);
|
|
196
261
|
});
|
|
197
262
|
|
|
@@ -428,14 +493,6 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
428
493
|
if (candidates.length === 0) {
|
|
429
494
|
return redirectToGitHubInstallation(c, deps, selectionState);
|
|
430
495
|
}
|
|
431
|
-
if (candidates.length === 1) {
|
|
432
|
-
return redirectToExactGitHubAuthorization(
|
|
433
|
-
c,
|
|
434
|
-
deps,
|
|
435
|
-
selectionState,
|
|
436
|
-
candidates[0]!.installation.installationId,
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
496
|
setGitHubStateCookie(c, deps, selectionState);
|
|
440
497
|
return c.html(
|
|
441
498
|
githubInstallationChooserHtml(
|
|
@@ -757,6 +814,50 @@ function githubAuthorityHttpError(error: unknown): HTTPException {
|
|
|
757
814
|
return new HTTPException(502, { message: "GitHub authority verification failed" });
|
|
758
815
|
}
|
|
759
816
|
|
|
817
|
+
function githubRepositoryBranchesRouteError(error: unknown): Error {
|
|
818
|
+
if (error instanceof HTTPException) {
|
|
819
|
+
return error;
|
|
820
|
+
}
|
|
821
|
+
if (error instanceof GitHubRepositoryBranchAuthorityError) {
|
|
822
|
+
return error.code === "not_authorized"
|
|
823
|
+
? new HTTPException(404, {
|
|
824
|
+
message: "GitHub repository is not authorized for this workspace",
|
|
825
|
+
})
|
|
826
|
+
: new HTTPException(409, {
|
|
827
|
+
message: "GitHub repository authorization changed; refresh and try again",
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
831
|
+
return new HTTPException(409, { message: "GitHub App is not configured" });
|
|
832
|
+
}
|
|
833
|
+
if (error instanceof GitHubAppApiError) {
|
|
834
|
+
if (error.status === 429) {
|
|
835
|
+
return new HTTPException(503, { message: "GitHub is temporarily rate limited" });
|
|
836
|
+
}
|
|
837
|
+
if (error.status === 401 || error.status === 403 || error.status === 404) {
|
|
838
|
+
return new HTTPException(409, {
|
|
839
|
+
message: "GitHub repository access changed; refresh and try again",
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return new HTTPException(502, { message: "GitHub branch discovery is unavailable" });
|
|
843
|
+
}
|
|
844
|
+
return new HTTPException(502, { message: "GitHub branch discovery is unavailable" });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function requirePublicGitHubRepositoryVerificationPermission(
|
|
848
|
+
grant: Pick<AccessGrant, "permissions">,
|
|
849
|
+
): void {
|
|
850
|
+
if (
|
|
851
|
+
hasPermission(grant.permissions, "sessions:create") ||
|
|
852
|
+
hasPermission(grant.permissions, "sessions:control")
|
|
853
|
+
) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
throw new HTTPException(403, {
|
|
857
|
+
message: "missing permission: sessions:create or sessions:control",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
760
861
|
function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
|
|
761
862
|
return (
|
|
762
863
|
deps.settings.publicBaseUrl?.startsWith("https://") ||
|
|
@@ -783,15 +884,15 @@ function githubInstallationChooserHtml(
|
|
|
783
884
|
): string {
|
|
784
885
|
const action = `${baseUrl}/v1/workspaces/${encodeURIComponent(workspaceId)}/github/installations/select`;
|
|
785
886
|
const options = candidates
|
|
786
|
-
.map(({ installation, authorityKind }) => {
|
|
887
|
+
.map(({ installation, authorityKind }, index) => {
|
|
787
888
|
const account = escapeHtml(
|
|
788
889
|
installation.accountLogin ?? `installation ${installation.installationId}`,
|
|
789
890
|
);
|
|
790
891
|
const label = authorityKind === "personal_owner" ? "Personal account" : "Organization owner";
|
|
791
|
-
return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required><span><strong>${account}</strong><small>${label}</small></span></label>`;
|
|
892
|
+
return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required${candidates.length === 1 && index === 0 ? " checked" : ""}><span><strong>${account}</strong><small>${label}</small></span></label>`;
|
|
792
893
|
})
|
|
793
894
|
.join("");
|
|
794
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>
|
|
895
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>These accounts already have the OpenGeni GitHub App installed and GitHub proved you are the personal owner or an active organization owner.</p><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
|
|
795
896
|
}
|
|
796
897
|
|
|
797
898
|
function githubSetupPendingHtml(): string {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AcceptOrganizationInvitationRequest,
|
|
3
|
+
CreateAdditionalOrganizationRequest,
|
|
4
|
+
CreateAdditionalOrganizationResponse,
|
|
3
5
|
CreateOrganizationRequest,
|
|
4
6
|
CreateOrganizationResponse,
|
|
5
7
|
CreateOrganizationWorkspaceRequest,
|
|
@@ -40,6 +42,7 @@ import {
|
|
|
40
42
|
acceptOrganizationInvitation,
|
|
41
43
|
bindPendingOrganizationInvitationsForVerifiedEmail,
|
|
42
44
|
claimOrganizationUserSetupDelivery,
|
|
45
|
+
createAdditionalManagedOrganization,
|
|
43
46
|
createManagedOrganization,
|
|
44
47
|
createOrganizationWorkspace,
|
|
45
48
|
createOrganizationInvitation,
|
|
@@ -68,6 +71,7 @@ import {
|
|
|
68
71
|
import type { Context, Hono } from "hono";
|
|
69
72
|
import { HTTPException } from "hono/http-exception";
|
|
70
73
|
import { z } from "zod";
|
|
74
|
+
import { deleteWorkspaceForRequest } from "../workspace-deletion";
|
|
71
75
|
|
|
72
76
|
import {
|
|
73
77
|
assertOrganizationUserSetupDeliveryConfigured,
|
|
@@ -117,7 +121,9 @@ async function requireOrganizationAdministrator(
|
|
|
117
121
|
);
|
|
118
122
|
return { subjectId };
|
|
119
123
|
}
|
|
120
|
-
throw new HTTPException(401, {
|
|
124
|
+
throw new HTTPException(401, {
|
|
125
|
+
message: "organization administrator session required",
|
|
126
|
+
});
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
@@ -136,8 +142,14 @@ function parseId(schema: z.ZodString, value: string, label: string): string {
|
|
|
136
142
|
return parsed.data;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
function rethrowMembershipError(error: unknown): never {
|
|
140
|
-
const
|
|
145
|
+
function rethrowMembershipError(error: unknown, resourceLimitMessage?: string): never {
|
|
146
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
147
|
+
if (sqlState === "54000" && resourceLimitMessage) {
|
|
148
|
+
throw new HTTPException(409, {
|
|
149
|
+
message: resourceLimitMessage,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const status = organizationMembershipHttpStatus(sqlState);
|
|
141
153
|
if (status !== null) {
|
|
142
154
|
throw new HTTPException(status, {
|
|
143
155
|
message:
|
|
@@ -173,6 +185,25 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
173
185
|
}
|
|
174
186
|
});
|
|
175
187
|
|
|
188
|
+
app.post("/v1/organizations/additional", async (context) => {
|
|
189
|
+
const { session, subjectId } = await requireManagedHuman(context, deps);
|
|
190
|
+
const payload = await parseBody(context, CreateAdditionalOrganizationRequest);
|
|
191
|
+
try {
|
|
192
|
+
return context.json(
|
|
193
|
+
CreateAdditionalOrganizationResponse.parse(
|
|
194
|
+
await createAdditionalManagedOrganization(deps.db, {
|
|
195
|
+
subjectId,
|
|
196
|
+
subjectLabel: session.user.email || session.user.name,
|
|
197
|
+
...payload,
|
|
198
|
+
}),
|
|
199
|
+
),
|
|
200
|
+
201,
|
|
201
|
+
);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
rethrowMembershipError(error, "additional organization limit reached");
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
176
207
|
app.get("/v1/organization-memberships", async (context) => {
|
|
177
208
|
const { session } = await requireManagedHuman(context, deps);
|
|
178
209
|
try {
|
|
@@ -328,6 +359,22 @@ export function registerOrganizationMembershipRoutes(app: Hono, deps: ApiRouteDe
|
|
|
328
359
|
}
|
|
329
360
|
});
|
|
330
361
|
|
|
362
|
+
app.delete("/v1/organizations/:organizationId/workspaces/:workspaceId", async (context) => {
|
|
363
|
+
const organizationId = parseId(
|
|
364
|
+
OrganizationId,
|
|
365
|
+
context.req.param("organizationId"),
|
|
366
|
+
"organization id",
|
|
367
|
+
);
|
|
368
|
+
const { subjectId } = await requireOrganizationAdministrator(context, deps, organizationId);
|
|
369
|
+
const workspaceId = parseId(WorkspaceId, context.req.param("workspaceId"), "workspace id");
|
|
370
|
+
await deleteWorkspaceForRequest(deps, {
|
|
371
|
+
accountId: organizationId,
|
|
372
|
+
workspaceId,
|
|
373
|
+
organizationAdministratorSubjectId: subjectId,
|
|
374
|
+
});
|
|
375
|
+
return context.body(null, 204);
|
|
376
|
+
});
|
|
377
|
+
|
|
331
378
|
app.patch(
|
|
332
379
|
"/v1/organizations/:organizationId/workspaces/:workspaceId/settings",
|
|
333
380
|
async (context) => {
|