@opengeni/core 0.4.5 → 0.4.7

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.4.5",
3
+ "version": "0.4.7",
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": {
@@ -8,6 +8,10 @@
8
8
  "url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
9
9
  "directory": "packages/core"
10
10
  },
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
11
15
  "type": "module",
12
16
  "sideEffects": false,
13
17
  "main": "./dist/index.js",
@@ -19,33 +23,29 @@
19
23
  "import": "./dist/index.js"
20
24
  }
21
25
  },
22
- "files": [
23
- "dist",
24
- "src"
25
- ],
26
- "engines": {
27
- "node": ">=18"
28
- },
29
26
  "publishConfig": {
30
27
  "access": "public",
31
28
  "provenance": true
32
29
  },
33
30
  "scripts": {
34
- "typecheck": "tsc --noEmit",
31
+ "typecheck": "tsgo --noEmit",
35
32
  "build": "tsup",
36
33
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
37
34
  },
38
35
  "dependencies": {
39
36
  "@modelcontextprotocol/sdk": "^1.29.0",
40
- "@opengeni/codex": "^0.2.1",
41
- "@opengeni/config": "^0.4.0",
42
- "@opengeni/contracts": "^0.9.0",
43
- "@opengeni/db": "^0.6.1",
44
- "@opengeni/documents": "^0.2.8",
45
- "@opengeni/events": "^0.2.8",
46
- "@opengeni/observability": "^0.2.1",
47
- "@opengeni/runtime": "^0.6.0",
48
- "@opengeni/storage": "^0.2.8",
37
+ "@opengeni/codex": "^0.2.2",
38
+ "@opengeni/config": "^0.5.0",
39
+ "@opengeni/contracts": "^0.10.0",
40
+ "@opengeni/db": "^0.7.0",
41
+ "@opengeni/documents": "^0.2.9",
42
+ "@opengeni/events": "^0.3.0",
43
+ "@opengeni/observability": "^0.3.0",
44
+ "@opengeni/runtime": "^0.7.0",
45
+ "@opengeni/storage": "^0.2.9",
49
46
  "hono": "^4.12.18"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
50
  }
51
51
  }
@@ -1,5 +1,10 @@
1
1
  import type { Settings } from "@opengeni/config";
2
- import { verifyDelegatedAccessToken, type AccessContext, type AccessGrant, type Permission } from "@opengeni/contracts";
2
+ import {
3
+ verifyDelegatedAccessToken,
4
+ type AccessContext,
5
+ type AccessGrant,
6
+ type Permission,
7
+ } from "@opengeni/contracts";
3
8
  import {
4
9
  bootstrapWorkspace,
5
10
  ensureManagedAccessForUser,
@@ -28,10 +33,16 @@ export async function requireAccessContext(c: Context, deps: AccessDeps): Promis
28
33
  return context;
29
34
  }
30
35
 
31
- export async function requireAccessGrant(c: Context, deps: AccessDeps, workspaceId: string, permission?: Permission): Promise<AccessGrant> {
36
+ export async function requireAccessGrant(
37
+ c: Context,
38
+ deps: AccessDeps,
39
+ workspaceId: string,
40
+ permission?: Permission,
41
+ ): Promise<AccessGrant> {
32
42
  const context = await requireAccessContext(c, deps);
33
- const grant = context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId)
34
- ?? await getWorkspaceGrant(deps.db, context.subjectId, workspaceId);
43
+ const grant =
44
+ context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId) ??
45
+ (await getWorkspaceGrant(deps.db, context.subjectId, workspaceId));
35
46
  if (!grant) {
36
47
  const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
37
48
  if (!workspace) {
@@ -47,12 +58,30 @@ export async function requireAccessGrant(c: Context, deps: AccessDeps, workspace
47
58
 
48
59
  export function requirePermission(grant: AccessGrant, permission: Permission): void {
49
60
  if (!hasPermission(grant.permissions, permission)) {
61
+ if (permission === "variable-sets:use") {
62
+ throw new HTTPException(403, {
63
+ message: "missing permission: variable-sets:use (deprecated alias: environments:use)",
64
+ });
65
+ }
66
+ if (permission === "variable-sets:manage") {
67
+ throw new HTTPException(403, {
68
+ message: "missing permission: variable-sets:manage (deprecated alias: environments:manage)",
69
+ });
70
+ }
50
71
  throw new HTTPException(403, { message: `missing permission: ${permission}` });
51
72
  }
52
73
  }
53
74
 
54
75
  export function hasPermission(permissions: Permission[], permission: Permission): boolean {
55
- return permissions.includes(permission) || permissions.includes("workspace:admin");
76
+ const aliases: Partial<Record<Permission, Permission[]>> = {
77
+ "variable-sets:use": ["environments:use" as Permission],
78
+ "variable-sets:manage": ["environments:manage" as Permission],
79
+ };
80
+ return (
81
+ permissions.includes(permission) ||
82
+ (aliases[permission]?.some((alias) => permissions.includes(alias)) ?? false) ||
83
+ permissions.includes("workspace:admin")
84
+ );
56
85
  }
57
86
 
58
87
  async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
@@ -69,16 +98,20 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
69
98
  });
70
99
  }
71
100
 
72
- if (deps.settings.productAccessMode === "configured") {
73
- const delegated = await delegatedAccessContext(c, deps, "configured");
74
- if (delegated) {
75
- return delegated;
76
- }
77
- if (deps.settings.delegationSecret) {
78
- return null;
79
- }
80
- return await bootstrapWorkspace(deps.db, {
81
- accountExternalSource: "opengeni:configured",
101
+ if (deps.settings.productAccessMode === "configured") {
102
+ const delegated = await delegatedAccessContext(c, deps, "configured");
103
+ if (delegated) {
104
+ return delegated;
105
+ }
106
+ const apiKey = await apiKeyAccessContext(c, deps, "configured");
107
+ if (apiKey) {
108
+ return apiKey;
109
+ }
110
+ if (deps.settings.delegationSecret) {
111
+ return null;
112
+ }
113
+ return await bootstrapWorkspace(deps.db, {
114
+ accountExternalSource: "opengeni:configured",
82
115
  accountExternalId: "default",
83
116
  accountName: "Configured",
84
117
  workspaceExternalSource: "opengeni:configured",
@@ -95,31 +128,9 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
95
128
  if (delegated) {
96
129
  return delegated;
97
130
  }
98
- const apiKey = await findActiveApiKeyByHash(deps.db, await sha256Hex(bearer));
131
+ const apiKey = await apiKeyAccessContext(c, deps, "managed");
99
132
  if (apiKey) {
100
- const accountPermissions = apiKey.workspaceId
101
- ? apiKey.permissions.filter((permission) => permission === "billing:read" || permission === "billing:manage")
102
- : apiKey.permissions;
103
- return {
104
- mode: "managed",
105
- subjectId: `api_key:${apiKey.id}`,
106
- subjectLabel: apiKey.name,
107
- accountGrants: [{
108
- accountId: apiKey.accountId,
109
- subjectId: `api_key:${apiKey.id}`,
110
- subjectLabel: apiKey.name,
111
- permissions: accountPermissions,
112
- }],
113
- workspaceGrants: apiKey.workspaceId ? [{
114
- workspaceId: apiKey.workspaceId,
115
- accountId: apiKey.accountId,
116
- subjectId: `api_key:${apiKey.id}`,
117
- subjectLabel: apiKey.name,
118
- permissions: apiKey.permissions,
119
- }] : [],
120
- defaultAccountId: apiKey.accountId,
121
- defaultWorkspaceId: apiKey.workspaceId,
122
- } satisfies AccessContext;
133
+ return apiKey;
123
134
  }
124
135
  }
125
136
 
@@ -137,7 +148,59 @@ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<Acces
137
148
  return null;
138
149
  }
139
150
 
140
- async function delegatedAccessContext(c: Context, deps: AccessDeps, mode: "configured" | "managed", token = bearerToken(c)): Promise<AccessContext | null> {
151
+ async function apiKeyAccessContext(
152
+ c: Context,
153
+ deps: AccessDeps,
154
+ mode: "configured" | "managed",
155
+ ): Promise<AccessContext | null> {
156
+ const bearer = bearerToken(c);
157
+ if (!bearer) {
158
+ return null;
159
+ }
160
+ const apiKey = await findActiveApiKeyByHash(deps.db, await sha256Hex(bearer));
161
+ if (!apiKey) {
162
+ return null;
163
+ }
164
+ const subjectId = `api_key:${apiKey.id}`;
165
+ const accountPermissions = apiKey.workspaceId
166
+ ? apiKey.permissions.filter(
167
+ (permission) => permission === "billing:read" || permission === "billing:manage",
168
+ )
169
+ : apiKey.permissions;
170
+ return {
171
+ mode,
172
+ subjectId,
173
+ subjectLabel: apiKey.name,
174
+ accountGrants: [
175
+ {
176
+ accountId: apiKey.accountId,
177
+ subjectId,
178
+ subjectLabel: apiKey.name,
179
+ permissions: accountPermissions,
180
+ },
181
+ ],
182
+ workspaceGrants: apiKey.workspaceId
183
+ ? [
184
+ {
185
+ workspaceId: apiKey.workspaceId,
186
+ accountId: apiKey.accountId,
187
+ subjectId,
188
+ subjectLabel: apiKey.name,
189
+ permissions: apiKey.permissions,
190
+ },
191
+ ]
192
+ : [],
193
+ defaultAccountId: apiKey.accountId,
194
+ defaultWorkspaceId: apiKey.workspaceId,
195
+ } satisfies AccessContext;
196
+ }
197
+
198
+ async function delegatedAccessContext(
199
+ c: Context,
200
+ deps: AccessDeps,
201
+ mode: "configured" | "managed",
202
+ token = bearerToken(c),
203
+ ): Promise<AccessContext | null> {
141
204
  if (!token || !deps.settings.delegationSecret) {
142
205
  return null;
143
206
  }
@@ -149,22 +212,32 @@ async function delegatedAccessContext(c: Context, deps: AccessDeps, mode: "confi
149
212
  mode,
150
213
  subjectId: payload.subjectId,
151
214
  ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
152
- accountGrants: [{
153
- accountId: payload.accountId,
154
- subjectId: payload.subjectId,
155
- ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
156
- permissions: payload.permissions,
157
- }],
158
- workspaceGrants: [{
159
- workspaceId: payload.workspaceId,
160
- accountId: payload.accountId,
161
- subjectId: payload.subjectId,
162
- ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
163
- permissions: payload.permissions,
164
- // sessionId is worker-asserted (HMAC-signed token claim), not agent
165
- // controlled; it scopes session-bound MCP tools such as goal management.
166
- metadata: { delegated: true, ...(payload.sessionId ? { sessionId: payload.sessionId } : {}) },
167
- }],
215
+ accountGrants: [
216
+ {
217
+ accountId: payload.accountId,
218
+ subjectId: payload.subjectId,
219
+ ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
220
+ permissions: payload.permissions,
221
+ },
222
+ ],
223
+ workspaceGrants: [
224
+ {
225
+ workspaceId: payload.workspaceId,
226
+ accountId: payload.accountId,
227
+ subjectId: payload.subjectId,
228
+ ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
229
+ permissions: payload.permissions,
230
+ // sessionId is worker-asserted (HMAC-signed token claim), not agent
231
+ // controlled; it scopes session-bound MCP tools such as goal management.
232
+ metadata: {
233
+ delegated: true,
234
+ ...(payload.sessionId ? { sessionId: payload.sessionId } : {}),
235
+ // Caller identity: the turn that minted this token. Tools classify the
236
+ // CALLER from this instead of re-reading the live active pointer.
237
+ ...(payload.turnId ? { turnId: payload.turnId } : {}),
238
+ },
239
+ },
240
+ ],
168
241
  defaultAccountId: payload.accountId,
169
242
  defaultWorkspaceId: payload.workspaceId,
170
243
  };
@@ -182,5 +255,7 @@ function bearerToken(c: Context): string | null {
182
255
 
183
256
  async function sha256Hex(value: string): Promise<string> {
184
257
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
185
- return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
258
+ return Array.from(new Uint8Array(digest))
259
+ .map((byte) => byte.toString(16).padStart(2, "0"))
260
+ .join("");
186
261
  }
@@ -12,6 +12,8 @@ import {
12
12
  import { HTTPException } from "hono/http-exception";
13
13
  import type { ApiRouteDeps } from "../dependencies";
14
14
 
15
+ export type LimitDependencies = Pick<ApiRouteDeps, "db" | "settings">;
16
+
15
17
  export type LimitCheckInput = {
16
18
  accountId: string;
17
19
  workspaceId?: string;
@@ -26,20 +28,30 @@ export type LimitCheckInput = {
26
28
  model?: string | null;
27
29
  };
28
30
 
29
- export async function requireLimit(deps: ApiRouteDeps, input: LimitCheckInput): Promise<void> {
31
+ export async function requireLimit(deps: LimitDependencies, input: LimitCheckInput): Promise<void> {
30
32
  const decision = await checkLimit(deps, input);
31
33
  if (decision.allowed) {
32
34
  return;
33
35
  }
34
- throw new HTTPException(decision.code === "insufficient_credits" ? 402 : 429, { message: decision.message });
36
+ throw new HTTPException(decision.code === "insufficient_credits" ? 402 : 429, {
37
+ message: decision.message,
38
+ });
35
39
  }
36
40
 
37
- export async function checkLimit(deps: ApiRouteDeps, input: LimitCheckInput): Promise<LimitDecision> {
41
+ export async function checkLimit(
42
+ deps: LimitDependencies,
43
+ input: LimitCheckInput,
44
+ ): Promise<LimitDecision> {
38
45
  // Resolve the canonical codex-billed predicate ONCE. Returns false for any
39
46
  // action that carries no model (infra caps) or any codex/<slug> model without
40
47
  // an active credential — so the bypass never triggers on the prefix alone.
41
48
  const codexBilled = input.workspaceId
42
- ? await isCodexBilledTurn({ db: deps.db, settings: deps.settings, workspaceId: input.workspaceId, model: input.model })
49
+ ? await isCodexBilledTurn({
50
+ db: deps.db,
51
+ settings: deps.settings,
52
+ workspaceId: input.workspaceId,
53
+ model: input.model,
54
+ })
43
55
  : false;
44
56
  const creditDecision = await checkCreditBalance(deps, input, codexBilled);
45
57
  if (!creditDecision.allowed) {
@@ -51,7 +63,11 @@ export async function checkLimit(deps: ApiRouteDeps, input: LimitCheckInput): Pr
51
63
  return await checkStaticCaps(deps, input, codexBilled);
52
64
  }
53
65
 
54
- async function checkCreditBalance(deps: ApiRouteDeps, input: LimitCheckInput, codexBilled: boolean): Promise<LimitDecision> {
66
+ async function checkCreditBalance(
67
+ deps: LimitDependencies,
68
+ input: LimitCheckInput,
69
+ codexBilled: boolean,
70
+ ): Promise<LimitDecision> {
55
71
  if (codexBilled) {
56
72
  return { allowed: true }; // paid by the user's ChatGPT/Codex plan — zero OpenGeni credits
57
73
  }
@@ -65,7 +81,11 @@ async function checkCreditBalance(deps: ApiRouteDeps, input: LimitCheckInput, co
65
81
  return { allowed: false, code: "insufficient_credits", message: "insufficient OpenGeni credits" };
66
82
  }
67
83
 
68
- async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codexBilled: boolean): Promise<LimitDecision> {
84
+ async function checkStaticCaps(
85
+ deps: LimitDependencies,
86
+ input: LimitCheckInput,
87
+ codexBilled: boolean,
88
+ ): Promise<LimitDecision> {
69
89
  const limits = configuredStaticUsageLimits(deps.settings);
70
90
  if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !codexBilled) {
71
91
  const used = await sumUsageQuantity(deps.db, {
@@ -74,7 +94,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
74
94
  since: startOfUtcMonth(),
75
95
  });
76
96
  if (used >= limits.maxMonthlyCostMicrosPerAccount) {
77
- return blocked("max_monthly_cost_micros_per_account", `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`);
97
+ return blocked(
98
+ "max_monthly_cost_micros_per_account",
99
+ `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`,
100
+ );
78
101
  }
79
102
  }
80
103
  switch (input.action) {
@@ -85,7 +108,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
85
108
  const count = await countWorkspacesForAccount(deps.db, input.accountId);
86
109
  return count < limits.maxWorkspacesPerAccount
87
110
  ? { allowed: true }
88
- : blocked("max_workspaces_per_account", `workspace limit reached (${limits.maxWorkspacesPerAccount})`);
111
+ : blocked(
112
+ "max_workspaces_per_account",
113
+ `workspace limit reached (${limits.maxWorkspacesPerAccount})`,
114
+ );
89
115
  }
90
116
  case "api_key:create": {
91
117
  if (!limits.maxApiKeysPerWorkspace || !input.workspaceId) {
@@ -94,7 +120,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
94
120
  const count = await countActiveApiKeysForWorkspace(deps.db, input.workspaceId);
95
121
  return count < limits.maxApiKeysPerWorkspace
96
122
  ? { allowed: true }
97
- : blocked("max_api_keys_per_workspace", `API key limit reached (${limits.maxApiKeysPerWorkspace})`);
123
+ : blocked(
124
+ "max_api_keys_per_workspace",
125
+ `API key limit reached (${limits.maxApiKeysPerWorkspace})`,
126
+ );
98
127
  }
99
128
  case "schedule:create": {
100
129
  if (!limits.maxSchedulesPerWorkspace || !input.workspaceId) {
@@ -103,7 +132,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
103
132
  const count = await countScheduledTasksForWorkspace(deps.db, input.workspaceId);
104
133
  return count < limits.maxSchedulesPerWorkspace
105
134
  ? { allowed: true }
106
- : blocked("max_schedules_per_workspace", `scheduled task limit reached (${limits.maxSchedulesPerWorkspace})`);
135
+ : blocked(
136
+ "max_schedules_per_workspace",
137
+ `scheduled task limit reached (${limits.maxSchedulesPerWorkspace})`,
138
+ );
107
139
  }
108
140
  case "file:upload": {
109
141
  if (!limits.maxFileUploadBytes || !input.quantity) {
@@ -111,7 +143,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
111
143
  }
112
144
  return input.quantity <= limits.maxFileUploadBytes
113
145
  ? { allowed: true }
114
- : blocked("max_file_upload_bytes", `file upload exceeds static limit of ${limits.maxFileUploadBytes} bytes`);
146
+ : blocked(
147
+ "max_file_upload_bytes",
148
+ `file upload exceeds static limit of ${limits.maxFileUploadBytes} bytes`,
149
+ );
115
150
  }
116
151
  case "agent_run:create": {
117
152
  if (!limits.maxMonthlyAgentRunsPerWorkspace || !input.workspaceId) {
@@ -125,7 +160,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
125
160
  const requested = input.quantity ?? 0;
126
161
  return used + requested <= limits.maxMonthlyAgentRunsPerWorkspace
127
162
  ? { allowed: true }
128
- : blocked("max_monthly_agent_runs_per_workspace", `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`);
163
+ : blocked(
164
+ "max_monthly_agent_runs_per_workspace",
165
+ `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`,
166
+ );
129
167
  }
130
168
  case "tokens:consume": {
131
169
  if (codexBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
@@ -139,7 +177,10 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
139
177
  const requested = input.quantity ?? 0;
140
178
  return used + requested <= limits.maxMonthlyTokensPerWorkspace
141
179
  ? { allowed: true }
142
- : blocked("max_monthly_tokens_per_workspace", `monthly token limit reached (${limits.maxMonthlyTokensPerWorkspace})`);
180
+ : blocked(
181
+ "max_monthly_tokens_per_workspace",
182
+ `monthly token limit reached (${limits.maxMonthlyTokensPerWorkspace})`,
183
+ );
143
184
  }
144
185
  case "document:index": {
145
186
  if (!limits.maxDocumentIndexedChunksPerWorkspace || !input.workspaceId) {
@@ -153,26 +194,28 @@ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codex
153
194
  const requested = input.quantity ?? 0;
154
195
  return used + requested <= limits.maxDocumentIndexedChunksPerWorkspace
155
196
  ? { allowed: true }
156
- : blocked("max_document_indexed_chunks_per_workspace", `monthly document indexing limit reached (${limits.maxDocumentIndexedChunksPerWorkspace} chunks)`);
197
+ : blocked(
198
+ "max_document_indexed_chunks_per_workspace",
199
+ `monthly document indexing limit reached (${limits.maxDocumentIndexedChunksPerWorkspace} chunks)`,
200
+ );
157
201
  }
158
202
  }
159
203
  }
160
204
 
161
- export async function recordWorkspaceUsage(deps: ApiRouteDeps, input: {
162
- accountId: string;
163
- workspaceId: string;
164
- subjectId?: string | null;
165
- eventType:
166
- | "agent_run.created"
167
- | "file.uploaded"
168
- | "document.indexed"
169
- | "scheduled_task.fired";
170
- quantity: number;
171
- unit: string;
172
- sourceResourceType: string;
173
- sourceResourceId: string;
174
- idempotencyKey: string;
175
- }): Promise<void> {
205
+ export async function recordWorkspaceUsage(
206
+ deps: LimitDependencies,
207
+ input: {
208
+ accountId: string;
209
+ workspaceId: string;
210
+ subjectId?: string | null;
211
+ eventType: "agent_run.created" | "file.uploaded" | "document.indexed" | "scheduled_task.fired";
212
+ quantity: number;
213
+ unit: string;
214
+ sourceResourceType: string;
215
+ sourceResourceId: string;
216
+ idempotencyKey: string;
217
+ },
218
+ ): Promise<void> {
176
219
  await recordUsageEvent(deps.db, {
177
220
  accountId: input.accountId,
178
221
  workspaceId: input.workspaceId,
@@ -186,15 +229,14 @@ export async function recordWorkspaceUsage(deps: ApiRouteDeps, input: {
186
229
  });
187
230
  }
188
231
 
189
- function usesCreditLimits(deps: ApiRouteDeps): boolean {
232
+ function usesCreditLimits(deps: LimitDependencies): boolean {
190
233
  return deps.settings.billingMode === "stripe" || deps.settings.usageLimitsMode === "managed";
191
234
  }
192
235
 
193
236
  function isCostlyAction(action: LimitAction): boolean {
194
- return action === "agent_run:create"
195
- || action === "tokens:consume"
196
- || action === "file:upload"
197
- || action === "document:index";
237
+ return (
238
+ action === "agent_run:create" || action === "tokens:consume" || action === "document:index"
239
+ );
198
240
  }
199
241
 
200
242
  function blocked(code: string, message: string): LimitDecision {
@@ -9,26 +9,71 @@ import type { ManagedAuth } from "./managed-auth-type";
9
9
  import type { ApiSandboxClient, ResumeBoxByIdInput, ResumedSandboxSession } from "./sandbox-types";
10
10
 
11
11
  export type SessionWorkflowClient = {
12
- signalUserMessage: (input: { sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
13
- wakeSessionWorkflow: (input: { accountId: string; workspaceId: string; sessionId: string; workflowId: string }) => Promise<void>;
14
- signalApprovalDecision: (input: { sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
15
- // Interrupt must reach the workflow whether or not an execution is currently
16
- // running: a long-lived session that has gone idle (its workflow returned
17
- // after markSessionIdle) has NO running execution, so a plain
18
- // getHandle().signal() throws WorkflowNotFoundError -> a 500 that leaves an
19
- // operator unable to stop a session. signalWithStart start-or-signals, so it
20
- // needs the session-workflow args (accountId/workspaceId) to start a fresh
21
- // run when none is live; the buffered `interrupt` signal is then honored by
22
- // the workflow's idle-interrupt path (pause goal + mark idle).
23
- signalInterrupt: (input: { accountId: string; workspaceId: string; sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
12
+ signalUserMessage: (input: {
13
+ sessionId: string;
14
+ eventId: string;
15
+ workflowId: string;
16
+ }) => Promise<void>;
17
+ wakeSessionWorkflow: (input: {
18
+ accountId: string;
19
+ workspaceId: string;
20
+ sessionId: string;
21
+ workflowId: string;
22
+ wakeRevision: number;
23
+ controlEventId?: string;
24
+ }) => Promise<void>;
25
+ // Dedicated, revision-carrying nudge for a durable Codex capacity waiter.
26
+ // Optional for embedded/back-compat clients: callers may fall back to the
27
+ // generic queueChanged wake because Postgres wakeRevision is authoritative.
28
+ signalCodexCapacity?: (input: {
29
+ accountId: string;
30
+ workspaceId: string;
31
+ sessionId: string;
32
+ workflowId: string;
33
+ wakeRevision: number;
34
+ workflowWakeRevision: number;
35
+ }) => Promise<void>;
36
+ signalApprovalDecision: (input: {
37
+ accountId: string;
38
+ workspaceId: string;
39
+ sessionId: string;
40
+ eventId: string;
41
+ workflowId: string;
42
+ workflowWakeRevision: number;
43
+ }) => Promise<void>;
44
+ // A durable Pause/Steer control must reach the workflow even when its previous
45
+ // run returned idle. signalWithStart either delivers to the live workflow or
46
+ // starts the session workflow with the control already buffered.
47
+ signalSessionControl: (input: {
48
+ accountId: string;
49
+ workspaceId: string;
50
+ sessionId: string;
51
+ eventId: string;
52
+ workflowId: string;
53
+ workflowWakeRevision: number;
54
+ }) => Promise<void>;
24
55
  syncScheduledTask: (input: { task: ScheduledTask }) => Promise<void>;
25
56
  deleteScheduledTaskSchedule: (input: { temporalScheduleId: string }) => Promise<void>;
26
- triggerScheduledTask: (input: { task: ScheduledTask; agentRunUsageIdempotencyKey?: string; triggerWorkflowId?: string }) => Promise<void>;
57
+ triggerScheduledTask: (input: {
58
+ task: ScheduledTask;
59
+ agentRunUsageIdempotencyKey?: string;
60
+ triggerWorkflowId?: string;
61
+ }) => Promise<void>;
62
+ startRigVerification: (input: {
63
+ workspaceId: string;
64
+ changeId?: string;
65
+ versionId?: string;
66
+ workflowId?: string;
67
+ }) => Promise<void>;
27
68
  check?: () => Promise<void>;
28
69
  };
29
70
 
30
71
  export type DocumentIndexClient = {
31
- indexDocument: (input: { accountId: string; workspaceId: string; documentId: string }) => Promise<Document | void>;
72
+ indexDocument: (input: {
73
+ accountId: string;
74
+ workspaceId: string;
75
+ documentId: string;
76
+ }) => Promise<Document | void>;
32
77
  };
33
78
 
34
79
  export type AppDependencies = {
@@ -36,6 +81,8 @@ export type AppDependencies = {
36
81
  db: Database;
37
82
  bus: EventBus;
38
83
  workflowClient: SessionWorkflowClient;
84
+ /** Optional provider override for deterministic API/object-storage tests. */
85
+ objectStorage?: ObjectStorageDependency;
39
86
  documentIndexer?: DocumentIndexClient;
40
87
  documentServices?: DocumentServices;
41
88
  observability?: Observability;
@@ -70,3 +117,18 @@ export type ApiRouteDeps = AppDependencies & {
70
117
  // resumeBoxById (it throws SandboxResumeError when sandboxBackend=none).
71
118
  resumeBoxById: (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession>;
72
119
  };
120
+
121
+ /**
122
+ * The exact dependency slice used by `acceptSessionUserMessage`.
123
+ *
124
+ * Keeping this narrower than `ApiRouteDeps` lets control-plane callers reuse
125
+ * the canonical admission path without constructing unrelated HTTP, document,
126
+ * or sandbox services. The public API still passes its `ApiRouteDeps` superset.
127
+ */
128
+ export type AcceptSessionUserMessageDependencies = Pick<
129
+ AppDependencies,
130
+ "settings" | "db" | "bus"
131
+ > & {
132
+ workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow" | "signalSessionControl">;
133
+ objectStorage: ObjectStorageDependency;
134
+ };