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