@opengeni/api-router 0.14.4 → 0.15.4

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.
@@ -0,0 +1,84 @@
1
+ import { type Settings } from "@opengeni/config";
2
+ import { OAuthStartResponse, type SocialConnection, type SocialOAuthProviderId, type SocialOAuthStartRequest } from "@opengeni/contracts";
3
+ import type { Observability } from "@opengeni/observability";
4
+ import { type Database } from "@opengeni/db";
5
+ export declare const SOCIAL_USER_AGENT = "opengeni:social-connector:v0.1.0 (self-hosted)";
6
+ export declare const SOCIAL_TIMEOUT_MS = 10000;
7
+ /** Token-endpoint failure that carries enough to tell invalid_grant from a blip. */
8
+ export declare class SocialTokenRequestError extends Error {
9
+ readonly status: number | null;
10
+ readonly oauthError: string | null;
11
+ constructor(message: string, status: number | null, oauthError: string | null);
12
+ /** True only for definitive authorization-server rejections of the grant. */
13
+ get definitive(): boolean;
14
+ }
15
+ type SocialProviderDefinition = {
16
+ id: SocialOAuthProviderId;
17
+ authorizationEndpoint: string;
18
+ tokenEndpoint: string;
19
+ defaultScopes: string[];
20
+ pkce: boolean;
21
+ extraAuthorizeParams: Record<string, string>;
22
+ };
23
+ export declare const SOCIAL_OAUTH_PROVIDERS: Record<SocialOAuthProviderId, SocialProviderDefinition>;
24
+ export type SocialCredentialBundle = {
25
+ provider: SocialOAuthProviderId;
26
+ accessToken: string;
27
+ refreshToken?: string;
28
+ tokenType: string;
29
+ expiresAt?: string;
30
+ scope?: string;
31
+ };
32
+ /**
33
+ * Provider-transport seam (Slack-connector pattern): production always goes
34
+ * through pinnedFetch; tests inject an in-process provider to exercise the
35
+ * full callback/refresh/tool loop functionally.
36
+ */
37
+ export type SocialProviderFetch = (url: string, init: RequestInit, label: string) => Promise<Response>;
38
+ type SocialOAuthDeps = {
39
+ db: Database;
40
+ settings: Settings;
41
+ observability?: Observability | undefined;
42
+ providerFetch?: SocialProviderFetch | undefined;
43
+ };
44
+ export type SocialOAuthStartContext = {
45
+ accountId: string;
46
+ workspaceId: string;
47
+ subjectId: string;
48
+ requestUrl: string;
49
+ payload: SocialOAuthStartRequest;
50
+ };
51
+ export declare function socialOAuthClientFor(settings: Settings, provider: SocialOAuthProviderId): {
52
+ clientId: string;
53
+ clientSecret?: string | undefined;
54
+ };
55
+ export declare function socialOAuthRedirectUri(settings: Settings, requestUrl: string): string;
56
+ export declare function startSocialOAuth(deps: SocialOAuthDeps, context: SocialOAuthStartContext): Promise<OAuthStartResponse>;
57
+ export declare function completeSocialOAuthCallback(deps: SocialOAuthDeps, input: {
58
+ code?: string | undefined;
59
+ state?: string | undefined;
60
+ error?: string | undefined;
61
+ requestUrl: string;
62
+ }): Promise<{
63
+ redirectTo: string;
64
+ }>;
65
+ /**
66
+ * Resolves a usable access token for a social connection, refreshing (and
67
+ * persisting the rotated bundle) when the stored token is near expiry. Marks
68
+ * the connection needs_reauth and throws when refresh is impossible so agents
69
+ * surface an actionable error instead of opaque 401s.
70
+ */
71
+ export declare function freshSocialAccessToken(deps: SocialOAuthDeps, ref: {
72
+ workspaceId: string;
73
+ connectionId: string;
74
+ }): Promise<{
75
+ connection: SocialConnection;
76
+ bundle: SocialCredentialBundle;
77
+ }>;
78
+ export declare function markNeedsReauth(deps: SocialOAuthDeps, ref: {
79
+ workspaceId: string;
80
+ connectionId: string;
81
+ }): Promise<void>;
82
+ export declare function parseSocialCredentialBundle(raw: string): SocialCredentialBundle;
83
+ export declare function socialTokenNeedsRefresh(bundle: SocialCredentialBundle, now: Date): boolean;
84
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type ApiRouteDeps } from "@opengeni/core";
2
+ import type { Hono } from "hono";
3
+ export declare function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.14.4",
3
+ "version": "0.15.4",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,17 +44,17 @@
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
46
  "@opengeni/codex": "^0.2.9",
47
- "@opengeni/config": "^0.7.22",
48
- "@opengeni/contracts": "^0.26.1",
49
- "@opengeni/core": "^0.14.4",
50
- "@opengeni/db": "^0.16.2",
51
- "@opengeni/documents": "^0.2.59",
52
- "@opengeni/events": "^0.3.50",
53
- "@opengeni/github": "^0.4.9",
47
+ "@opengeni/config": "^0.9.1",
48
+ "@opengeni/contracts": "^0.28.1",
49
+ "@opengeni/core": "^0.16.1",
50
+ "@opengeni/db": "^0.18.1",
51
+ "@opengeni/documents": "^0.2.63",
52
+ "@opengeni/events": "^0.3.54",
53
+ "@opengeni/github": "^0.4.13",
54
54
  "@opengeni/network": "^0.1.1",
55
55
  "@opengeni/observability": "^0.3.0",
56
- "@opengeni/runtime": "^0.14.16",
57
- "@opengeni/storage": "^0.2.46",
56
+ "@opengeni/runtime": "^0.15.3",
57
+ "@opengeni/storage": "^0.2.50",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -27,6 +27,7 @@ import { createObjectStorage } from "@opengeni/storage";
27
27
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
28
28
  import { Hono } from "hono";
29
29
  import { bodyLimit } from "hono/body-limit";
30
+ import { compress } from "hono/compress";
30
31
  import { cors } from "hono/cors";
31
32
  import { HTTPException } from "hono/http-exception";
32
33
  import type { ContentfulStatusCode } from "hono/utils/http-status";
@@ -68,11 +69,13 @@ import { registerSocialRoutes } from "./routes/social";
68
69
  import { registerWorkspaceRoutes } from "./routes/workspaces";
69
70
  import { registerWorkspaceInstructionPolicyRoutes } from "./routes/workspace-instruction-policies";
70
71
  import { registerWorkspaceStateRoutes } from "./routes/workspace-state";
72
+ import { registerWorkspaceArtifactRoutes } from "./routes/workspace-artifacts";
71
73
  import { registerPreferenceRegistryRoutes } from "./routes/preference-registry";
72
74
  import { registerInsightsRoutes } from "./routes/insights";
73
75
  import { registerTranscriptionRoutes } from "./routes/transcriptions";
74
76
  import { projectClientModel } from "./model-catalog";
75
77
  import { createTranscriptionService } from "./transcription/service";
78
+ import { registerSlackInteractionRoutes } from "./integrations/slack-interactions";
76
79
 
77
80
  export type {
78
81
  ApiRouteDeps,
@@ -104,6 +107,13 @@ export function apiRequestBodyLimitBytes(settings: { voiceInputMaxSizeBytes: num
104
107
  const API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
105
108
 
106
109
  export function createApp(deps: AppDependencies): Hono {
110
+ return createAppComposition(deps).app;
111
+ }
112
+
113
+ export function createAppComposition(deps: AppDependencies): {
114
+ app: Hono;
115
+ routeDeps: ApiRouteDeps;
116
+ } {
107
117
  const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
108
118
  const objectStorage =
109
119
  deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
@@ -186,28 +196,34 @@ export function createApp(deps: AppDependencies): Hono {
186
196
  await next();
187
197
  });
188
198
 
189
- app.use(
190
- "*",
191
- cors({
192
- credentials: true,
193
- allowHeaders: [
194
- "Accept",
195
- "Authorization",
196
- "Content-Type",
197
- "X-OpenGeni-Access-Key",
198
- "X-OpenGeni-Api-Contract",
199
- "X-OpenGeni-Correlation-Id",
200
- "X-OpenGeni-Subject",
201
- ],
202
- exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
203
- origin: (origin) => {
204
- if (!origin) {
205
- return null;
206
- }
207
- return allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin) ? origin : null;
208
- },
209
- }),
210
- );
199
+ const corsHeaders = {
200
+ allowHeaders: [
201
+ "Accept",
202
+ "Authorization",
203
+ "Content-Type",
204
+ "X-OpenGeni-Access-Key",
205
+ "X-OpenGeni-Api-Contract",
206
+ "X-OpenGeni-Correlation-Id",
207
+ "X-OpenGeni-Subject",
208
+ ],
209
+ exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
210
+ };
211
+ const publicApiCors = cors({ ...corsHeaders, credentials: false, origin: "*" });
212
+ const credentialedCors = cors({
213
+ ...corsHeaders,
214
+ credentials: true,
215
+ origin: (origin) =>
216
+ allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin) ? origin : null,
217
+ });
218
+
219
+ app.use("*", (c, next) => {
220
+ const origin = c.req.header("origin");
221
+ const middleware =
222
+ origin && allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin)
223
+ ? credentialedCors
224
+ : publicApiCors;
225
+ return middleware(c, next);
226
+ });
211
227
 
212
228
  app.use(
213
229
  "*",
@@ -218,6 +234,20 @@ export function createApp(deps: AppDependencies): Hono {
218
234
  }),
219
235
  );
220
236
 
237
+ // Large catalog, capture, and session-list responses are on the browser's
238
+ // critical path. Compress JSON at the API boundary while leaving SSE and
239
+ // other streaming transports byte-for-byte unchanged.
240
+ const compressJson = compress({
241
+ encoding: "gzip",
242
+ contentTypeFilter: /^application\/json(?:;|$)/i,
243
+ });
244
+ app.use("/v1/*", async (c, next) => {
245
+ await compressJson(c, next);
246
+ if (/^application\/json(?:;|$)/i.test(c.res.headers.get("content-type") ?? "")) {
247
+ c.res.headers.set("vary", appendVary(c.res.headers.get("vary"), "Accept-Encoding"));
248
+ }
249
+ });
250
+
221
251
  app.use("*", async (c, next) => {
222
252
  const url = new URL(c.req.url);
223
253
  const route = routeLabel(url.pathname);
@@ -377,6 +407,7 @@ export function createApp(deps: AppDependencies): Hono {
377
407
  },
378
408
  productAccessMode: deps.settings.productAccessMode,
379
409
  auth: clientAuthConfig(deps.settings),
410
+ analytics: clientAnalyticsConfig(deps.settings),
380
411
  // Channel-A structured services (P4.4) ride exec/readFile/createEditor,
381
412
  // available on every real backend; `none` has no box so they are all off.
382
413
  // Per-session availability is still negotiated on /stream-capabilities.
@@ -392,7 +423,9 @@ export function createApp(deps: AppDependencies): Hono {
392
423
  boundedRequest = await boundedMcpRequest(c.req.raw);
393
424
  } catch (error) {
394
425
  if (error instanceof McpPayloadTooLargeError) {
395
- throw new HTTPException(413, { message: "MCP request body exceeds the safety limit" });
426
+ throw new HTTPException(413, {
427
+ message: "MCP request body exceeds the safety limit",
428
+ });
396
429
  }
397
430
  throw error;
398
431
  }
@@ -414,7 +447,9 @@ export function createApp(deps: AppDependencies): Hono {
414
447
  throw new HTTPException(404, { message: "session not found" });
415
448
  }
416
449
  if (error instanceof SessionAuthorizationUnavailableError) {
417
- throw new HTTPException(503, { message: "session authorization is unavailable" });
450
+ throw new HTTPException(503, {
451
+ message: "session authorization is unavailable",
452
+ });
418
453
  }
419
454
  throw error;
420
455
  }
@@ -422,10 +457,15 @@ export function createApp(deps: AppDependencies): Hono {
422
457
  let toolspace: Awaited<ReturnType<typeof prepareToolspaceMcpSurface>> = null;
423
458
  if (toolspaceGrant) {
424
459
  try {
425
- toolspace = await prepareToolspaceMcpSurface({ deps: routeDeps, grant });
460
+ toolspace = await prepareToolspaceMcpSurface({
461
+ deps: routeDeps,
462
+ grant,
463
+ });
426
464
  } catch (error) {
427
465
  if (error instanceof McpPayloadTooLargeError) {
428
- throw new HTTPException(413, { message: "MCP tool list exceeds the safety limit" });
466
+ throw new HTTPException(413, {
467
+ message: "MCP tool list exceeds the safety limit",
468
+ });
429
469
  }
430
470
  throw error;
431
471
  }
@@ -458,6 +498,7 @@ export function createApp(deps: AppDependencies): Hono {
458
498
  registerInsightsRoutes(app, routeDeps);
459
499
  registerWorkspaceInstructionPolicyRoutes(app, routeDeps);
460
500
  registerWorkspaceStateRoutes(app, routeDeps);
501
+ registerWorkspaceArtifactRoutes(app, routeDeps);
461
502
  registerPreferenceRegistryRoutes(app, routeDeps);
462
503
  registerSocialRoutes(app, routeDeps);
463
504
  registerConnectionRoutes(app, routeDeps);
@@ -472,6 +513,7 @@ export function createApp(deps: AppDependencies): Hono {
472
513
  registerScheduledTaskRoutes(app, routeDeps);
473
514
  registerCodexRoutes(app, routeDeps);
474
515
  registerTranscriptionRoutes(app, routeDeps);
516
+ registerSlackInteractionRoutes(app, routeDeps);
475
517
 
476
518
  app.notFound((c) => {
477
519
  if (!new URL(c.req.url).pathname.startsWith("/v1/")) return c.text("Not Found", 404);
@@ -513,7 +555,18 @@ export function createApp(deps: AppDependencies): Hono {
513
555
  return c.json(envelope, status as ContentfulStatusCode);
514
556
  });
515
557
 
516
- return app;
558
+ return { app, routeDeps };
559
+ }
560
+
561
+ export function appendVary(current: string | null, value: string): string {
562
+ const values = (current ?? "")
563
+ .split(",")
564
+ .map((entry) => entry.trim())
565
+ .filter(Boolean);
566
+ if (!values.some((entry) => entry.toLowerCase() === value.toLowerCase())) {
567
+ values.push(value);
568
+ }
569
+ return values.join(", ");
517
570
  }
518
571
 
519
572
  async function requireMcpAccessGrant(
@@ -559,6 +612,31 @@ function clientAuthConfig(settings: AppDependencies["settings"]) {
559
612
  return { mode: "none" as const };
560
613
  }
561
614
 
615
+ function clientAnalyticsConfig(settings: AppDependencies["settings"]) {
616
+ if (!settings.analyticsEnabled) {
617
+ return { consentRequired: true, providers: {} };
618
+ }
619
+ return {
620
+ consentRequired: settings.analyticsConsentRequired,
621
+ providers: {
622
+ ...(settings.analyticsReoClientId
623
+ ? { reo: { clientId: settings.analyticsReoClientId } }
624
+ : {}),
625
+ ...(settings.analyticsPosthogProjectKey && settings.analyticsPosthogHost
626
+ ? {
627
+ posthog: {
628
+ projectKey: settings.analyticsPosthogProjectKey,
629
+ host: settings.analyticsPosthogHost,
630
+ },
631
+ }
632
+ : {}),
633
+ ...(settings.analyticsGa4MeasurementId
634
+ ? { ga4: { measurementId: settings.analyticsGa4MeasurementId } }
635
+ : {}),
636
+ },
637
+ };
638
+ }
639
+
562
640
  function structuredServicesHint(backend: string): {
563
641
  fileSystem: boolean;
564
642
  git: boolean;
@@ -694,7 +772,9 @@ async function runReadinessChecks<const Checks extends Readonly<Record<string, R
694
772
  }
695
773
  }),
696
774
  );
697
- const result = Object.fromEntries(entries) as { [Name in keyof Checks]: ReadinessCheckResult };
775
+ const result = Object.fromEntries(entries) as {
776
+ [Name in keyof Checks]: ReadinessCheckResult;
777
+ };
698
778
  return {
699
779
  ok: Object.values(result).every((check) => check.ok),
700
780
  checks: result,
@@ -1055,6 +1135,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
1055
1135
  pattern: /^\/v1\/integrations\/slack\/callback$/,
1056
1136
  label: "/v1/integrations/slack/callback",
1057
1137
  },
1138
+ {
1139
+ pattern: /^\/v1\/social\/oauth\/callback$/,
1140
+ label: "/v1/social/oauth/callback",
1141
+ },
1058
1142
  {
1059
1143
  pattern: /^\/v1\/enrollments\/device\/start$/,
1060
1144
  label: "/v1/enrollments/device/start",
@@ -1122,6 +1206,9 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
1122
1206
  pathname.startsWith("/v1/auth/") ||
1123
1207
  pathname.startsWith("/v1/webhooks/") ||
1124
1208
  pathname.startsWith("/v1/integrations/oauth/") ||
1209
+ pathname === "/v1/integrations/slack/events" ||
1210
+ pathname === "/v1/integrations/slack/commands" ||
1211
+ pathname === "/v1/integrations/slack/interactions" ||
1125
1212
  pathname.startsWith("/v1/github/") ||
1126
1213
  pathname === "/v1/enrollments/device/start" ||
1127
1214
  pathname === "/v1/enrollments/device/poll" ||
package/src/http/auth.ts CHANGED
@@ -50,10 +50,18 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
50
50
  if (
51
51
  path === "/v1/integrations/oauth/callback" ||
52
52
  path === "/v1/integrations/oauth/client-metadata.json" ||
53
- path === "/v1/integrations/slack/callback"
53
+ path === "/v1/integrations/slack/callback" ||
54
+ path === "/v1/integrations/slack/events" ||
55
+ path === "/v1/integrations/slack/commands" ||
56
+ path === "/v1/integrations/slack/interactions"
54
57
  ) {
55
58
  return true;
56
59
  }
60
+ // Social OAuth (X / Reddit) browser redirect: exact path only, protected by
61
+ // signed single-use state plus a callback-time grant recheck.
62
+ if (path === "/v1/social/oauth/callback") {
63
+ return true;
64
+ }
57
65
  // Catalog logos are rendered via bare <img> tags, which carry no credentials;
58
66
  // the images are public vendor logos, digest-keyed by content, and the route
59
67
  // itself enforces the catalog-assets/ prefix lock and extension whitelist.
package/src/index.ts CHANGED
@@ -30,10 +30,11 @@ import {
30
30
  WorkflowExecutionAlreadyStartedError,
31
31
  } from "@temporalio/client";
32
32
  import type { ScheduleOptions, ScheduleSpec, ScheduleUpdateOptions } from "@temporalio/client";
33
- import { createApp, type DocumentIndexClient, type SessionWorkflowClient } from "./app";
33
+ import { createAppComposition, type DocumentIndexClient, type SessionWorkflowClient } from "./app";
34
34
  import { observabilityEventLogger } from "./observability";
35
35
  import { startAuthCalloutResponder } from "./sandbox/auth-callout";
36
36
  import { startHelloIngestion, startMetricsIngestion } from "./sandbox/metrics-ingestion";
37
+ import { startSlackInteractionPump } from "./integrations/slack-interactions";
37
38
 
38
39
  /**
39
40
  * A REJECT_DUPLICATE start collides on the deterministic workflowId when the
@@ -310,7 +311,7 @@ export async function startApi() {
310
311
  await dbClient.close();
311
312
  throw new Error("OpenGeni API startup dependencies were not initialized");
312
313
  }
313
- const app = createApp({
314
+ const { app, routeDeps } = createAppComposition({
314
315
  settings,
315
316
  db: dbClient.db,
316
317
  bus,
@@ -327,6 +328,9 @@ export async function startApi() {
327
328
  idleTimeout: 255,
328
329
  fetch: app.fetch,
329
330
  });
331
+ const stopSlackInteractionPump = settings.slackSigningSecret
332
+ ? startSlackInteractionPump(routeDeps)
333
+ : undefined;
330
334
  // M10 — start the metrics-ingestion consumer (agent heartbeats → DB last-sample
331
335
  // + downsampled series), gated on the selfhosted flag. A no-op when disabled.
332
336
  let stopMetricsIngestion: (() => void) | undefined;
@@ -342,8 +346,16 @@ export async function startApi() {
342
346
  // user), separate from the privileged control-plane bus.
343
347
  let authCalloutResponder: ResponderConnection | undefined;
344
348
  if (settings.sandboxSelfhostedEnabled) {
345
- stopMetricsIngestion = startMetricsIngestion({ db: dbClient.db, bus, observability });
346
- stopHelloIngestion = startHelloIngestion({ db: dbClient.db, bus, observability });
349
+ stopMetricsIngestion = startMetricsIngestion({
350
+ db: dbClient.db,
351
+ bus,
352
+ observability,
353
+ });
354
+ stopHelloIngestion = startHelloIngestion({
355
+ db: dbClient.db,
356
+ bus,
357
+ observability,
358
+ });
347
359
  observability.info("OpenGeni machine-metrics + hello ingestion consumers started", {});
348
360
 
349
361
  const callout = resolveNatsCalloutConfig(settings);
@@ -375,6 +387,7 @@ export async function startApi() {
375
387
  server,
376
388
  close: async () => {
377
389
  server.stop(true);
390
+ stopSlackInteractionPump?.();
378
391
  stopMetricsIngestion?.();
379
392
  stopHelloIngestion?.();
380
393
  await Promise.allSettled([
@@ -5,6 +5,7 @@ import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/conf
5
5
  import {
6
6
  OPENGENI_PERSONAL_SLACK_MCP_URL,
7
7
  OAuthStartResponse,
8
+ selectCanonicalPersonalSlackConnection,
8
9
  type OAuthStartRequest,
9
10
  } from "@opengeni/contracts";
10
11
  import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
@@ -161,6 +162,8 @@ export async function startMcpOAuth(
161
162
  workspaceId: context.workspaceId,
162
163
  subjectId: context.subjectId,
163
164
  providerDomain,
165
+ mcpUrl,
166
+ personalSlack,
164
167
  connectionId: context.payload.connectionId,
165
168
  });
166
169
  if (context.payload.connectionId && !existing) {
@@ -847,6 +850,8 @@ async function existingOAuthConnectionForStart(
847
850
  workspaceId: string;
848
851
  subjectId: string;
849
852
  providerDomain: string;
853
+ mcpUrl: string;
854
+ personalSlack: boolean;
850
855
  connectionId?: string | undefined;
851
856
  },
852
857
  ) {
@@ -859,20 +864,23 @@ async function existingOAuthConnectionForStart(
859
864
  );
860
865
  return connection?.subjectId === input.subjectId &&
861
866
  connection.kind === "oauth2" &&
862
- connection.providerDomain === input.providerDomain
867
+ connection.providerDomain === input.providerDomain &&
868
+ (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl)
863
869
  ? connection
864
870
  : null;
865
871
  }
866
872
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
867
- return (
868
- visible.find(
869
- (connection) =>
870
- connection.subjectId === input.subjectId &&
871
- connection.kind === "oauth2" &&
872
- connection.status === "active" &&
873
- connection.providerDomain === input.providerDomain,
874
- ) ?? null
873
+ const matching = visible.filter(
874
+ (connection) =>
875
+ connection.subjectId === input.subjectId &&
876
+ connection.kind === "oauth2" &&
877
+ connection.providerDomain === input.providerDomain &&
878
+ (!input.personalSlack || connection.metadata.mcpUrl === input.mcpUrl),
875
879
  );
880
+ if (input.personalSlack) {
881
+ return selectCanonicalPersonalSlackConnection(matching);
882
+ }
883
+ return matching.find((connection) => connection.status === "active") ?? null;
876
884
  }
877
885
 
878
886
  function buildAuthorizationUrl(input: {
@@ -1300,6 +1308,13 @@ function callbackReturnPath(
1300
1308
  for (const [key, value] of Object.entries(params)) {
1301
1309
  url.searchParams.set(key, value);
1302
1310
  }
1311
+ // Defense in depth: a `//host` pathname becomes a protocol-relative absolute
1312
+ // Location — an open redirect from the unauthenticated callback.
1313
+ if (url.pathname.startsWith("//")) {
1314
+ const fallback = new URL("/integrations", "https://opengeni.local");
1315
+ fallback.search = url.search;
1316
+ return `${fallback.pathname}${fallback.search}`;
1317
+ }
1303
1318
  return `${url.pathname}${url.search}${url.hash}`;
1304
1319
  }
1305
1320
 
@@ -1357,7 +1372,9 @@ function safeReturnPath(value: string): string {
1357
1372
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
1358
1373
  }
1359
1374
  const parsed = new URL(value, "https://opengeni.local");
1360
- if (parsed.origin !== "https://opengeni.local") {
1375
+ // `..` segments can normalize back into a `//host` prefix, which browsers
1376
+ // resolve as a protocol-relative absolute URL. Reject the NORMALIZED path.
1377
+ if (parsed.origin !== "https://opengeni.local" || parsed.pathname.startsWith("//")) {
1361
1378
  throw new HTTPException(400, { message: "OAuth returnPath must be a relative path" });
1362
1379
  }
1363
1380
  return `${parsed.pathname}${parsed.search}${parsed.hash}`;