@opengeni/api-router 0.30.1 → 0.30.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.
@@ -100,7 +100,7 @@ import {
100
100
  getBrowserRevisionArtifactAuthority,
101
101
  getEnrollment,
102
102
  getExternalAuthInteractiveContext,
103
- getFiles,
103
+ getFilesForSubject,
104
104
  getFileUpload,
105
105
  getAuthRun,
106
106
  getExternalAuthPreparation,
@@ -145,6 +145,7 @@ import {
145
145
  SessionAuthorizationDeniedError,
146
146
  SessionAuthorizationUnavailableError,
147
147
  type ApiRouteDeps,
148
+ type ResolvedSessionAuthorization,
148
149
  } from "@opengeni/core";
149
150
  import {
150
151
  BrowserControlProtocolError,
@@ -927,7 +928,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
927
928
  browserSessionId,
928
929
  "session.control",
929
930
  "browser.action",
930
- async ({ sessionClient, binding, record }) => {
931
+ async ({ sessionClient, binding, record, sourceAuthorization }) => {
931
932
  const command = BrowserActionCommand.parse({
932
933
  protocolVersion: BROWSER_CONTROL_PROTOCOL_VERSION,
933
934
  operationId: request.operationId,
@@ -957,27 +958,25 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
957
958
  message: "object storage is not configured",
958
959
  });
959
960
  }
960
- const files = await getFiles(deps.db, workspaceId, workspaceFileIds);
961
- const byId = new Map(files.map((file) => [file.id, file]));
962
- const ordered = workspaceFileIds.map((fileId) => byId.get(fileId));
963
- if (ordered.some((file) => !file)) {
964
- throw new HTTPException(404, { message: "workspace file not found" });
965
- }
966
- if (ordered.some((file) => file!.status !== "ready")) {
967
- throw new HTTPException(409, { message: "workspace file is not ready" });
968
- }
961
+ const files = await getFilesForSubject(deps.db, {
962
+ accountId: grant.accountId,
963
+ workspaceId,
964
+ subjectId: browserFileAuthoritySubjectId(grant, sourceAuthorization),
965
+ fileIds: workspaceFileIds,
966
+ });
967
+ const ordered = requireAuthorizedBrowserUploadFiles(workspaceFileIds, files);
969
968
  const authorities = await Promise.all(
970
969
  ordered.map(async (file) => {
971
970
  const signed = await deps.objectStorage!.createGetUrl({
972
- key: file!.objectKey,
971
+ key: file.objectKey,
973
972
  expiresInSeconds: BROWSER_WORKSPACE_FILE_AUTHORITY_TTL_SECONDS,
974
973
  audience: signedUrlAudienceForPlacement(record.session.placement),
975
974
  });
976
975
  return {
977
- fileId: file!.id,
978
- safeFilename: browserUploadFilename(file!.safeFilename),
979
- sizeBytes: file!.sizeBytes,
980
- sha256: browserUploadSha256(file!.sha256),
976
+ fileId: file.id,
977
+ safeFilename: browserUploadFilename(file.safeFilename),
978
+ sizeBytes: file.sizeBytes,
979
+ sha256: browserUploadSha256(file.sha256),
981
980
  download: {
982
981
  url: signed.url,
983
982
  expiresAt: signed.expiresAt.toISOString(),
@@ -2874,6 +2873,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2874
2873
  record: BrowserSessionControlRecord;
2875
2874
  binding: NonNullable<BrowserSessionControlRecord["session"]["controller"]>;
2876
2875
  placement: BrowserPlacement;
2876
+ sourceAuthorization: ResolvedSessionAuthorization | null;
2877
2877
  }) => Promise<T>,
2878
2878
  recoverMissing = true,
2879
2879
  ): Promise<T> {
@@ -2883,7 +2883,12 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2883
2883
  workspaceId,
2884
2884
  browserSessionId,
2885
2885
  });
2886
- await authorizeSourceSession(deps, grant, record.sourceSessionId, authorizationOperation);
2886
+ const sourceAuthorization = await authorizeSourceSession(
2887
+ deps,
2888
+ grant,
2889
+ record.sourceSessionId,
2890
+ authorizationOperation,
2891
+ );
2887
2892
  if (record.session.lifecycle !== "active" || !record.session.controller) {
2888
2893
  throw new BrowserSessionStateError("BrowserSession is not active");
2889
2894
  }
@@ -2923,6 +2928,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2923
2928
  record,
2924
2929
  binding,
2925
2930
  placement,
2931
+ sourceAuthorization,
2926
2932
  };
2927
2933
  let result: T;
2928
2934
  try {
@@ -3792,9 +3798,9 @@ async function authorizeSourceSession(
3792
3798
  grant: AccessGrant,
3793
3799
  sessionId: string,
3794
3800
  operation: SessionAuthorizationOperation,
3795
- ): Promise<void> {
3801
+ ): Promise<ResolvedSessionAuthorization | null> {
3796
3802
  try {
3797
- await requireSessionAuthorization(deps, grant, {
3803
+ return await requireSessionAuthorization(deps, grant, {
3798
3804
  sessionId,
3799
3805
  operation,
3800
3806
  surface: "http",
@@ -4285,6 +4291,40 @@ function browserUploadFileIds(action: BrowserActionRequestValue["action"]): stri
4285
4291
  ];
4286
4292
  }
4287
4293
 
4294
+ /** Resolve Drive authority from the same immutable session authorization that
4295
+ * admitted the browser action. Agent-attempt subjects are technical worker
4296
+ * identities; their frozen initiating human is the only personal Drive
4297
+ * principal. Pure service attempts retain null and can read only ordinary
4298
+ * workspace files through the database predicate. */
4299
+ export function browserFileAuthoritySubjectId(
4300
+ grant: AccessGrant,
4301
+ authorization: ResolvedSessionAuthorization | null,
4302
+ ): string | null {
4303
+ if (!authorization) return grant.principalKind === "agent_attempt" ? null : grant.subjectId;
4304
+ return authorization.actor.kind === "agent_attempt"
4305
+ ? authorization.actor.initiatingHumanSubjectId
4306
+ : authorization.actor.subjectId;
4307
+ }
4308
+
4309
+ /** The batch authority query intentionally omits every unauthorized file. Any
4310
+ * omission therefore fails the whole upload before an object-storage URL is
4311
+ * minted, including mixed ordinary/Drive mappings and partially authorized
4312
+ * batches. */
4313
+ export function requireAuthorizedBrowserUploadFiles(
4314
+ workspaceFileIds: readonly string[],
4315
+ authorizedFiles: readonly FileAsset[],
4316
+ ): FileAsset[] {
4317
+ const byId = new Map(authorizedFiles.map((file) => [file.id, file]));
4318
+ return workspaceFileIds.map((fileId) => {
4319
+ const file = byId.get(fileId);
4320
+ if (!file) throw new HTTPException(404, { message: "workspace file not found" });
4321
+ if (file.status !== "ready") {
4322
+ throw new HTTPException(409, { message: "workspace file is not ready" });
4323
+ }
4324
+ return file;
4325
+ });
4326
+ }
4327
+
4288
4328
  function browserUploadFilename(value: string): string {
4289
4329
  const normalized = value
4290
4330
  .trim()
@@ -557,6 +557,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
557
557
  ...(c.req.query("code") ? { code: c.req.query("code") } : {}),
558
558
  ...(c.req.query("state") ? { state: c.req.query("state") } : {}),
559
559
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
560
+ ...(c.req.query("picked_file_ids") ? { pickedFileIds: c.req.query("picked_file_ids") } : {}),
560
561
  requestUrl: c.req.url,
561
562
  };
562
563
  const result = await completeGoogleDriveOAuthCallback(deps, input);
@@ -24,9 +24,10 @@ import {
24
24
  getFileUpload,
25
25
  getGeneratedImageArtifact,
26
26
  getGeneratedVideoArtifact,
27
+ getFilesForSubject,
27
28
  getRetainedFileArtifact,
28
29
  getRetainedScreenshotArtifact,
29
- requireFile,
30
+ requireFileForSubject,
30
31
  type RetainedFileArtifact,
31
32
  type GeneratedImageArtifact,
32
33
  type GeneratedVideoArtifact,
@@ -34,7 +35,11 @@ import {
34
35
  } from "@opengeni/db";
35
36
  import type { Context, Hono } from "hono";
36
37
  import { HTTPException } from "hono/http-exception";
37
- import { requireAccessGrant } from "@opengeni/core";
38
+ import {
39
+ requireAccessGrant,
40
+ requireLiveAgentAttemptAuthorization,
41
+ SessionAuthorizationDeniedError,
42
+ } from "@opengeni/core";
38
43
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
39
44
  import type { ApiRouteDeps } from "@opengeni/core";
40
45
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -265,8 +270,13 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
265
270
 
266
271
  app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
267
272
  const workspaceId = c.req.param("workspaceId");
268
- await requireAccessGrant(c, deps, workspaceId, "files:read");
269
- const file = await requireFile(db, workspaceId, c.req.param("fileId")).catch(() => null);
273
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
274
+ const file = await requireFileForSubject(db, {
275
+ accountId: grant.accountId,
276
+ workspaceId,
277
+ subjectId: grant.subjectId,
278
+ fileId: c.req.param("fileId"),
279
+ }).catch(() => null);
270
280
  if (!file) {
271
281
  throw new HTTPException(404, { message: "file not found" });
272
282
  }
@@ -275,9 +285,14 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
275
285
 
276
286
  app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
277
287
  const workspaceId = c.req.param("workspaceId");
278
- await requireAccessGrant(c, deps, workspaceId, "files:read");
288
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
279
289
  const artifactId = retainedArtifactId(c.req.param("artifactId"));
280
- const artifact = await getWorkspaceArtifact(db, workspaceId, artifactId);
290
+ const artifact = await getWorkspaceArtifact(db, {
291
+ accountId: grant.accountId,
292
+ workspaceId,
293
+ subjectId: await fileAuthoritySubjectIdForGrant(deps, grant),
294
+ artifactId,
295
+ });
281
296
  if (!artifact) {
282
297
  return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
283
298
  }
@@ -286,9 +301,14 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
286
301
 
287
302
  app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
288
303
  const workspaceId = c.req.param("workspaceId");
289
- await requireAccessGrant(c, deps, workspaceId, "files:read");
304
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
290
305
  const artifactId = retainedArtifactId(c.req.param("artifactId"));
291
- const artifact = await getWorkspaceArtifact(db, workspaceId, artifactId);
306
+ const artifact = await getWorkspaceArtifact(db, {
307
+ accountId: grant.accountId,
308
+ workspaceId,
309
+ subjectId: await fileAuthoritySubjectIdForGrant(deps, grant),
310
+ artifactId,
311
+ });
292
312
  if (!artifact) {
293
313
  return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
294
314
  }
@@ -378,13 +398,18 @@ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
378
398
 
379
399
  app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
380
400
  const workspaceId = c.req.param("workspaceId");
381
- await requireAccessGrant(c, deps, workspaceId, "files:read");
401
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:read");
382
402
  if (!objectStorage) {
383
403
  throw new HTTPException(503, {
384
404
  message: "object storage is not configured",
385
405
  });
386
406
  }
387
- const file = await requireFile(db, workspaceId, c.req.param("fileId")).catch(() => null);
407
+ const file = await requireFileForSubject(db, {
408
+ accountId: grant.accountId,
409
+ workspaceId,
410
+ subjectId: grant.subjectId,
411
+ fileId: c.req.param("fileId"),
412
+ }).catch(() => null);
388
413
  if (!file) {
389
414
  throw new HTTPException(404, { message: "file not found" });
390
415
  }
@@ -537,14 +562,18 @@ function retainedArtifactMetadata(artifact: RetainedFileArtifact): RetainedArtif
537
562
 
538
563
  async function getWorkspaceArtifact(
539
564
  db: ApiRouteDeps["db"],
540
- workspaceId: string,
541
- artifactId: string,
565
+ input: {
566
+ accountId: string;
567
+ workspaceId: string;
568
+ subjectId: string | null;
569
+ artifactId: string;
570
+ },
542
571
  ): Promise<{ file: RetainedFileArtifact["file"]; metadata: RetainedArtifactMetadata } | null> {
543
- const generated = await getGeneratedImageArtifact(db, workspaceId, artifactId);
572
+ const generated = await getGeneratedImageArtifact(db, input.workspaceId, input.artifactId);
544
573
  if (generated) {
545
574
  return { file: generated.file, metadata: retainedGeneratedImageMetadata(generated) };
546
575
  }
547
- const generatedVideo = await getGeneratedVideoArtifact(db, workspaceId, artifactId);
576
+ const generatedVideo = await getGeneratedVideoArtifact(db, input.workspaceId, input.artifactId);
548
577
  if (generatedVideo && !generatedVideo.artifact.deletedAt) {
549
578
  const file = generatedVideoFileAsset(generatedVideo.file);
550
579
  return {
@@ -555,8 +584,43 @@ async function getWorkspaceArtifact(
555
584
  }),
556
585
  };
557
586
  }
558
- const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
559
- return artifact ? { file: artifact.file, metadata: retainedArtifactMetadata(artifact) } : null;
587
+ const artifact = await getRetainedFileArtifact(db, input.workspaceId, input.artifactId);
588
+ if (!artifact) return null;
589
+ const [authorizedFile] = await getFilesForSubject(db, {
590
+ accountId: input.accountId,
591
+ workspaceId: input.workspaceId,
592
+ subjectId: input.subjectId,
593
+ fileIds: [artifact.file.id],
594
+ });
595
+ if (!authorizedFile) return null;
596
+ const authorizedArtifact = { ...artifact, file: authorizedFile };
597
+ return {
598
+ file: authorizedFile,
599
+ metadata: retainedArtifactMetadata(authorizedArtifact),
600
+ };
601
+ }
602
+
603
+ /** Human and stable service grants already carry their immutable subject.
604
+ * Agent tokens carry only a technical worker subject, so resolve the exact
605
+ * live attempt and use the initiating human frozen on its durable turn. */
606
+ export async function fileAuthoritySubjectIdForGrant(
607
+ deps: Pick<ApiRouteDeps, "db">,
608
+ grant: import("@opengeni/contracts").AccessGrant,
609
+ ): Promise<string | null> {
610
+ if (grant.principalKind !== "agent_attempt") return grant.subjectId;
611
+ const sessionId = grant.metadata?.["sessionId"];
612
+ if (typeof sessionId !== "string") {
613
+ throw new HTTPException(404, { message: "file not found" });
614
+ }
615
+ try {
616
+ const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, sessionId);
617
+ return actor.initiatingHumanSubjectId;
618
+ } catch (error) {
619
+ if (error instanceof SessionAuthorizationDeniedError) {
620
+ throw new HTTPException(404, { message: "file not found", cause: error });
621
+ }
622
+ throw error;
623
+ }
560
624
  }
561
625
 
562
626
  function generatedVideoFileAsset(
@@ -2257,7 +2257,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2257
2257
  const result = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2258
2258
  text: payload.text,
2259
2259
  annotations: payload.annotations,
2260
- turnInstructions: payload.turnInstructions ?? null,
2260
+ modelContext: payload.modelContext ?? null,
2261
2261
  resources: payload.resources,
2262
2262
  model: payload.model ?? null,
2263
2263
  reasoningEffort: payload.reasoningEffort ?? null,
@@ -2300,7 +2300,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
2300
2300
  const { accepted } = await acceptSessionUserMessage(deps, grant, workspaceId, sessionId, {
2301
2301
  text: event.payload.text,
2302
2302
  annotations: event.payload.annotations,
2303
- turnInstructions: event.payload.turnInstructions ?? null,
2303
+ modelContext: event.payload.modelContext ?? null,
2304
2304
  resources: event.payload.resources ?? [],
2305
2305
  model: event.payload.model ?? null,
2306
2306
  reasoningEffort: event.payload.reasoningEffort ?? null,