@opengeni/api-router 0.11.1 → 0.11.8

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.
@@ -7,8 +7,10 @@ import {
7
7
  } from "@opengeni/config";
8
8
  import {
9
9
  ClientConfig,
10
+ ErrorEnvelope,
10
11
  OPENGENI_API_CONTRACT_HEADER,
11
12
  OPENGENI_API_CONTRACT_REVISION,
13
+ OPENGENI_CORRELATION_HEADER,
12
14
  resolveWorkspaceMemoryEnabled
13
15
  } from "@opengeni/contracts";
14
16
  import {
@@ -24,7 +26,7 @@ import { bodyLimit } from "hono/body-limit";
24
26
  import { cors } from "hono/cors";
25
27
  import { HTTPException as HTTPException24 } from "hono/http-exception";
26
28
  import {
27
- hasPermission as hasPermission5,
29
+ hasPermission as hasPermission7,
28
30
  requireAccessGrant as requireAccessGrant17,
29
31
  requirePermission,
30
32
  requireSessionAuthorization as requireSessionAuthorization3,
@@ -348,6 +350,7 @@ import {
348
350
  } from "@opengeni/db";
349
351
  import { appendAndPublishEvents as appendAndPublishEvents2, publishDurableSessionEvents } from "@opengeni/events";
350
352
  import {
353
+ createSignedState,
351
354
  createGitHubAppInstallationToken,
352
355
  GitHubAppConfigurationError,
353
356
  githubAppMissingSettings
@@ -355,46 +358,162 @@ import {
355
358
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
356
359
  import * as z4 from "zod/v4";
357
360
  import {
358
- hasPermission,
361
+ hasPermission as hasPermission2,
359
362
  requireSessionAuthorization,
360
363
  requireSessionAuthorizationListScope
361
364
  } from "@opengeni/core";
362
365
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
363
366
 
364
367
  // src/github-access.ts
365
- import { listGitHubInstallationAccessForWorkspace } from "@opengeni/db";
366
- import { listGitHubAppRepositories } from "@opengeni/github";
368
+ import {
369
+ hasAuditableGitHubInstallationAuthority,
370
+ listGitHubInstallationAccessForWorkspace
371
+ } from "@opengeni/db";
372
+ import { listGitHubAppInstallationSummaries, listGitHubAppRepositories } from "@opengeni/github";
367
373
  async function listWorkspaceGitHubInstallationBindings(deps, workspaceId) {
368
374
  const installations = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
375
+ if (installations.length === 0) {
376
+ return [];
377
+ }
378
+ let liveById = /* @__PURE__ */ new Map();
379
+ let lifecycleVerified = false;
380
+ try {
381
+ if (deps.githubAppApi?.getInstallation) {
382
+ liveById = new Map(
383
+ await Promise.all(
384
+ installations.map(
385
+ async (installation) => [
386
+ installation.installationId,
387
+ await deps.githubAppApi.getInstallation({
388
+ installationId: installation.installationId
389
+ })
390
+ ]
391
+ )
392
+ )
393
+ );
394
+ lifecycleVerified = true;
395
+ } else if (!deps.githubAppApi) {
396
+ liveById = new Map(
397
+ (await listGitHubAppInstallationSummaries(deps.settings)).map((installation) => [
398
+ installation.installationId,
399
+ installation
400
+ ])
401
+ );
402
+ lifecycleVerified = true;
403
+ }
404
+ } catch {
405
+ liveById = /* @__PURE__ */ new Map();
406
+ }
369
407
  return installations.map((installation) => ({
370
408
  installationId: installation.installationId,
409
+ githubAccountId: installation.githubAccountId,
371
410
  accountLogin: installation.accountLogin,
372
411
  accountType: installation.accountType,
412
+ lifecycle: githubInstallationBindingLifecycle(
413
+ installation,
414
+ liveById,
415
+ installation.installationId,
416
+ lifecycleVerified
417
+ ),
373
418
  repositoryScope: installation.repositoryScope,
374
419
  repositoryCount: installation.repositoryIds.length,
375
420
  createdAt: installation.createdAt,
376
421
  updatedAt: installation.updatedAt
377
422
  }));
378
423
  }
424
+ function githubBindingStatus(configured, installations) {
425
+ if (!configured) {
426
+ return "disabled";
427
+ }
428
+ return installations.some((installation) => installation.lifecycle === "active") ? "bound" : "unbound";
429
+ }
430
+ function githubInstallationBindingLifecycle(stored, liveById, installationId, lifecycleVerified) {
431
+ if (!hasAuditableGitHubInstallationAuthority(stored)) {
432
+ return "unverified";
433
+ }
434
+ if (!lifecycleVerified) {
435
+ return "unverified";
436
+ }
437
+ if (!liveById.has(installationId)) {
438
+ return "deleted";
439
+ }
440
+ const live = liveById.get(installationId);
441
+ if (!live) {
442
+ return "deleted";
443
+ }
444
+ if (live.suspended) {
445
+ return "suspended";
446
+ }
447
+ if (live.installationId !== installationId || stored.githubAccountId !== live.accountId) {
448
+ return "unverified";
449
+ }
450
+ return "active";
451
+ }
379
452
  async function listWorkspaceGitHubRepositories(deps, workspaceId) {
453
+ const bindings = await listWorkspaceGitHubInstallationBindings(deps, workspaceId);
454
+ const activeInstallationIds = new Set(
455
+ bindings.filter((installation) => installation.lifecycle === "active").map((installation) => installation.installationId)
456
+ );
457
+ if (activeInstallationIds.size === 0) {
458
+ return [];
459
+ }
380
460
  const access = await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId);
381
- if (access.length === 0) {
461
+ const authorizedAccess = access.filter(
462
+ (installation) => activeInstallationIds.has(installation.installationId) && hasAuditableGitHubInstallationAuthority(installation)
463
+ );
464
+ if (authorizedAccess.length === 0) {
382
465
  return [];
383
466
  }
384
- const installationIds = access.map((installation) => installation.installationId);
467
+ const installationIds = authorizedAccess.map((installation) => installation.installationId);
385
468
  const repositories = deps.githubAppApi?.listRepositories ? await deps.githubAppApi.listRepositories({ installationIds }) : await listGitHubAppRepositories(deps.settings, { installationIds });
386
469
  const accessByInstallation = new Map(
387
- access.map((installation) => [installation.installationId, installation])
470
+ authorizedAccess.map((installation) => [installation.installationId, installation])
388
471
  );
389
472
  return repositories.filter((repository) => {
390
473
  const installation = accessByInstallation.get(repository.installationId);
391
474
  if (!installation) {
392
475
  return false;
393
476
  }
394
- return installation.repositoryScope === "all" || installation.repositoryIds.includes(repository.id);
477
+ return installation.repositoryIds.includes(repository.id);
395
478
  });
396
479
  }
397
480
 
481
+ // src/github-browser-flow.ts
482
+ import { hasPermission } from "@opengeni/core";
483
+ var githubBrowserGrantMaxAgeSeconds = 10 * 60;
484
+ function githubBrowserGrantClaims(settings, grant, nowSeconds = Math.floor(Date.now() / 1e3)) {
485
+ if (settings.productAccessMode !== "configured" || !hasPermission(grant.permissions, "github:manage")) {
486
+ return {};
487
+ }
488
+ return {
489
+ browserGrantSubjectId: grant.subjectId,
490
+ browserGrantExpiresAt: nowSeconds + githubBrowserGrantMaxAgeSeconds
491
+ };
492
+ }
493
+ function continuedGitHubBrowserGrantClaims(payload) {
494
+ return typeof payload.browserGrantSubjectId === "string" && typeof payload.browserGrantExpiresAt === "number" ? {
495
+ browserGrantSubjectId: payload.browserGrantSubjectId,
496
+ browserGrantExpiresAt: payload.browserGrantExpiresAt
497
+ } : {};
498
+ }
499
+ function githubBrowserGrantFromState(settings, payload, workspaceId, nowSeconds = Math.floor(Date.now() / 1e3)) {
500
+ const subjectId = payload.browserGrantSubjectId;
501
+ const expiresAt = payload.browserGrantExpiresAt;
502
+ if (settings.productAccessMode !== "configured" || typeof payload.accountId !== "string" || payload.workspaceId !== workspaceId || typeof subjectId !== "string" || subjectId.length === 0 || typeof expiresAt !== "number" || !Number.isInteger(expiresAt) || expiresAt < nowSeconds || expiresAt > payload.iat + githubBrowserGrantMaxAgeSeconds) {
503
+ return null;
504
+ }
505
+ return {
506
+ accountId: payload.accountId,
507
+ workspaceId,
508
+ subjectId,
509
+ permissions: ["github:manage"],
510
+ metadata: { githubBrowserHandoff: true, expiresAt }
511
+ };
512
+ }
513
+ function githubBrowserBaseUrl(settings, requestOrigin) {
514
+ return (settings.githubAppManifestBaseUrl ?? settings.publicBaseUrl ?? requestOrigin ?? "").replace(/\/+$/, "");
515
+ }
516
+
398
517
  // src/mcp/server.ts
399
518
  import {
400
519
  promoteVerifiedDefinitionEditChangeForApi,
@@ -1887,7 +2006,7 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
1887
2006
  const json = (value) => ({
1888
2007
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
1889
2008
  });
1890
- const can = (permission) => hasPermission(grant.permissions, permission);
2009
+ const can = (permission) => hasPermission2(grant.permissions, permission);
1891
2010
  const toolspaceMode = options.toolspace != null;
1892
2011
  const sessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] : null;
1893
2012
  if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
@@ -1923,7 +2042,7 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
1923
2042
  registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
1924
2043
  registerVariableSetTools(server, deps, grant, can, json);
1925
2044
  if (can("github:use")) {
1926
- registerGitHubConnectTool(server, deps, json);
2045
+ registerGitHubConnectTool(server, deps, grant, options, json);
1927
2046
  if (sessionId !== null) {
1928
2047
  registerGitHubTokenTool(server, deps, grant, sessionId, json);
1929
2048
  }
@@ -3471,11 +3590,11 @@ function registerVariableSetTools(server, deps, grant, can, json) {
3471
3590
  );
3472
3591
  }
3473
3592
  }
3474
- function registerGitHubConnectTool(server, deps, json) {
3593
+ function registerGitHubConnectTool(server, deps, grant, options, json) {
3475
3594
  server.registerTool(
3476
3595
  "github_connect_link",
3477
3596
  {
3478
- description: "Report GitHub App connection availability. New installation binding is disabled until GitHub installation authority can be proven, so installUrl and linkUrl are null.",
3597
+ description: "Report truthful GitHub App workspace binding status and, for a human grant with github:manage, return the fresh GitHub owner-consent link. Server App configuration alone is never reported as a usable binding.",
3479
3598
  inputSchema: {}
3480
3599
  },
3481
3600
  async () => {
@@ -3485,17 +3604,30 @@ function registerGitHubConnectTool(server, deps, json) {
3485
3604
  if (missing.length > 0 || !slug) {
3486
3605
  return json({
3487
3606
  configured: false,
3607
+ status: "disabled",
3488
3608
  appSlug: slug,
3489
3609
  installUrl: null,
3490
3610
  linkUrl: null,
3491
3611
  missing
3492
3612
  });
3493
3613
  }
3614
+ const installations = await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId);
3615
+ const status = githubBindingStatus(true, installations);
3616
+ const baseUrl = githubBrowserBaseUrl(settings, options.requestOrigin);
3617
+ const state = baseUrl && hasPermission2(grant.permissions, "github:manage") ? createSignedState(deps.githubStateSecret, {
3618
+ accountId: grant.accountId,
3619
+ workspaceId: grant.workspaceId,
3620
+ intent: "installation_authority",
3621
+ ...githubBrowserGrantClaims(settings, grant)
3622
+ }) : null;
3623
+ const connectUrl = state ? `${baseUrl}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}` : null;
3494
3624
  return json({
3495
3625
  configured: true,
3626
+ status,
3496
3627
  appSlug: slug,
3497
- installUrl: null,
3498
- linkUrl: null,
3628
+ installUrl: connectUrl,
3629
+ linkUrl: connectUrl,
3630
+ installations,
3499
3631
  missing: []
3500
3632
  });
3501
3633
  }
@@ -3546,7 +3678,7 @@ function registerGitHubTokenTool(server, deps, grant, sessionId, json) {
3546
3678
  );
3547
3679
  }
3548
3680
  function requireVariableSetsUseForMcpAttachment(grant, variableSetId) {
3549
- if (variableSetId !== void 0 && !hasPermission(grant.permissions, "variable-sets:use")) {
3681
+ if (variableSetId !== void 0 && !hasPermission2(grant.permissions, "variable-sets:use")) {
3550
3682
  throw new Error("missing permission: variable-sets:use");
3551
3683
  }
3552
3684
  }
@@ -3898,7 +4030,7 @@ import {
3898
4030
  prefixedMcpToolName
3899
4031
  } from "@opengeni/contracts";
3900
4032
  import {
3901
- hasPermission as hasPermission2,
4033
+ hasPermission as hasPermission3,
3902
4034
  settingsWithEnabledCapabilityMcpServers
3903
4035
  } from "@opengeni/core";
3904
4036
  import {
@@ -4002,7 +4134,7 @@ function toolspaceAuthorityForGrant(grant) {
4002
4134
  return typeof sessionId === "string" ? { sessionId } : null;
4003
4135
  }
4004
4136
  function isToolspaceGrant(settings, grant) {
4005
- return settings.toolspaceEnabled && hasPermission2(grant.permissions, "toolspace:call") && toolspaceAuthorityForGrant(grant) !== null;
4137
+ return settings.toolspaceEnabled && hasPermission3(grant.permissions, "toolspace:call") && toolspaceAuthorityForGrant(grant) !== null;
4006
4138
  }
4007
4139
  async function prepareToolspaceMcpSurface(input) {
4008
4140
  const { deps, grant } = input;
@@ -4736,6 +4868,7 @@ var ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
4736
4868
  var VERSION_SEG = /^v[A-Za-z0-9][A-Za-z0-9._-]*$/;
4737
4869
  function registerInstallRoutes(app, deps) {
4738
4870
  const releasesBase = deps.settings.agentReleasesBaseUrl.replace(/\/+$/, "");
4871
+ const stableAgentTag = `agent-v${deps.settings.agentStableVersion}`;
4739
4872
  for (const [path, { file, contentType }] of Object.entries(TEXT_ASSETS)) {
4740
4873
  app.get(path, async (c) => {
4741
4874
  const body = rewriteDefaultBaseUrl(file, await loadAsset(file), deps.settings.publicBaseUrl);
@@ -4769,7 +4902,7 @@ function registerInstallRoutes(app, deps) {
4769
4902
  if (!ASSET_NAME.test(asset)) {
4770
4903
  throw new HTTPException2(400, { message: "invalid asset name" });
4771
4904
  }
4772
- return serveAsset(asset, `${releasesBase}/download/agent-latest/${asset}`);
4905
+ return serveAsset(asset, `${releasesBase}/download/${stableAgentTag}/${asset}`);
4773
4906
  });
4774
4907
  app.get("/agent/:versionSeg/:asset", async (c) => {
4775
4908
  const versionSeg = c.req.param("versionSeg");
@@ -5102,8 +5235,8 @@ import {
5102
5235
  upsertCodexSubscriptionCredential,
5103
5236
  withCodexCapacityMutation
5104
5237
  } from "@opengeni/db";
5105
- import { createSignedState, readSignedState } from "@opengeni/github";
5106
- import { hasPermission as hasPermission3, requireAccessGrant as requireAccessGrant2 } from "@opengeni/core";
5238
+ import { createSignedState as createSignedState2, readSignedState } from "@opengeni/github";
5239
+ import { hasPermission as hasPermission4, requireAccessGrant as requireAccessGrant2 } from "@opengeni/core";
5107
5240
  import { HTTPException as HTTPException5 } from "hono/http-exception";
5108
5241
  import * as z from "zod/v4";
5109
5242
 
@@ -5489,7 +5622,7 @@ function registerCodexRoutes(app, deps) {
5489
5622
  message: error instanceof CodexDeviceError ? error.message : "failed to start Codex device login"
5490
5623
  });
5491
5624
  }
5492
- const state = createSignedState(githubStateSecret, {
5625
+ const state = createSignedState2(githubStateSecret, {
5493
5626
  workspaceId,
5494
5627
  deviceAuthId: start.deviceAuthId,
5495
5628
  userCode: start.userCode
@@ -5876,7 +6009,7 @@ function registerCodexRoutes(app, deps) {
5876
6009
  const grant = await requireAccessGrant2(c, deps, workspaceId, "workspace:read");
5877
6010
  const human = await managedCookieHuman(c, deps);
5878
6011
  const accounts = await listCodexAccountStatuses(db, workspaceId);
5879
- const ownerRecoveries = human && human.subjectId === grant.subjectId && hasPermission3(grant.permissions, "workspace:admin") ? await listCodexResetRedemptionRecoveries(db, {
6012
+ const ownerRecoveries = human && human.subjectId === grant.subjectId && hasPermission4(grant.permissions, "workspace:admin") ? await listCodexResetRedemptionRecoveries(db, {
5880
6013
  accountId: grant.accountId,
5881
6014
  workspaceId,
5882
6015
  subjectId: human.subjectId
@@ -5891,7 +6024,7 @@ function registerCodexRoutes(app, deps) {
5891
6024
  const account = queue.shift();
5892
6025
  if (!account) return;
5893
6026
  const canResumeRedemption = Boolean(
5894
- human && human.subjectId === grant.subjectId && human.subjectId === account.connectedBySubjectId && hasPermission3(grant.permissions, "workspace:admin")
6027
+ human && human.subjectId === grant.subjectId && human.subjectId === account.connectedBySubjectId && hasPermission4(grant.permissions, "workspace:admin")
5895
6028
  );
5896
6029
  const canRedeem = canResumeRedemption && account.status === "active";
5897
6030
  overview[account.id] = await fetchCodexAccountOverview(
@@ -5927,7 +6060,7 @@ function registerCodexRoutes(app, deps) {
5927
6060
  await Promise.all(
5928
6061
  accounts.filter((account) => overview[account.id] == null).map(async (account) => {
5929
6062
  const canResumeRedemption = Boolean(
5930
- human && human.subjectId === grant.subjectId && human.subjectId === account.connectedBySubjectId && hasPermission3(grant.permissions, "workspace:admin")
6063
+ human && human.subjectId === grant.subjectId && human.subjectId === account.connectedBySubjectId && hasPermission4(grant.permissions, "workspace:admin")
5931
6064
  );
5932
6065
  const fallback = await fetchCodexAccountOverview(
5933
6066
  deps,
@@ -6274,7 +6407,7 @@ import {
6274
6407
  storeIntegrationOAuthClient,
6275
6408
  updateConnection
6276
6409
  } from "@opengeni/db";
6277
- import { createSignedState as createSignedState2, readSignedState as readSignedState2 } from "@opengeni/github";
6410
+ import { createSignedState as createSignedState3, readSignedState as readSignedState2 } from "@opengeni/github";
6278
6411
  import {
6279
6412
  DestinationPolicyError,
6280
6413
  OAUTH_MAX_RESPONSE_BYTES,
@@ -6346,7 +6479,7 @@ async function startMcpOAuth(deps, context) {
6346
6479
  context.payload.oauthClient
6347
6480
  );
6348
6481
  const key = requireEnvironmentEncryption(settings);
6349
- const state = createSignedState2(requireIntegrationsStateSecret(settings), {
6482
+ const state = createSignedState3(requireIntegrationsStateSecret(settings), {
6350
6483
  accountId: context.accountId,
6351
6484
  workspaceId: context.workspaceId,
6352
6485
  subjectId: context.subjectId,
@@ -7542,6 +7675,7 @@ function encryptCredentialBundle(key, credential) {
7542
7675
  // src/routes/documents.ts
7543
7676
  import {
7544
7677
  AddDocumentRequest,
7678
+ CreateKnowledgeDropRequest,
7545
7679
  CreateKnowledgeMemoryRequest,
7546
7680
  CreateDocumentBaseRequest,
7547
7681
  Document,
@@ -7549,11 +7683,14 @@ import {
7549
7683
  DocumentSearchRequest,
7550
7684
  KnowledgeMemory,
7551
7685
  KnowledgeMemorySearchRequest,
7686
+ MoveDocumentRequest,
7552
7687
  UpdateKnowledgeMemoryRequest,
7553
7688
  WorkspaceMemorySearchRequest,
7554
7689
  WorkspaceMemorySearchResponse
7555
7690
  } from "@opengeni/contracts";
7556
7691
  import {
7692
+ completeFileUpload as completeFileUpload2,
7693
+ createFileUpload as createFileUpload2,
7557
7694
  createKnowledgeMemory as createKnowledgeMemory2,
7558
7695
  getKnowledgeMemory,
7559
7696
  listKnowledgeMemories as listKnowledgeMemories2,
@@ -7565,17 +7702,19 @@ import {
7565
7702
  addDocumentToBase,
7566
7703
  createDocumentBase,
7567
7704
  deleteDocumentFromBase,
7705
+ ensureDefaultBase,
7568
7706
  getDocument,
7569
7707
  getDocumentBase,
7570
7708
  listDocumentBases as listDocumentBases2,
7571
7709
  listDocuments,
7710
+ moveDocumentToBase,
7572
7711
  queueDocumentForReindex,
7573
7712
  searchDocuments as searchDocuments2
7574
7713
  } from "@opengeni/documents";
7575
7714
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7576
- import { HTTPException as HTTPException9 } from "hono/http-exception";
7577
- import { requireAccessGrant as requireAccessGrant4 } from "@opengeni/core";
7578
- import { recordWorkspaceUsage as recordWorkspaceUsage2, requireLimit as requireLimit2 } from "@opengeni/core";
7715
+ import { HTTPException as HTTPException10 } from "hono/http-exception";
7716
+ import { requireAccessGrant as requireAccessGrant5 } from "@opengeni/core";
7717
+ import { recordWorkspaceUsage as recordWorkspaceUsage3, requireLimit as requireLimit3 } from "@opengeni/core";
7579
7718
 
7580
7719
  // src/mcp/documents.ts
7581
7720
  import {
@@ -7618,6 +7757,10 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7618
7757
  name: "opengeni-documents",
7619
7758
  version: "1.0.0"
7620
7759
  });
7760
+ const agentAccess = {
7761
+ agentOnly: true,
7762
+ ...options.viewerSubjectId ? { viewerSubjectId: options.viewerSubjectId } : {}
7763
+ };
7621
7764
  server.registerTool(
7622
7765
  "list_document_bases",
7623
7766
  {
@@ -7634,7 +7777,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7634
7777
  description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
7635
7778
  inputSchema: SearchInputSchema
7636
7779
  },
7637
- async (input) => searchContent(db, workspaceId, documentServices, input)
7780
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess)
7638
7781
  );
7639
7782
  server.registerTool(
7640
7783
  "knowledge_search",
@@ -7642,7 +7785,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7642
7785
  description: "Search company knowledge sources with optional base, source-kind, ACL, and retrieval-mode filters.",
7643
7786
  inputSchema: SearchInputSchema
7644
7787
  },
7645
- async (input) => searchContent(db, workspaceId, documentServices, input)
7788
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess)
7646
7789
  );
7647
7790
  server.registerTool(
7648
7791
  "fetch_document_chunk",
@@ -7653,7 +7796,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7653
7796
  }
7654
7797
  },
7655
7798
  async ({ chunkId }) => {
7656
- const found = await getDocumentChunk(db, workspaceId, chunkId);
7799
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
7657
7800
  return {
7658
7801
  content: [
7659
7802
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }
@@ -7671,7 +7814,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7671
7814
  }
7672
7815
  },
7673
7816
  async ({ chunkId }) => {
7674
- const found = await getDocumentChunk(db, workspaceId, chunkId);
7817
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
7675
7818
  return {
7676
7819
  content: [
7677
7820
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }
@@ -7748,7 +7891,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7748
7891
  );
7749
7892
  return server;
7750
7893
  }
7751
- async function searchContent(db, workspaceId, documentServices, input) {
7894
+ async function searchContent(db, workspaceId, documentServices, input, access) {
7752
7895
  return {
7753
7896
  content: [
7754
7897
  {
@@ -7763,7 +7906,8 @@ async function searchContent(db, workspaceId, documentServices, input) {
7763
7906
  ...input.limit ? { limit: input.limit } : {},
7764
7907
  ...input.mode ? { mode: input.mode } : {},
7765
7908
  ...input.sourceKinds ? { sourceKinds: input.sourceKinds } : {},
7766
- ...input.aclTags ? { aclTags: input.aclTags } : {}
7909
+ ...input.aclTags ? { aclTags: input.aclTags } : {},
7910
+ access
7767
7911
  },
7768
7912
  documentServices
7769
7913
  )
@@ -7773,1773 +7917,1933 @@ async function searchContent(db, workspaceId, documentServices, input) {
7773
7917
  };
7774
7918
  }
7775
7919
 
7776
- // src/routes/documents.ts
7777
- function registerDocumentRoutes(app, deps) {
7778
- const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
7779
- app.post("/v1/workspaces/:workspaceId/document-bases", async (c) => {
7780
- const workspaceId = c.req.param("workspaceId");
7781
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7782
- const payload = CreateDocumentBaseRequest.parse(await c.req.json());
7783
- return c.json(
7784
- DocumentBase.parse(
7785
- await createDocumentBase(db, { ...payload, accountId: grant.accountId, workspaceId })
7786
- ),
7787
- 201
7788
- );
7789
- });
7790
- app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
7791
- const workspaceId = c.req.param("workspaceId");
7792
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7793
- return c.json(
7794
- (await listDocumentBases2(db, workspaceId)).map((base) => DocumentBase.parse(base))
7795
- );
7796
- });
7797
- app.get("/v1/workspaces/:workspaceId/document-bases/:baseId", async (c) => {
7798
- const workspaceId = c.req.param("workspaceId");
7799
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7800
- const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
7801
- if (!base) {
7802
- throw new HTTPException9(404, { message: "document base not found" });
7803
- }
7804
- return c.json(DocumentBase.parse(base));
7805
- });
7806
- app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
7920
+ // src/routes/files.ts
7921
+ import {
7922
+ CompleteFileUploadResponse,
7923
+ CreateFileUploadRequest,
7924
+ CreateFileUploadResponse,
7925
+ FileAsset,
7926
+ FileDownloadUrlResponse,
7927
+ RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
7928
+ RETAINED_OUTPUT_MAX_PAGE_BYTES,
7929
+ RetainedArtifactMetadataSchema,
7930
+ retainedArtifactReferenceFromFile,
7931
+ resolveRetainedOutputRange
7932
+ } from "@opengeni/contracts";
7933
+ import {
7934
+ claimFileUploadCleanup,
7935
+ completeFileUploadCleanup,
7936
+ completeFileUpload,
7937
+ createFileUpload,
7938
+ getFileUpload,
7939
+ getRetainedFileArtifact,
7940
+ requireFile as requireFile2
7941
+ } from "@opengeni/db";
7942
+ import { HTTPException as HTTPException9 } from "hono/http-exception";
7943
+ import { requireAccessGrant as requireAccessGrant4 } from "@opengeni/core";
7944
+ import { recordWorkspaceUsage as recordWorkspaceUsage2, requireLimit as requireLimit2 } from "@opengeni/core";
7945
+ function registerFileRoutes(app, deps) {
7946
+ const { db, objectStorage } = deps;
7947
+ app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {
7807
7948
  const workspaceId = c.req.param("workspaceId");
7808
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7949
+ const grant = await requireAccessGrant4(c, deps, workspaceId, "files:upload");
7809
7950
  if (!objectStorage) {
7810
7951
  throw new HTTPException9(503, { message: "object storage is not configured" });
7811
7952
  }
7953
+ const payload = CreateFileUploadRequest.parse(await c.req.json());
7812
7954
  await requireLimit2(deps, {
7813
7955
  accountId: grant.accountId,
7814
7956
  workspaceId,
7815
- action: "document:index",
7816
- quantity: 0
7957
+ action: "file:upload",
7958
+ quantity: payload.sizeBytes
7817
7959
  });
7818
- const payload = AddDocumentRequest.parse(await c.req.json());
7819
- try {
7820
- const document = await addDocumentToBase(db, {
7821
- ...payload,
7822
- accountId: grant.accountId,
7823
- workspaceId,
7824
- baseId: c.req.param("baseId")
7960
+ if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
7961
+ throw new HTTPException9(413, {
7962
+ message: `file exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`
7825
7963
  });
7826
- const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
7827
- const indexed = document.status === "ready" ? document : await documentIndexer.indexDocument({
7828
- accountId: grant.accountId,
7829
- workspaceId,
7830
- documentId: document.id
7831
- }) ?? document;
7832
- if (indexed.status === "ready") {
7833
- await recordWorkspaceUsage2(deps, {
7834
- accountId: grant.accountId,
7835
- workspaceId,
7836
- subjectId: grant.subjectId,
7837
- eventType: "document.indexed",
7838
- quantity: indexed.chunkCount,
7839
- unit: "chunk",
7840
- sourceResourceType: "document",
7841
- sourceResourceId: indexed.id,
7842
- idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
7843
- });
7844
- }
7845
- return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
7846
- } catch (error) {
7847
- throw documentHttpException(error);
7848
7964
  }
7849
- });
7850
- app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
7851
- const workspaceId = c.req.param("workspaceId");
7852
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7965
+ const fileId = crypto.randomUUID();
7966
+ const safeFilename = sanitizeFilename(payload.filename);
7967
+ const objectKey = `workspaces/${workspaceId}/files/${fileId}/original/${safeFilename}`;
7968
+ const signed = await objectStorage.createPutUrl({
7969
+ key: objectKey,
7970
+ contentType: payload.contentType,
7971
+ ...payload.sha256 ? { sha256: payload.sha256 } : {}
7972
+ });
7973
+ const upload = await createFileUpload(db, {
7974
+ accountId: grant.accountId,
7975
+ workspaceId,
7976
+ fileId,
7977
+ filename: payload.filename,
7978
+ safeFilename,
7979
+ contentType: payload.contentType,
7980
+ sizeBytes: payload.sizeBytes,
7981
+ sha256: payload.sha256 ?? null,
7982
+ bucket: objectStorage.bucket,
7983
+ objectKey,
7984
+ expiresAt: signed.expiresAt
7985
+ });
7853
7986
  return c.json(
7854
- (await listDocuments(db, workspaceId, c.req.param("baseId"))).map(
7855
- (document) => Document.parse(document)
7856
- )
7987
+ CreateFileUploadResponse.parse({
7988
+ fileId: upload.file.id,
7989
+ uploadId: upload.uploadId,
7990
+ putUrl: signed.url,
7991
+ requiredHeaders: signed.requiredHeaders,
7992
+ expiresAt: upload.expiresAt,
7993
+ maxSizeBytes: objectStorage.maxSinglePutSizeBytes
7994
+ }),
7995
+ 201
7857
7996
  );
7858
7997
  });
7859
- app.delete(
7860
- "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId",
7861
- async (c) => {
7862
- const workspaceId = c.req.param("workspaceId");
7863
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7864
- try {
7865
- await deleteDocumentFromBase(db, {
7866
- accountId: grant.accountId,
7867
- workspaceId,
7868
- baseId: c.req.param("baseId"),
7869
- documentId: c.req.param("documentId")
7870
- });
7871
- return c.body(null, 204);
7872
- } catch (error) {
7873
- throw documentHttpException(error);
7874
- }
7998
+ app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
7999
+ const workspaceId = c.req.param("workspaceId");
8000
+ const grant = await requireAccessGrant4(c, deps, workspaceId, "files:upload");
8001
+ if (!objectStorage) {
8002
+ throw new HTTPException9(503, { message: "object storage is not configured" });
7875
8003
  }
7876
- );
7877
- app.post(
7878
- "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex",
7879
- async (c) => {
7880
- const workspaceId = c.req.param("workspaceId");
7881
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7882
- if (!objectStorage) {
7883
- throw new HTTPException9(503, { message: "object storage is not configured" });
7884
- }
7885
- await requireLimit2(deps, {
8004
+ const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
8005
+ if (!upload) {
8006
+ throw new HTTPException9(404, { message: "file upload not found" });
8007
+ }
8008
+ const recordUploadedFileUsage = async (file2) => {
8009
+ await recordWorkspaceUsage2(deps, {
7886
8010
  accountId: grant.accountId,
7887
8011
  workspaceId,
7888
- action: "document:index",
7889
- quantity: 0
8012
+ subjectId: grant.subjectId,
8013
+ eventType: "file.uploaded",
8014
+ quantity: file2.sizeBytes,
8015
+ unit: "byte",
8016
+ sourceResourceType: "file",
8017
+ sourceResourceId: file2.id,
8018
+ idempotencyKey: `file.uploaded:${workspaceId}:${file2.id}`
7890
8019
  });
8020
+ };
8021
+ const completeAndRecordUsage = async () => {
8022
+ let file2;
7891
8023
  try {
7892
- const document = await getDocument(db, workspaceId, c.req.param("documentId"));
7893
- if (!document) {
7894
- throw new HTTPException9(404, { message: "document not found" });
7895
- }
7896
- if (document.status !== "failed") {
7897
- throw new HTTPException9(422, { message: "only failed documents can be retried" });
7898
- }
7899
- if (document.baseId !== c.req.param("baseId")) {
7900
- throw new HTTPException9(404, { message: "document not found" });
7901
- }
7902
- const queued = await queueDocumentForReindex(db, workspaceId, document.id);
7903
- const indexed = await documentIndexer.indexDocument({
7904
- accountId: grant.accountId,
7905
- workspaceId,
7906
- documentId: document.id
7907
- }) ?? queued;
7908
- if (indexed.status === "ready") {
7909
- await recordWorkspaceUsage2(deps, {
7910
- accountId: grant.accountId,
7911
- workspaceId,
7912
- subjectId: grant.subjectId,
7913
- eventType: "document.indexed",
7914
- quantity: indexed.chunkCount,
7915
- unit: "chunk",
7916
- sourceResourceType: "document",
7917
- sourceResourceId: indexed.id,
7918
- idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
7919
- });
7920
- }
7921
- return c.json(Document.parse(indexed));
8024
+ file2 = await completeFileUpload(db, workspaceId, upload.id);
7922
8025
  } catch (error) {
7923
- if (error instanceof HTTPException9) {
8026
+ const current = await getFileUpload(db, workspaceId, upload.id);
8027
+ if (current?.status === "completed" && current.file.status === "ready") {
8028
+ file2 = current.file;
8029
+ } else if (current && current.status !== "pending") {
8030
+ throw new HTTPException9(409, {
8031
+ message: `file upload is ${publicFileUploadStatus(current.status)}`
8032
+ });
8033
+ } else {
7924
8034
  throw error;
7925
8035
  }
7926
- throw documentHttpException(error);
7927
8036
  }
8037
+ await recordUploadedFileUsage(file2);
8038
+ return file2;
8039
+ };
8040
+ const rejectAndCleanObject = async (status, message, terminalStatus) => {
8041
+ const claim = await claimFileUploadCleanup(db, {
8042
+ workspaceId,
8043
+ uploadId: upload.id,
8044
+ fileId: upload.file.id
8045
+ });
8046
+ if (claim.outcome === "completed") {
8047
+ await recordUploadedFileUsage(claim.file);
8048
+ return claim.file;
8049
+ }
8050
+ if (claim.outcome === "unavailable") {
8051
+ throw new HTTPException9(409, {
8052
+ message: `file upload is ${publicFileUploadStatus(claim.status)}`
8053
+ });
8054
+ }
8055
+ try {
8056
+ await objectStorage.deleteObject(upload.file.objectKey);
8057
+ } catch (error) {
8058
+ deps.observability?.warn(
8059
+ "file upload rejection cleanup failed; claim remains reclaimable",
8060
+ {
8061
+ workspaceId,
8062
+ fileId: upload.file.id,
8063
+ uploadId: upload.id,
8064
+ error: error instanceof Error ? error.message : String(error)
8065
+ }
8066
+ );
8067
+ throw new HTTPException9(status, { message });
8068
+ }
8069
+ const settled = await completeFileUploadCleanup(db, {
8070
+ accountId: grant.accountId,
8071
+ workspaceId,
8072
+ uploadId: upload.id,
8073
+ fileId: upload.file.id,
8074
+ terminalStatus
8075
+ });
8076
+ if (!settled) {
8077
+ throw new HTTPException9(409, { message: "file upload cleanup claim was superseded" });
8078
+ }
8079
+ throw new HTTPException9(status, { message });
8080
+ };
8081
+ if (upload.status === "completed" && upload.file.status === "ready") {
8082
+ const file2 = await completeAndRecordUsage();
8083
+ return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
7928
8084
  }
7929
- );
7930
- app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
7931
- const workspaceId = c.req.param("workspaceId");
7932
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7933
- const payload = DocumentSearchRequest.parse(await c.req.json());
7934
- const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
7935
- if (!base) {
7936
- throw new HTTPException9(404, { message: "document base not found" });
7937
- }
7938
- return c.json({
7939
- results: await searchDocuments2(
7940
- db,
7941
- {
7942
- workspaceId,
7943
- baseIds: [base.id],
7944
- query: payload.query,
7945
- limit: payload.limit,
7946
- mode: payload.mode,
7947
- sourceKinds: payload.sourceKinds,
7948
- aclTags: payload.aclTags
7949
- },
7950
- getDocumentServices()
7951
- )
7952
- });
7953
- });
7954
- app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
7955
- const workspaceId = c.req.param("workspaceId");
7956
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7957
- const payload = DocumentSearchRequest.parse(await c.req.json());
7958
- return c.json({
7959
- results: await searchDocuments2(
7960
- db,
7961
- {
7962
- workspaceId,
7963
- query: payload.query,
7964
- baseIds: payload.baseIds,
7965
- limit: payload.limit,
7966
- mode: payload.mode,
7967
- sourceKinds: payload.sourceKinds,
7968
- aclTags: payload.aclTags
7969
- },
7970
- getDocumentServices()
7971
- )
7972
- });
7973
- });
7974
- app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
7975
- const workspaceId = c.req.param("workspaceId");
7976
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7977
- const parsed = KnowledgeMemorySearchRequest.safeParse({
7978
- query: c.req.query("query") || void 0,
7979
- status: c.req.query("status") || void 0,
7980
- kind: c.req.query("kind") || void 0,
7981
- scope: c.req.query("scope") || void 0,
7982
- limit: c.req.query("limit") ? Number(c.req.query("limit")) : void 0
8085
+ if (upload.status !== "pending") {
8086
+ throw new HTTPException9(409, {
8087
+ message: `file upload is ${publicFileUploadStatus(upload.status)}`
8088
+ });
8089
+ }
8090
+ if (upload.expiresAt.getTime() < Date.now()) {
8091
+ const file2 = await rejectAndCleanObject(409, "file upload has expired", "expired");
8092
+ return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
8093
+ }
8094
+ const head = await objectStorage.headFile(upload.file).catch((error) => {
8095
+ throw new HTTPException9(409, {
8096
+ message: `uploaded object is not available: ${error instanceof Error ? error.message : String(error)}`
8097
+ });
7983
8098
  });
7984
- if (!parsed.success) {
7985
- throw new HTTPException9(400, { message: "invalid knowledge memory query parameters" });
8099
+ if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
8100
+ const file2 = await rejectAndCleanObject(
8101
+ 422,
8102
+ "uploaded object size does not match file metadata",
8103
+ "failed"
8104
+ );
8105
+ return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
7986
8106
  }
7987
- return c.json(
7988
- (await listKnowledgeMemories2(db, workspaceId, parsed.data)).map(
7989
- (memory) => KnowledgeMemory.parse(memory)
7990
- )
7991
- );
8107
+ if (upload.file.contentType && head.ContentType && head.ContentType !== upload.file.contentType) {
8108
+ const file2 = await rejectAndCleanObject(
8109
+ 422,
8110
+ "uploaded object content type does not match file metadata",
8111
+ "failed"
8112
+ );
8113
+ return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
8114
+ }
8115
+ if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
8116
+ const file2 = await rejectAndCleanObject(
8117
+ 422,
8118
+ "uploaded object checksum metadata does not match file metadata",
8119
+ "failed"
8120
+ );
8121
+ return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
8122
+ }
8123
+ const file = await completeAndRecordUsage();
8124
+ return c.json(CompleteFileUploadResponse.parse({ file }));
7992
8125
  });
7993
- app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8126
+ app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
7994
8127
  const workspaceId = c.req.param("workspaceId");
7995
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7996
- const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
7997
- if (!memory) {
7998
- throw new HTTPException9(404, { message: "knowledge memory not found" });
8128
+ await requireAccessGrant4(c, deps, workspaceId, "files:read");
8129
+ const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
8130
+ if (!file) {
8131
+ throw new HTTPException9(404, { message: "file not found" });
7999
8132
  }
8000
- return c.json(KnowledgeMemory.parse(memory));
8133
+ return c.json(FileAsset.parse(file));
8001
8134
  });
8002
- app.post("/v1/workspaces/:workspaceId/knowledge/memories/search", async (c) => {
8135
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
8003
8136
  const workspaceId = c.req.param("workspaceId");
8004
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
8005
- const parsed = WorkspaceMemorySearchRequest.safeParse(await c.req.json());
8006
- if (!parsed.success) {
8007
- throw new HTTPException9(400, { message: "invalid workspace memory search request" });
8137
+ await requireAccessGrant4(c, deps, workspaceId, "files:read");
8138
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
8139
+ const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
8140
+ if (!artifact) {
8141
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
8008
8142
  }
8009
- const results = await searchWorkspaceMemories2(
8010
- db,
8011
- workspaceId,
8012
- parsed.data,
8013
- getDocumentServices().embedder
8014
- );
8015
- return c.json(
8016
- WorkspaceMemorySearchResponse.parse({
8017
- results: results.map((result) => ({
8018
- ...result,
8019
- memory: KnowledgeMemory.parse(result.memory)
8020
- }))
8021
- })
8022
- );
8143
+ return c.json(retainedArtifactMetadata(artifact));
8023
8144
  });
8024
- app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8145
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
8025
8146
  const workspaceId = c.req.param("workspaceId");
8026
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
8027
- const parsedBody = CreateKnowledgeMemoryRequest.safeParse(await c.req.json());
8028
- if (!parsedBody.success) {
8029
- throw new HTTPException9(400, { message: "invalid knowledge memory request" });
8147
+ await requireAccessGrant4(c, deps, workspaceId, "files:read");
8148
+ const artifactId = retainedArtifactId(c.req.param("artifactId"));
8149
+ const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
8150
+ if (!artifact) {
8151
+ return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
8030
8152
  }
8031
- const payload = parsedBody.data;
8032
- if (payload.status === "active") {
8033
- try {
8034
- const result = await saveWorkspaceMemory2(
8035
- db,
8036
- {
8037
- accountId: grant.accountId,
8038
- workspaceId,
8039
- text: payload.text,
8040
- kind: payload.kind,
8041
- confidence: payload.confidence,
8042
- pinned: payload.pinned,
8043
- replacesId: payload.replacesId ?? null,
8044
- metadata: payload.metadata,
8045
- origin: "human"
8046
- },
8047
- getDocumentServices().embedder
8048
- );
8049
- return c.json(KnowledgeMemory.parse(result.memory), 201);
8050
- } catch (error) {
8051
- throw documentHttpException(error);
8052
- }
8153
+ const metadata = retainedArtifactMetadata(artifact);
8154
+ if (!metadata.available) {
8155
+ return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
8053
8156
  }
8054
- return c.json(
8055
- KnowledgeMemory.parse(
8056
- await createKnowledgeMemory2(db, {
8057
- ...payload,
8058
- accountId: grant.accountId,
8059
- workspaceId
8060
- })
8061
- ),
8062
- 201
8157
+ if (!objectStorage) {
8158
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
8159
+ }
8160
+ const rangeHeader = c.req.header("range");
8161
+ const range = resolveRetainedOutputRange(
8162
+ rangeHeader,
8163
+ metadata.originalBytes,
8164
+ rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES
8063
8165
  );
8064
- });
8065
- app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8066
- const workspaceId = c.req.param("workspaceId");
8067
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
8068
- const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
8069
- const reviewedBy = payload.reviewedBy ?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : void 0);
8070
- try {
8166
+ if (range.kind === "invalid") {
8071
8167
  return c.json(
8072
- KnowledgeMemory.parse(
8073
- await updateKnowledgeMemory(
8074
- db,
8075
- workspaceId,
8076
- c.req.param("memoryId"),
8077
- {
8078
- ...payload,
8079
- ...reviewedBy ? { reviewedBy } : {}
8080
- },
8081
- getDocumentServices().embedder
8082
- )
8083
- )
8168
+ {
8169
+ message: "invalid retained artifact byte range",
8170
+ reason: range.reason,
8171
+ maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES
8172
+ },
8173
+ 400
8084
8174
  );
8085
- } catch (error) {
8086
- throw documentHttpException(error);
8087
8175
  }
8088
- });
8089
- app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
8090
- const workspaceId = c.req.param("workspaceId");
8091
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:search");
8092
- const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : void 0;
8093
- const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
8094
- const server = buildDocumentsMcpServer(
8095
- db,
8096
- grant.accountId,
8097
- workspaceId,
8098
- getDocumentServices(),
8099
- { createdBySessionId: sessionId }
8100
- );
8101
- await server.connect(transport);
8102
- return await transport.handleRequest(c.req.raw);
8103
- });
8104
- }
8105
- function documentHttpException(error) {
8106
- const message = error instanceof Error ? error.message : String(error);
8107
- if (message.includes("not found")) {
8108
- return new HTTPException9(404, { message });
8109
- }
8110
- if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
8111
- return new HTTPException9(422, { message });
8112
- }
8113
- if (message.includes("too long") || message.includes("visible memory is full") || message.includes("empty after sanitization") || message.includes("does not match") || message.includes("Ambiguous memory id")) {
8114
- return new HTTPException9(400, { message });
8115
- }
8116
- return new HTTPException9(500, { message });
8176
+ if (range.kind === "unsatisfiable") {
8177
+ return c.json(
8178
+ { message: "retained artifact byte range is not satisfiable", reason: range.reason },
8179
+ 416,
8180
+ {
8181
+ "Accept-Ranges": "bytes",
8182
+ "Content-Range": range.contentRange,
8183
+ "Cache-Control": "private, no-store"
8184
+ }
8185
+ );
8186
+ }
8187
+ const headers = {
8188
+ "Accept-Ranges": range.acceptRanges,
8189
+ "Cache-Control": "private, no-store",
8190
+ "Content-Length": String(range.length),
8191
+ "Content-Type": metadata.contentType,
8192
+ "X-Content-Type-Options": "nosniff",
8193
+ ...range.contentRange ? { "Content-Range": range.contentRange } : {}
8194
+ };
8195
+ if (range.kind === "empty") {
8196
+ if (!await objectStorage.fileExists(artifact.file)) {
8197
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
8198
+ }
8199
+ return c.body(null, 200, headers);
8200
+ }
8201
+ const bytes = await objectStorage.getFileRange(artifact.file, {
8202
+ start: range.start,
8203
+ end: range.end
8204
+ });
8205
+ if (!bytes) {
8206
+ return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
8207
+ }
8208
+ if (bytes.byteLength !== range.length) {
8209
+ throw new HTTPException9(502, { message: "object storage returned an invalid byte range" });
8210
+ }
8211
+ return c.body(new Uint8Array(bytes), range.status, headers);
8212
+ });
8213
+ app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
8214
+ const workspaceId = c.req.param("workspaceId");
8215
+ await requireAccessGrant4(c, deps, workspaceId, "files:read");
8216
+ if (!objectStorage) {
8217
+ throw new HTTPException9(503, { message: "object storage is not configured" });
8218
+ }
8219
+ const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
8220
+ if (!file) {
8221
+ throw new HTTPException9(404, { message: "file not found" });
8222
+ }
8223
+ if (file.status !== "ready") {
8224
+ throw new HTTPException9(409, { message: `file is ${file.status}` });
8225
+ }
8226
+ const signed = await objectStorage.createGetUrl({ key: file.objectKey });
8227
+ return c.json(
8228
+ FileDownloadUrlResponse.parse({
8229
+ url: signed.url,
8230
+ expiresAt: signed.expiresAt.toISOString()
8231
+ })
8232
+ );
8233
+ });
8117
8234
  }
8118
-
8119
- // src/routes/enrollments.ts
8120
- import {
8121
- DeviceEnrollmentApproveRequest,
8122
- DeviceEnrollmentApproveResponse,
8123
- DeviceEnrollmentDenyRequest,
8124
- DeviceEnrollmentDenyResponse,
8125
- DeviceEnrollmentLookupRequest,
8126
- DeviceEnrollmentLookupResponse,
8127
- DeviceEnrollmentPollRequest,
8128
- DeviceEnrollmentStartRequest,
8129
- EnrollmentSummary,
8130
- EnrollTokenExchangeRequest,
8131
- EnrollTokenExchangeResponse,
8132
- ListEnrollmentsResponse,
8133
- MintEnrollTokenRequest,
8134
- MintEnrollTokenResponse,
8135
- RevokeEnrollmentResponse
8136
- } from "@opengeni/contracts";
8137
- import { getWorkspace, listEnrollments, revokeEnrollment } from "@opengeni/db";
8138
- import { HTTPException as HTTPException10 } from "hono/http-exception";
8139
- import { requireAccessGrant as requireAccessGrant5 } from "@opengeni/core";
8140
-
8141
- // src/sandbox/enrollment.ts
8142
- import { randomBytes as randomBytes2 } from "crypto";
8143
- import {
8144
- resolveEnrollmentSigningSecret,
8145
- resolveRelayTokenSecret
8146
- } from "@opengeni/config";
8147
- import {
8148
- DeviceEnrollmentState,
8149
- signEnrollmentBearer,
8150
- signEnrollToken,
8151
- signRelayToken,
8152
- verifyEnrollToken
8153
- } from "@opengeni/contracts";
8154
- import {
8155
- approveDeviceEnrollmentRequest,
8156
- consumeDeviceEnrollmentRequest,
8157
- createDeviceEnrollmentRequest,
8158
- denyDeviceEnrollmentRequest,
8159
- finalizeEnrollmentByToken,
8160
- getDeviceEnrollmentRequestByDeviceCode,
8161
- getEnrollment,
8162
- getPendingDeviceEnrollmentRequestByUserCode,
8163
- getPendingDeviceEnrollmentRequestByUserCodeGlobal
8164
- } from "@opengeni/db";
8165
- import { relayDialBaseFromSettings } from "@opengeni/core";
8166
- var DEVICE_CODE_TTL_SECONDS = 600;
8167
- var DEVICE_POLL_INTERVAL_SECONDS = 5;
8168
- var ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
8169
- var RELAY_TOKEN_TTL_SECONDS = 30 * 24 * 3600;
8170
- var ENROLL_TOKEN_TTL_SECONDS = 3600;
8171
- function mintDeviceCode() {
8172
- return randomBytes2(32).toString("base64url");
8235
+ function sanitizeFilename(filename) {
8236
+ const trimmed = filename.trim().replace(/[/\\]/g, "_");
8237
+ const safe = trimmed.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim();
8238
+ return safe || "file";
8173
8239
  }
8174
- var USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
8175
- function mintUserCode() {
8176
- const bytes = randomBytes2(8);
8177
- let out = "";
8178
- for (let i = 0; i < 8; i += 1) {
8179
- out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length];
8240
+ function publicFileUploadStatus(status) {
8241
+ return status === "cleanup_pending" ? "failed" : status;
8242
+ }
8243
+ function retainedArtifactId(value) {
8244
+ const parsed = FileAsset.shape.id.safeParse(value);
8245
+ if (!parsed.success) {
8246
+ throw new HTTPException9(404, { message: "artifact not found" });
8180
8247
  }
8181
- return `${out.slice(0, 4)}-${out.slice(4, 8)}`;
8248
+ return parsed.data;
8182
8249
  }
8183
- async function startDeviceEnrollment(services, input) {
8184
- const { db } = services;
8185
- const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1e3);
8186
- let request;
8187
- let lastError;
8188
- for (let attempt = 0; attempt < 5 && !request; attempt += 1) {
8189
- const deviceCode = mintDeviceCode();
8190
- const userCode = mintUserCode();
8191
- try {
8192
- request = await createDeviceEnrollmentRequest(db, {
8193
- accountId: input.accountId,
8194
- workspaceId: input.workspaceId,
8195
- deviceCode,
8196
- userCode,
8197
- pubkey: input.publicKey,
8198
- os: input.os,
8199
- arch: input.arch,
8200
- machineName: input.machineName ?? null,
8201
- requestedExposure: "whole-machine",
8202
- canOfferDisplay: input.canOfferDisplay,
8203
- requestsScreenControl: input.requestsScreenControl,
8204
- expiresAt
8205
- });
8206
- } catch (error) {
8207
- lastError = error;
8208
- }
8250
+ function retainedArtifactUnavailable(artifactId, reason) {
8251
+ return RetainedArtifactMetadataSchema.parse({ available: false, artifactId, reason });
8252
+ }
8253
+ function retainedArtifactMetadata(artifact) {
8254
+ const reference = retainedArtifactReferenceFromFile(artifact.file);
8255
+ if (reference) return reference;
8256
+ const { file, uploadStatus, uploadExpiresAt } = artifact;
8257
+ if (file.status === "deleted") {
8258
+ return retainedArtifactUnavailable(file.id, "deleted");
8209
8259
  }
8210
- if (!request) {
8211
- throw lastError instanceof Error ? lastError : new Error("failed to start device enrollment");
8260
+ if (file.status === "expired" || uploadStatus === "expired" || uploadStatus === "pending" && uploadExpiresAt !== null && uploadExpiresAt.getTime() < Date.now()) {
8261
+ return retainedArtifactUnavailable(file.id, "expired");
8212
8262
  }
8213
- const base = input.verificationOrigin.replace(/\/$/, "");
8214
- const verificationUri = `${base}/device`;
8215
- const verificationUriComplete = `${verificationUri}?user_code=${encodeURIComponent(request.userCode)}`;
8216
- return {
8217
- deviceCode: request.deviceCode,
8218
- userCode: request.userCode,
8219
- verificationUri,
8220
- verificationUriComplete,
8221
- intervalSeconds: DEVICE_POLL_INTERVAL_SECONDS,
8222
- expiresInSeconds: DEVICE_CODE_TTL_SECONDS
8223
- };
8224
- }
8225
- async function approveDeviceEnrollment(services, input) {
8226
- const { db } = services;
8227
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8228
- db,
8229
- input.workspaceId,
8230
- input.userCode
8231
- );
8232
- if (!pending) {
8233
- return null;
8263
+ if (file.status === "failed" || uploadStatus === "failed" || uploadStatus === "cleanup_pending") {
8264
+ return retainedArtifactUnavailable(file.id, "failed");
8234
8265
  }
8235
- const sandboxName = (pending.machineName?.trim() || `${pending.os} machine`).slice(0, 256);
8236
- const result = await approveDeviceEnrollmentRequest(db, {
8237
- accountId: input.accountId,
8238
- workspaceId: input.workspaceId,
8239
- requestId: pending.id,
8240
- allowScreenControl: input.allowScreenControl,
8241
- approvedBySubjectId: input.approvedBySubjectId,
8242
- approvedBySubjectLabel: input.approvedBySubjectLabel ?? null,
8243
- sandboxName
8244
- });
8245
- if (!result.approved || !result.enrollment || !result.sandbox) {
8246
- return null;
8266
+ if (file.status === "pending_upload" || uploadStatus === "pending") {
8267
+ return retainedArtifactUnavailable(file.id, "pending");
8247
8268
  }
8248
- return {
8249
- enrollmentId: result.enrollment.id,
8250
- sandboxId: result.sandbox.id,
8251
- allowScreenControl: result.enrollment.allowScreenControl
8252
- };
8253
- }
8254
- async function lookupDeviceEnrollment(services, input) {
8255
- const { db } = services;
8256
- return await getPendingDeviceEnrollmentRequestByUserCodeGlobal(db, input.userCode);
8257
- }
8258
- function toLookupResponse(record3) {
8259
- return {
8260
- workspaceId: record3.workspaceId,
8261
- userCode: record3.userCode,
8262
- machine: {
8263
- machineName: record3.machineName,
8264
- os: record3.os,
8265
- arch: record3.arch,
8266
- canOfferDisplay: record3.canOfferDisplay,
8267
- requestsScreenControl: record3.requestsScreenControl
8268
- },
8269
- expiresAt: record3.expiresAt
8270
- };
8269
+ return retainedArtifactUnavailable(file.id, "unsupported");
8271
8270
  }
8272
- async function denyDeviceEnrollment(services, input) {
8273
- const { db } = services;
8274
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8275
- db,
8276
- input.workspaceId,
8277
- input.userCode
8278
- );
8279
- if (!pending) {
8280
- return { denied: false };
8271
+ function retainedArtifactUnavailableStatus(reason) {
8272
+ switch (reason) {
8273
+ case "deleted":
8274
+ return 404;
8275
+ case "expired":
8276
+ case "missing_storage":
8277
+ return 410;
8278
+ case "unsupported":
8279
+ case "not_retained":
8280
+ case "storage_write_failed":
8281
+ return 422;
8282
+ case "pending":
8283
+ case "failed":
8284
+ return 409;
8281
8285
  }
8282
- return await denyDeviceEnrollmentRequest(db, {
8283
- accountId: input.accountId,
8284
- workspaceId: input.workspaceId,
8285
- requestId: pending.id
8286
- });
8287
- }
8288
- async function mintEnrollToken(services, input) {
8289
- const { settings } = services;
8290
- const secret = resolveEnrollmentSigningSecret(settings);
8291
- if (!secret) {
8292
- return null;
8293
- }
8294
- const nowSeconds = Math.floor(Date.now() / 1e3);
8295
- const exp = nowSeconds + ENROLL_TOKEN_TTL_SECONDS;
8296
- const token = await signEnrollToken(secret, {
8297
- typ: "enroll",
8298
- workspaceId: input.workspaceId,
8299
- accountId: input.accountId,
8300
- allowScreenControl: input.allowScreenControl,
8301
- iat: nowSeconds,
8302
- exp
8303
- });
8304
- return {
8305
- token,
8306
- expiresAt: new Date(exp * 1e3).toISOString(),
8307
- expiresInSeconds: ENROLL_TOKEN_TTL_SECONDS
8308
- };
8309
8286
  }
8310
- async function exchangeEnrollToken(services, input) {
8311
- const { db, settings } = services;
8312
- const secret = resolveEnrollmentSigningSecret(settings);
8313
- if (!secret) {
8314
- return { ok: false, reason: "disabled" };
8315
- }
8316
- const claims = await verifyEnrollToken(secret, input.token);
8317
- if (!claims) {
8318
- return { ok: false, reason: "invalid" };
8319
- }
8320
- const sandboxName = (input.machineName?.trim() || `${input.os} machine`).slice(0, 256);
8321
- const { enrollment } = await finalizeEnrollmentByToken(db, {
8322
- accountId: claims.accountId,
8323
- workspaceId: claims.workspaceId,
8324
- pubkey: input.publicKey,
8325
- hasDisplay: input.canOfferDisplay,
8326
- // The token's allowScreenControl is the AUTHORITATIVE consent (NOT the agent's
8327
- // requestsScreenControl) — it was baked in at mint by the authorizing user.
8328
- allowScreenControl: claims.allowScreenControl,
8329
- os: input.os,
8330
- arch: input.arch,
8331
- sandboxName
8287
+
8288
+ // src/routes/documents.ts
8289
+ function registerDocumentRoutes(app, deps) {
8290
+ const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
8291
+ app.post("/v1/workspaces/:workspaceId/document-bases", async (c) => {
8292
+ const workspaceId = c.req.param("workspaceId");
8293
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8294
+ const payload = CreateDocumentBaseRequest.parse(await c.req.json());
8295
+ return c.json(
8296
+ DocumentBase.parse(
8297
+ await createDocumentBase(db, { ...payload, accountId: grant.accountId, workspaceId })
8298
+ ),
8299
+ 201
8300
+ );
8332
8301
  });
8333
- const credentials = await buildEnrollmentCredentials(services, {
8334
- secret,
8335
- workspaceId: claims.workspaceId,
8336
- agentId: enrollment.id,
8337
- consentedScreenControl: enrollment.allowScreenControl
8302
+ app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
8303
+ const workspaceId = c.req.param("workspaceId");
8304
+ await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8305
+ return c.json(
8306
+ (await listDocumentBases2(db, workspaceId)).map((base) => DocumentBase.parse(base))
8307
+ );
8338
8308
  });
8339
- return { ok: true, credentials };
8340
- }
8341
- async function pollDeviceEnrollment(services, input) {
8342
- const { db, settings } = services;
8343
- const request = await getDeviceEnrollmentRequestByDeviceCode(db, input.deviceCode);
8344
- if (!request) {
8345
- return { state: "expired" };
8346
- }
8347
- if (request.status === "denied") {
8348
- return { state: "denied" };
8349
- }
8350
- if (request.status === "pending") {
8351
- if (new Date(request.expiresAt).getTime() <= Date.now()) {
8352
- return { state: "expired" };
8309
+ app.get("/v1/workspaces/:workspaceId/document-bases/:baseId", async (c) => {
8310
+ const workspaceId = c.req.param("workspaceId");
8311
+ await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8312
+ const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
8313
+ if (!base) {
8314
+ throw new HTTPException10(404, { message: "document base not found" });
8353
8315
  }
8354
- return { state: "pending" };
8355
- }
8356
- if (!request.enrollmentId) {
8357
- return { state: "expired" };
8358
- }
8359
- const secret = resolveEnrollmentSigningSecret(settings);
8360
- if (!secret) {
8361
- return { state: "disabled" };
8362
- }
8363
- const enrollment = await getEnrollment(db, request.workspaceId, request.enrollmentId);
8364
- if (!enrollment || enrollment.status !== "active") {
8365
- return { state: "denied" };
8366
- }
8367
- const credentials = await buildEnrollmentCredentials(services, {
8368
- secret,
8369
- workspaceId: request.workspaceId,
8370
- agentId: enrollment.id,
8371
- consentedScreenControl: enrollment.allowScreenControl
8316
+ return c.json(DocumentBase.parse(base));
8372
8317
  });
8373
- if (request.status === "approved") {
8374
- await consumeDeviceEnrollmentRequest(db, {
8375
- accountId: request.accountId,
8376
- workspaceId: request.workspaceId,
8377
- requestId: request.id
8318
+ app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
8319
+ const workspaceId = c.req.param("workspaceId");
8320
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8321
+ if (!objectStorage) {
8322
+ throw new HTTPException10(503, { message: "object storage is not configured" });
8323
+ }
8324
+ await requireLimit3(deps, {
8325
+ accountId: grant.accountId,
8326
+ workspaceId,
8327
+ action: "document:index",
8328
+ quantity: 0
8378
8329
  });
8379
- }
8380
- return { state: DeviceEnrollmentState.enum.authorized, credentials };
8381
- }
8382
- async function buildEnrollmentCredentials(services, input) {
8383
- const { settings } = services;
8384
- const subjectPrefix = `agent.${input.workspaceId}.${input.agentId}`;
8385
- const nowSeconds = Math.floor(Date.now() / 1e3);
8386
- const exp = nowSeconds + ENROLLMENT_BEARER_TTL_SECONDS;
8387
- const bearer = await signEnrollmentBearer(input.secret, {
8388
- workspaceId: input.workspaceId,
8389
- agentId: input.agentId,
8390
- enrollmentId: input.agentId,
8391
- subjectPrefix,
8392
- exp
8393
- });
8394
- const natsUrls = settings.selfhostedNatsUrl ? [settings.selfhostedNatsUrl] : [];
8395
- const relayTokenSecret = resolveRelayTokenSecret(settings);
8396
- const relayToken = relayTokenSecret ? await signRelayToken(relayTokenSecret, {
8397
- workspaceId: input.workspaceId,
8398
- agentId: input.agentId,
8399
- exp: nowSeconds + RELAY_TOKEN_TTL_SECONDS
8400
- }) : "";
8401
- return {
8402
- agentId: input.agentId,
8403
- workspaceId: input.workspaceId,
8404
- bearer,
8405
- subjectPrefix,
8406
- natsUrls,
8407
- // Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
8408
- // The agent's relay producer appends only its routing query and assumes the base
8409
- // already carries the relay's `/stream` route; a path-less base 400s the dial and
8410
- // makes the terminal/desktop streams unreachable.
8411
- relayUrl: relayDialBaseFromSettings(settings),
8412
- relayToken,
8413
- // M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
8414
- // file. The agent presents the BEARER as the NATS connect auth-token; the
8415
- // server's auth-callout responder validates it and mints a workspace-scoped
8416
- // user JWT. We echo the bearer here so a consumer reading this (vestigial) field
8417
- // as the connect credential still works — the value IS the bearer.
8418
- natsAccountCreds: bearer,
8419
- updatePublicKey: settings.agentUpdatePublicKey ?? "",
8420
- consentedWholeMachine: true,
8421
- consentedScreenControl: input.consentedScreenControl
8422
- };
8423
- }
8424
-
8425
- // src/routes/enrollments.ts
8426
- function registerEnrollmentRoutes(app, deps) {
8427
- const { settings, db } = deps;
8428
- function assertSelfhostedEnabled() {
8429
- if (!settings.sandboxSelfhostedEnabled) {
8430
- throw new HTTPException10(404, {
8431
- message: "selfhosted enrollment is not enabled for this deployment"
8330
+ const payload = AddDocumentRequest.parse(await c.req.json());
8331
+ try {
8332
+ const document = await addDocumentToBase(db, {
8333
+ ...payload,
8334
+ accountId: grant.accountId,
8335
+ workspaceId,
8336
+ baseId: c.req.param("baseId"),
8337
+ createdBy: grant.subjectId,
8338
+ access: { viewerSubjectId: grant.subjectId }
8432
8339
  });
8433
- }
8434
- }
8435
- const startLimiter = new TokenBucket({ capacity: 10, refillPerSecond: 0.5 });
8436
- const pollLimiter = new TokenBucket({ capacity: 60, refillPerSecond: 2 });
8437
- const lookupLimiter = new TokenBucket({ capacity: 30, refillPerSecond: 1 });
8438
- const exchangeLimiter = new TokenBucket({ capacity: 20, refillPerSecond: 0.5 });
8439
- function rateLimit(c, limiter) {
8440
- const ip = clientIp(c);
8441
- if (!limiter.take(ip)) {
8442
- throw new HTTPException10(429, { message: "too many requests; slow down" });
8443
- }
8444
- }
8445
- app.post("/v1/enrollments/device/start", async (c) => {
8446
- assertSelfhostedEnabled();
8447
- rateLimit(c, startLimiter);
8448
- const parsed = DeviceEnrollmentStartRequest.safeParse(await c.req.json().catch(() => null));
8449
- if (!parsed.success) {
8450
- throw new HTTPException10(400, { message: "invalid device-start request" });
8451
- }
8452
- const body = parsed.data;
8453
- const workspace = await getWorkspace(db, body.workspaceId);
8454
- if (!workspace) {
8455
- throw new HTTPException10(404, { message: "workspace not found" });
8456
- }
8457
- const result = await startDeviceEnrollment(
8458
- { db, settings },
8459
- {
8460
- accountId: workspace.accountId,
8461
- workspaceId: workspace.id,
8462
- publicKey: body.publicKey,
8463
- os: body.os,
8464
- arch: body.arch,
8465
- machineName: body.machineName ?? null,
8466
- canOfferDisplay: body.canOfferDisplay,
8467
- requestsScreenControl: body.requestsScreenControl,
8468
- // The approve page is served at the SAME origin as this request.
8469
- verificationOrigin: new URL(c.req.url).origin
8340
+ const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
8341
+ const indexed = document.status === "ready" ? document : await documentIndexer.indexDocument({
8342
+ accountId: grant.accountId,
8343
+ workspaceId,
8344
+ documentId: document.id
8345
+ }) ?? document;
8346
+ if (indexed.status === "ready") {
8347
+ await recordWorkspaceUsage3(deps, {
8348
+ accountId: grant.accountId,
8349
+ workspaceId,
8350
+ subjectId: grant.subjectId,
8351
+ eventType: "document.indexed",
8352
+ quantity: indexed.chunkCount,
8353
+ unit: "chunk",
8354
+ sourceResourceType: "document",
8355
+ sourceResourceId: indexed.id,
8356
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
8357
+ });
8470
8358
  }
8471
- );
8472
- return c.json(result, 201);
8473
- });
8474
- app.post("/v1/enrollments/device/poll", async (c) => {
8475
- assertSelfhostedEnabled();
8476
- rateLimit(c, pollLimiter);
8477
- const parsed = DeviceEnrollmentPollRequest.safeParse(await c.req.json().catch(() => null));
8478
- if (!parsed.success) {
8479
- throw new HTTPException10(400, { message: "invalid device-poll request" });
8359
+ return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
8360
+ } catch (error) {
8361
+ throw documentHttpException(error);
8480
8362
  }
8481
- const result = await pollDeviceEnrollment(
8482
- { db, settings },
8483
- { deviceCode: parsed.data.deviceCode }
8363
+ });
8364
+ app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
8365
+ const workspaceId = c.req.param("workspaceId");
8366
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8367
+ return c.json(
8368
+ (await listDocuments(db, workspaceId, c.req.param("baseId"), {
8369
+ viewerSubjectId: grant.subjectId
8370
+ })).map((document) => Document.parse(document))
8484
8371
  );
8485
- return c.json(result, 200);
8486
8372
  });
8487
- app.post("/v1/enrollments/device/lookup", async (c) => {
8488
- assertSelfhostedEnabled();
8489
- rateLimit(c, lookupLimiter);
8490
- const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
8491
- if (!parsed.success) {
8492
- throw new HTTPException10(400, { message: "invalid device-lookup request" });
8493
- }
8494
- const record3 = await lookupDeviceEnrollment(
8495
- { db, settings },
8496
- { userCode: parsed.data.userCode }
8497
- );
8498
- if (!record3) {
8499
- throw new HTTPException10(404, { message: "no pending enrollment for that code" });
8500
- }
8501
- try {
8502
- await requireAccessGrant5(c, deps, record3.workspaceId, "enrollments:read");
8503
- } catch {
8504
- throw new HTTPException10(404, { message: "no pending enrollment for that code" });
8505
- }
8506
- return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record3)), 200);
8507
- });
8508
- app.post("/v1/enrollments/token/exchange", async (c) => {
8509
- assertSelfhostedEnabled();
8510
- rateLimit(c, exchangeLimiter);
8511
- const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
8512
- if (!parsed.success) {
8513
- throw new HTTPException10(400, { message: "invalid enroll-token-exchange request" });
8373
+ app.delete(
8374
+ "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId",
8375
+ async (c) => {
8376
+ const workspaceId = c.req.param("workspaceId");
8377
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8378
+ try {
8379
+ await deleteDocumentFromBase(db, {
8380
+ accountId: grant.accountId,
8381
+ workspaceId,
8382
+ baseId: c.req.param("baseId"),
8383
+ documentId: c.req.param("documentId"),
8384
+ access: { viewerSubjectId: grant.subjectId }
8385
+ });
8386
+ return c.body(null, 204);
8387
+ } catch (error) {
8388
+ if (error instanceof HTTPException10) {
8389
+ throw error;
8390
+ }
8391
+ throw documentHttpException(error);
8392
+ }
8514
8393
  }
8515
- const body = parsed.data;
8516
- const result = await exchangeEnrollToken(
8517
- { db, settings },
8518
- {
8519
- token: body.token,
8520
- publicKey: body.publicKey,
8521
- os: body.os,
8522
- arch: body.arch,
8523
- machineName: body.machineName ?? null,
8524
- canOfferDisplay: body.canOfferDisplay
8394
+ );
8395
+ app.post(
8396
+ "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex",
8397
+ async (c) => {
8398
+ const workspaceId = c.req.param("workspaceId");
8399
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8400
+ if (!objectStorage) {
8401
+ throw new HTTPException10(503, { message: "object storage is not configured" });
8525
8402
  }
8526
- );
8527
- if (!result.ok) {
8528
- if (result.reason === "disabled") {
8529
- throw new HTTPException10(503, { message: "enrollment credential plane is not configured" });
8403
+ await requireLimit3(deps, {
8404
+ accountId: grant.accountId,
8405
+ workspaceId,
8406
+ action: "document:index",
8407
+ quantity: 0
8408
+ });
8409
+ try {
8410
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
8411
+ viewerSubjectId: grant.subjectId
8412
+ });
8413
+ if (!document) {
8414
+ throw new HTTPException10(404, { message: "document not found" });
8415
+ }
8416
+ if (document.status !== "failed") {
8417
+ throw new HTTPException10(422, { message: "only failed documents can be retried" });
8418
+ }
8419
+ if (document.baseId !== c.req.param("baseId")) {
8420
+ throw new HTTPException10(404, { message: "document not found" });
8421
+ }
8422
+ const queued = await queueDocumentForReindex(db, workspaceId, document.id, {
8423
+ viewerSubjectId: grant.subjectId
8424
+ });
8425
+ const indexed = await documentIndexer.indexDocument({
8426
+ accountId: grant.accountId,
8427
+ workspaceId,
8428
+ documentId: document.id
8429
+ }) ?? queued;
8430
+ if (indexed.status === "ready") {
8431
+ await recordWorkspaceUsage3(deps, {
8432
+ accountId: grant.accountId,
8433
+ workspaceId,
8434
+ subjectId: grant.subjectId,
8435
+ eventType: "document.indexed",
8436
+ quantity: indexed.chunkCount,
8437
+ unit: "chunk",
8438
+ sourceResourceType: "document",
8439
+ sourceResourceId: indexed.id,
8440
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
8441
+ });
8442
+ }
8443
+ return c.json(Document.parse(indexed));
8444
+ } catch (error) {
8445
+ if (error instanceof HTTPException10) {
8446
+ throw error;
8447
+ }
8448
+ throw documentHttpException(error);
8530
8449
  }
8531
- throw new HTTPException10(401, { message: "invalid or expired enroll token" });
8532
8450
  }
8533
- return c.json(EnrollTokenExchangeResponse.parse({ credentials: result.credentials }), 201);
8451
+ );
8452
+ app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
8453
+ const workspaceId = c.req.param("workspaceId");
8454
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8455
+ const payload = DocumentSearchRequest.parse(await c.req.json());
8456
+ const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
8457
+ if (!base) {
8458
+ throw new HTTPException10(404, { message: "document base not found" });
8459
+ }
8460
+ return c.json({
8461
+ results: await searchDocuments2(
8462
+ db,
8463
+ {
8464
+ workspaceId,
8465
+ baseIds: [base.id],
8466
+ query: payload.query,
8467
+ limit: payload.limit,
8468
+ mode: payload.mode,
8469
+ sourceKinds: payload.sourceKinds,
8470
+ aclTags: payload.aclTags,
8471
+ access: { viewerSubjectId: grant.subjectId }
8472
+ },
8473
+ getDocumentServices()
8474
+ )
8475
+ });
8534
8476
  });
8535
- app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
8477
+ app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
8536
8478
  const workspaceId = c.req.param("workspaceId");
8537
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8538
- assertSelfhostedEnabled();
8539
- const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
8540
- if (!parsed.success) {
8541
- throw new HTTPException10(400, { message: "invalid device-approve request" });
8479
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8480
+ const payload = DocumentSearchRequest.parse(await c.req.json());
8481
+ return c.json({
8482
+ results: await searchDocuments2(
8483
+ db,
8484
+ {
8485
+ workspaceId,
8486
+ query: payload.query,
8487
+ baseIds: payload.baseIds,
8488
+ limit: payload.limit,
8489
+ mode: payload.mode,
8490
+ sourceKinds: payload.sourceKinds,
8491
+ aclTags: payload.aclTags,
8492
+ access: { viewerSubjectId: grant.subjectId }
8493
+ },
8494
+ getDocumentServices()
8495
+ )
8496
+ });
8497
+ });
8498
+ app.post("/v1/workspaces/:workspaceId/knowledge/drops", async (c) => {
8499
+ const workspaceId = c.req.param("workspaceId");
8500
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8501
+ if (!objectStorage) {
8502
+ throw new HTTPException10(503, { message: "object storage is not configured" });
8542
8503
  }
8543
- const body = parsed.data;
8544
- const approved = await approveDeviceEnrollment(
8545
- { db, settings },
8546
- {
8504
+ await requireLimit3(deps, {
8505
+ accountId: grant.accountId,
8506
+ workspaceId,
8507
+ action: "document:index",
8508
+ quantity: 0
8509
+ });
8510
+ const payload = CreateKnowledgeDropRequest.parse(await c.req.json());
8511
+ try {
8512
+ let fileId;
8513
+ if (payload.text !== void 0) {
8514
+ const bytes = new TextEncoder().encode(payload.text);
8515
+ await requireLimit3(deps, {
8516
+ accountId: grant.accountId,
8517
+ workspaceId,
8518
+ action: "file:upload",
8519
+ quantity: bytes.length
8520
+ });
8521
+ if (bytes.length > objectStorage.maxSinglePutSizeBytes) {
8522
+ throw new HTTPException10(413, {
8523
+ message: `drop exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`
8524
+ });
8525
+ }
8526
+ const filename = dropFilename(payload.filename ?? payload.title);
8527
+ const newFileId = crypto.randomUUID();
8528
+ const safeFilename = sanitizeFilename(filename);
8529
+ const objectKey = `workspaces/${workspaceId}/files/${newFileId}/original/${safeFilename}`;
8530
+ const upload = await createFileUpload2(db, {
8531
+ accountId: grant.accountId,
8532
+ workspaceId,
8533
+ fileId: newFileId,
8534
+ filename,
8535
+ safeFilename,
8536
+ contentType: "text/plain; charset=utf-8",
8537
+ sizeBytes: bytes.length,
8538
+ sha256: null,
8539
+ bucket: objectStorage.bucket,
8540
+ objectKey,
8541
+ expiresAt: new Date(Date.now() + 15 * 60 * 1e3)
8542
+ });
8543
+ await objectStorage.putObject({
8544
+ key: objectKey,
8545
+ contentType: "text/plain; charset=utf-8",
8546
+ body: bytes
8547
+ });
8548
+ const file = await completeFileUpload2(db, workspaceId, upload.uploadId);
8549
+ await recordWorkspaceUsage3(deps, {
8550
+ accountId: grant.accountId,
8551
+ workspaceId,
8552
+ subjectId: grant.subjectId,
8553
+ eventType: "file.uploaded",
8554
+ quantity: file.sizeBytes,
8555
+ unit: "byte",
8556
+ sourceResourceType: "file",
8557
+ sourceResourceId: file.id,
8558
+ idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`
8559
+ });
8560
+ fileId = file.id;
8561
+ } else {
8562
+ fileId = payload.fileId;
8563
+ }
8564
+ const defaultBase = await ensureDefaultBase(db, {
8565
+ accountId: grant.accountId,
8566
+ workspaceId
8567
+ });
8568
+ const document = await addDocumentToBase(db, {
8569
+ fileId,
8570
+ ...payload.title ? { title: payload.title } : {},
8571
+ ...payload.visibility ? { visibility: payload.visibility } : {},
8572
+ ...payload.agentAccess !== void 0 ? { agentAccess: payload.agentAccess } : {},
8547
8573
  accountId: grant.accountId,
8548
8574
  workspaceId,
8549
- userCode: body.userCode,
8550
- allowScreenControl: body.allowScreenControl,
8551
- // The LOUD consent record: WHO consented (the authenticated subject + label).
8552
- approvedBySubjectId: grant.subjectId,
8553
- approvedBySubjectLabel: grant.subjectLabel ?? null
8575
+ baseId: defaultBase.id,
8576
+ createdBy: grant.subjectId,
8577
+ curationStatus: "pending",
8578
+ access: { viewerSubjectId: grant.subjectId }
8579
+ });
8580
+ const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
8581
+ const indexed = document.status === "ready" ? document : await documentIndexer.indexDocument({
8582
+ accountId: grant.accountId,
8583
+ workspaceId,
8584
+ documentId: document.id
8585
+ }) ?? document;
8586
+ if (indexed.status === "ready") {
8587
+ await recordWorkspaceUsage3(deps, {
8588
+ accountId: grant.accountId,
8589
+ workspaceId,
8590
+ subjectId: grant.subjectId,
8591
+ eventType: "document.indexed",
8592
+ quantity: indexed.chunkCount,
8593
+ unit: "chunk",
8594
+ sourceResourceType: "document",
8595
+ sourceResourceId: indexed.id,
8596
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
8597
+ });
8554
8598
  }
8555
- );
8556
- if (!approved) {
8557
- throw new HTTPException10(404, { message: "no pending enrollment for that code" });
8599
+ return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
8600
+ } catch (error) {
8601
+ if (error instanceof HTTPException10) {
8602
+ throw error;
8603
+ }
8604
+ throw documentHttpException(error);
8558
8605
  }
8559
- return c.json(
8560
- DeviceEnrollmentApproveResponse.parse({
8561
- approved: true,
8562
- enrollmentId: approved.enrollmentId,
8563
- sandboxId: approved.sandboxId,
8564
- allowScreenControl: approved.allowScreenControl
8565
- }),
8566
- 201
8567
- );
8568
8606
  });
8569
- app.post("/v1/workspaces/:workspaceId/enrollments/device/deny", async (c) => {
8607
+ app.post("/v1/workspaces/:workspaceId/documents/:documentId/move", async (c) => {
8570
8608
  const workspaceId = c.req.param("workspaceId");
8571
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8572
- assertSelfhostedEnabled();
8573
- const parsed = DeviceEnrollmentDenyRequest.safeParse(await c.req.json().catch(() => null));
8574
- if (!parsed.success) {
8575
- throw new HTTPException10(400, { message: "invalid device-deny request" });
8576
- }
8577
- const result = await denyDeviceEnrollment(
8578
- { db, settings },
8579
- {
8580
- accountId: grant.accountId,
8581
- workspaceId,
8582
- userCode: parsed.data.userCode
8609
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8610
+ const payload = MoveDocumentRequest.parse(await c.req.json().catch(() => ({})));
8611
+ try {
8612
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
8613
+ viewerSubjectId: grant.subjectId
8614
+ });
8615
+ if (!document) {
8616
+ throw new HTTPException10(404, { message: "document not found" });
8583
8617
  }
8584
- );
8585
- return c.json(DeviceEnrollmentDenyResponse.parse({ denied: result.denied }), 200);
8618
+ return c.json(
8619
+ Document.parse(
8620
+ await moveDocumentToBase(db, {
8621
+ accountId: grant.accountId,
8622
+ workspaceId,
8623
+ documentId: document.id,
8624
+ targetBaseId: payload.targetBaseId ?? null,
8625
+ access: { viewerSubjectId: grant.subjectId }
8626
+ })
8627
+ )
8628
+ );
8629
+ } catch (error) {
8630
+ if (error instanceof HTTPException10) {
8631
+ throw error;
8632
+ }
8633
+ throw documentHttpException(error);
8634
+ }
8586
8635
  });
8587
- app.post("/v1/workspaces/:workspaceId/enrollments/token", async (c) => {
8636
+ app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8588
8637
  const workspaceId = c.req.param("workspaceId");
8589
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8590
- assertSelfhostedEnabled();
8591
- const parsed = MintEnrollTokenRequest.safeParse(await c.req.json().catch(() => ({})));
8638
+ await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8639
+ const parsed = KnowledgeMemorySearchRequest.safeParse({
8640
+ query: c.req.query("query") || void 0,
8641
+ status: c.req.query("status") || void 0,
8642
+ kind: c.req.query("kind") || void 0,
8643
+ scope: c.req.query("scope") || void 0,
8644
+ limit: c.req.query("limit") ? Number(c.req.query("limit")) : void 0
8645
+ });
8592
8646
  if (!parsed.success) {
8593
- throw new HTTPException10(400, { message: "invalid mint-enroll-token request" });
8647
+ throw new HTTPException10(400, { message: "invalid knowledge memory query parameters" });
8594
8648
  }
8595
- const minted = await mintEnrollToken(
8596
- { db, settings },
8597
- {
8598
- accountId: grant.accountId,
8599
- workspaceId,
8600
- allowScreenControl: parsed.data.allowScreenControl
8601
- }
8649
+ return c.json(
8650
+ (await listKnowledgeMemories2(db, workspaceId, parsed.data)).map(
8651
+ (memory) => KnowledgeMemory.parse(memory)
8652
+ )
8602
8653
  );
8603
- if (!minted) {
8604
- throw new HTTPException10(503, { message: "enrollment credential plane is not configured" });
8654
+ });
8655
+ app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8656
+ const workspaceId = c.req.param("workspaceId");
8657
+ await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8658
+ const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
8659
+ if (!memory) {
8660
+ throw new HTTPException10(404, { message: "knowledge memory not found" });
8605
8661
  }
8606
- return c.json(MintEnrollTokenResponse.parse(minted), 201);
8662
+ return c.json(KnowledgeMemory.parse(memory));
8607
8663
  });
8608
- app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
8664
+ app.post("/v1/workspaces/:workspaceId/knowledge/memories/search", async (c) => {
8609
8665
  const workspaceId = c.req.param("workspaceId");
8610
- await requireAccessGrant5(c, deps, workspaceId, "enrollments:read");
8611
- assertSelfhostedEnabled();
8612
- const statusFilter = c.req.query("status");
8613
- const rows = await listEnrollments(
8666
+ await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8667
+ const parsed = WorkspaceMemorySearchRequest.safeParse(await c.req.json());
8668
+ if (!parsed.success) {
8669
+ throw new HTTPException10(400, { message: "invalid workspace memory search request" });
8670
+ }
8671
+ const results = await searchWorkspaceMemories2(
8614
8672
  db,
8615
8673
  workspaceId,
8616
- statusFilter === "active" ? { status: "active" } : {}
8674
+ parsed.data,
8675
+ getDocumentServices().embedder
8617
8676
  );
8618
8677
  return c.json(
8619
- ListEnrollmentsResponse.parse({
8620
- enrollments: rows.map(
8621
- (row) => EnrollmentSummary.parse({
8622
- id: row.id,
8623
- pubkey: row.pubkey,
8624
- exposure: row.exposure,
8625
- hasDisplay: row.hasDisplay,
8626
- desktopUnavailableReason: row.desktopUnavailableReason,
8627
- allowScreenControl: row.allowScreenControl,
8628
- status: row.status,
8629
- os: row.os,
8630
- arch: row.arch,
8631
- lastSeenAt: row.lastSeenAt,
8632
- createdAt: row.createdAt,
8633
- revokedAt: row.revokedAt
8634
- })
8635
- )
8678
+ WorkspaceMemorySearchResponse.parse({
8679
+ results: results.map((result) => ({
8680
+ ...result,
8681
+ memory: KnowledgeMemory.parse(result.memory)
8682
+ }))
8636
8683
  })
8637
8684
  );
8638
8685
  });
8639
- app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
8686
+ app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8640
8687
  const workspaceId = c.req.param("workspaceId");
8641
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8642
- assertSelfhostedEnabled();
8643
- const result = await revokeEnrollment(db, {
8644
- accountId: grant.accountId,
8688
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8689
+ const parsedBody = CreateKnowledgeMemoryRequest.safeParse(await c.req.json());
8690
+ if (!parsedBody.success) {
8691
+ throw new HTTPException10(400, { message: "invalid knowledge memory request" });
8692
+ }
8693
+ const payload = parsedBody.data;
8694
+ if (payload.status === "active") {
8695
+ try {
8696
+ const result = await saveWorkspaceMemory2(
8697
+ db,
8698
+ {
8699
+ accountId: grant.accountId,
8700
+ workspaceId,
8701
+ text: payload.text,
8702
+ kind: payload.kind,
8703
+ confidence: payload.confidence,
8704
+ pinned: payload.pinned,
8705
+ replacesId: payload.replacesId ?? null,
8706
+ metadata: payload.metadata,
8707
+ origin: "human"
8708
+ },
8709
+ getDocumentServices().embedder
8710
+ );
8711
+ return c.json(KnowledgeMemory.parse(result.memory), 201);
8712
+ } catch (error) {
8713
+ throw documentHttpException(error);
8714
+ }
8715
+ }
8716
+ return c.json(
8717
+ KnowledgeMemory.parse(
8718
+ await createKnowledgeMemory2(db, {
8719
+ ...payload,
8720
+ accountId: grant.accountId,
8721
+ workspaceId
8722
+ })
8723
+ ),
8724
+ 201
8725
+ );
8726
+ });
8727
+ app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8728
+ const workspaceId = c.req.param("workspaceId");
8729
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:manage");
8730
+ const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
8731
+ const reviewedBy = payload.reviewedBy ?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : void 0);
8732
+ try {
8733
+ return c.json(
8734
+ KnowledgeMemory.parse(
8735
+ await updateKnowledgeMemory(
8736
+ db,
8737
+ workspaceId,
8738
+ c.req.param("memoryId"),
8739
+ {
8740
+ ...payload,
8741
+ ...reviewedBy ? { reviewedBy } : {}
8742
+ },
8743
+ getDocumentServices().embedder
8744
+ )
8745
+ )
8746
+ );
8747
+ } catch (error) {
8748
+ throw documentHttpException(error);
8749
+ }
8750
+ });
8751
+ app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
8752
+ const workspaceId = c.req.param("workspaceId");
8753
+ const grant = await requireAccessGrant5(c, deps, workspaceId, "documents:search");
8754
+ const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : void 0;
8755
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
8756
+ const server = buildDocumentsMcpServer(
8757
+ db,
8758
+ grant.accountId,
8645
8759
  workspaceId,
8646
- enrollmentId: c.req.param("enrollmentId")
8647
- });
8648
- return c.json(RevokeEnrollmentResponse.parse(result));
8760
+ getDocumentServices(),
8761
+ { createdBySessionId: sessionId, viewerSubjectId: grant.subjectId }
8762
+ );
8763
+ await server.connect(transport);
8764
+ return await transport.handleRequest(c.req.raw);
8649
8765
  });
8650
8766
  }
8651
- function clientIp(c) {
8652
- const xff = c.req.header("x-forwarded-for");
8653
- if (xff) {
8654
- const first = xff.split(",")[0]?.trim();
8655
- if (first) return first;
8656
- }
8657
- return c.req.header("x-real-ip")?.trim() || "unknown";
8767
+ function dropFilename(preferred) {
8768
+ const stem = (preferred ?? "").trim() || `note-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
8769
+ return /\.[A-Za-z0-9]{1,8}$/.test(stem) ? stem : `${stem}.txt`;
8658
8770
  }
8659
- var TokenBucket = class {
8660
- capacity;
8661
- refillPerSecond;
8662
- buckets = /* @__PURE__ */ new Map();
8663
- constructor(options) {
8664
- this.capacity = options.capacity;
8665
- this.refillPerSecond = options.refillPerSecond;
8771
+ function documentHttpException(error) {
8772
+ const message = error instanceof Error ? error.message : String(error);
8773
+ if (message.includes("not found")) {
8774
+ return new HTTPException10(404, { message });
8666
8775
  }
8667
- take(key, now = Date.now()) {
8668
- const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
8669
- const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1e3);
8670
- bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
8671
- bucket.updatedAt = now;
8672
- if (bucket.tokens >= this.capacity && this.buckets.size > 1e4) {
8673
- this.buckets.delete(key);
8674
- }
8675
- if (bucket.tokens < 1) {
8676
- this.buckets.set(key, bucket);
8677
- return false;
8678
- }
8679
- bucket.tokens -= 1;
8680
- this.buckets.set(key, bucket);
8681
- return true;
8776
+ if (message.includes("already exists")) {
8777
+ return new HTTPException10(409, { message });
8682
8778
  }
8683
- };
8779
+ if (message.includes("no suggested base")) {
8780
+ return new HTTPException10(422, { message });
8781
+ }
8782
+ if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
8783
+ return new HTTPException10(422, { message });
8784
+ }
8785
+ if (message.includes("too long") || message.includes("visible memory is full") || message.includes("empty after sanitization") || message.includes("does not match") || message.includes("Ambiguous memory id")) {
8786
+ return new HTTPException10(400, { message });
8787
+ }
8788
+ return new HTTPException10(500, { message });
8789
+ }
8684
8790
 
8685
- // src/routes/machines.ts
8791
+ // src/routes/enrollments.ts
8686
8792
  import {
8687
- MachineMetricsSeriesResponse,
8688
- MachinesResponse,
8689
- SwapActiveSandboxRequest,
8690
- SwapActiveSandboxResponse
8793
+ DeviceEnrollmentApproveRequest,
8794
+ DeviceEnrollmentApproveResponse,
8795
+ DeviceEnrollmentDenyRequest,
8796
+ DeviceEnrollmentDenyResponse,
8797
+ DeviceEnrollmentLookupRequest,
8798
+ DeviceEnrollmentLookupResponse,
8799
+ DeviceEnrollmentPollRequest,
8800
+ DeviceEnrollmentStartRequest,
8801
+ EnrollmentSummary,
8802
+ EnrollTokenExchangeRequest,
8803
+ EnrollTokenExchangeResponse,
8804
+ ListEnrollmentsResponse,
8805
+ MintEnrollTokenRequest,
8806
+ MintEnrollTokenResponse,
8807
+ RevokeEnrollmentResponse
8691
8808
  } from "@opengeni/contracts";
8692
- import { getEnrollment as getEnrollment2, readMachineMetricsSeries, requireSession as requireSession3 } from "@opengeni/db";
8809
+ import { getWorkspace, listEnrollments, revokeEnrollment } from "@opengeni/db";
8693
8810
  import { HTTPException as HTTPException11 } from "hono/http-exception";
8694
8811
  import { requireAccessGrant as requireAccessGrant6 } from "@opengeni/core";
8695
- import { buildFleetContextForSession as buildFleetContextForSession2, swapActiveSandbox as swapActiveSandbox2 } from "@opengeni/core";
8696
8812
 
8697
- // src/sandbox/machines.ts
8813
+ // src/sandbox/enrollment.ts
8814
+ import { randomBytes as randomBytes2 } from "crypto";
8698
8815
  import {
8699
- getSession as getSession3,
8700
- listEnrollments as listEnrollments2,
8701
- listSandboxes,
8702
- readActiveSandbox,
8703
- readLease as readLease2,
8704
- readMachineMetricsLatestForWorkspace
8705
- } from "@opengeni/db";
8706
- import { MachineView, MetricSample } from "@opengeni/contracts";
8816
+ resolveEnrollmentSigningSecret,
8817
+ resolveRelayTokenSecret
8818
+ } from "@opengeni/config";
8707
8819
  import {
8708
- NatsControlRpc as NatsControlRpc2,
8709
- selfhostedLiveness,
8710
- SelfhostedSession
8711
- } from "@opengeni/runtime/sandbox";
8712
- import { relayConfigFromSettings as relayConfigFromSettings2 } from "@opengeni/core";
8713
- var PROBE_TIMEOUT_MS = 5e3;
8714
- function controlRpc2(bus) {
8715
- return new NatsControlRpc2(async () => {
8716
- if (!bus) {
8717
- return null;
8718
- }
8719
- return bus.getRequestConnection();
8720
- });
8820
+ DeviceEnrollmentState,
8821
+ signEnrollmentBearer,
8822
+ signEnrollToken,
8823
+ signRelayToken,
8824
+ verifyEnrollToken
8825
+ } from "@opengeni/contracts";
8826
+ import {
8827
+ approveDeviceEnrollmentRequest,
8828
+ consumeDeviceEnrollmentRequest,
8829
+ createDeviceEnrollmentRequest,
8830
+ denyDeviceEnrollmentRequest,
8831
+ finalizeEnrollmentByToken,
8832
+ getDeviceEnrollmentRequestByDeviceCode,
8833
+ getEnrollment,
8834
+ getPendingDeviceEnrollmentRequestByUserCode,
8835
+ getPendingDeviceEnrollmentRequestByUserCodeGlobal
8836
+ } from "@opengeni/db";
8837
+ import { relayDialBaseFromSettings } from "@opengeni/core";
8838
+ var DEVICE_CODE_TTL_SECONDS = 600;
8839
+ var DEVICE_POLL_INTERVAL_SECONDS = 5;
8840
+ var ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
8841
+ var RELAY_TOKEN_TTL_SECONDS = 30 * 24 * 3600;
8842
+ var ENROLL_TOKEN_TTL_SECONDS = 3600;
8843
+ function mintDeviceCode() {
8844
+ return randomBytes2(32).toString("base64url");
8721
8845
  }
8722
- function metricRowToSample(row) {
8723
- return MetricSample.parse({
8724
- cpuPct: row.cpuPercent ?? 0,
8725
- load1: row.load1 ?? 0,
8726
- load5: row.load5 ?? 0,
8727
- load15: row.load15 ?? 0,
8728
- memUsedBytes: row.memUsedBytes ?? 0,
8729
- memTotalBytes: row.memTotalBytes ?? 0,
8730
- diskUsedBytes: row.diskUsedBytes ?? 0,
8731
- diskTotalBytes: row.diskTotalBytes ?? 0,
8732
- gpuUtilPct: row.gpuUtilPercent,
8733
- gpuMemBytes: row.gpuMemUsedBytes,
8734
- runQueue: row.contention ?? 0,
8735
- sampledAt: row.sampledAt
8736
- });
8846
+ var USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
8847
+ function mintUserCode() {
8848
+ const bytes = randomBytes2(8);
8849
+ let out = "";
8850
+ for (let i = 0; i < 8; i += 1) {
8851
+ out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length];
8852
+ }
8853
+ return `${out.slice(0, 4)}-${out.slice(4, 8)}`;
8737
8854
  }
8738
- async function probeEnrollment(services, workspaceId, enrollment) {
8739
- const { settings, bus } = services;
8740
- let probeResponded = false;
8741
- if (enrollment.status === "active") {
8742
- const session = new SelfhostedSession({
8743
- workspaceId,
8744
- agentId: enrollment.id,
8745
- controlRpc: controlRpc2(bus),
8746
- relay: relayConfigFromSettings2(settings),
8747
- timeoutMs: PROBE_TIMEOUT_MS
8748
- });
8855
+ async function startDeviceEnrollment(services, input) {
8856
+ const { db } = services;
8857
+ const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1e3);
8858
+ let request;
8859
+ let lastError;
8860
+ for (let attempt = 0; attempt < 5 && !request; attempt += 1) {
8861
+ const deviceCode = mintDeviceCode();
8862
+ const userCode = mintUserCode();
8749
8863
  try {
8750
- probeResponded = await session.ping();
8751
- } catch {
8752
- probeResponded = false;
8864
+ request = await createDeviceEnrollmentRequest(db, {
8865
+ accountId: input.accountId,
8866
+ workspaceId: input.workspaceId,
8867
+ deviceCode,
8868
+ userCode,
8869
+ pubkey: input.publicKey,
8870
+ os: input.os,
8871
+ arch: input.arch,
8872
+ machineName: input.machineName ?? null,
8873
+ requestedExposure: "whole-machine",
8874
+ canOfferDisplay: input.canOfferDisplay,
8875
+ requestsScreenControl: input.requestsScreenControl,
8876
+ expiresAt
8877
+ });
8878
+ } catch (error) {
8879
+ lastError = error;
8753
8880
  }
8754
8881
  }
8755
- const derived = selfhostedLiveness({
8756
- enrollment: {
8757
- status: enrollment.status,
8758
- exposure: enrollment.exposure,
8759
- allowScreenControl: enrollment.allowScreenControl,
8760
- hasDisplay: enrollment.hasDisplay,
8761
- lastSeenAt: enrollment.lastSeenAt,
8762
- wentOfflineAt: enrollment.wentOfflineAt,
8763
- wentOfflineReason: enrollment.wentOfflineReason
8764
- },
8765
- probeResponded
8766
- });
8767
- return { state: derived.state, consented: derived.consented, hasDisplay: derived.hasDisplay };
8768
- }
8769
- function machineStateFor(liveness, hasDisplay) {
8770
- if (liveness !== "online") {
8771
- return liveness;
8772
- }
8773
- if (!hasDisplay) {
8774
- return "display_unavailable";
8882
+ if (!request) {
8883
+ throw lastError instanceof Error ? lastError : new Error("failed to start device enrollment");
8775
8884
  }
8776
- return "online";
8885
+ const base = input.verificationOrigin.replace(/\/$/, "");
8886
+ const verificationUri = `${base}/device`;
8887
+ const verificationUriComplete = `${verificationUri}?user_code=${encodeURIComponent(request.userCode)}`;
8888
+ return {
8889
+ deviceCode: request.deviceCode,
8890
+ userCode: request.userCode,
8891
+ verificationUri,
8892
+ verificationUriComplete,
8893
+ intervalSeconds: DEVICE_POLL_INTERVAL_SECONDS,
8894
+ expiresInSeconds: DEVICE_CODE_TTL_SECONDS
8895
+ };
8777
8896
  }
8778
- async function listMachines(services, input) {
8897
+ async function approveDeviceEnrollment(services, input) {
8779
8898
  const { db } = services;
8780
- const { workspaceId } = input;
8781
- let activeSandboxId = null;
8782
- let activeEpoch = 0;
8783
- let session = null;
8784
- if (input.sessionId) {
8785
- session = await getSession3(db, workspaceId, input.sessionId);
8786
- if (session) {
8787
- const pointer = await readActiveSandbox(db, workspaceId, input.sessionId);
8788
- activeSandboxId = pointer?.activeSandboxId ?? null;
8789
- activeEpoch = pointer?.activeEpoch ?? 0;
8790
- }
8899
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8900
+ db,
8901
+ input.workspaceId,
8902
+ input.userCode
8903
+ );
8904
+ if (!pending) {
8905
+ return null;
8791
8906
  }
8792
- const machines = [];
8793
- if (session) {
8794
- const groupActive = activeSandboxId === null;
8795
- const groupLease = await readLease2(db, workspaceId, session.sandboxGroupId);
8796
- machines.push(
8797
- MachineView.parse({
8798
- sandboxId: session.sandboxGroupId,
8799
- enrollmentId: null,
8800
- name: "session sandbox",
8801
- kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
8802
- state: "online",
8803
- active: groupActive,
8804
- isSessionGroup: true,
8805
- workspaceGeneration: groupLease?.workspaceGeneration ?? null,
8806
- archiveGeneration: groupLease?.archiveGeneration ?? null,
8807
- archiveComplete: groupLease?.archiveComplete ?? false,
8808
- // The Modal group box is a cloud Linux box; its precise OS/arch is not
8809
- // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
8810
- os: "linux",
8811
- arch: "x86_64",
8812
- hasDisplay: false,
8813
- desktopUnavailableReason: null,
8814
- allowScreenControl: false,
8815
- sharedSessionCount: 1,
8816
- lastSeenAt: null,
8817
- metrics: null
8818
- })
8819
- );
8907
+ const sandboxName = (pending.machineName?.trim() || `${pending.os} machine`).slice(0, 256);
8908
+ const result = await approveDeviceEnrollmentRequest(db, {
8909
+ accountId: input.accountId,
8910
+ workspaceId: input.workspaceId,
8911
+ requestId: pending.id,
8912
+ allowScreenControl: input.allowScreenControl,
8913
+ approvedBySubjectId: input.approvedBySubjectId,
8914
+ approvedBySubjectLabel: input.approvedBySubjectLabel ?? null,
8915
+ sandboxName
8916
+ });
8917
+ if (!result.approved || !result.enrollment || !result.sandbox) {
8918
+ return null;
8820
8919
  }
8821
- const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
8822
- listSandboxes(db, workspaceId),
8823
- listEnrollments2(db, workspaceId),
8824
- readMachineMetricsLatestForWorkspace(db, workspaceId)
8825
- ]);
8826
- const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
8827
- const machineViews = await Promise.all(
8828
- sandboxes.map(async (sandbox) => {
8829
- if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
8830
- return null;
8831
- }
8832
- const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
8833
- if (!enrollment) {
8834
- return null;
8835
- }
8836
- const [probe, lease] = await Promise.all([
8837
- probeEnrollment(services, workspaceId, enrollment),
8838
- readLease2(db, workspaceId, sandbox.id)
8839
- ]);
8840
- const state = machineStateFor(probe.state, probe.hasDisplay);
8841
- const sharedSessionCount = lease?.refcount ?? 0;
8842
- const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
8843
- return MachineView.parse({
8844
- sandboxId: sandbox.id,
8845
- enrollmentId: enrollment.id,
8846
- name: sandbox.name,
8847
- kind: "selfhosted",
8848
- state,
8849
- active: activeSandboxId === sandbox.id,
8850
- isSessionGroup: false,
8851
- workspaceGeneration: null,
8852
- archiveGeneration: null,
8853
- archiveComplete: false,
8854
- os: enrollment.os,
8855
- arch: enrollment.arch,
8856
- hasDisplay: enrollment.hasDisplay,
8857
- desktopUnavailableReason: enrollment.desktopUnavailableReason,
8858
- allowScreenControl: enrollment.allowScreenControl,
8859
- sharedSessionCount,
8860
- lastSeenAt: enrollment.lastSeenAt,
8861
- metrics: metricsRow ? metricRowToSample(metricsRow) : null
8862
- });
8863
- })
8920
+ return {
8921
+ enrollmentId: result.enrollment.id,
8922
+ sandboxId: result.sandbox.id,
8923
+ allowScreenControl: result.enrollment.allowScreenControl
8924
+ };
8925
+ }
8926
+ async function lookupDeviceEnrollment(services, input) {
8927
+ const { db } = services;
8928
+ return await getPendingDeviceEnrollmentRequestByUserCodeGlobal(db, input.userCode);
8929
+ }
8930
+ function toLookupResponse(record3) {
8931
+ return {
8932
+ workspaceId: record3.workspaceId,
8933
+ userCode: record3.userCode,
8934
+ machine: {
8935
+ machineName: record3.machineName,
8936
+ os: record3.os,
8937
+ arch: record3.arch,
8938
+ canOfferDisplay: record3.canOfferDisplay,
8939
+ requestsScreenControl: record3.requestsScreenControl
8940
+ },
8941
+ expiresAt: record3.expiresAt
8942
+ };
8943
+ }
8944
+ async function denyDeviceEnrollment(services, input) {
8945
+ const { db } = services;
8946
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8947
+ db,
8948
+ input.workspaceId,
8949
+ input.userCode
8864
8950
  );
8865
- machines.push(...machineViews.filter((machine) => machine !== null));
8866
- return { activeSandboxId, activeEpoch, machines };
8951
+ if (!pending) {
8952
+ return { denied: false };
8953
+ }
8954
+ return await denyDeviceEnrollmentRequest(db, {
8955
+ accountId: input.accountId,
8956
+ workspaceId: input.workspaceId,
8957
+ requestId: pending.id
8958
+ });
8867
8959
  }
8868
-
8869
- // src/routes/machines.ts
8870
- var SERIES_WINDOWS_MS = {
8871
- "15m": 15 * 6e4,
8872
- "1h": 60 * 6e4,
8873
- "6h": 6 * 60 * 6e4,
8874
- "24h": 24 * 60 * 6e4
8875
- };
8876
- var DEFAULT_SERIES_WINDOW_MS = SERIES_WINDOWS_MS["1h"];
8877
- function registerMachineRoutes(app, deps) {
8878
- const { settings, db, bus } = deps;
8879
- function assertSelfhostedEnabled() {
8880
- if (!settings.sandboxSelfhostedEnabled) {
8881
- throw new HTTPException11(404, {
8882
- message: "selfhosted machines are not enabled for this deployment"
8883
- });
8884
- }
8960
+ async function mintEnrollToken(services, input) {
8961
+ const { settings } = services;
8962
+ const secret = resolveEnrollmentSigningSecret(settings);
8963
+ if (!secret) {
8964
+ return null;
8885
8965
  }
8886
- app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
8887
- const workspaceId = c.req.param("workspaceId");
8888
- await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
8889
- assertSelfhostedEnabled();
8890
- const sessionId = c.req.query("sessionId") ?? null;
8891
- const response = await listMachines({ db, settings, bus }, { workspaceId, sessionId });
8892
- return c.json(MachinesResponse.parse(response));
8966
+ const nowSeconds = Math.floor(Date.now() / 1e3);
8967
+ const exp = nowSeconds + ENROLL_TOKEN_TTL_SECONDS;
8968
+ const token = await signEnrollToken(secret, {
8969
+ typ: "enroll",
8970
+ workspaceId: input.workspaceId,
8971
+ accountId: input.accountId,
8972
+ allowScreenControl: input.allowScreenControl,
8973
+ iat: nowSeconds,
8974
+ exp
8893
8975
  });
8894
- app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
8895
- const workspaceId = c.req.param("workspaceId");
8896
- await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
8897
- assertSelfhostedEnabled();
8898
- const enrollmentId = c.req.param("enrollmentId");
8899
- const enrollment = await getEnrollment2(db, workspaceId, enrollmentId);
8900
- if (!enrollment) {
8901
- throw new HTTPException11(404, { message: "machine not found in this workspace" });
8902
- }
8903
- const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
8904
- const since = new Date(Date.now() - windowMs);
8905
- const rows = await readMachineMetricsSeries(db, { workspaceId, enrollmentId, since });
8906
- return c.json(
8907
- MachineMetricsSeriesResponse.parse({
8908
- samples: rows.map(metricRowToSample)
8909
- })
8910
- );
8976
+ return {
8977
+ token,
8978
+ expiresAt: new Date(exp * 1e3).toISOString(),
8979
+ expiresInSeconds: ENROLL_TOKEN_TTL_SECONDS
8980
+ };
8981
+ }
8982
+ async function exchangeEnrollToken(services, input) {
8983
+ const { db, settings } = services;
8984
+ const secret = resolveEnrollmentSigningSecret(settings);
8985
+ if (!secret) {
8986
+ return { ok: false, reason: "disabled" };
8987
+ }
8988
+ const claims = await verifyEnrollToken(secret, input.token);
8989
+ if (!claims) {
8990
+ return { ok: false, reason: "invalid" };
8991
+ }
8992
+ const sandboxName = (input.machineName?.trim() || `${input.os} machine`).slice(0, 256);
8993
+ const { enrollment } = await finalizeEnrollmentByToken(db, {
8994
+ accountId: claims.accountId,
8995
+ workspaceId: claims.workspaceId,
8996
+ pubkey: input.publicKey,
8997
+ hasDisplay: input.canOfferDisplay,
8998
+ // The token's allowScreenControl is the AUTHORITATIVE consent (NOT the agent's
8999
+ // requestsScreenControl) — it was baked in at mint by the authorizing user.
9000
+ allowScreenControl: claims.allowScreenControl,
9001
+ os: input.os,
9002
+ arch: input.arch,
9003
+ sandboxName
8911
9004
  });
8912
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/active-sandbox", async (c) => {
8913
- const workspaceId = c.req.param("workspaceId");
8914
- const grant = await requireAccessGrant6(c, deps, workspaceId, "sessions:control");
8915
- assertSelfhostedEnabled();
8916
- const sessionId = c.req.param("sessionId");
8917
- const body = SwapActiveSandboxRequest.parse(await c.req.json());
8918
- const ctx = await buildFleetContextForSession2(deps, {
8919
- accountId: grant.accountId,
8920
- workspaceId,
8921
- sessionId
9005
+ const credentials = await buildEnrollmentCredentials(services, {
9006
+ secret,
9007
+ workspaceId: claims.workspaceId,
9008
+ agentId: enrollment.id,
9009
+ consentedScreenControl: enrollment.allowScreenControl
9010
+ });
9011
+ return { ok: true, credentials };
9012
+ }
9013
+ async function pollDeviceEnrollment(services, input) {
9014
+ const { db, settings } = services;
9015
+ const request = await getDeviceEnrollmentRequestByDeviceCode(db, input.deviceCode);
9016
+ if (!request) {
9017
+ return { state: "expired" };
9018
+ }
9019
+ if (request.status === "denied") {
9020
+ return { state: "denied" };
9021
+ }
9022
+ if (request.status === "pending") {
9023
+ if (new Date(request.expiresAt).getTime() <= Date.now()) {
9024
+ return { state: "expired" };
9025
+ }
9026
+ return { state: "pending" };
9027
+ }
9028
+ if (!request.enrollmentId) {
9029
+ return { state: "expired" };
9030
+ }
9031
+ const secret = resolveEnrollmentSigningSecret(settings);
9032
+ if (!secret) {
9033
+ return { state: "disabled" };
9034
+ }
9035
+ const enrollment = await getEnrollment(db, request.workspaceId, request.enrollmentId);
9036
+ if (!enrollment || enrollment.status !== "active") {
9037
+ return { state: "denied" };
9038
+ }
9039
+ const credentials = await buildEnrollmentCredentials(services, {
9040
+ secret,
9041
+ workspaceId: request.workspaceId,
9042
+ agentId: enrollment.id,
9043
+ consentedScreenControl: enrollment.allowScreenControl
9044
+ });
9045
+ if (request.status === "approved") {
9046
+ await consumeDeviceEnrollmentRequest(db, {
9047
+ accountId: request.accountId,
9048
+ workspaceId: request.workspaceId,
9049
+ requestId: request.id
8922
9050
  });
8923
- const result = await swapActiveSandbox2(
8924
- {
8925
- db,
8926
- settings,
8927
- bus,
8928
- ensureSessionGroupReady: async (fleetCtx) => {
8929
- const session = await requireSession3(db, fleetCtx.workspaceId, fleetCtx.sessionId);
8930
- return await ensureSessionGroupReady(
8931
- { db, settings, bus },
8932
- {
8933
- accountId: fleetCtx.accountId,
8934
- workspaceId: fleetCtx.workspaceId,
8935
- session
8936
- }
8937
- );
8938
- }
8939
- },
8940
- ctx,
8941
- body.target
8942
- );
8943
- return c.json(SwapActiveSandboxResponse.parse(result));
9051
+ }
9052
+ return { state: DeviceEnrollmentState.enum.authorized, credentials };
9053
+ }
9054
+ async function buildEnrollmentCredentials(services, input) {
9055
+ const { settings } = services;
9056
+ const subjectPrefix = `agent.${input.workspaceId}.${input.agentId}`;
9057
+ const nowSeconds = Math.floor(Date.now() / 1e3);
9058
+ const exp = nowSeconds + ENROLLMENT_BEARER_TTL_SECONDS;
9059
+ const bearer = await signEnrollmentBearer(input.secret, {
9060
+ workspaceId: input.workspaceId,
9061
+ agentId: input.agentId,
9062
+ enrollmentId: input.agentId,
9063
+ subjectPrefix,
9064
+ exp
8944
9065
  });
9066
+ const natsUrls = settings.selfhostedNatsUrl ? [settings.selfhostedNatsUrl] : [];
9067
+ const relayTokenSecret = resolveRelayTokenSecret(settings);
9068
+ const relayToken = relayTokenSecret ? await signRelayToken(relayTokenSecret, {
9069
+ workspaceId: input.workspaceId,
9070
+ agentId: input.agentId,
9071
+ exp: nowSeconds + RELAY_TOKEN_TTL_SECONDS
9072
+ }) : "";
9073
+ return {
9074
+ agentId: input.agentId,
9075
+ workspaceId: input.workspaceId,
9076
+ bearer,
9077
+ subjectPrefix,
9078
+ natsUrls,
9079
+ // Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
9080
+ // The agent's relay producer appends only its routing query and assumes the base
9081
+ // already carries the relay's `/stream` route; a path-less base 400s the dial and
9082
+ // makes the terminal/desktop streams unreachable.
9083
+ relayUrl: relayDialBaseFromSettings(settings),
9084
+ relayToken,
9085
+ // M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
9086
+ // file. The agent presents the BEARER as the NATS connect auth-token; the
9087
+ // server's auth-callout responder validates it and mints a workspace-scoped
9088
+ // user JWT. We echo the bearer here so a consumer reading this (vestigial) field
9089
+ // as the connect credential still works — the value IS the bearer.
9090
+ natsAccountCreds: bearer,
9091
+ updatePublicKey: settings.agentUpdatePublicKey ?? "",
9092
+ consentedWholeMachine: true,
9093
+ consentedScreenControl: input.consentedScreenControl
9094
+ };
8945
9095
  }
8946
9096
 
8947
- // src/routes/environments.ts
8948
- import {
8949
- CreateVariableSetRequest,
8950
- SetVariableSetVariableRequest,
8951
- UpdateVariableSetRequest,
8952
- VariableSetVariableName as VariableSetVariableName2
8953
- } from "@opengeni/contracts";
8954
- import {
8955
- countActiveSessionsUsingVariableSet,
8956
- countScheduledTasksUsingVariableSet,
8957
- countVariableSets as countVariableSets2,
8958
- createVariableSet as createVariableSet2,
8959
- deleteVariableSet,
8960
- deleteVariableSetVariable,
8961
- encryptVariableSetValue as encryptVariableSetValue2,
8962
- getVariableSetByName as getVariableSetByName2,
8963
- listVariableSets as listVariableSets2,
8964
- setVariableSetVariable as setVariableSetVariable2,
8965
- updateVariableSet
8966
- } from "@opengeni/db";
8967
- import { HTTPException as HTTPException12 } from "hono/http-exception";
8968
- import { requireAccessGrant as requireAccessGrant7 } from "@opengeni/core";
8969
- import {
8970
- assertAllowedVariableSetVariableName as assertAllowedVariableSetVariableName2,
8971
- MAX_ENVIRONMENTS_PER_WORKSPACE as MAX_ENVIRONMENTS_PER_WORKSPACE2,
8972
- MAX_VARIABLES_PER_ENVIRONMENT as MAX_VARIABLES_PER_ENVIRONMENT2,
8973
- recordVariableSetAuditEvent as recordVariableSetAuditEvent2,
8974
- requireVariableSetEncryption as requireVariableSetEncryption2,
8975
- requireVariableSetForApi
8976
- } from "@opengeni/core";
8977
- function registerVariableSetRoutes(app, deps) {
9097
+ // src/routes/enrollments.ts
9098
+ function registerEnrollmentRoutes(app, deps) {
8978
9099
  const { settings, db } = deps;
8979
- const prefixes = [
8980
- "/v1/workspaces/:workspaceId/variable-sets",
8981
- "/v1/workspaces/:workspaceId/environments"
8982
- ];
8983
- for (const prefix of prefixes) {
8984
- app.get(`${prefix}`, async (c) => {
8985
- const workspaceId = c.req.param("workspaceId");
8986
- await requireAccessGrant7(c, deps, workspaceId, "variable-sets:use");
8987
- return c.json(await listVariableSets2(db, workspaceId));
8988
- });
8989
- app.post(`${prefix}`, async (c) => {
8990
- const workspaceId = c.req.param("workspaceId");
8991
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
8992
- const key = requireVariableSetEncryption2(settings);
8993
- const payload = CreateVariableSetRequest.parse(await c.req.json());
8994
- const name = trimmedVariableSetName(payload.name);
8995
- if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT2) {
8996
- throw new HTTPException12(422, {
8997
- message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
8998
- });
8999
- }
9000
- const variableNames = /* @__PURE__ */ new Set();
9001
- for (const variable of payload.variables) {
9002
- assertAllowedVariableSetVariableName2(variable.name);
9003
- if (variableNames.has(variable.name)) {
9004
- throw new HTTPException12(422, {
9005
- message: `duplicate variable set variable name: ${variable.name}`
9006
- });
9007
- }
9008
- variableNames.add(variable.name);
9009
- }
9010
- if (await countVariableSets2(db, workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE2) {
9011
- throw new HTTPException12(422, {
9012
- message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE2} variable sets`
9013
- });
9014
- }
9015
- if (await getVariableSetByName2(db, workspaceId, name)) {
9016
- throw new HTTPException12(409, { message: `variable set name is already in use: ${name}` });
9017
- }
9018
- const created = await createVariableSet2(db, {
9019
- accountId: grant.accountId,
9020
- workspaceId,
9021
- name,
9022
- description: payload.description ?? null,
9023
- variables: payload.variables.map((variable) => ({
9024
- name: variable.name,
9025
- valueEncrypted: encryptVariableSetValue2(key, variable.value)
9026
- }))
9027
- });
9028
- await recordVariableSetAuditEvent2(db, {
9029
- grant,
9030
- action: "variable_set.created",
9031
- variableSetId: created.id
9100
+ function assertSelfhostedEnabled() {
9101
+ if (!settings.sandboxSelfhostedEnabled) {
9102
+ throw new HTTPException11(404, {
9103
+ message: "selfhosted enrollment is not enabled for this deployment"
9032
9104
  });
9033
- return c.json(created, 201);
9034
- });
9035
- app.get(`${prefix}/:variableSetId`, async (c) => {
9036
- const workspaceId = c.req.param("workspaceId");
9037
- await requireAccessGrant7(c, deps, workspaceId, "variable-sets:use");
9038
- return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")));
9039
- });
9040
- app.patch(`${prefix}/:variableSetId`, async (c) => {
9041
- const workspaceId = c.req.param("workspaceId");
9042
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9043
- const variableSet = await requireVariableSetForApi(
9044
- db,
9045
- workspaceId,
9046
- c.req.param("variableSetId")
9047
- );
9048
- const payload = UpdateVariableSetRequest.parse(await c.req.json());
9049
- const name = payload.name !== void 0 ? trimmedVariableSetName(payload.name) : void 0;
9050
- if (name !== void 0 && name !== variableSet.name) {
9051
- const existing = await getVariableSetByName2(db, workspaceId, name);
9052
- if (existing && existing.id !== variableSet.id) {
9053
- throw new HTTPException12(409, { message: `variable set name is already in use: ${name}` });
9054
- }
9055
- }
9056
- const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
9057
- ...name !== void 0 ? { name } : {},
9058
- ...payload.description !== void 0 ? { description: payload.description } : {}
9059
- });
9060
- await recordVariableSetAuditEvent2(db, {
9061
- grant,
9062
- action: "variable_set.updated",
9063
- variableSetId: variableSet.id
9064
- });
9065
- return c.json(updated);
9066
- });
9067
- app.delete(`${prefix}/:variableSetId`, async (c) => {
9068
- const workspaceId = c.req.param("workspaceId");
9069
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9070
- const variableSet = await requireVariableSetForApi(
9071
- db,
9072
- workspaceId,
9073
- c.req.param("variableSetId")
9074
- );
9075
- const attachedTasks = await countScheduledTasksUsingVariableSet(
9076
- db,
9077
- workspaceId,
9078
- variableSet.id
9079
- );
9080
- if (attachedTasks > 0) {
9081
- throw new HTTPException12(409, {
9082
- message: `variable set is attached to ${attachedTasks} scheduled task(s); detach first`
9083
- });
9105
+ }
9106
+ }
9107
+ const startLimiter = new TokenBucket({ capacity: 10, refillPerSecond: 0.5 });
9108
+ const pollLimiter = new TokenBucket({ capacity: 60, refillPerSecond: 2 });
9109
+ const lookupLimiter = new TokenBucket({ capacity: 30, refillPerSecond: 1 });
9110
+ const exchangeLimiter = new TokenBucket({ capacity: 20, refillPerSecond: 0.5 });
9111
+ function rateLimit(c, limiter) {
9112
+ const ip = clientIp(c);
9113
+ if (!limiter.take(ip)) {
9114
+ throw new HTTPException11(429, { message: "too many requests; slow down" });
9115
+ }
9116
+ }
9117
+ app.post("/v1/enrollments/device/start", async (c) => {
9118
+ assertSelfhostedEnabled();
9119
+ rateLimit(c, startLimiter);
9120
+ const parsed = DeviceEnrollmentStartRequest.safeParse(await c.req.json().catch(() => null));
9121
+ if (!parsed.success) {
9122
+ throw new HTTPException11(400, { message: "invalid device-start request" });
9123
+ }
9124
+ const body = parsed.data;
9125
+ const workspace = await getWorkspace(db, body.workspaceId);
9126
+ if (!workspace) {
9127
+ throw new HTTPException11(404, { message: "workspace not found" });
9128
+ }
9129
+ const result = await startDeviceEnrollment(
9130
+ { db, settings },
9131
+ {
9132
+ accountId: workspace.accountId,
9133
+ workspaceId: workspace.id,
9134
+ publicKey: body.publicKey,
9135
+ os: body.os,
9136
+ arch: body.arch,
9137
+ machineName: body.machineName ?? null,
9138
+ canOfferDisplay: body.canOfferDisplay,
9139
+ requestsScreenControl: body.requestsScreenControl,
9140
+ // The approve page is served at the SAME origin as this request.
9141
+ verificationOrigin: new URL(c.req.url).origin
9084
9142
  }
9085
- const activeSessions = await countActiveSessionsUsingVariableSet(
9086
- db,
9143
+ );
9144
+ return c.json(result, 201);
9145
+ });
9146
+ app.post("/v1/enrollments/device/poll", async (c) => {
9147
+ assertSelfhostedEnabled();
9148
+ rateLimit(c, pollLimiter);
9149
+ const parsed = DeviceEnrollmentPollRequest.safeParse(await c.req.json().catch(() => null));
9150
+ if (!parsed.success) {
9151
+ throw new HTTPException11(400, { message: "invalid device-poll request" });
9152
+ }
9153
+ const result = await pollDeviceEnrollment(
9154
+ { db, settings },
9155
+ { deviceCode: parsed.data.deviceCode }
9156
+ );
9157
+ return c.json(result, 200);
9158
+ });
9159
+ app.post("/v1/enrollments/device/lookup", async (c) => {
9160
+ assertSelfhostedEnabled();
9161
+ rateLimit(c, lookupLimiter);
9162
+ const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
9163
+ if (!parsed.success) {
9164
+ throw new HTTPException11(400, { message: "invalid device-lookup request" });
9165
+ }
9166
+ const record3 = await lookupDeviceEnrollment(
9167
+ { db, settings },
9168
+ { userCode: parsed.data.userCode }
9169
+ );
9170
+ if (!record3) {
9171
+ throw new HTTPException11(404, { message: "no pending enrollment for that code" });
9172
+ }
9173
+ try {
9174
+ await requireAccessGrant6(c, deps, record3.workspaceId, "enrollments:read");
9175
+ } catch {
9176
+ throw new HTTPException11(404, { message: "no pending enrollment for that code" });
9177
+ }
9178
+ return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record3)), 200);
9179
+ });
9180
+ app.post("/v1/enrollments/token/exchange", async (c) => {
9181
+ assertSelfhostedEnabled();
9182
+ rateLimit(c, exchangeLimiter);
9183
+ const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
9184
+ if (!parsed.success) {
9185
+ throw new HTTPException11(400, { message: "invalid enroll-token-exchange request" });
9186
+ }
9187
+ const body = parsed.data;
9188
+ const result = await exchangeEnrollToken(
9189
+ { db, settings },
9190
+ {
9191
+ token: body.token,
9192
+ publicKey: body.publicKey,
9193
+ os: body.os,
9194
+ arch: body.arch,
9195
+ machineName: body.machineName ?? null,
9196
+ canOfferDisplay: body.canOfferDisplay
9197
+ }
9198
+ );
9199
+ if (!result.ok) {
9200
+ if (result.reason === "disabled") {
9201
+ throw new HTTPException11(503, { message: "enrollment credential plane is not configured" });
9202
+ }
9203
+ throw new HTTPException11(401, { message: "invalid or expired enroll token" });
9204
+ }
9205
+ return c.json(EnrollTokenExchangeResponse.parse({ credentials: result.credentials }), 201);
9206
+ });
9207
+ app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
9208
+ const workspaceId = c.req.param("workspaceId");
9209
+ const grant = await requireAccessGrant6(c, deps, workspaceId, "enrollments:manage");
9210
+ assertSelfhostedEnabled();
9211
+ const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
9212
+ if (!parsed.success) {
9213
+ throw new HTTPException11(400, { message: "invalid device-approve request" });
9214
+ }
9215
+ const body = parsed.data;
9216
+ const approved = await approveDeviceEnrollment(
9217
+ { db, settings },
9218
+ {
9219
+ accountId: grant.accountId,
9087
9220
  workspaceId,
9088
- variableSet.id
9089
- );
9090
- if (activeSessions > 0) {
9091
- throw new HTTPException12(409, {
9092
- message: `variable set is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first`
9093
- });
9221
+ userCode: body.userCode,
9222
+ allowScreenControl: body.allowScreenControl,
9223
+ // The LOUD consent record: WHO consented (the authenticated subject + label).
9224
+ approvedBySubjectId: grant.subjectId,
9225
+ approvedBySubjectLabel: grant.subjectLabel ?? null
9094
9226
  }
9095
- await deleteVariableSet(db, workspaceId, variableSet.id);
9096
- await recordVariableSetAuditEvent2(db, {
9097
- grant,
9098
- action: "variable_set.deleted",
9099
- variableSetId: variableSet.id
9100
- });
9101
- return c.json({ ok: true });
9102
- });
9103
- app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9104
- const workspaceId = c.req.param("workspaceId");
9105
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9106
- const key = requireVariableSetEncryption2(settings);
9107
- const name = parseVariableName(c.req.param("name"));
9108
- const variableSet = await requireVariableSetForApi(
9109
- db,
9227
+ );
9228
+ if (!approved) {
9229
+ throw new HTTPException11(404, { message: "no pending enrollment for that code" });
9230
+ }
9231
+ return c.json(
9232
+ DeviceEnrollmentApproveResponse.parse({
9233
+ approved: true,
9234
+ enrollmentId: approved.enrollmentId,
9235
+ sandboxId: approved.sandboxId,
9236
+ allowScreenControl: approved.allowScreenControl
9237
+ }),
9238
+ 201
9239
+ );
9240
+ });
9241
+ app.post("/v1/workspaces/:workspaceId/enrollments/device/deny", async (c) => {
9242
+ const workspaceId = c.req.param("workspaceId");
9243
+ const grant = await requireAccessGrant6(c, deps, workspaceId, "enrollments:manage");
9244
+ assertSelfhostedEnabled();
9245
+ const parsed = DeviceEnrollmentDenyRequest.safeParse(await c.req.json().catch(() => null));
9246
+ if (!parsed.success) {
9247
+ throw new HTTPException11(400, { message: "invalid device-deny request" });
9248
+ }
9249
+ const result = await denyDeviceEnrollment(
9250
+ { db, settings },
9251
+ {
9252
+ accountId: grant.accountId,
9110
9253
  workspaceId,
9111
- c.req.param("variableSetId")
9112
- );
9113
- const payload = SetVariableSetVariableRequest.parse(await c.req.json());
9114
- const exists = variableSet.variables.some((variable) => variable.name === name);
9115
- if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT2) {
9116
- throw new HTTPException12(422, {
9117
- message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
9118
- });
9254
+ userCode: parsed.data.userCode
9119
9255
  }
9120
- const metadata = await setVariableSetVariable2(db, {
9256
+ );
9257
+ return c.json(DeviceEnrollmentDenyResponse.parse({ denied: result.denied }), 200);
9258
+ });
9259
+ app.post("/v1/workspaces/:workspaceId/enrollments/token", async (c) => {
9260
+ const workspaceId = c.req.param("workspaceId");
9261
+ const grant = await requireAccessGrant6(c, deps, workspaceId, "enrollments:manage");
9262
+ assertSelfhostedEnabled();
9263
+ const parsed = MintEnrollTokenRequest.safeParse(await c.req.json().catch(() => ({})));
9264
+ if (!parsed.success) {
9265
+ throw new HTTPException11(400, { message: "invalid mint-enroll-token request" });
9266
+ }
9267
+ const minted = await mintEnrollToken(
9268
+ { db, settings },
9269
+ {
9121
9270
  accountId: grant.accountId,
9122
9271
  workspaceId,
9123
- variableSetId: variableSet.id,
9124
- name,
9125
- valueEncrypted: encryptVariableSetValue2(key, payload.value)
9126
- });
9127
- await recordVariableSetAuditEvent2(db, {
9128
- grant,
9129
- action: "variable_set.variable.set",
9130
- variableSetId: variableSet.id,
9131
- variableName: name
9132
- });
9133
- return c.json(metadata);
9272
+ allowScreenControl: parsed.data.allowScreenControl
9273
+ }
9274
+ );
9275
+ if (!minted) {
9276
+ throw new HTTPException11(503, { message: "enrollment credential plane is not configured" });
9277
+ }
9278
+ return c.json(MintEnrollTokenResponse.parse(minted), 201);
9279
+ });
9280
+ app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
9281
+ const workspaceId = c.req.param("workspaceId");
9282
+ await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
9283
+ assertSelfhostedEnabled();
9284
+ const statusFilter = c.req.query("status");
9285
+ const rows = await listEnrollments(
9286
+ db,
9287
+ workspaceId,
9288
+ statusFilter === "active" ? { status: "active" } : {}
9289
+ );
9290
+ return c.json(
9291
+ ListEnrollmentsResponse.parse({
9292
+ enrollments: rows.map(
9293
+ (row) => EnrollmentSummary.parse({
9294
+ id: row.id,
9295
+ pubkey: row.pubkey,
9296
+ exposure: row.exposure,
9297
+ hasDisplay: row.hasDisplay,
9298
+ desktopUnavailableReason: row.desktopUnavailableReason,
9299
+ allowScreenControl: row.allowScreenControl,
9300
+ status: row.status,
9301
+ os: row.os,
9302
+ arch: row.arch,
9303
+ lastSeenAt: row.lastSeenAt,
9304
+ createdAt: row.createdAt,
9305
+ revokedAt: row.revokedAt
9306
+ })
9307
+ )
9308
+ })
9309
+ );
9310
+ });
9311
+ app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
9312
+ const workspaceId = c.req.param("workspaceId");
9313
+ const grant = await requireAccessGrant6(c, deps, workspaceId, "enrollments:manage");
9314
+ assertSelfhostedEnabled();
9315
+ const result = await revokeEnrollment(db, {
9316
+ accountId: grant.accountId,
9317
+ workspaceId,
9318
+ enrollmentId: c.req.param("enrollmentId")
9134
9319
  });
9135
- app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9136
- const workspaceId = c.req.param("workspaceId");
9137
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9138
- const name = parseVariableName(c.req.param("name"));
9139
- const variableSet = await requireVariableSetForApi(
9140
- db,
9141
- workspaceId,
9142
- c.req.param("variableSetId")
9143
- );
9144
- const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
9145
- if (!deleted) {
9146
- throw new HTTPException12(404, { message: "variable set variable not found" });
9320
+ return c.json(RevokeEnrollmentResponse.parse(result));
9321
+ });
9322
+ }
9323
+ function clientIp(c) {
9324
+ const xff = c.req.header("x-forwarded-for");
9325
+ if (xff) {
9326
+ const first = xff.split(",")[0]?.trim();
9327
+ if (first) return first;
9328
+ }
9329
+ return c.req.header("x-real-ip")?.trim() || "unknown";
9330
+ }
9331
+ var TokenBucket = class {
9332
+ capacity;
9333
+ refillPerSecond;
9334
+ buckets = /* @__PURE__ */ new Map();
9335
+ constructor(options) {
9336
+ this.capacity = options.capacity;
9337
+ this.refillPerSecond = options.refillPerSecond;
9338
+ }
9339
+ take(key, now = Date.now()) {
9340
+ const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
9341
+ const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1e3);
9342
+ bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
9343
+ bucket.updatedAt = now;
9344
+ if (bucket.tokens >= this.capacity && this.buckets.size > 1e4) {
9345
+ this.buckets.delete(key);
9346
+ }
9347
+ if (bucket.tokens < 1) {
9348
+ this.buckets.set(key, bucket);
9349
+ return false;
9350
+ }
9351
+ bucket.tokens -= 1;
9352
+ this.buckets.set(key, bucket);
9353
+ return true;
9354
+ }
9355
+ };
9356
+
9357
+ // src/routes/machines.ts
9358
+ import {
9359
+ MachineMetricsSeriesResponse,
9360
+ MachinesResponse,
9361
+ SwapActiveSandboxRequest,
9362
+ SwapActiveSandboxResponse
9363
+ } from "@opengeni/contracts";
9364
+ import { getEnrollment as getEnrollment2, readMachineMetricsSeries, requireSession as requireSession3 } from "@opengeni/db";
9365
+ import { HTTPException as HTTPException12 } from "hono/http-exception";
9366
+ import { requireAccessGrant as requireAccessGrant7 } from "@opengeni/core";
9367
+ import { buildFleetContextForSession as buildFleetContextForSession2, swapActiveSandbox as swapActiveSandbox2 } from "@opengeni/core";
9368
+
9369
+ // src/sandbox/machines.ts
9370
+ import {
9371
+ getSession as getSession3,
9372
+ listEnrollments as listEnrollments2,
9373
+ listSandboxes,
9374
+ readActiveSandbox,
9375
+ readLease as readLease2,
9376
+ readMachineMetricsLatestForWorkspace
9377
+ } from "@opengeni/db";
9378
+ import { MachineView, MetricSample } from "@opengeni/contracts";
9379
+ import {
9380
+ NatsControlRpc as NatsControlRpc2,
9381
+ selfhostedLiveness,
9382
+ SelfhostedSession
9383
+ } from "@opengeni/runtime/sandbox";
9384
+ import { relayConfigFromSettings as relayConfigFromSettings2 } from "@opengeni/core";
9385
+ var PROBE_TIMEOUT_MS = 5e3;
9386
+ function controlRpc2(bus) {
9387
+ return new NatsControlRpc2(async () => {
9388
+ if (!bus) {
9389
+ return null;
9390
+ }
9391
+ return bus.getRequestConnection();
9392
+ });
9393
+ }
9394
+ function metricRowToSample(row) {
9395
+ return MetricSample.parse({
9396
+ cpuPct: row.cpuPercent ?? 0,
9397
+ load1: row.load1 ?? 0,
9398
+ load5: row.load5 ?? 0,
9399
+ load15: row.load15 ?? 0,
9400
+ memUsedBytes: row.memUsedBytes ?? 0,
9401
+ memTotalBytes: row.memTotalBytes ?? 0,
9402
+ diskUsedBytes: row.diskUsedBytes ?? 0,
9403
+ diskTotalBytes: row.diskTotalBytes ?? 0,
9404
+ gpuUtilPct: row.gpuUtilPercent,
9405
+ gpuMemBytes: row.gpuMemUsedBytes,
9406
+ runQueue: row.contention ?? 0,
9407
+ sampledAt: row.sampledAt
9408
+ });
9409
+ }
9410
+ async function probeEnrollment(services, workspaceId, enrollment) {
9411
+ const { settings, bus } = services;
9412
+ let probeResponded = false;
9413
+ if (enrollment.status === "active") {
9414
+ const session = new SelfhostedSession({
9415
+ workspaceId,
9416
+ agentId: enrollment.id,
9417
+ controlRpc: controlRpc2(bus),
9418
+ relay: relayConfigFromSettings2(settings),
9419
+ timeoutMs: PROBE_TIMEOUT_MS
9420
+ });
9421
+ try {
9422
+ probeResponded = await session.ping();
9423
+ } catch {
9424
+ probeResponded = false;
9425
+ }
9426
+ }
9427
+ const derived = selfhostedLiveness({
9428
+ enrollment: {
9429
+ status: enrollment.status,
9430
+ exposure: enrollment.exposure,
9431
+ allowScreenControl: enrollment.allowScreenControl,
9432
+ hasDisplay: enrollment.hasDisplay,
9433
+ lastSeenAt: enrollment.lastSeenAt,
9434
+ wentOfflineAt: enrollment.wentOfflineAt,
9435
+ wentOfflineReason: enrollment.wentOfflineReason
9436
+ },
9437
+ probeResponded
9438
+ });
9439
+ return { state: derived.state, consented: derived.consented, hasDisplay: derived.hasDisplay };
9440
+ }
9441
+ function machineStateFor(liveness, hasDisplay) {
9442
+ if (liveness !== "online") {
9443
+ return liveness;
9444
+ }
9445
+ if (!hasDisplay) {
9446
+ return "display_unavailable";
9447
+ }
9448
+ return "online";
9449
+ }
9450
+ async function listMachines(services, input) {
9451
+ const { db } = services;
9452
+ const { workspaceId } = input;
9453
+ let activeSandboxId = null;
9454
+ let activeEpoch = 0;
9455
+ let session = null;
9456
+ if (input.sessionId) {
9457
+ session = await getSession3(db, workspaceId, input.sessionId);
9458
+ if (session) {
9459
+ const pointer = await readActiveSandbox(db, workspaceId, input.sessionId);
9460
+ activeSandboxId = pointer?.activeSandboxId ?? null;
9461
+ activeEpoch = pointer?.activeEpoch ?? 0;
9462
+ }
9463
+ }
9464
+ const machines = [];
9465
+ if (session) {
9466
+ const groupActive = activeSandboxId === null;
9467
+ const groupLease = await readLease2(db, workspaceId, session.sandboxGroupId);
9468
+ machines.push(
9469
+ MachineView.parse({
9470
+ sandboxId: session.sandboxGroupId,
9471
+ enrollmentId: null,
9472
+ name: "session sandbox",
9473
+ kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
9474
+ state: "online",
9475
+ active: groupActive,
9476
+ isSessionGroup: true,
9477
+ workspaceGeneration: groupLease?.workspaceGeneration ?? null,
9478
+ archiveGeneration: groupLease?.archiveGeneration ?? null,
9479
+ archiveComplete: groupLease?.archiveComplete ?? false,
9480
+ // The Modal group box is a cloud Linux box; its precise OS/arch is not
9481
+ // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
9482
+ os: "linux",
9483
+ arch: "x86_64",
9484
+ hasDisplay: false,
9485
+ desktopUnavailableReason: null,
9486
+ allowScreenControl: false,
9487
+ sharedSessionCount: 1,
9488
+ lastSeenAt: null,
9489
+ metrics: null
9490
+ })
9491
+ );
9492
+ }
9493
+ const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
9494
+ listSandboxes(db, workspaceId),
9495
+ listEnrollments2(db, workspaceId),
9496
+ readMachineMetricsLatestForWorkspace(db, workspaceId)
9497
+ ]);
9498
+ const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
9499
+ const machineViews = await Promise.all(
9500
+ sandboxes.map(async (sandbox) => {
9501
+ if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
9502
+ return null;
9147
9503
  }
9148
- await recordVariableSetAuditEvent2(db, {
9149
- grant,
9150
- action: "variable_set.variable.deleted",
9151
- variableSetId: variableSet.id,
9152
- variableName: name
9504
+ const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
9505
+ if (!enrollment) {
9506
+ return null;
9507
+ }
9508
+ const [probe, lease] = await Promise.all([
9509
+ probeEnrollment(services, workspaceId, enrollment),
9510
+ readLease2(db, workspaceId, sandbox.id)
9511
+ ]);
9512
+ const state = machineStateFor(probe.state, probe.hasDisplay);
9513
+ const sharedSessionCount = lease?.refcount ?? 0;
9514
+ const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
9515
+ return MachineView.parse({
9516
+ sandboxId: sandbox.id,
9517
+ enrollmentId: enrollment.id,
9518
+ name: sandbox.name,
9519
+ kind: "selfhosted",
9520
+ state,
9521
+ active: activeSandboxId === sandbox.id,
9522
+ isSessionGroup: false,
9523
+ workspaceGeneration: null,
9524
+ archiveGeneration: null,
9525
+ archiveComplete: false,
9526
+ os: enrollment.os,
9527
+ arch: enrollment.arch,
9528
+ hasDisplay: enrollment.hasDisplay,
9529
+ desktopUnavailableReason: enrollment.desktopUnavailableReason,
9530
+ allowScreenControl: enrollment.allowScreenControl,
9531
+ sharedSessionCount,
9532
+ lastSeenAt: enrollment.lastSeenAt,
9533
+ metrics: metricsRow ? metricRowToSample(metricsRow) : null
9153
9534
  });
9154
- return c.json({ ok: true });
9155
- });
9156
- }
9157
- }
9158
- var registerEnvironmentRoutes = registerVariableSetRoutes;
9159
- function parseVariableName(raw) {
9160
- const parsed = VariableSetVariableName2.safeParse(raw);
9161
- if (!parsed.success) {
9162
- throw new HTTPException12(422, {
9163
- message: "variable set variable names must match ^[A-Z][A-Z0-9_]*$"
9164
- });
9165
- }
9166
- assertAllowedVariableSetVariableName2(parsed.data);
9167
- return parsed.data;
9168
- }
9169
- function trimmedVariableSetName(name) {
9170
- const trimmed = name.trim();
9171
- if (!trimmed) {
9172
- throw new HTTPException12(422, { message: "variable set name is required" });
9173
- }
9174
- return trimmed;
9535
+ })
9536
+ );
9537
+ machines.push(...machineViews.filter((machine) => machine !== null));
9538
+ return { activeSandboxId, activeEpoch, machines };
9175
9539
  }
9176
9540
 
9177
- // src/routes/files.ts
9178
- import {
9179
- CompleteFileUploadResponse,
9180
- CreateFileUploadRequest,
9181
- CreateFileUploadResponse,
9182
- FileAsset,
9183
- FileDownloadUrlResponse,
9184
- RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
9185
- RETAINED_OUTPUT_MAX_PAGE_BYTES,
9186
- RetainedArtifactMetadataSchema,
9187
- retainedArtifactReferenceFromFile,
9188
- resolveRetainedOutputRange
9189
- } from "@opengeni/contracts";
9190
- import {
9191
- claimFileUploadCleanup,
9192
- completeFileUploadCleanup,
9193
- completeFileUpload,
9194
- createFileUpload,
9195
- getFileUpload,
9196
- getRetainedFileArtifact,
9197
- requireFile as requireFile2
9198
- } from "@opengeni/db";
9199
- import { HTTPException as HTTPException13 } from "hono/http-exception";
9200
- import { requireAccessGrant as requireAccessGrant8 } from "@opengeni/core";
9201
- import { recordWorkspaceUsage as recordWorkspaceUsage3, requireLimit as requireLimit3 } from "@opengeni/core";
9202
- function registerFileRoutes(app, deps) {
9203
- const { db, objectStorage } = deps;
9204
- app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {
9205
- const workspaceId = c.req.param("workspaceId");
9206
- const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
9207
- if (!objectStorage) {
9208
- throw new HTTPException13(503, { message: "object storage is not configured" });
9209
- }
9210
- const payload = CreateFileUploadRequest.parse(await c.req.json());
9211
- await requireLimit3(deps, {
9212
- accountId: grant.accountId,
9213
- workspaceId,
9214
- action: "file:upload",
9215
- quantity: payload.sizeBytes
9216
- });
9217
- if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
9218
- throw new HTTPException13(413, {
9219
- message: `file exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes`
9541
+ // src/routes/machines.ts
9542
+ var SERIES_WINDOWS_MS = {
9543
+ "15m": 15 * 6e4,
9544
+ "1h": 60 * 6e4,
9545
+ "6h": 6 * 60 * 6e4,
9546
+ "24h": 24 * 60 * 6e4
9547
+ };
9548
+ var DEFAULT_SERIES_WINDOW_MS = SERIES_WINDOWS_MS["1h"];
9549
+ function registerMachineRoutes(app, deps) {
9550
+ const { settings, db, bus } = deps;
9551
+ function assertSelfhostedEnabled() {
9552
+ if (!settings.sandboxSelfhostedEnabled) {
9553
+ throw new HTTPException12(404, {
9554
+ message: "selfhosted machines are not enabled for this deployment"
9220
9555
  });
9221
9556
  }
9222
- const fileId = crypto.randomUUID();
9223
- const safeFilename = sanitizeFilename(payload.filename);
9224
- const objectKey = `workspaces/${workspaceId}/files/${fileId}/original/${safeFilename}`;
9225
- const signed = await objectStorage.createPutUrl({
9226
- key: objectKey,
9227
- contentType: payload.contentType,
9228
- ...payload.sha256 ? { sha256: payload.sha256 } : {}
9229
- });
9230
- const upload = await createFileUpload(db, {
9557
+ }
9558
+ app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
9559
+ const workspaceId = c.req.param("workspaceId");
9560
+ await requireAccessGrant7(c, deps, workspaceId, "enrollments:read");
9561
+ assertSelfhostedEnabled();
9562
+ const sessionId = c.req.query("sessionId") ?? null;
9563
+ const response = await listMachines({ db, settings, bus }, { workspaceId, sessionId });
9564
+ return c.json(MachinesResponse.parse(response));
9565
+ });
9566
+ app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
9567
+ const workspaceId = c.req.param("workspaceId");
9568
+ await requireAccessGrant7(c, deps, workspaceId, "enrollments:read");
9569
+ assertSelfhostedEnabled();
9570
+ const enrollmentId = c.req.param("enrollmentId");
9571
+ const enrollment = await getEnrollment2(db, workspaceId, enrollmentId);
9572
+ if (!enrollment) {
9573
+ throw new HTTPException12(404, { message: "machine not found in this workspace" });
9574
+ }
9575
+ const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
9576
+ const since = new Date(Date.now() - windowMs);
9577
+ const rows = await readMachineMetricsSeries(db, { workspaceId, enrollmentId, since });
9578
+ return c.json(
9579
+ MachineMetricsSeriesResponse.parse({
9580
+ samples: rows.map(metricRowToSample)
9581
+ })
9582
+ );
9583
+ });
9584
+ app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/active-sandbox", async (c) => {
9585
+ const workspaceId = c.req.param("workspaceId");
9586
+ const grant = await requireAccessGrant7(c, deps, workspaceId, "sessions:control");
9587
+ assertSelfhostedEnabled();
9588
+ const sessionId = c.req.param("sessionId");
9589
+ const body = SwapActiveSandboxRequest.parse(await c.req.json());
9590
+ const ctx = await buildFleetContextForSession2(deps, {
9231
9591
  accountId: grant.accountId,
9232
9592
  workspaceId,
9233
- fileId,
9234
- filename: payload.filename,
9235
- safeFilename,
9236
- contentType: payload.contentType,
9237
- sizeBytes: payload.sizeBytes,
9238
- sha256: payload.sha256 ?? null,
9239
- bucket: objectStorage.bucket,
9240
- objectKey,
9241
- expiresAt: signed.expiresAt
9593
+ sessionId
9242
9594
  });
9243
- return c.json(
9244
- CreateFileUploadResponse.parse({
9245
- fileId: upload.file.id,
9246
- uploadId: upload.uploadId,
9247
- putUrl: signed.url,
9248
- requiredHeaders: signed.requiredHeaders,
9249
- expiresAt: upload.expiresAt,
9250
- maxSizeBytes: objectStorage.maxSinglePutSizeBytes
9251
- }),
9252
- 201
9595
+ const result = await swapActiveSandbox2(
9596
+ {
9597
+ db,
9598
+ settings,
9599
+ bus,
9600
+ ensureSessionGroupReady: async (fleetCtx) => {
9601
+ const session = await requireSession3(db, fleetCtx.workspaceId, fleetCtx.sessionId);
9602
+ return await ensureSessionGroupReady(
9603
+ { db, settings, bus },
9604
+ {
9605
+ accountId: fleetCtx.accountId,
9606
+ workspaceId: fleetCtx.workspaceId,
9607
+ session
9608
+ }
9609
+ );
9610
+ }
9611
+ },
9612
+ ctx,
9613
+ body.target
9253
9614
  );
9615
+ return c.json(SwapActiveSandboxResponse.parse(result));
9254
9616
  });
9255
- app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
9256
- const workspaceId = c.req.param("workspaceId");
9257
- const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
9258
- if (!objectStorage) {
9259
- throw new HTTPException13(503, { message: "object storage is not configured" });
9260
- }
9261
- const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
9262
- if (!upload) {
9263
- throw new HTTPException13(404, { message: "file upload not found" });
9264
- }
9265
- const recordUploadedFileUsage = async (file2) => {
9266
- await recordWorkspaceUsage3(deps, {
9267
- accountId: grant.accountId,
9268
- workspaceId,
9269
- subjectId: grant.subjectId,
9270
- eventType: "file.uploaded",
9271
- quantity: file2.sizeBytes,
9272
- unit: "byte",
9273
- sourceResourceType: "file",
9274
- sourceResourceId: file2.id,
9275
- idempotencyKey: `file.uploaded:${workspaceId}:${file2.id}`
9276
- });
9277
- };
9278
- const completeAndRecordUsage = async () => {
9279
- let file2;
9280
- try {
9281
- file2 = await completeFileUpload(db, workspaceId, upload.id);
9282
- } catch (error) {
9283
- const current = await getFileUpload(db, workspaceId, upload.id);
9284
- if (current?.status === "completed" && current.file.status === "ready") {
9285
- file2 = current.file;
9286
- } else if (current && current.status !== "pending") {
9287
- throw new HTTPException13(409, {
9288
- message: `file upload is ${publicFileUploadStatus(current.status)}`
9617
+ }
9618
+
9619
+ // src/routes/environments.ts
9620
+ import {
9621
+ CreateVariableSetRequest,
9622
+ SetVariableSetVariableRequest,
9623
+ UpdateVariableSetRequest,
9624
+ VariableSetVariableName as VariableSetVariableName2
9625
+ } from "@opengeni/contracts";
9626
+ import {
9627
+ countActiveSessionsUsingVariableSet,
9628
+ countScheduledTasksUsingVariableSet,
9629
+ countVariableSets as countVariableSets2,
9630
+ createVariableSet as createVariableSet2,
9631
+ deleteVariableSet,
9632
+ deleteVariableSetVariable,
9633
+ encryptVariableSetValue as encryptVariableSetValue2,
9634
+ getVariableSetByName as getVariableSetByName2,
9635
+ listVariableSets as listVariableSets2,
9636
+ setVariableSetVariable as setVariableSetVariable2,
9637
+ updateVariableSet
9638
+ } from "@opengeni/db";
9639
+ import { HTTPException as HTTPException13 } from "hono/http-exception";
9640
+ import { requireAccessGrant as requireAccessGrant8 } from "@opengeni/core";
9641
+ import {
9642
+ assertAllowedVariableSetVariableName as assertAllowedVariableSetVariableName2,
9643
+ MAX_ENVIRONMENTS_PER_WORKSPACE as MAX_ENVIRONMENTS_PER_WORKSPACE2,
9644
+ MAX_VARIABLES_PER_ENVIRONMENT as MAX_VARIABLES_PER_ENVIRONMENT2,
9645
+ recordVariableSetAuditEvent as recordVariableSetAuditEvent2,
9646
+ requireVariableSetEncryption as requireVariableSetEncryption2,
9647
+ requireVariableSetForApi
9648
+ } from "@opengeni/core";
9649
+ function registerVariableSetRoutes(app, deps) {
9650
+ const { settings, db } = deps;
9651
+ const prefixes = [
9652
+ "/v1/workspaces/:workspaceId/variable-sets",
9653
+ "/v1/workspaces/:workspaceId/environments"
9654
+ ];
9655
+ for (const prefix of prefixes) {
9656
+ app.get(`${prefix}`, async (c) => {
9657
+ const workspaceId = c.req.param("workspaceId");
9658
+ await requireAccessGrant8(c, deps, workspaceId, "variable-sets:use");
9659
+ return c.json(await listVariableSets2(db, workspaceId));
9660
+ });
9661
+ app.post(`${prefix}`, async (c) => {
9662
+ const workspaceId = c.req.param("workspaceId");
9663
+ const grant = await requireAccessGrant8(c, deps, workspaceId, "variable-sets:manage");
9664
+ const key = requireVariableSetEncryption2(settings);
9665
+ const payload = CreateVariableSetRequest.parse(await c.req.json());
9666
+ const name = trimmedVariableSetName(payload.name);
9667
+ if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT2) {
9668
+ throw new HTTPException13(422, {
9669
+ message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
9670
+ });
9671
+ }
9672
+ const variableNames = /* @__PURE__ */ new Set();
9673
+ for (const variable of payload.variables) {
9674
+ assertAllowedVariableSetVariableName2(variable.name);
9675
+ if (variableNames.has(variable.name)) {
9676
+ throw new HTTPException13(422, {
9677
+ message: `duplicate variable set variable name: ${variable.name}`
9289
9678
  });
9290
- } else {
9291
- throw error;
9292
9679
  }
9680
+ variableNames.add(variable.name);
9293
9681
  }
9294
- await recordUploadedFileUsage(file2);
9295
- return file2;
9296
- };
9297
- const rejectAndCleanObject = async (status, message, terminalStatus) => {
9298
- const claim = await claimFileUploadCleanup(db, {
9299
- workspaceId,
9300
- uploadId: upload.id,
9301
- fileId: upload.file.id
9302
- });
9303
- if (claim.outcome === "completed") {
9304
- await recordUploadedFileUsage(claim.file);
9305
- return claim.file;
9306
- }
9307
- if (claim.outcome === "unavailable") {
9308
- throw new HTTPException13(409, {
9309
- message: `file upload is ${publicFileUploadStatus(claim.status)}`
9682
+ if (await countVariableSets2(db, workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE2) {
9683
+ throw new HTTPException13(422, {
9684
+ message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE2} variable sets`
9310
9685
  });
9311
9686
  }
9312
- try {
9313
- await objectStorage.deleteObject(upload.file.objectKey);
9314
- } catch (error) {
9315
- deps.observability?.warn(
9316
- "file upload rejection cleanup failed; claim remains reclaimable",
9317
- {
9318
- workspaceId,
9319
- fileId: upload.file.id,
9320
- uploadId: upload.id,
9321
- error: error instanceof Error ? error.message : String(error)
9322
- }
9323
- );
9324
- throw new HTTPException13(status, { message });
9687
+ if (await getVariableSetByName2(db, workspaceId, name)) {
9688
+ throw new HTTPException13(409, { message: `variable set name is already in use: ${name}` });
9325
9689
  }
9326
- const settled = await completeFileUploadCleanup(db, {
9690
+ const created = await createVariableSet2(db, {
9327
9691
  accountId: grant.accountId,
9328
9692
  workspaceId,
9329
- uploadId: upload.id,
9330
- fileId: upload.file.id,
9331
- terminalStatus
9693
+ name,
9694
+ description: payload.description ?? null,
9695
+ variables: payload.variables.map((variable) => ({
9696
+ name: variable.name,
9697
+ valueEncrypted: encryptVariableSetValue2(key, variable.value)
9698
+ }))
9332
9699
  });
9333
- if (!settled) {
9334
- throw new HTTPException13(409, { message: "file upload cleanup claim was superseded" });
9700
+ await recordVariableSetAuditEvent2(db, {
9701
+ grant,
9702
+ action: "variable_set.created",
9703
+ variableSetId: created.id
9704
+ });
9705
+ return c.json(created, 201);
9706
+ });
9707
+ app.get(`${prefix}/:variableSetId`, async (c) => {
9708
+ const workspaceId = c.req.param("workspaceId");
9709
+ await requireAccessGrant8(c, deps, workspaceId, "variable-sets:use");
9710
+ return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")));
9711
+ });
9712
+ app.patch(`${prefix}/:variableSetId`, async (c) => {
9713
+ const workspaceId = c.req.param("workspaceId");
9714
+ const grant = await requireAccessGrant8(c, deps, workspaceId, "variable-sets:manage");
9715
+ const variableSet = await requireVariableSetForApi(
9716
+ db,
9717
+ workspaceId,
9718
+ c.req.param("variableSetId")
9719
+ );
9720
+ const payload = UpdateVariableSetRequest.parse(await c.req.json());
9721
+ const name = payload.name !== void 0 ? trimmedVariableSetName(payload.name) : void 0;
9722
+ if (name !== void 0 && name !== variableSet.name) {
9723
+ const existing = await getVariableSetByName2(db, workspaceId, name);
9724
+ if (existing && existing.id !== variableSet.id) {
9725
+ throw new HTTPException13(409, { message: `variable set name is already in use: ${name}` });
9726
+ }
9335
9727
  }
9336
- throw new HTTPException13(status, { message });
9337
- };
9338
- if (upload.status === "completed" && upload.file.status === "ready") {
9339
- const file2 = await completeAndRecordUsage();
9340
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9341
- }
9342
- if (upload.status !== "pending") {
9343
- throw new HTTPException13(409, {
9344
- message: `file upload is ${publicFileUploadStatus(upload.status)}`
9728
+ const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
9729
+ ...name !== void 0 ? { name } : {},
9730
+ ...payload.description !== void 0 ? { description: payload.description } : {}
9345
9731
  });
9346
- }
9347
- if (upload.expiresAt.getTime() < Date.now()) {
9348
- const file2 = await rejectAndCleanObject(409, "file upload has expired", "expired");
9349
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9350
- }
9351
- const head = await objectStorage.headFile(upload.file).catch((error) => {
9352
- throw new HTTPException13(409, {
9353
- message: `uploaded object is not available: ${error instanceof Error ? error.message : String(error)}`
9732
+ await recordVariableSetAuditEvent2(db, {
9733
+ grant,
9734
+ action: "variable_set.updated",
9735
+ variableSetId: variableSet.id
9354
9736
  });
9737
+ return c.json(updated);
9355
9738
  });
9356
- if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
9357
- const file2 = await rejectAndCleanObject(
9358
- 422,
9359
- "uploaded object size does not match file metadata",
9360
- "failed"
9739
+ app.delete(`${prefix}/:variableSetId`, async (c) => {
9740
+ const workspaceId = c.req.param("workspaceId");
9741
+ const grant = await requireAccessGrant8(c, deps, workspaceId, "variable-sets:manage");
9742
+ const variableSet = await requireVariableSetForApi(
9743
+ db,
9744
+ workspaceId,
9745
+ c.req.param("variableSetId")
9361
9746
  );
9362
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9363
- }
9364
- if (upload.file.contentType && head.ContentType && head.ContentType !== upload.file.contentType) {
9365
- const file2 = await rejectAndCleanObject(
9366
- 422,
9367
- "uploaded object content type does not match file metadata",
9368
- "failed"
9747
+ const attachedTasks = await countScheduledTasksUsingVariableSet(
9748
+ db,
9749
+ workspaceId,
9750
+ variableSet.id
9369
9751
  );
9370
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9371
- }
9372
- if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
9373
- const file2 = await rejectAndCleanObject(
9374
- 422,
9375
- "uploaded object checksum metadata does not match file metadata",
9376
- "failed"
9752
+ if (attachedTasks > 0) {
9753
+ throw new HTTPException13(409, {
9754
+ message: `variable set is attached to ${attachedTasks} scheduled task(s); detach first`
9755
+ });
9756
+ }
9757
+ const activeSessions = await countActiveSessionsUsingVariableSet(
9758
+ db,
9759
+ workspaceId,
9760
+ variableSet.id
9377
9761
  );
9378
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9379
- }
9380
- const file = await completeAndRecordUsage();
9381
- return c.json(CompleteFileUploadResponse.parse({ file }));
9382
- });
9383
- app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
9384
- const workspaceId = c.req.param("workspaceId");
9385
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9386
- const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
9387
- if (!file) {
9388
- throw new HTTPException13(404, { message: "file not found" });
9389
- }
9390
- return c.json(FileAsset.parse(file));
9391
- });
9392
- app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
9393
- const workspaceId = c.req.param("workspaceId");
9394
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9395
- const artifactId = retainedArtifactId(c.req.param("artifactId"));
9396
- const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
9397
- if (!artifact) {
9398
- return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
9399
- }
9400
- return c.json(retainedArtifactMetadata(artifact));
9401
- });
9402
- app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
9403
- const workspaceId = c.req.param("workspaceId");
9404
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9405
- const artifactId = retainedArtifactId(c.req.param("artifactId"));
9406
- const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
9407
- if (!artifact) {
9408
- return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
9409
- }
9410
- const metadata = retainedArtifactMetadata(artifact);
9411
- if (!metadata.available) {
9412
- return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
9413
- }
9414
- if (!objectStorage) {
9415
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
9416
- }
9417
- const rangeHeader = c.req.header("range");
9418
- const range = resolveRetainedOutputRange(
9419
- rangeHeader,
9420
- metadata.originalBytes,
9421
- rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES
9422
- );
9423
- if (range.kind === "invalid") {
9424
- return c.json(
9425
- {
9426
- message: "invalid retained artifact byte range",
9427
- reason: range.reason,
9428
- maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES
9429
- },
9430
- 400
9762
+ if (activeSessions > 0) {
9763
+ throw new HTTPException13(409, {
9764
+ message: `variable set is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first`
9765
+ });
9766
+ }
9767
+ await deleteVariableSet(db, workspaceId, variableSet.id);
9768
+ await recordVariableSetAuditEvent2(db, {
9769
+ grant,
9770
+ action: "variable_set.deleted",
9771
+ variableSetId: variableSet.id
9772
+ });
9773
+ return c.json({ ok: true });
9774
+ });
9775
+ app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9776
+ const workspaceId = c.req.param("workspaceId");
9777
+ const grant = await requireAccessGrant8(c, deps, workspaceId, "variable-sets:manage");
9778
+ const key = requireVariableSetEncryption2(settings);
9779
+ const name = parseVariableName(c.req.param("name"));
9780
+ const variableSet = await requireVariableSetForApi(
9781
+ db,
9782
+ workspaceId,
9783
+ c.req.param("variableSetId")
9431
9784
  );
9432
- }
9433
- if (range.kind === "unsatisfiable") {
9434
- return c.json(
9435
- { message: "retained artifact byte range is not satisfiable", reason: range.reason },
9436
- 416,
9437
- {
9438
- "Accept-Ranges": "bytes",
9439
- "Content-Range": range.contentRange,
9440
- "Cache-Control": "private, no-store"
9441
- }
9785
+ const payload = SetVariableSetVariableRequest.parse(await c.req.json());
9786
+ const exists = variableSet.variables.some((variable) => variable.name === name);
9787
+ if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT2) {
9788
+ throw new HTTPException13(422, {
9789
+ message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
9790
+ });
9791
+ }
9792
+ const metadata = await setVariableSetVariable2(db, {
9793
+ accountId: grant.accountId,
9794
+ workspaceId,
9795
+ variableSetId: variableSet.id,
9796
+ name,
9797
+ valueEncrypted: encryptVariableSetValue2(key, payload.value)
9798
+ });
9799
+ await recordVariableSetAuditEvent2(db, {
9800
+ grant,
9801
+ action: "variable_set.variable.set",
9802
+ variableSetId: variableSet.id,
9803
+ variableName: name
9804
+ });
9805
+ return c.json(metadata);
9806
+ });
9807
+ app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9808
+ const workspaceId = c.req.param("workspaceId");
9809
+ const grant = await requireAccessGrant8(c, deps, workspaceId, "variable-sets:manage");
9810
+ const name = parseVariableName(c.req.param("name"));
9811
+ const variableSet = await requireVariableSetForApi(
9812
+ db,
9813
+ workspaceId,
9814
+ c.req.param("variableSetId")
9442
9815
  );
9443
- }
9444
- const headers = {
9445
- "Accept-Ranges": range.acceptRanges,
9446
- "Cache-Control": "private, no-store",
9447
- "Content-Length": String(range.length),
9448
- "Content-Type": metadata.contentType,
9449
- "X-Content-Type-Options": "nosniff",
9450
- ...range.contentRange ? { "Content-Range": range.contentRange } : {}
9451
- };
9452
- if (range.kind === "empty") {
9453
- if (!await objectStorage.fileExists(artifact.file)) {
9454
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
9816
+ const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
9817
+ if (!deleted) {
9818
+ throw new HTTPException13(404, { message: "variable set variable not found" });
9455
9819
  }
9456
- return c.body(null, 200, headers);
9457
- }
9458
- const bytes = await objectStorage.getFileRange(artifact.file, {
9459
- start: range.start,
9460
- end: range.end
9820
+ await recordVariableSetAuditEvent2(db, {
9821
+ grant,
9822
+ action: "variable_set.variable.deleted",
9823
+ variableSetId: variableSet.id,
9824
+ variableName: name
9825
+ });
9826
+ return c.json({ ok: true });
9461
9827
  });
9462
- if (!bytes) {
9463
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
9464
- }
9465
- if (bytes.byteLength !== range.length) {
9466
- throw new HTTPException13(502, { message: "object storage returned an invalid byte range" });
9467
- }
9468
- return c.body(new Uint8Array(bytes), range.status, headers);
9469
- });
9470
- app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
9471
- const workspaceId = c.req.param("workspaceId");
9472
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9473
- if (!objectStorage) {
9474
- throw new HTTPException13(503, { message: "object storage is not configured" });
9475
- }
9476
- const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
9477
- if (!file) {
9478
- throw new HTTPException13(404, { message: "file not found" });
9479
- }
9480
- if (file.status !== "ready") {
9481
- throw new HTTPException13(409, { message: `file is ${file.status}` });
9482
- }
9483
- const signed = await objectStorage.createGetUrl({ key: file.objectKey });
9484
- return c.json(
9485
- FileDownloadUrlResponse.parse({
9486
- url: signed.url,
9487
- expiresAt: signed.expiresAt.toISOString()
9488
- })
9489
- );
9490
- });
9491
- }
9492
- function sanitizeFilename(filename) {
9493
- const trimmed = filename.trim().replace(/[/\\]/g, "_");
9494
- const safe = trimmed.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim();
9495
- return safe || "file";
9496
- }
9497
- function publicFileUploadStatus(status) {
9498
- return status === "cleanup_pending" ? "failed" : status;
9828
+ }
9499
9829
  }
9500
- function retainedArtifactId(value) {
9501
- const parsed = FileAsset.shape.id.safeParse(value);
9830
+ var registerEnvironmentRoutes = registerVariableSetRoutes;
9831
+ function parseVariableName(raw) {
9832
+ const parsed = VariableSetVariableName2.safeParse(raw);
9502
9833
  if (!parsed.success) {
9503
- throw new HTTPException13(404, { message: "artifact not found" });
9834
+ throw new HTTPException13(422, {
9835
+ message: "variable set variable names must match ^[A-Z][A-Z0-9_]*$"
9836
+ });
9504
9837
  }
9838
+ assertAllowedVariableSetVariableName2(parsed.data);
9505
9839
  return parsed.data;
9506
9840
  }
9507
- function retainedArtifactUnavailable(artifactId, reason) {
9508
- return RetainedArtifactMetadataSchema.parse({ available: false, artifactId, reason });
9509
- }
9510
- function retainedArtifactMetadata(artifact) {
9511
- const reference = retainedArtifactReferenceFromFile(artifact.file);
9512
- if (reference) return reference;
9513
- const { file, uploadStatus, uploadExpiresAt } = artifact;
9514
- if (file.status === "deleted") {
9515
- return retainedArtifactUnavailable(file.id, "deleted");
9516
- }
9517
- if (file.status === "expired" || uploadStatus === "expired" || uploadStatus === "pending" && uploadExpiresAt !== null && uploadExpiresAt.getTime() < Date.now()) {
9518
- return retainedArtifactUnavailable(file.id, "expired");
9519
- }
9520
- if (file.status === "failed" || uploadStatus === "failed" || uploadStatus === "cleanup_pending") {
9521
- return retainedArtifactUnavailable(file.id, "failed");
9522
- }
9523
- if (file.status === "pending_upload" || uploadStatus === "pending") {
9524
- return retainedArtifactUnavailable(file.id, "pending");
9525
- }
9526
- return retainedArtifactUnavailable(file.id, "unsupported");
9527
- }
9528
- function retainedArtifactUnavailableStatus(reason) {
9529
- switch (reason) {
9530
- case "deleted":
9531
- return 404;
9532
- case "expired":
9533
- case "missing_storage":
9534
- return 410;
9535
- case "unsupported":
9536
- case "not_retained":
9537
- case "storage_write_failed":
9538
- return 422;
9539
- case "pending":
9540
- case "failed":
9541
- return 409;
9841
+ function trimmedVariableSetName(name) {
9842
+ const trimmed = name.trim();
9843
+ if (!trimmed) {
9844
+ throw new HTTPException13(422, { message: "variable set name is required" });
9542
9845
  }
9846
+ return trimmed;
9543
9847
  }
9544
9848
 
9545
9849
  // src/routes/api-keys.ts
@@ -10116,27 +10420,37 @@ function looksLikeEmail(value) {
10116
10420
  }
10117
10421
 
10118
10422
  // src/routes/github.ts
10119
- import { GitHubAppManifestCreate } from "@opengeni/contracts";
10120
- import { deleteGitHubInstallationBinding } from "@opengeni/db";
10121
10423
  import {
10424
+ GitHubAppManifestCreate
10425
+ } from "@opengeni/contracts";
10426
+ import {
10427
+ bindAuthorizedGitHubInstallationRepositories,
10428
+ deleteGitHubInstallationBinding,
10429
+ GitHubInstallationAuthorityCommitError
10430
+ } from "@opengeni/db";
10431
+ import {
10432
+ authorizeGitHubInstallationBinding,
10122
10433
  buildGitHubAppManifest,
10123
10434
  convertGitHubAppManifest,
10124
- createSignedState as createSignedState3,
10435
+ createSignedState as createSignedState4,
10125
10436
  envLinesFromGitHubManifestConversion,
10126
10437
  GitHubAppApiError,
10127
10438
  GitHubAppConfigurationError as GitHubAppConfigurationError2,
10439
+ GitHubInstallationAuthorityError,
10128
10440
  githubAppMissingSettings as githubAppMissingSettings2,
10441
+ githubOAuthAuthorizeUrl,
10129
10442
  organizationAppManifestUrl,
10130
10443
  personalAppManifestUrl,
10131
10444
  readSignedState as readSignedState3,
10132
10445
  stateMaxAgeSeconds,
10133
10446
  verifySignedState
10134
10447
  } from "@opengeni/github";
10135
- import { setCookie } from "hono/cookie";
10448
+ import { deleteCookie, setCookie } from "hono/cookie";
10136
10449
  import { HTTPException as HTTPException16 } from "hono/http-exception";
10137
- import { requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
10450
+ import { hasPermission as hasPermission5, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
10138
10451
  var githubStateCookie = "opengeni_github_state";
10139
- var installationBindingDisabledMessage = "Connecting a GitHub App installation is disabled until GitHub installation authority can be proven";
10452
+ var githubBindingStateMaxAgeSeconds = 10 * 60;
10453
+ var legacyInstallationChooserDisabledMessage = "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
10140
10454
  function registerGitHubRoutes(app, deps) {
10141
10455
  const { db, settings, githubStateSecret } = deps;
10142
10456
  app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
@@ -10144,17 +10458,25 @@ function registerGitHubRoutes(app, deps) {
10144
10458
  const grant = await requireAccessGrant10(c, deps, workspaceId, "github:use");
10145
10459
  const missing = githubAppMissingSettings2(settings);
10146
10460
  const slug = settings.githubAppSlug?.trim() || null;
10461
+ const installations = missing.length === 0 ? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId) : [];
10462
+ const status = githubBindingStatus(missing.length === 0, installations);
10463
+ const canManage = hasPermission5(grant.permissions, "github:manage");
10464
+ const connectState = missing.length === 0 && slug && canManage ? createSignedState4(githubStateSecret, {
10465
+ accountId: grant.accountId,
10466
+ workspaceId: grant.workspaceId,
10467
+ intent: "installation_authority",
10468
+ ...githubBrowserGrantClaims(settings, grant)
10469
+ }) : null;
10470
+ const connectUrl = connectState ? `${openGeniBaseUrl(settings, c)}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(connectState)}` : null;
10147
10471
  return c.json({
10148
10472
  configured: missing.length === 0,
10473
+ status,
10149
10474
  appId: settings.githubAppId ?? null,
10150
10475
  clientId: settings.githubClientId ?? null,
10151
10476
  appSlug: slug,
10152
- // Kept nullable for SDK compatibility. GitHub's setup callback contains
10153
- // a spoofable installation_id, while user-installation visibility and
10154
- // repository admin permission do not prove that this human may bind it.
10155
- installUrl: null,
10156
- linkUrl: null,
10157
- installations: await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId),
10477
+ installUrl: connectUrl,
10478
+ linkUrl: connectUrl,
10479
+ installations,
10158
10480
  missing
10159
10481
  });
10160
10482
  });
@@ -10165,10 +10487,22 @@ function registerGitHubRoutes(app, deps) {
10165
10487
  throw new HTTPException16(400, { message: "missing GitHub installation state" });
10166
10488
  }
10167
10489
  const statePayload = readSignedState3(state, githubStateSecret);
10168
- if (!statePayload || statePayload.workspaceId !== workspaceId) {
10490
+ if (!statePayload || statePayload.intent !== "installation_authority" || statePayload.workspaceId !== workspaceId || typeof statePayload.accountId !== "string" || !isFreshGitHubBindingState(statePayload)) {
10169
10491
  throw new HTTPException16(400, { message: "invalid or expired GitHub installation state" });
10170
10492
  }
10171
- throw installationBindingDisabled();
10493
+ const slug = settings.githubAppSlug?.trim();
10494
+ if (!slug || githubAppMissingSettings2(settings).length > 0) {
10495
+ throw new HTTPException16(409, {
10496
+ message: JSON.stringify({
10497
+ message: "GitHub App is not configured",
10498
+ missing: githubAppMissingSettings2(settings)
10499
+ })
10500
+ });
10501
+ }
10502
+ setGitHubStateCookie(c, deps, state);
10503
+ return c.redirect(
10504
+ `https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`
10505
+ );
10172
10506
  });
10173
10507
  app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
10174
10508
  const workspaceId = c.req.param("workspaceId");
@@ -10227,7 +10561,7 @@ function registerGitHubRoutes(app, deps) {
10227
10561
  /\/+$/,
10228
10562
  ""
10229
10563
  );
10230
- const state = createSignedState3(githubStateSecret, {
10564
+ const state = createSignedState4(githubStateSecret, {
10231
10565
  accountId: grant.accountId,
10232
10566
  workspaceId: grant.workspaceId
10233
10567
  });
@@ -10272,23 +10606,134 @@ function registerGitHubRoutes(app, deps) {
10272
10606
  throw new HTTPException16(400, { message: "missing GitHub installation state" });
10273
10607
  }
10274
10608
  const statePayload = readSignedState3(state, githubStateSecret);
10275
- if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string") {
10609
+ if (!statePayload || statePayload.intent !== "installation_authority" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
10276
10610
  throw new HTTPException16(400, { message: "invalid or expired GitHub installation state" });
10277
10611
  }
10278
- throw installationBindingDisabled();
10612
+ requireGitHubStateCookie(c, state);
10613
+ const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
10614
+ if (grant.accountId !== statePayload.accountId) {
10615
+ throw new HTTPException16(403, {
10616
+ message: "GitHub installation state does not match this workspace"
10617
+ });
10618
+ }
10619
+ const setupAction = c.req.query("setup_action");
10620
+ if (setupAction === "request") {
10621
+ return c.html(githubSetupPendingHtml());
10622
+ }
10623
+ if (setupAction !== "install" && setupAction !== "update") {
10624
+ throw new HTTPException16(400, { message: "unsupported GitHub setup action" });
10625
+ }
10626
+ const installationId = parsePositiveInteger(c.req.query("installation_id"));
10627
+ if (installationId === null) {
10628
+ throw new HTTPException16(400, { message: "missing or invalid GitHub installation_id" });
10629
+ }
10630
+ const clientId = settings.githubClientId?.trim();
10631
+ if (!clientId) {
10632
+ throw new HTTPException16(409, {
10633
+ message: JSON.stringify({
10634
+ message: "GitHub App is not configured",
10635
+ missing: ["OPENGENI_GITHUB_CLIENT_ID"]
10636
+ })
10637
+ });
10638
+ }
10639
+ const oauthState = createSignedState4(githubStateSecret, {
10640
+ accountId: grant.accountId,
10641
+ workspaceId: grant.workspaceId,
10642
+ installationId,
10643
+ intent: "installation_authority_oauth",
10644
+ ...continuedGitHubBrowserGrantClaims(statePayload)
10645
+ });
10646
+ setGitHubStateCookie(c, deps, oauthState);
10647
+ return c.redirect(
10648
+ githubOAuthAuthorizeUrl({
10649
+ clientId,
10650
+ state: oauthState,
10651
+ redirectUri: `${openGeniBaseUrl(settings, c)}/v1/github/oauth/callback`
10652
+ })
10653
+ );
10279
10654
  };
10280
10655
  app.get("/v1/github/setup", handleGitHubInstallCallback);
10281
10656
  app.get("/v1/github/install/callback", handleGitHubInstallCallback);
10282
10657
  app.get("/v1/github/oauth/callback", async (c) => {
10658
+ const code = c.req.query("code");
10283
10659
  const state = c.req.query("state");
10660
+ if (!code) {
10661
+ throw new HTTPException16(400, { message: "missing GitHub OAuth code" });
10662
+ }
10284
10663
  if (!state) {
10285
10664
  throw new HTTPException16(400, { message: "missing GitHub OAuth state" });
10286
10665
  }
10287
10666
  const statePayload = readSignedState3(state, githubStateSecret);
10288
- if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string") {
10667
+ if (!statePayload || statePayload.intent !== "installation_authority_oauth" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
10289
10668
  throw new HTTPException16(400, { message: "invalid or expired GitHub OAuth state" });
10290
10669
  }
10291
- throw installationBindingDisabled();
10670
+ const installationId = parsePositiveInteger(String(statePayload.installationId ?? ""));
10671
+ if (installationId === null) {
10672
+ throw new HTTPException16(400, { message: "invalid GitHub installation id" });
10673
+ }
10674
+ requireGitHubStateCookie(c, state);
10675
+ const grant = await requireGitHubManageGrant(c, deps, statePayload.workspaceId, statePayload);
10676
+ if (grant.accountId !== statePayload.accountId) {
10677
+ throw new HTTPException16(403, {
10678
+ message: "GitHub OAuth state does not match this workspace"
10679
+ });
10680
+ }
10681
+ let proof;
10682
+ try {
10683
+ proof = deps.githubAppApi?.authorizeInstallationBinding ? await deps.githubAppApi.authorizeInstallationBinding({ code, installationId }) : deps.githubAppApi ? null : await authorizeGitHubInstallationBinding(settings, { code, installationId });
10684
+ } catch (error) {
10685
+ throw githubAuthorityHttpError(error);
10686
+ }
10687
+ if (!proof) {
10688
+ throw new HTTPException16(409, {
10689
+ message: "The configured GitHub provider cannot prove personal-owner or organization-owner authority"
10690
+ });
10691
+ }
10692
+ if (!isConsistentGitHubBindingProof(proof, installationId)) {
10693
+ throw new HTTPException16(409, { message: "GitHub installation proof is stale or invalid" });
10694
+ }
10695
+ const repositoryIds = [...new Set(proof.repositories.map((repository) => repository.id))];
10696
+ if (repositoryIds.length !== proof.repositories.length) {
10697
+ throw new HTTPException16(409, { message: "GitHub returned duplicate repository identities" });
10698
+ }
10699
+ const authorityCheckedAt = /* @__PURE__ */ new Date();
10700
+ const expiresAt = new Date((statePayload.iat + githubBindingStateMaxAgeSeconds) * 1e3);
10701
+ let bound;
10702
+ try {
10703
+ bound = await bindAuthorizedGitHubInstallationRepositories(db, {
10704
+ accountId: grant.accountId,
10705
+ workspaceId: grant.workspaceId,
10706
+ installationId,
10707
+ githubAccountId: proof.installation.accountId,
10708
+ accountLogin: proof.installation.accountLogin,
10709
+ accountType: proof.installation.accountType,
10710
+ linkedBySubjectId: grant.subjectId,
10711
+ githubActorId: proof.actorId,
10712
+ githubActorLogin: proof.actorLogin,
10713
+ authorityKind: proof.authorityKind,
10714
+ authorityCheckedAt,
10715
+ authorityExpiresAt: expiresAt,
10716
+ authorityNonce: statePayload.nonce,
10717
+ repositoryIds
10718
+ });
10719
+ } catch (error) {
10720
+ if (error instanceof GitHubInstallationAuthorityCommitError) {
10721
+ throw new HTTPException16(409, { message: error.message });
10722
+ }
10723
+ throw error;
10724
+ }
10725
+ if (!bound) {
10726
+ throw new HTTPException16(409, {
10727
+ message: "GitHub installation authorization was already used"
10728
+ });
10729
+ }
10730
+ deleteCookie(c, githubStateCookie, { path: "/v1" });
10731
+ return c.html(
10732
+ githubSetupSuccessHtml(
10733
+ proof.installation.accountLogin ?? `installation ${installationId}`,
10734
+ openGeniReturnUrl(settings, c, grant.workspaceId)
10735
+ )
10736
+ );
10292
10737
  });
10293
10738
  app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
10294
10739
  const workspaceId = c.req.param("workspaceId");
@@ -10301,11 +10746,11 @@ function registerGitHubRoutes(app, deps) {
10301
10746
  if (!statePayload || typeof statePayload.accountId !== "string" || statePayload.accountId.length === 0 || statePayload.workspaceId !== workspaceId) {
10302
10747
  throw new HTTPException16(400, { message: "invalid or expired GitHub OAuth state" });
10303
10748
  }
10304
- throw installationBindingDisabled();
10749
+ throw legacyInstallationChooserDisabled();
10305
10750
  });
10306
10751
  }
10307
- function installationBindingDisabled() {
10308
- return new HTTPException16(410, { message: installationBindingDisabledMessage });
10752
+ function legacyInstallationChooserDisabled() {
10753
+ return new HTTPException16(410, { message: legacyInstallationChooserDisabledMessage });
10309
10754
  }
10310
10755
  function setGitHubStateCookie(c, deps, state) {
10311
10756
  setCookie(c, githubStateCookie, state, {
@@ -10316,6 +10761,61 @@ function setGitHubStateCookie(c, deps, state) {
10316
10761
  maxAge: stateMaxAgeSeconds
10317
10762
  });
10318
10763
  }
10764
+ function requireGitHubStateCookie(c, state) {
10765
+ if (!allCookieValues(c, githubStateCookie).includes(state)) {
10766
+ throw new HTTPException16(400, {
10767
+ message: "invalid or expired GitHub installation browser state"
10768
+ });
10769
+ }
10770
+ }
10771
+ async function requireGitHubManageGrant(c, deps, workspaceId, expectedState) {
10772
+ try {
10773
+ return await requireAccessGrant10(c, deps, workspaceId, "github:manage");
10774
+ } catch (error) {
10775
+ if (!(error instanceof HTTPException16) || error.status !== 401) {
10776
+ throw error;
10777
+ }
10778
+ const grant = githubBrowserGrantFromState(deps.settings, expectedState, workspaceId);
10779
+ if (grant) {
10780
+ return grant;
10781
+ }
10782
+ throw error;
10783
+ }
10784
+ }
10785
+ function allCookieValues(c, name) {
10786
+ const prefix = `${name}=`;
10787
+ return (c.req.header("cookie") ?? "").split(";").map((part) => part.trim()).filter((part) => part.startsWith(prefix)).map((part) => {
10788
+ const raw = part.slice(prefix.length);
10789
+ try {
10790
+ return decodeURIComponent(raw);
10791
+ } catch {
10792
+ return raw;
10793
+ }
10794
+ });
10795
+ }
10796
+ function githubAuthorityHttpError(error) {
10797
+ if (error instanceof HTTPException16) {
10798
+ return error;
10799
+ }
10800
+ if (error instanceof GitHubInstallationAuthorityError) {
10801
+ if (error.reason === "authority_denied") {
10802
+ return new HTTPException16(403, { message: error.message });
10803
+ }
10804
+ if (error.reason === "installation_missing") {
10805
+ return new HTTPException16(404, { message: error.message });
10806
+ }
10807
+ return new HTTPException16(409, { message: error.message });
10808
+ }
10809
+ if (error instanceof GitHubAppConfigurationError2) {
10810
+ return new HTTPException16(409, {
10811
+ message: JSON.stringify({ message: error.message, missing: error.missing })
10812
+ });
10813
+ }
10814
+ if (error instanceof GitHubAppApiError) {
10815
+ return new HTTPException16(502, { message: error.message });
10816
+ }
10817
+ return new HTTPException16(502, { message: "GitHub authority verification failed" });
10818
+ }
10319
10819
  function isSecureRequest(c, deps) {
10320
10820
  return deps.settings.publicBaseUrl?.startsWith("https://") || c.req.header("x-forwarded-proto") === "https" || new URL(c.req.url).protocol === "https:";
10321
10821
  }
@@ -10324,6 +10824,12 @@ function githubSuccessHtml(envLines) {
10324
10824
  const escaped = escapeHtml2(envText);
10325
10825
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
10326
10826
  }
10827
+ function githubSetupSuccessHtml(account, returnUrl) {
10828
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml2(account)} is now available to this OpenGeni workspace through an explicit repository allowlist.</p><a class="button" href="${escapeHtml2(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
10829
+ }
10830
+ function githubSetupPendingHtml() {
10831
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>A GitHub organization owner must approve the installation. OpenGeni has not created a workspace binding.</p></main></body></html>`;
10832
+ }
10327
10833
  function parsePositiveInteger(value) {
10328
10834
  if (!value || !/^\d+$/.test(value)) {
10329
10835
  return null;
@@ -10331,6 +10837,22 @@ function parsePositiveInteger(value) {
10331
10837
  const parsed = Number(value);
10332
10838
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
10333
10839
  }
10840
+ function isFreshGitHubBindingState(payload) {
10841
+ const age = Math.floor(Date.now() / 1e3) - payload.iat;
10842
+ return age >= 0 && age < githubBindingStateMaxAgeSeconds;
10843
+ }
10844
+ function isConsistentGitHubBindingProof(proof, installationId) {
10845
+ const installation = proof.installation;
10846
+ if (installation.installationId !== installationId || !Number.isSafeInteger(installation.accountId) || installation.accountId <= 0 || !installation.accountLogin?.trim() || installation.suspended || !Number.isSafeInteger(proof.actorId) || proof.actorId <= 0 || !proof.actorLogin.trim() || proof.repositories.length === 0) {
10847
+ return false;
10848
+ }
10849
+ if (proof.authorityKind === "personal_owner" ? installation.accountType !== "User" || proof.actorId !== installation.accountId : installation.accountType !== "Organization") {
10850
+ return false;
10851
+ }
10852
+ return proof.repositories.every(
10853
+ (repository) => Number.isSafeInteger(repository.id) && repository.id > 0 && repository.installationId === installationId && repository.accountLogin === installation.accountLogin && repository.accountType === installation.accountType
10854
+ );
10855
+ }
10334
10856
  function escapeHtml2(value) {
10335
10857
  return value.replace(
10336
10858
  /[&<>"']/g,
@@ -10343,6 +10865,14 @@ function escapeHtml2(value) {
10343
10865
  })[char] ?? char
10344
10866
  );
10345
10867
  }
10868
+ function openGeniReturnUrl(settings, c, workspaceId) {
10869
+ const url = new URL(openGeniBaseUrl(settings, c) || new URL(c.req.url).origin);
10870
+ url.searchParams.set("workspaceId", workspaceId);
10871
+ return url.toString();
10872
+ }
10873
+ function openGeniBaseUrl(settings, c) {
10874
+ return githubBrowserBaseUrl(settings, new URL(c.req.url).origin);
10875
+ }
10346
10876
 
10347
10877
  // src/routes/packs.ts
10348
10878
  import {
@@ -10948,6 +11478,7 @@ import {
10948
11478
  UpdateSessionGoalRequest,
10949
11479
  UpdateSessionMcpApprovalPolicyRequest,
10950
11480
  UpdateSessionRequest,
11481
+ UpdateSessionToolPolicyRequest,
10951
11482
  ViewerHeartbeatRequest,
10952
11483
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
10953
11484
  workspaceControlUtf8Bytes
@@ -10995,6 +11526,7 @@ import {
10995
11526
  NewSessionDraftConflictError,
10996
11527
  SessionCommandIdempotencyError,
10997
11528
  SessionControlConflictError,
11529
+ SessionToolPolicyVersionConflictError,
10998
11530
  SessionContextBusyError,
10999
11531
  HumanInputResponseValidationError,
11000
11532
  latestWorkspaceCapture,
@@ -11259,6 +11791,7 @@ import {
11259
11791
  sessionSpawnDenialEnvelope as sessionSpawnDenialEnvelope2,
11260
11792
  steerHumanQueuePrompt,
11261
11793
  updateSessionMcpApprovalPolicy,
11794
+ updateSessionToolPolicy,
11262
11795
  updateSessionTitle as updateSessionTitle2,
11263
11796
  workflowIdForSession,
11264
11797
  sessionWithEffectiveToolPolicy as sessionWithEffectiveToolPolicy2,
@@ -12310,6 +12843,28 @@ function registerSessionRoutes(app, deps) {
12310
12843
  );
12311
12844
  }
12312
12845
  );
12846
+ app.put("/v1/workspaces/:workspaceId/sessions/:sessionId/tool-policy", async (c) => {
12847
+ const workspaceId = c.req.param("workspaceId");
12848
+ const grant = await requireAccessGrant14(c, deps, workspaceId, "sessions:control");
12849
+ const sessionId = c.req.param("sessionId");
12850
+ const payload = UpdateSessionToolPolicyRequest.parse(await c.req.json().catch(() => null));
12851
+ try {
12852
+ const session = await updateSessionToolPolicy(deps, grant, sessionId, payload);
12853
+ return c.json(await withEffectivePolicy(deps, workspaceId, session));
12854
+ } catch (error) {
12855
+ if (error instanceof SessionToolPolicyVersionConflictError) {
12856
+ return c.json(
12857
+ {
12858
+ code: error.code,
12859
+ message: error.message,
12860
+ currentVersion: error.currentVersion
12861
+ },
12862
+ 409
12863
+ );
12864
+ }
12865
+ throw error;
12866
+ }
12867
+ });
12313
12868
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
12314
12869
  const workspaceId = c.req.param("workspaceId");
12315
12870
  await requireAccessGrant14(c, deps, workspaceId, "sessions:read");
@@ -13741,6 +14296,7 @@ function sessionAuthorizationOperationForHttp(method, pathname, sessionId) {
13741
14296
  return null;
13742
14297
  }
13743
14298
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
14299
+ if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
13744
14300
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
13745
14301
  return "session.mcp.approval_policy.write";
13746
14302
  }
@@ -14142,7 +14698,7 @@ import {
14142
14698
  } from "@opengeni/db";
14143
14699
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
14144
14700
  import { HTTPException as HTTPException23 } from "hono/http-exception";
14145
- import { hasPermission as hasPermission4, requireAccessContext as requireAccessContext2, requireAccessGrant as requireAccessGrant16 } from "@opengeni/core";
14701
+ import { hasPermission as hasPermission6, requireAccessContext as requireAccessContext2, requireAccessGrant as requireAccessGrant16 } from "@opengeni/core";
14146
14702
  import { requireLimit as requireLimit7 } from "@opengeni/core";
14147
14703
  import {
14148
14704
  assertWorkspaceDeletable,
@@ -14389,7 +14945,7 @@ function registerWorkspaceRoutes(app, deps) {
14389
14945
  const context = await requireAccessContext2(c, deps);
14390
14946
  const readableWorkspaceIds = [
14391
14947
  ...new Set(
14392
- context.workspaceGrants.filter((grant) => hasPermission4(grant.permissions, "workspace:read")).map((grant) => grant.workspaceId)
14948
+ context.workspaceGrants.filter((grant) => hasPermission6(grant.permissions, "workspace:read")).map((grant) => grant.workspaceId)
14393
14949
  )
14394
14950
  ];
14395
14951
  if (readableWorkspaceIds.length > 0) {
@@ -14663,6 +15219,7 @@ import {
14663
15219
  } from "@opengeni/core";
14664
15220
  import { workflowIdForSession as workflowIdForSession2 } from "@opengeni/core";
14665
15221
  var API_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
15222
+ var API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
14666
15223
  function createApp(deps) {
14667
15224
  const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
14668
15225
  const objectStorage = deps.objectStorage === void 0 ? createObjectStorage(deps.settings) : deps.objectStorage;
@@ -14716,6 +15273,13 @@ function createApp(deps) {
14716
15273
  resumeBoxById
14717
15274
  };
14718
15275
  const app = new Hono();
15276
+ const correlationIds = /* @__PURE__ */ new WeakMap();
15277
+ app.use("*", async (c, next) => {
15278
+ const correlationId = boundedCorrelationId(c.req.header(OPENGENI_CORRELATION_HEADER)) ?? crypto.randomUUID();
15279
+ correlationIds.set(c.req.raw, correlationId);
15280
+ c.header(OPENGENI_CORRELATION_HEADER, correlationId);
15281
+ await next();
15282
+ });
14719
15283
  app.use(
14720
15284
  "*",
14721
15285
  cors({
@@ -14726,9 +15290,10 @@ function createApp(deps) {
14726
15290
  "Content-Type",
14727
15291
  "X-OpenGeni-Access-Key",
14728
15292
  "X-OpenGeni-Api-Contract",
15293
+ "X-OpenGeni-Correlation-Id",
14729
15294
  "X-OpenGeni-Subject"
14730
15295
  ],
14731
- exposeHeaders: ["X-OpenGeni-Api-Contract"],
15296
+ exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
14732
15297
  origin: (origin) => {
14733
15298
  if (!origin) {
14734
15299
  return null;
@@ -14747,6 +15312,7 @@ function createApp(deps) {
14747
15312
  app.use("*", async (c, next) => {
14748
15313
  const url = new URL(c.req.url);
14749
15314
  const route = routeLabel(url.pathname);
15315
+ const correlationId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
14750
15316
  const start = performance.now();
14751
15317
  const span = observability.startSpan(`HTTP ${c.req.method} ${route}`, {
14752
15318
  "http.request.method": c.req.method,
@@ -14775,10 +15341,12 @@ function createApp(deps) {
14775
15341
  status,
14776
15342
  durationMs: Math.round(durationSeconds * 1e3),
14777
15343
  traceId: span.traceId,
14778
- spanId: span.spanId
15344
+ spanId: span.spanId,
15345
+ correlationId
14779
15346
  });
14780
15347
  } catch (error) {
14781
15348
  const status = httpStatusForError(error);
15349
+ const errorCode = errorCodeForStatus(status);
14782
15350
  const durationSeconds = (performance.now() - start) / 1e3;
14783
15351
  observability.recordHttpRequest({
14784
15352
  method: c.req.method,
@@ -14786,6 +15354,11 @@ function createApp(deps) {
14786
15354
  status,
14787
15355
  durationSeconds
14788
15356
  });
15357
+ observability.incrementCounter({
15358
+ name: "opengeni_http_errors_total",
15359
+ help: "Total OpenGeni HTTP request failures by bounded route, status, and stable code.",
15360
+ labels: { route, status: String(status), code: errorCode }
15361
+ });
14789
15362
  span.end({
14790
15363
  attributes: {
14791
15364
  "http.response.status_code": status,
@@ -14800,7 +15373,9 @@ function createApp(deps) {
14800
15373
  durationMs: Math.round(durationSeconds * 1e3),
14801
15374
  traceId: span.traceId,
14802
15375
  spanId: span.spanId,
14803
- error: error instanceof Error ? error.message : String(error)
15376
+ correlationId,
15377
+ errorCode,
15378
+ errorClass: error instanceof Error ? error.name : "NonErrorThrown"
14804
15379
  });
14805
15380
  throw error;
14806
15381
  }
@@ -14957,11 +15532,46 @@ function createApp(deps) {
14957
15532
  registerSessionRoutes(app, routeDeps);
14958
15533
  registerScheduledTaskRoutes(app, routeDeps);
14959
15534
  registerCodexRoutes(app, routeDeps);
15535
+ app.notFound((c) => {
15536
+ if (!new URL(c.req.url).pathname.startsWith("/v1/")) return c.text("Not Found", 404);
15537
+ const requestId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
15538
+ return c.json(
15539
+ ErrorEnvelope.parse({
15540
+ error: {
15541
+ status: 404,
15542
+ code: "not_found",
15543
+ message: "Resource not found.",
15544
+ retryable: false,
15545
+ requestId
15546
+ }
15547
+ }),
15548
+ 404
15549
+ );
15550
+ });
15551
+ app.onError((error, c) => {
15552
+ const status = httpStatusForError(error);
15553
+ const code = errorCodeForStatus(status);
15554
+ const requestId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
15555
+ c.header(OPENGENI_CORRELATION_HEADER, requestId);
15556
+ if (new URL(c.req.url).pathname.startsWith("/v1/")) {
15557
+ c.header(OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION);
15558
+ }
15559
+ const envelope = ErrorEnvelope.parse({
15560
+ error: {
15561
+ status,
15562
+ code,
15563
+ message: publicErrorMessage(error, status),
15564
+ retryable: retryableHttpStatus(status),
15565
+ requestId
15566
+ }
15567
+ });
15568
+ return c.json(envelope, status);
15569
+ });
14960
15570
  return app;
14961
15571
  }
14962
15572
  async function requireMcpAccessGrant(c, deps, workspaceId) {
14963
15573
  const grant = await requireAccessGrant17(c, deps, workspaceId);
14964
- if (hasPermission5(grant.permissions, "workspace:read")) {
15574
+ if (hasPermission7(grant.permissions, "workspace:read")) {
14965
15575
  return grant;
14966
15576
  }
14967
15577
  if (isToolspaceGrant(deps.settings, grant)) {
@@ -15005,6 +15615,45 @@ function httpStatusForError(error) {
15005
15615
  }
15006
15616
  return 500;
15007
15617
  }
15618
+ function errorCodeForStatus(status) {
15619
+ if (status === 401) return "unauthenticated";
15620
+ if (status === 403) return "forbidden";
15621
+ if (status === 404) return "not_found";
15622
+ if (status === 409) return "conflict";
15623
+ if (status === 413 || status === 422 || status === 400) return "validation_failed";
15624
+ if (status === 429) return "limit_exceeded";
15625
+ if (status === 502 || status === 503 || status === 504) return "upstream_unavailable";
15626
+ return "internal_error";
15627
+ }
15628
+ function retryableHttpStatus(status) {
15629
+ return status === 408 || status === 425 || status === 429 || status >= 500;
15630
+ }
15631
+ function publicErrorMessage(error, status) {
15632
+ if (status === 502 || status === 503 || status === 504) {
15633
+ return "OpenGeni is temporarily unavailable \u2014 retry.";
15634
+ }
15635
+ if (status >= 500) {
15636
+ return "OpenGeni could not complete the request.";
15637
+ }
15638
+ if (error instanceof HTTPException24) {
15639
+ return boundedPublicMessage(error.message) ?? "Request failed.";
15640
+ }
15641
+ if (error instanceof McpPayloadTooLargeError2) {
15642
+ return "Request payload is too large.";
15643
+ }
15644
+ return "Request failed.";
15645
+ }
15646
+ function boundedPublicMessage(value) {
15647
+ const normalized = value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
15648
+ if (!normalized) return null;
15649
+ const bytes = new TextEncoder().encode(normalized);
15650
+ if (bytes.byteLength <= API_PUBLIC_ERROR_MESSAGE_MAX_BYTES) return normalized;
15651
+ return new TextDecoder().decode(bytes.slice(0, API_PUBLIC_ERROR_MESSAGE_MAX_BYTES)).trim();
15652
+ }
15653
+ function boundedCorrelationId(value) {
15654
+ if (!value || value.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(value)) return null;
15655
+ return value;
15656
+ }
15008
15657
  function readinessChecks(deps) {
15009
15658
  return {
15010
15659
  db: deps.readinessChecks?.db ?? (async () => {
@@ -15443,6 +16092,7 @@ export {
15443
16092
  createApp,
15444
16093
  allowedCorsOrigin,
15445
16094
  httpStatusForError,
16095
+ errorCodeForStatus,
15446
16096
  routeLabel,
15447
16097
  isApiContractProtectedMutation,
15448
16098
  mergeResourceRefs,
@@ -15456,4 +16106,4 @@ export {
15456
16106
  withDefaultEnabledCapabilityMcpTools,
15457
16107
  workflowIdForSession2 as workflowIdForSession
15458
16108
  };
15459
- //# sourceMappingURL=chunk-7PQKPKW5.js.map
16109
+ //# sourceMappingURL=chunk-DQWFAIPE.js.map