@opengeni/api-router 0.22.2 → 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.
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +0 -30
- package/dist/{chunk-HWXJW5C7.js → chunk-T4T2PGU4.js} +3036 -1362
- package/dist/chunk-T4T2PGU4.js.map +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.js +29 -9
- package/dist/index.js.map +1 -1
- package/dist/integrations/oauth-client.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +7 -1
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +350 -0
- package/dist/mcp/toolspace.d.ts +9 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +5 -1
- package/package.json +12 -12
- package/src/app.ts +3 -4
- package/src/auth/managed-auth.ts +0 -16
- package/src/http/sse.ts +101 -6
- package/src/index.ts +28 -3
- package/src/integrations/oauth-client.ts +36 -56
- package/src/integrations/slack-interactions.ts +123 -15
- package/src/mcp/documents.ts +42 -25
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +608 -0
- package/src/mcp/server.ts +812 -182
- package/src/mcp/toolspace.ts +75 -71
- package/src/observability.ts +3 -3
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/codex.ts +7 -4
- package/src/routes/connections.ts +74 -3
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +175 -65
- package/src/routes/install.ts +31 -1
- package/src/routes/machines.ts +1 -1
- package/src/routes/scheduled-tasks.ts +39 -14
- package/src/routes/sessions.ts +77 -12
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +124 -7
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/viewer.ts +29 -20
- package/dist/chunk-HWXJW5C7.js.map +0 -1
package/src/mcp/toolspace.ts
CHANGED
|
@@ -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(
|
|
403
|
-
|
|
404
|
-
|
|
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(
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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:
|
|
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
|
|
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(
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
deps.observability?.warn(
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
784
|
-
|
|
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
|
-
|
|
802
|
-
errorClass:
|
|
803
|
-
errorCode
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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 =
|
package/src/observability.ts
CHANGED
|
@@ -12,11 +12,11 @@ function eventAttributes(attributes: Record<string, unknown> | undefined): Attri
|
|
|
12
12
|
if (!attributes) {
|
|
13
13
|
return undefined;
|
|
14
14
|
}
|
|
15
|
-
const
|
|
15
|
+
const projected: Attributes = {};
|
|
16
16
|
for (const [key, value] of Object.entries(attributes)) {
|
|
17
|
-
|
|
17
|
+
projected[key] = eventAttributeValue(value);
|
|
18
18
|
}
|
|
19
|
-
return
|
|
19
|
+
return projected;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function eventAttributeValue(value: unknown): AttributeValue {
|
package/src/routes/api-keys.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/routes/codex.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
394
|
-
|
|
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 {
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
258
|
+
throw new HTTPException(404, {
|
|
259
|
+
message: "variable set variable not found",
|
|
260
|
+
});
|
|
212
261
|
}
|
|
213
262
|
await recordVariableSetAuditEvent(db, {
|
|
214
263
|
grant,
|