@opengeni/api-router 0.30.3 → 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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -15,7 +15,11 @@ import {
15
15
  import type { Hono } from "hono";
16
16
  import { HTTPException } from "hono/http-exception";
17
17
  import { z } from "zod";
18
- import { requireAccessGrant } from "@opengeni/core";
18
+ import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/core";
19
+ import {
20
+ assertPersonalConnectionOwnerPrincipal,
21
+ isPersonalConnectionOwnerPrincipal,
22
+ } from "../connection-ownership";
19
23
  import type { ApiRouteDeps } from "@opengeni/core";
20
24
  import { boundedLimit } from "../http/common";
21
25
  import { completeSocialOAuthCallback, startSocialOAuth } from "../integrations/social-oauth";
@@ -99,18 +103,26 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
99
103
  });
100
104
  }
101
105
  const payload = parsed.data;
102
- const grant = await requireAccessGrant(
106
+ const access = await requireAccessGrantAuthorization(
103
107
  c,
104
108
  deps,
105
109
  workspaceId,
106
110
  payload.ownership === "workspace" ? "workspace:admin" : "workspace:read",
107
111
  );
112
+ // The contract already defaults this request to workspace ownership; an
113
+ // explicit personal choice still needs a human who can own it.
114
+ const personalOwnershipAllowed = isPersonalConnectionOwnerPrincipal(access);
115
+ if (payload.ownership === "personal") {
116
+ assertPersonalConnectionOwnerPrincipal(access);
117
+ }
118
+ const grant = access.grant;
108
119
  const result = await startSocialOAuth(
109
120
  { db, settings, observability },
110
121
  {
111
122
  accountId: grant.accountId,
112
123
  workspaceId,
113
124
  subjectId: grant.subjectId,
125
+ personalOwnershipAllowed,
114
126
  requestUrl: c.req.url,
115
127
  payload,
116
128
  },
@@ -31,11 +31,11 @@ import {
31
31
  XAI_CLIENT_VERSION,
32
32
  XaiSubscriptionError,
33
33
  fetchXaiSubscriptionModels,
34
- fetchXaiVerifiedIdentity,
35
34
  pollXaiDeviceCode,
36
35
  refreshXaiToken,
37
36
  requestXaiDeviceCode,
38
37
  xaiAccessTokenExpiry,
38
+ xaiIdentityFromDeviceTokens,
39
39
  type XaiFetch,
40
40
  type XaiProxyAuthContext,
41
41
  } from "@opengeni/xai-subscription";
@@ -392,6 +392,7 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
392
392
  if (Math.floor(Date.now() / 1_000) >= state.expiresAt) {
393
393
  return c.json({ status: "expired" as const });
394
394
  }
395
+ let phase = "device_token_exchange";
395
396
  try {
396
397
  const poll = await pollXaiDeviceCode(
397
398
  {
@@ -401,9 +402,9 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
401
402
  { fetch: (deps.xaiFetch ?? fetch) as XaiFetch },
402
403
  );
403
404
  if (poll.status !== "authorized") return c.json(poll);
404
- const identity = await fetchXaiVerifiedIdentity(poll.tokens.accessToken, {
405
- fetch: (deps.xaiFetch ?? fetch) as XaiFetch,
406
- });
405
+ phase = "token_identity";
406
+ const identity = xaiIdentityFromDeviceTokens(poll.tokens);
407
+ phase = "credential_persist";
407
408
  const encryptionKey = environmentsEncryptionKeyBytes(deps.settings);
408
409
  if (!encryptionKey) {
409
410
  throw new HTTPException(500, {
@@ -458,6 +459,24 @@ export function registerSuperGrokRoutes(app: Hono, deps: ApiRouteDeps): void {
458
459
  email: identity.email,
459
460
  });
460
461
  } catch (error) {
462
+ deps.observability?.warn("SuperGrok connection operation failed", {
463
+ provider: "xai",
464
+ providerApi: "oauth",
465
+ op: phase,
466
+ outcome: "failed",
467
+ reason:
468
+ error instanceof XaiSubscriptionError
469
+ ? error.kind
470
+ : error instanceof HTTPException
471
+ ? "http_exception"
472
+ : "unexpected",
473
+ status:
474
+ error instanceof XaiSubscriptionError
475
+ ? error.status
476
+ : error instanceof HTTPException
477
+ ? error.status
478
+ : undefined,
479
+ });
461
480
  throw xaiHttpError(error, "SuperGrok device login failed");
462
481
  }
463
482
  });
@@ -17,6 +17,7 @@ import {
17
17
  TranscriptionServiceError,
18
18
  type ApiRouteDeps,
19
19
  } from "@opengeni/core";
20
+ import { retryWhileMissing } from "@opengeni/storage";
20
21
  import {
21
22
  claimNextTranscriptionRecordingSegment,
22
23
  claimTranscriptionRecordingAssembly,
@@ -394,7 +395,10 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
394
395
  );
395
396
  }
396
397
  segmentNumber = claim.segment.segmentNumber;
397
- const stored = await deps.objectStorage?.getObjectBytes(claim.segment.objectKey);
398
+ const segmentKey = claim.segment.objectKey;
399
+ const stored = deps.objectStorage
400
+ ? await retryWhileMissing(async () => deps.objectStorage!.getObjectBytes(segmentKey))
401
+ : null;
398
402
  if (!stored) {
399
403
  throw new RecordingProcessingError("Provider segment is missing", "invalid_audio", false);
400
404
  }
@@ -578,7 +582,9 @@ async function* verifiedChunkBytes(
578
582
  }
579
583
  let stored: Awaited<ReturnType<NonNullable<ApiRouteDeps["objectStorage"]>["getObjectBytes"]>>;
580
584
  try {
581
- stored = await deps.objectStorage!.getObjectBytes(chunk.objectKey);
585
+ stored = await retryWhileMissing(async () =>
586
+ deps.objectStorage!.getObjectBytes(chunk.objectKey),
587
+ );
582
588
  } catch {
583
589
  throw new RecordingProcessingError("Chunk download failed", "network", true);
584
590
  }
@@ -0,0 +1,138 @@
1
+ import {
2
+ IssueUserResourceGrantRequest,
3
+ ListUserResourceAuthoritiesQuery,
4
+ ListUserResourceAuthoritiesResponse,
5
+ RevokeUserResourceGrantQuery,
6
+ UserResourceGrantMutationResponse,
7
+ UserResourceGrantRevocationResponse,
8
+ } from "@opengeni/contracts";
9
+ import {
10
+ issueManagedHumanUserResourceGrant,
11
+ listManagedHumanUserResourceAuthorities,
12
+ requireAccessGrantAuthorization,
13
+ revokeManagedHumanUserResourceGrant,
14
+ SessionAuthorizationDeniedError,
15
+ SessionAuthorizationUnavailableError,
16
+ SessionTenancyManagedHumanRequiredError,
17
+ type ApiRouteDeps,
18
+ } from "@opengeni/core";
19
+ import { nestedPostgresSqlState, SessionTenancyNotActivatedError } from "@opengeni/db";
20
+ import type { Context, Hono } from "hono";
21
+ import { HTTPException } from "hono/http-exception";
22
+ import { z } from "zod";
23
+
24
+ async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
25
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
26
+ if (!parsed.success) throw new HTTPException(422, { message: "invalid user-resource request" });
27
+ return parsed.data;
28
+ }
29
+
30
+ function lifecycleError(error: unknown): never {
31
+ if (
32
+ error instanceof SessionAuthorizationDeniedError ||
33
+ error instanceof SessionTenancyManagedHumanRequiredError ||
34
+ error instanceof SessionTenancyNotActivatedError
35
+ ) {
36
+ throw new HTTPException(403, { message: "user-resource authority denied" });
37
+ }
38
+ if (error instanceof SessionAuthorizationUnavailableError) {
39
+ throw new HTTPException(503, { message: "session authorization unavailable" });
40
+ }
41
+ if (nestedPostgresSqlState(error) === "42501") {
42
+ throw new HTTPException(403, { message: "user-resource authority denied" });
43
+ }
44
+ if (nestedPostgresSqlState(error) === "22023") {
45
+ throw new HTTPException(422, { message: "invalid user-resource request" });
46
+ }
47
+ throw error;
48
+ }
49
+
50
+ export function registerUserResourceAuthorityRoutes(app: Hono, deps: ApiRouteDeps): void {
51
+ const base = "/v1/workspaces/:workspaceId/user-resource-authorities";
52
+ app.get(base, async (context) => {
53
+ const query = ListUserResourceAuthoritiesQuery.safeParse({
54
+ scope: context.req.query("scope"),
55
+ resourceKind: context.req.query("resourceKind"),
56
+ cursor: context.req.query("cursor"),
57
+ limit: context.req.query("limit"),
58
+ });
59
+ if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
60
+ const workspaceId = context.req.param("workspaceId");
61
+ const access = await requireAccessGrantAuthorization(
62
+ context,
63
+ deps,
64
+ workspaceId,
65
+ "workspace:read",
66
+ );
67
+ try {
68
+ const page = await listManagedHumanUserResourceAuthorities(
69
+ deps,
70
+ access,
71
+ workspaceId,
72
+ query.data,
73
+ );
74
+ return context.json(
75
+ ListUserResourceAuthoritiesResponse.parse({
76
+ scope: "user",
77
+ ...page,
78
+ }),
79
+ );
80
+ } catch (error) {
81
+ return lifecycleError(error);
82
+ }
83
+ });
84
+
85
+ app.post(`${base}/:authorityId/grants`, async (context) => {
86
+ const workspaceId = context.req.param("workspaceId");
87
+ const authorityId = z.string().uuid().safeParse(context.req.param("authorityId"));
88
+ if (!authorityId.success) throw new HTTPException(422, { message: "invalid authority id" });
89
+ const request = await body(context, IssueUserResourceGrantRequest);
90
+ const access = await requireAccessGrantAuthorization(
91
+ context,
92
+ deps,
93
+ workspaceId,
94
+ "workspace:read",
95
+ );
96
+ try {
97
+ return context.json(
98
+ UserResourceGrantMutationResponse.parse({
99
+ scope: "user",
100
+ grant: await issueManagedHumanUserResourceGrant(
101
+ deps,
102
+ access,
103
+ workspaceId,
104
+ authorityId.data,
105
+ request,
106
+ "http",
107
+ ),
108
+ }),
109
+ );
110
+ } catch (error) {
111
+ return lifecycleError(error);
112
+ }
113
+ });
114
+
115
+ app.delete(`${base}/grants/:grantId`, async (context) => {
116
+ const query = RevokeUserResourceGrantQuery.safeParse({ scope: context.req.query("scope") });
117
+ if (!query.success) throw new HTTPException(422, { message: "explicit scope=user required" });
118
+ const grantId = z.string().uuid().safeParse(context.req.param("grantId"));
119
+ if (!grantId.success) throw new HTTPException(422, { message: "invalid grant id" });
120
+ const workspaceId = context.req.param("workspaceId");
121
+ const access = await requireAccessGrantAuthorization(
122
+ context,
123
+ deps,
124
+ workspaceId,
125
+ "workspace:read",
126
+ );
127
+ try {
128
+ return context.json(
129
+ UserResourceGrantRevocationResponse.parse({
130
+ scope: "user",
131
+ grant: await revokeManagedHumanUserResourceGrant(deps, access, workspaceId, grantId.data),
132
+ }),
133
+ );
134
+ } catch (error) {
135
+ return lifecycleError(error);
136
+ }
137
+ });
138
+ }
@@ -11,6 +11,7 @@ import {
11
11
  normalizeWorkspaceArtifactSlug,
12
12
  } from "@opengeni/contracts";
13
13
  import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
14
+ import { retryWhileMissing } from "@opengeni/storage";
14
15
  import {
15
16
  createWorkspaceArtifact,
16
17
  getWorkspaceArtifact,
@@ -197,7 +198,9 @@ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps):
197
198
  artifactId(context),
198
199
  parsedVersion,
199
200
  );
200
- const object = await deps.objectStorage.getObjectBytes(ref.contentKey);
201
+ const object = await retryWhileMissing(async () =>
202
+ deps.objectStorage!.getObjectBytes(ref.contentKey),
203
+ );
201
204
  if (!object) throw new HTTPException(503, { message: "Artifact content is unavailable" });
202
205
  const actualHash = createHash("sha256").update(object.bytes).digest("hex");
203
206
  if (actualHash !== ref.version.contentSha256) {
@@ -215,10 +215,20 @@ async function loadManifest(
215
215
  * manifest blob has been GC'd (all graceful cold-fallback states — never errors).
216
216
  * Inline manifest for ≤2MB, signed URL above.
217
217
  */
218
+ /** Awaited before a signed capture URL is returned to the caller: the route
219
+ * records the metadata-only issuance audit fact. A throwing recorder fails
220
+ * the serve closed - no recorded fact, no bearer capability. */
221
+ export type CaptureSignedUrlIssuanceRecorder = (fact: {
222
+ kind: "capture_manifest" | "capture_file";
223
+ revision: number;
224
+ expiresAt: string;
225
+ }) => Promise<void>;
226
+
218
227
  export async function serveWorkspaceCapture(
219
228
  row: WorkspaceCaptureRow | null,
220
229
  storage: CaptureStoragePort,
221
230
  cache?: WorkspaceCaptureManifestCache,
231
+ onSignedUrlIssued?: CaptureSignedUrlIssuanceRecorder,
222
232
  ): Promise<GetWorkspaceCaptureResponse> {
223
233
  if (!row) {
224
234
  return { available: false };
@@ -274,6 +284,11 @@ export async function serveWorkspaceCapture(
274
284
  key: row.manifestKey,
275
285
  expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
276
286
  });
287
+ await onSignedUrlIssued?.({
288
+ kind: "capture_manifest",
289
+ revision: row.revision,
290
+ expiresAt: signed.expiresAt.toISOString(),
291
+ });
277
292
  return GetWorkspaceCaptureResponse.parse({
278
293
  ...meta,
279
294
  manifest: null,
@@ -293,6 +308,7 @@ export async function serveWorkspaceCaptureFile(
293
308
  row: WorkspaceCaptureRow | null,
294
309
  path: string,
295
310
  storage: CaptureStoragePort,
311
+ onSignedUrlIssued?: CaptureSignedUrlIssuanceRecorder,
296
312
  ): Promise<GetWorkspaceCaptureFileResponse> {
297
313
  const loaded = row ? await loadManifest(row, storage) : null;
298
314
  if (!loaded) {
@@ -347,6 +363,11 @@ export async function serveWorkspaceCaptureFile(
347
363
  key: file.contentRef,
348
364
  expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
349
365
  });
366
+ await onSignedUrlIssued?.({
367
+ kind: "capture_file",
368
+ revision: row!.revision,
369
+ expiresAt: signed.expiresAt.toISOString(),
370
+ });
350
371
  return GetWorkspaceCaptureFileResponse.parse({
351
372
  ...base,
352
373
  encoding: null,
@@ -0,0 +1,228 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ActivateWorkspaceLearningPolicyRevisionRequest,
4
+ CreateWorkspaceLearningPolicyRevisionRequest,
5
+ RollbackWorkspaceLearningPolicyRevisionRequest,
6
+ UndoGovernedLearningActivationHttpRequest,
7
+ WorkspaceLearningHistoryResponse,
8
+ WorkspaceLearningPolicyHistoryQuery,
9
+ WorkspaceLearningPolicyMutationResponse,
10
+ WorkspaceLearningPolicyRevision,
11
+ } from "@opengeni/contracts";
12
+ import {
13
+ publishGovernedLearningEventToSlack,
14
+ requireAccessGrant,
15
+ type ApiRouteDeps,
16
+ } from "@opengeni/core";
17
+ import {
18
+ activateWorkspaceLearningPolicyRevision,
19
+ createWorkspaceLearningPolicyRevision,
20
+ GovernedLearningActivationAuthorityError,
21
+ GovernedLearningActivationConflictError,
22
+ GovernedLearningActivationInvalidOperationError,
23
+ GovernedLearningEvaluationAuthorityError,
24
+ listGovernedLearningActivationHistory,
25
+ listGovernedLearningDecisionReceipts,
26
+ listWorkspaceLearningPolicyHistory,
27
+ rollbackWorkspaceLearningPolicyRevision,
28
+ undoGovernedLearningActivation,
29
+ WorkspaceLearningPolicyAuthorityError,
30
+ WorkspaceLearningPolicyConflictError,
31
+ WorkspaceLearningPolicyInvalidOperationError,
32
+ WorkspaceLearningPolicyNotFoundError,
33
+ WorkspaceLearningPolicyOperationReuseError,
34
+ } from "@opengeni/db";
35
+ import type { Context, Hono } from "hono";
36
+ import { HTTPException } from "hono/http-exception";
37
+ import { z } from "zod";
38
+
39
+ async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
40
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
41
+ if (!parsed.success) {
42
+ throw new HTTPException(422, { message: "Invalid workspace learning request" });
43
+ }
44
+ return parsed.data;
45
+ }
46
+
47
+ function learningError(error: unknown): never {
48
+ if (
49
+ error instanceof WorkspaceLearningPolicyAuthorityError ||
50
+ error instanceof GovernedLearningEvaluationAuthorityError ||
51
+ error instanceof GovernedLearningActivationAuthorityError
52
+ ) {
53
+ throw new HTTPException(403, { message: error.message });
54
+ }
55
+ if (error instanceof WorkspaceLearningPolicyNotFoundError) {
56
+ throw new HTTPException(404, { message: error.message });
57
+ }
58
+ if (
59
+ error instanceof WorkspaceLearningPolicyConflictError ||
60
+ error instanceof WorkspaceLearningPolicyOperationReuseError ||
61
+ error instanceof GovernedLearningActivationConflictError
62
+ ) {
63
+ throw new HTTPException(409, { message: error.message });
64
+ }
65
+ if (
66
+ error instanceof WorkspaceLearningPolicyInvalidOperationError ||
67
+ error instanceof GovernedLearningActivationInvalidOperationError
68
+ ) {
69
+ throw new HTTPException(422, { message: error.message });
70
+ }
71
+ throw error;
72
+ }
73
+
74
+ export function registerWorkspaceLearningRoutes(app: Hono, deps: ApiRouteDeps): void {
75
+ const base = "/v1/workspaces/:workspaceId/learning";
76
+
77
+ app.get(base, async (context) => {
78
+ const workspaceId = context.req.param("workspaceId");
79
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
80
+ const query = WorkspaceLearningPolicyHistoryQuery.safeParse({
81
+ limit: context.req.query("limit"),
82
+ });
83
+ if (!query.success) {
84
+ throw new HTTPException(422, { message: "Invalid workspace learning history query" });
85
+ }
86
+ try {
87
+ const [policy, decisions, lifecycle] = await Promise.all([
88
+ listWorkspaceLearningPolicyHistory(deps.db, {
89
+ accountId: grant.accountId,
90
+ workspaceId,
91
+ limit: query.data.limit,
92
+ }),
93
+ listGovernedLearningDecisionReceipts(deps.db, {
94
+ workspaceId,
95
+ subjectId: grant.subjectId,
96
+ principalKind: grant.principalKind ?? "",
97
+ limit: query.data.limit,
98
+ }),
99
+ listGovernedLearningActivationHistory(deps.db, {
100
+ workspaceId,
101
+ subjectId: grant.subjectId,
102
+ principalKind: grant.principalKind ?? "",
103
+ limit: query.data.limit,
104
+ }),
105
+ ]);
106
+ return context.json(
107
+ WorkspaceLearningHistoryResponse.parse({
108
+ head: policy.head,
109
+ revisions: policy.revisions,
110
+ policyEvents: policy.events,
111
+ decisions: decisions.receipts,
112
+ activations: lifecycle.activations,
113
+ undos: lifecycle.undos,
114
+ truncated: policy.truncated || decisions.truncated || lifecycle.truncated,
115
+ effectiveBoundary: "next_accepted_attempt",
116
+ }),
117
+ );
118
+ } catch (error) {
119
+ learningError(error);
120
+ }
121
+ });
122
+
123
+ app.post(`${base}/revisions`, async (context) => {
124
+ const workspaceId = context.req.param("workspaceId");
125
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
126
+ const request = await parseBody(context, CreateWorkspaceLearningPolicyRevisionRequest);
127
+ try {
128
+ return context.json(
129
+ WorkspaceLearningPolicyRevision.parse(
130
+ await createWorkspaceLearningPolicyRevision(deps.db, {
131
+ operationId: request.operationId ?? randomUUID(),
132
+ accountId: grant.accountId,
133
+ workspaceId,
134
+ workspaceMode: request.workspaceMode,
135
+ sourceOverrides: request.sourceOverrides,
136
+ supersedesRevisionId: request.supersedesRevisionId,
137
+ actorSubjectId: grant.subjectId,
138
+ principalKind: grant.principalKind,
139
+ }),
140
+ ),
141
+ 201,
142
+ );
143
+ } catch (error) {
144
+ learningError(error);
145
+ }
146
+ });
147
+
148
+ app.post(`${base}/revisions/:revisionId/activate`, async (context) => {
149
+ const workspaceId = context.req.param("workspaceId");
150
+ const revisionId = z.string().uuid().safeParse(context.req.param("revisionId"));
151
+ if (!revisionId.success) throw new HTTPException(422, { message: "Invalid revision id" });
152
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
153
+ const request = await parseBody(context, ActivateWorkspaceLearningPolicyRevisionRequest);
154
+ try {
155
+ return context.json(
156
+ WorkspaceLearningPolicyMutationResponse.parse(
157
+ await activateWorkspaceLearningPolicyRevision(deps.db, {
158
+ operationId: request.operationId ?? randomUUID(),
159
+ accountId: grant.accountId,
160
+ workspaceId,
161
+ revisionId: revisionId.data,
162
+ expectedCurrentRevisionId: request.expectedCurrentRevisionId,
163
+ expectedActivationVersion: request.expectedActivationVersion,
164
+ actorSubjectId: grant.subjectId,
165
+ principalKind: grant.principalKind,
166
+ reason: request.reason,
167
+ }),
168
+ ),
169
+ );
170
+ } catch (error) {
171
+ learningError(error);
172
+ }
173
+ });
174
+
175
+ app.post(`${base}/rollback`, async (context) => {
176
+ const workspaceId = context.req.param("workspaceId");
177
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
178
+ const request = await parseBody(context, RollbackWorkspaceLearningPolicyRevisionRequest);
179
+ try {
180
+ return context.json(
181
+ WorkspaceLearningPolicyMutationResponse.parse(
182
+ await rollbackWorkspaceLearningPolicyRevision(deps.db, {
183
+ operationId: request.operationId ?? randomUUID(),
184
+ accountId: grant.accountId,
185
+ workspaceId,
186
+ targetRevisionId: request.targetRevisionId,
187
+ expectedCurrentRevisionId: request.expectedCurrentRevisionId,
188
+ expectedActivationVersion: request.expectedActivationVersion,
189
+ actorSubjectId: grant.subjectId,
190
+ principalKind: grant.principalKind,
191
+ reason: request.reason,
192
+ }),
193
+ ),
194
+ );
195
+ } catch (error) {
196
+ learningError(error);
197
+ }
198
+ });
199
+
200
+ app.post(`${base}/activations/:activationReceiptId/undo`, async (context) => {
201
+ const workspaceId = context.req.param("workspaceId");
202
+ const activationReceiptId = z
203
+ .string()
204
+ .uuid()
205
+ .safeParse(context.req.param("activationReceiptId"));
206
+ if (!activationReceiptId.success) {
207
+ throw new HTTPException(422, { message: "Invalid activation receipt id" });
208
+ }
209
+ const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
210
+ const request = await parseBody(context, UndoGovernedLearningActivationHttpRequest);
211
+ try {
212
+ const undo = await undoGovernedLearningActivation(deps.db, {
213
+ caller: { workspaceId, subjectId: grant.subjectId },
214
+ request: {
215
+ operationId: request.operationId ?? randomUUID(),
216
+ activationReceiptId: activationReceiptId.data,
217
+ },
218
+ });
219
+ // Best-effort notification; the undo receipt is already durable.
220
+ await publishGovernedLearningEventToSlack(deps.db, { kind: "undone", receipt: undo }).catch(
221
+ () => undefined,
222
+ );
223
+ return context.json(undo);
224
+ } catch (error) {
225
+ learningError(error);
226
+ }
227
+ });
228
+ }