@opengeni/api-router 0.14.4 → 0.15.4

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.
@@ -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
+ }