@opengeni/api-router 0.11.2 → 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;
@@ -5103,8 +5235,8 @@ import {
5103
5235
  upsertCodexSubscriptionCredential,
5104
5236
  withCodexCapacityMutation
5105
5237
  } from "@opengeni/db";
5106
- import { createSignedState, readSignedState } from "@opengeni/github";
5107
- 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";
5108
5240
  import { HTTPException as HTTPException5 } from "hono/http-exception";
5109
5241
  import * as z from "zod/v4";
5110
5242
 
@@ -5490,7 +5622,7 @@ function registerCodexRoutes(app, deps) {
5490
5622
  message: error instanceof CodexDeviceError ? error.message : "failed to start Codex device login"
5491
5623
  });
5492
5624
  }
5493
- const state = createSignedState(githubStateSecret, {
5625
+ const state = createSignedState2(githubStateSecret, {
5494
5626
  workspaceId,
5495
5627
  deviceAuthId: start.deviceAuthId,
5496
5628
  userCode: start.userCode
@@ -5877,7 +6009,7 @@ function registerCodexRoutes(app, deps) {
5877
6009
  const grant = await requireAccessGrant2(c, deps, workspaceId, "workspace:read");
5878
6010
  const human = await managedCookieHuman(c, deps);
5879
6011
  const accounts = await listCodexAccountStatuses(db, workspaceId);
5880
- 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, {
5881
6013
  accountId: grant.accountId,
5882
6014
  workspaceId,
5883
6015
  subjectId: human.subjectId
@@ -5892,7 +6024,7 @@ function registerCodexRoutes(app, deps) {
5892
6024
  const account = queue.shift();
5893
6025
  if (!account) return;
5894
6026
  const canResumeRedemption = Boolean(
5895
- 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")
5896
6028
  );
5897
6029
  const canRedeem = canResumeRedemption && account.status === "active";
5898
6030
  overview[account.id] = await fetchCodexAccountOverview(
@@ -5928,7 +6060,7 @@ function registerCodexRoutes(app, deps) {
5928
6060
  await Promise.all(
5929
6061
  accounts.filter((account) => overview[account.id] == null).map(async (account) => {
5930
6062
  const canResumeRedemption = Boolean(
5931
- 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")
5932
6064
  );
5933
6065
  const fallback = await fetchCodexAccountOverview(
5934
6066
  deps,
@@ -6275,7 +6407,7 @@ import {
6275
6407
  storeIntegrationOAuthClient,
6276
6408
  updateConnection
6277
6409
  } from "@opengeni/db";
6278
- import { createSignedState as createSignedState2, readSignedState as readSignedState2 } from "@opengeni/github";
6410
+ import { createSignedState as createSignedState3, readSignedState as readSignedState2 } from "@opengeni/github";
6279
6411
  import {
6280
6412
  DestinationPolicyError,
6281
6413
  OAUTH_MAX_RESPONSE_BYTES,
@@ -6347,7 +6479,7 @@ async function startMcpOAuth(deps, context) {
6347
6479
  context.payload.oauthClient
6348
6480
  );
6349
6481
  const key = requireEnvironmentEncryption(settings);
6350
- const state = createSignedState2(requireIntegrationsStateSecret(settings), {
6482
+ const state = createSignedState3(requireIntegrationsStateSecret(settings), {
6351
6483
  accountId: context.accountId,
6352
6484
  workspaceId: context.workspaceId,
6353
6485
  subjectId: context.subjectId,
@@ -7543,6 +7675,7 @@ function encryptCredentialBundle(key, credential) {
7543
7675
  // src/routes/documents.ts
7544
7676
  import {
7545
7677
  AddDocumentRequest,
7678
+ CreateKnowledgeDropRequest,
7546
7679
  CreateKnowledgeMemoryRequest,
7547
7680
  CreateDocumentBaseRequest,
7548
7681
  Document,
@@ -7550,11 +7683,14 @@ import {
7550
7683
  DocumentSearchRequest,
7551
7684
  KnowledgeMemory,
7552
7685
  KnowledgeMemorySearchRequest,
7686
+ MoveDocumentRequest,
7553
7687
  UpdateKnowledgeMemoryRequest,
7554
7688
  WorkspaceMemorySearchRequest,
7555
7689
  WorkspaceMemorySearchResponse
7556
7690
  } from "@opengeni/contracts";
7557
7691
  import {
7692
+ completeFileUpload as completeFileUpload2,
7693
+ createFileUpload as createFileUpload2,
7558
7694
  createKnowledgeMemory as createKnowledgeMemory2,
7559
7695
  getKnowledgeMemory,
7560
7696
  listKnowledgeMemories as listKnowledgeMemories2,
@@ -7566,17 +7702,19 @@ import {
7566
7702
  addDocumentToBase,
7567
7703
  createDocumentBase,
7568
7704
  deleteDocumentFromBase,
7705
+ ensureDefaultBase,
7569
7706
  getDocument,
7570
7707
  getDocumentBase,
7571
7708
  listDocumentBases as listDocumentBases2,
7572
7709
  listDocuments,
7710
+ moveDocumentToBase,
7573
7711
  queueDocumentForReindex,
7574
7712
  searchDocuments as searchDocuments2
7575
7713
  } from "@opengeni/documents";
7576
7714
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7577
- import { HTTPException as HTTPException9 } from "hono/http-exception";
7578
- import { requireAccessGrant as requireAccessGrant4 } from "@opengeni/core";
7579
- 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";
7580
7718
 
7581
7719
  // src/mcp/documents.ts
7582
7720
  import {
@@ -7619,6 +7757,10 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7619
7757
  name: "opengeni-documents",
7620
7758
  version: "1.0.0"
7621
7759
  });
7760
+ const agentAccess = {
7761
+ agentOnly: true,
7762
+ ...options.viewerSubjectId ? { viewerSubjectId: options.viewerSubjectId } : {}
7763
+ };
7622
7764
  server.registerTool(
7623
7765
  "list_document_bases",
7624
7766
  {
@@ -7635,7 +7777,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7635
7777
  description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
7636
7778
  inputSchema: SearchInputSchema
7637
7779
  },
7638
- async (input) => searchContent(db, workspaceId, documentServices, input)
7780
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess)
7639
7781
  );
7640
7782
  server.registerTool(
7641
7783
  "knowledge_search",
@@ -7643,7 +7785,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7643
7785
  description: "Search company knowledge sources with optional base, source-kind, ACL, and retrieval-mode filters.",
7644
7786
  inputSchema: SearchInputSchema
7645
7787
  },
7646
- async (input) => searchContent(db, workspaceId, documentServices, input)
7788
+ async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess)
7647
7789
  );
7648
7790
  server.registerTool(
7649
7791
  "fetch_document_chunk",
@@ -7654,7 +7796,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7654
7796
  }
7655
7797
  },
7656
7798
  async ({ chunkId }) => {
7657
- const found = await getDocumentChunk(db, workspaceId, chunkId);
7799
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
7658
7800
  return {
7659
7801
  content: [
7660
7802
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }
@@ -7672,7 +7814,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7672
7814
  }
7673
7815
  },
7674
7816
  async ({ chunkId }) => {
7675
- const found = await getDocumentChunk(db, workspaceId, chunkId);
7817
+ const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
7676
7818
  return {
7677
7819
  content: [
7678
7820
  { type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }
@@ -7749,7 +7891,7 @@ function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, o
7749
7891
  );
7750
7892
  return server;
7751
7893
  }
7752
- async function searchContent(db, workspaceId, documentServices, input) {
7894
+ async function searchContent(db, workspaceId, documentServices, input, access) {
7753
7895
  return {
7754
7896
  content: [
7755
7897
  {
@@ -7764,7 +7906,8 @@ async function searchContent(db, workspaceId, documentServices, input) {
7764
7906
  ...input.limit ? { limit: input.limit } : {},
7765
7907
  ...input.mode ? { mode: input.mode } : {},
7766
7908
  ...input.sourceKinds ? { sourceKinds: input.sourceKinds } : {},
7767
- ...input.aclTags ? { aclTags: input.aclTags } : {}
7909
+ ...input.aclTags ? { aclTags: input.aclTags } : {},
7910
+ access
7768
7911
  },
7769
7912
  documentServices
7770
7913
  )
@@ -7774,1773 +7917,1933 @@ async function searchContent(db, workspaceId, documentServices, input) {
7774
7917
  };
7775
7918
  }
7776
7919
 
7777
- // src/routes/documents.ts
7778
- function registerDocumentRoutes(app, deps) {
7779
- const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
7780
- app.post("/v1/workspaces/:workspaceId/document-bases", async (c) => {
7781
- const workspaceId = c.req.param("workspaceId");
7782
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7783
- const payload = CreateDocumentBaseRequest.parse(await c.req.json());
7784
- return c.json(
7785
- DocumentBase.parse(
7786
- await createDocumentBase(db, { ...payload, accountId: grant.accountId, workspaceId })
7787
- ),
7788
- 201
7789
- );
7790
- });
7791
- app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
7792
- const workspaceId = c.req.param("workspaceId");
7793
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7794
- return c.json(
7795
- (await listDocumentBases2(db, workspaceId)).map((base) => DocumentBase.parse(base))
7796
- );
7797
- });
7798
- app.get("/v1/workspaces/:workspaceId/document-bases/:baseId", async (c) => {
7799
- const workspaceId = c.req.param("workspaceId");
7800
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7801
- const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
7802
- if (!base) {
7803
- throw new HTTPException9(404, { message: "document base not found" });
7804
- }
7805
- return c.json(DocumentBase.parse(base));
7806
- });
7807
- 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) => {
7808
7948
  const workspaceId = c.req.param("workspaceId");
7809
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7949
+ const grant = await requireAccessGrant4(c, deps, workspaceId, "files:upload");
7810
7950
  if (!objectStorage) {
7811
7951
  throw new HTTPException9(503, { message: "object storage is not configured" });
7812
7952
  }
7953
+ const payload = CreateFileUploadRequest.parse(await c.req.json());
7813
7954
  await requireLimit2(deps, {
7814
7955
  accountId: grant.accountId,
7815
7956
  workspaceId,
7816
- action: "document:index",
7817
- quantity: 0
7957
+ action: "file:upload",
7958
+ quantity: payload.sizeBytes
7818
7959
  });
7819
- const payload = AddDocumentRequest.parse(await c.req.json());
7820
- try {
7821
- const document = await addDocumentToBase(db, {
7822
- ...payload,
7823
- accountId: grant.accountId,
7824
- workspaceId,
7825
- 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`
7826
7963
  });
7827
- const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
7828
- const indexed = document.status === "ready" ? document : await documentIndexer.indexDocument({
7829
- accountId: grant.accountId,
7830
- workspaceId,
7831
- documentId: document.id
7832
- }) ?? document;
7833
- if (indexed.status === "ready") {
7834
- await recordWorkspaceUsage2(deps, {
7835
- accountId: grant.accountId,
7836
- workspaceId,
7837
- subjectId: grant.subjectId,
7838
- eventType: "document.indexed",
7839
- quantity: indexed.chunkCount,
7840
- unit: "chunk",
7841
- sourceResourceType: "document",
7842
- sourceResourceId: indexed.id,
7843
- idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
7844
- });
7845
- }
7846
- return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
7847
- } catch (error) {
7848
- throw documentHttpException(error);
7849
7964
  }
7850
- });
7851
- app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
7852
- const workspaceId = c.req.param("workspaceId");
7853
- 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
+ });
7854
7986
  return c.json(
7855
- (await listDocuments(db, workspaceId, c.req.param("baseId"))).map(
7856
- (document) => Document.parse(document)
7857
- )
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
7858
7996
  );
7859
7997
  });
7860
- app.delete(
7861
- "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId",
7862
- async (c) => {
7863
- const workspaceId = c.req.param("workspaceId");
7864
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7865
- try {
7866
- await deleteDocumentFromBase(db, {
7867
- accountId: grant.accountId,
7868
- workspaceId,
7869
- baseId: c.req.param("baseId"),
7870
- documentId: c.req.param("documentId")
7871
- });
7872
- return c.body(null, 204);
7873
- } catch (error) {
7874
- throw documentHttpException(error);
7875
- }
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" });
7876
8003
  }
7877
- );
7878
- app.post(
7879
- "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex",
7880
- async (c) => {
7881
- const workspaceId = c.req.param("workspaceId");
7882
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
7883
- if (!objectStorage) {
7884
- throw new HTTPException9(503, { message: "object storage is not configured" });
7885
- }
7886
- 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, {
7887
8010
  accountId: grant.accountId,
7888
8011
  workspaceId,
7889
- action: "document:index",
7890
- 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}`
7891
8019
  });
8020
+ };
8021
+ const completeAndRecordUsage = async () => {
8022
+ let file2;
7892
8023
  try {
7893
- const document = await getDocument(db, workspaceId, c.req.param("documentId"));
7894
- if (!document) {
7895
- throw new HTTPException9(404, { message: "document not found" });
7896
- }
7897
- if (document.status !== "failed") {
7898
- throw new HTTPException9(422, { message: "only failed documents can be retried" });
7899
- }
7900
- if (document.baseId !== c.req.param("baseId")) {
7901
- throw new HTTPException9(404, { message: "document not found" });
7902
- }
7903
- const queued = await queueDocumentForReindex(db, workspaceId, document.id);
7904
- const indexed = await documentIndexer.indexDocument({
7905
- accountId: grant.accountId,
7906
- workspaceId,
7907
- documentId: document.id
7908
- }) ?? queued;
7909
- if (indexed.status === "ready") {
7910
- await recordWorkspaceUsage2(deps, {
7911
- accountId: grant.accountId,
7912
- workspaceId,
7913
- subjectId: grant.subjectId,
7914
- eventType: "document.indexed",
7915
- quantity: indexed.chunkCount,
7916
- unit: "chunk",
7917
- sourceResourceType: "document",
7918
- sourceResourceId: indexed.id,
7919
- idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`
7920
- });
7921
- }
7922
- return c.json(Document.parse(indexed));
8024
+ file2 = await completeFileUpload(db, workspaceId, upload.id);
7923
8025
  } catch (error) {
7924
- 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 {
7925
8034
  throw error;
7926
8035
  }
7927
- throw documentHttpException(error);
7928
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 }));
7929
8084
  }
7930
- );
7931
- app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
7932
- const workspaceId = c.req.param("workspaceId");
7933
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7934
- const payload = DocumentSearchRequest.parse(await c.req.json());
7935
- const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
7936
- if (!base) {
7937
- throw new HTTPException9(404, { message: "document base not found" });
7938
- }
7939
- return c.json({
7940
- results: await searchDocuments2(
7941
- db,
7942
- {
7943
- workspaceId,
7944
- baseIds: [base.id],
7945
- query: payload.query,
7946
- limit: payload.limit,
7947
- mode: payload.mode,
7948
- sourceKinds: payload.sourceKinds,
7949
- aclTags: payload.aclTags
7950
- },
7951
- getDocumentServices()
7952
- )
7953
- });
7954
- });
7955
- app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
7956
- const workspaceId = c.req.param("workspaceId");
7957
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7958
- const payload = DocumentSearchRequest.parse(await c.req.json());
7959
- return c.json({
7960
- results: await searchDocuments2(
7961
- db,
7962
- {
7963
- workspaceId,
7964
- query: payload.query,
7965
- baseIds: payload.baseIds,
7966
- limit: payload.limit,
7967
- mode: payload.mode,
7968
- sourceKinds: payload.sourceKinds,
7969
- aclTags: payload.aclTags
7970
- },
7971
- getDocumentServices()
7972
- )
7973
- });
7974
- });
7975
- app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
7976
- const workspaceId = c.req.param("workspaceId");
7977
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7978
- const parsed = KnowledgeMemorySearchRequest.safeParse({
7979
- query: c.req.query("query") || void 0,
7980
- status: c.req.query("status") || void 0,
7981
- kind: c.req.query("kind") || void 0,
7982
- scope: c.req.query("scope") || void 0,
7983
- 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
+ });
7984
8098
  });
7985
- if (!parsed.success) {
7986
- 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 }));
7987
8106
  }
7988
- return c.json(
7989
- (await listKnowledgeMemories2(db, workspaceId, parsed.data)).map(
7990
- (memory) => KnowledgeMemory.parse(memory)
7991
- )
7992
- );
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 }));
7993
8125
  });
7994
- app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8126
+ app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
7995
8127
  const workspaceId = c.req.param("workspaceId");
7996
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
7997
- const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
7998
- if (!memory) {
7999
- 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" });
8000
8132
  }
8001
- return c.json(KnowledgeMemory.parse(memory));
8133
+ return c.json(FileAsset.parse(file));
8002
8134
  });
8003
- app.post("/v1/workspaces/:workspaceId/knowledge/memories/search", async (c) => {
8135
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
8004
8136
  const workspaceId = c.req.param("workspaceId");
8005
- await requireAccessGrant4(c, deps, workspaceId, "documents:search");
8006
- const parsed = WorkspaceMemorySearchRequest.safeParse(await c.req.json());
8007
- if (!parsed.success) {
8008
- 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);
8009
8142
  }
8010
- const results = await searchWorkspaceMemories2(
8011
- db,
8012
- workspaceId,
8013
- parsed.data,
8014
- getDocumentServices().embedder
8015
- );
8016
- return c.json(
8017
- WorkspaceMemorySearchResponse.parse({
8018
- results: results.map((result) => ({
8019
- ...result,
8020
- memory: KnowledgeMemory.parse(result.memory)
8021
- }))
8022
- })
8023
- );
8143
+ return c.json(retainedArtifactMetadata(artifact));
8024
8144
  });
8025
- app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8145
+ app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
8026
8146
  const workspaceId = c.req.param("workspaceId");
8027
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
8028
- const parsedBody = CreateKnowledgeMemoryRequest.safeParse(await c.req.json());
8029
- if (!parsedBody.success) {
8030
- 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);
8031
8152
  }
8032
- const payload = parsedBody.data;
8033
- if (payload.status === "active") {
8034
- try {
8035
- const result = await saveWorkspaceMemory2(
8036
- db,
8037
- {
8038
- accountId: grant.accountId,
8039
- workspaceId,
8040
- text: payload.text,
8041
- kind: payload.kind,
8042
- confidence: payload.confidence,
8043
- pinned: payload.pinned,
8044
- replacesId: payload.replacesId ?? null,
8045
- metadata: payload.metadata,
8046
- origin: "human"
8047
- },
8048
- getDocumentServices().embedder
8049
- );
8050
- return c.json(KnowledgeMemory.parse(result.memory), 201);
8051
- } catch (error) {
8052
- throw documentHttpException(error);
8053
- }
8153
+ const metadata = retainedArtifactMetadata(artifact);
8154
+ if (!metadata.available) {
8155
+ return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
8054
8156
  }
8055
- return c.json(
8056
- KnowledgeMemory.parse(
8057
- await createKnowledgeMemory2(db, {
8058
- ...payload,
8059
- accountId: grant.accountId,
8060
- workspaceId
8061
- })
8062
- ),
8063
- 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
8064
8165
  );
8065
- });
8066
- app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
8067
- const workspaceId = c.req.param("workspaceId");
8068
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
8069
- const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
8070
- const reviewedBy = payload.reviewedBy ?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : void 0);
8071
- try {
8166
+ if (range.kind === "invalid") {
8072
8167
  return c.json(
8073
- KnowledgeMemory.parse(
8074
- await updateKnowledgeMemory(
8075
- db,
8076
- workspaceId,
8077
- c.req.param("memoryId"),
8078
- {
8079
- ...payload,
8080
- ...reviewedBy ? { reviewedBy } : {}
8081
- },
8082
- getDocumentServices().embedder
8083
- )
8084
- )
8168
+ {
8169
+ message: "invalid retained artifact byte range",
8170
+ reason: range.reason,
8171
+ maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES
8172
+ },
8173
+ 400
8085
8174
  );
8086
- } catch (error) {
8087
- throw documentHttpException(error);
8088
8175
  }
8089
- });
8090
- app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
8091
- const workspaceId = c.req.param("workspaceId");
8092
- const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:search");
8093
- const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : void 0;
8094
- const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
8095
- const server = buildDocumentsMcpServer(
8096
- db,
8097
- grant.accountId,
8098
- workspaceId,
8099
- getDocumentServices(),
8100
- { createdBySessionId: sessionId }
8101
- );
8102
- await server.connect(transport);
8103
- return await transport.handleRequest(c.req.raw);
8104
- });
8105
- }
8106
- function documentHttpException(error) {
8107
- const message = error instanceof Error ? error.message : String(error);
8108
- if (message.includes("not found")) {
8109
- return new HTTPException9(404, { message });
8110
- }
8111
- if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
8112
- return new HTTPException9(422, { message });
8113
- }
8114
- 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")) {
8115
- return new HTTPException9(400, { message });
8116
- }
8117
- 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
+ });
8118
8234
  }
8119
-
8120
- // src/routes/enrollments.ts
8121
- import {
8122
- DeviceEnrollmentApproveRequest,
8123
- DeviceEnrollmentApproveResponse,
8124
- DeviceEnrollmentDenyRequest,
8125
- DeviceEnrollmentDenyResponse,
8126
- DeviceEnrollmentLookupRequest,
8127
- DeviceEnrollmentLookupResponse,
8128
- DeviceEnrollmentPollRequest,
8129
- DeviceEnrollmentStartRequest,
8130
- EnrollmentSummary,
8131
- EnrollTokenExchangeRequest,
8132
- EnrollTokenExchangeResponse,
8133
- ListEnrollmentsResponse,
8134
- MintEnrollTokenRequest,
8135
- MintEnrollTokenResponse,
8136
- RevokeEnrollmentResponse
8137
- } from "@opengeni/contracts";
8138
- import { getWorkspace, listEnrollments, revokeEnrollment } from "@opengeni/db";
8139
- import { HTTPException as HTTPException10 } from "hono/http-exception";
8140
- import { requireAccessGrant as requireAccessGrant5 } from "@opengeni/core";
8141
-
8142
- // src/sandbox/enrollment.ts
8143
- import { randomBytes as randomBytes2 } from "crypto";
8144
- import {
8145
- resolveEnrollmentSigningSecret,
8146
- resolveRelayTokenSecret
8147
- } from "@opengeni/config";
8148
- import {
8149
- DeviceEnrollmentState,
8150
- signEnrollmentBearer,
8151
- signEnrollToken,
8152
- signRelayToken,
8153
- verifyEnrollToken
8154
- } from "@opengeni/contracts";
8155
- import {
8156
- approveDeviceEnrollmentRequest,
8157
- consumeDeviceEnrollmentRequest,
8158
- createDeviceEnrollmentRequest,
8159
- denyDeviceEnrollmentRequest,
8160
- finalizeEnrollmentByToken,
8161
- getDeviceEnrollmentRequestByDeviceCode,
8162
- getEnrollment,
8163
- getPendingDeviceEnrollmentRequestByUserCode,
8164
- getPendingDeviceEnrollmentRequestByUserCodeGlobal
8165
- } from "@opengeni/db";
8166
- import { relayDialBaseFromSettings } from "@opengeni/core";
8167
- var DEVICE_CODE_TTL_SECONDS = 600;
8168
- var DEVICE_POLL_INTERVAL_SECONDS = 5;
8169
- var ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
8170
- var RELAY_TOKEN_TTL_SECONDS = 30 * 24 * 3600;
8171
- var ENROLL_TOKEN_TTL_SECONDS = 3600;
8172
- function mintDeviceCode() {
8173
- 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";
8174
8239
  }
8175
- var USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
8176
- function mintUserCode() {
8177
- const bytes = randomBytes2(8);
8178
- let out = "";
8179
- for (let i = 0; i < 8; i += 1) {
8180
- 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" });
8181
8247
  }
8182
- return `${out.slice(0, 4)}-${out.slice(4, 8)}`;
8248
+ return parsed.data;
8183
8249
  }
8184
- async function startDeviceEnrollment(services, input) {
8185
- const { db } = services;
8186
- const expiresAt = new Date(Date.now() + DEVICE_CODE_TTL_SECONDS * 1e3);
8187
- let request;
8188
- let lastError;
8189
- for (let attempt = 0; attempt < 5 && !request; attempt += 1) {
8190
- const deviceCode = mintDeviceCode();
8191
- const userCode = mintUserCode();
8192
- try {
8193
- request = await createDeviceEnrollmentRequest(db, {
8194
- accountId: input.accountId,
8195
- workspaceId: input.workspaceId,
8196
- deviceCode,
8197
- userCode,
8198
- pubkey: input.publicKey,
8199
- os: input.os,
8200
- arch: input.arch,
8201
- machineName: input.machineName ?? null,
8202
- requestedExposure: "whole-machine",
8203
- canOfferDisplay: input.canOfferDisplay,
8204
- requestsScreenControl: input.requestsScreenControl,
8205
- expiresAt
8206
- });
8207
- } catch (error) {
8208
- lastError = error;
8209
- }
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");
8210
8259
  }
8211
- if (!request) {
8212
- 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");
8213
8262
  }
8214
- const base = input.verificationOrigin.replace(/\/$/, "");
8215
- const verificationUri = `${base}/device`;
8216
- const verificationUriComplete = `${verificationUri}?user_code=${encodeURIComponent(request.userCode)}`;
8217
- return {
8218
- deviceCode: request.deviceCode,
8219
- userCode: request.userCode,
8220
- verificationUri,
8221
- verificationUriComplete,
8222
- intervalSeconds: DEVICE_POLL_INTERVAL_SECONDS,
8223
- expiresInSeconds: DEVICE_CODE_TTL_SECONDS
8224
- };
8225
- }
8226
- async function approveDeviceEnrollment(services, input) {
8227
- const { db } = services;
8228
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8229
- db,
8230
- input.workspaceId,
8231
- input.userCode
8232
- );
8233
- if (!pending) {
8234
- return null;
8263
+ if (file.status === "failed" || uploadStatus === "failed" || uploadStatus === "cleanup_pending") {
8264
+ return retainedArtifactUnavailable(file.id, "failed");
8235
8265
  }
8236
- const sandboxName = (pending.machineName?.trim() || `${pending.os} machine`).slice(0, 256);
8237
- const result = await approveDeviceEnrollmentRequest(db, {
8238
- accountId: input.accountId,
8239
- workspaceId: input.workspaceId,
8240
- requestId: pending.id,
8241
- allowScreenControl: input.allowScreenControl,
8242
- approvedBySubjectId: input.approvedBySubjectId,
8243
- approvedBySubjectLabel: input.approvedBySubjectLabel ?? null,
8244
- sandboxName
8245
- });
8246
- if (!result.approved || !result.enrollment || !result.sandbox) {
8247
- return null;
8266
+ if (file.status === "pending_upload" || uploadStatus === "pending") {
8267
+ return retainedArtifactUnavailable(file.id, "pending");
8248
8268
  }
8249
- return {
8250
- enrollmentId: result.enrollment.id,
8251
- sandboxId: result.sandbox.id,
8252
- allowScreenControl: result.enrollment.allowScreenControl
8253
- };
8254
- }
8255
- async function lookupDeviceEnrollment(services, input) {
8256
- const { db } = services;
8257
- return await getPendingDeviceEnrollmentRequestByUserCodeGlobal(db, input.userCode);
8258
- }
8259
- function toLookupResponse(record3) {
8260
- return {
8261
- workspaceId: record3.workspaceId,
8262
- userCode: record3.userCode,
8263
- machine: {
8264
- machineName: record3.machineName,
8265
- os: record3.os,
8266
- arch: record3.arch,
8267
- canOfferDisplay: record3.canOfferDisplay,
8268
- requestsScreenControl: record3.requestsScreenControl
8269
- },
8270
- expiresAt: record3.expiresAt
8271
- };
8269
+ return retainedArtifactUnavailable(file.id, "unsupported");
8272
8270
  }
8273
- async function denyDeviceEnrollment(services, input) {
8274
- const { db } = services;
8275
- const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8276
- db,
8277
- input.workspaceId,
8278
- input.userCode
8279
- );
8280
- if (!pending) {
8281
- 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;
8282
8285
  }
8283
- return await denyDeviceEnrollmentRequest(db, {
8284
- accountId: input.accountId,
8285
- workspaceId: input.workspaceId,
8286
- requestId: pending.id
8287
- });
8288
- }
8289
- async function mintEnrollToken(services, input) {
8290
- const { settings } = services;
8291
- const secret = resolveEnrollmentSigningSecret(settings);
8292
- if (!secret) {
8293
- return null;
8294
- }
8295
- const nowSeconds = Math.floor(Date.now() / 1e3);
8296
- const exp = nowSeconds + ENROLL_TOKEN_TTL_SECONDS;
8297
- const token = await signEnrollToken(secret, {
8298
- typ: "enroll",
8299
- workspaceId: input.workspaceId,
8300
- accountId: input.accountId,
8301
- allowScreenControl: input.allowScreenControl,
8302
- iat: nowSeconds,
8303
- exp
8304
- });
8305
- return {
8306
- token,
8307
- expiresAt: new Date(exp * 1e3).toISOString(),
8308
- expiresInSeconds: ENROLL_TOKEN_TTL_SECONDS
8309
- };
8310
8286
  }
8311
- async function exchangeEnrollToken(services, input) {
8312
- const { db, settings } = services;
8313
- const secret = resolveEnrollmentSigningSecret(settings);
8314
- if (!secret) {
8315
- return { ok: false, reason: "disabled" };
8316
- }
8317
- const claims = await verifyEnrollToken(secret, input.token);
8318
- if (!claims) {
8319
- return { ok: false, reason: "invalid" };
8320
- }
8321
- const sandboxName = (input.machineName?.trim() || `${input.os} machine`).slice(0, 256);
8322
- const { enrollment } = await finalizeEnrollmentByToken(db, {
8323
- accountId: claims.accountId,
8324
- workspaceId: claims.workspaceId,
8325
- pubkey: input.publicKey,
8326
- hasDisplay: input.canOfferDisplay,
8327
- // The token's allowScreenControl is the AUTHORITATIVE consent (NOT the agent's
8328
- // requestsScreenControl) — it was baked in at mint by the authorizing user.
8329
- allowScreenControl: claims.allowScreenControl,
8330
- os: input.os,
8331
- arch: input.arch,
8332
- 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
+ );
8333
8301
  });
8334
- const credentials = await buildEnrollmentCredentials(services, {
8335
- secret,
8336
- workspaceId: claims.workspaceId,
8337
- agentId: enrollment.id,
8338
- 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
+ );
8339
8308
  });
8340
- return { ok: true, credentials };
8341
- }
8342
- async function pollDeviceEnrollment(services, input) {
8343
- const { db, settings } = services;
8344
- const request = await getDeviceEnrollmentRequestByDeviceCode(db, input.deviceCode);
8345
- if (!request) {
8346
- return { state: "expired" };
8347
- }
8348
- if (request.status === "denied") {
8349
- return { state: "denied" };
8350
- }
8351
- if (request.status === "pending") {
8352
- if (new Date(request.expiresAt).getTime() <= Date.now()) {
8353
- 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" });
8354
8315
  }
8355
- return { state: "pending" };
8356
- }
8357
- if (!request.enrollmentId) {
8358
- return { state: "expired" };
8359
- }
8360
- const secret = resolveEnrollmentSigningSecret(settings);
8361
- if (!secret) {
8362
- return { state: "disabled" };
8363
- }
8364
- const enrollment = await getEnrollment(db, request.workspaceId, request.enrollmentId);
8365
- if (!enrollment || enrollment.status !== "active") {
8366
- return { state: "denied" };
8367
- }
8368
- const credentials = await buildEnrollmentCredentials(services, {
8369
- secret,
8370
- workspaceId: request.workspaceId,
8371
- agentId: enrollment.id,
8372
- consentedScreenControl: enrollment.allowScreenControl
8316
+ return c.json(DocumentBase.parse(base));
8373
8317
  });
8374
- if (request.status === "approved") {
8375
- await consumeDeviceEnrollmentRequest(db, {
8376
- accountId: request.accountId,
8377
- workspaceId: request.workspaceId,
8378
- 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
8379
8329
  });
8380
- }
8381
- return { state: DeviceEnrollmentState.enum.authorized, credentials };
8382
- }
8383
- async function buildEnrollmentCredentials(services, input) {
8384
- const { settings } = services;
8385
- const subjectPrefix = `agent.${input.workspaceId}.${input.agentId}`;
8386
- const nowSeconds = Math.floor(Date.now() / 1e3);
8387
- const exp = nowSeconds + ENROLLMENT_BEARER_TTL_SECONDS;
8388
- const bearer = await signEnrollmentBearer(input.secret, {
8389
- workspaceId: input.workspaceId,
8390
- agentId: input.agentId,
8391
- enrollmentId: input.agentId,
8392
- subjectPrefix,
8393
- exp
8394
- });
8395
- const natsUrls = settings.selfhostedNatsUrl ? [settings.selfhostedNatsUrl] : [];
8396
- const relayTokenSecret = resolveRelayTokenSecret(settings);
8397
- const relayToken = relayTokenSecret ? await signRelayToken(relayTokenSecret, {
8398
- workspaceId: input.workspaceId,
8399
- agentId: input.agentId,
8400
- exp: nowSeconds + RELAY_TOKEN_TTL_SECONDS
8401
- }) : "";
8402
- return {
8403
- agentId: input.agentId,
8404
- workspaceId: input.workspaceId,
8405
- bearer,
8406
- subjectPrefix,
8407
- natsUrls,
8408
- // Hand the agent the canonical `/stream` dial base, NOT the raw configured URL.
8409
- // The agent's relay producer appends only its routing query and assumes the base
8410
- // already carries the relay's `/stream` route; a path-less base 400s the dial and
8411
- // makes the terminal/desktop streams unreachable.
8412
- relayUrl: relayDialBaseFromSettings(settings),
8413
- relayToken,
8414
- // M-AUTH closes the placeholder: there is NO per-machine NATS Account creds
8415
- // file. The agent presents the BEARER as the NATS connect auth-token; the
8416
- // server's auth-callout responder validates it and mints a workspace-scoped
8417
- // user JWT. We echo the bearer here so a consumer reading this (vestigial) field
8418
- // as the connect credential still works — the value IS the bearer.
8419
- natsAccountCreds: bearer,
8420
- updatePublicKey: settings.agentUpdatePublicKey ?? "",
8421
- consentedWholeMachine: true,
8422
- consentedScreenControl: input.consentedScreenControl
8423
- };
8424
- }
8425
-
8426
- // src/routes/enrollments.ts
8427
- function registerEnrollmentRoutes(app, deps) {
8428
- const { settings, db } = deps;
8429
- function assertSelfhostedEnabled() {
8430
- if (!settings.sandboxSelfhostedEnabled) {
8431
- throw new HTTPException10(404, {
8432
- 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 }
8433
8339
  });
8434
- }
8435
- }
8436
- const startLimiter = new TokenBucket({ capacity: 10, refillPerSecond: 0.5 });
8437
- const pollLimiter = new TokenBucket({ capacity: 60, refillPerSecond: 2 });
8438
- const lookupLimiter = new TokenBucket({ capacity: 30, refillPerSecond: 1 });
8439
- const exchangeLimiter = new TokenBucket({ capacity: 20, refillPerSecond: 0.5 });
8440
- function rateLimit(c, limiter) {
8441
- const ip = clientIp(c);
8442
- if (!limiter.take(ip)) {
8443
- throw new HTTPException10(429, { message: "too many requests; slow down" });
8444
- }
8445
- }
8446
- app.post("/v1/enrollments/device/start", async (c) => {
8447
- assertSelfhostedEnabled();
8448
- rateLimit(c, startLimiter);
8449
- const parsed = DeviceEnrollmentStartRequest.safeParse(await c.req.json().catch(() => null));
8450
- if (!parsed.success) {
8451
- throw new HTTPException10(400, { message: "invalid device-start request" });
8452
- }
8453
- const body = parsed.data;
8454
- const workspace = await getWorkspace(db, body.workspaceId);
8455
- if (!workspace) {
8456
- throw new HTTPException10(404, { message: "workspace not found" });
8457
- }
8458
- const result = await startDeviceEnrollment(
8459
- { db, settings },
8460
- {
8461
- accountId: workspace.accountId,
8462
- workspaceId: workspace.id,
8463
- publicKey: body.publicKey,
8464
- os: body.os,
8465
- arch: body.arch,
8466
- machineName: body.machineName ?? null,
8467
- canOfferDisplay: body.canOfferDisplay,
8468
- requestsScreenControl: body.requestsScreenControl,
8469
- // The approve page is served at the SAME origin as this request.
8470
- 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
+ });
8471
8358
  }
8472
- );
8473
- return c.json(result, 201);
8474
- });
8475
- app.post("/v1/enrollments/device/poll", async (c) => {
8476
- assertSelfhostedEnabled();
8477
- rateLimit(c, pollLimiter);
8478
- const parsed = DeviceEnrollmentPollRequest.safeParse(await c.req.json().catch(() => null));
8479
- if (!parsed.success) {
8480
- 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);
8481
8362
  }
8482
- const result = await pollDeviceEnrollment(
8483
- { db, settings },
8484
- { 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))
8485
8371
  );
8486
- return c.json(result, 200);
8487
8372
  });
8488
- app.post("/v1/enrollments/device/lookup", async (c) => {
8489
- assertSelfhostedEnabled();
8490
- rateLimit(c, lookupLimiter);
8491
- const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
8492
- if (!parsed.success) {
8493
- throw new HTTPException10(400, { message: "invalid device-lookup request" });
8494
- }
8495
- const record3 = await lookupDeviceEnrollment(
8496
- { db, settings },
8497
- { userCode: parsed.data.userCode }
8498
- );
8499
- if (!record3) {
8500
- throw new HTTPException10(404, { message: "no pending enrollment for that code" });
8501
- }
8502
- try {
8503
- await requireAccessGrant5(c, deps, record3.workspaceId, "enrollments:read");
8504
- } catch {
8505
- throw new HTTPException10(404, { message: "no pending enrollment for that code" });
8506
- }
8507
- return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record3)), 200);
8508
- });
8509
- app.post("/v1/enrollments/token/exchange", async (c) => {
8510
- assertSelfhostedEnabled();
8511
- rateLimit(c, exchangeLimiter);
8512
- const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
8513
- if (!parsed.success) {
8514
- 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
+ }
8515
8393
  }
8516
- const body = parsed.data;
8517
- const result = await exchangeEnrollToken(
8518
- { db, settings },
8519
- {
8520
- token: body.token,
8521
- publicKey: body.publicKey,
8522
- os: body.os,
8523
- arch: body.arch,
8524
- machineName: body.machineName ?? null,
8525
- 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" });
8526
8402
  }
8527
- );
8528
- if (!result.ok) {
8529
- if (result.reason === "disabled") {
8530
- 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);
8531
8449
  }
8532
- throw new HTTPException10(401, { message: "invalid or expired enroll token" });
8533
8450
  }
8534
- 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
+ });
8535
8476
  });
8536
- app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
8477
+ app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
8537
8478
  const workspaceId = c.req.param("workspaceId");
8538
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8539
- assertSelfhostedEnabled();
8540
- const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
8541
- if (!parsed.success) {
8542
- 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" });
8543
8503
  }
8544
- const body = parsed.data;
8545
- const approved = await approveDeviceEnrollment(
8546
- { db, settings },
8547
- {
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 } : {},
8548
8573
  accountId: grant.accountId,
8549
8574
  workspaceId,
8550
- userCode: body.userCode,
8551
- allowScreenControl: body.allowScreenControl,
8552
- // The LOUD consent record: WHO consented (the authenticated subject + label).
8553
- approvedBySubjectId: grant.subjectId,
8554
- 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
+ });
8555
8598
  }
8556
- );
8557
- if (!approved) {
8558
- 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);
8559
8605
  }
8560
- return c.json(
8561
- DeviceEnrollmentApproveResponse.parse({
8562
- approved: true,
8563
- enrollmentId: approved.enrollmentId,
8564
- sandboxId: approved.sandboxId,
8565
- allowScreenControl: approved.allowScreenControl
8566
- }),
8567
- 201
8568
- );
8569
8606
  });
8570
- app.post("/v1/workspaces/:workspaceId/enrollments/device/deny", async (c) => {
8607
+ app.post("/v1/workspaces/:workspaceId/documents/:documentId/move", async (c) => {
8571
8608
  const workspaceId = c.req.param("workspaceId");
8572
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8573
- assertSelfhostedEnabled();
8574
- const parsed = DeviceEnrollmentDenyRequest.safeParse(await c.req.json().catch(() => null));
8575
- if (!parsed.success) {
8576
- throw new HTTPException10(400, { message: "invalid device-deny request" });
8577
- }
8578
- const result = await denyDeviceEnrollment(
8579
- { db, settings },
8580
- {
8581
- accountId: grant.accountId,
8582
- workspaceId,
8583
- 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" });
8584
8617
  }
8585
- );
8586
- 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
+ }
8587
8635
  });
8588
- app.post("/v1/workspaces/:workspaceId/enrollments/token", async (c) => {
8636
+ app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8589
8637
  const workspaceId = c.req.param("workspaceId");
8590
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8591
- assertSelfhostedEnabled();
8592
- 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
+ });
8593
8646
  if (!parsed.success) {
8594
- throw new HTTPException10(400, { message: "invalid mint-enroll-token request" });
8647
+ throw new HTTPException10(400, { message: "invalid knowledge memory query parameters" });
8595
8648
  }
8596
- const minted = await mintEnrollToken(
8597
- { db, settings },
8598
- {
8599
- accountId: grant.accountId,
8600
- workspaceId,
8601
- allowScreenControl: parsed.data.allowScreenControl
8602
- }
8649
+ return c.json(
8650
+ (await listKnowledgeMemories2(db, workspaceId, parsed.data)).map(
8651
+ (memory) => KnowledgeMemory.parse(memory)
8652
+ )
8603
8653
  );
8604
- if (!minted) {
8605
- 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" });
8606
8661
  }
8607
- return c.json(MintEnrollTokenResponse.parse(minted), 201);
8662
+ return c.json(KnowledgeMemory.parse(memory));
8608
8663
  });
8609
- app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
8664
+ app.post("/v1/workspaces/:workspaceId/knowledge/memories/search", async (c) => {
8610
8665
  const workspaceId = c.req.param("workspaceId");
8611
- await requireAccessGrant5(c, deps, workspaceId, "enrollments:read");
8612
- assertSelfhostedEnabled();
8613
- const statusFilter = c.req.query("status");
8614
- 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(
8615
8672
  db,
8616
8673
  workspaceId,
8617
- statusFilter === "active" ? { status: "active" } : {}
8674
+ parsed.data,
8675
+ getDocumentServices().embedder
8618
8676
  );
8619
8677
  return c.json(
8620
- ListEnrollmentsResponse.parse({
8621
- enrollments: rows.map(
8622
- (row) => EnrollmentSummary.parse({
8623
- id: row.id,
8624
- pubkey: row.pubkey,
8625
- exposure: row.exposure,
8626
- hasDisplay: row.hasDisplay,
8627
- desktopUnavailableReason: row.desktopUnavailableReason,
8628
- allowScreenControl: row.allowScreenControl,
8629
- status: row.status,
8630
- os: row.os,
8631
- arch: row.arch,
8632
- lastSeenAt: row.lastSeenAt,
8633
- createdAt: row.createdAt,
8634
- revokedAt: row.revokedAt
8635
- })
8636
- )
8678
+ WorkspaceMemorySearchResponse.parse({
8679
+ results: results.map((result) => ({
8680
+ ...result,
8681
+ memory: KnowledgeMemory.parse(result.memory)
8682
+ }))
8637
8683
  })
8638
8684
  );
8639
8685
  });
8640
- app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
8686
+ app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
8641
8687
  const workspaceId = c.req.param("workspaceId");
8642
- const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
8643
- assertSelfhostedEnabled();
8644
- const result = await revokeEnrollment(db, {
8645
- 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,
8646
8759
  workspaceId,
8647
- enrollmentId: c.req.param("enrollmentId")
8648
- });
8649
- 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);
8650
8765
  });
8651
8766
  }
8652
- function clientIp(c) {
8653
- const xff = c.req.header("x-forwarded-for");
8654
- if (xff) {
8655
- const first = xff.split(",")[0]?.trim();
8656
- if (first) return first;
8657
- }
8658
- 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`;
8659
8770
  }
8660
- var TokenBucket = class {
8661
- capacity;
8662
- refillPerSecond;
8663
- buckets = /* @__PURE__ */ new Map();
8664
- constructor(options) {
8665
- this.capacity = options.capacity;
8666
- 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 });
8667
8775
  }
8668
- take(key, now = Date.now()) {
8669
- const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
8670
- const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1e3);
8671
- bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
8672
- bucket.updatedAt = now;
8673
- if (bucket.tokens >= this.capacity && this.buckets.size > 1e4) {
8674
- this.buckets.delete(key);
8675
- }
8676
- if (bucket.tokens < 1) {
8677
- this.buckets.set(key, bucket);
8678
- return false;
8679
- }
8680
- bucket.tokens -= 1;
8681
- this.buckets.set(key, bucket);
8682
- return true;
8776
+ if (message.includes("already exists")) {
8777
+ return new HTTPException10(409, { message });
8683
8778
  }
8684
- };
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
+ }
8685
8790
 
8686
- // src/routes/machines.ts
8791
+ // src/routes/enrollments.ts
8687
8792
  import {
8688
- MachineMetricsSeriesResponse,
8689
- MachinesResponse,
8690
- SwapActiveSandboxRequest,
8691
- 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
8692
8808
  } from "@opengeni/contracts";
8693
- import { getEnrollment as getEnrollment2, readMachineMetricsSeries, requireSession as requireSession3 } from "@opengeni/db";
8809
+ import { getWorkspace, listEnrollments, revokeEnrollment } from "@opengeni/db";
8694
8810
  import { HTTPException as HTTPException11 } from "hono/http-exception";
8695
8811
  import { requireAccessGrant as requireAccessGrant6 } from "@opengeni/core";
8696
- import { buildFleetContextForSession as buildFleetContextForSession2, swapActiveSandbox as swapActiveSandbox2 } from "@opengeni/core";
8697
8812
 
8698
- // src/sandbox/machines.ts
8813
+ // src/sandbox/enrollment.ts
8814
+ import { randomBytes as randomBytes2 } from "crypto";
8699
8815
  import {
8700
- getSession as getSession3,
8701
- listEnrollments as listEnrollments2,
8702
- listSandboxes,
8703
- readActiveSandbox,
8704
- readLease as readLease2,
8705
- readMachineMetricsLatestForWorkspace
8706
- } from "@opengeni/db";
8707
- import { MachineView, MetricSample } from "@opengeni/contracts";
8816
+ resolveEnrollmentSigningSecret,
8817
+ resolveRelayTokenSecret
8818
+ } from "@opengeni/config";
8708
8819
  import {
8709
- NatsControlRpc as NatsControlRpc2,
8710
- selfhostedLiveness,
8711
- SelfhostedSession
8712
- } from "@opengeni/runtime/sandbox";
8713
- import { relayConfigFromSettings as relayConfigFromSettings2 } from "@opengeni/core";
8714
- var PROBE_TIMEOUT_MS = 5e3;
8715
- function controlRpc2(bus) {
8716
- return new NatsControlRpc2(async () => {
8717
- if (!bus) {
8718
- return null;
8719
- }
8720
- return bus.getRequestConnection();
8721
- });
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");
8722
8845
  }
8723
- function metricRowToSample(row) {
8724
- return MetricSample.parse({
8725
- cpuPct: row.cpuPercent ?? 0,
8726
- load1: row.load1 ?? 0,
8727
- load5: row.load5 ?? 0,
8728
- load15: row.load15 ?? 0,
8729
- memUsedBytes: row.memUsedBytes ?? 0,
8730
- memTotalBytes: row.memTotalBytes ?? 0,
8731
- diskUsedBytes: row.diskUsedBytes ?? 0,
8732
- diskTotalBytes: row.diskTotalBytes ?? 0,
8733
- gpuUtilPct: row.gpuUtilPercent,
8734
- gpuMemBytes: row.gpuMemUsedBytes,
8735
- runQueue: row.contention ?? 0,
8736
- sampledAt: row.sampledAt
8737
- });
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)}`;
8738
8854
  }
8739
- async function probeEnrollment(services, workspaceId, enrollment) {
8740
- const { settings, bus } = services;
8741
- let probeResponded = false;
8742
- if (enrollment.status === "active") {
8743
- const session = new SelfhostedSession({
8744
- workspaceId,
8745
- agentId: enrollment.id,
8746
- controlRpc: controlRpc2(bus),
8747
- relay: relayConfigFromSettings2(settings),
8748
- timeoutMs: PROBE_TIMEOUT_MS
8749
- });
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();
8750
8863
  try {
8751
- probeResponded = await session.ping();
8752
- } catch {
8753
- 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;
8754
8880
  }
8755
8881
  }
8756
- const derived = selfhostedLiveness({
8757
- enrollment: {
8758
- status: enrollment.status,
8759
- exposure: enrollment.exposure,
8760
- allowScreenControl: enrollment.allowScreenControl,
8761
- hasDisplay: enrollment.hasDisplay,
8762
- lastSeenAt: enrollment.lastSeenAt,
8763
- wentOfflineAt: enrollment.wentOfflineAt,
8764
- wentOfflineReason: enrollment.wentOfflineReason
8765
- },
8766
- probeResponded
8767
- });
8768
- return { state: derived.state, consented: derived.consented, hasDisplay: derived.hasDisplay };
8769
- }
8770
- function machineStateFor(liveness, hasDisplay) {
8771
- if (liveness !== "online") {
8772
- return liveness;
8773
- }
8774
- if (!hasDisplay) {
8775
- return "display_unavailable";
8882
+ if (!request) {
8883
+ throw lastError instanceof Error ? lastError : new Error("failed to start device enrollment");
8776
8884
  }
8777
- 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
+ };
8778
8896
  }
8779
- async function listMachines(services, input) {
8897
+ async function approveDeviceEnrollment(services, input) {
8780
8898
  const { db } = services;
8781
- const { workspaceId } = input;
8782
- let activeSandboxId = null;
8783
- let activeEpoch = 0;
8784
- let session = null;
8785
- if (input.sessionId) {
8786
- session = await getSession3(db, workspaceId, input.sessionId);
8787
- if (session) {
8788
- const pointer = await readActiveSandbox(db, workspaceId, input.sessionId);
8789
- activeSandboxId = pointer?.activeSandboxId ?? null;
8790
- activeEpoch = pointer?.activeEpoch ?? 0;
8791
- }
8899
+ const pending = await getPendingDeviceEnrollmentRequestByUserCode(
8900
+ db,
8901
+ input.workspaceId,
8902
+ input.userCode
8903
+ );
8904
+ if (!pending) {
8905
+ return null;
8792
8906
  }
8793
- const machines = [];
8794
- if (session) {
8795
- const groupActive = activeSandboxId === null;
8796
- const groupLease = await readLease2(db, workspaceId, session.sandboxGroupId);
8797
- machines.push(
8798
- MachineView.parse({
8799
- sandboxId: session.sandboxGroupId,
8800
- enrollmentId: null,
8801
- name: "session sandbox",
8802
- kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
8803
- state: "online",
8804
- active: groupActive,
8805
- isSessionGroup: true,
8806
- workspaceGeneration: groupLease?.workspaceGeneration ?? null,
8807
- archiveGeneration: groupLease?.archiveGeneration ?? null,
8808
- archiveComplete: groupLease?.archiveComplete ?? false,
8809
- // The Modal group box is a cloud Linux box; its precise OS/arch is not
8810
- // surfaced as a metric, so the dashboard shows the canonical linux/x86_64.
8811
- os: "linux",
8812
- arch: "x86_64",
8813
- hasDisplay: false,
8814
- desktopUnavailableReason: null,
8815
- allowScreenControl: false,
8816
- sharedSessionCount: 1,
8817
- lastSeenAt: null,
8818
- metrics: null
8819
- })
8820
- );
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;
8821
8919
  }
8822
- const [sandboxes, enrollments, metricsByEnrollment] = await Promise.all([
8823
- listSandboxes(db, workspaceId),
8824
- listEnrollments2(db, workspaceId),
8825
- readMachineMetricsLatestForWorkspace(db, workspaceId)
8826
- ]);
8827
- const enrollmentById = new Map(enrollments.map((e) => [e.id, e]));
8828
- const machineViews = await Promise.all(
8829
- sandboxes.map(async (sandbox) => {
8830
- if (sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
8831
- return null;
8832
- }
8833
- const enrollment = enrollmentById.get(sandbox.enrollmentId) ?? null;
8834
- if (!enrollment) {
8835
- return null;
8836
- }
8837
- const [probe, lease] = await Promise.all([
8838
- probeEnrollment(services, workspaceId, enrollment),
8839
- readLease2(db, workspaceId, sandbox.id)
8840
- ]);
8841
- const state = machineStateFor(probe.state, probe.hasDisplay);
8842
- const sharedSessionCount = lease?.refcount ?? 0;
8843
- const metricsRow = metricsByEnrollment.get(enrollment.id) ?? null;
8844
- return MachineView.parse({
8845
- sandboxId: sandbox.id,
8846
- enrollmentId: enrollment.id,
8847
- name: sandbox.name,
8848
- kind: "selfhosted",
8849
- state,
8850
- active: activeSandboxId === sandbox.id,
8851
- isSessionGroup: false,
8852
- workspaceGeneration: null,
8853
- archiveGeneration: null,
8854
- archiveComplete: false,
8855
- os: enrollment.os,
8856
- arch: enrollment.arch,
8857
- hasDisplay: enrollment.hasDisplay,
8858
- desktopUnavailableReason: enrollment.desktopUnavailableReason,
8859
- allowScreenControl: enrollment.allowScreenControl,
8860
- sharedSessionCount,
8861
- lastSeenAt: enrollment.lastSeenAt,
8862
- metrics: metricsRow ? metricRowToSample(metricsRow) : null
8863
- });
8864
- })
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
8865
8950
  );
8866
- machines.push(...machineViews.filter((machine) => machine !== null));
8867
- 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
+ });
8868
8959
  }
8869
-
8870
- // src/routes/machines.ts
8871
- var SERIES_WINDOWS_MS = {
8872
- "15m": 15 * 6e4,
8873
- "1h": 60 * 6e4,
8874
- "6h": 6 * 60 * 6e4,
8875
- "24h": 24 * 60 * 6e4
8876
- };
8877
- var DEFAULT_SERIES_WINDOW_MS = SERIES_WINDOWS_MS["1h"];
8878
- function registerMachineRoutes(app, deps) {
8879
- const { settings, db, bus } = deps;
8880
- function assertSelfhostedEnabled() {
8881
- if (!settings.sandboxSelfhostedEnabled) {
8882
- throw new HTTPException11(404, {
8883
- message: "selfhosted machines are not enabled for this deployment"
8884
- });
8885
- }
8960
+ async function mintEnrollToken(services, input) {
8961
+ const { settings } = services;
8962
+ const secret = resolveEnrollmentSigningSecret(settings);
8963
+ if (!secret) {
8964
+ return null;
8886
8965
  }
8887
- app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
8888
- const workspaceId = c.req.param("workspaceId");
8889
- await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
8890
- assertSelfhostedEnabled();
8891
- const sessionId = c.req.query("sessionId") ?? null;
8892
- const response = await listMachines({ db, settings, bus }, { workspaceId, sessionId });
8893
- 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
8894
8975
  });
8895
- app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
8896
- const workspaceId = c.req.param("workspaceId");
8897
- await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
8898
- assertSelfhostedEnabled();
8899
- const enrollmentId = c.req.param("enrollmentId");
8900
- const enrollment = await getEnrollment2(db, workspaceId, enrollmentId);
8901
- if (!enrollment) {
8902
- throw new HTTPException11(404, { message: "machine not found in this workspace" });
8903
- }
8904
- const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
8905
- const since = new Date(Date.now() - windowMs);
8906
- const rows = await readMachineMetricsSeries(db, { workspaceId, enrollmentId, since });
8907
- return c.json(
8908
- MachineMetricsSeriesResponse.parse({
8909
- samples: rows.map(metricRowToSample)
8910
- })
8911
- );
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
8912
9004
  });
8913
- app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/active-sandbox", async (c) => {
8914
- const workspaceId = c.req.param("workspaceId");
8915
- const grant = await requireAccessGrant6(c, deps, workspaceId, "sessions:control");
8916
- assertSelfhostedEnabled();
8917
- const sessionId = c.req.param("sessionId");
8918
- const body = SwapActiveSandboxRequest.parse(await c.req.json());
8919
- const ctx = await buildFleetContextForSession2(deps, {
8920
- accountId: grant.accountId,
8921
- workspaceId,
8922
- 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
8923
9050
  });
8924
- const result = await swapActiveSandbox2(
8925
- {
8926
- db,
8927
- settings,
8928
- bus,
8929
- ensureSessionGroupReady: async (fleetCtx) => {
8930
- const session = await requireSession3(db, fleetCtx.workspaceId, fleetCtx.sessionId);
8931
- return await ensureSessionGroupReady(
8932
- { db, settings, bus },
8933
- {
8934
- accountId: fleetCtx.accountId,
8935
- workspaceId: fleetCtx.workspaceId,
8936
- session
8937
- }
8938
- );
8939
- }
8940
- },
8941
- ctx,
8942
- body.target
8943
- );
8944
- 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
8945
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
+ };
8946
9095
  }
8947
9096
 
8948
- // src/routes/environments.ts
8949
- import {
8950
- CreateVariableSetRequest,
8951
- SetVariableSetVariableRequest,
8952
- UpdateVariableSetRequest,
8953
- VariableSetVariableName as VariableSetVariableName2
8954
- } from "@opengeni/contracts";
8955
- import {
8956
- countActiveSessionsUsingVariableSet,
8957
- countScheduledTasksUsingVariableSet,
8958
- countVariableSets as countVariableSets2,
8959
- createVariableSet as createVariableSet2,
8960
- deleteVariableSet,
8961
- deleteVariableSetVariable,
8962
- encryptVariableSetValue as encryptVariableSetValue2,
8963
- getVariableSetByName as getVariableSetByName2,
8964
- listVariableSets as listVariableSets2,
8965
- setVariableSetVariable as setVariableSetVariable2,
8966
- updateVariableSet
8967
- } from "@opengeni/db";
8968
- import { HTTPException as HTTPException12 } from "hono/http-exception";
8969
- import { requireAccessGrant as requireAccessGrant7 } from "@opengeni/core";
8970
- import {
8971
- assertAllowedVariableSetVariableName as assertAllowedVariableSetVariableName2,
8972
- MAX_ENVIRONMENTS_PER_WORKSPACE as MAX_ENVIRONMENTS_PER_WORKSPACE2,
8973
- MAX_VARIABLES_PER_ENVIRONMENT as MAX_VARIABLES_PER_ENVIRONMENT2,
8974
- recordVariableSetAuditEvent as recordVariableSetAuditEvent2,
8975
- requireVariableSetEncryption as requireVariableSetEncryption2,
8976
- requireVariableSetForApi
8977
- } from "@opengeni/core";
8978
- function registerVariableSetRoutes(app, deps) {
9097
+ // src/routes/enrollments.ts
9098
+ function registerEnrollmentRoutes(app, deps) {
8979
9099
  const { settings, db } = deps;
8980
- const prefixes = [
8981
- "/v1/workspaces/:workspaceId/variable-sets",
8982
- "/v1/workspaces/:workspaceId/environments"
8983
- ];
8984
- for (const prefix of prefixes) {
8985
- app.get(`${prefix}`, async (c) => {
8986
- const workspaceId = c.req.param("workspaceId");
8987
- await requireAccessGrant7(c, deps, workspaceId, "variable-sets:use");
8988
- return c.json(await listVariableSets2(db, workspaceId));
8989
- });
8990
- app.post(`${prefix}`, async (c) => {
8991
- const workspaceId = c.req.param("workspaceId");
8992
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
8993
- const key = requireVariableSetEncryption2(settings);
8994
- const payload = CreateVariableSetRequest.parse(await c.req.json());
8995
- const name = trimmedVariableSetName(payload.name);
8996
- if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT2) {
8997
- throw new HTTPException12(422, {
8998
- message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
8999
- });
9000
- }
9001
- const variableNames = /* @__PURE__ */ new Set();
9002
- for (const variable of payload.variables) {
9003
- assertAllowedVariableSetVariableName2(variable.name);
9004
- if (variableNames.has(variable.name)) {
9005
- throw new HTTPException12(422, {
9006
- message: `duplicate variable set variable name: ${variable.name}`
9007
- });
9008
- }
9009
- variableNames.add(variable.name);
9010
- }
9011
- if (await countVariableSets2(db, workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE2) {
9012
- throw new HTTPException12(422, {
9013
- message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE2} variable sets`
9014
- });
9015
- }
9016
- if (await getVariableSetByName2(db, workspaceId, name)) {
9017
- throw new HTTPException12(409, { message: `variable set name is already in use: ${name}` });
9018
- }
9019
- const created = await createVariableSet2(db, {
9020
- accountId: grant.accountId,
9021
- workspaceId,
9022
- name,
9023
- description: payload.description ?? null,
9024
- variables: payload.variables.map((variable) => ({
9025
- name: variable.name,
9026
- valueEncrypted: encryptVariableSetValue2(key, variable.value)
9027
- }))
9028
- });
9029
- await recordVariableSetAuditEvent2(db, {
9030
- grant,
9031
- action: "variable_set.created",
9032
- 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"
9033
9104
  });
9034
- return c.json(created, 201);
9035
- });
9036
- app.get(`${prefix}/:variableSetId`, async (c) => {
9037
- const workspaceId = c.req.param("workspaceId");
9038
- await requireAccessGrant7(c, deps, workspaceId, "variable-sets:use");
9039
- return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")));
9040
- });
9041
- app.patch(`${prefix}/:variableSetId`, async (c) => {
9042
- const workspaceId = c.req.param("workspaceId");
9043
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9044
- const variableSet = await requireVariableSetForApi(
9045
- db,
9046
- workspaceId,
9047
- c.req.param("variableSetId")
9048
- );
9049
- const payload = UpdateVariableSetRequest.parse(await c.req.json());
9050
- const name = payload.name !== void 0 ? trimmedVariableSetName(payload.name) : void 0;
9051
- if (name !== void 0 && name !== variableSet.name) {
9052
- const existing = await getVariableSetByName2(db, workspaceId, name);
9053
- if (existing && existing.id !== variableSet.id) {
9054
- throw new HTTPException12(409, { message: `variable set name is already in use: ${name}` });
9055
- }
9056
- }
9057
- const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
9058
- ...name !== void 0 ? { name } : {},
9059
- ...payload.description !== void 0 ? { description: payload.description } : {}
9060
- });
9061
- await recordVariableSetAuditEvent2(db, {
9062
- grant,
9063
- action: "variable_set.updated",
9064
- variableSetId: variableSet.id
9065
- });
9066
- return c.json(updated);
9067
- });
9068
- app.delete(`${prefix}/:variableSetId`, async (c) => {
9069
- const workspaceId = c.req.param("workspaceId");
9070
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9071
- const variableSet = await requireVariableSetForApi(
9072
- db,
9073
- workspaceId,
9074
- c.req.param("variableSetId")
9075
- );
9076
- const attachedTasks = await countScheduledTasksUsingVariableSet(
9077
- db,
9078
- workspaceId,
9079
- variableSet.id
9080
- );
9081
- if (attachedTasks > 0) {
9082
- throw new HTTPException12(409, {
9083
- message: `variable set is attached to ${attachedTasks} scheduled task(s); detach first`
9084
- });
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
9085
9142
  }
9086
- const activeSessions = await countActiveSessionsUsingVariableSet(
9087
- 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,
9088
9220
  workspaceId,
9089
- variableSet.id
9090
- );
9091
- if (activeSessions > 0) {
9092
- throw new HTTPException12(409, {
9093
- message: `variable set is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first`
9094
- });
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
9095
9226
  }
9096
- await deleteVariableSet(db, workspaceId, variableSet.id);
9097
- await recordVariableSetAuditEvent2(db, {
9098
- grant,
9099
- action: "variable_set.deleted",
9100
- variableSetId: variableSet.id
9101
- });
9102
- return c.json({ ok: true });
9103
- });
9104
- app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9105
- const workspaceId = c.req.param("workspaceId");
9106
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9107
- const key = requireVariableSetEncryption2(settings);
9108
- const name = parseVariableName(c.req.param("name"));
9109
- const variableSet = await requireVariableSetForApi(
9110
- 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,
9111
9253
  workspaceId,
9112
- c.req.param("variableSetId")
9113
- );
9114
- const payload = SetVariableSetVariableRequest.parse(await c.req.json());
9115
- const exists = variableSet.variables.some((variable) => variable.name === name);
9116
- if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT2) {
9117
- throw new HTTPException12(422, {
9118
- message: `a variable set supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables`
9119
- });
9254
+ userCode: parsed.data.userCode
9120
9255
  }
9121
- 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
+ {
9122
9270
  accountId: grant.accountId,
9123
9271
  workspaceId,
9124
- variableSetId: variableSet.id,
9125
- name,
9126
- valueEncrypted: encryptVariableSetValue2(key, payload.value)
9127
- });
9128
- await recordVariableSetAuditEvent2(db, {
9129
- grant,
9130
- action: "variable_set.variable.set",
9131
- variableSetId: variableSet.id,
9132
- variableName: name
9133
- });
9134
- 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")
9135
9319
  });
9136
- app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
9137
- const workspaceId = c.req.param("workspaceId");
9138
- const grant = await requireAccessGrant7(c, deps, workspaceId, "variable-sets:manage");
9139
- const name = parseVariableName(c.req.param("name"));
9140
- const variableSet = await requireVariableSetForApi(
9141
- db,
9142
- workspaceId,
9143
- c.req.param("variableSetId")
9144
- );
9145
- const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
9146
- if (!deleted) {
9147
- 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;
9148
9503
  }
9149
- await recordVariableSetAuditEvent2(db, {
9150
- grant,
9151
- action: "variable_set.variable.deleted",
9152
- variableSetId: variableSet.id,
9153
- 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
9154
9534
  });
9155
- return c.json({ ok: true });
9156
- });
9157
- }
9158
- }
9159
- var registerEnvironmentRoutes = registerVariableSetRoutes;
9160
- function parseVariableName(raw) {
9161
- const parsed = VariableSetVariableName2.safeParse(raw);
9162
- if (!parsed.success) {
9163
- throw new HTTPException12(422, {
9164
- message: "variable set variable names must match ^[A-Z][A-Z0-9_]*$"
9165
- });
9166
- }
9167
- assertAllowedVariableSetVariableName2(parsed.data);
9168
- return parsed.data;
9169
- }
9170
- function trimmedVariableSetName(name) {
9171
- const trimmed = name.trim();
9172
- if (!trimmed) {
9173
- throw new HTTPException12(422, { message: "variable set name is required" });
9174
- }
9175
- return trimmed;
9535
+ })
9536
+ );
9537
+ machines.push(...machineViews.filter((machine) => machine !== null));
9538
+ return { activeSandboxId, activeEpoch, machines };
9176
9539
  }
9177
9540
 
9178
- // src/routes/files.ts
9179
- import {
9180
- CompleteFileUploadResponse,
9181
- CreateFileUploadRequest,
9182
- CreateFileUploadResponse,
9183
- FileAsset,
9184
- FileDownloadUrlResponse,
9185
- RETAINED_OUTPUT_DEFAULT_PAGE_BYTES,
9186
- RETAINED_OUTPUT_MAX_PAGE_BYTES,
9187
- RetainedArtifactMetadataSchema,
9188
- retainedArtifactReferenceFromFile,
9189
- resolveRetainedOutputRange
9190
- } from "@opengeni/contracts";
9191
- import {
9192
- claimFileUploadCleanup,
9193
- completeFileUploadCleanup,
9194
- completeFileUpload,
9195
- createFileUpload,
9196
- getFileUpload,
9197
- getRetainedFileArtifact,
9198
- requireFile as requireFile2
9199
- } from "@opengeni/db";
9200
- import { HTTPException as HTTPException13 } from "hono/http-exception";
9201
- import { requireAccessGrant as requireAccessGrant8 } from "@opengeni/core";
9202
- import { recordWorkspaceUsage as recordWorkspaceUsage3, requireLimit as requireLimit3 } from "@opengeni/core";
9203
- function registerFileRoutes(app, deps) {
9204
- const { db, objectStorage } = deps;
9205
- app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {
9206
- const workspaceId = c.req.param("workspaceId");
9207
- const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
9208
- if (!objectStorage) {
9209
- throw new HTTPException13(503, { message: "object storage is not configured" });
9210
- }
9211
- const payload = CreateFileUploadRequest.parse(await c.req.json());
9212
- await requireLimit3(deps, {
9213
- accountId: grant.accountId,
9214
- workspaceId,
9215
- action: "file:upload",
9216
- quantity: payload.sizeBytes
9217
- });
9218
- if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
9219
- throw new HTTPException13(413, {
9220
- 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"
9221
9555
  });
9222
9556
  }
9223
- const fileId = crypto.randomUUID();
9224
- const safeFilename = sanitizeFilename(payload.filename);
9225
- const objectKey = `workspaces/${workspaceId}/files/${fileId}/original/${safeFilename}`;
9226
- const signed = await objectStorage.createPutUrl({
9227
- key: objectKey,
9228
- contentType: payload.contentType,
9229
- ...payload.sha256 ? { sha256: payload.sha256 } : {}
9230
- });
9231
- 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, {
9232
9591
  accountId: grant.accountId,
9233
9592
  workspaceId,
9234
- fileId,
9235
- filename: payload.filename,
9236
- safeFilename,
9237
- contentType: payload.contentType,
9238
- sizeBytes: payload.sizeBytes,
9239
- sha256: payload.sha256 ?? null,
9240
- bucket: objectStorage.bucket,
9241
- objectKey,
9242
- expiresAt: signed.expiresAt
9593
+ sessionId
9243
9594
  });
9244
- return c.json(
9245
- CreateFileUploadResponse.parse({
9246
- fileId: upload.file.id,
9247
- uploadId: upload.uploadId,
9248
- putUrl: signed.url,
9249
- requiredHeaders: signed.requiredHeaders,
9250
- expiresAt: upload.expiresAt,
9251
- maxSizeBytes: objectStorage.maxSinglePutSizeBytes
9252
- }),
9253
- 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
9254
9614
  );
9615
+ return c.json(SwapActiveSandboxResponse.parse(result));
9255
9616
  });
9256
- app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
9257
- const workspaceId = c.req.param("workspaceId");
9258
- const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
9259
- if (!objectStorage) {
9260
- throw new HTTPException13(503, { message: "object storage is not configured" });
9261
- }
9262
- const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
9263
- if (!upload) {
9264
- throw new HTTPException13(404, { message: "file upload not found" });
9265
- }
9266
- const recordUploadedFileUsage = async (file2) => {
9267
- await recordWorkspaceUsage3(deps, {
9268
- accountId: grant.accountId,
9269
- workspaceId,
9270
- subjectId: grant.subjectId,
9271
- eventType: "file.uploaded",
9272
- quantity: file2.sizeBytes,
9273
- unit: "byte",
9274
- sourceResourceType: "file",
9275
- sourceResourceId: file2.id,
9276
- idempotencyKey: `file.uploaded:${workspaceId}:${file2.id}`
9277
- });
9278
- };
9279
- const completeAndRecordUsage = async () => {
9280
- let file2;
9281
- try {
9282
- file2 = await completeFileUpload(db, workspaceId, upload.id);
9283
- } catch (error) {
9284
- const current = await getFileUpload(db, workspaceId, upload.id);
9285
- if (current?.status === "completed" && current.file.status === "ready") {
9286
- file2 = current.file;
9287
- } else if (current && current.status !== "pending") {
9288
- throw new HTTPException13(409, {
9289
- 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}`
9290
9678
  });
9291
- } else {
9292
- throw error;
9293
9679
  }
9680
+ variableNames.add(variable.name);
9294
9681
  }
9295
- await recordUploadedFileUsage(file2);
9296
- return file2;
9297
- };
9298
- const rejectAndCleanObject = async (status, message, terminalStatus) => {
9299
- const claim = await claimFileUploadCleanup(db, {
9300
- workspaceId,
9301
- uploadId: upload.id,
9302
- fileId: upload.file.id
9303
- });
9304
- if (claim.outcome === "completed") {
9305
- await recordUploadedFileUsage(claim.file);
9306
- return claim.file;
9307
- }
9308
- if (claim.outcome === "unavailable") {
9309
- throw new HTTPException13(409, {
9310
- 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`
9311
9685
  });
9312
9686
  }
9313
- try {
9314
- await objectStorage.deleteObject(upload.file.objectKey);
9315
- } catch (error) {
9316
- deps.observability?.warn(
9317
- "file upload rejection cleanup failed; claim remains reclaimable",
9318
- {
9319
- workspaceId,
9320
- fileId: upload.file.id,
9321
- uploadId: upload.id,
9322
- error: error instanceof Error ? error.message : String(error)
9323
- }
9324
- );
9325
- 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}` });
9326
9689
  }
9327
- const settled = await completeFileUploadCleanup(db, {
9690
+ const created = await createVariableSet2(db, {
9328
9691
  accountId: grant.accountId,
9329
9692
  workspaceId,
9330
- uploadId: upload.id,
9331
- fileId: upload.file.id,
9332
- 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
+ }))
9333
9699
  });
9334
- if (!settled) {
9335
- 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
+ }
9336
9727
  }
9337
- throw new HTTPException13(status, { message });
9338
- };
9339
- if (upload.status === "completed" && upload.file.status === "ready") {
9340
- const file2 = await completeAndRecordUsage();
9341
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9342
- }
9343
- if (upload.status !== "pending") {
9344
- throw new HTTPException13(409, {
9345
- 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 } : {}
9346
9731
  });
9347
- }
9348
- if (upload.expiresAt.getTime() < Date.now()) {
9349
- const file2 = await rejectAndCleanObject(409, "file upload has expired", "expired");
9350
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9351
- }
9352
- const head = await objectStorage.headFile(upload.file).catch((error) => {
9353
- throw new HTTPException13(409, {
9354
- 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
9355
9736
  });
9737
+ return c.json(updated);
9356
9738
  });
9357
- if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
9358
- const file2 = await rejectAndCleanObject(
9359
- 422,
9360
- "uploaded object size does not match file metadata",
9361
- "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")
9362
9746
  );
9363
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9364
- }
9365
- if (upload.file.contentType && head.ContentType && head.ContentType !== upload.file.contentType) {
9366
- const file2 = await rejectAndCleanObject(
9367
- 422,
9368
- "uploaded object content type does not match file metadata",
9369
- "failed"
9747
+ const attachedTasks = await countScheduledTasksUsingVariableSet(
9748
+ db,
9749
+ workspaceId,
9750
+ variableSet.id
9370
9751
  );
9371
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9372
- }
9373
- if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
9374
- const file2 = await rejectAndCleanObject(
9375
- 422,
9376
- "uploaded object checksum metadata does not match file metadata",
9377
- "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
9378
9761
  );
9379
- return c.json(CompleteFileUploadResponse.parse({ file: file2 }));
9380
- }
9381
- const file = await completeAndRecordUsage();
9382
- return c.json(CompleteFileUploadResponse.parse({ file }));
9383
- });
9384
- app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
9385
- const workspaceId = c.req.param("workspaceId");
9386
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9387
- const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
9388
- if (!file) {
9389
- throw new HTTPException13(404, { message: "file not found" });
9390
- }
9391
- return c.json(FileAsset.parse(file));
9392
- });
9393
- app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId", async (c) => {
9394
- const workspaceId = c.req.param("workspaceId");
9395
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9396
- const artifactId = retainedArtifactId(c.req.param("artifactId"));
9397
- const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
9398
- if (!artifact) {
9399
- return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
9400
- }
9401
- return c.json(retainedArtifactMetadata(artifact));
9402
- });
9403
- app.get("/v1/workspaces/:workspaceId/artifacts/:artifactId/content", async (c) => {
9404
- const workspaceId = c.req.param("workspaceId");
9405
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9406
- const artifactId = retainedArtifactId(c.req.param("artifactId"));
9407
- const artifact = await getRetainedFileArtifact(db, workspaceId, artifactId);
9408
- if (!artifact) {
9409
- return c.json(retainedArtifactUnavailable(artifactId, "deleted"), 404);
9410
- }
9411
- const metadata = retainedArtifactMetadata(artifact);
9412
- if (!metadata.available) {
9413
- return c.json(metadata, retainedArtifactUnavailableStatus(metadata.reason));
9414
- }
9415
- if (!objectStorage) {
9416
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 503);
9417
- }
9418
- const rangeHeader = c.req.header("range");
9419
- const range = resolveRetainedOutputRange(
9420
- rangeHeader,
9421
- metadata.originalBytes,
9422
- rangeHeader ? RETAINED_OUTPUT_MAX_PAGE_BYTES : RETAINED_OUTPUT_DEFAULT_PAGE_BYTES
9423
- );
9424
- if (range.kind === "invalid") {
9425
- return c.json(
9426
- {
9427
- message: "invalid retained artifact byte range",
9428
- reason: range.reason,
9429
- maxRangeBytes: RETAINED_OUTPUT_MAX_PAGE_BYTES
9430
- },
9431
- 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")
9432
9784
  );
9433
- }
9434
- if (range.kind === "unsatisfiable") {
9435
- return c.json(
9436
- { message: "retained artifact byte range is not satisfiable", reason: range.reason },
9437
- 416,
9438
- {
9439
- "Accept-Ranges": "bytes",
9440
- "Content-Range": range.contentRange,
9441
- "Cache-Control": "private, no-store"
9442
- }
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")
9443
9815
  );
9444
- }
9445
- const headers = {
9446
- "Accept-Ranges": range.acceptRanges,
9447
- "Cache-Control": "private, no-store",
9448
- "Content-Length": String(range.length),
9449
- "Content-Type": metadata.contentType,
9450
- "X-Content-Type-Options": "nosniff",
9451
- ...range.contentRange ? { "Content-Range": range.contentRange } : {}
9452
- };
9453
- if (range.kind === "empty") {
9454
- if (!await objectStorage.fileExists(artifact.file)) {
9455
- 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" });
9456
9819
  }
9457
- return c.body(null, 200, headers);
9458
- }
9459
- const bytes = await objectStorage.getFileRange(artifact.file, {
9460
- start: range.start,
9461
- 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 });
9462
9827
  });
9463
- if (!bytes) {
9464
- return c.json(retainedArtifactUnavailable(artifactId, "missing_storage"), 410);
9465
- }
9466
- if (bytes.byteLength !== range.length) {
9467
- throw new HTTPException13(502, { message: "object storage returned an invalid byte range" });
9468
- }
9469
- return c.body(new Uint8Array(bytes), range.status, headers);
9470
- });
9471
- app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
9472
- const workspaceId = c.req.param("workspaceId");
9473
- await requireAccessGrant8(c, deps, workspaceId, "files:read");
9474
- if (!objectStorage) {
9475
- throw new HTTPException13(503, { message: "object storage is not configured" });
9476
- }
9477
- const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
9478
- if (!file) {
9479
- throw new HTTPException13(404, { message: "file not found" });
9480
- }
9481
- if (file.status !== "ready") {
9482
- throw new HTTPException13(409, { message: `file is ${file.status}` });
9483
- }
9484
- const signed = await objectStorage.createGetUrl({ key: file.objectKey });
9485
- return c.json(
9486
- FileDownloadUrlResponse.parse({
9487
- url: signed.url,
9488
- expiresAt: signed.expiresAt.toISOString()
9489
- })
9490
- );
9491
- });
9492
- }
9493
- function sanitizeFilename(filename) {
9494
- const trimmed = filename.trim().replace(/[/\\]/g, "_");
9495
- const safe = trimmed.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim();
9496
- return safe || "file";
9497
- }
9498
- function publicFileUploadStatus(status) {
9499
- return status === "cleanup_pending" ? "failed" : status;
9828
+ }
9500
9829
  }
9501
- function retainedArtifactId(value) {
9502
- const parsed = FileAsset.shape.id.safeParse(value);
9830
+ var registerEnvironmentRoutes = registerVariableSetRoutes;
9831
+ function parseVariableName(raw) {
9832
+ const parsed = VariableSetVariableName2.safeParse(raw);
9503
9833
  if (!parsed.success) {
9504
- 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
+ });
9505
9837
  }
9838
+ assertAllowedVariableSetVariableName2(parsed.data);
9506
9839
  return parsed.data;
9507
9840
  }
9508
- function retainedArtifactUnavailable(artifactId, reason) {
9509
- return RetainedArtifactMetadataSchema.parse({ available: false, artifactId, reason });
9510
- }
9511
- function retainedArtifactMetadata(artifact) {
9512
- const reference = retainedArtifactReferenceFromFile(artifact.file);
9513
- if (reference) return reference;
9514
- const { file, uploadStatus, uploadExpiresAt } = artifact;
9515
- if (file.status === "deleted") {
9516
- return retainedArtifactUnavailable(file.id, "deleted");
9517
- }
9518
- if (file.status === "expired" || uploadStatus === "expired" || uploadStatus === "pending" && uploadExpiresAt !== null && uploadExpiresAt.getTime() < Date.now()) {
9519
- return retainedArtifactUnavailable(file.id, "expired");
9520
- }
9521
- if (file.status === "failed" || uploadStatus === "failed" || uploadStatus === "cleanup_pending") {
9522
- return retainedArtifactUnavailable(file.id, "failed");
9523
- }
9524
- if (file.status === "pending_upload" || uploadStatus === "pending") {
9525
- return retainedArtifactUnavailable(file.id, "pending");
9526
- }
9527
- return retainedArtifactUnavailable(file.id, "unsupported");
9528
- }
9529
- function retainedArtifactUnavailableStatus(reason) {
9530
- switch (reason) {
9531
- case "deleted":
9532
- return 404;
9533
- case "expired":
9534
- case "missing_storage":
9535
- return 410;
9536
- case "unsupported":
9537
- case "not_retained":
9538
- case "storage_write_failed":
9539
- return 422;
9540
- case "pending":
9541
- case "failed":
9542
- 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" });
9543
9845
  }
9846
+ return trimmed;
9544
9847
  }
9545
9848
 
9546
9849
  // src/routes/api-keys.ts
@@ -10117,27 +10420,37 @@ function looksLikeEmail(value) {
10117
10420
  }
10118
10421
 
10119
10422
  // src/routes/github.ts
10120
- import { GitHubAppManifestCreate } from "@opengeni/contracts";
10121
- import { deleteGitHubInstallationBinding } from "@opengeni/db";
10122
10423
  import {
10424
+ GitHubAppManifestCreate
10425
+ } from "@opengeni/contracts";
10426
+ import {
10427
+ bindAuthorizedGitHubInstallationRepositories,
10428
+ deleteGitHubInstallationBinding,
10429
+ GitHubInstallationAuthorityCommitError
10430
+ } from "@opengeni/db";
10431
+ import {
10432
+ authorizeGitHubInstallationBinding,
10123
10433
  buildGitHubAppManifest,
10124
10434
  convertGitHubAppManifest,
10125
- createSignedState as createSignedState3,
10435
+ createSignedState as createSignedState4,
10126
10436
  envLinesFromGitHubManifestConversion,
10127
10437
  GitHubAppApiError,
10128
10438
  GitHubAppConfigurationError as GitHubAppConfigurationError2,
10439
+ GitHubInstallationAuthorityError,
10129
10440
  githubAppMissingSettings as githubAppMissingSettings2,
10441
+ githubOAuthAuthorizeUrl,
10130
10442
  organizationAppManifestUrl,
10131
10443
  personalAppManifestUrl,
10132
10444
  readSignedState as readSignedState3,
10133
10445
  stateMaxAgeSeconds,
10134
10446
  verifySignedState
10135
10447
  } from "@opengeni/github";
10136
- import { setCookie } from "hono/cookie";
10448
+ import { deleteCookie, setCookie } from "hono/cookie";
10137
10449
  import { HTTPException as HTTPException16 } from "hono/http-exception";
10138
- import { requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
10450
+ import { hasPermission as hasPermission5, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
10139
10451
  var githubStateCookie = "opengeni_github_state";
10140
- 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";
10141
10454
  function registerGitHubRoutes(app, deps) {
10142
10455
  const { db, settings, githubStateSecret } = deps;
10143
10456
  app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
@@ -10145,17 +10458,25 @@ function registerGitHubRoutes(app, deps) {
10145
10458
  const grant = await requireAccessGrant10(c, deps, workspaceId, "github:use");
10146
10459
  const missing = githubAppMissingSettings2(settings);
10147
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;
10148
10471
  return c.json({
10149
10472
  configured: missing.length === 0,
10473
+ status,
10150
10474
  appId: settings.githubAppId ?? null,
10151
10475
  clientId: settings.githubClientId ?? null,
10152
10476
  appSlug: slug,
10153
- // Kept nullable for SDK compatibility. GitHub's setup callback contains
10154
- // a spoofable installation_id, while user-installation visibility and
10155
- // repository admin permission do not prove that this human may bind it.
10156
- installUrl: null,
10157
- linkUrl: null,
10158
- installations: await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId),
10477
+ installUrl: connectUrl,
10478
+ linkUrl: connectUrl,
10479
+ installations,
10159
10480
  missing
10160
10481
  });
10161
10482
  });
@@ -10166,10 +10487,22 @@ function registerGitHubRoutes(app, deps) {
10166
10487
  throw new HTTPException16(400, { message: "missing GitHub installation state" });
10167
10488
  }
10168
10489
  const statePayload = readSignedState3(state, githubStateSecret);
10169
- if (!statePayload || statePayload.workspaceId !== workspaceId) {
10490
+ if (!statePayload || statePayload.intent !== "installation_authority" || statePayload.workspaceId !== workspaceId || typeof statePayload.accountId !== "string" || !isFreshGitHubBindingState(statePayload)) {
10170
10491
  throw new HTTPException16(400, { message: "invalid or expired GitHub installation state" });
10171
10492
  }
10172
- 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
+ );
10173
10506
  });
10174
10507
  app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
10175
10508
  const workspaceId = c.req.param("workspaceId");
@@ -10228,7 +10561,7 @@ function registerGitHubRoutes(app, deps) {
10228
10561
  /\/+$/,
10229
10562
  ""
10230
10563
  );
10231
- const state = createSignedState3(githubStateSecret, {
10564
+ const state = createSignedState4(githubStateSecret, {
10232
10565
  accountId: grant.accountId,
10233
10566
  workspaceId: grant.workspaceId
10234
10567
  });
@@ -10273,23 +10606,134 @@ function registerGitHubRoutes(app, deps) {
10273
10606
  throw new HTTPException16(400, { message: "missing GitHub installation state" });
10274
10607
  }
10275
10608
  const statePayload = readSignedState3(state, githubStateSecret);
10276
- 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)) {
10277
10610
  throw new HTTPException16(400, { message: "invalid or expired GitHub installation state" });
10278
10611
  }
10279
- 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
+ );
10280
10654
  };
10281
10655
  app.get("/v1/github/setup", handleGitHubInstallCallback);
10282
10656
  app.get("/v1/github/install/callback", handleGitHubInstallCallback);
10283
10657
  app.get("/v1/github/oauth/callback", async (c) => {
10658
+ const code = c.req.query("code");
10284
10659
  const state = c.req.query("state");
10660
+ if (!code) {
10661
+ throw new HTTPException16(400, { message: "missing GitHub OAuth code" });
10662
+ }
10285
10663
  if (!state) {
10286
10664
  throw new HTTPException16(400, { message: "missing GitHub OAuth state" });
10287
10665
  }
10288
10666
  const statePayload = readSignedState3(state, githubStateSecret);
10289
- 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)) {
10290
10668
  throw new HTTPException16(400, { message: "invalid or expired GitHub OAuth state" });
10291
10669
  }
10292
- 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
+ );
10293
10737
  });
10294
10738
  app.post("/v1/workspaces/:workspaceId/github/installations", async (c) => {
10295
10739
  const workspaceId = c.req.param("workspaceId");
@@ -10302,11 +10746,11 @@ function registerGitHubRoutes(app, deps) {
10302
10746
  if (!statePayload || typeof statePayload.accountId !== "string" || statePayload.accountId.length === 0 || statePayload.workspaceId !== workspaceId) {
10303
10747
  throw new HTTPException16(400, { message: "invalid or expired GitHub OAuth state" });
10304
10748
  }
10305
- throw installationBindingDisabled();
10749
+ throw legacyInstallationChooserDisabled();
10306
10750
  });
10307
10751
  }
10308
- function installationBindingDisabled() {
10309
- return new HTTPException16(410, { message: installationBindingDisabledMessage });
10752
+ function legacyInstallationChooserDisabled() {
10753
+ return new HTTPException16(410, { message: legacyInstallationChooserDisabledMessage });
10310
10754
  }
10311
10755
  function setGitHubStateCookie(c, deps, state) {
10312
10756
  setCookie(c, githubStateCookie, state, {
@@ -10317,6 +10761,61 @@ function setGitHubStateCookie(c, deps, state) {
10317
10761
  maxAge: stateMaxAgeSeconds
10318
10762
  });
10319
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
+ }
10320
10819
  function isSecureRequest(c, deps) {
10321
10820
  return deps.settings.publicBaseUrl?.startsWith("https://") || c.req.header("x-forwarded-proto") === "https" || new URL(c.req.url).protocol === "https:";
10322
10821
  }
@@ -10325,6 +10824,12 @@ function githubSuccessHtml(envLines) {
10325
10824
  const escaped = escapeHtml2(envText);
10326
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>`;
10327
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
+ }
10328
10833
  function parsePositiveInteger(value) {
10329
10834
  if (!value || !/^\d+$/.test(value)) {
10330
10835
  return null;
@@ -10332,6 +10837,22 @@ function parsePositiveInteger(value) {
10332
10837
  const parsed = Number(value);
10333
10838
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
10334
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
+ }
10335
10856
  function escapeHtml2(value) {
10336
10857
  return value.replace(
10337
10858
  /[&<>"']/g,
@@ -10344,6 +10865,14 @@ function escapeHtml2(value) {
10344
10865
  })[char] ?? char
10345
10866
  );
10346
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
+ }
10347
10876
 
10348
10877
  // src/routes/packs.ts
10349
10878
  import {
@@ -10949,6 +11478,7 @@ import {
10949
11478
  UpdateSessionGoalRequest,
10950
11479
  UpdateSessionMcpApprovalPolicyRequest,
10951
11480
  UpdateSessionRequest,
11481
+ UpdateSessionToolPolicyRequest,
10952
11482
  ViewerHeartbeatRequest,
10953
11483
  WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
10954
11484
  workspaceControlUtf8Bytes
@@ -10996,6 +11526,7 @@ import {
10996
11526
  NewSessionDraftConflictError,
10997
11527
  SessionCommandIdempotencyError,
10998
11528
  SessionControlConflictError,
11529
+ SessionToolPolicyVersionConflictError,
10999
11530
  SessionContextBusyError,
11000
11531
  HumanInputResponseValidationError,
11001
11532
  latestWorkspaceCapture,
@@ -11260,6 +11791,7 @@ import {
11260
11791
  sessionSpawnDenialEnvelope as sessionSpawnDenialEnvelope2,
11261
11792
  steerHumanQueuePrompt,
11262
11793
  updateSessionMcpApprovalPolicy,
11794
+ updateSessionToolPolicy,
11263
11795
  updateSessionTitle as updateSessionTitle2,
11264
11796
  workflowIdForSession,
11265
11797
  sessionWithEffectiveToolPolicy as sessionWithEffectiveToolPolicy2,
@@ -12311,6 +12843,28 @@ function registerSessionRoutes(app, deps) {
12311
12843
  );
12312
12844
  }
12313
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
+ });
12314
12868
  app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
12315
12869
  const workspaceId = c.req.param("workspaceId");
12316
12870
  await requireAccessGrant14(c, deps, workspaceId, "sessions:read");
@@ -13742,6 +14296,7 @@ function sessionAuthorizationOperationForHttp(method, pathname, sessionId) {
13742
14296
  return null;
13743
14297
  }
13744
14298
  if (suffix === "/pin" && verb === "PUT") return "session.pin.write";
14299
+ if (suffix === "/tool-policy" && verb === "PUT") return "session.tool_policy.write";
13745
14300
  if (/^\/mcp-servers\/[^/]+\/approval-policy$/.test(suffix) && verb === "PATCH") {
13746
14301
  return "session.mcp.approval_policy.write";
13747
14302
  }
@@ -14143,7 +14698,7 @@ import {
14143
14698
  } from "@opengeni/db";
14144
14699
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
14145
14700
  import { HTTPException as HTTPException23 } from "hono/http-exception";
14146
- 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";
14147
14702
  import { requireLimit as requireLimit7 } from "@opengeni/core";
14148
14703
  import {
14149
14704
  assertWorkspaceDeletable,
@@ -14390,7 +14945,7 @@ function registerWorkspaceRoutes(app, deps) {
14390
14945
  const context = await requireAccessContext2(c, deps);
14391
14946
  const readableWorkspaceIds = [
14392
14947
  ...new Set(
14393
- 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)
14394
14949
  )
14395
14950
  ];
14396
14951
  if (readableWorkspaceIds.length > 0) {
@@ -14664,6 +15219,7 @@ import {
14664
15219
  } from "@opengeni/core";
14665
15220
  import { workflowIdForSession as workflowIdForSession2 } from "@opengeni/core";
14666
15221
  var API_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
15222
+ var API_PUBLIC_ERROR_MESSAGE_MAX_BYTES = 512;
14667
15223
  function createApp(deps) {
14668
15224
  const managedAuth = deps.managedAuth ?? createManagedAuth(deps.settings, deps.db);
14669
15225
  const objectStorage = deps.objectStorage === void 0 ? createObjectStorage(deps.settings) : deps.objectStorage;
@@ -14717,6 +15273,13 @@ function createApp(deps) {
14717
15273
  resumeBoxById
14718
15274
  };
14719
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
+ });
14720
15283
  app.use(
14721
15284
  "*",
14722
15285
  cors({
@@ -14727,9 +15290,10 @@ function createApp(deps) {
14727
15290
  "Content-Type",
14728
15291
  "X-OpenGeni-Access-Key",
14729
15292
  "X-OpenGeni-Api-Contract",
15293
+ "X-OpenGeni-Correlation-Id",
14730
15294
  "X-OpenGeni-Subject"
14731
15295
  ],
14732
- exposeHeaders: ["X-OpenGeni-Api-Contract"],
15296
+ exposeHeaders: ["X-OpenGeni-Api-Contract", "X-OpenGeni-Correlation-Id"],
14733
15297
  origin: (origin) => {
14734
15298
  if (!origin) {
14735
15299
  return null;
@@ -14748,6 +15312,7 @@ function createApp(deps) {
14748
15312
  app.use("*", async (c, next) => {
14749
15313
  const url = new URL(c.req.url);
14750
15314
  const route = routeLabel(url.pathname);
15315
+ const correlationId = correlationIds.get(c.req.raw) ?? crypto.randomUUID();
14751
15316
  const start = performance.now();
14752
15317
  const span = observability.startSpan(`HTTP ${c.req.method} ${route}`, {
14753
15318
  "http.request.method": c.req.method,
@@ -14776,10 +15341,12 @@ function createApp(deps) {
14776
15341
  status,
14777
15342
  durationMs: Math.round(durationSeconds * 1e3),
14778
15343
  traceId: span.traceId,
14779
- spanId: span.spanId
15344
+ spanId: span.spanId,
15345
+ correlationId
14780
15346
  });
14781
15347
  } catch (error) {
14782
15348
  const status = httpStatusForError(error);
15349
+ const errorCode = errorCodeForStatus(status);
14783
15350
  const durationSeconds = (performance.now() - start) / 1e3;
14784
15351
  observability.recordHttpRequest({
14785
15352
  method: c.req.method,
@@ -14787,6 +15354,11 @@ function createApp(deps) {
14787
15354
  status,
14788
15355
  durationSeconds
14789
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
+ });
14790
15362
  span.end({
14791
15363
  attributes: {
14792
15364
  "http.response.status_code": status,
@@ -14801,7 +15373,9 @@ function createApp(deps) {
14801
15373
  durationMs: Math.round(durationSeconds * 1e3),
14802
15374
  traceId: span.traceId,
14803
15375
  spanId: span.spanId,
14804
- error: error instanceof Error ? error.message : String(error)
15376
+ correlationId,
15377
+ errorCode,
15378
+ errorClass: error instanceof Error ? error.name : "NonErrorThrown"
14805
15379
  });
14806
15380
  throw error;
14807
15381
  }
@@ -14958,11 +15532,46 @@ function createApp(deps) {
14958
15532
  registerSessionRoutes(app, routeDeps);
14959
15533
  registerScheduledTaskRoutes(app, routeDeps);
14960
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
+ });
14961
15570
  return app;
14962
15571
  }
14963
15572
  async function requireMcpAccessGrant(c, deps, workspaceId) {
14964
15573
  const grant = await requireAccessGrant17(c, deps, workspaceId);
14965
- if (hasPermission5(grant.permissions, "workspace:read")) {
15574
+ if (hasPermission7(grant.permissions, "workspace:read")) {
14966
15575
  return grant;
14967
15576
  }
14968
15577
  if (isToolspaceGrant(deps.settings, grant)) {
@@ -15006,6 +15615,45 @@ function httpStatusForError(error) {
15006
15615
  }
15007
15616
  return 500;
15008
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
+ }
15009
15657
  function readinessChecks(deps) {
15010
15658
  return {
15011
15659
  db: deps.readinessChecks?.db ?? (async () => {
@@ -15444,6 +16092,7 @@ export {
15444
16092
  createApp,
15445
16093
  allowedCorsOrigin,
15446
16094
  httpStatusForError,
16095
+ errorCodeForStatus,
15447
16096
  routeLabel,
15448
16097
  isApiContractProtectedMutation,
15449
16098
  mergeResourceRefs,
@@ -15457,4 +16106,4 @@ export {
15457
16106
  withDefaultEnabledCapabilityMcpTools,
15458
16107
  workflowIdForSession2 as workflowIdForSession
15459
16108
  };
15460
- //# sourceMappingURL=chunk-BFWSDESE.js.map
16109
+ //# sourceMappingURL=chunk-DQWFAIPE.js.map