@opengeni/api-router 0.21.14 → 0.23.1

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 (54) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +0 -30
  4. package/dist/{chunk-R5PDSH2A.js → chunk-T4T2PGU4.js} +3989 -1356
  5. package/dist/chunk-T4T2PGU4.js.map +1 -0
  6. package/dist/http/sse.d.ts +2 -0
  7. package/dist/index.js +29 -9
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/oauth-client.d.ts +8 -0
  10. package/dist/integrations/slack-interactions.d.ts +7 -1
  11. package/dist/mcp/receipts.d.ts +28 -0
  12. package/dist/mcp/scheduled-task-view.d.ts +350 -0
  13. package/dist/mcp/toolspace.d.ts +9 -0
  14. package/dist/routes/transcription-recordings.d.ts +3 -0
  15. package/dist/sandbox/auth-callout.d.ts +2 -0
  16. package/dist/sandbox/channel-a.d.ts +5 -1
  17. package/dist/transcription/segmenter.d.ts +10 -0
  18. package/dist/transcription/service.d.ts +5 -0
  19. package/package.json +12 -12
  20. package/src/app.ts +39 -6
  21. package/src/auth/managed-auth.ts +0 -16
  22. package/src/http/sse.ts +101 -6
  23. package/src/index.ts +28 -3
  24. package/src/integrations/oauth-client.ts +36 -56
  25. package/src/integrations/slack-interactions.ts +123 -15
  26. package/src/mcp/documents.ts +42 -25
  27. package/src/mcp/receipts.ts +95 -0
  28. package/src/mcp/scheduled-task-view.ts +608 -0
  29. package/src/mcp/server.ts +812 -182
  30. package/src/mcp/toolspace.ts +75 -71
  31. package/src/observability.ts +3 -3
  32. package/src/routes/api-keys.ts +7 -1
  33. package/src/routes/codex.ts +7 -4
  34. package/src/routes/connections.ts +74 -3
  35. package/src/routes/enrollments.ts +54 -12
  36. package/src/routes/environments.ts +60 -11
  37. package/src/routes/files.ts +175 -65
  38. package/src/routes/install.ts +31 -1
  39. package/src/routes/machines.ts +1 -1
  40. package/src/routes/scheduled-tasks.ts +39 -14
  41. package/src/routes/sessions.ts +77 -12
  42. package/src/routes/transcription-recordings.ts +754 -0
  43. package/src/routes/transcriptions.ts +2 -0
  44. package/src/sandbox/auth-callout.ts +16 -4
  45. package/src/sandbox/channel-a.ts +124 -7
  46. package/src/sandbox/enrollment.ts +13 -3
  47. package/src/sandbox/machines.ts +1 -1
  48. package/src/sandbox/viewer.ts +29 -20
  49. package/src/transcription/providers/azure-openai.ts +4 -3
  50. package/src/transcription/providers/codex-subscription.ts +4 -1
  51. package/src/transcription/providers/openai.ts +7 -2
  52. package/src/transcription/segmenter.ts +260 -0
  53. package/src/transcription/service.ts +111 -10
  54. package/dist/chunk-R5PDSH2A.js.map +0 -1
@@ -51,7 +51,6 @@ import {
51
51
  type McpRequestReplayInfo,
52
52
  } from "@opengeni/runtime/mcp-network";
53
53
  import { Buffer } from "node:buffer";
54
- import { createHash } from "node:crypto";
55
54
 
56
55
  export type ToolspaceCallResult = CallToolResult;
57
56
 
@@ -399,10 +398,10 @@ async function resolveToolListing(input: {
399
398
  turn: activeTurn,
400
399
  personalConnectionDelegations,
401
400
  }).catch((error) => {
402
- deps.observability?.warn("toolspace upstream connection failed", {
403
- serverId,
404
- ...toolspaceErrorAttributes(error),
405
- });
401
+ deps.observability?.warn(
402
+ "toolspace upstream connection failed",
403
+ toolspacePublicErrorFields(error),
404
+ );
406
405
  return null;
407
406
  });
408
407
  if (!connection) {
@@ -413,20 +412,20 @@ async function resolveToolListing(input: {
413
412
  const listed = await connection.client
414
413
  .listTools(undefined, toolspaceRequestOptions(config))
415
414
  .catch((error) => {
416
- deps.observability?.warn("toolspace upstream tool list failed", {
417
- serverId,
418
- ...toolspaceErrorAttributes(error),
419
- });
415
+ deps.observability?.warn(
416
+ "toolspace upstream tool list failed",
417
+ toolspacePublicErrorFields(error),
418
+ );
420
419
  return { tools: [] };
421
420
  });
422
421
  let boundedTools: readonly McpTool[];
423
422
  try {
424
423
  boundedTools = assertMcpToolListWithinBounds(listed.tools as McpTool[]) as McpTool[];
425
424
  } catch (error) {
426
- deps.observability?.warn("toolspace upstream tool list exceeded safety limit", {
427
- serverId,
428
- errorClass: error instanceof Error ? error.name : typeof error,
429
- });
425
+ deps.observability?.warn(
426
+ "toolspace upstream tool list exceeded safety limit",
427
+ toolspacePublicErrorFields(error, "tool_list_too_large"),
428
+ );
430
429
  aggregateBudget.replace(serverId, []);
431
430
  return;
432
431
  }
@@ -629,17 +628,19 @@ function toolspaceToolFor(input: {
629
628
  if (mcpToolRequiresApproval(config.requireApproval, tool.name)) {
630
629
  return mcpError(APPROVAL_REQUIRED_MESSAGE);
631
630
  }
632
- const connection = await connectToolspaceServer({
633
- deps,
634
- grant,
635
- config,
636
- sessionId,
637
- rootSessionId,
638
- turn: reservation.turn,
639
- personalConnectionDelegations,
640
- }).catch(() => null);
641
- if (!connection) {
642
- return mcpError(`upstream tool failed: ${name}`);
631
+ let connection: ConnectedToolspaceServer;
632
+ try {
633
+ connection = await connectToolspaceServer({
634
+ deps,
635
+ grant,
636
+ config,
637
+ sessionId,
638
+ rootSessionId,
639
+ turn: reservation.turn,
640
+ personalConnectionDelegations,
641
+ });
642
+ } catch (error) {
643
+ return mcpError(exactErrorMessage(error));
643
644
  }
644
645
  try {
645
646
  const callId = crypto.randomUUID();
@@ -659,7 +660,7 @@ function toolspaceToolFor(input: {
659
660
  type: "toolspace_call",
660
661
  id: callId,
661
662
  name,
662
- arguments: toolspaceAuditSummary(args),
663
+ arguments: args,
663
664
  serverId,
664
665
  toolName: tool.name,
665
666
  },
@@ -718,7 +719,7 @@ function toolspaceToolFor(input: {
718
719
  producerId: grant.subjectId,
719
720
  payload: {
720
721
  id: callId,
721
- output: toolspaceAuditSummary(output),
722
+ output,
722
723
  origin: "toolspace",
723
724
  subjectId: grant.subjectId,
724
725
  },
@@ -755,11 +756,10 @@ async function callRemoteTool(
755
756
  return output;
756
757
  } catch (error) {
757
758
  if (error instanceof McpPayloadTooLargeError) {
758
- deps.observability?.warn("toolspace upstream tool result exceeded safety limit", {
759
- serverId: server.config.id,
760
- toolName,
761
- errorClass: error.name,
762
- });
759
+ deps.observability?.warn(
760
+ "toolspace upstream tool result exceeded safety limit",
761
+ toolspacePublicErrorFields(error, "tool_result_too_large"),
762
+ );
763
763
  return mcpError("upstream tool result exceeded the safety limit");
764
764
  }
765
765
  if (isToolspaceAuthNeededError(error)) {
@@ -768,52 +768,56 @@ async function callRemoteTool(
768
768
  if (isToolspaceOutcomeUncertainError(error)) {
769
769
  return mcpError(TOOLSPACE_TOOL_OUTCOME_UNCERTAIN_ERROR.message);
770
770
  }
771
- // The raw upstream error can carry provider-specific detail; log it
772
- // server-side and return only a generic result to the sandbox so no header
773
- // or credential material can ride the message back out.
774
- deps.observability?.warn("toolspace upstream tool call failed", {
775
- serverId: server.config.id,
776
- toolName,
777
- error: error instanceof Error ? error.message : String(error),
778
- });
779
- return mcpError(`upstream tool failed: ${prefixedMcpToolName(server.config.id, toolName)}`);
771
+ const message = exactErrorMessage(error);
772
+ // The exact provider diagnostic remains the internal/model-facing tool
773
+ // result. Public observability receives only allowlisted metadata.
774
+ deps.observability?.warn(
775
+ "toolspace upstream tool call failed",
776
+ toolspacePublicErrorFields(error),
777
+ );
778
+ return mcpError(message);
780
779
  }
781
780
  }
782
781
 
783
- function toolspaceAuditSummary(value: unknown): {
784
- redacted: true;
785
- sizeBytes: number;
786
- sha256: string;
787
- } {
788
- let serialized: string;
789
- try {
790
- serialized = JSON.stringify(value) ?? "null";
791
- } catch {
792
- serialized = "[unserializable]";
793
- }
794
- return {
795
- redacted: true,
796
- sizeBytes: Buffer.byteLength(serialized),
797
- sha256: createHash("sha256").update(serialized).digest("hex"),
798
- };
782
+ function exactErrorMessage(error: unknown): string {
783
+ return error instanceof Error ? error.message : String(error);
799
784
  }
800
785
 
801
- function toolspaceErrorAttributes(error: unknown): {
802
- errorClass: string;
803
- errorCode?: string;
804
- errorMessage?: string;
805
- } {
806
- if (!(error instanceof Error)) {
807
- return { errorClass: typeof error };
808
- }
809
- const code = (error as Error & { code?: unknown }).code;
810
- return {
811
- errorClass: error.name,
812
- ...(typeof code === "string" ? { errorCode: code } : {}),
813
- ...(error.message
814
- ? { errorMessage: error.message.replaceAll(/[\r\n]+/gu, " ").slice(0, 512) }
815
- : {}),
786
+ export type ToolspacePublicErrorFields = {
787
+ errorClass: "ToolspaceOperationError";
788
+ errorCode: ToolspacePublicFailureCode;
789
+ status?: number;
790
+ origin: "toolspace";
791
+ };
792
+
793
+ type ToolspacePublicFailureCode =
794
+ | "toolspace_operation_failed"
795
+ | "tool_list_too_large"
796
+ | "tool_result_too_large";
797
+
798
+ /** Allowlisted projection for public telemetry; product data stays exact. */
799
+ export function toolspacePublicErrorFields(
800
+ error: unknown,
801
+ errorCode: ToolspacePublicFailureCode = "toolspace_operation_failed",
802
+ ): ToolspacePublicErrorFields {
803
+ const fields: ToolspacePublicErrorFields = {
804
+ errorClass: "ToolspaceOperationError",
805
+ errorCode,
806
+ origin: "toolspace",
816
807
  };
808
+ try {
809
+ const rawStatus =
810
+ error && typeof error === "object"
811
+ ? ((error as { status?: unknown; statusCode?: unknown }).status ??
812
+ (error as { statusCode?: unknown }).statusCode)
813
+ : undefined;
814
+ const status = Number(rawStatus);
815
+ if (Number.isInteger(status) && status >= 100 && status <= 599) fields.status = status;
816
+ } catch {
817
+ // Public telemetry is best-effort. A hostile getter/proxy must never
818
+ // replace the exact Toolspace failure with a projection failure.
819
+ }
820
+ return fields;
817
821
  }
818
822
 
819
823
  type ToolspaceReservation =
@@ -12,11 +12,11 @@ function eventAttributes(attributes: Record<string, unknown> | undefined): Attri
12
12
  if (!attributes) {
13
13
  return undefined;
14
14
  }
15
- const sanitized: Attributes = {};
15
+ const projected: Attributes = {};
16
16
  for (const [key, value] of Object.entries(attributes)) {
17
- sanitized[key] = eventAttributeValue(value);
17
+ projected[key] = eventAttributeValue(value);
18
18
  }
19
- return sanitized;
19
+ return projected;
20
20
  }
21
21
 
22
22
  function eventAttributeValue(value: unknown): AttributeValue {
@@ -54,7 +54,13 @@ export function registerApiKeyRoutes(app: Hono, deps: ApiRouteDeps): void {
54
54
 
55
55
  function ensureDelegablePermissions(grantPermissions: Permission[], requested: Permission[]): void {
56
56
  if (grantPermissions.includes("workspace:admin")) {
57
- return;
57
+ const highTrustMissing = requested.filter(
58
+ (permission) => permission === "secrets:read" && !grantPermissions.includes("secrets:read"),
59
+ );
60
+ if (highTrustMissing.length === 0) return;
61
+ throw new HTTPException(403, {
62
+ message: `cannot delegate missing literal permissions: ${highTrustMissing.join(", ")}`,
63
+ });
58
64
  }
59
65
  const missing = requested.filter((permission) => !grantPermissions.includes(permission));
60
66
  if (missing.length > 0) {
@@ -150,7 +150,12 @@ export function codexModelsForPicker(liveSlugs: readonly string[]): Array<{
150
150
  }));
151
151
  }
152
152
  import { createSignedState, readSignedState } from "@opengeni/github";
153
- import { hasPermission, requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
153
+ import {
154
+ getManagedSession,
155
+ hasPermission,
156
+ requireAccessGrant,
157
+ type ApiRouteDeps,
158
+ } from "@opengeni/core";
154
159
  import type { Context, Hono } from "hono";
155
160
  import { HTTPException } from "hono/http-exception";
156
161
  import * as z from "zod/v4";
@@ -190,9 +195,7 @@ async function managedCookieHuman(
190
195
  ) {
191
196
  return null;
192
197
  }
193
- const session = await deps.managedAuth.api.getSession({
194
- headers: c.req.raw.headers,
195
- });
198
+ const session = await getManagedSession(c, deps.managedAuth);
196
199
  if (!session?.user?.id || !session.session?.id) return null;
197
200
  return {
198
201
  subjectId: `user:${session.user.id}`,
@@ -10,6 +10,10 @@ import {
10
10
  OAuthStartResponse,
11
11
  UpdateConnectionRequest,
12
12
  } from "@opengeni/contracts";
13
+ import {
14
+ bindConnectorDocumentDestination,
15
+ ConnectorDocumentDestinationSelection,
16
+ } from "@opengeni/contracts/connector-destinations";
13
17
  import {
14
18
  GOOGLE_DRIVE_PROVIDER_DOMAIN,
15
19
  GoogleDriveConnectionMetadata,
@@ -41,6 +45,7 @@ import {
41
45
  SlackBotLifecycleSuccessAuditError,
42
46
  updateConnection,
43
47
  updateConnectionWithSlackBotSuccessAudit,
48
+ updateSlackBotDocumentDestination,
44
49
  type SlackBotInstallCallbackFailureReason,
45
50
  type SlackBotInstallCallbackFailureStage,
46
51
  } from "@opengeni/db";
@@ -380,7 +385,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
380
385
 
381
386
  app.patch("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
382
387
  const workspaceId = c.req.param("workspaceId");
383
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
388
+ const authorization = await requireAccessGrantAuthorization(
389
+ c,
390
+ deps,
391
+ workspaceId,
392
+ "connections:write",
393
+ );
394
+ const { grant } = authorization;
384
395
  const payload = UpdateConnectionRequest.parse(await c.req.json());
385
396
  assertNotReservedSlackBotMetadata(payload.metadata);
386
397
  const existing = await getConnectionMetadata(
@@ -390,9 +401,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
390
401
  grant.subjectId,
391
402
  );
392
403
  if (existing && isOpenGeniSlackBotConnection(existing)) {
393
- throw new HTTPException(422, {
394
- message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
404
+ const destination = ConnectorDocumentDestinationSelection.safeParse(
405
+ payload.metadata?.documentDestination,
406
+ );
407
+ const destinationOnlyUpdate =
408
+ payload.metadata !== undefined &&
409
+ Object.keys(payload.metadata).length === 1 &&
410
+ payload.providerDomain === undefined &&
411
+ payload.subjectId === undefined &&
412
+ payload.kind === undefined &&
413
+ payload.status === undefined &&
414
+ payload.credential === undefined &&
415
+ payload.grantedScopes === undefined &&
416
+ payload.expiresAt === undefined;
417
+ if (!destination.success || !destinationOnlyUpdate) {
418
+ throw new HTTPException(422, {
419
+ message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
420
+ });
421
+ }
422
+ const destinationSelection = destination.data;
423
+ if (
424
+ destinationSelection.authorityKind === "organization" &&
425
+ authorization.accountGrant?.permissions.includes("account:admin") !== true
426
+ ) {
427
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
428
+ }
429
+ if (
430
+ destinationSelection.authorityKind === "workspace" &&
431
+ !hasPermission(grant.permissions, "workspace:admin")
432
+ ) {
433
+ throw new HTTPException(403, { message: "missing permission: workspace:admin" });
434
+ }
435
+ if (
436
+ destinationSelection.authorityKind === "personal" &&
437
+ (!authorization.contextIntegrity ||
438
+ authorization.authenticatedSubjectId !== grant.subjectId)
439
+ ) {
440
+ throw new HTTPException(403, {
441
+ message: "personal destination requires the exact actor",
442
+ });
443
+ }
444
+ const metadata = {
445
+ ...existing.metadata,
446
+ documentDestination: bindConnectorDocumentDestination(destinationSelection, {
447
+ accountId: grant.accountId,
448
+ workspaceId,
449
+ initiatingSubjectId: grant.subjectId,
450
+ }),
451
+ };
452
+ const connection = await updateSlackBotDocumentDestination(db, {
453
+ accountId: grant.accountId,
454
+ workspaceId,
455
+ connectionId: existing.id,
456
+ visibleToSubjectId: grant.subjectId,
457
+ expectedVersion: existing.version,
458
+ metadata,
459
+ updatedBySubjectId: grant.subjectId,
395
460
  });
461
+ if (!connection) {
462
+ throw new HTTPException(409, {
463
+ message: "Slack bot connection changed; reload before saving its destination",
464
+ });
465
+ }
466
+ return c.json(ConnectionResponse.parse({ connection }));
396
467
  }
397
468
  if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
398
469
  throw new HTTPException(422, {
@@ -20,6 +20,7 @@
20
20
  // workspace can approve it (the approve's user_code lookup is workspace-scoped) — a
21
21
  // start can never grant access to a workspace no authorized user later approves in.
22
22
 
23
+ import { randomUUID } from "node:crypto";
23
24
  import {
24
25
  DeviceEnrollmentApproveRequest,
25
26
  DeviceEnrollmentApproveResponse,
@@ -35,11 +36,18 @@ import {
35
36
  ListEnrollmentsResponse,
36
37
  MintEnrollTokenRequest,
37
38
  MintEnrollTokenResponse,
39
+ RemoveEnrollmentRequest,
38
40
  RevokeEnrollmentResponse,
39
41
  type EnrollmentArch,
40
42
  type EnrollmentOs,
41
43
  } from "@opengeni/contracts";
42
- import { getWorkspace, listEnrollments, revokeEnrollment } from "@opengeni/db";
44
+ import {
45
+ getWorkspace,
46
+ listEnrollments,
47
+ MachineRemovalIdempotencyError,
48
+ MachineRemovalRevisionConflictError,
49
+ removeEnrollment,
50
+ } from "@opengeni/db";
43
51
  import type { Context, Hono } from "hono";
44
52
  import { HTTPException } from "hono/http-exception";
45
53
  import { requireAccessGrant } from "@opengeni/core";
@@ -301,11 +309,12 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
301
309
  await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
302
310
  assertSelfhostedEnabled();
303
311
  const statusFilter = c.req.query("status");
304
- const rows = await listEnrollments(
305
- db,
306
- workspaceId,
307
- statusFilter === "active" ? { status: "active" } : {},
308
- );
312
+ if (statusFilter !== undefined && statusFilter !== "active" && statusFilter !== "revoked") {
313
+ throw new HTTPException(400, { message: "status must be active or revoked" });
314
+ }
315
+ const rows = await listEnrollments(db, workspaceId, {
316
+ status: statusFilter === "revoked" ? "revoked" : "active",
317
+ });
309
318
  return c.json(
310
319
  ListEnrollmentsResponse.parse({
311
320
  enrollments: rows.map((row) =>
@@ -333,12 +342,45 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
333
342
  const workspaceId = c.req.param("workspaceId");
334
343
  const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
335
344
  assertSelfhostedEnabled();
336
- const result = await revokeEnrollment(db, {
337
- accountId: grant.accountId,
338
- workspaceId,
339
- enrollmentId: c.req.param("enrollmentId"),
340
- });
341
- return c.json(RevokeEnrollmentResponse.parse(result));
345
+ const parsed = RemoveEnrollmentRequest.safeParse(await c.req.json().catch(() => ({})));
346
+ if (!parsed.success) {
347
+ throw new HTTPException(400, { message: "invalid machine removal request" });
348
+ }
349
+ const idempotencyKey =
350
+ parsed.data.idempotencyKey?.trim() || c.req.header("idempotency-key")?.trim() || randomUUID();
351
+ try {
352
+ const result = await removeEnrollment(db, {
353
+ accountId: grant.accountId,
354
+ workspaceId,
355
+ enrollmentId: c.req.param("enrollmentId"),
356
+ operationKey: idempotencyKey,
357
+ ...(parsed.data.expectedUpdatedAt
358
+ ? { expectedUpdatedAt: parsed.data.expectedUpdatedAt }
359
+ : {}),
360
+ subjectId: grant.subjectId,
361
+ });
362
+ if (!result) {
363
+ throw new HTTPException(404, { message: "machine enrollment not found" });
364
+ }
365
+ return c.json(
366
+ RevokeEnrollmentResponse.parse({
367
+ revoked: result.removed,
368
+ ...result,
369
+ }),
370
+ 200,
371
+ );
372
+ } catch (error) {
373
+ if (error instanceof MachineRemovalIdempotencyError) {
374
+ throw new HTTPException(409, { message: error.message });
375
+ }
376
+ if (error instanceof MachineRemovalRevisionConflictError) {
377
+ throw new HTTPException(409, {
378
+ message: error.message,
379
+ cause: { code: "stale_revision", currentUpdatedAt: error.currentUpdatedAt },
380
+ });
381
+ }
382
+ throw error;
383
+ }
342
384
  });
343
385
  }
344
386
 
@@ -9,17 +9,19 @@ import {
9
9
  countScheduledTasksUsingVariableSet,
10
10
  countVariableSets,
11
11
  createVariableSet,
12
+ decryptVariableSetValue,
12
13
  deleteVariableSet,
13
14
  deleteVariableSetVariable,
14
15
  encryptVariableSetValue,
15
16
  getVariableSetByName,
16
17
  listVariableSets,
18
+ readVariableSetSecretAtomically,
17
19
  setVariableSetVariable,
18
20
  updateVariableSet,
19
21
  } from "@opengeni/db";
20
22
  import type { Hono } from "hono";
21
23
  import { HTTPException } from "hono/http-exception";
22
- import { requireAccessGrant } from "@opengeni/core";
24
+ import { requireAccessGrant, requireLiteralPermission, requirePermission } from "@opengeni/core";
23
25
  import type { ApiRouteDeps } from "@opengeni/core";
24
26
  import {
25
27
  assertAllowedVariableSetVariableName,
@@ -40,15 +42,21 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
40
42
  for (const prefix of prefixes) {
41
43
  app.get(`${prefix}`, async (c) => {
42
44
  const workspaceId = c.req.param("workspaceId")!;
43
- await requireAccessGrant(c, deps, workspaceId, "variable-sets:use");
45
+ const grant = await requireAccessGrant(c, deps, workspaceId);
46
+ requirePermission(grant, "variable-sets:list");
47
+ requirePermission(grant, "secrets:list");
44
48
  return c.json(await listVariableSets(db, workspaceId));
45
49
  });
46
50
 
47
51
  app.post(`${prefix}`, async (c) => {
48
52
  const workspaceId = c.req.param("workspaceId")!;
49
- const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
53
+ const grant = await requireAccessGrant(c, deps, workspaceId);
54
+ requirePermission(grant, "variable-sets:write");
50
55
  const key = requireVariableSetEncryption(settings);
51
56
  const payload = CreateVariableSetRequest.parse(await c.req.json());
57
+ if (payload.variables.length > 0) {
58
+ requirePermission(grant, "secrets:write");
59
+ }
52
60
  const name = trimmedVariableSetName(payload.name);
53
61
  if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT) {
54
62
  throw new HTTPException(422, {
@@ -71,7 +79,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
71
79
  });
72
80
  }
73
81
  if (await getVariableSetByName(db, workspaceId, name)) {
74
- throw new HTTPException(409, { message: `variable set name is already in use: ${name}` });
82
+ throw new HTTPException(409, {
83
+ message: `variable set name is already in use: ${name}`,
84
+ });
75
85
  }
76
86
  // Values are encrypted up front and the variableSet plus all initial
77
87
  // variables are written in one transaction: a failure leaves nothing.
@@ -95,13 +105,42 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
95
105
 
96
106
  app.get(`${prefix}/:variableSetId`, async (c) => {
97
107
  const workspaceId = c.req.param("workspaceId")!;
98
- await requireAccessGrant(c, deps, workspaceId, "variable-sets:use");
108
+ const grant = await requireAccessGrant(c, deps, workspaceId);
109
+ requirePermission(grant, "variable-sets:read");
110
+ requirePermission(grant, "secrets:list");
99
111
  return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")!));
100
112
  });
101
113
 
114
+ if (prefix.endsWith("/variable-sets")) {
115
+ app.get(`${prefix}/:variableSetId/variables/:name`, async (c) => {
116
+ const workspaceId = c.req.param("workspaceId")!;
117
+ const grant = await requireAccessGrant(c, deps, workspaceId);
118
+ requirePermission(grant, "variable-sets:read");
119
+ requireLiteralPermission(grant, "secrets:read");
120
+ const key = requireVariableSetEncryption(settings);
121
+ const name = parseVariableName(c.req.param("name")!);
122
+ const secret = await readVariableSetSecretAtomically(db, {
123
+ accountId: grant.accountId,
124
+ workspaceId,
125
+ subjectId: grant.subjectId,
126
+ variableSetId: c.req.param("variableSetId")!,
127
+ name,
128
+ actor: { kind: "subject" },
129
+ decrypt: (valueEncrypted) => decryptVariableSetValue(key, valueEncrypted),
130
+ });
131
+ if (!secret) {
132
+ throw new HTTPException(404, {
133
+ message: "variable set variable not found",
134
+ });
135
+ }
136
+ return c.json(secret);
137
+ });
138
+ }
139
+
102
140
  app.patch(`${prefix}/:variableSetId`, async (c) => {
103
141
  const workspaceId = c.req.param("workspaceId")!;
104
- const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
142
+ const grant = await requireAccessGrant(c, deps, workspaceId);
143
+ requirePermission(grant, "variable-sets:write");
105
144
  const variableSet = await requireVariableSetForApi(
106
145
  db,
107
146
  workspaceId,
@@ -112,7 +151,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
112
151
  if (name !== undefined && name !== variableSet.name) {
113
152
  const existing = await getVariableSetByName(db, workspaceId, name);
114
153
  if (existing && existing.id !== variableSet.id) {
115
- throw new HTTPException(409, { message: `variable set name is already in use: ${name}` });
154
+ throw new HTTPException(409, {
155
+ message: `variable set name is already in use: ${name}`,
156
+ });
116
157
  }
117
158
  }
118
159
  const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
@@ -129,7 +170,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
129
170
 
130
171
  app.delete(`${prefix}/:variableSetId`, async (c) => {
131
172
  const workspaceId = c.req.param("workspaceId")!;
132
- const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
173
+ const grant = await requireAccessGrant(c, deps, workspaceId);
174
+ requirePermission(grant, "variable-sets:write");
175
+ requirePermission(grant, "secrets:write");
133
176
  const variableSet = await requireVariableSetForApi(
134
177
  db,
135
178
  workspaceId,
@@ -166,7 +209,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
166
209
 
167
210
  app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
168
211
  const workspaceId = c.req.param("workspaceId")!;
169
- const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
212
+ const grant = await requireAccessGrant(c, deps, workspaceId);
213
+ requirePermission(grant, "variable-sets:write");
214
+ requirePermission(grant, "secrets:write");
170
215
  const key = requireVariableSetEncryption(settings);
171
216
  const name = parseVariableName(c.req.param("name")!);
172
217
  const variableSet = await requireVariableSetForApi(
@@ -199,7 +244,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
199
244
 
200
245
  app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
201
246
  const workspaceId = c.req.param("workspaceId")!;
202
- const grant = await requireAccessGrant(c, deps, workspaceId, "variable-sets:manage");
247
+ const grant = await requireAccessGrant(c, deps, workspaceId);
248
+ requirePermission(grant, "variable-sets:write");
249
+ requirePermission(grant, "secrets:write");
203
250
  const name = parseVariableName(c.req.param("name")!);
204
251
  const variableSet = await requireVariableSetForApi(
205
252
  db,
@@ -208,7 +255,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
208
255
  );
209
256
  const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
210
257
  if (!deleted) {
211
- throw new HTTPException(404, { message: "variable set variable not found" });
258
+ throw new HTTPException(404, {
259
+ message: "variable set variable not found",
260
+ });
212
261
  }
213
262
  await recordVariableSetAuditEvent(db, {
214
263
  grant,