@opengeni/api-router 0.30.3 → 2.2.0-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.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Per-channel and per-DM Slack workspace routing.
3
+ *
4
+ * Routing is decided BEFORE any agent runs, from durable facts only, so it can
5
+ * never be a model judgement. The resolver is pure with respect to the database
6
+ * reads it is handed: it returns a decision, and the caller persists and acts on
7
+ * it.
8
+ *
9
+ * Two rules are load-bearing and easy to break by accident:
10
+ *
11
+ * A mapped thread keeps the workspace it was created in, unconditionally,
12
+ * including after the channel has been re-pointed. Otherwise a live
13
+ * conversation would silently change tenants mid-thread.
14
+ *
15
+ * A subject who lacks access to the routed workspace is NEVER quietly served
16
+ * from another workspace they happen to belong to. Every failure is explicit
17
+ * and creates no session.
18
+ */
19
+ import type { AccessGrant } from "@opengeni/contracts";
20
+ import type {
21
+ SlackChannelRoute,
22
+ SlackInteractionInboxEntry,
23
+ SlackRoutableWorkspace,
24
+ SlackUserDmRoute,
25
+ } from "@opengeni/db";
26
+
27
+ export type SlackRouteTenancy = { accountId: string; workspaceId: string };
28
+
29
+ export type SlackRouteResolution =
30
+ | (SlackRouteTenancy & {
31
+ kind: "resolved";
32
+ label: string | null;
33
+ source:
34
+ | "thread"
35
+ | "prefix"
36
+ | "channel"
37
+ | "dm_route"
38
+ | "dm_personal"
39
+ | "sole_candidate"
40
+ | "installation";
41
+ })
42
+ | { kind: "ask"; candidates: readonly SlackRoutableWorkspace[] }
43
+ | {
44
+ kind: "denied";
45
+ reason: "no_access_to_named" | "no_access_to_route" | "no_candidates";
46
+ requested: string | null;
47
+ candidates: readonly SlackRoutableWorkspace[];
48
+ };
49
+
50
+ /**
51
+ * A Slack direct message to the bot, by any trigger that can carry one.
52
+ *
53
+ * `isDirectMessageShortcut` is deliberately narrower: it means "a message
54
+ * shortcut invoked inside a DM", which is a private-handoff concern. Routing
55
+ * cares about the broader question of whether this conversation is one human's
56
+ * private channel with the bot, which is also true of an ordinary `dm` event.
57
+ */
58
+ export function isSlackDirectMessageConversation(
59
+ entry: Pick<SlackInteractionInboxEntry, "triggerKind" | "slackChannelId">,
60
+ ): boolean {
61
+ return entry.triggerKind === "dm" || entry.slackChannelId.startsWith("D");
62
+ }
63
+
64
+ const PREFIX = "in ";
65
+
66
+ /**
67
+ * Trigger kinds whose text the invoking human wrote themselves. A message
68
+ * shortcut and a reaction both act on another person's message.
69
+ */
70
+ const AUTHORED_BY_INVOKER: ReadonlySet<string> = new Set([
71
+ "app_mention",
72
+ "dm",
73
+ "slash_command",
74
+ "thread_reply",
75
+ ]);
76
+
77
+ /**
78
+ * Split a leading bot mention off the message text.
79
+ *
80
+ * Slack delivers an `app_mention` with the mention still in the text
81
+ * (`<@U123> do the thing`), and OpenGeni stores it verbatim. The workspace
82
+ * prefix is only an override when it is the first thing the person typed, so it
83
+ * is parsed after the mention rather than at byte 0 of the raw text. Everything
84
+ * split off here is put back, so the message the model sees is unchanged apart
85
+ * from the addressing the person used to route it.
86
+ */
87
+ export function splitSlackLeadingMention(
88
+ text: string,
89
+ botUserId: string | null,
90
+ ): { lead: string; rest: string } {
91
+ if (!botUserId || !/^[UWB][A-Z0-9]{1,63}$/u.test(botUserId)) return { lead: "", rest: text };
92
+ const mention = `<@${botUserId}>`;
93
+ if (!text.startsWith(mention)) return { lead: "", rest: text };
94
+ const remainder = text.slice(mention.length);
95
+ const trimmed = remainder.replace(/^[ \t]+/u, "");
96
+ return { lead: text.slice(0, text.length - trimmed.length), rest: trimmed };
97
+ }
98
+
99
+ /**
100
+ * The strict `in <workspace>: ...` override.
101
+ *
102
+ * Parsed only at byte 0, so ordinary prose that happens to contain the word
103
+ * cannot trigger it, and matched case-insensitively against the exact label of a
104
+ * workspace the subject can already start work in. A prefix that names nothing
105
+ * recognizable is NOT a suggestion that falls through: it is a refusal, because
106
+ * silently ignoring an explicit override is how a message lands somewhere the
107
+ * person did not intend.
108
+ */
109
+ export function parseSlackWorkspacePrefix(text: string): {
110
+ requested: string;
111
+ remainder: string;
112
+ } | null {
113
+ if (!text.toLowerCase().startsWith(PREFIX)) return null;
114
+ const separator = text.indexOf(":");
115
+ if (separator <= PREFIX.length) return null;
116
+ const requested = text.slice(PREFIX.length, separator).trim();
117
+ if (requested.length === 0) return null;
118
+ // A bare address with nothing after it is not a request.
119
+ if (text.slice(separator + 1).trim().length === 0) return null;
120
+ // A label is one line. A colon further down a multi-line message is not a
121
+ // prefix, it is punctuation.
122
+ if (/[\r\n]/u.test(requested)) return null;
123
+ return { requested, remainder: text.slice(separator + 1).replace(/^[ \t]+/u, "") };
124
+ }
125
+
126
+ function matchCandidate(
127
+ candidates: readonly SlackRoutableWorkspace[],
128
+ requested: string,
129
+ ): SlackRoutableWorkspace | null | "ambiguous" {
130
+ const wanted = requested.toLowerCase();
131
+ const matches = candidates.filter((candidate) => candidate.label.toLowerCase() === wanted);
132
+ // Two workspaces whose labels differ only by case are not a tie to break
133
+ // silently: guessing one is the same failure as ignoring the override.
134
+ if (matches.length > 1) return "ambiguous";
135
+ return matches[0] ?? null;
136
+ }
137
+
138
+ export type SlackRouteInputs = {
139
+ /** The installation binding's tenancy. Always the fallback, never a silent one. */
140
+ home: SlackRouteTenancy;
141
+ entry: Pick<
142
+ SlackInteractionInboxEntry,
143
+ "triggerKind" | "slackChannelId" | "slackUserId" | "text"
144
+ >;
145
+ /**
146
+ * The tenancy of an interaction already mapped to this thread, if any. A
147
+ * mapped thread wins unconditionally.
148
+ */
149
+ threadTenancy: SlackRouteTenancy | null;
150
+ channelRoute: SlackChannelRoute | null;
151
+ dmRoute: SlackUserDmRoute | null;
152
+ /** The subject's own personal workspace in the home organization, if any. */
153
+ personalWorkspaceId: string | null;
154
+ /** Workspaces this subject may actually start work in, ordered stably. */
155
+ candidates: readonly SlackRoutableWorkspace[];
156
+ /** The installation's bot user, so a mention does not hide the prefix. */
157
+ botUserId: string | null;
158
+ /** `OPENGENI_SLACK_WORKSPACE_ROUTING_ENABLED`. */
159
+ routingEnabled: boolean;
160
+ /** Whether the first-use picker exists yet. Until it does, ambiguity keeps home. */
161
+ askEnabled: boolean;
162
+ };
163
+
164
+ function labelFor(
165
+ candidates: readonly SlackRoutableWorkspace[],
166
+ workspaceId: string,
167
+ ): string | null {
168
+ return candidates.find((candidate) => candidate.workspaceId === workspaceId)?.label ?? null;
169
+ }
170
+
171
+ /**
172
+ * Decide which workspace this Slack message starts work in.
173
+ *
174
+ * First match wins, and the order is deliberate: continuity beats an explicit
175
+ * override beats configuration beats derivation beats asking.
176
+ */
177
+ export function resolveSlackWorkspaceRoute(input: SlackRouteInputs): SlackRouteResolution {
178
+ // A personal workspace is only ever a destination for that person's own bot
179
+ // DM. Offering it in a channel would be wrong twice over: routing a shared
180
+ // conversation into one member's private space hides it from everyone else
181
+ // in the channel, and - because managed tenancy provisions a personal
182
+ // workspace for every member - counting it as a candidate means nobody ever
183
+ // has exactly one. That defeats the sole-candidate rule below, so an
184
+ // organization with a single shared workspace would be asked to choose in
185
+ // every channel despite having no choice to make.
186
+ const directMessage = isSlackDirectMessageConversation(input.entry);
187
+ const candidates = directMessage
188
+ ? input.candidates
189
+ : input.candidates.filter((candidate) => !candidate.personal);
190
+
191
+ const installation = {
192
+ kind: "resolved" as const,
193
+ accountId: input.home.accountId,
194
+ workspaceId: input.home.workspaceId,
195
+ label: labelFor(candidates, input.home.workspaceId),
196
+ source: "installation" as const,
197
+ };
198
+
199
+ // 0. With routing off no routing read is consulted, so an existing install is
200
+ // byte-identical. A thread that a previous flag-on window already mapped is
201
+ // still honoured, because its interaction, session and events genuinely
202
+ // live there: turning the flag off must stop new routing, not strand a
203
+ // conversation by addressing it in a workspace it is not in.
204
+ if (!input.routingEnabled) {
205
+ return input.threadTenancy
206
+ ? {
207
+ kind: "resolved",
208
+ accountId: input.threadTenancy.accountId,
209
+ workspaceId: input.threadTenancy.workspaceId,
210
+ label: null,
211
+ source: "thread",
212
+ }
213
+ : installation;
214
+ }
215
+
216
+ // 1. A mapped thread keeps its workspace, even if the channel moved since.
217
+ if (input.threadTenancy) {
218
+ return {
219
+ kind: "resolved",
220
+ accountId: input.threadTenancy.accountId,
221
+ workspaceId: input.threadTenancy.workspaceId,
222
+ label: labelFor(candidates, input.threadTenancy.workspaceId),
223
+ source: "thread",
224
+ };
225
+ }
226
+
227
+ // 2. The strict prefix override. It applies to this message only and never
228
+ // writes a route row: an override is not a decision about the channel.
229
+ //
230
+ // Only text the invoking human actually typed can address anything. A
231
+ // message shortcut and a reaction both carry SOMEONE ELSE'S message, so a
232
+ // prefix found there was never an instruction to OpenGeni.
233
+ const prefix = AUTHORED_BY_INVOKER.has(input.entry.triggerKind)
234
+ ? parseSlackWorkspacePrefix(splitSlackLeadingMention(input.entry.text, input.botUserId).rest)
235
+ : null;
236
+ if (prefix) {
237
+ const named = matchCandidate(candidates, prefix.requested);
238
+ if (named === "ambiguous" || !named) {
239
+ return {
240
+ kind: "denied",
241
+ reason: "no_access_to_named",
242
+ requested: prefix.requested,
243
+ candidates: candidates,
244
+ };
245
+ }
246
+ return {
247
+ kind: "resolved",
248
+ accountId: named.accountId,
249
+ workspaceId: named.workspaceId,
250
+ label: named.label,
251
+ source: "prefix",
252
+ };
253
+ }
254
+
255
+ // 3. The channel's remembered answer.
256
+ if (input.channelRoute) {
257
+ return {
258
+ kind: "resolved",
259
+ accountId: input.channelRoute.targetAccountId,
260
+ workspaceId: input.channelRoute.targetWorkspaceId,
261
+ label: labelFor(candidates, input.channelRoute.targetWorkspaceId),
262
+ source: "channel",
263
+ };
264
+ }
265
+
266
+ // 4. A direct message is this human's own conversation with the bot, so it
267
+ // lands in their own workspace unless they chose otherwise. The personal
268
+ // workspace id is DERIVED from an active organization membership pointer;
269
+ // it is never accepted from a Slack payload or a route row.
270
+ if (directMessage) {
271
+ if (input.dmRoute) {
272
+ return {
273
+ kind: "resolved",
274
+ accountId: input.dmRoute.targetAccountId,
275
+ workspaceId: input.dmRoute.targetWorkspaceId,
276
+ label: labelFor(candidates, input.dmRoute.targetWorkspaceId),
277
+ source: "dm_route",
278
+ };
279
+ }
280
+ if (input.personalWorkspaceId) {
281
+ return {
282
+ kind: "resolved",
283
+ accountId: input.home.accountId,
284
+ workspaceId: input.personalWorkspaceId,
285
+ label: labelFor(candidates, input.personalWorkspaceId),
286
+ source: "dm_personal",
287
+ };
288
+ }
289
+ // No workspace of their own is not the same as no workspace at all. Fall
290
+ // through to the ordinary rules so a member of exactly one shared workspace
291
+ // can still work in their bot DM.
292
+ }
293
+
294
+ // 5. One workspace is not a choice. This is what keeps the flag quiet for
295
+ // installs that only ever had one workspace.
296
+ const sole = candidates[0];
297
+ if (candidates.length === 1 && sole) {
298
+ return {
299
+ kind: "resolved",
300
+ accountId: sole.accountId,
301
+ workspaceId: sole.workspaceId,
302
+ label: sole.label,
303
+ source: "sole_candidate",
304
+ };
305
+ }
306
+
307
+ if (candidates.length === 0) {
308
+ return { kind: "denied", reason: "no_candidates", requested: null, candidates: [] };
309
+ }
310
+
311
+ // 6. Genuinely ambiguous. Until the picker exists, keep the installation's
312
+ // workspace rather than inventing an answer.
313
+ return input.askEnabled ? { kind: "ask", candidates: candidates } : installation;
314
+ }
315
+
316
+ /**
317
+ * The message text the model actually sees.
318
+ *
319
+ * A prefix override is addressing information, not part of the request, so it is
320
+ * stripped exactly once and only when it matched.
321
+ */
322
+ export function slackRoutedRequestText(
323
+ text: string,
324
+ resolution: SlackRouteResolution,
325
+ botUserId: string | null,
326
+ ): string {
327
+ if (resolution.kind !== "resolved" || resolution.source !== "prefix") return text;
328
+ const { lead, rest } = splitSlackLeadingMention(text, botUserId);
329
+ // Reached only for a `prefix` resolution, which the authorship rule above
330
+ // already restricted to text the invoking human wrote.
331
+ const parsed = parseSlackWorkspacePrefix(rest);
332
+ return parsed ? `${lead}${parsed.remainder}` : text;
333
+ }
334
+
335
+ export type SlackRouteAuthorization =
336
+ | { kind: "authorized"; grant: AccessGrant; tenancy: SlackRouteTenancy; label: string | null }
337
+ | { kind: "denied"; reason: "no_access_to_route"; tenancy: SlackRouteTenancy };
@@ -22,6 +22,12 @@ import { OAUTH_MAX_RESPONSE_BYTES, pinnedFetch, readResponseJsonBounded } from "
22
22
  import { Buffer } from "node:buffer";
23
23
  import { createHash, randomBytes } from "node:crypto";
24
24
  import { HTTPException } from "hono/http-exception";
25
+ import {
26
+ personalOwnerStateAccepted,
27
+ personalOwnerVerifiedInState,
28
+ PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
29
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
30
+ } from "../connection-ownership";
25
31
  import { ApiHttpError } from "../http/api-error";
26
32
  import {
27
33
  integrationBaseUrl,
@@ -132,6 +138,11 @@ export type SocialOAuthStartContext = {
132
138
  accountId: string;
133
139
  workspaceId: string;
134
140
  subjectId: string;
141
+ /**
142
+ * False for every principal that cannot own a personal Connection. Resolved
143
+ * by the route from the live authenticated principal, never inferred here.
144
+ */
145
+ personalOwnershipAllowed: boolean;
135
146
  requestUrl: string;
136
147
  payload: SocialOAuthStartRequest;
137
148
  };
@@ -142,6 +153,8 @@ type SocialOAuthStatePayload = {
142
153
  workspaceId: string;
143
154
  subjectId: string;
144
155
  ownership: "workspace" | "personal";
156
+ /** Signed proof that a live managed human authorized personal ownership. */
157
+ personalOwnerVerified: boolean;
145
158
  provider: SocialOAuthProviderId;
146
159
  scopes: string[];
147
160
  encryptedPkceVerifier?: string;
@@ -192,6 +205,9 @@ export async function startSocialOAuth(
192
205
  workspaceId: context.workspaceId,
193
206
  subjectId: context.subjectId,
194
207
  ownership: context.payload.ownership,
208
+ // Signed record that a live principal was checked; the callback has no
209
+ // principal of its own and enforces exactly this decision.
210
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
195
211
  provider: provider.id,
196
212
  scopes,
197
213
  ...(verifier && key ? { encryptedPkceVerifier: encryptEnvironmentValue(key, verifier) } : {}),
@@ -274,6 +290,13 @@ export async function completeSocialOAuthCallback(
274
290
  // the MCP OAuth client — a grant revoked inside the state TTL must not be
275
291
  // able to land a workspace credential.
276
292
  try {
293
+ // Only a managed human may own a personal Connection. This callback has no
294
+ // live principal, so it enforces the signed start-time decision; a state
295
+ // minted before that fence existed carries no claim and is refused, which
296
+ // closes the in-flight window across a rolling deploy.
297
+ if (!personalOwnerStateAccepted(state)) {
298
+ throw new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE });
299
+ }
277
300
  const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
278
301
  if (
279
302
  !grant ||
@@ -687,6 +710,8 @@ function readSocialOAuthState(
687
710
  workspaceId: requiredStateString(payload.workspaceId, "workspaceId"),
688
711
  subjectId: requiredStateString(payload.subjectId, "subjectId"),
689
712
  ownership: payload.ownership === "personal" ? "personal" : "workspace",
713
+ // Absent on a legacy state, which therefore cannot land a personal owner.
714
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
690
715
  provider,
691
716
  scopes: Array.isArray(payload.scopes)
692
717
  ? payload.scopes.filter((scope): scope is string => typeof scope === "string")