@opengeni/api-router 2.11.3 → 2.12.3-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.js +1 -1
- package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
- package/dist/chunk-4AQMKVQM.js.map +1 -0
- package/dist/connection-ownership.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +4 -0
- package/dist/integrations/connect-authority.d.ts +1 -0
- package/dist/integrations/connect-callback-return.d.ts +6 -0
- package/dist/integrations/fiken.d.ts +7 -0
- package/dist/integrations/github-app-connect.d.ts +47 -0
- package/dist/integrations/github-installation-proof.d.ts +3 -0
- package/dist/integrations/github-lens-connect.d.ts +153 -0
- package/dist/integrations/google-drive.d.ts +4 -0
- package/dist/integrations/oauth-client.d.ts +4 -0
- package/dist/integrations/oauth-return-path.d.ts +1 -0
- package/dist/integrations/personal-github.d.ts +2 -0
- package/dist/integrations/provider-oauth.d.ts +11 -1
- package/dist/integrations/slack-install.d.ts +20 -0
- package/dist/integrations/social-oauth.d.ts +2 -0
- package/dist/mcp/scheduled-task-view.d.ts +3 -3
- package/dist/routes/api-integrations.d.ts +14 -0
- package/dist/routes/connect.d.ts +5 -0
- package/dist/routes/external-identity-links.d.ts +6 -0
- package/dist/routes/host-mcp-bindings.d.ts +5 -0
- package/dist/sandbox/rematerialize.d.ts +1 -1
- package/dist/workspace-tool-gateway.d.ts +1 -0
- package/package.json +19 -19
- package/src/app.ts +22 -2
- package/src/connection-ownership.ts +14 -1
- package/src/index.ts +2 -0
- package/src/integrations/atlassian.ts +143 -39
- package/src/integrations/connect-authority.ts +2 -0
- package/src/integrations/connect-callback-return.ts +86 -0
- package/src/integrations/fiken.ts +222 -40
- package/src/integrations/github-app-connect.ts +389 -0
- package/src/integrations/github-installation-proof.ts +60 -0
- package/src/integrations/github-lens-connect.ts +97 -0
- package/src/integrations/google-drive.ts +152 -47
- package/src/integrations/oauth-client.ts +180 -84
- package/src/integrations/oauth-return-path.ts +18 -0
- package/src/integrations/personal-github.ts +146 -52
- package/src/integrations/provider-oauth.ts +132 -21
- package/src/integrations/slack-install.ts +83 -0
- package/src/integrations/social-oauth.ts +142 -1
- package/src/mcp/documents.ts +1 -1
- package/src/mcp/server.ts +64 -17
- package/src/routes/api-integrations.ts +74 -64
- package/src/routes/connect.ts +1869 -0
- package/src/routes/connections.ts +263 -221
- package/src/routes/documents.ts +2 -2
- package/src/routes/external-identity-links.ts +200 -0
- package/src/routes/github.ts +39 -64
- package/src/routes/host-mcp-bindings.ts +122 -0
- package/src/routes/organization-memberships.ts +23 -0
- package/src/routes/organization-sessions.ts +2 -2
- package/src/routes/personal-github.ts +8 -1
- package/src/routes/pr-review-github.ts +30 -3
- package/src/routes/scheduled-tasks.ts +29 -4
- package/src/routes/sessions.ts +61 -32
- package/src/routes/social.ts +5 -1
- package/src/routes/supergrok.ts +90 -3
- package/src/routes/workspaces.ts +16 -0
- package/src/sandbox/rematerialize.ts +33 -1
- package/src/workspace-tool-gateway.ts +136 -24
- package/dist/chunk-5X3XM6CE.js.map +0 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
|
|
3
|
+
import {
|
|
4
|
+
startSlackBotInstall,
|
|
5
|
+
requireOpenGeniSlackOAuthSettings,
|
|
6
|
+
} from "../integrations/slack-install";
|
|
2
7
|
import {
|
|
3
8
|
WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
|
|
4
9
|
WORKSPACE_OPENROUTER_CONNECTION_ROLE,
|
|
@@ -18,9 +23,6 @@ import {
|
|
|
18
23
|
ApiIntegrationOAuthStartRequest,
|
|
19
24
|
ConnectionResponse,
|
|
20
25
|
CreateConnectionRequest,
|
|
21
|
-
FIKEN_CREDENTIAL_LABEL,
|
|
22
|
-
FIKEN_CREDENTIAL_ROLE,
|
|
23
|
-
FIKEN_PROVIDER_DOMAIN,
|
|
24
26
|
FikenInstallRequest,
|
|
25
27
|
FikenOAuthStartRequest,
|
|
26
28
|
FikenOAuthStartResponse,
|
|
@@ -30,7 +32,6 @@ import {
|
|
|
30
32
|
OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
|
|
31
33
|
OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
|
|
32
34
|
OpenGeniSlackBotInstallRequest,
|
|
33
|
-
OpenGeniSlackBotInstallStart,
|
|
34
35
|
OAuthStartRequest,
|
|
35
36
|
OAuthStartResponse,
|
|
36
37
|
UpdateConnectionRequest,
|
|
@@ -57,24 +58,27 @@ import {
|
|
|
57
58
|
isPersonalGitHubConnection,
|
|
58
59
|
} from "@opengeni/contracts/personal-github";
|
|
59
60
|
import {
|
|
60
|
-
fikenConnectionMetadata,
|
|
61
61
|
hasPermission,
|
|
62
62
|
hasReservedFikenMetadata,
|
|
63
63
|
hasReservedOpenGeniSlackBotMetadata,
|
|
64
|
-
isFikenConnection,
|
|
65
64
|
isOpenGeniSlackBotConnection,
|
|
66
65
|
openGeniSlackBotMetadata,
|
|
67
66
|
requireAccessGrant,
|
|
68
67
|
requireAccessGrantAuthorization,
|
|
69
68
|
requireEnvironmentEncryption,
|
|
70
|
-
|
|
69
|
+
externalContinuationCommitAuthorizer,
|
|
71
70
|
} from "@opengeni/core";
|
|
72
71
|
import {
|
|
73
72
|
consumeIntegrationOAuthStateNonce,
|
|
73
|
+
claimConnectOperation,
|
|
74
|
+
finishConnectOperation,
|
|
75
|
+
getConnectAttempt,
|
|
76
|
+
decryptEnvironmentValue,
|
|
74
77
|
createConnection,
|
|
78
|
+
withWorkspaceSubjectRls,
|
|
79
|
+
type Database,
|
|
75
80
|
encryptEnvironmentValue,
|
|
76
81
|
getConnectionMetadata,
|
|
77
|
-
getWorkspaceGrant,
|
|
78
82
|
listConnectionsMetadata,
|
|
79
83
|
listSlackInstallationBindings,
|
|
80
84
|
persistSlackBotInstallationWithSuccessAudit,
|
|
@@ -127,8 +131,10 @@ import {
|
|
|
127
131
|
import {
|
|
128
132
|
assertPersonalConnectionOwnerPrincipal,
|
|
129
133
|
isPersonalConnectionOwnerPrincipal,
|
|
134
|
+
requireLegacyOAuthActor,
|
|
130
135
|
} from "../connection-ownership";
|
|
131
136
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
137
|
+
import { externalActorContinuationForAuthorization } from "@opengeni/core";
|
|
132
138
|
import {
|
|
133
139
|
exchangeOpenGeniSlackAuthorizationCode,
|
|
134
140
|
SlackBotCredentialVerificationError,
|
|
@@ -136,19 +142,20 @@ import {
|
|
|
136
142
|
} from "../integrations/slack-bot";
|
|
137
143
|
import {
|
|
138
144
|
completeFikenOAuthCallback,
|
|
139
|
-
fikenCredentialBundle,
|
|
140
145
|
startFikenOAuth,
|
|
141
|
-
|
|
146
|
+
prepareFikenTokenInstall,
|
|
142
147
|
} from "../integrations/fiken";
|
|
148
|
+
import { requireConnectOwnerAuthority } from "../integrations/connect-authority";
|
|
143
149
|
import {
|
|
144
150
|
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
145
151
|
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
146
|
-
OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
|
|
147
152
|
} from "@opengeni/contracts";
|
|
148
|
-
import {
|
|
153
|
+
import { readSignedState } from "@opengeni/github";
|
|
149
154
|
import { oauthStateTtlMs, requireIntegrationsStateSecret } from "../integrations/oauth-client";
|
|
150
155
|
|
|
151
156
|
type OpenGeniSlackInstallState = {
|
|
157
|
+
connectAttemptId?: string;
|
|
158
|
+
externalContinuation?: ExternalActorContinuation;
|
|
152
159
|
accountId: string;
|
|
153
160
|
workspaceId: string;
|
|
154
161
|
subjectId: string;
|
|
@@ -195,122 +202,111 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
195
202
|
const workspaceId = c.req.param("workspaceId");
|
|
196
203
|
const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
|
|
197
204
|
const grant = access.grant;
|
|
205
|
+
const beforeCommit = externalContinuationCommitAuthorizer(access);
|
|
198
206
|
const payload = CreateConnectionRequest.parse(await c.req.json());
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
payload.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
207
|
+
// All writes in this closure must use the caller-owned scoped transaction.
|
|
208
|
+
// eslint-disable-next-line no-shadow
|
|
209
|
+
const persist = async (db: Database) => {
|
|
210
|
+
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
211
|
+
assertNotReservedFikenMetadata(payload.metadata);
|
|
212
|
+
assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
|
|
213
|
+
assertNotReservedPersonalGitHubMetadata(payload.metadata);
|
|
214
|
+
const key = requireEnvironmentEncryption(settings);
|
|
215
|
+
const subjectId = createConnectionSubjectId(payload, grant.subjectId);
|
|
216
|
+
if (subjectId !== null) {
|
|
217
|
+
assertPersonalConnectionOwnerPrincipal(access);
|
|
218
|
+
}
|
|
219
|
+
const providerDomain = canonicalProviderDomain(payload.providerDomain);
|
|
220
|
+
assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
|
|
221
|
+
assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
|
|
222
|
+
assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
|
|
223
|
+
assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
|
|
224
|
+
const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
|
|
225
|
+
const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
|
|
226
|
+
subjectId,
|
|
227
|
+
providerDomain,
|
|
228
|
+
kind: payload.kind,
|
|
229
|
+
metadata: payload.metadata,
|
|
230
|
+
});
|
|
231
|
+
const connection = workspaceProviderKind
|
|
232
|
+
? await (async () => {
|
|
233
|
+
const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
|
|
234
|
+
if (!payload.operationId) {
|
|
235
|
+
throw new HTTPException(400, {
|
|
236
|
+
message: `connecting ${provider.label} requires an operationId`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
|
|
240
|
+
const metadata = workspaceProviderCredentialMetadata(
|
|
241
|
+
workspaceProviderKind,
|
|
242
|
+
payload.metadata,
|
|
243
|
+
);
|
|
244
|
+
const input = {
|
|
245
|
+
accountId: grant.accountId,
|
|
246
|
+
workspaceId,
|
|
247
|
+
operationId: payload.operationId,
|
|
248
|
+
requestDigest: workspaceProviderCredentialRequestDigest(key, {
|
|
249
|
+
action: "create",
|
|
250
|
+
providerDomain,
|
|
251
|
+
kind: payload.kind,
|
|
252
|
+
subjectId,
|
|
253
|
+
credential: payload.credential,
|
|
254
|
+
grantedScopes: payload.grantedScopes,
|
|
255
|
+
expiresAt: expiresAt?.toISOString() ?? null,
|
|
256
|
+
metadata,
|
|
257
|
+
}),
|
|
258
|
+
credentialEncrypted,
|
|
243
259
|
grantedScopes: payload.grantedScopes,
|
|
244
|
-
expiresAt
|
|
260
|
+
expiresAt,
|
|
245
261
|
metadata,
|
|
246
|
-
|
|
262
|
+
updatedBySubjectId: grant.subjectId,
|
|
263
|
+
};
|
|
264
|
+
const created =
|
|
265
|
+
workspaceProviderKind === "vercel_gateway"
|
|
266
|
+
? await upsertWorkspaceVercelAiGatewayConnection(db, input)
|
|
267
|
+
: await upsertWorkspaceOpenRouterConnection(db, input);
|
|
268
|
+
if (!created || created.status === "revoked") {
|
|
269
|
+
throw new HTTPException(409, {
|
|
270
|
+
message: `${provider.label} is already connected; reload before replacing its key`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return created;
|
|
274
|
+
})()
|
|
275
|
+
: await createConnection(db, {
|
|
276
|
+
accountId: grant.accountId,
|
|
277
|
+
workspaceId,
|
|
278
|
+
subjectId,
|
|
279
|
+
providerDomain,
|
|
280
|
+
kind: payload.kind,
|
|
247
281
|
credentialEncrypted,
|
|
248
282
|
grantedScopes: payload.grantedScopes,
|
|
249
|
-
expiresAt,
|
|
250
|
-
metadata,
|
|
251
|
-
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
});
|
|
276
|
-
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
283
|
+
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
|
|
284
|
+
metadata: payload.metadata,
|
|
285
|
+
createdBySubjectId: grant.subjectId,
|
|
286
|
+
});
|
|
287
|
+
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
288
|
+
};
|
|
289
|
+
return beforeCommit
|
|
290
|
+
? withWorkspaceSubjectRls(db, workspaceId, grant.subjectId, async (tx) => {
|
|
291
|
+
await beforeCommit(tx);
|
|
292
|
+
return persist(tx);
|
|
293
|
+
})
|
|
294
|
+
: persist(db);
|
|
277
295
|
});
|
|
278
296
|
|
|
279
297
|
app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
|
|
280
298
|
const workspaceId = c.req.param("workspaceId");
|
|
281
|
-
const
|
|
299
|
+
const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
|
|
300
|
+
requireLegacyOAuthActor(access);
|
|
301
|
+
const grant = access.grant;
|
|
282
302
|
const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
|
|
283
|
-
const slack = requireOpenGeniSlackOAuthSettings(settings);
|
|
284
|
-
const existing = payload.connectionId
|
|
285
|
-
? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
|
|
286
|
-
: null;
|
|
287
|
-
if (payload.connectionId && !existing) {
|
|
288
|
-
throw new HTTPException(404, { message: "connection not found" });
|
|
289
|
-
}
|
|
290
|
-
if (existing && !isOpenGeniSlackBotConnection(existing)) {
|
|
291
|
-
throw new HTTPException(422, {
|
|
292
|
-
message: "connectionId is not an OpenGeni Slack bot connection",
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
296
|
-
const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
|
|
297
|
-
const returnPath = `/workspaces/${workspaceId}/capabilities`;
|
|
298
|
-
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
299
|
-
accountId: grant.accountId,
|
|
300
|
-
workspaceId,
|
|
301
|
-
subjectId: grant.subjectId,
|
|
302
|
-
returnPath,
|
|
303
|
-
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
304
|
-
});
|
|
305
|
-
const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
|
|
306
|
-
authorizationUrl.searchParams.set("client_id", slack.clientId);
|
|
307
|
-
authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
|
|
308
|
-
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
309
|
-
authorizationUrl.searchParams.set("state", state);
|
|
310
303
|
return c.json(
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
304
|
+
await startSlackBotInstall(deps, {
|
|
305
|
+
accountId: grant.accountId,
|
|
306
|
+
workspaceId,
|
|
307
|
+
subjectId: grant.subjectId,
|
|
308
|
+
requestUrl: c.req.url,
|
|
309
|
+
...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
|
|
314
310
|
}),
|
|
315
311
|
);
|
|
316
312
|
});
|
|
@@ -318,9 +314,30 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
318
314
|
app.get("/v1/integrations/slack/callback", async (c) => {
|
|
319
315
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
320
316
|
let state: OpenGeniSlackInstallState | null = null;
|
|
317
|
+
let exactReturnUrl: string | undefined;
|
|
318
|
+
let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
|
|
321
319
|
let stage: SlackBotInstallCallbackFailureStage = "permission_check";
|
|
322
320
|
try {
|
|
323
321
|
state = readOpenGeniSlackInstallState(c.req.query("state"), settings);
|
|
322
|
+
if (state.connectAttemptId) {
|
|
323
|
+
const stored = await getConnectAttempt(db, state, state.connectAttemptId);
|
|
324
|
+
if (stored.attempt.providerId !== "slack-bot" || stored.attempt.ownership !== "workspace")
|
|
325
|
+
throw new HTTPException(403, { message: "Slack attempt mismatch" });
|
|
326
|
+
exactReturnUrl = stored.returnUrl;
|
|
327
|
+
operation = {
|
|
328
|
+
attemptId: state.connectAttemptId,
|
|
329
|
+
operationId: `oauth:${state.nonce}`,
|
|
330
|
+
inputDigest: createHash("sha256").update(c.req.query("state")!).digest("hex"),
|
|
331
|
+
};
|
|
332
|
+
const claim = await claimConnectOperation(db, state, {
|
|
333
|
+
...operation,
|
|
334
|
+
expectedRevision: stored.attempt.revision,
|
|
335
|
+
authorize: (tx, _attempt, origin) =>
|
|
336
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
337
|
+
});
|
|
338
|
+
if (claim.status === "replayed")
|
|
339
|
+
return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
|
|
340
|
+
}
|
|
324
341
|
await requireSlackInstallCallbackGrant(db, state);
|
|
325
342
|
stage = "nonce_consume";
|
|
326
343
|
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
@@ -338,6 +355,25 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
338
355
|
"Slack installation state has already been used",
|
|
339
356
|
);
|
|
340
357
|
}
|
|
358
|
+
if (operation && (c.req.query("error") || !c.req.query("code"))) {
|
|
359
|
+
await finishConnectOperation(db, state, {
|
|
360
|
+
...operation,
|
|
361
|
+
authorize: (tx, _attempt, origin) =>
|
|
362
|
+
requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
|
|
363
|
+
commit: async (_tx, current) => ({
|
|
364
|
+
...current,
|
|
365
|
+
revision: current.revision + 1,
|
|
366
|
+
state: c.req.query("error") === "access_denied" ? "cancelled" : "failed",
|
|
367
|
+
nextAction: { type: "none" },
|
|
368
|
+
error: {
|
|
369
|
+
code: c.req.query("error") ? "provider_denied" : "missing_code",
|
|
370
|
+
message: "Authorization was not completed. Start a new connection attempt.",
|
|
371
|
+
retryable: false,
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
|
|
376
|
+
}
|
|
341
377
|
if (c.req.query("error")) {
|
|
342
378
|
stage = "provider_denial";
|
|
343
379
|
throw new SlackInstallCallbackError(
|
|
@@ -379,17 +415,51 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
379
415
|
stage = "permission_recheck";
|
|
380
416
|
await requireSlackInstallCallbackGrant(db, state);
|
|
381
417
|
stage = "persistence";
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
418
|
+
const acceptedState = state;
|
|
419
|
+
const persist = (tx: Database) =>
|
|
420
|
+
persistOpenGeniSlackBotConnection({
|
|
421
|
+
deps: { ...deps, db: tx },
|
|
422
|
+
state: acceptedState,
|
|
423
|
+
token: authorization.accessToken,
|
|
424
|
+
verified,
|
|
425
|
+
});
|
|
426
|
+
if (operation) {
|
|
427
|
+
await finishConnectOperation(db, acceptedState, {
|
|
428
|
+
...operation,
|
|
429
|
+
authorize: (tx, _attempt, origin) =>
|
|
430
|
+
requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
|
|
431
|
+
commit: async (tx, current) => {
|
|
432
|
+
const connection = await persist(tx);
|
|
433
|
+
return {
|
|
434
|
+
...current,
|
|
435
|
+
revision: current.revision + 1,
|
|
436
|
+
state: "complete",
|
|
437
|
+
credentialsCommitted: true,
|
|
438
|
+
nextAction: { type: "none" },
|
|
439
|
+
account: {
|
|
440
|
+
id: connection.id,
|
|
441
|
+
version: connection.version,
|
|
442
|
+
providerId: "slack-bot",
|
|
443
|
+
label: "Slack workspace bot",
|
|
444
|
+
ownership: "workspace",
|
|
445
|
+
status: "connected",
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
|
|
451
|
+
}
|
|
452
|
+
const connection = await db.transaction(async (tx) => {
|
|
453
|
+
await requireSlackInstallCallbackGrant(tx, acceptedState);
|
|
454
|
+
return persist(tx);
|
|
387
455
|
});
|
|
388
456
|
return c.redirect(
|
|
389
457
|
slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
|
|
390
458
|
302,
|
|
391
459
|
);
|
|
392
460
|
} catch (error) {
|
|
461
|
+
if (exactReturnUrl)
|
|
462
|
+
return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
|
|
393
463
|
if (state) {
|
|
394
464
|
const failure = slackInstallCallbackFailure(stage, error);
|
|
395
465
|
try {
|
|
@@ -422,80 +492,27 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
422
492
|
app.post("/v1/workspaces/:workspaceId/connections/fiken/install", async (c) => {
|
|
423
493
|
assertIntegrationsEnabled();
|
|
424
494
|
const workspaceId = c.req.param("workspaceId");
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
: null;
|
|
431
|
-
if (payload.connectionId && !existing) {
|
|
432
|
-
throw new HTTPException(404, { message: "connection not found" });
|
|
433
|
-
}
|
|
434
|
-
if (existing && !isFikenConnection(existing)) {
|
|
435
|
-
throw new HTTPException(422, { message: "connectionId is not a Fiken connection" });
|
|
436
|
-
}
|
|
437
|
-
const verified = await verifyFikenApiToken(payload.apiToken, deps.fikenFetch ?? fetch);
|
|
438
|
-
const previousDefault = existing
|
|
439
|
-
? (fikenConnectionMetadata(existing.metadata)?.defaultCompanySlug ?? null)
|
|
440
|
-
: null;
|
|
441
|
-
const defaultCompanySlug = resolveFikenDefaultCompanySlug({
|
|
442
|
-
requested: payload.defaultCompanySlug ?? null,
|
|
443
|
-
previous: previousDefault,
|
|
444
|
-
companies: verified.companies,
|
|
445
|
-
});
|
|
446
|
-
if (payload.defaultCompanySlug && defaultCompanySlug !== payload.defaultCompanySlug) {
|
|
447
|
-
throw new HTTPException(422, {
|
|
448
|
-
message: `defaultCompanySlug is not among the companies this token can access: ${verified.companies
|
|
449
|
-
.map((company) => company.slug)
|
|
450
|
-
.join(", ")}`,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
const metadata = {
|
|
454
|
-
credentialRole: FIKEN_CREDENTIAL_ROLE,
|
|
455
|
-
credentialLabel: FIKEN_CREDENTIAL_LABEL,
|
|
456
|
-
companies: verified.companies,
|
|
457
|
-
defaultCompanySlug,
|
|
458
|
-
verifiedAt: new Date().toISOString(),
|
|
459
|
-
};
|
|
460
|
-
const credentialEncrypted = encryptCredentialBundle(
|
|
461
|
-
key,
|
|
462
|
-
fikenCredentialBundle(payload.apiToken),
|
|
495
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
496
|
+
c,
|
|
497
|
+
deps,
|
|
498
|
+
workspaceId,
|
|
499
|
+
"connections:write",
|
|
463
500
|
);
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
501
|
+
const grant = authorization.grant;
|
|
502
|
+
const payload = FikenInstallRequest.parse(await c.req.json());
|
|
503
|
+
const persist = await prepareFikenTokenInstall(deps, grant, payload);
|
|
504
|
+
const continuation = externalActorContinuationForAuthorization(authorization);
|
|
505
|
+
const connection = await db.transaction(async (tx) => {
|
|
506
|
+
await requireConnectOwnerAuthority(tx, {
|
|
507
|
+
accountId: grant.accountId,
|
|
469
508
|
workspaceId,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
kind: "api_key",
|
|
474
|
-
status: "active",
|
|
475
|
-
credentialEncrypted,
|
|
476
|
-
grantedScopes: [],
|
|
477
|
-
expiresAt: null,
|
|
478
|
-
metadata,
|
|
479
|
-
updatedBySubjectId: grant.subjectId,
|
|
509
|
+
subjectId: grant.subjectId,
|
|
510
|
+
personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
|
|
511
|
+
...(continuation ? { externalContinuation: continuation } : {}),
|
|
480
512
|
});
|
|
481
|
-
|
|
482
|
-
throw new HTTPException(409, { message: "the Fiken connection changed; retry" });
|
|
483
|
-
}
|
|
484
|
-
return c.json(ConnectionResponse.parse({ connection: updated }));
|
|
485
|
-
}
|
|
486
|
-
const connection = await createConnection(db, {
|
|
487
|
-
accountId: grant.accountId,
|
|
488
|
-
workspaceId,
|
|
489
|
-
subjectId: null,
|
|
490
|
-
providerDomain: FIKEN_PROVIDER_DOMAIN,
|
|
491
|
-
kind: "api_key",
|
|
492
|
-
credentialEncrypted,
|
|
493
|
-
grantedScopes: [],
|
|
494
|
-
expiresAt: null,
|
|
495
|
-
metadata,
|
|
496
|
-
createdBySubjectId: grant.subjectId,
|
|
513
|
+
return persist(tx);
|
|
497
514
|
});
|
|
498
|
-
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
515
|
+
return c.json(ConnectionResponse.parse({ connection }), payload.connectionId ? 200 : 201);
|
|
499
516
|
});
|
|
500
517
|
|
|
501
518
|
// Fiken OAuth (registered app) start. Both Fiken lanes produce the same
|
|
@@ -503,7 +520,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
503
520
|
app.post("/v1/workspaces/:workspaceId/connections/fiken/oauth/start", async (c) => {
|
|
504
521
|
assertIntegrationsEnabled();
|
|
505
522
|
const workspaceId = c.req.param("workspaceId");
|
|
506
|
-
const
|
|
523
|
+
const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
|
|
524
|
+
requireLegacyOAuthActor(access);
|
|
525
|
+
const grant = access.grant;
|
|
507
526
|
const payload = FikenOAuthStartRequest.parse(await c.req.json());
|
|
508
527
|
requireEnvironmentEncryption(settings);
|
|
509
528
|
return c.json(
|
|
@@ -527,6 +546,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
527
546
|
...(c.req.query("error") ? { error: c.req.query("error") } : {}),
|
|
528
547
|
requestUrl: c.req.url,
|
|
529
548
|
});
|
|
549
|
+
if (result.exactReturn)
|
|
550
|
+
return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
|
|
530
551
|
return c.redirect(result.redirectTo, 302);
|
|
531
552
|
});
|
|
532
553
|
|
|
@@ -537,6 +558,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
537
558
|
// human may start it.
|
|
538
559
|
const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
|
|
539
560
|
assertPersonalConnectionOwnerPrincipal(access, "Google Drive");
|
|
561
|
+
requireLegacyOAuthActor(access);
|
|
540
562
|
const grant = access.grant;
|
|
541
563
|
const parsed = GoogleDriveOAuthStartRequest.safeParse(await c.req.json());
|
|
542
564
|
if (!parsed.success) {
|
|
@@ -562,6 +584,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
562
584
|
// managed human may start it.
|
|
563
585
|
const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
|
|
564
586
|
assertPersonalConnectionOwnerPrincipal(access, "Atlassian");
|
|
587
|
+
requireLegacyOAuthActor(access);
|
|
565
588
|
const grant = access.grant;
|
|
566
589
|
const parsed = AtlassianOAuthStartRequest.safeParse(await c.req.json());
|
|
567
590
|
if (!parsed.success) {
|
|
@@ -588,6 +611,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
588
611
|
...(c.req.query("error") ? { error: c.req.query("error") } : {}),
|
|
589
612
|
requestUrl: c.req.url,
|
|
590
613
|
});
|
|
614
|
+
if (result.exactReturn)
|
|
615
|
+
return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
|
|
591
616
|
return c.redirect(result.redirectTo, 302);
|
|
592
617
|
});
|
|
593
618
|
|
|
@@ -663,6 +688,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
663
688
|
requestUrl: c.req.url,
|
|
664
689
|
};
|
|
665
690
|
const result = await completeGoogleDriveOAuthCallback(deps, input);
|
|
691
|
+
if (result.exactReturn)
|
|
692
|
+
return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
|
|
666
693
|
return c.redirect(result.redirectTo, 302);
|
|
667
694
|
});
|
|
668
695
|
|
|
@@ -1024,6 +1051,15 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1024
1051
|
if (!existing) {
|
|
1025
1052
|
throw new HTTPException(404, { message: "connection not found" });
|
|
1026
1053
|
}
|
|
1054
|
+
const expectedVersion = c.req.query("expectedVersion");
|
|
1055
|
+
if (expectedVersion !== undefined) {
|
|
1056
|
+
if (!/^[1-9]\d*$/.test(expectedVersion) || !Number.isSafeInteger(Number(expectedVersion))) {
|
|
1057
|
+
throw new HTTPException(400, { message: "invalid expected connection version" });
|
|
1058
|
+
}
|
|
1059
|
+
if (Number(expectedVersion) !== existing.version) {
|
|
1060
|
+
throw new HTTPException(409, { message: "connection changed; refresh before disconnect" });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1027
1063
|
const isPersonalGitHub =
|
|
1028
1064
|
existing.subjectId === grant.subjectId && isPersonalGitHubConnection(existing);
|
|
1029
1065
|
if (isPersonalGitHub) {
|
|
@@ -1161,6 +1197,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1161
1197
|
const result = await startMcpOAuth(
|
|
1162
1198
|
{ db, settings, observability, oauthStartDeadlineMs: deps.oauthStartDeadlineMs },
|
|
1163
1199
|
{
|
|
1200
|
+
...(externalActorContinuationForAuthorization(access)
|
|
1201
|
+
? { externalContinuation: externalActorContinuationForAuthorization(access)! }
|
|
1202
|
+
: {}),
|
|
1164
1203
|
accountId: grant.accountId,
|
|
1165
1204
|
workspaceId,
|
|
1166
1205
|
subjectId: grant.subjectId,
|
|
@@ -1186,6 +1225,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1186
1225
|
return c.json(
|
|
1187
1226
|
OAuthStartResponse.parse(
|
|
1188
1227
|
await startApiIntegrationProviderOAuth(deps, {
|
|
1228
|
+
...(externalActorContinuationForAuthorization(access)
|
|
1229
|
+
? { externalContinuation: externalActorContinuationForAuthorization(access)! }
|
|
1230
|
+
: {}),
|
|
1189
1231
|
accountId: grant.accountId,
|
|
1190
1232
|
workspaceId,
|
|
1191
1233
|
subjectId: grant.subjectId,
|
|
@@ -1211,6 +1253,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1211
1253
|
{ db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
|
|
1212
1254
|
input,
|
|
1213
1255
|
);
|
|
1256
|
+
if ("exactReturn" in result && result.exactReturn)
|
|
1257
|
+
return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
|
|
1214
1258
|
const redirectTo = settings.webBaseUrl
|
|
1215
1259
|
? new URL(result.redirectTo, settings.webBaseUrl).toString()
|
|
1216
1260
|
: result.redirectTo;
|
|
@@ -1225,6 +1269,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1225
1269
|
...(c.req.query("error") ? { error: c.req.query("error") } : {}),
|
|
1226
1270
|
requestUrl: c.req.url,
|
|
1227
1271
|
});
|
|
1272
|
+
if (result.exactReturn)
|
|
1273
|
+
return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
|
|
1228
1274
|
return c.redirect(result.redirectTo, 302);
|
|
1229
1275
|
});
|
|
1230
1276
|
|
|
@@ -1341,22 +1387,6 @@ async function persistOpenGeniSlackBotConnection(input: {
|
|
|
1341
1387
|
}
|
|
1342
1388
|
}
|
|
1343
1389
|
|
|
1344
|
-
function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]): {
|
|
1345
|
-
clientId: string;
|
|
1346
|
-
clientSecret: string;
|
|
1347
|
-
} {
|
|
1348
|
-
const clientId = settings.slackClientId?.trim();
|
|
1349
|
-
const clientSecret = settings.slackClientSecret?.trim();
|
|
1350
|
-
const signingSecret = settings.slackSigningSecret?.trim();
|
|
1351
|
-
if (!clientId || !clientSecret || !signingSecret) {
|
|
1352
|
-
throw new HTTPException(503, {
|
|
1353
|
-
message:
|
|
1354
|
-
"OpenGeni Slack bot installation requires OPENGENI_SLACK_CLIENT_ID, OPENGENI_SLACK_CLIENT_SECRET, and OPENGENI_SLACK_SIGNING_SECRET",
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
return { clientId, clientSecret };
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
1390
|
function readOpenGeniSlackInstallState(
|
|
1361
1391
|
rawState: string | undefined,
|
|
1362
1392
|
settings: ApiRouteDeps["settings"],
|
|
@@ -1371,6 +1401,11 @@ function readOpenGeniSlackInstallState(
|
|
|
1371
1401
|
if (!payload) {
|
|
1372
1402
|
throw new HTTPException(400, { message: "invalid or expired Slack installation state" });
|
|
1373
1403
|
}
|
|
1404
|
+
if (
|
|
1405
|
+
(payload.kind !== undefined && payload.kind !== "slack_bot_install") ||
|
|
1406
|
+
(payload.connectAttemptId && payload.kind !== "slack_bot_install")
|
|
1407
|
+
)
|
|
1408
|
+
throw new HTTPException(400, { message: "invalid Slack installation state kind" });
|
|
1374
1409
|
const requiredString = (value: unknown, label: string): string => {
|
|
1375
1410
|
if (typeof value !== "string" || value.length === 0) {
|
|
1376
1411
|
throw new HTTPException(400, { message: `invalid Slack installation ${label}` });
|
|
@@ -1405,6 +1440,21 @@ function readOpenGeniSlackInstallState(
|
|
|
1405
1440
|
workspaceId,
|
|
1406
1441
|
subjectId,
|
|
1407
1442
|
returnPath,
|
|
1443
|
+
...(typeof payload.connectAttemptId === "string"
|
|
1444
|
+
? { connectAttemptId: payload.connectAttemptId }
|
|
1445
|
+
: {}),
|
|
1446
|
+
...(typeof payload.encryptedExternalContinuation === "string"
|
|
1447
|
+
? {
|
|
1448
|
+
externalContinuation: ExternalActorContinuation.parse(
|
|
1449
|
+
JSON.parse(
|
|
1450
|
+
decryptEnvironmentValue(
|
|
1451
|
+
requireEnvironmentEncryption(settings),
|
|
1452
|
+
payload.encryptedExternalContinuation,
|
|
1453
|
+
),
|
|
1454
|
+
),
|
|
1455
|
+
),
|
|
1456
|
+
}
|
|
1457
|
+
: {}),
|
|
1408
1458
|
...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
|
|
1409
1459
|
nonce: requiredString(payload.nonce, "nonce"),
|
|
1410
1460
|
iat:
|
|
@@ -1456,6 +1506,9 @@ function slackInstallCallbackFailure(
|
|
|
1456
1506
|
if (error instanceof SlackBotLifecycleSuccessAuditError) {
|
|
1457
1507
|
return { stage: "persistence", reason: "success_audit_failed" };
|
|
1458
1508
|
}
|
|
1509
|
+
if (stage === "permission_check" && error instanceof HTTPException && error.status === 403) {
|
|
1510
|
+
return { stage, reason: "permission_lost" };
|
|
1511
|
+
}
|
|
1459
1512
|
if (stage === "code_exchange") {
|
|
1460
1513
|
return { stage, reason: "exchange_failed" };
|
|
1461
1514
|
}
|
|
@@ -1479,18 +1532,7 @@ async function requireSlackInstallCallbackGrant(
|
|
|
1479
1532
|
db: ApiRouteDeps["db"],
|
|
1480
1533
|
state: OpenGeniSlackInstallState,
|
|
1481
1534
|
): Promise<void> {
|
|
1482
|
-
|
|
1483
|
-
if (
|
|
1484
|
-
!grant ||
|
|
1485
|
-
grant.accountId !== state.accountId ||
|
|
1486
|
-
!hasPermission(grant.permissions, "connections:write")
|
|
1487
|
-
) {
|
|
1488
|
-
throw new SlackInstallCallbackError(
|
|
1489
|
-
403,
|
|
1490
|
-
"permission_lost",
|
|
1491
|
-
"Slack installation subject no longer has permission for this workspace",
|
|
1492
|
-
);
|
|
1493
|
-
}
|
|
1535
|
+
await requireConnectOwnerAuthority(db, state, "connections:write");
|
|
1494
1536
|
}
|
|
1495
1537
|
|
|
1496
1538
|
function assertNotDirectPersonalSlackOAuth(providerDomain: string, kind: string): void {
|