@opengeni/api-router 0.7.3 → 0.11.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.
@@ -4,6 +4,13 @@ import {
4
4
  CreateFileUploadResponse,
5
5
  FileAsset,
6
6
  FileDownloadUrlResponse,
7
+ RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
8
+ RETAINED_OUTPUT_MAX_PAGE_BYTES,
9
+ RetainedArtifactMetadataSchema,
10
+ retainedArtifactReferenceFromFile,
11
+ resolveRetainedOutputRange,
12
+ type RetainedArtifactMetadata,
13
+ type RetainedOutputUnavailableReason,
7
14
  } from "@opengeni/contracts";
8
15
  import {
9
16
  claimFileUploadCleanup,
@@ -11,7 +18,9 @@ import {
11
18
  completeFileUpload,
12
19
  createFileUpload,
13
20
  getFileUpload,
21
+ getRetainedFileArtifact,
14
22
  requireFile,
23
+ type RetainedFileArtifact,
15
24
  } from "@opengeni/db";
16
25
  import type { Hono } from "hono";
17
26
  import { HTTPException } from "hono/http-exception";
@@ -235,6 +244,90 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
235
244
  return c.json(FileAsset.parse(file));
236
245
  });
237
246
 
247
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
248
+ const workspaceId = c.req.param("workspaceId");
249
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
250
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
251
+ const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
252
+ if (!artifact) {
253
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
254
+ }
255
+ return c.json(retainedArtifactMetadata(artifact));
256
+ });
257
+
258
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
259
+ const workspaceId = c.req.param("workspaceId");
260
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
261
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
262
+ const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
263
+ if (!artifact) {
264
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
265
+ }
266
+
267
+ const metadata = retainedArtifactMetadata(artifact);
268
+ if (!metadata.available) {
269
+ return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
270
+ }
271
+ if (!objectStorage) {
272
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
273
+ }
274
+
275
+ const rangeHeader = c.req.header("range");
276
+ const range = resolveRetainedOutputRange(
277
+ rangeHeader,
278
+ metadata.originalBytes,
279
+ rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
280
+ );
281
+ if (range.kind === "invalid") {
282
+ return c.json(
283
+ {
284
+ message: "invalid retained artifact byte range",
285
+ reason: range.reason,
286
+ maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES,
287
+ },
288
+ 400,
289
+ );
290
+ }
291
+ if (range.kind === "unsatisfiable") {
292
+ return c.json(
293
+ { message: "retained artifact byte range is not satisfiable", reason: range.reason },
294
+ 416,
295
+ {
296
+ "Accept-Ranges": "bytes",
297
+ "Content-Range": range.contentRange,
298
+ "Cache-Control": "private, no-store",
299
+ },
300
+ );
301
+ }
302
+
303
+ const headers = {
304
+ "Accept-Ranges": range.acceptRanges,
305
+ "Cache-Control": "private, no-store",
306
+ "Content-Length": String(range.length),
307
+ "Content-Type": metadata.contentType,
308
+ "X-Content-Type-Options": "nosniff",
309
+ ...(range.contentRange ? { "Content-Range": range.contentRange } : {}),
310
+ };
311
+ if (range.kind === "empty") {
312
+ if (!(await objectStorage.fileExists(artifact.file))) {
313
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
314
+ }
315
+ return c.body(null, 200, headers);
316
+ }
317
+
318
+ const bytes = await objectStorage.getFileRange(artifact.file, {
319
+ start: range.start,
320
+ end: range.end,
321
+ });
322
+ if (!bytes) {
323
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
324
+ }
325
+ if (bytes.byteLength !== range.length) {
326
+ throw new HTTPException(502, { message: "object storage returned an invalid byte range" });
327
+ }
328
+ return c.body(new Uint8Array(bytes), range.status, headers);
329
+ });
330
+
238
331
  app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
239
332
  const workspaceId = c.req.param("workspaceId");
240
333
  await requireAccessGrant(c, deps, workspaceId, "files:read");
@@ -271,3 +364,63 @@ function sanitizeFilename(filename: string): string {
271
364
  function publicFileUploadStatus(status: string): string {
272
365
  return status === "cleanup_pending" ? "failed" : status;
273
366
  }
367
+
368
+ function retainedArtifactId(value: string): string {
369
+ const parsed = FileAsset.shape.id.safeParse(value);
370
+ if (!parsed.success) {
371
+ throw new HTTPException(404, { message: "artifact not found" });
372
+ }
373
+ return parsed.data;
374
+ }
375
+
376
+ function retainedArtifactUnavailable(
377
+ artifactId: string,
378
+ reason: RetainedOutputUnavailableReason,
379
+ ): RetainedArtifactMetadata {
380
+ return RetainedArtifactMetadataSchema.parse({ available: false, artifactId, reason });
381
+ }
382
+
383
+ function retainedArtifactMetadata(artifact: RetainedFileArtifact): RetainedArtifactMetadata {
384
+ const reference = retainedArtifactReferenceFromFile(artifact.file);
385
+ if (reference) return reference;
386
+
387
+ const { file, uploadStatus, uploadExpiresAt } = artifact;
388
+ if (file.status === "deleted") {
389
+ return retainedArtifactUnavailable(file.id, "deleted");
390
+ }
391
+ if (
392
+ file.status === "expired" ||
393
+ uploadStatus === "expired" ||
394
+ (uploadStatus === "pending" &&
395
+ uploadExpiresAt !== null &&
396
+ uploadExpiresAt.getTime() < Date.now())
397
+ ) {
398
+ return retainedArtifactUnavailable(file.id, "expired");
399
+ }
400
+ if (file.status === "failed" || uploadStatus === "failed" || uploadStatus === "cleanup_pending") {
401
+ return retainedArtifactUnavailable(file.id, "failed");
402
+ }
403
+ if (file.status === "pending_upload" || uploadStatus === "pending") {
404
+ return retainedArtifactUnavailable(file.id, "pending");
405
+ }
406
+ return retainedArtifactUnavailable(file.id, "unsupported");
407
+ }
408
+
409
+ function retainedArtifactUnavailableStatus(
410
+ reason: RetainedOutputUnavailableReason,
411
+ ): 404 | 409 | 410 | 422 {
412
+ switch (reason) {
413
+ case "deleted":
414
+ return 404;
415
+ case "expired":
416
+ case "missing_storage":
417
+ return 410;
418
+ case "unsupported":
419
+ case "not_retained":
420
+ case "storage_write_failed":
421
+ return 422;
422
+ case "pending":
423
+ case "failed":
424
+ return 409;
425
+ }
426
+ }
@@ -19,13 +19,14 @@ import {
19
19
  SwapActiveSandboxRequest,
20
20
  SwapActiveSandboxResponse,
21
21
  } from "@opengeni/contracts";
22
- import { getEnrollment, readMachineMetricsSeries } from "@opengeni/db";
22
+ import { getEnrollment, readMachineMetricsSeries, requireSession } from "@opengeni/db";
23
23
  import type { Hono } from "hono";
24
24
  import { HTTPException } from "hono/http-exception";
25
25
  import { requireAccessGrant } from "@opengeni/core";
26
26
  import type { ApiRouteDeps } from "@opengeni/core";
27
27
  import { buildFleetContextForSession, swapActiveSandbox } from "@opengeni/core";
28
28
  import { listMachines, metricRowToSample } from "../sandbox/machines";
29
+ import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
29
30
 
30
31
  // The supported series windows → milliseconds. An unknown/absent window defaults
31
32
  // to 1h (the default). Bounded so a caller cannot request an unbounded
@@ -102,7 +103,26 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
102
103
  workspaceId,
103
104
  sessionId,
104
105
  });
105
- const result = await swapActiveSandbox({ db, settings, bus }, ctx, body.target);
106
+ const result = await swapActiveSandbox(
107
+ {
108
+ db,
109
+ settings,
110
+ bus,
111
+ ensureSessionGroupReady: async (fleetCtx) => {
112
+ const session = await requireSession(db, fleetCtx.workspaceId, fleetCtx.sessionId);
113
+ return await ensureViewerSessionGroupReady(
114
+ { db, settings, bus },
115
+ {
116
+ accountId: fleetCtx.accountId,
117
+ workspaceId: fleetCtx.workspaceId,
118
+ session,
119
+ },
120
+ );
121
+ },
122
+ },
123
+ ctx,
124
+ body.target,
125
+ );
106
126
  return c.json(SwapActiveSandboxResponse.parse(result));
107
127
  });
108
128
  }