@opengeni/api-router 0.14.3 → 0.15.1

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/src/app.ts CHANGED
@@ -68,11 +68,13 @@ import { registerSocialRoutes } from "./routes/social";
68
68
  import { registerWorkspaceRoutes } from "./routes/workspaces";
69
69
  import { registerWorkspaceInstructionPolicyRoutes } from "./routes/workspace-instruction-policies";
70
70
  import { registerWorkspaceStateRoutes } from "./routes/workspace-state";
71
+ import { registerWorkspaceArtifactRoutes } from "./routes/workspace-artifacts";
71
72
  import { registerPreferenceRegistryRoutes } from "./routes/preference-registry";
72
73
  import { registerInsightsRoutes } from "./routes/insights";
73
74
  import { registerTranscriptionRoutes } from "./routes/transcriptions";
74
75
  import { projectClientModel } from "./model-catalog";
75
76
  import { createTranscriptionService } from "./transcription/service";
77
+ import { registerSlackInteractionRoutes } from "./integrations/slack-interactions";
76
78
 
77
79
  export type {
78
80
  ApiRouteDeps,
@@ -104,6 +106,13 @@ export function apiRequestBodyLimitBytes(settings: { voiceInputMaxSizeBytes: num
104
106
  const API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
105
107
 
106
108
  export function createApp(deps: AppDependencies): Hono {
109
+ return createAppComposition(deps).app;
110
+ }
111
+
112
+ export function createAppComposition(deps: AppDependencies): {
113
+ app: Hono;
114
+ routeDeps: ApiRouteDeps;
115
+ } {
107
116
  const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
108
117
  const objectStorage =
109
118
  deps.objectStorage === undefined ? createObjectStorage(deps.settings) : deps.objectStorage;
@@ -186,28 +195,34 @@ export function createApp(deps: AppDependencies): Hono {
186
195
  await next();
187
196
  });
188
197
 
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
- );
198
+ const corsHeaders = {
199
+ allowHeaders: [
200
+ "Accept",
201
+ "Authorization",
202
+ "Content-Type",
203
+ "X-OpenGeni-Access-Key",
204
+ "X-OpenGeni-Api-Contract",
205
+ "X-OpenGeni-Correlation-Id",
206
+ "X-OpenGeni-Subject",
207
+ ],
208
+ exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
209
+ };
210
+ const publicApiCors = cors({ ...corsHeaders, credentials: false, origin: "*" });
211
+ const credentialedCors = cors({
212
+ ...corsHeaders,
213
+ credentials: true,
214
+ origin: (origin) =>
215
+ allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin) ? origin : null,
216
+ });
217
+
218
+ app.use("*", (c, next) => {
219
+ const origin = c.req.header("origin");
220
+ const middleware =
221
+ origin && allowedCorsOrigin(deps.settings.corsAllowOriginRegex, origin)
222
+ ? credentialedCors
223
+ : publicApiCors;
224
+ return middleware(c, next);
225
+ });
211
226
 
212
227
  app.use(
213
228
  "*",
@@ -392,7 +407,9 @@ export function createApp(deps: AppDependencies): Hono {
392
407
  boundedRequest = await boundedMcpRequest(c.req.raw);
393
408
  } catch (error) {
394
409
  if (error instanceof McpPayloadTooLargeError) {
395
- throw new HTTPException(413, { message: "MCP request body exceeds the safety limit" });
410
+ throw new HTTPException(413, {
411
+ message: "MCP request body exceeds the safety limit",
412
+ });
396
413
  }
397
414
  throw error;
398
415
  }
@@ -414,7 +431,9 @@ export function createApp(deps: AppDependencies): Hono {
414
431
  throw new HTTPException(404, { message: "session not found" });
415
432
  }
416
433
  if (error instanceof SessionAuthorizationUnavailableError) {
417
- throw new HTTPException(503, { message: "session authorization is unavailable" });
434
+ throw new HTTPException(503, {
435
+ message: "session authorization is unavailable",
436
+ });
418
437
  }
419
438
  throw error;
420
439
  }
@@ -422,10 +441,15 @@ export function createApp(deps: AppDependencies): Hono {
422
441
  let toolspace: Awaited<ReturnType<typeof prepareToolspaceMcpSurface>> = null;
423
442
  if (toolspaceGrant) {
424
443
  try {
425
- toolspace = await prepareToolspaceMcpSurface({ deps: routeDeps, grant });
444
+ toolspace = await prepareToolspaceMcpSurface({
445
+ deps: routeDeps,
446
+ grant,
447
+ });
426
448
  } catch (error) {
427
449
  if (error instanceof McpPayloadTooLargeError) {
428
- throw new HTTPException(413, { message: "MCP tool list exceeds the safety limit" });
450
+ throw new HTTPException(413, {
451
+ message: "MCP tool list exceeds the safety limit",
452
+ });
429
453
  }
430
454
  throw error;
431
455
  }
@@ -458,6 +482,7 @@ export function createApp(deps: AppDependencies): Hono {
458
482
  registerInsightsRoutes(app, routeDeps);
459
483
  registerWorkspaceInstructionPolicyRoutes(app, routeDeps);
460
484
  registerWorkspaceStateRoutes(app, routeDeps);
485
+ registerWorkspaceArtifactRoutes(app, routeDeps);
461
486
  registerPreferenceRegistryRoutes(app, routeDeps);
462
487
  registerSocialRoutes(app, routeDeps);
463
488
  registerConnectionRoutes(app, routeDeps);
@@ -472,6 +497,7 @@ export function createApp(deps: AppDependencies): Hono {
472
497
  registerScheduledTaskRoutes(app, routeDeps);
473
498
  registerCodexRoutes(app, routeDeps);
474
499
  registerTranscriptionRoutes(app, routeDeps);
500
+ registerSlackInteractionRoutes(app, routeDeps);
475
501
 
476
502
  app.notFound((c) => {
477
503
  if (!new URL(c.req.url).pathname.startsWith("/v1/")) return c.text("Not Found", 404);
@@ -513,7 +539,7 @@ export function createApp(deps: AppDependencies): Hono {
513
539
  return c.json(envelope, status as ContentfulStatusCode);
514
540
  });
515
541
 
516
- return app;
542
+ return { app, routeDeps };
517
543
  }
518
544
 
519
545
  async function requireMcpAccessGrant(
@@ -694,7 +720,9 @@ async function runReadinessChecks<const Checks extends Readonly<Record<string, R
694
720
  }
695
721
  }),
696
722
  );
697
- const result = Object.fromEntries(entries) as { [Name in keyof Checks]: ReadinessCheckResult };
723
+ const result = Object.fromEntries(entries) as {
724
+ [Name in keyof Checks]: ReadinessCheckResult;
725
+ };
698
726
  return {
699
727
  ok: Object.values(result).every((check) => check.ok),
700
728
  checks: result,
@@ -1122,6 +1150,9 @@ export function isApiContractProtectedMutation(method: string, pathname: string)
1122
1150
  pathname.startsWith("/v1/auth/") ||
1123
1151
  pathname.startsWith("/v1/webhooks/") ||
1124
1152
  pathname.startsWith("/v1/integrations/oauth/") ||
1153
+ pathname === "/v1/integrations/slack/events" ||
1154
+ pathname === "/v1/integrations/slack/commands" ||
1155
+ pathname === "/v1/integrations/slack/interactions" ||
1125
1156
  pathname.startsWith("/v1/github/") ||
1126
1157
  pathname === "/v1/enrollments/device/start" ||
1127
1158
  pathname === "/v1/enrollments/device/poll" ||
package/src/http/auth.ts CHANGED
@@ -50,7 +50,10 @@ 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
  }
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([
@@ -96,10 +96,14 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
96
96
  signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
97
97
  });
98
98
  } catch {
99
- throw new HTTPException(502, { message: "Slack installation token exchange failed" });
99
+ throw new HTTPException(502, {
100
+ message: "Slack installation token exchange failed",
101
+ });
100
102
  }
101
103
  if (!response.ok) {
102
- throw new HTTPException(502, { message: "Slack installation token exchange failed" });
104
+ throw new HTTPException(502, {
105
+ message: "Slack installation token exchange failed",
106
+ });
103
107
  }
104
108
  const payload = await readResponseJsonBounded<unknown>(
105
109
  response,
@@ -112,7 +116,9 @@ export async function exchangeOpenGeniSlackAuthorizationCode(
112
116
  }
113
117
  const accessToken = slackString(record.access_token);
114
118
  if (!accessToken?.startsWith("xoxb-")) {
115
- throw new HTTPException(502, { message: "Slack installation did not return a bot token" });
119
+ throw new HTTPException(502, {
120
+ message: "Slack installation did not return a bot token",
121
+ });
116
122
  }
117
123
  return accessToken;
118
124
  }
@@ -342,6 +348,11 @@ export class OpenGeniSlackBotClient {
342
348
  });
343
349
  }
344
350
 
351
+ async verifyChannelAccess(channelId: string) {
352
+ const headers = await this.headersFor("channel_history.read");
353
+ return await this.requireMemberChannel(headers, channelId);
354
+ }
355
+
345
356
  async channelHistory(input: { channelId: string; limit?: number; cursor?: string }) {
346
357
  return await this.withAudit("channel_history.read", async (headers) => {
347
358
  const info = await this.requireMemberChannel(headers, input.channelId);
@@ -484,7 +495,9 @@ export class OpenGeniSlackBotClient {
484
495
  const headers = await this.headersFor(operation);
485
496
  let channelId = input.channelId;
486
497
  if (input.userId) {
487
- const opened = await this.call(headers, "conversations.open", { users: input.userId });
498
+ const opened = await this.call(headers, "conversations.open", {
499
+ users: input.userId,
500
+ });
488
501
  channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id");
489
502
  } else if (channelId) {
490
503
  await this.requireMemberChannel(headers, channelId);
@@ -704,7 +717,10 @@ export class OpenGeniSlackBotClient {
704
717
  `${SLACK_API_BASE}chat.getPermalink`,
705
718
  );
706
719
  try {
707
- await this.call(headers, "chat.getPermalink", { channel: channelId, message_ts: timestamp });
720
+ await this.call(headers, "chat.getPermalink", {
721
+ channel: channelId,
722
+ message_ts: timestamp,
723
+ });
708
724
  return true;
709
725
  } catch (error) {
710
726
  if (error instanceof SlackBotProviderError && error.code === "message_not_found") {
@@ -715,7 +731,9 @@ export class OpenGeniSlackBotClient {
715
731
  }
716
732
 
717
733
  private async requireMemberChannel(headers: Record<string, string>, channelId: string) {
718
- const payload = await this.call(headers, "conversations.info", { channel: channelId });
734
+ const payload = await this.call(headers, "conversations.info", {
735
+ channel: channelId,
736
+ });
719
737
  const projected = projectChannel(payload.channel);
720
738
  if (!projected || projected.isMember !== true) {
721
739
  throw new SlackBotProviderError("not_in_channel");
@@ -946,7 +964,10 @@ export class OpenGeniSlackBotClient {
946
964
  }
947
965
 
948
966
  private completedDeleteResult(
949
- operation: { slackChannelId: string | null; slackMessageTimestamp: string | null },
967
+ operation: {
968
+ slackChannelId: string | null;
969
+ slackMessageTimestamp: string | null;
970
+ },
950
971
  operationId: string,
951
972
  ) {
952
973
  if (!operation.slackChannelId || !operation.slackMessageTimestamp) {
@@ -1008,9 +1029,15 @@ export class OpenGeniSlackBotClient {
1008
1029
  return { type: "subject", id: this.context.subjectId };
1009
1030
  }
1010
1031
  if (this.context.scheduledTaskId) {
1011
- return { type: "service", id: `scheduler:${this.context.scheduledTaskId}` };
1032
+ return {
1033
+ type: "service",
1034
+ id: `scheduler:${this.context.scheduledTaskId}`,
1035
+ };
1012
1036
  }
1013
- return { type: "service", id: `session:${this.context.sessionId ?? "workspace"}` };
1037
+ return {
1038
+ type: "service",
1039
+ id: `session:${this.context.sessionId ?? "workspace"}`,
1040
+ };
1014
1041
  }
1015
1042
 
1016
1043
  private fileListPage(input: {
@@ -1020,13 +1047,19 @@ export class OpenGeniSlackBotClient {
1020
1047
  }): SlackFilesListPage {
1021
1048
  const key = environmentsEncryptionKeyBytes(this.settings);
1022
1049
  if (!key) throw new Error("connection encryption is not configured");
1023
- return resolveSlackFilesListPage(input, { connectionId: this.connection.id, key });
1050
+ return resolveSlackFilesListPage(input, {
1051
+ connectionId: this.connection.id,
1052
+ key,
1053
+ });
1024
1054
  }
1025
1055
 
1026
1056
  private fileListCursor(input: { channelId: string; count: number; page: number }): string {
1027
1057
  const key = environmentsEncryptionKeyBytes(this.settings);
1028
1058
  if (!key) throw new Error("connection encryption is not configured");
1029
- return createSlackFilesListCursor(input, { connectionId: this.connection.id, key });
1059
+ return createSlackFilesListCursor(input, {
1060
+ connectionId: this.connection.id,
1061
+ key,
1062
+ });
1030
1063
  }
1031
1064
 
1032
1065
  private async recordAudit(
@@ -1076,6 +1109,42 @@ export function createOpenGeniSlackBotClient(
1076
1109
  );
1077
1110
  }
1078
1111
 
1112
+ export async function createOpenGeniSlackBotInteractionClient(
1113
+ deps: { db: Database; settings: Settings; slackFetch?: typeof fetch },
1114
+ input: {
1115
+ accountId: string;
1116
+ workspaceId: string;
1117
+ connectionId: string;
1118
+ subjectId: string;
1119
+ sessionId?: string | null;
1120
+ },
1121
+ ): Promise<OpenGeniSlackBotClient> {
1122
+ const connection = await requireOpenGeniSlackBotConnection(
1123
+ deps.db,
1124
+ input.workspaceId,
1125
+ input.connectionId,
1126
+ );
1127
+ if (connection.accountId !== input.accountId) {
1128
+ throw new Error("OpenGeni Slack bot connection tenant mismatch");
1129
+ }
1130
+ const metadata = openGeniSlackBotMetadata(connection.metadata);
1131
+ if (!metadata) throw new Error("OpenGeni Slack bot connection metadata is invalid");
1132
+ return new OpenGeniSlackBotClient(
1133
+ deps.db,
1134
+ deps.settings,
1135
+ connection,
1136
+ metadata,
1137
+ {
1138
+ accountId: input.accountId,
1139
+ workspaceId: input.workspaceId,
1140
+ subjectId: input.subjectId,
1141
+ sessionId: input.sessionId ?? null,
1142
+ scheduledTaskId: null,
1143
+ },
1144
+ deps.slackFetch,
1145
+ );
1146
+ }
1147
+
1079
1148
  async function slackApiFetch(
1080
1149
  fetchImpl: FetchLike,
1081
1150
  method: string,