@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.
@@ -51,9 +51,13 @@ export function assertWorkspaceMemberRemovable(input: {
51
51
  throw new HTTPException(404, { message: "member not found" });
52
52
  }
53
53
  if (memberCanAdminister(target)) {
54
- const remainingAdmins = members.filter((member) => member.subjectId !== subjectId && memberCanAdminister(member));
54
+ const remainingAdmins = members.filter(
55
+ (member) => member.subjectId !== subjectId && memberCanAdminister(member),
56
+ );
55
57
  if (remainingAdmins.length === 0) {
56
- throw new HTTPException(409, { message: "cannot remove the last member who can manage this workspace" });
58
+ throw new HTTPException(409, {
59
+ message: "cannot remove the last member who can manage this workspace",
60
+ });
57
61
  }
58
62
  }
59
63
  }
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export * from "./billing/limits";
52
52
  // scheduled-task/workspace-member logic, …).
53
53
  export * from "./domain/capabilities";
54
54
  export * from "./domain/environments";
55
+ export * from "./rigs";
55
56
  export * from "./domain/packs";
56
57
  export * from "./domain/resources";
57
58
  export * from "./domain/scheduled-tasks";
@@ -0,0 +1,540 @@
1
+ // packages/core/src/rigs/index.ts — the rig domain: the workspace-scoped,
2
+ // versioned sandbox-machine-definition business logic the REST routes (M2) and
3
+ // (later) the MCP rig tools (M4) both call. Validation + audit events live here;
4
+ // the raw RLS-scoped persistence lives in @opengeni/db. M4 adds verification /
5
+ // auto-merge / promotion on top of the change substrate created here.
6
+
7
+ import type {
8
+ AccessGrant,
9
+ CreateRigRequest,
10
+ RigDefinitionEditPayload,
11
+ ProposeRigChangeRequest,
12
+ Rig,
13
+ RigChange,
14
+ RigVersion,
15
+ UpdateRigRequest,
16
+ } from "@opengeni/contracts";
17
+ import {
18
+ activateRigVersion,
19
+ countRigs,
20
+ createRig,
21
+ createRigChange,
22
+ createRigVersion,
23
+ createRigVersionForChangePromotion,
24
+ deleteRigIfNoActiveSessions,
25
+ getRig,
26
+ getRigByName,
27
+ getRigChange,
28
+ getRigVersion,
29
+ getVariableSet,
30
+ listRigChanges,
31
+ listRigVersions,
32
+ recordAuditEvent,
33
+ RigActiveVersionChangedError,
34
+ RigChangeTransitionError,
35
+ updateRig,
36
+ type Database,
37
+ } from "@opengeni/db";
38
+ import { HTTPException } from "hono/http-exception";
39
+
40
+ export const MAX_RIGS_PER_WORKSPACE = 50;
41
+ export const MAX_CHECKS_PER_RIG = 100;
42
+ export const MAX_CREDENTIAL_HOOKS_PER_RIG = 50;
43
+ export const MAX_DEFAULT_VARIABLE_SETS_PER_RIG = 25;
44
+
45
+ export type RigServices = {
46
+ db: Database;
47
+ };
48
+
49
+ type RigAuditAction =
50
+ | "rig.created"
51
+ | "rig.updated"
52
+ | "rig.deleted"
53
+ | "rig.change.proposed"
54
+ | "rig.change.verified"
55
+ | "rig.change.rejected"
56
+ | "rig.change.failed"
57
+ | "rig.change.merged"
58
+ | "rig.verification.started"
59
+ | "rig.verification.passed"
60
+ | "rig.verification.failed"
61
+ | "rig.version.activated"
62
+ | "rig.version.promoted";
63
+
64
+ export async function recordRigAuditEvent(
65
+ db: Database,
66
+ input: {
67
+ grant: AccessGrant;
68
+ action: RigAuditAction;
69
+ rigId: string;
70
+ metadata?: Record<string, unknown>;
71
+ },
72
+ ): Promise<void> {
73
+ await recordAuditEvent(db, {
74
+ accountId: input.grant.accountId,
75
+ workspaceId: input.grant.workspaceId,
76
+ subjectId: input.grant.subjectId,
77
+ action: input.action,
78
+ targetType: "rig",
79
+ targetId: input.rigId,
80
+ metadata: { rigId: input.rigId, ...(input.metadata ?? {}) },
81
+ });
82
+ }
83
+
84
+ // Version attribution string for an API (user-authenticated) mutation. The MCP
85
+ // session path (M4) will use `session:<id>` instead.
86
+ export function rigActorForGrant(grant: AccessGrant): string {
87
+ return `user:${grant.subjectId}`;
88
+ }
89
+
90
+ export async function requireRigForApi(
91
+ db: Database,
92
+ workspaceId: string,
93
+ rigId: string,
94
+ ): Promise<Rig> {
95
+ const rig = await getRig(db, workspaceId, rigId);
96
+ if (!rig) {
97
+ throw new HTTPException(404, { message: "rig not found" });
98
+ }
99
+ return rig;
100
+ }
101
+
102
+ export async function requireRigChangeForApi(
103
+ db: Database,
104
+ workspaceId: string,
105
+ rigId: string,
106
+ changeId: string,
107
+ ): Promise<RigChange> {
108
+ const change = await getRigChange(db, workspaceId, changeId);
109
+ // RLS + the workspace clause make a cross-workspace id indistinguishable from
110
+ // missing; the rigId clause keeps the change addressable only under its rig.
111
+ if (!change || change.rigId !== rigId) {
112
+ throw new HTTPException(404, { message: "rig change not found" });
113
+ }
114
+ return change;
115
+ }
116
+
117
+ function trimmedRigName(name: string): string {
118
+ const trimmed = name.trim();
119
+ if (!trimmed) {
120
+ throw new HTTPException(422, { message: "rig name is required" });
121
+ }
122
+ return trimmed;
123
+ }
124
+
125
+ // Duplicate check names would make check results ambiguous; reject them.
126
+ function assertUniqueCheckNames(checks: ReadonlyArray<{ name: string }> | undefined): void {
127
+ if (!checks) {
128
+ return;
129
+ }
130
+ const seen = new Set<string>();
131
+ for (const check of checks) {
132
+ if (seen.has(check.name)) {
133
+ throw new HTTPException(422, { message: `duplicate rig check name: ${check.name}` });
134
+ }
135
+ seen.add(check.name);
136
+ }
137
+ }
138
+
139
+ // Every referenced default variable set must exist in the workspace. RLS makes a
140
+ // cross-workspace id indistinguishable from a missing one, so both map to 422.
141
+ async function assertVariableSetsExist(
142
+ db: Database,
143
+ workspaceId: string,
144
+ ids: ReadonlyArray<string> | undefined,
145
+ ): Promise<void> {
146
+ if (!ids || ids.length === 0) {
147
+ return;
148
+ }
149
+ const unique = [...new Set(ids)];
150
+ for (const id of unique) {
151
+ const variableSet = await getVariableSet(db, workspaceId, id);
152
+ if (!variableSet) {
153
+ throw new HTTPException(422, { message: `unknown defaultVariableSetId: ${id}` });
154
+ }
155
+ }
156
+ }
157
+
158
+ export async function createRigForApi(
159
+ deps: RigServices,
160
+ grant: AccessGrant,
161
+ payload: CreateRigRequest,
162
+ ): Promise<Rig> {
163
+ const workspaceId = grant.workspaceId;
164
+ const name = trimmedRigName(payload.name);
165
+ assertUniqueCheckNames(payload.checks);
166
+ await assertVariableSetsExist(deps.db, workspaceId, payload.defaultVariableSetIds);
167
+ if ((await countRigs(deps.db, workspaceId)) >= MAX_RIGS_PER_WORKSPACE) {
168
+ throw new HTTPException(422, {
169
+ message: `a workspace supports at most ${MAX_RIGS_PER_WORKSPACE} rigs`,
170
+ });
171
+ }
172
+ if (await getRigByName(deps.db, workspaceId, name)) {
173
+ throw new HTTPException(409, { message: `rig name is already in use: ${name}` });
174
+ }
175
+ const createdBy = rigActorForGrant(grant);
176
+ const rig = await createRig(deps.db, {
177
+ accountId: grant.accountId,
178
+ workspaceId,
179
+ name,
180
+ description: payload.description ?? null,
181
+ createdBy,
182
+ initialVersion: {
183
+ image: payload.image ?? null,
184
+ setupScript: payload.setupScript ?? null,
185
+ checks: payload.checks,
186
+ credentialHooks: payload.credentialHooks,
187
+ defaultVariableSetIds: payload.defaultVariableSetIds,
188
+ changelog: "Initial version",
189
+ createdBy,
190
+ },
191
+ });
192
+ await recordRigAuditEvent(deps.db, { grant, action: "rig.created", rigId: rig.id });
193
+ return rig;
194
+ }
195
+
196
+ export async function updateRigForApi(
197
+ deps: RigServices,
198
+ grant: AccessGrant,
199
+ rig: Rig,
200
+ payload: UpdateRigRequest,
201
+ ): Promise<Rig> {
202
+ const workspaceId = grant.workspaceId;
203
+ const name = payload.name !== undefined ? trimmedRigName(payload.name) : undefined;
204
+ if (name !== undefined && name !== rig.name) {
205
+ const existing = await getRigByName(deps.db, workspaceId, name);
206
+ if (existing && existing.id !== rig.id) {
207
+ throw new HTTPException(409, { message: `rig name is already in use: ${name}` });
208
+ }
209
+ }
210
+ const updated = await updateRig(deps.db, workspaceId, rig.id, {
211
+ ...(name !== undefined ? { name } : {}),
212
+ ...(payload.description !== undefined ? { description: payload.description } : {}),
213
+ });
214
+ await recordRigAuditEvent(deps.db, { grant, action: "rig.updated", rigId: rig.id });
215
+ return updated;
216
+ }
217
+
218
+ export async function deleteRigForApi(
219
+ deps: RigServices,
220
+ grant: AccessGrant,
221
+ rig: Rig,
222
+ ): Promise<void> {
223
+ const workspaceId = grant.workspaceId;
224
+ const deleted = await deleteRigIfNoActiveSessions(deps.db, workspaceId, rig.id);
225
+ if (deleted.activeSessionCount > 0) {
226
+ throw new HTTPException(409, {
227
+ message: `rig is referenced by ${deleted.activeSessionCount} active session(s); it cannot be deleted`,
228
+ });
229
+ }
230
+ if (!deleted.deleted) {
231
+ throw new HTTPException(404, { message: "rig not found" });
232
+ }
233
+ await recordRigAuditEvent(deps.db, { grant, action: "rig.deleted", rigId: rig.id });
234
+ }
235
+
236
+ // Records a proposed change against the rig's CURRENT active version (the base
237
+ // clean-replay minted new versions from). Verification / auto-merge is M4; here
238
+ // the row is created in `proposed`. `proposedBy` overrides the actor string for
239
+ // the session-scoped MCP path (M4).
240
+ export async function proposeRigChangeForApi(
241
+ deps: RigServices,
242
+ grant: AccessGrant,
243
+ rig: Rig,
244
+ request: ProposeRigChangeRequest,
245
+ options: { proposedBy?: string } = {},
246
+ ): Promise<RigChange> {
247
+ const workspaceId = grant.workspaceId;
248
+ if (!rig.activeVersion) {
249
+ throw new HTTPException(422, { message: "rig has no active version to base a change on" });
250
+ }
251
+ if (request.kind === "definition_edit") {
252
+ assertUniqueCheckNames(request.payload.checks);
253
+ await assertVariableSetsExist(
254
+ deps.db,
255
+ workspaceId,
256
+ request.payload.defaultVariableSetIds ?? undefined,
257
+ );
258
+ }
259
+ const change = await createRigChange(deps.db, {
260
+ accountId: grant.accountId,
261
+ workspaceId,
262
+ rigId: rig.id,
263
+ baseVersionId: rig.activeVersion.id,
264
+ kind: request.kind,
265
+ payload: request.payload as Record<string, unknown>,
266
+ proposedBy: options.proposedBy ?? rigActorForGrant(grant),
267
+ });
268
+ await recordRigAuditEvent(deps.db, {
269
+ grant,
270
+ action: "rig.change.proposed",
271
+ rigId: rig.id,
272
+ metadata: { changeId: change.id, kind: change.kind },
273
+ });
274
+ return change;
275
+ }
276
+
277
+ export type RigVerificationClassification =
278
+ | { status: "merged"; action: "auto_promote" }
279
+ | { status: "proposed"; action: "await_manage_promote" }
280
+ | { status: "rejected"; action: "reject" }
281
+ | { status: "failed"; action: "retryable_failure" };
282
+
283
+ export function classifyRigVerificationOutcome(input: {
284
+ kind: "setup_append" | "definition_edit";
285
+ passed: boolean;
286
+ infraError?: boolean;
287
+ }): RigVerificationClassification {
288
+ if (input.infraError) {
289
+ return { status: "failed", action: "retryable_failure" };
290
+ }
291
+ if (!input.passed) {
292
+ return { status: "rejected", action: "reject" };
293
+ }
294
+ if (input.kind === "setup_append") {
295
+ return { status: "merged", action: "auto_promote" };
296
+ }
297
+ return { status: "proposed", action: "await_manage_promote" };
298
+ }
299
+
300
+ export function appendRigSetupCommand(
301
+ baseSetupScript: string | null | undefined,
302
+ command: string,
303
+ ): string {
304
+ const base = (baseSetupScript ?? "").trimEnd();
305
+ return base ? `${base}\n${command}` : command;
306
+ }
307
+
308
+ async function promoteChangeWithActiveCas(
309
+ deps: RigServices,
310
+ workspaceId: string,
311
+ rigId: string,
312
+ changeId: string,
313
+ input: Parameters<typeof createRigVersionForChangePromotion>[4],
314
+ ): Promise<{ version: RigVersion; change: RigChange }> {
315
+ try {
316
+ return await createRigVersionForChangePromotion(deps.db, workspaceId, rigId, changeId, input);
317
+ } catch (error) {
318
+ if (error instanceof RigActiveVersionChangedError) {
319
+ throw new HTTPException(409, {
320
+ message: `rig moved since this change was verified (base ${error.expectedVersionId}, now ${error.actualVersionId ?? "none"}); re-verify before promoting`,
321
+ });
322
+ }
323
+ if (error instanceof RigChangeTransitionError) {
324
+ throw new HTTPException(409, { message: error.message });
325
+ }
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ export async function promoteSetupAppendChange(
331
+ deps: RigServices,
332
+ grant: AccessGrant,
333
+ rig: Rig,
334
+ change: RigChange,
335
+ ): Promise<{ change: RigChange; version: RigVersion }> {
336
+ if (change.kind !== "setup_append") {
337
+ throw new HTTPException(422, {
338
+ message: "only setup_append changes auto-promote through this path",
339
+ });
340
+ }
341
+ if (change.status !== "proposed" && change.status !== "verifying") {
342
+ throw new HTTPException(409, { message: `rig change is ${change.status}; cannot promote` });
343
+ }
344
+ if (!change.baseVersionId) {
345
+ throw new HTTPException(422, { message: "rig change has no base version" });
346
+ }
347
+ const base = await getRigVersion(deps.db, grant.workspaceId, rig.id, change.baseVersionId);
348
+ if (!base) {
349
+ throw new HTTPException(404, { message: "base rig version not found" });
350
+ }
351
+ const payload = change.payload as { command?: unknown; note?: unknown };
352
+ if (typeof payload.command !== "string" || !payload.command.trim()) {
353
+ throw new HTTPException(422, { message: "setup_append change is missing command" });
354
+ }
355
+ const { version, change: updated } = await promoteChangeWithActiveCas(
356
+ deps,
357
+ grant.workspaceId,
358
+ rig.id,
359
+ change.id,
360
+ {
361
+ expectedActiveVersionId: change.baseVersionId,
362
+ image: base.image,
363
+ setupScript: appendRigSetupCommand(base.setupScript, payload.command),
364
+ checks: base.checks,
365
+ credentialHooks: base.credentialHooks,
366
+ defaultVariableSetIds: base.defaultVariableSetIds,
367
+ changelog:
368
+ typeof payload.note === "string" && payload.note.trim()
369
+ ? payload.note
370
+ : "Verified setup append",
371
+ createdBy: change.proposedBy ?? rigActorForGrant(grant),
372
+ },
373
+ );
374
+ await recordRigAuditEvent(deps.db, {
375
+ grant,
376
+ action: "rig.change.merged",
377
+ rigId: rig.id,
378
+ metadata: { changeId: change.id, versionId: version.id, version: version.version },
379
+ });
380
+ await recordRigAuditEvent(deps.db, {
381
+ grant,
382
+ action: "rig.version.promoted",
383
+ rigId: rig.id,
384
+ metadata: { changeId: change.id, versionId: version.id, version: version.version },
385
+ });
386
+ return { change: updated, version };
387
+ }
388
+
389
+ export async function promoteVerifiedDefinitionEditChangeForApi(
390
+ deps: RigServices,
391
+ grant: AccessGrant,
392
+ rig: Rig,
393
+ change: RigChange,
394
+ ): Promise<{ change: RigChange; version: RigVersion }> {
395
+ if (change.kind !== "definition_edit") {
396
+ throw new HTTPException(422, { message: "only definition_edit changes use explicit promote" });
397
+ }
398
+ if (change.status !== "proposed") {
399
+ throw new HTTPException(409, { message: `rig change is ${change.status}; cannot promote` });
400
+ }
401
+ if (change.verification?.passed !== true) {
402
+ throw new HTTPException(422, {
403
+ message: "definition_edit change must pass verification before promote",
404
+ });
405
+ }
406
+ if (!change.baseVersionId) {
407
+ throw new HTTPException(422, { message: "rig change has no base version" });
408
+ }
409
+ const base = await getRigVersion(deps.db, grant.workspaceId, rig.id, change.baseVersionId);
410
+ if (!base) {
411
+ throw new HTTPException(404, { message: "base rig version not found" });
412
+ }
413
+ const payload = change.payload as {
414
+ image?: unknown;
415
+ setupScript?: unknown;
416
+ checks?: unknown;
417
+ credentialHooks?: unknown;
418
+ defaultVariableSetIds?: unknown;
419
+ changelog?: unknown;
420
+ };
421
+ const { version, change: updated } = await promoteChangeWithActiveCas(
422
+ deps,
423
+ grant.workspaceId,
424
+ rig.id,
425
+ change.id,
426
+ {
427
+ expectedActiveVersionId: change.baseVersionId,
428
+ image: payload.image === undefined ? base.image : (payload.image as string | null),
429
+ setupScript:
430
+ payload.setupScript === undefined
431
+ ? base.setupScript
432
+ : (payload.setupScript as string | null),
433
+ checks: Array.isArray(payload.checks)
434
+ ? (payload.checks as RigVersion["checks"])
435
+ : base.checks,
436
+ credentialHooks: Array.isArray(payload.credentialHooks)
437
+ ? (payload.credentialHooks as string[])
438
+ : base.credentialHooks,
439
+ defaultVariableSetIds: Array.isArray(payload.defaultVariableSetIds)
440
+ ? (payload.defaultVariableSetIds as string[])
441
+ : base.defaultVariableSetIds,
442
+ changelog:
443
+ typeof payload.changelog === "string" && payload.changelog.trim()
444
+ ? payload.changelog
445
+ : "Verified definition edit",
446
+ createdBy: rigActorForGrant(grant),
447
+ },
448
+ );
449
+ await recordRigAuditEvent(deps.db, {
450
+ grant,
451
+ action: "rig.change.merged",
452
+ rigId: rig.id,
453
+ metadata: { changeId: change.id, versionId: version.id, version: version.version },
454
+ });
455
+ await recordRigAuditEvent(deps.db, {
456
+ grant,
457
+ action: "rig.version.promoted",
458
+ rigId: rig.id,
459
+ metadata: { changeId: change.id, versionId: version.id, version: version.version },
460
+ });
461
+ return { change: updated, version };
462
+ }
463
+
464
+ export async function createRigVersionForApi(
465
+ deps: RigServices,
466
+ grant: AccessGrant,
467
+ rig: Rig,
468
+ payload: RigDefinitionEditPayload,
469
+ ): Promise<RigVersion> {
470
+ if (!rig.activeVersion) {
471
+ throw new HTTPException(422, { message: "rig has no active version" });
472
+ }
473
+ assertUniqueCheckNames(payload.checks);
474
+ await assertVariableSetsExist(
475
+ deps.db,
476
+ grant.workspaceId,
477
+ payload.defaultVariableSetIds ?? undefined,
478
+ );
479
+ const base = rig.activeVersion;
480
+ const version = await createRigVersion(
481
+ deps.db,
482
+ grant.workspaceId,
483
+ rig.id,
484
+ {
485
+ image: payload.image === undefined ? base.image : payload.image,
486
+ setupScript: payload.setupScript === undefined ? base.setupScript : payload.setupScript,
487
+ checks: payload.checks ?? base.checks,
488
+ credentialHooks: payload.credentialHooks ?? base.credentialHooks,
489
+ defaultVariableSetIds: payload.defaultVariableSetIds ?? base.defaultVariableSetIds,
490
+ changelog: payload.changelog ?? "Manager-created version",
491
+ createdBy: rigActorForGrant(grant),
492
+ },
493
+ { activate: true },
494
+ );
495
+ await recordRigAuditEvent(deps.db, {
496
+ grant,
497
+ action: "rig.version.promoted",
498
+ rigId: rig.id,
499
+ metadata: { versionId: version.id, version: version.version, direct: true },
500
+ });
501
+ return version;
502
+ }
503
+
504
+ // Rollback / promote-activate: flips which existing version is active. Mints no
505
+ // new version and never touches content.
506
+ export async function activateRigVersionForApi(
507
+ deps: RigServices,
508
+ grant: AccessGrant,
509
+ rig: Rig,
510
+ versionId: string,
511
+ ): Promise<RigVersion> {
512
+ const workspaceId = grant.workspaceId;
513
+ const version = await activateRigVersion(deps.db, workspaceId, rig.id, versionId);
514
+ await recordRigAuditEvent(deps.db, {
515
+ grant,
516
+ action: "rig.version.activated",
517
+ rigId: rig.id,
518
+ metadata: { versionId: version.id, version: version.version },
519
+ });
520
+ return version;
521
+ }
522
+
523
+ // Read pass-throughs (route-facing; keep the route thin and the imports in one
524
+ // place). Versions/changes are always addressed under their rig.
525
+ export async function listRigVersionsForApi(
526
+ deps: RigServices,
527
+ workspaceId: string,
528
+ rigId: string,
529
+ ): Promise<RigVersion[]> {
530
+ return await listRigVersions(deps.db, workspaceId, rigId);
531
+ }
532
+
533
+ export async function listRigChangesForApi(
534
+ deps: RigServices,
535
+ workspaceId: string,
536
+ rigId: string,
537
+ limit?: number,
538
+ ): Promise<RigChange[]> {
539
+ return await listRigChanges(deps.db, workspaceId, rigId, limit);
540
+ }