@opengeni/core 1.0.1 → 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 (99) hide show
  1. package/dist/access/index.d.ts +37 -0
  2. package/dist/application/new-session-drafts.d.ts +8 -1
  3. package/dist/application/session-commands.d.ts +18 -11
  4. package/dist/application/session-tenancy.d.ts +18 -0
  5. package/dist/application/user-resource-grants.d.ts +20 -0
  6. package/dist/{chunk-6WKPWE5S.js → chunk-H7X52RI2.js} +27 -18
  7. package/dist/chunk-H7X52RI2.js.map +1 -0
  8. package/dist/{chunk-JJU6I5XD.js → chunk-VCZFFEPB.js} +21 -9
  9. package/dist/chunk-VCZFFEPB.js.map +1 -0
  10. package/dist/{chunk-NYPMQ7NO.js → chunk-ZYCNLYGH.js} +29 -27
  11. package/dist/chunk-ZYCNLYGH.js.map +1 -0
  12. package/dist/dependencies.d.ts +12 -1
  13. package/dist/domain/automations.d.ts +51 -0
  14. package/dist/domain/company-brain-governed-writes.d.ts +70 -0
  15. package/dist/domain/company-profile-agent-admin.d.ts +24 -0
  16. package/dist/domain/connection-authority.d.ts +97 -0
  17. package/dist/domain/connection-authority.js +169 -0
  18. package/dist/domain/connection-authority.js.map +1 -0
  19. package/dist/domain/editable-artifacts/service.d.ts +1 -2
  20. package/dist/domain/environments.d.ts +1 -1
  21. package/dist/domain/github-repository-bindings.d.ts +117 -0
  22. package/dist/domain/governed-learning-activation.d.ts +16 -0
  23. package/dist/domain/governed-learning-evaluator.d.ts +6 -0
  24. package/dist/domain/governed-learning-slack-publication.d.ts +45 -0
  25. package/dist/domain/memory-slack-delivery.d.ts +1 -4
  26. package/dist/domain/organization-membership-lifecycle.d.ts +1 -0
  27. package/dist/domain/packs.d.ts +5 -1
  28. package/dist/domain/personal-connection-delegations.d.ts +34 -2
  29. package/dist/domain/pr-review.d.ts +149 -0
  30. package/dist/domain/remember.d.ts +69 -0
  31. package/dist/domain/resources.d.ts +22 -1
  32. package/dist/domain/scheduled-tasks.d.ts +43 -2
  33. package/dist/domain/session-tool-policy.d.ts +1 -1
  34. package/dist/domain/sessions.d.ts +40 -6
  35. package/dist/editable-artifact-live/application.d.ts +10 -2
  36. package/dist/editable-artifact-live/ports.d.ts +0 -1
  37. package/dist/editable-artifact-live/server.d.ts +0 -1
  38. package/dist/editable-artifact-live/ticket.d.ts +0 -1
  39. package/dist/editable-artifact-live/types.d.ts +5 -2
  40. package/dist/editable-artifact-live.js +2 -2
  41. package/dist/editable-artifacts.js +2 -4
  42. package/dist/index.d.ts +13 -1
  43. package/dist/index.js +4827 -1164
  44. package/dist/index.js.map +1 -1
  45. package/dist/rigs/index.d.ts +22 -5
  46. package/dist/sandbox/fleet.d.ts +34 -6
  47. package/dist/sandbox/routing.d.ts +15 -1
  48. package/dist/sandbox/runtime-settings.d.ts +39 -0
  49. package/package.json +16 -11
  50. package/src/access/index.ts +157 -31
  51. package/src/application/new-session-drafts.ts +24 -2
  52. package/src/application/session-commands.ts +305 -71
  53. package/src/application/session-tenancy.ts +321 -0
  54. package/src/application/user-resource-grants.ts +140 -0
  55. package/src/billing/limits.ts +25 -15
  56. package/src/dependencies.ts +12 -1
  57. package/src/domain/automations.ts +196 -0
  58. package/src/domain/capabilities.ts +7 -1
  59. package/src/domain/company-brain-governed-writes.ts +330 -0
  60. package/src/domain/company-profile-agent-admin.ts +55 -0
  61. package/src/domain/connection-authority.ts +329 -0
  62. package/src/domain/editable-artifacts/agent-application.ts +17 -9
  63. package/src/domain/editable-artifacts/service.ts +12 -1
  64. package/src/domain/editable-artifacts/snapshot-compaction.ts +9 -5
  65. package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +10 -8
  66. package/src/domain/editable-artifacts/snapshot-verifier.ts +10 -3
  67. package/src/domain/environments.ts +16 -3
  68. package/src/domain/github-repository-bindings.ts +295 -0
  69. package/src/domain/governed-learning-activation.ts +44 -0
  70. package/src/domain/governed-learning-evaluator.ts +49 -0
  71. package/src/domain/governed-learning-slack-publication.ts +157 -0
  72. package/src/domain/insights.ts +9 -0
  73. package/src/domain/memory-slack-delivery.ts +0 -30
  74. package/src/domain/organization-membership-lifecycle.ts +9 -0
  75. package/src/domain/packs.ts +106 -3
  76. package/src/domain/personal-connection-delegations.ts +532 -51
  77. package/src/domain/pr-review.ts +671 -0
  78. package/src/domain/remember.ts +549 -0
  79. package/src/domain/resources.ts +131 -4
  80. package/src/domain/scheduled-tasks.ts +497 -76
  81. package/src/domain/session-tool-policy.ts +12 -9
  82. package/src/domain/sessions.ts +613 -166
  83. package/src/editable-artifact-live/application.ts +21 -3
  84. package/src/editable-artifact-live/ports.ts +0 -1
  85. package/src/editable-artifact-live/server.ts +13 -7
  86. package/src/editable-artifact-live/ticket.ts +1 -5
  87. package/src/editable-artifact-live/types.ts +6 -2
  88. package/src/editable-artifact-live/wire.ts +0 -10
  89. package/src/index.ts +13 -1
  90. package/src/rigs/index.ts +165 -48
  91. package/src/sandbox/fleet.ts +376 -30
  92. package/src/sandbox/routing.ts +91 -29
  93. package/src/sandbox/runtime-settings.ts +274 -0
  94. package/src/session-authorization.ts +17 -49
  95. package/dist/chunk-6WKPWE5S.js.map +0 -1
  96. package/dist/chunk-JJU6I5XD.js.map +0 -1
  97. package/dist/chunk-NYPMQ7NO.js.map +0 -1
  98. package/dist/domain/durable-learning-slack-publication.d.ts +0 -72
  99. package/src/domain/durable-learning-slack-publication.ts +0 -202
@@ -0,0 +1,321 @@
1
+ import {
2
+ ForkSessionResponse,
3
+ SessionTenancyCreateCapabilities,
4
+ UpdateSessionVisibilityResponse,
5
+ sessionVisibilityFromPublic,
6
+ sessionVisibilityToPublic,
7
+ type ForkSessionRequest,
8
+ type SessionAuthorizationSurface,
9
+ type UpdateSessionVisibilityRequest,
10
+ } from "@opengeni/contracts";
11
+ import {
12
+ forkSessionContent,
13
+ getPrivateSessionCreatePolicy,
14
+ getSessionEventForSubject,
15
+ isRetryableDatabaseTransportFailure,
16
+ nestedPostgresSqlState,
17
+ replayAppliedSessionFork,
18
+ sessionTenancyProductActivated,
19
+ SessionTenancyAccessError,
20
+ SessionTenancyNotActivatedError,
21
+ transitionSessionVisibility,
22
+ type Database,
23
+ type ForkSessionContentResult,
24
+ } from "@opengeni/db";
25
+ import { publishDurableSessionEvents, type EventBus } from "@opengeni/events";
26
+ import { requirePermission, type AccessGrantAuthorization } from "../access";
27
+ import { requireSessionAuthorization } from "../session-authorization";
28
+ import type { AppDependencies } from "../dependencies";
29
+
30
+ type SessionTenancyDependencies = Pick<AppDependencies, "db" | "bus" | "sessionAuthorization">;
31
+
32
+ export class SessionTenancyManagedHumanRequiredError extends Error {
33
+ readonly name = "SessionTenancyManagedHumanRequiredError";
34
+ constructor() {
35
+ super("Session tenancy mutations require the owning managed-human session");
36
+ }
37
+ }
38
+
39
+ export class SessionTenancyPersistenceOutcomeUnknownError extends Error {
40
+ readonly name = "SessionTenancyPersistenceOutcomeUnknownError";
41
+ constructor(options?: ErrorOptions) {
42
+ super("The session tenancy mutation outcome is unknown", options);
43
+ }
44
+ }
45
+
46
+ export function requireCanonicalManagedHuman(
47
+ authorization: AccessGrantAuthorization,
48
+ workspaceId: string,
49
+ ): void {
50
+ if (
51
+ !authorization.canonicalManagedHumanSession ||
52
+ !authorization.contextIntegrity ||
53
+ authorization.authenticatedSubjectId !== authorization.grant.subjectId ||
54
+ authorization.grant.workspaceId !== workspaceId
55
+ ) {
56
+ throw new SessionTenancyManagedHumanRequiredError();
57
+ }
58
+ }
59
+
60
+ export async function getManagedHumanSessionCreateCapabilities(
61
+ deps: Pick<SessionTenancyDependencies, "db">,
62
+ authorization: AccessGrantAuthorization,
63
+ workspaceId: string,
64
+ ): Promise<SessionTenancyCreateCapabilities> {
65
+ requirePermission(authorization.grant, "sessions:create");
66
+ try {
67
+ requireCanonicalManagedHuman(authorization, workspaceId);
68
+ } catch (error) {
69
+ if (error instanceof SessionTenancyManagedHumanRequiredError) {
70
+ return SessionTenancyCreateCapabilities.parse({
71
+ activated: false,
72
+ canCreatePrivate: false,
73
+ reason: "managed_session_required",
74
+ });
75
+ }
76
+ throw error;
77
+ }
78
+ // Mirror the database create fence exactly: a managed human's own Personal
79
+ // workspace needs only the operator readiness receipt, while a shared
80
+ // organization workspace additionally needs the owner/admin product setting.
81
+ let activated = false;
82
+ try {
83
+ const policy = await getPrivateSessionCreatePolicy(deps.db, {
84
+ workspaceId,
85
+ actorSubjectId: authorization.grant.subjectId,
86
+ });
87
+ activated = policy.personalWorkspace ? policy.platformAvailable : policy.organizationEnabled;
88
+ } catch (error) {
89
+ // A canonical managed human without an active organization membership or
90
+ // stated workspace authority (legacy managed access) is simply not
91
+ // private-ready; the definer's 42501 is a capability answer here, not a
92
+ // failure. Every other error still propagates.
93
+ if (
94
+ !(error instanceof SessionTenancyAccessError) &&
95
+ nestedPostgresSqlState(error) !== "42501"
96
+ ) {
97
+ throw error;
98
+ }
99
+ }
100
+ return SessionTenancyCreateCapabilities.parse({
101
+ activated,
102
+ canCreatePrivate: activated,
103
+ reason: activated ? "available" : "not_activated",
104
+ });
105
+ }
106
+
107
+ export async function requireManagedHumanPrivateSessionCreate(
108
+ deps: Pick<SessionTenancyDependencies, "db">,
109
+ authorization: AccessGrantAuthorization,
110
+ workspaceId: string,
111
+ ): Promise<void> {
112
+ // Target-free readiness preflight only. The owner/admin organization setting
113
+ // is enforced by the create transaction itself (under the organization
114
+ // fence, after keyed replay resolution), so a committed keyed success still
115
+ // replays after the setting is disabled while a fresh key fails closed.
116
+ await requireSessionTenancyMutationGate(deps, authorization, workspaceId, ["sessions:create"]);
117
+ }
118
+
119
+ async function requireSessionTenancyMutationGate(
120
+ deps: Pick<SessionTenancyDependencies, "db">,
121
+ authorization: AccessGrantAuthorization,
122
+ workspaceId: string,
123
+ permissions: ReadonlyArray<"sessions:read" | "sessions:create" | "sessions:control">,
124
+ ): Promise<void> {
125
+ // These checks are deliberately target-free. A rejected principal must not
126
+ // cause a session lookup or embedding-host callback that distinguishes a
127
+ // missing, shared, or another owner's private session.
128
+ requireCanonicalManagedHuman(authorization, workspaceId);
129
+ for (const permission of permissions) requirePermission(authorization.grant, permission);
130
+ if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
131
+ throw new SessionTenancyNotActivatedError();
132
+ }
133
+ }
134
+
135
+ async function publishExactCommittedEvent(
136
+ deps: { db: Database; bus: EventBus },
137
+ input: {
138
+ workspaceId: string;
139
+ sessionId: string;
140
+ subjectId: string;
141
+ eventId: string | null;
142
+ eventSequence: number | null;
143
+ },
144
+ ): Promise<void> {
145
+ if (input.eventId === null && input.eventSequence === null) return;
146
+ if (input.eventId === null || input.eventSequence === null) {
147
+ console.error("[session-tenancy] committed event receipt is incomplete", {
148
+ errorClass: "SessionTenancyEventInvariantError",
149
+ errorCode: "session_tenancy_event_receipt_incomplete",
150
+ origin: "core",
151
+ sessionId: input.sessionId,
152
+ });
153
+ return;
154
+ }
155
+ try {
156
+ const event = await getSessionEventForSubject(
157
+ deps.db,
158
+ input.workspaceId,
159
+ input.subjectId,
160
+ input.eventId,
161
+ );
162
+ if (!event || event.sessionId !== input.sessionId || event.sequence !== input.eventSequence) {
163
+ console.error("[session-tenancy] committed event receipt did not resolve exactly", {
164
+ errorClass: "SessionTenancyEventInvariantError",
165
+ errorCode: "session_tenancy_event_receipt_mismatch",
166
+ origin: "core",
167
+ sessionId: input.sessionId,
168
+ });
169
+ return;
170
+ }
171
+ await publishDurableSessionEvents(deps.bus, input.workspaceId, input.sessionId, [event]);
172
+ } catch {
173
+ // The mutation and event committed atomically before this read. Live fanout
174
+ // is repairable from the durable stream, so a publication-side failure must
175
+ // not turn a known successful mutation into an unknown client outcome.
176
+ console.warn("[session-tenancy] committed event live publication was deferred to replay", {
177
+ errorClass: "SessionTenancyEventPublishOperationError",
178
+ errorCode: "session_tenancy_event_publish_deferred",
179
+ origin: "core",
180
+ sessionId: input.sessionId,
181
+ });
182
+ }
183
+ }
184
+
185
+ async function runTenancyMutation<T>(operation: () => Promise<T>): Promise<T> {
186
+ try {
187
+ return await operation();
188
+ } catch (error) {
189
+ if (isRetryableDatabaseTransportFailure(error)) {
190
+ throw new SessionTenancyPersistenceOutcomeUnknownError({ cause: error });
191
+ }
192
+ throw error;
193
+ }
194
+ }
195
+
196
+ async function projectAndPublishSessionFork(
197
+ deps: SessionTenancyDependencies,
198
+ actorSubjectId: string,
199
+ result: ForkSessionContentResult,
200
+ ): Promise<ForkSessionResponse> {
201
+ const response = ForkSessionResponse.parse({
202
+ operationId: result.operationId,
203
+ eventId: result.eventId,
204
+ eventSequence: result.eventSequence,
205
+ sessionId: result.sessionId,
206
+ workspaceId: result.workspaceId,
207
+ visibility: sessionVisibilityToPublic(result.visibility),
208
+ authorityEpoch: result.authorityEpoch,
209
+ copiedHistoryItemCount: result.copiedHistoryItemCount,
210
+ replay: result.replay,
211
+ });
212
+ await publishExactCommittedEvent(deps, {
213
+ workspaceId: response.workspaceId,
214
+ sessionId: response.sessionId,
215
+ subjectId: actorSubjectId,
216
+ eventId: response.eventId,
217
+ eventSequence: response.eventSequence,
218
+ });
219
+ return response;
220
+ }
221
+
222
+ export async function updateManagedHumanSessionVisibility(
223
+ deps: SessionTenancyDependencies,
224
+ authorization: AccessGrantAuthorization,
225
+ workspaceId: string,
226
+ sessionId: string,
227
+ request: UpdateSessionVisibilityRequest,
228
+ authorizationSurface: SessionAuthorizationSurface = "core",
229
+ ): Promise<UpdateSessionVisibilityResponse> {
230
+ await requireSessionTenancyMutationGate(deps, authorization, workspaceId, ["sessions:control"]);
231
+ await requireSessionAuthorization(deps, authorization.grant, {
232
+ sessionId,
233
+ operation: "session.visibility.write",
234
+ surface: authorizationSurface,
235
+ });
236
+
237
+ const result = await runTenancyMutation(
238
+ async () =>
239
+ await transitionSessionVisibility(deps.db, {
240
+ workspaceId,
241
+ sessionId,
242
+ actorSubjectId: authorization.grant.subjectId,
243
+ targetVisibility: sessionVisibilityFromPublic(request.visibility),
244
+ expectedAuthorityEpoch: request.expectedAuthorityEpoch,
245
+ operationKey: request.idempotencyKey,
246
+ }),
247
+ );
248
+ const response = UpdateSessionVisibilityResponse.parse({
249
+ operationId: result.operationId,
250
+ eventId: result.eventId,
251
+ eventSequence: result.eventSequence,
252
+ visibility: sessionVisibilityToPublic(result.visibility),
253
+ authorityEpoch: result.authorityEpoch,
254
+ changed: result.changed,
255
+ replay: result.replay,
256
+ revokedGrantCount: result.revokedGrantCount,
257
+ });
258
+ await publishExactCommittedEvent(deps, {
259
+ workspaceId,
260
+ sessionId,
261
+ subjectId: authorization.grant.subjectId,
262
+ eventId: response.eventId,
263
+ eventSequence: response.eventSequence,
264
+ });
265
+ return response;
266
+ }
267
+
268
+ export async function forkManagedHumanSession(
269
+ deps: SessionTenancyDependencies,
270
+ authorization: AccessGrantAuthorization,
271
+ workspaceId: string,
272
+ sourceSessionId: string,
273
+ request: ForkSessionRequest,
274
+ authorizationSurface: SessionAuthorizationSurface = "core",
275
+ ): Promise<ForkSessionResponse> {
276
+ await requireSessionTenancyMutationGate(deps, authorization, workspaceId, [
277
+ "sessions:read",
278
+ "sessions:create",
279
+ ]);
280
+ const forkInput = {
281
+ sourceWorkspaceId: workspaceId,
282
+ sourceSessionId,
283
+ actorSubjectId: authorization.grant.subjectId,
284
+ destinationWorkspaceId: workspaceId,
285
+ destinationVisibility:
286
+ request.visibility === "private" ? ("user_private" as const) : ("workspace_shared" as const),
287
+ workspaceSharedAcknowledged: request.workspaceSharedAcknowledged,
288
+ operationKey: request.idempotencyKey,
289
+ };
290
+ const recoverAppliedReceipt = async () =>
291
+ await runTenancyMutation(async () => await replayAppliedSessionFork(deps.db, forkInput));
292
+
293
+ // A committed response is owned by the exact actor/key/request tuple and no
294
+ // longer depends on mutable source visibility. Resolve it before source/host
295
+ // authorization so a lost response remains recoverable after the source
296
+ // owner makes a formerly shared session private.
297
+ const applied = await recoverAppliedReceipt();
298
+ if (applied) {
299
+ return await projectAndPublishSessionFork(deps, authorization.grant.subjectId, applied);
300
+ }
301
+
302
+ try {
303
+ await requireSessionAuthorization(deps, authorization.grant, {
304
+ sessionId: sourceSessionId,
305
+ operation: "session.fork.create",
306
+ surface: authorizationSurface,
307
+ });
308
+ } catch (authorizationError) {
309
+ // Close the race where a duplicate request checked before the first commit
310
+ // but mutable source/host authority changed immediately after it. Only an
311
+ // exact applied receipt can convert the authorization failure into replay.
312
+ const racedApplied = await recoverAppliedReceipt();
313
+ if (racedApplied) {
314
+ return await projectAndPublishSessionFork(deps, authorization.grant.subjectId, racedApplied);
315
+ }
316
+ throw authorizationError;
317
+ }
318
+
319
+ const result = await runTenancyMutation(async () => await forkSessionContent(deps.db, forkInput));
320
+ return await projectAndPublishSessionFork(deps, authorization.grant.subjectId, result);
321
+ }
@@ -0,0 +1,140 @@
1
+ import type {
2
+ IssueUserResourceGrantRequest,
3
+ Permission,
4
+ SessionAuthorizationSurface,
5
+ UserResourceKind,
6
+ } from "@opengeni/contracts";
7
+ import {
8
+ issueSelfUserResourceGrant,
9
+ listSelfUserResourceAuthorities,
10
+ revokeSelfUserResourceGrant,
11
+ sessionTenancyProductActivated,
12
+ SessionTenancyNotActivatedError,
13
+ } from "@opengeni/db";
14
+ import { requirePermission, type AccessGrantAuthorization } from "../access";
15
+ import type { AppDependencies } from "../dependencies";
16
+ import { requireSessionAuthorization } from "../session-authorization";
17
+ import { requireCanonicalManagedHuman } from "./session-tenancy";
18
+
19
+ type UserResourceGrantDependencies = Pick<AppDependencies, "db" | "sessionAuthorization">;
20
+
21
+ const LIST_PERMISSIONS = {
22
+ connection: ["connections:read"],
23
+ document: ["documents:search"],
24
+ variable_set: ["variable-sets:list"],
25
+ rig: ["rigs:use"],
26
+ connected_machine: ["enrollments:read"],
27
+ } as const satisfies Record<UserResourceKind, readonly Permission[]>;
28
+
29
+ const ISSUE_PERMISSIONS = {
30
+ connection: ["connections:read"],
31
+ document: ["documents:search"],
32
+ variable_set: ["variable-sets:list", "variable-sets:attach", "variable-sets:use"],
33
+ rig: ["rigs:use"],
34
+ connected_machine: ["enrollments:read"],
35
+ } as const satisfies Record<UserResourceKind, readonly Permission[]>;
36
+
37
+ async function requireOwnerProductGate(
38
+ deps: Pick<UserResourceGrantDependencies, "db">,
39
+ authorization: AccessGrantAuthorization,
40
+ workspaceId: string,
41
+ permissions: readonly Permission[],
42
+ ): Promise<void> {
43
+ // Target-free gates must run before an optional target-session lookup or host
44
+ // callback so rejected principals cannot use grant management as an oracle.
45
+ requireOwnerAuthority(authorization, workspaceId, permissions);
46
+ if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
47
+ throw new SessionTenancyNotActivatedError();
48
+ }
49
+ }
50
+
51
+ function requireOwnerAuthority(
52
+ authorization: AccessGrantAuthorization,
53
+ workspaceId: string,
54
+ permissions: readonly Permission[],
55
+ ): void {
56
+ requireCanonicalManagedHuman(authorization, workspaceId);
57
+ for (const permission of permissions) requirePermission(authorization.grant, permission);
58
+ }
59
+
60
+ export async function listManagedHumanUserResourceAuthorities(
61
+ deps: Pick<UserResourceGrantDependencies, "db">,
62
+ authorization: AccessGrantAuthorization,
63
+ workspaceId: string,
64
+ input: { resourceKind: UserResourceKind; cursor?: string | undefined; limit: number },
65
+ ) {
66
+ requireOwnerAuthority(authorization, workspaceId, LIST_PERMISSIONS[input.resourceKind]);
67
+ // Before activation there cannot be usable personal-resource authority in
68
+ // this organization. Discovery therefore has an exact empty answer; only
69
+ // issue/revoke and runtime use remain behind the forward-only product gate.
70
+ // This keeps ordinary workspace-owned Rig/Variable Set selection independent
71
+ // from an optional personal-resource product that is not enabled.
72
+ if (!(await sessionTenancyProductActivated(deps.db, workspaceId))) {
73
+ return { authorities: [], nextCursor: null };
74
+ }
75
+ return await listSelfUserResourceAuthorities(deps.db, {
76
+ accountId: authorization.grant.accountId,
77
+ workspaceId,
78
+ subjectId: authorization.grant.subjectId,
79
+ ...input,
80
+ });
81
+ }
82
+
83
+ export async function issueManagedHumanUserResourceGrant(
84
+ deps: UserResourceGrantDependencies,
85
+ authorization: AccessGrantAuthorization,
86
+ workspaceId: string,
87
+ authorityId: string,
88
+ request: IssueUserResourceGrantRequest,
89
+ authorizationSurface: SessionAuthorizationSurface = "core",
90
+ ) {
91
+ const modePermissions: Permission[] =
92
+ request.mode === "session" ? ["sessions:control"] : ["sessions:create"];
93
+ await requireOwnerProductGate(deps, authorization, workspaceId, [
94
+ ...ISSUE_PERMISSIONS[request.resourceKind],
95
+ ...modePermissions,
96
+ ]);
97
+
98
+ if (request.mode === "session") {
99
+ // Contract refinement guarantees both fields. Keep the defensive check so
100
+ // direct core callers cannot accidentally weaken the target fence.
101
+ if (!request.sessionId || !request.expectedAuthorityEpoch) {
102
+ throw new TypeError("session grant requires its exact authority epoch");
103
+ }
104
+ await requireSessionAuthorization(deps, authorization.grant, {
105
+ sessionId: request.sessionId,
106
+ operation: "session.personal_resource.grant",
107
+ surface: authorizationSurface,
108
+ });
109
+ }
110
+
111
+ return await issueSelfUserResourceGrant(deps.db, {
112
+ accountId: authorization.grant.accountId,
113
+ workspaceId,
114
+ subjectId: authorization.grant.subjectId,
115
+ authorityId,
116
+ resourceKind: request.resourceKind,
117
+ mode: request.mode,
118
+ context: request.context,
119
+ sessionId: request.sessionId ?? null,
120
+ expectedAuthorityEpoch: request.expectedAuthorityEpoch ?? null,
121
+ workspaceSharedAcknowledged: request.workspaceSharedAcknowledged,
122
+ });
123
+ }
124
+
125
+ export async function revokeManagedHumanUserResourceGrant(
126
+ deps: Pick<UserResourceGrantDependencies, "db">,
127
+ authorization: AccessGrantAuthorization,
128
+ workspaceId: string,
129
+ grantId: string,
130
+ ) {
131
+ // Revocation narrows authority. It intentionally does not require the
132
+ // resource-specific permission that may have been removed since issuance.
133
+ await requireOwnerProductGate(deps, authorization, workspaceId, []);
134
+ return await revokeSelfUserResourceGrant(deps.db, {
135
+ accountId: authorization.grant.accountId,
136
+ workspaceId,
137
+ subjectId: authorization.grant.subjectId,
138
+ grantId,
139
+ });
140
+ }
@@ -1,4 +1,4 @@
1
- import { configuredStaticUsageLimits } from "@opengeni/config";
1
+ import { configuredStaticUsageLimits, resolveModelProvider } from "@opengeni/config";
2
2
  import type {
3
3
  LimitAction,
4
4
  LimitDecision,
@@ -25,12 +25,11 @@ export type LimitCheckInput = {
25
25
  workspaceId?: string;
26
26
  action: LimitAction;
27
27
  quantity?: number;
28
- // The turn's model id, when the action represents an agent turn. When this is a
29
- // Codex-billed turn (codex/<slug> + feature enabled + active workspace
30
- // credential) the turn is paid by the user's ChatGPT/Codex plan and consumes
31
- // ZERO OpenGeni credits, so the credit-balance + model-cost + token gates are
32
- // skipped. Non-model infra actions (workspace/api_key/schedule create) leave
33
- // this undefined and are unaffected.
28
+ // The turn's model id, when the action represents an agent turn. Externally
29
+ // billed models consume ZERO OpenGeni credits, so the credit-balance +
30
+ // model-cost + token gates are skipped. Connected subscriptions still require
31
+ // live workspace readiness; an explicitly anonymous deployment route is
32
+ // statically ready by definition. Non-model infra actions leave this undefined.
34
33
  model?: string | null;
35
34
  };
36
35
 
@@ -59,23 +58,34 @@ export async function checkLimit(
59
58
  model: input.model,
60
59
  })
61
60
  : false;
62
- const creditDecision = await checkCreditBalance(deps, input, codexBilled);
61
+ const credentialFreeExternal = input.model
62
+ ? (() => {
63
+ const resolved = resolveModelProvider(deps.settings, input.model);
64
+ return (
65
+ resolved?.model.billing.metering === "external" &&
66
+ resolved.model.credentialSource.kind === "deployment" &&
67
+ resolved.model.credentialSource.mechanism === "none"
68
+ );
69
+ })()
70
+ : false;
71
+ const externallyBilled = codexBilled || credentialFreeExternal;
72
+ const creditDecision = await checkCreditBalance(deps, input, externallyBilled);
63
73
  if (!creditDecision.allowed) {
64
74
  return creditDecision;
65
75
  }
66
76
  if (deps.settings.usageLimitsMode !== "static" && deps.settings.usageLimitsMode !== "managed") {
67
77
  return { allowed: true };
68
78
  }
69
- return await checkStaticCaps(deps, input, codexBilled);
79
+ return await checkStaticCaps(deps, input, externallyBilled);
70
80
  }
71
81
 
72
82
  async function checkCreditBalance(
73
83
  deps: LimitDependencies,
74
84
  input: LimitCheckInput,
75
- codexBilled: boolean,
85
+ externallyBilled: boolean,
76
86
  ): Promise<LimitDecision> {
77
- if (codexBilled) {
78
- return { allowed: true }; // paid by the user's ChatGPT/Codex plan — zero OpenGeni credits
87
+ if (externallyBilled) {
88
+ return { allowed: true }; // paid outside OpenGeni — zero OpenGeni credits
79
89
  }
80
90
  if (!usesCreditLimits(deps) || !isCostlyAction(input.action)) {
81
91
  return { allowed: true };
@@ -90,10 +100,10 @@ async function checkCreditBalance(
90
100
  async function checkStaticCaps(
91
101
  deps: LimitDependencies,
92
102
  input: LimitCheckInput,
93
- codexBilled: boolean,
103
+ externallyBilled: boolean,
94
104
  ): Promise<LimitDecision> {
95
105
  const limits = configuredStaticUsageLimits(deps.settings);
96
- if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !codexBilled) {
106
+ if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !externallyBilled) {
97
107
  const used = await sumUsageQuantity(deps.db, {
98
108
  accountId: input.accountId,
99
109
  eventType: "model.cost",
@@ -172,7 +182,7 @@ async function checkStaticCaps(
172
182
  );
173
183
  }
174
184
  case "tokens:consume": {
175
- if (codexBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
185
+ if (externallyBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
176
186
  return { allowed: true };
177
187
  }
178
188
  const used = await sumUsageQuantity(deps.db, {
@@ -25,6 +25,11 @@ import type {
25
25
  } from "./editable-artifacts";
26
26
 
27
27
  export type SessionWorkflowClient = {
28
+ triggerAutomationRun?: (input: {
29
+ accountId: string;
30
+ workspaceId: string;
31
+ runId: string;
32
+ }) => Promise<void>;
28
33
  signalUserMessage: (input: {
29
34
  sessionId: string;
30
35
  eventId: string;
@@ -108,7 +113,11 @@ export type AppDependencies = {
108
113
  editableArtifactOfficeImports?: EditableArtifactOfficeImportPort;
109
114
  bus: EventBus;
110
115
  workflowClient: SessionWorkflowClient;
111
- /** Injectable structural seam for replayable prompt fanout and wake work. */
116
+ /**
117
+ * Injectable structural seam for replayable post-commit fanout and wake work.
118
+ * The historical name is retained for host compatibility; every interactive
119
+ * session command now uses this boundary, not only prompt submission.
120
+ */
112
121
  schedulePromptPostCommit?: (task: () => Promise<void>) => void;
113
122
  /** Optional provider override for deterministic API/object-storage tests. */
114
123
  objectStorage?: ObjectStorageDependency;
@@ -138,6 +147,8 @@ export type AppDependencies = {
138
147
  managedAuth?: ManagedAuth | null;
139
148
  /** Injectable Codex HTTP transport for deterministic API/provider tests. */
140
149
  codexFetch?: typeof fetch;
150
+ /** Injectable GitHub transport for deterministic personal-OAuth tests. */
151
+ githubPersonalFetch?: typeof fetch;
141
152
  /** Injectable xAI OAuth/subscription transport for deterministic API/provider tests. */
142
153
  xaiFetch?: typeof fetch;
143
154
  /** Injectable Slack Web API transport for deterministic bot-connection tests. */