@opengeni/api-router 0.15.1 → 0.15.5

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  createAppComposition,
3
3
  startSlackInteractionPump
4
- } from "./chunk-UVL2KH56.js";
4
+ } from "./chunk-ICRZC3JH.js";
5
5
 
6
6
  // src/index.ts
7
7
  import {
@@ -44,7 +44,8 @@ type SlackBotContext = {
44
44
  };
45
45
  export declare class SlackBotProviderError extends Error {
46
46
  readonly code: string;
47
- constructor(code: string);
47
+ readonly retryAfterMs: number | null;
48
+ constructor(code: string, retryAfterMs?: number | null);
48
49
  }
49
50
  export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch";
50
51
  export declare class SlackBotCredentialVerificationError extends HTTPException {
@@ -86,6 +87,7 @@ export declare class OpenGeniSlackBotClient {
86
87
  name: string;
87
88
  isPrivate: boolean;
88
89
  isMember: boolean;
90
+ isDirectMessage: boolean;
89
91
  isArchived: boolean;
90
92
  topic: string;
91
93
  purpose: string;
@@ -100,6 +102,7 @@ export declare class OpenGeniSlackBotClient {
100
102
  name: string;
101
103
  isPrivate: boolean;
102
104
  isMember: boolean;
105
+ isDirectMessage: boolean;
103
106
  isArchived: boolean;
104
107
  topic: string;
105
108
  purpose: string;
@@ -115,6 +118,7 @@ export declare class OpenGeniSlackBotClient {
115
118
  name: string;
116
119
  isPrivate: boolean;
117
120
  isMember: boolean;
121
+ isDirectMessage: boolean;
118
122
  isArchived: boolean;
119
123
  topic: string;
120
124
  purpose: string;
@@ -153,6 +157,7 @@ export declare class OpenGeniSlackBotClient {
153
157
  name: string;
154
158
  isPrivate: boolean;
155
159
  isMember: boolean;
160
+ isDirectMessage: boolean;
156
161
  isArchived: boolean;
157
162
  topic: string;
158
163
  purpose: string;
@@ -207,6 +212,7 @@ export declare class OpenGeniSlackBotClient {
207
212
  name: string;
208
213
  isPrivate: boolean;
209
214
  isMember: boolean;
215
+ isDirectMessage: boolean;
210
216
  isArchived: boolean;
211
217
  topic: string;
212
218
  purpose: string;
@@ -237,6 +243,7 @@ export declare class OpenGeniSlackBotClient {
237
243
  name: string;
238
244
  isPrivate: boolean;
239
245
  isMember: boolean;
246
+ isDirectMessage: boolean;
240
247
  isArchived: boolean;
241
248
  topic: string;
242
249
  purpose: string;
@@ -267,6 +274,7 @@ export declare class OpenGeniSlackBotClient {
267
274
  name: string;
268
275
  isPrivate: boolean;
269
276
  isMember: boolean;
277
+ isDirectMessage: boolean;
270
278
  isArchived: boolean;
271
279
  topic: string;
272
280
  purpose: string;
@@ -1,10 +1,17 @@
1
- import { type SlackInstallationRoute, type SlackInteractionTriggerKind } from "@opengeni/db";
1
+ import { type SlackInstallationRoute, type SlackInteractionInboxEntry, type SlackInteractionTriggerKind } from "@opengeni/db";
2
2
  import { type ApiRouteDeps } from "@opengeni/core";
3
3
  import type { Hono } from "hono";
4
4
  export declare const SLACK_INTERACTION_MAX_BODY_BYTES: number;
5
5
  export declare const SLACK_SIGNATURE_REPLAY_WINDOW_SECONDS = 300;
6
6
  export declare const SLACK_DELIVERY_EVENT_TYPES: readonly ["agent.message.completed", "session.humanInput.requested", "turn.completed", "turn.failed", "turn.cancelled", "session.status.changed"];
7
7
  export declare const SLACK_TASK_INSTRUCTIONS: string;
8
+ /**
9
+ * Slack-originated tasks may retrieve the workspace bot's bounded read surface
10
+ * on demand. Connector tools are explicit-only, so freeze that narrow context
11
+ * selection at session creation while keeping Slack mutations out of the model
12
+ * surface; interaction delivery remains owned by the durable delivery pump.
13
+ */
14
+ export declare const SLACK_TASK_FIRST_PARTY_MCP_TOOLS: ("artifacts_create" | "artifacts_get_source" | "artifacts_list" | "artifacts_publish" | "artifacts_rollback" | "environment_list" | "environment_set_variable" | "github_connect_link" | "github_repositories_list" | "github_token" | "goal_complete" | "goal_pause" | "goal_set" | "goal_update" | "memory_correct" | "memory_save" | "memory_search" | "preference_registry_get" | "preference_registry_summary" | "rig_get" | "rig_list" | "rig_promote" | "rig_propose_change" | "rig_verify" | "run_on" | "sandbox_attach" | "sandbox_provision" | "sandbox_swap" | "sandboxes_list" | "scheduled_task_runs_list" | "scheduled_tasks_create" | "scheduled_tasks_delete" | "scheduled_tasks_get" | "scheduled_tasks_list" | "scheduled_tasks_pause" | "scheduled_tasks_resume" | "scheduled_tasks_trigger" | "scheduled_tasks_update" | "session_create" | "session_events" | "session_get" | "session_pause" | "session_resume" | "session_send_message" | "session_steer" | "sessions_list" | "set_other_session_title" | "set_session_title" | "slack_bot_channel_history" | "slack_bot_delete_message" | "slack_bot_file_content" | "slack_bot_file_info" | "slack_bot_list_channels" | "slack_bot_list_files" | "slack_bot_list_users" | "slack_bot_post_message" | "slack_bot_thread_replies" | "social_connections_list" | "social_daily_analysis_context" | "social_mentions_live" | "social_post_reply" | "social_posts_recent" | "social_posts_sync" | "social_search_live" | "social_thread_fetch" | "variable_set_list" | "variable_set_set_variable")[];
8
15
  export type NormalizedSlackInteraction = {
9
16
  providerEventId: string;
10
17
  providerMessageId: string;
@@ -28,3 +35,13 @@ export declare function startSlackInteractionPump(deps: ApiRouteDeps, options?:
28
35
  intervalMs?: number;
29
36
  maxPerTick?: number;
30
37
  }): () => void;
38
+ type SlackUserLinkToken = {
39
+ workspaceId: string;
40
+ connectionId: string;
41
+ slackTeamId: string;
42
+ slackUserId: string;
43
+ expiresAt: number;
44
+ };
45
+ export declare function createSlackUserLinkToken(signingSecret: string, entry: Pick<SlackInteractionInboxEntry, "workspaceId" | "connectionId" | "slackTeamId" | "slackUserId">, nowMs?: number): string;
46
+ export declare function verifySlackUserLinkToken(signingSecret: string, token: string, nowMs?: number): SlackUserLinkToken | null;
47
+ export {};
@@ -0,0 +1,91 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { SocialConnection } from "@opengeni/contracts";
3
+ import type { Observability } from "@opengeni/observability";
4
+ import type { Database } from "@opengeni/db";
5
+ import { type SocialProviderFetch } from "./social-oauth";
6
+ export type SocialLivePost = {
7
+ id: string;
8
+ provider: "x" | "reddit";
9
+ url: string | null;
10
+ author: string | null;
11
+ text: string;
12
+ createdAt: string | null;
13
+ metrics: Record<string, number>;
14
+ context: Record<string, string>;
15
+ };
16
+ type SocialApiDeps = {
17
+ db: Database;
18
+ settings: Settings;
19
+ observability?: Observability | undefined;
20
+ providerFetch?: SocialProviderFetch | undefined;
21
+ };
22
+ type ConnectionRef = {
23
+ workspaceId: string;
24
+ connectionId: string;
25
+ };
26
+ export declare function socialSearchLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
27
+ query: string;
28
+ limit?: number | undefined;
29
+ subreddit?: string | undefined;
30
+ }): Promise<{
31
+ connection: SocialConnection;
32
+ posts: SocialLivePost[];
33
+ }>;
34
+ export declare function socialMentionsLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
35
+ limit?: number | undefined;
36
+ sinceId?: string | undefined;
37
+ }): Promise<{
38
+ connection: SocialConnection;
39
+ posts: SocialLivePost[];
40
+ }>;
41
+ export declare function socialThreadLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
42
+ id: string;
43
+ limit?: number | undefined;
44
+ }): Promise<{
45
+ connection: SocialConnection;
46
+ posts: SocialLivePost[];
47
+ }>;
48
+ export declare function socialOwnPostsLive(deps: SocialApiDeps, ref: ConnectionRef, input: {
49
+ limit?: number | undefined;
50
+ }): Promise<{
51
+ connection: SocialConnection;
52
+ posts: SocialLivePost[];
53
+ }>;
54
+ export declare function socialPostReply(deps: SocialApiDeps, ref: ConnectionRef, input: {
55
+ inReplyToId: string;
56
+ text: string;
57
+ }): Promise<{
58
+ connection: SocialConnection;
59
+ postedId: string | null;
60
+ url: string | null;
61
+ }>;
62
+ /**
63
+ * Reddit write endpoints address targets by fullname (t3_xxx post, t1_xxx
64
+ * comment). Bare ids are ambiguous, so require the caller to be explicit.
65
+ */
66
+ export declare function redditThingId(id: string): string;
67
+ /**
68
+ * Agent-controlled values that land in a provider URL PATH must be shape-
69
+ * validated: encodeURIComponent leaves `.` intact, so `..` would collapse a
70
+ * path segment and select a different endpoint on the same host.
71
+ */
72
+ export declare function redditSubredditName(value: string): string;
73
+ export declare function redditArticleId(value: string): string;
74
+ /**
75
+ * Joins a provider-supplied path against the provider origin and refuses
76
+ * results that escape it (e.g. a hostile `@evil.com/x` or `//evil.com`
77
+ * permalink), so mapper output URLs can be trusted downstream.
78
+ */
79
+ export declare function redditUrl(path: string): string | null;
80
+ export declare function mapXTweets(payload: Record<string, unknown>, fallbackAuthor?: string): SocialLivePost[];
81
+ export declare function mapRedditListing(payload: Record<string, unknown>): SocialLivePost[];
82
+ /**
83
+ * /comments/{article} returns [post listing, comment listing]; flatten the
84
+ * submission first, then its comment tree in order.
85
+ */
86
+ export declare function mapRedditThread(payload: Record<string, unknown>): SocialLivePost[];
87
+ export declare function redditCommentFromApiJson(payload: Record<string, unknown>): {
88
+ id: string | null;
89
+ url: string | null;
90
+ };
91
+ export {};
@@ -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 {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.15.1",
3
+ "version": "0.15.5",
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.8.1",
48
- "@opengeni/contracts": "^0.27.0",
49
- "@opengeni/core": "^0.15.1",
50
- "@opengeni/db": "^0.17.1",
51
- "@opengeni/documents": "^0.2.61",
52
- "@opengeni/events": "^0.3.52",
53
- "@opengeni/github": "^0.4.11",
47
+ "@opengeni/config": "^0.9.1",
48
+ "@opengeni/contracts": "^0.28.1",
49
+ "@opengeni/core": "^0.16.2",
50
+ "@opengeni/db": "^0.19.0",
51
+ "@opengeni/documents": "^0.2.64",
52
+ "@opengeni/events": "^0.3.55",
53
+ "@opengeni/github": "^0.4.13",
54
54
  "@opengeni/network": "^0.1.1",
55
55
  "@opengeni/observability": "^0.3.0",
56
- "@opengeni/runtime": "^0.15.1",
57
- "@opengeni/storage": "^0.2.48",
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";
@@ -233,6 +234,20 @@ export function createAppComposition(deps: AppDependencies): {
233
234
  }),
234
235
  );
235
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
+
236
251
  app.use("*", async (c, next) => {
237
252
  const url = new URL(c.req.url);
238
253
  const route = routeLabel(url.pathname);
@@ -392,6 +407,7 @@ export function createAppComposition(deps: AppDependencies): {
392
407
  },
393
408
  productAccessMode: deps.settings.productAccessMode,
394
409
  auth: clientAuthConfig(deps.settings),
410
+ analytics: clientAnalyticsConfig(deps.settings),
395
411
  // Channel-A structured services (P4.4) ride exec/readFile/createEditor,
396
412
  // available on every real backend; `none` has no box so they are all off.
397
413
  // Per-session availability is still negotiated on /stream-capabilities.
@@ -542,6 +558,17 @@ export function createAppComposition(deps: AppDependencies): {
542
558
  return { app, routeDeps };
543
559
  }
544
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(", ");
570
+ }
571
+
545
572
  async function requireMcpAccessGrant(
546
573
  c: Parameters<typeof requireAccessGrant>[0],
547
574
  deps: ApiRouteDeps,
@@ -585,6 +612,31 @@ function clientAuthConfig(settings: AppDependencies["settings"]) {
585
612
  return { mode: "none" as const };
586
613
  }
587
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
+
588
640
  function structuredServicesHint(backend: string): {
589
641
  fileSystem: boolean;
590
642
  git: boolean;
@@ -1083,6 +1135,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
1083
1135
  pattern: /^\/v1\/integrations\/slack\/callback$/,
1084
1136
  label: "/v1/integrations/slack/callback",
1085
1137
  },
1138
+ {
1139
+ pattern: /^\/v1\/social\/oauth\/callback$/,
1140
+ label: "/v1/social/oauth/callback",
1141
+ },
1086
1142
  {
1087
1143
  pattern: /^\/v1\/enrollments\/device\/start$/,
1088
1144
  label: "/v1/enrollments/device/start",
package/src/http/auth.ts CHANGED
@@ -57,6 +57,11 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
57
57
  ) {
58
58
  return true;
59
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
+ }
60
65
  // Catalog logos are rendered via bare <img> tags, which carry no credentials;
61
66
  // the images are public vendor logos, digest-keyed by content, and the route
62
67
  // itself enforces the catalog-assets/ prefix lock and extension whitelist.
@@ -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}`;
@@ -153,7 +153,10 @@ type SlackBotContext = {
153
153
  };
154
154
 
155
155
  export class SlackBotProviderError extends Error {
156
- constructor(readonly code: string) {
156
+ constructor(
157
+ readonly code: string,
158
+ readonly retryAfterMs: number | null = null,
159
+ ) {
157
160
  super(`Slack bot request failed: ${safeSlackCode(code)}`);
158
161
  this.name = "SlackBotProviderError";
159
162
  }
@@ -735,7 +738,10 @@ export class OpenGeniSlackBotClient {
735
738
  channel: channelId,
736
739
  });
737
740
  const projected = projectChannel(payload.channel);
738
- if (!projected || projected.isMember !== true) {
741
+ // Slack omits `is_member` for a bot's one-to-one App Home conversation.
742
+ // A successful conversations.info response with `is_im` is itself proof
743
+ // that this installation can address that direct conversation.
744
+ if (!projected || (projected.isMember !== true && projected.isDirectMessage !== true)) {
739
745
  throw new SlackBotProviderError("not_in_channel");
740
746
  }
741
747
  return projected;
@@ -1186,8 +1192,9 @@ async function slackApiFetchWithHeaders(
1186
1192
  throw new SlackBotProviderError("transport_error");
1187
1193
  }
1188
1194
  if (!response.ok) {
1195
+ const retryAfterMs = slackRetryAfterMs(response);
1189
1196
  await response.body?.cancel().catch(() => undefined);
1190
- throw new SlackBotProviderError(`http_${response.status}`);
1197
+ throw new SlackBotProviderError(`http_${response.status}`, retryAfterMs);
1191
1198
  }
1192
1199
  let payload: SlackPayload;
1193
1200
  try {
@@ -1205,6 +1212,15 @@ async function slackApiFetchWithHeaders(
1205
1212
  return { response, payload };
1206
1213
  }
1207
1214
 
1215
+ function slackRetryAfterMs(response: Response): number | null {
1216
+ if (response.status !== 429) return null;
1217
+ const raw = response.headers.get("retry-after");
1218
+ if (!raw || !/^\d+$/.test(raw)) return null;
1219
+ const seconds = Number(raw);
1220
+ if (!Number.isSafeInteger(seconds) || seconds < 1) return null;
1221
+ return Math.min(seconds, 3_600) * 1_000;
1222
+ }
1223
+
1208
1224
  function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void {
1209
1225
  const policy = evaluateOpenGeniSlackBotScopes(grantedScopes);
1210
1226
  if (!policy.accepted) {
@@ -1245,6 +1261,7 @@ function projectChannel(value: unknown) {
1245
1261
  name: boundedSlackString(channel.name, 256),
1246
1262
  isPrivate: channel.is_private === true,
1247
1263
  isMember: channel.is_member === true,
1264
+ isDirectMessage: channel.is_im === true,
1248
1265
  isArchived: channel.is_archived === true,
1249
1266
  topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024),
1250
1267
  purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024),