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