@opengeni/api-router 0.5.6 → 0.7.3

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.
@@ -1,4 +1,4 @@
1
- // Workbench v2 — capture READ serving (dossier §10.3).
1
+ // Workbench v2 — capture READ serving.
2
2
  //
3
3
  // The two GET capture routes in `sessions.ts` are thin: grant-first (files:read),
4
4
  // load the row (DB, RLS-scoped), then delegate the response SHAPING to the two
@@ -46,13 +46,24 @@ function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; exp
46
46
  // manifest key, the blob is gone (GC'd), or the bytes fail to parse/validate — a
47
47
  // malformed capture is treated as "no capture available" (the list route degrades
48
48
  // to {available:false}, the file route to 404). Capture reads must NEVER be worse
49
- // than the status-quo live/wake fallback (dossier §10.10), so a poison row can
49
+ // than the status-quo live/wake fallback, so a poison row can
50
50
  // never 500 the workbench; it degrades and logs.
51
51
  async function loadManifest(
52
52
  row: WorkspaceCaptureRow,
53
53
  storage: CaptureStoragePort,
54
- ): Promise<{ manifest: WorkspaceCaptureManifest; byteLength: number } | null> {
54
+ ): Promise<{
55
+ manifest: WorkspaceCaptureManifest;
56
+ byteLength: number;
57
+ stats: WorkspaceCaptureStats;
58
+ } | null> {
55
59
  if (!row.manifestKey) return null;
60
+ const stats = WorkspaceCaptureStats.safeParse(row.stats);
61
+ if (!stats.success) {
62
+ console.warn(
63
+ `workspace capture read — row stats failed schema validation (session=${row.sessionId} rev=${row.revision})`,
64
+ );
65
+ return null;
66
+ }
56
67
  const blob = await storage.getObjectBytes(row.manifestKey);
57
68
  if (!blob) return null;
58
69
  let json: unknown;
@@ -71,7 +82,39 @@ async function loadManifest(
71
82
  );
72
83
  return null;
73
84
  }
74
- return { manifest: parsed.data, byteLength: blob.bytes.byteLength };
85
+ const manifest = parsed.data;
86
+ const servedStats = stats.data;
87
+ const statsMatch =
88
+ manifest.stats.repoCount === servedStats.repoCount &&
89
+ manifest.stats.fileCount === servedStats.fileCount &&
90
+ manifest.stats.additions === servedStats.additions &&
91
+ manifest.stats.deletions === servedStats.deletions &&
92
+ manifest.stats.totalBytes === servedStats.totalBytes &&
93
+ manifest.stats.tooLargeCount === servedStats.tooLargeCount &&
94
+ manifest.stats.binaryCount === servedStats.binaryCount &&
95
+ manifest.stats.treeEntryCount === servedStats.treeEntryCount &&
96
+ manifest.stats.treeTruncated === servedStats.treeTruncated &&
97
+ manifest.stats.durationMs === servedStats.durationMs &&
98
+ (manifest.stats.fingerprint ?? null) === (servedStats.fingerprint ?? null);
99
+ if (
100
+ manifest.revision !== row.revision ||
101
+ manifest.capturedAt !== row.capturedAt ||
102
+ manifest.turnId !== row.turnId ||
103
+ manifest.leaseEpoch !== row.leaseEpoch ||
104
+ !statsMatch ||
105
+ manifest.repos.length !== manifest.stats.repoCount ||
106
+ manifest.files.length !== manifest.stats.fileCount ||
107
+ manifest.treeTruncated !== manifest.stats.treeTruncated
108
+ ) {
109
+ // A valid blob under the wrong row/key is still poison: row metadata drives
110
+ // cache identity and revision pinning in the client. Never combine two
111
+ // different captures into one apparently authoritative response.
112
+ console.warn(
113
+ `workspace capture read — manifest identity did not match row (session=${row.sessionId} rev=${row.revision})`,
114
+ );
115
+ return null;
116
+ }
117
+ return { manifest, byteLength: blob.bytes.byteLength, stats: servedStats };
75
118
  }
76
119
 
77
120
  /**
@@ -105,15 +148,12 @@ export async function serveWorkspaceCapture(
105
148
  });
106
149
  }
107
150
  if (row.state !== "available" || !row.manifestKey) return { available: false };
108
- const stats = WorkspaceCaptureStats.safeParse(row.stats);
109
- if (!stats.success) {
110
- // A row with malformed stats (or a synthetic/partial row) degrades to the
111
- // cold-fallback state rather than 500.
112
- console.warn(
113
- `workspace capture read row stats failed schema validation (session=${row.sessionId} rev=${row.revision})`,
114
- );
115
- return { available: false };
116
- }
151
+ // Validate every manifest before serving it, including the rare >2MB signed
152
+ // path. Previously that branch signed arbitrary bytes merely because they
153
+ // exceeded the inline cap, allowing a poison/mis-keyed blob to bypass both the
154
+ // schema and row-identity checks.
155
+ const loaded = await loadManifest(row, storage);
156
+ if (!loaded) return { available: false };
117
157
  const meta = {
118
158
  available: true as const,
119
159
  revision: row.revision,
@@ -121,34 +161,12 @@ export async function serveWorkspaceCapture(
121
161
  turnId: row.turnId,
122
162
  leaseEpoch: row.leaseEpoch,
123
163
  sizeBytes: row.sizeBytes ?? 0,
124
- stats: stats.data,
164
+ stats: loaded.stats,
125
165
  };
126
- const blob = await storage.getObjectBytes(row.manifestKey);
127
- if (!blob) {
128
- // Manifest raced GC between the row read and the blob fetch — degrade to the
129
- // cold-fallback state rather than 500.
130
- return { available: false };
131
- }
132
- if (blob.bytes.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
133
- let json: unknown;
134
- try {
135
- json = JSON.parse(new TextDecoder().decode(blob.bytes));
136
- } catch {
137
- console.warn(
138
- `workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
139
- );
140
- return { available: false };
141
- }
142
- const manifest = WorkspaceCaptureManifest.safeParse(json);
143
- if (!manifest.success) {
144
- console.warn(
145
- `workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
146
- );
147
- return { available: false };
148
- }
166
+ if (loaded.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
149
167
  return GetWorkspaceCaptureResponse.parse({
150
168
  ...meta,
151
- manifest: manifest.data,
169
+ manifest: loaded.manifest,
152
170
  manifestUrl: null,
153
171
  });
154
172
  }
@@ -7,9 +7,11 @@ import {
7
7
  UpdateWorkspaceModelPolicyRequest,
8
8
  UpdateWorkspaceRequest,
9
9
  UpdateWorkspaceSettingsRequest,
10
+ WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
10
11
  WorkspaceInferenceControlRequest,
11
12
  Workspace,
12
13
  WorkspaceMember,
14
+ workspaceControlUtf8Bytes,
13
15
  type AccessContext,
14
16
  type Permission,
15
17
  } from "@opengeni/contracts";
@@ -34,6 +36,7 @@ import {
34
36
  updateWorkspaceSettings,
35
37
  upsertWorkspaceModelPolicy,
36
38
  } from "@opengeni/db";
39
+ import { boundWorkspaceControlHttpPage } from "@opengeni/events";
37
40
  import type { Hono } from "hono";
38
41
  import { HTTPException } from "hono/http-exception";
39
42
  import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
@@ -171,12 +174,18 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
171
174
  app.post("/v1/workspaces/:workspaceId/inference-control", async (c) => {
172
175
  const workspaceId = c.req.param("workspaceId");
173
176
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
174
- const payload = WorkspaceInferenceControlRequest.parse(await c.req.json());
177
+ if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
178
+ throw new HTTPException(400, { message: "workspace-control actor is too large" });
179
+ }
180
+ const parsed = WorkspaceInferenceControlRequest.safeParse(await c.req.json().catch(() => null));
181
+ if (!parsed.success) {
182
+ throw new HTTPException(400, { message: "invalid workspace inference-control request" });
183
+ }
175
184
  return c.json(
176
185
  await controlHumanWorkspace(
177
186
  { db: deps.db, bus: deps.bus, workflowClient: deps.workflowClient },
178
187
  { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
179
- payload,
188
+ parsed.data,
180
189
  ),
181
190
  );
182
191
  });
@@ -185,21 +194,31 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
185
194
  const workspaceId = c.req.param("workspaceId");
186
195
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
187
196
  const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
188
- return c.json(
189
- await listWorkspaceControlEvents(
190
- deps.db,
191
- workspaceId,
192
- after,
193
- boundedLimit(c.req.query("limit")),
194
- ),
195
- );
197
+ const limit = boundedLimit(c.req.query("limit"));
198
+ const fetched = await listWorkspaceControlEvents(deps.db, workspaceId, after, limit + 1);
199
+ const countHasMore = fetched.length > limit;
200
+ const page = boundWorkspaceControlHttpPage(fetched.slice(0, limit));
201
+ const truncated = countHasMore || page.truncated;
202
+ c.header("X-OpenGeni-Page-Bytes", String(page.bytes));
203
+ c.header("X-OpenGeni-Page-Truncated", String(truncated));
204
+ if (page.nextSequence !== null) {
205
+ c.header("X-OpenGeni-Next-After", String(page.nextSequence));
206
+ }
207
+ return c.json(page.events);
196
208
  });
197
209
 
198
210
  app.get("/v1/workspaces/:workspaceId/control-events/stream", async (c) => {
199
211
  const workspaceId = c.req.param("workspaceId");
200
212
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
201
213
  const after = Math.max(0, Number.parseInt(c.req.query("after") ?? "0", 10) || 0);
202
- return await sseWorkspaceControlStream(deps.db, deps.bus, workspaceId, after, c.req.raw.signal);
214
+ return await sseWorkspaceControlStream(
215
+ deps.db,
216
+ deps.bus,
217
+ workspaceId,
218
+ after,
219
+ c.req.raw.signal,
220
+ { observability: deps.observability },
221
+ );
203
222
  });
204
223
 
205
224
  app.put("/v1/workspaces/:workspaceId/default-rig", async (c) => {
@@ -1,7 +1,7 @@
1
1
  // apps/api/src/sandbox/access.ts — the API-tier sandbox access seam.
2
2
  //
3
3
  // This is the foundation of the API-DIRECT control plane
4
- // (docs/design/sandbox-surfacing): the apps/api process constructs its OWN
4
+ // (docs/connected-machines.md): the apps/api process constructs its OWN
5
5
  // sandbox client and resumes boxes by id IN-PROCESS, so non-turn ops (viewer
6
6
  // attach, FS/git reads, tunnel URL mint) never touch Temporal or a worker.
7
7
  //
@@ -1,5 +1,5 @@
1
1
  // apps/api/src/sandbox/auth-callout.ts — the NATS AUTH-CALLOUT responder (the
2
- // bring-your-own-compute M-AUTH tenancy boundary; dossier §10.1 NATS Accounts per
2
+ // bring-your-own-compute M-AUTH tenancy boundary; NATS Accounts per
3
3
  // workspace + §17 the isolation smoke + §19 the NATS-Accounts-misconfig leak risk).
4
4
  //
5
5
  // THE BOUNDARY THIS CLOSES: an external agent connects to NATS presenting its
@@ -50,7 +50,11 @@ import {
50
50
  ChannelAConflictError,
51
51
  ChannelANotFoundError,
52
52
  ChannelAUnsupportedError,
53
+ ChannelAUnavailableError,
53
54
  ChannelAValidationError,
55
+ toolspaceTokenFileFromEnvironment,
56
+ withToolspaceTokenSession,
57
+ withRunCredentialsSession,
54
58
  type ChannelASession,
55
59
  type EstablishedSandboxSession,
56
60
  } from "@opengeni/runtime/sandbox";
@@ -293,9 +297,16 @@ export async function withChannelA<T>(
293
297
  established,
294
298
  ).session
295
299
  : established.session;
300
+ const credentialSession = withRunCredentialsSession(routedSession as object, session.id);
301
+ const scopedSession = environment.OPENGENI_TOOLSPACE_TOKEN_FILE
302
+ ? withToolspaceTokenSession(
303
+ credentialSession,
304
+ toolspaceTokenFileFromEnvironment(environment, session.id),
305
+ )
306
+ : credentialSession;
296
307
 
297
308
  const service = new SandboxChannelAService({
298
- session: routedSession as ChannelASession,
309
+ session: scopedSession as ChannelASession,
299
310
  leaseEpoch: leaseSnapshot.leaseEpoch,
300
311
  emit,
301
312
  });
@@ -313,6 +324,8 @@ export async function withChannelA<T>(
313
324
  * already-HTTPException unchanged. */
314
325
  export function mapChannelAError(error: unknown): unknown {
315
326
  if (error instanceof HTTPException) return error;
327
+ if (error instanceof ChannelAUnavailableError)
328
+ return new HTTPException(503, { message: error.message });
316
329
  if (error instanceof ChannelAValidationError)
317
330
  return new HTTPException(400, { message: error.message });
318
331
  if (error instanceof ChannelANotFoundError)
@@ -1,5 +1,5 @@
1
1
  // apps/api/src/sandbox/enrollment.ts — the API-DIRECT enrollment device-flow seam
2
- // (M5 of the bring-your-own-compute mega-PR; dossier §10.2 enrollment + §18 LOUD
2
+ // (M5 of the bring-your-own-compute mega-PR; enrollment + §18 LOUD
3
3
  // consent). This is the service layer the routes (routes/enrollments.ts) call — it
4
4
  // mirrors the channel-a.ts / viewer.ts split (a thin route over a focused service).
5
5
  //
@@ -20,7 +20,7 @@
20
20
  // placeholder for the per-workspace NATS Account creds [infra-deferred]);
21
21
  // denied/expired/disabled → the typed state.
22
22
  //
23
- // SECURITY (dossier §18): device_code/user_code are CSPRNG-unguessable + short-TTL +
23
+ // SECURITY: device_code/user_code are CSPRNG-unguessable + short-TTL +
24
24
  // single-use; approve is strictly workspace-gated (the route asserts the grant); the
25
25
  // signing secret value is NEVER logged. Rate-limiting of start/poll is enforced at
26
26
  // the route. The consent record (who/when/what) lives on the request row.
@@ -78,7 +78,7 @@ export const DEVICE_POLL_INTERVAL_SECONDS = 5;
78
78
  // enrollment status on every (re)connect (auth-callout.ts) — a revoked machine is
79
79
  // denied regardless of bearer life — exactly as the long-lived relay token relies on.
80
80
  export const ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
81
- // The relay PRODUCER token (the `ogr_` token; M8b/dossier §10.5) is ENROLLMENT-scoped,
81
+ // The relay PRODUCER token (the `ogr_` token; M8b) is ENROLLMENT-scoped,
82
82
  // NOT per-stream: the agent presents it on every channel registration for the life
83
83
  // of its run, and the producer side has no per-viewer epoch fence (that is the
84
84
  // VIEWER's `ogs_` token's job). So it is long-lived — 30 days — re-minted on every
@@ -492,7 +492,7 @@ async function buildEnrollmentCredentials(
492
492
  // Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
493
493
  // The agent's relay producer appends only its routing query and assumes the base
494
494
  // already carries the relay's `/stream` route; a path-less base 400s the dial and
495
- // makes the terminal/desktop streams unreachable (dossier §V5/§V6).
495
+ // makes the terminal/desktop streams unreachable.
496
496
  relayUrl: relayDialBaseFromSettings(settings),
497
497
  relayToken,
498
498
  // M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
@@ -1,4 +1,4 @@
1
- // apps/api/src/sandbox/machines.ts — the M10 Machines-DASHBOARD service (dossier
1
+ // apps/api/src/sandbox/machines.ts — the M10 Machines-DASHBOARD service (design
2
2
  // §10.7). Builds the `MachinesResponse` the dashboard renders: the workspace's
3
3
  // enrolled selfhosted machines, each enriched with
4
4
  // * STATE — the M3 liveness (online/reconnecting/offline) overlaid with the
@@ -1,5 +1,5 @@
1
1
  // apps/api/src/sandbox/metrics-ingestion.ts — the M10 metrics INGESTION consumer
2
- // (dossier §10.7 + §10.6) + the connect-Hello DISPLAY-REFRESH consumer. The
2
+ // + the connect-Hello DISPLAY-REFRESH consumer. The
3
3
  // enrolled agent piggybacks a `MetricsSample` on its ~5s heartbeat (an
4
4
  // `AgentEvent` published one-way on `agent.<ws>.<id>.events`) and publishes a
5
5
  // `Hello` (its live self-description) on `agent.<ws>.<id>.hello` on every connect
@@ -141,7 +141,7 @@ export async function sessionAttachEnvironment(
141
141
  // the same env from runSettings.sandboxBackend = the session's backend. An
142
142
  // attach env keyed off the deployment default would cold-create e.g. an e2b
143
143
  // session's box with /workspace-rooted values while its turn declares
144
- // /home/user-rooted ones — the same guard-killed first turn all over again.
144
+ // /home/user ones — the same guard-killed first turn all over again.
145
145
  const settingsForSession =
146
146
  session.sandboxBackend !== services.settings.sandboxBackend
147
147
  ? { ...services.settings, sandboxBackend: session.sandboxBackend }