@opengeni/api-router 0.14.4 → 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/dist/app.d.ts +5 -1
- package/dist/app.js +3 -1
- package/dist/auth/managed-auth.d.ts +2 -2
- package/dist/{chunk-36PW33ND.js → chunk-UVL2KH56.js} +1470 -213
- package/dist/chunk-UVL2KH56.js.map +1 -0
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-bot.d.ts +21 -0
- package/dist/integrations/slack-interactions.d.ts +30 -0
- package/dist/routes/workspace-artifacts.d.ts +3 -0
- package/package.json +10 -10
- package/src/app.ts +59 -28
- package/src/http/auth.ts +4 -1
- package/src/index.ts +17 -4
- package/src/integrations/slack-bot.ts +80 -11
- package/src/integrations/slack-interactions.ts +856 -0
- package/src/mcp/server.ts +209 -0
- package/src/routes/sessions.ts +23 -10
- package/src/routes/workspace-artifacts.ts +274 -0
- package/dist/chunk-36PW33ND.js.map +0 -1
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;
|
package/src/routes/sessions.ts
CHANGED
|
@@ -296,16 +296,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
296
296
|
),
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
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
|
+
}
|