@opengeni/core 0.11.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/core",
3
- "version": "0.11.8",
3
+ "version": "0.12.0",
4
4
  "description": "OpenGeni framework-agnostic core: the domain, access, and billing layers (neutral access, off-HTTP V2 surface). Behavior-preserving extraction from apps/api — keeps Hono's HTTPException for error throwing (typed-errors cleanup deferred).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -35,14 +35,14 @@
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.29.0",
37
37
  "@opengeni/codex": "^0.2.7",
38
- "@opengeni/config": "^0.7.6",
39
- "@opengeni/contracts": "^0.19.4",
40
- "@opengeni/db": "^0.12.6",
41
- "@opengeni/documents": "^0.2.36",
42
- "@opengeni/events": "^0.3.27",
38
+ "@opengeni/config": "^0.7.7",
39
+ "@opengeni/contracts": "^0.20.0",
40
+ "@opengeni/db": "^0.13.0",
41
+ "@opengeni/documents": "^0.2.37",
42
+ "@opengeni/events": "^0.3.28",
43
43
  "@opengeni/observability": "^0.3.0",
44
- "@opengeni/runtime": "^0.13.10",
45
- "@opengeni/storage": "^0.2.30",
44
+ "@opengeni/runtime": "^0.13.11",
45
+ "@opengeni/storage": "^0.2.31",
46
46
  "hono": "^4.12.18"
47
47
  },
48
48
  "engines": {
@@ -106,6 +106,8 @@ export type AppDependencies = {
106
106
  managedAuth?: ManagedAuth | null;
107
107
  /** Injectable Codex HTTP transport for deterministic API/provider tests. */
108
108
  codexFetch?: typeof fetch;
109
+ /** Injectable Slack Web API transport for deterministic bot-connection tests. */
110
+ slackFetch?: typeof fetch;
109
111
  // The API process's OWN agent-loop-free sandbox client (constructed from
110
112
  // settings via @opengeni/runtime/sandbox). Undefined when sandboxBackend=none.
111
113
  // This is the foundation of the API-direct control plane: the API resumes
@@ -6,6 +6,7 @@ import type {
6
6
  CreateScheduledTaskRequest as CreateScheduledTaskPayload,
7
7
  UpdateScheduledTaskRequest as UpdateScheduledTaskPayload,
8
8
  } from "@opengeni/contracts";
9
+ import { OPENGENI_SLACK_BOT_SESSION_METADATA_KEY } from "@opengeni/contracts";
9
10
  import {
10
11
  createScheduledTask,
11
12
  deleteScheduledTask,
@@ -24,6 +25,10 @@ import type { ObjectStorageDependency } from "../dependencies";
24
25
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
25
26
  import { validateVariableSetAttachment } from "./environments";
26
27
  import { assertWorkspaceModelPolicyAllows, canonicalConfiguredModel } from "./sessions";
28
+ import {
29
+ hasReservedOpenGeniSlackBotSessionMetadata,
30
+ validateOpenGeniSlackBotConnectionSelection,
31
+ } from "./slack-bot";
27
32
  import {
28
33
  normalizeResources,
29
34
  validateFileResources,
@@ -197,7 +202,7 @@ export async function validatedScheduledTaskUpdate(input: {
197
202
  if (willHaveVariableSet) {
198
203
  requirePermission(input.grant, "variable-sets:use");
199
204
  }
200
- update.agentConfig = await validateScheduledTaskAgentConfig({
205
+ const nextAgentConfig = await validateScheduledTaskAgentConfig({
201
206
  settings: input.settings,
202
207
  db: input.db,
203
208
  objectStorage: input.objectStorage,
@@ -206,6 +211,18 @@ export async function validatedScheduledTaskUpdate(input: {
206
211
  payload: { agentConfig: input.payload.agentConfig },
207
212
  ...(input.toolsProvided !== undefined ? { toolsProvided: input.toolsProvided } : {}),
208
213
  });
214
+ if (
215
+ input.existing.reusableSessionId &&
216
+ input.existing.runMode === "reusable_session" &&
217
+ (input.existing.agentConfig.slackBotConnectionId ?? null) !==
218
+ (nextAgentConfig.slackBotConnectionId ?? null)
219
+ ) {
220
+ throw new HTTPException(409, {
221
+ message:
222
+ "cannot change the OpenGeni Slack bot connection of a task with a live reusable session; recreate the task",
223
+ });
224
+ }
225
+ update.agentConfig = nextAgentConfig;
209
226
  }
210
227
  return update;
211
228
  }
@@ -355,11 +372,24 @@ async function validateScheduledTaskAgentConfig(input: {
355
372
  if (!prompt) {
356
373
  throw new HTTPException(422, { message: "scheduled task prompt is required" });
357
374
  }
375
+ if (hasReservedOpenGeniSlackBotSessionMetadata(input.payload.agentConfig.metadata)) {
376
+ throw new HTTPException(422, {
377
+ message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
378
+ });
379
+ }
358
380
  await validateGitHubRepositorySelection(input.db, input.workspaceId, resources);
359
381
  if (resources.some((resource) => resource.kind === "file") && !input.objectStorage) {
360
382
  throw new HTTPException(503, { message: "object storage is not configured" });
361
383
  }
362
384
  await validateFileResources(input.db, input.workspaceId, resources);
385
+ if (input.payload.agentConfig.slackBotConnectionId) {
386
+ await validateOpenGeniSlackBotConnectionSelection(
387
+ input.db,
388
+ input.grant,
389
+ input.workspaceId,
390
+ input.payload.agentConfig.slackBotConnectionId,
391
+ );
392
+ }
363
393
  const requestedMaxDepth = input.payload.agentConfig.maxNestedAgentDepth;
364
394
  if (requestedMaxDepth !== undefined) {
365
395
  const workspace = await requireWorkspace(input.db, input.workspaceId);
@@ -9,6 +9,7 @@ import {
9
9
  import {
10
10
  CreateSessionRequest,
11
11
  DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
12
+ OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
12
13
  SessionSpawnDenial,
13
14
  ServiceTurnInitiator,
14
15
  ServiceTurnInitiatorContext,
@@ -92,6 +93,7 @@ import { requireSessionAuthorization } from "../session-authorization";
92
93
  import { swapActiveSandbox, type FleetContext } from "../sandbox/fleet";
93
94
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
94
95
  import { requireVariableSetEncryption, validateVariableSetAttachment } from "./environments";
96
+ import { hasReservedOpenGeniSlackBotSessionMetadata } from "./slack-bot";
95
97
  import {
96
98
  assertToolRefsSubset,
97
99
  availableToolRefs,
@@ -1027,6 +1029,11 @@ export async function createSessionForRequest(
1027
1029
  ): Promise<Session> {
1028
1030
  const { settings, db, bus, workflowClient, objectStorage } = deps;
1029
1031
  const payload = CreateSessionRequest.parse(rawPayload);
1032
+ if (hasReservedOpenGeniSlackBotSessionMetadata(payload.metadata)) {
1033
+ throw new HTTPException(422, {
1034
+ message: `${OPENGENI_SLACK_BOT_SESSION_METADATA_KEY} is reserved for scheduler routing`,
1035
+ });
1036
+ }
1030
1037
  // A committed keyed denial is the idempotent outcome even if mutable
1031
1038
  // resources, policy, authorization, or budget have changed since the first
1032
1039
  // attempt. Replay it before any of those checks, just as a keyed successful
@@ -0,0 +1,130 @@
1
+ import {
2
+ OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
3
+ OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
4
+ OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
5
+ OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
6
+ OpenGeniSlackBotConnectionMetadata,
7
+ type AccessGrant,
8
+ type ConnectionMetadata,
9
+ type OpenGeniSlackBotConnectionMetadata as OpenGeniSlackBotMetadata,
10
+ type Session,
11
+ } from "@opengeni/contracts";
12
+ import {
13
+ getConnectionMetadata,
14
+ type ConnectionMetadataWithVerification,
15
+ type Database,
16
+ } from "@opengeni/db";
17
+ import { HTTPException } from "hono/http-exception";
18
+ import { requirePermission } from "../access";
19
+
20
+ export function openGeniSlackBotMetadata(
21
+ metadata: Record<string, unknown>,
22
+ ): OpenGeniSlackBotMetadata | null {
23
+ const parsed = OpenGeniSlackBotConnectionMetadata.safeParse(metadata);
24
+ return parsed.success ? parsed.data : null;
25
+ }
26
+
27
+ export function isOpenGeniSlackBotConnection(
28
+ connection: ConnectionMetadata &
29
+ Partial<
30
+ Pick<ConnectionMetadataWithVerification, "verifiedInstallAt" | "verifiedInstallVersion">
31
+ >,
32
+ ): boolean {
33
+ const granted = new Set(connection.grantedScopes);
34
+ return (
35
+ connection.verifiedInstallAt != null &&
36
+ connection.verifiedInstallVersion === connection.version &&
37
+ connection.subjectId === null &&
38
+ connection.providerDomain === "slack.com" &&
39
+ connection.kind === "app_install" &&
40
+ granted.size === OPENGENI_SLACK_BOT_REQUIRED_SCOPES.length &&
41
+ OPENGENI_SLACK_BOT_REQUIRED_SCOPES.every((scope) => granted.has(scope)) &&
42
+ openGeniSlackBotMetadata(connection.metadata)?.credentialRole ===
43
+ OPENGENI_SLACK_BOT_CREDENTIAL_ROLE
44
+ );
45
+ }
46
+
47
+ export function hasReservedOpenGeniSlackBotMetadata(
48
+ metadata: Record<string, unknown> | null | undefined,
49
+ ): boolean {
50
+ return (
51
+ metadata?.credentialRole === OPENGENI_SLACK_BOT_CREDENTIAL_ROLE ||
52
+ metadata?.credentialLabel === OPENGENI_SLACK_BOT_CREDENTIAL_LABEL
53
+ );
54
+ }
55
+
56
+ export function hasReservedOpenGeniSlackBotSessionMetadata(
57
+ metadata: Record<string, unknown> | null | undefined,
58
+ ): boolean {
59
+ return Boolean(
60
+ metadata &&
61
+ Object.prototype.hasOwnProperty.call(metadata, OPENGENI_SLACK_BOT_SESSION_METADATA_KEY),
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Internal, pre-authorized lookup used by the scheduler and Slack tool adapter.
67
+ * Public callers must perform their permission check before calling this helper.
68
+ */
69
+ export async function requireOpenGeniSlackBotConnection(
70
+ db: Database,
71
+ workspaceId: string,
72
+ connectionId: string,
73
+ ): Promise<ConnectionMetadata> {
74
+ const connection = await getConnectionMetadata(db, workspaceId, connectionId, null);
75
+ if (!connection || !isOpenGeniSlackBotConnection(connection)) {
76
+ throw new HTTPException(422, {
77
+ message: "slackBotConnectionId must reference an OpenGeni Slack bot connection",
78
+ });
79
+ }
80
+ if (connection.status !== "active") {
81
+ throw new HTTPException(422, {
82
+ message: `OpenGeni Slack bot connection is not active (${connection.status})`,
83
+ });
84
+ }
85
+ return connection;
86
+ }
87
+
88
+ export async function validateOpenGeniSlackBotConnectionSelection(
89
+ db: Database,
90
+ grant: AccessGrant,
91
+ workspaceId: string,
92
+ connectionId: string,
93
+ ): Promise<ConnectionMetadata> {
94
+ requirePermission(grant, "connections:read");
95
+ return await requireOpenGeniSlackBotConnection(db, workspaceId, connectionId);
96
+ }
97
+
98
+ export function scheduledSlackBotConnectionId(
99
+ metadata: Record<string, unknown> | null | undefined,
100
+ ): string | null {
101
+ const value = metadata?.[OPENGENI_SLACK_BOT_SESSION_METADATA_KEY];
102
+ return typeof value === "string" && zUuid(value) ? value : null;
103
+ }
104
+
105
+ /**
106
+ * The scheduler writes the connection pointer together with immutable creator
107
+ * provenance. Requiring both prevents ordinary session metadata from becoming
108
+ * an authorization mechanism for a workspace-shared bot credential.
109
+ */
110
+ export function isTrustedScheduledSlackBotSession(
111
+ session: Pick<Session, "createdBy" | "createdByContext" | "metadata">,
112
+ ): boolean {
113
+ const scheduledTaskId = session.metadata.scheduledTaskId;
114
+ const scheduledTaskRunId = session.metadata.scheduledTaskRunId;
115
+ return (
116
+ session.createdBy?.kind === "service" &&
117
+ session.createdBy.subjectId === "scheduler" &&
118
+ typeof scheduledTaskId === "string" &&
119
+ zUuid(scheduledTaskId) &&
120
+ typeof scheduledTaskRunId === "string" &&
121
+ zUuid(scheduledTaskRunId) &&
122
+ session.createdByContext.scheduledTaskId === scheduledTaskId &&
123
+ session.createdByContext.scheduledTaskRunId === scheduledTaskRunId &&
124
+ scheduledSlackBotConnectionId(session.metadata) !== null
125
+ );
126
+ }
127
+
128
+ function zUuid(value: string): boolean {
129
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
130
+ }
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export * from "./domain/resources";
60
60
  export * from "./domain/session-tool-policy";
61
61
  export * from "./domain/scheduled-tasks";
62
62
  export * from "./domain/sessions";
63
+ export * from "./domain/slack-bot";
63
64
  export * from "./domain/workspace-members";
64
65
  export * from "./application/new-session-drafts";
65
66
  export * from "./application/session-commands";