@opengeni/api-router 0.14.3 → 0.15.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/src/mcp/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import {
2
3
  CreateScheduledTaskRequest,
3
4
  DEFAULT_FIRST_PARTY_MCP_TOOLS,
@@ -24,6 +25,8 @@ import {
24
25
  type SessionAuthorizationSurface,
25
26
  type Session,
26
27
  UpdateScheduledTaskRequest,
28
+ normalizeWorkspaceArtifactSlug,
29
+ WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES,
27
30
  } from "@opengeni/contracts";
28
31
  import {
29
32
  correctWorkspaceMemory,
@@ -70,6 +73,12 @@ import {
70
73
  upsertSessionGoalWithEvent,
71
74
  RigChangeAlreadyVerifyingError,
72
75
  RigChangeTransitionError,
76
+ createWorkspaceArtifact,
77
+ getWorkspaceArtifact,
78
+ getWorkspaceArtifactContentRef,
79
+ listWorkspaceArtifacts,
80
+ publishWorkspaceArtifactVersion,
81
+ rollbackWorkspaceArtifact,
73
82
  } from "@opengeni/db";
74
83
  import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
75
84
  import {
@@ -237,6 +246,11 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
237
246
  slack_bot_file_content: { allOf: ["connections:read"] },
238
247
  slack_bot_post_message: { allOf: ["connections:read"] },
239
248
  slack_bot_delete_message: { allOf: ["connections:read"] },
249
+ artifacts_list: { sessionRequired: true, allOf: ["artifacts:read"] },
250
+ artifacts_get_source: { sessionRequired: true, allOf: ["artifacts:read"] },
251
+ artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
252
+ artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
253
+ artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
240
254
  } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
241
255
 
242
256
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
@@ -378,6 +392,9 @@ export function buildOpenGeniMcpServer(
378
392
  if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
379
393
  registerPreferenceRegistryTools(server, deps, grant, json);
380
394
  }
395
+ if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
396
+ registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
397
+ }
381
398
 
382
399
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
383
400
  // provision over the session's Modal box + the workspace's enrolled machines.
@@ -1282,6 +1299,198 @@ async function authorizeFirstPartySession(
1282
1299
  });
1283
1300
  }
1284
1301
 
1302
+ function registerWorkspaceArtifactTools(
1303
+ server: McpServer,
1304
+ deps: ApiRouteDeps,
1305
+ grant: AccessGrant,
1306
+ sessionId: string,
1307
+ json: JsonResult,
1308
+ ): void {
1309
+ const attempt = () => {
1310
+ const claims = preferenceAttemptClaims(grant);
1311
+ if (!claims) throw new Error("Exact signed artifact attempt authority is required.");
1312
+ return claims;
1313
+ };
1314
+ const authorize = async () => {
1315
+ await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
1316
+ };
1317
+ const prepare = (html: string) => {
1318
+ if (!deps.objectStorage) throw new Error("Object storage is not configured");
1319
+ const bytes = new TextEncoder().encode(html);
1320
+ if (bytes.byteLength < 1 || bytes.byteLength > WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES) {
1321
+ throw new Error(
1322
+ `Artifact HTML must be 1-${WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES} UTF-8 bytes`,
1323
+ );
1324
+ }
1325
+ const contentSha256 = createHash("sha256").update(bytes).digest("hex");
1326
+ const contentKey = `workspaces/${grant.workspaceId}/workspace-artifacts/blobs/${contentSha256}.html`;
1327
+ return {
1328
+ contentKey,
1329
+ contentSha256,
1330
+ sizeBytes: bytes.byteLength,
1331
+ persistContent: async () => {
1332
+ await deps.objectStorage!.putObject({
1333
+ key: contentKey,
1334
+ contentType: "text/html; charset=utf-8",
1335
+ body: bytes,
1336
+ sha256: contentSha256,
1337
+ });
1338
+ },
1339
+ };
1340
+ };
1341
+ const provenance = (
1342
+ idempotencyKey: string,
1343
+ sourceToolName: "artifacts_create" | "artifacts_publish" | "artifacts_rollback",
1344
+ ) => {
1345
+ const claims = attempt();
1346
+ return {
1347
+ accountId: grant.accountId,
1348
+ workspaceId: grant.workspaceId,
1349
+ operationKey: `attempt:${createHash("sha256")
1350
+ .update(
1351
+ `${claims.sessionId}:${claims.turnId}:${claims.attemptId}:${claims.executionGeneration}:${idempotencyKey}`,
1352
+ )
1353
+ .digest("hex")}`,
1354
+ actorSubjectId: grant.subjectId,
1355
+ sourceSessionId: claims.sessionId,
1356
+ sourceTurnId: claims.turnId,
1357
+ sourceAttemptId: claims.attemptId,
1358
+ sourceExecutionGeneration: claims.executionGeneration,
1359
+ sourceToolName,
1360
+ };
1361
+ };
1362
+
1363
+ server.registerTool(
1364
+ "artifacts_list",
1365
+ {
1366
+ description:
1367
+ "List the generic published artifacts in this workspace and their current versions.",
1368
+ inputSchema: {},
1369
+ },
1370
+ async () => {
1371
+ await authorize();
1372
+ return json(await listWorkspaceArtifacts(deps.db, grant.workspaceId));
1373
+ },
1374
+ );
1375
+
1376
+ server.registerTool(
1377
+ "artifacts_get_source",
1378
+ {
1379
+ description:
1380
+ "Read an artifact's metadata and exact HTML source. Omit versionId for the current version.",
1381
+ inputSchema: {
1382
+ artifactId: z4.string().uuid(),
1383
+ versionId: z4.string().uuid().optional(),
1384
+ },
1385
+ },
1386
+ async ({ artifactId, versionId }) => {
1387
+ await authorize();
1388
+ if (!deps.objectStorage) throw new Error("Object storage is not configured");
1389
+ const [detail, ref] = await Promise.all([
1390
+ getWorkspaceArtifact(deps.db, grant.workspaceId, artifactId),
1391
+ getWorkspaceArtifactContentRef(deps.db, grant.workspaceId, artifactId, versionId),
1392
+ ]);
1393
+ const object = await deps.objectStorage.getObjectBytes(ref.contentKey);
1394
+ if (!object) throw new Error("Artifact content is unavailable");
1395
+ const actualHash = createHash("sha256").update(object.bytes).digest("hex");
1396
+ if (actualHash !== ref.version.contentSha256)
1397
+ throw new Error("Artifact content failed integrity verification");
1398
+ return json({ detail, version: ref.version, html: new TextDecoder().decode(object.bytes) });
1399
+ },
1400
+ );
1401
+
1402
+ server.registerTool(
1403
+ "artifacts_create",
1404
+ {
1405
+ description:
1406
+ "Create and publish a generic static workspace artifact from a complete, self-contained HTML document with inline CSS. JavaScript and active or navigation-capable markup do not render in the MVP.",
1407
+ inputSchema: {
1408
+ title: z4.string().min(1).max(120),
1409
+ description: z4.string().max(2000).nullable().optional(),
1410
+ slug: z4
1411
+ .string()
1412
+ .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/)
1413
+ .max(96)
1414
+ .optional(),
1415
+ html: z4.string().min(1).max(WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES),
1416
+ idempotencyKey: z4.string().min(1).max(200),
1417
+ },
1418
+ },
1419
+ async ({ title, description, slug, html, idempotencyKey }) => {
1420
+ await authorize();
1421
+ const artifactId = crypto.randomUUID();
1422
+ const slugBase = slug ?? (normalizeWorkspaceArtifactSlug(title) || "artifact");
1423
+ const resolvedSlug = slug ?? `${slugBase.slice(0, 87)}-${artifactId.slice(0, 8)}`;
1424
+ return json(
1425
+ await createWorkspaceArtifact(deps.db, {
1426
+ artifactId,
1427
+ slug: resolvedSlug,
1428
+ title,
1429
+ description: description ?? null,
1430
+ ...prepare(html),
1431
+ ...provenance(idempotencyKey, "artifacts_create"),
1432
+ }),
1433
+ );
1434
+ },
1435
+ );
1436
+
1437
+ server.registerTool(
1438
+ "artifacts_publish",
1439
+ {
1440
+ description:
1441
+ "Publish a new immutable static HTML/CSS version. JavaScript and active or navigation-capable markup do not render in the MVP. First read the current source and pass its version id for optimistic concurrency.",
1442
+ inputSchema: {
1443
+ artifactId: z4.string().uuid(),
1444
+ expectedCurrentVersionId: z4.string().uuid(),
1445
+ title: z4.string().min(1).max(120).optional(),
1446
+ description: z4.string().max(2000).nullable().optional(),
1447
+ html: z4.string().min(1).max(WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES),
1448
+ idempotencyKey: z4.string().min(1).max(200),
1449
+ },
1450
+ },
1451
+ async ({ artifactId, expectedCurrentVersionId, title, description, html, idempotencyKey }) => {
1452
+ await authorize();
1453
+ return json(
1454
+ await publishWorkspaceArtifactVersion(deps.db, {
1455
+ artifactId,
1456
+ expectedCurrentVersionId,
1457
+ ...(title !== undefined ? { title } : {}),
1458
+ ...(description !== undefined ? { description } : {}),
1459
+ ...prepare(html),
1460
+ ...provenance(idempotencyKey, "artifacts_publish"),
1461
+ }),
1462
+ );
1463
+ },
1464
+ );
1465
+
1466
+ server.registerTool(
1467
+ "artifacts_rollback",
1468
+ {
1469
+ description:
1470
+ "Promote an existing immutable artifact version back to current without rewriting history.",
1471
+ inputSchema: {
1472
+ artifactId: z4.string().uuid(),
1473
+ versionId: z4.string().uuid(),
1474
+ expectedCurrentVersionId: z4.string().uuid(),
1475
+ reason: z4.string().min(1).max(4096),
1476
+ idempotencyKey: z4.string().min(1).max(200),
1477
+ },
1478
+ },
1479
+ async ({ artifactId, versionId, expectedCurrentVersionId, reason, idempotencyKey }) => {
1480
+ await authorize();
1481
+ return json(
1482
+ await rollbackWorkspaceArtifact(deps.db, {
1483
+ artifactId,
1484
+ versionId,
1485
+ expectedCurrentVersionId,
1486
+ reason,
1487
+ ...provenance(idempotencyKey, "artifacts_rollback"),
1488
+ }),
1489
+ );
1490
+ },
1491
+ );
1492
+ }
1493
+
1285
1494
  function preferenceAttemptClaims(grant: AccessGrant): {
1286
1495
  sessionId: string;
1287
1496
  turnId: string;
@@ -296,16 +296,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
296
296
  ),
297
297
  });
298
298
 
299
- // A host-bound deployment has one fail-closed authorization seam for every
300
- // HTTP session surface. Register it before the routes so a newly added path
301
- // cannot accidentally inherit workspace access without an explicit operation
302
- // classification. The long-lived event stream performs its own initial check
303
- // and bounded reauthorization below.
299
+ // Every deployment has one fail-closed authorization seam for every HTTP
300
+ // session surface. The core boundary always enforces durable OpenGeni-owned
301
+ // private-session rules; an embedding host port can add narrower policy.
302
+ // Register it before the routes so a newly added path cannot accidentally
303
+ // inherit workspace access without an explicit operation classification. The
304
+ // long-lived event stream performs its own initial check and bounded
305
+ // reauthorization below.
304
306
  const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
305
- if (!deps.sessionAuthorization) {
306
- await next();
307
- return;
308
- }
309
307
  const workspaceId = c.req.param("workspaceId") ?? "";
310
308
  const sessionId = c.req.param("sessionId") ?? "";
311
309
  const operation = sessionAuthorizationOperationForHttp(
@@ -320,6 +318,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
320
318
  if (!operation) {
321
319
  throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
322
320
  }
321
+ if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
322
+ await next();
323
+ return;
324
+ }
323
325
  const grant = await requireAccessGrant(c, deps, workspaceId);
324
326
  try {
325
327
  const authorization = await requireSessionAuthorization(deps, grant, {
@@ -587,7 +589,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
587
589
  // account id isn't in the workspace.
588
590
  app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-account", async (c) => {
589
591
  const workspaceId = c.req.param("workspaceId");
590
- await requireAccessGrant(c, deps, workspaceId, "sessions:control");
592
+ const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
591
593
  const sessionId = c.req.param("sessionId");
592
594
  const body = (await c.req.json()) as { target?: string };
593
595
  const target = typeof body.target === "string" ? body.target : "";
@@ -596,6 +598,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
596
598
  message: 'target is required ("auto" or an account id)',
597
599
  });
598
600
  }
601
+ if (!deps.sessionAuthorization) {
602
+ try {
603
+ await requireSessionAuthorization(deps, grant, {
604
+ sessionId,
605
+ operation: "session.codex_account.write",
606
+ surface: "http",
607
+ });
608
+ } catch (error) {
609
+ throw sessionAuthorizationHttpError(error);
610
+ }
611
+ }
599
612
  const pinned = target === "auto" ? null : target;
600
613
  const mutation = await withCodexCapacityMutation(
601
614
  db,
@@ -0,0 +1,274 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ CreateWorkspaceArtifactRequest,
4
+ PublishWorkspaceArtifactVersionRequest,
5
+ RollbackWorkspaceArtifactRequest,
6
+ WorkspaceArtifactContentResponse,
7
+ WorkspaceArtifactDetailResponse,
8
+ WorkspaceArtifactListQuery,
9
+ WorkspaceArtifactListResponse,
10
+ WorkspaceArtifactMutationResponse,
11
+ normalizeWorkspaceArtifactSlug,
12
+ } from "@opengeni/contracts";
13
+ import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
14
+ import {
15
+ createWorkspaceArtifact,
16
+ getWorkspaceArtifact,
17
+ getWorkspaceArtifactContentRef,
18
+ listWorkspaceArtifacts,
19
+ publishWorkspaceArtifactVersion,
20
+ rollbackWorkspaceArtifact,
21
+ WorkspaceArtifactConflictError,
22
+ WorkspaceArtifactNotFoundError,
23
+ WorkspaceArtifactOperationError,
24
+ } from "@opengeni/db";
25
+ import type { Context, Hono } from "hono";
26
+ import { HTTPException } from "hono/http-exception";
27
+ import { z } from "zod";
28
+
29
+ const ArtifactId = z.string().uuid();
30
+ const encoder = new TextEncoder();
31
+ const decoder = new TextDecoder("utf-8", { fatal: true });
32
+
33
+ async function body<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
34
+ const parsed = schema.safeParse(await context.req.json().catch(() => null));
35
+ if (!parsed.success) throw new HTTPException(422, { message: "Invalid artifact request" });
36
+ return parsed.data;
37
+ }
38
+
39
+ function artifactId(context: Context): string {
40
+ const parsed = ArtifactId.safeParse(context.req.param("artifactId"));
41
+ if (!parsed.success) throw new HTTPException(422, { message: "Invalid artifact id" });
42
+ return parsed.data;
43
+ }
44
+
45
+ function errorResponse(context: Context, error: unknown): Response {
46
+ if (error instanceof WorkspaceArtifactNotFoundError) {
47
+ return context.json({ code: "WORKSPACE_ARTIFACT_NOT_FOUND", message: error.message }, 404);
48
+ }
49
+ if (error instanceof WorkspaceArtifactConflictError) {
50
+ return context.json(
51
+ {
52
+ code: "WORKSPACE_ARTIFACT_CONFLICT",
53
+ message: error.message,
54
+ currentVersionId: error.currentVersionId,
55
+ },
56
+ 409,
57
+ );
58
+ }
59
+ if (error instanceof WorkspaceArtifactOperationError) {
60
+ return context.json(
61
+ { code: "INVALID_WORKSPACE_ARTIFACT_OPERATION", message: error.message },
62
+ 422,
63
+ );
64
+ }
65
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "23505") {
66
+ return context.json(
67
+ { code: "WORKSPACE_ARTIFACT_CONFLICT", message: "Artifact slug or operation already exists" },
68
+ 409,
69
+ );
70
+ }
71
+ throw error;
72
+ }
73
+
74
+ function contentMetadata(workspaceId: string, html: string) {
75
+ const bytes = encoder.encode(html);
76
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
77
+ return {
78
+ bytes,
79
+ contentSha256: sha256,
80
+ sizeBytes: bytes.byteLength,
81
+ contentKey: `workspaces/${workspaceId}/workspace-artifacts/blobs/${sha256}.html`,
82
+ };
83
+ }
84
+
85
+ function prepareHtml(deps: ApiRouteDeps, workspaceId: string, html: string) {
86
+ if (!deps.objectStorage)
87
+ throw new HTTPException(503, { message: "Object storage is not configured" });
88
+ const content = contentMetadata(workspaceId, html);
89
+ return {
90
+ ...content,
91
+ persistContent: async () => {
92
+ await deps.objectStorage!.putObject({
93
+ key: content.contentKey,
94
+ contentType: "text/html; charset=utf-8",
95
+ body: content.bytes,
96
+ sha256: content.contentSha256,
97
+ });
98
+ },
99
+ };
100
+ }
101
+
102
+ function provenance(subjectId: string, idempotencyKey: string) {
103
+ return {
104
+ operationKey: `subject:${createHash("sha256").update(`${subjectId}:${idempotencyKey}`).digest("hex")}`,
105
+ actorSubjectId: subjectId,
106
+ sourceSessionId: null,
107
+ sourceTurnId: null,
108
+ sourceAttemptId: null,
109
+ sourceExecutionGeneration: null,
110
+ sourceToolName: null,
111
+ };
112
+ }
113
+
114
+ export function registerWorkspaceArtifactRoutes(app: Hono, deps: ApiRouteDeps): void {
115
+ // `published-artifacts` deliberately avoids the existing `/artifacts/:id`
116
+ // retained-output API. The product route remains simply `/artifacts`.
117
+ const base = "/v1/workspaces/:workspaceId/published-artifacts";
118
+
119
+ app.get(base, async (context) => {
120
+ const workspaceId = context.req.param("workspaceId");
121
+ await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
122
+ const query = WorkspaceArtifactListQuery.safeParse({
123
+ limit: context.req.query("limit"),
124
+ cursor: context.req.query("cursor"),
125
+ });
126
+ if (!query.success) throw new HTTPException(422, { message: "Invalid artifact list query" });
127
+ try {
128
+ return context.json(
129
+ WorkspaceArtifactListResponse.parse(
130
+ await listWorkspaceArtifacts(deps.db, workspaceId, {
131
+ limit: query.data.limit,
132
+ ...(query.data.cursor ? { cursor: query.data.cursor } : {}),
133
+ }),
134
+ ),
135
+ );
136
+ } catch (error) {
137
+ return errorResponse(context, error);
138
+ }
139
+ });
140
+
141
+ app.post(base, async (context) => {
142
+ const workspaceId = context.req.param("workspaceId");
143
+ const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:publish");
144
+ const request = await body(context, CreateWorkspaceArtifactRequest);
145
+ const id = crypto.randomUUID();
146
+ const slugBase = request.slug ?? (normalizeWorkspaceArtifactSlug(request.title) || "artifact");
147
+ const slug = request.slug ?? `${slugBase.slice(0, 87)}-${id.slice(0, 8)}`;
148
+ const content = prepareHtml(deps, workspaceId, request.html);
149
+ try {
150
+ return context.json(
151
+ WorkspaceArtifactMutationResponse.parse(
152
+ await createWorkspaceArtifact(deps.db, {
153
+ accountId: grant.accountId,
154
+ workspaceId,
155
+ artifactId: id,
156
+ slug,
157
+ title: request.title,
158
+ description: request.description ?? null,
159
+ ...content,
160
+ ...provenance(grant.subjectId, request.idempotencyKey),
161
+ }),
162
+ ),
163
+ 201,
164
+ );
165
+ } catch (error) {
166
+ return errorResponse(context, error);
167
+ }
168
+ });
169
+
170
+ app.get(`${base}/:artifactId`, async (context) => {
171
+ const workspaceId = context.req.param("workspaceId");
172
+ await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
173
+ try {
174
+ return context.json(
175
+ WorkspaceArtifactDetailResponse.parse(
176
+ await getWorkspaceArtifact(deps.db, workspaceId, artifactId(context)),
177
+ ),
178
+ );
179
+ } catch (error) {
180
+ return errorResponse(context, error);
181
+ }
182
+ });
183
+
184
+ app.get(`${base}/:artifactId/content`, async (context) => {
185
+ const workspaceId = context.req.param("workspaceId");
186
+ await requireAccessGrant(context, deps, workspaceId, "artifacts:read");
187
+ if (!deps.objectStorage)
188
+ throw new HTTPException(503, { message: "Object storage is not configured" });
189
+ const parsedVersion = context.req.query("versionId");
190
+ if (parsedVersion && !ArtifactId.safeParse(parsedVersion).success) {
191
+ throw new HTTPException(422, { message: "Invalid artifact version id" });
192
+ }
193
+ try {
194
+ const ref = await getWorkspaceArtifactContentRef(
195
+ deps.db,
196
+ workspaceId,
197
+ artifactId(context),
198
+ parsedVersion,
199
+ );
200
+ const object = await deps.objectStorage.getObjectBytes(ref.contentKey);
201
+ if (!object) throw new HTTPException(503, { message: "Artifact content is unavailable" });
202
+ const actualHash = createHash("sha256").update(object.bytes).digest("hex");
203
+ if (actualHash !== ref.version.contentSha256) {
204
+ throw new HTTPException(503, { message: "Artifact content failed integrity verification" });
205
+ }
206
+ let html: string;
207
+ try {
208
+ html = decoder.decode(object.bytes);
209
+ } catch {
210
+ throw new HTTPException(503, { message: "Artifact content is not valid UTF-8" });
211
+ }
212
+ return context.json(
213
+ WorkspaceArtifactContentResponse.parse({
214
+ artifactId: ref.artifactId,
215
+ versionId: ref.version.id,
216
+ contentType: "text/html",
217
+ contentSha256: ref.version.contentSha256,
218
+ html,
219
+ }),
220
+ );
221
+ } catch (error) {
222
+ return errorResponse(context, error);
223
+ }
224
+ });
225
+
226
+ app.post(`${base}/:artifactId/versions`, async (context) => {
227
+ const workspaceId = context.req.param("workspaceId");
228
+ const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:publish");
229
+ const request = await body(context, PublishWorkspaceArtifactVersionRequest);
230
+ const id = artifactId(context);
231
+ const content = prepareHtml(deps, workspaceId, request.html);
232
+ try {
233
+ return context.json(
234
+ WorkspaceArtifactMutationResponse.parse(
235
+ await publishWorkspaceArtifactVersion(deps.db, {
236
+ accountId: grant.accountId,
237
+ workspaceId,
238
+ artifactId: id,
239
+ expectedCurrentVersionId: request.expectedCurrentVersionId,
240
+ ...(request.title !== undefined ? { title: request.title } : {}),
241
+ ...(request.description !== undefined ? { description: request.description } : {}),
242
+ ...content,
243
+ ...provenance(grant.subjectId, request.idempotencyKey),
244
+ }),
245
+ ),
246
+ );
247
+ } catch (error) {
248
+ return errorResponse(context, error);
249
+ }
250
+ });
251
+
252
+ app.post(`${base}/:artifactId/rollback`, async (context) => {
253
+ const workspaceId = context.req.param("workspaceId");
254
+ const grant = await requireAccessGrant(context, deps, workspaceId, "artifacts:publish");
255
+ const request = await body(context, RollbackWorkspaceArtifactRequest);
256
+ try {
257
+ return context.json(
258
+ WorkspaceArtifactMutationResponse.parse(
259
+ await rollbackWorkspaceArtifact(deps.db, {
260
+ accountId: grant.accountId,
261
+ workspaceId,
262
+ artifactId: artifactId(context),
263
+ versionId: request.versionId,
264
+ expectedCurrentVersionId: request.expectedCurrentVersionId,
265
+ reason: request.reason,
266
+ ...provenance(grant.subjectId, request.idempotencyKey),
267
+ }),
268
+ ),
269
+ );
270
+ } catch (error) {
271
+ return errorResponse(context, error);
272
+ }
273
+ });
274
+ }