@opengeni/api-router 0.4.0 → 0.5.0
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/LICENSE +190 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-I2EDWHVF.js → chunk-DQ5TIRDZ.js} +2229 -616
- package/dist/chunk-DQ5TIRDZ.js.map +1 -0
- package/dist/index.js +53 -8
- package/dist/index.js.map +1 -1
- package/package.json +12 -18
- package/src/app.ts +106 -7
- package/src/http/auth.ts +7 -1
- package/src/index.ts +9 -4
- package/src/integrations/oauth-client.ts +899 -0
- package/src/mcp/documents.ts +124 -20
- package/src/mcp/server.ts +37 -2
- package/src/mcp/toolspace.ts +627 -0
- package/src/observability.ts +31 -0
- package/src/routes/billing.ts +19 -1
- package/src/routes/connections.ts +179 -0
- package/src/routes/documents.ts +87 -3
- package/src/routes/enrollments.ts +1 -0
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +1 -1
- package/src/sandbox/machines.ts +2 -0
- package/src/sandbox/metrics-ingestion.ts +41 -10
- package/src/sandbox/viewer.ts +1 -1
- package/dist/chunk-I2EDWHVF.js.map +0 -1
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
} from "@opengeni/config";
|
|
7
7
|
import { ClientConfig } from "@opengeni/contracts";
|
|
8
8
|
import { createDocumentServices, indexDocumentNow } from "@opengeni/documents";
|
|
9
|
+
import { dbSql } from "@opengeni/db";
|
|
9
10
|
import { createObservability } from "@opengeni/observability";
|
|
10
11
|
import { createObjectStorage } from "@opengeni/storage";
|
|
11
12
|
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
12
13
|
import { Hono } from "hono";
|
|
13
14
|
import { cors } from "hono/cors";
|
|
14
|
-
import { HTTPException as
|
|
15
|
-
import { requireAccessGrant as
|
|
15
|
+
import { HTTPException as HTTPException20 } from "hono/http-exception";
|
|
16
|
+
import { hasPermission as hasPermission4, requireAccessGrant as requireAccessGrant16, requirePermission } from "@opengeni/core";
|
|
16
17
|
|
|
17
18
|
// src/auth/managed-auth.ts
|
|
18
19
|
import { ensureManagedAccessForUser } from "@opengeni/db";
|
|
@@ -493,8 +494,9 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
|
|
|
493
494
|
});
|
|
494
495
|
const json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value, null, 2) }] });
|
|
495
496
|
const can = (permission) => hasPermission(grant.permissions, permission);
|
|
497
|
+
const toolspaceMode = options.toolspace != null;
|
|
496
498
|
const sessionId = typeof grant.metadata?.["sessionId"] === "string" ? grant.metadata["sessionId"] : null;
|
|
497
|
-
if (sessionId !== null) {
|
|
499
|
+
if (sessionId !== null && (!toolspaceMode || can("sessions:control"))) {
|
|
498
500
|
server.registerTool("set_session_title", {
|
|
499
501
|
description: "Set this session's display title to a concise 3-7 word summary. Call once early to name the session; calling again replaces it unless a human has manually set the title.",
|
|
500
502
|
inputSchema: { title: z4.string().min(1).max(200) }
|
|
@@ -506,7 +508,7 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
|
|
|
506
508
|
if (sessionId !== null && can("goals:manage")) {
|
|
507
509
|
registerGoalTools(server, deps, grant, sessionId, json);
|
|
508
510
|
}
|
|
509
|
-
if (sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
|
|
511
|
+
if (!toolspaceMode && sessionId !== null && deps.settings.sandboxSelfhostedEnabled) {
|
|
510
512
|
registerFleetTools(server, deps, grant, sessionId, json);
|
|
511
513
|
}
|
|
512
514
|
registerWorkspaceOrchestrationTools(server, deps, grant, can, json);
|
|
@@ -517,225 +519,253 @@ function buildOpenGeniMcpServer(deps, grant, options = {}) {
|
|
|
517
519
|
registerGitHubTokenTool(server, deps, grant, sessionId, json);
|
|
518
520
|
}
|
|
519
521
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const file = await requireFile(deps.db, grant.workspaceId, fileId);
|
|
528
|
-
if (file.status !== "ready") {
|
|
529
|
-
throw new Error(`file is ${file.status}`);
|
|
530
|
-
}
|
|
531
|
-
const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
|
|
532
|
-
return json({
|
|
533
|
-
file: {
|
|
534
|
-
id: file.id,
|
|
535
|
-
filename: file.filename,
|
|
536
|
-
safeFilename: file.safeFilename,
|
|
537
|
-
contentType: file.contentType,
|
|
538
|
-
sizeBytes: file.sizeBytes,
|
|
539
|
-
sha256: file.sha256,
|
|
540
|
-
status: file.status,
|
|
541
|
-
createdAt: file.createdAt,
|
|
542
|
-
updatedAt: file.updatedAt
|
|
543
|
-
},
|
|
544
|
-
downloadUrl: {
|
|
545
|
-
url: signed.url,
|
|
546
|
-
expiresAt: signed.expiresAt.toISOString()
|
|
522
|
+
if (!toolspaceMode || can("files:read")) {
|
|
523
|
+
server.registerTool("files_get_download_url", {
|
|
524
|
+
description: "Create a short-lived download URL for a ready file asset.",
|
|
525
|
+
inputSchema: { fileId: z4.string().uuid() }
|
|
526
|
+
}, async ({ fileId }) => {
|
|
527
|
+
if (!deps.objectStorage) {
|
|
528
|
+
throw new Error("object storage is not configured");
|
|
547
529
|
}
|
|
530
|
+
const file = await requireFile(deps.db, grant.workspaceId, fileId);
|
|
531
|
+
if (file.status !== "ready") {
|
|
532
|
+
throw new Error(`file is ${file.status}`);
|
|
533
|
+
}
|
|
534
|
+
const signed = await deps.objectStorage.createGetUrl({ key: file.objectKey });
|
|
535
|
+
return json({
|
|
536
|
+
file: {
|
|
537
|
+
id: file.id,
|
|
538
|
+
filename: file.filename,
|
|
539
|
+
safeFilename: file.safeFilename,
|
|
540
|
+
contentType: file.contentType,
|
|
541
|
+
sizeBytes: file.sizeBytes,
|
|
542
|
+
sha256: file.sha256,
|
|
543
|
+
status: file.status,
|
|
544
|
+
createdAt: file.createdAt,
|
|
545
|
+
updatedAt: file.updatedAt
|
|
546
|
+
},
|
|
547
|
+
downloadUrl: {
|
|
548
|
+
url: signed.url,
|
|
549
|
+
expiresAt: signed.expiresAt.toISOString()
|
|
550
|
+
}
|
|
551
|
+
});
|
|
548
552
|
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
553
|
+
}
|
|
554
|
+
if (!toolspaceMode || can("github:use")) {
|
|
555
|
+
server.registerTool("github_repositories_list", {
|
|
556
|
+
description: "List GitHub App repositories available as scheduled task repository resources. Use the returned resource object in scheduled task agentConfig.resources.",
|
|
557
|
+
inputSchema: { limit: z4.number().int().positive().optional() }
|
|
558
|
+
}, async ({ limit }) => {
|
|
559
|
+
try {
|
|
560
|
+
const installationIds = await listGitHubInstallationIdsForWorkspace(deps.db, grant.workspaceId);
|
|
561
|
+
const repositories = await listGitHubAppRepositories(deps.settings, { installationIds });
|
|
562
|
+
const visible = typeof limit === "number" ? repositories.slice(0, limit) : repositories;
|
|
563
|
+
return json({ repositories: visible.map((repository) => repositoryWithScheduledTaskResource(repository)) });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
566
|
+
throw new Error(`GitHub App is not configured: ${error.missing.join(", ")}`);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
562
569
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (!toolspaceMode || can("connections:read")) {
|
|
573
|
+
server.registerTool("social_connections_list", {
|
|
574
|
+
description: "List connected social media accounts available to social media analysis packs.",
|
|
575
|
+
inputSchema: { limit: z4.number().int().positive().optional() }
|
|
576
|
+
}, async ({ limit }) => json({ connections: await listSocialConnections(deps.db, grant.workspaceId, boundedMcpLimit(limit)) }));
|
|
577
|
+
server.registerTool("social_posts_recent", {
|
|
578
|
+
description: "List recent social media posts imported or synced into OpenGeni.",
|
|
579
|
+
inputSchema: {
|
|
580
|
+
connectionIds: z4.array(z4.string().uuid()).optional(),
|
|
581
|
+
since: z4.string().optional(),
|
|
582
|
+
windowHours: z4.number().int().positive().optional(),
|
|
583
|
+
limit: z4.number().int().positive().optional()
|
|
584
|
+
}
|
|
585
|
+
}, async ({ connectionIds, since, windowHours, limit }) => {
|
|
586
|
+
const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1e3);
|
|
587
|
+
return json({
|
|
588
|
+
since: sinceDate.toISOString(),
|
|
589
|
+
posts: await listSocialPosts(deps.db, {
|
|
590
|
+
workspaceId: grant.workspaceId,
|
|
591
|
+
...connectionIds?.length ? { connectionIds } : {},
|
|
592
|
+
since: sinceDate,
|
|
593
|
+
limit: boundedMcpLimit(limit)
|
|
594
|
+
})
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
server.registerTool("social_daily_analysis_context", {
|
|
598
|
+
description: "Collect social account and recent post context for a daily marketing analysis run.",
|
|
599
|
+
inputSchema: {
|
|
600
|
+
connectionIds: z4.array(z4.string().uuid()).optional(),
|
|
601
|
+
documentBaseIds: z4.array(z4.string().uuid()).optional(),
|
|
602
|
+
since: z4.string().optional(),
|
|
603
|
+
windowHours: z4.number().int().positive().optional(),
|
|
604
|
+
limit: z4.number().int().positive().optional()
|
|
605
|
+
}
|
|
606
|
+
}, async ({ connectionIds, documentBaseIds, since, windowHours, limit }) => {
|
|
607
|
+
const allConnections = await listSocialConnections(deps.db, grant.workspaceId, 500);
|
|
608
|
+
const selectedIds = connectionIds && connectionIds.length > 0 ? new Set(connectionIds) : null;
|
|
609
|
+
const connections = selectedIds ? allConnections.filter((connection) => selectedIds.has(connection.id)) : allConnections.filter((connection) => connection.status === "connected");
|
|
610
|
+
if (selectedIds) {
|
|
611
|
+
const foundIds = new Set(connections.map((connection) => connection.id));
|
|
612
|
+
const missing = [...selectedIds].filter((id) => !foundIds.has(id));
|
|
613
|
+
if (missing.length > 0) {
|
|
614
|
+
throw new Error(`Unknown social connection IDs: ${missing.join(", ")}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const sinceDate = since ? parseMcpDate(since, "since") : new Date(Date.now() - (windowHours ?? 24) * 60 * 60 * 1e3);
|
|
618
|
+
const posts = connections.length > 0 ? await listSocialPosts(deps.db, {
|
|
583
619
|
workspaceId: grant.workspaceId,
|
|
584
|
-
|
|
620
|
+
connectionIds: connections.map((connection) => connection.id),
|
|
585
621
|
since: sinceDate,
|
|
586
622
|
limit: boundedMcpLimit(limit)
|
|
587
|
-
})
|
|
623
|
+
}) : [];
|
|
624
|
+
return json({
|
|
625
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
626
|
+
window: {
|
|
627
|
+
since: sinceDate.toISOString(),
|
|
628
|
+
until: (/* @__PURE__ */ new Date()).toISOString()
|
|
629
|
+
},
|
|
630
|
+
documentBaseIds: documentBaseIds ?? [],
|
|
631
|
+
connections,
|
|
632
|
+
posts,
|
|
633
|
+
instructions: [
|
|
634
|
+
"Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
|
|
635
|
+
"Report data gaps explicitly when posts or metrics are missing.",
|
|
636
|
+
"Do not infer unpublished metrics or hidden platform data."
|
|
637
|
+
]
|
|
638
|
+
});
|
|
588
639
|
});
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
640
|
+
}
|
|
641
|
+
if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
|
|
642
|
+
server.registerTool("scheduled_tasks_list", {
|
|
643
|
+
description: "List scheduled tasks.",
|
|
644
|
+
inputSchema: { limit: z4.number().int().positive().optional() }
|
|
645
|
+
}, async ({ limit }) => json({ tasks: await listScheduledTasks(deps.db, grant.workspaceId, limit ?? 100) }));
|
|
646
|
+
server.registerTool("scheduled_tasks_get", {
|
|
647
|
+
description: "Get one scheduled task.",
|
|
648
|
+
inputSchema: { id: z4.string().uuid() }
|
|
649
|
+
}, async ({ id }) => json(await requireScheduledTask(deps.db, grant.workspaceId, id)));
|
|
650
|
+
server.registerTool("scheduled_tasks_create", {
|
|
651
|
+
description: "Create a scheduled task.",
|
|
652
|
+
inputSchema: {
|
|
653
|
+
name: z4.string(),
|
|
654
|
+
schedule: z4.unknown(),
|
|
655
|
+
runMode: z4.string().optional(),
|
|
656
|
+
overlapPolicy: z4.string().optional(),
|
|
657
|
+
agentConfig: z4.unknown(),
|
|
658
|
+
status: z4.string().optional(),
|
|
659
|
+
environmentId: z4.string().uuid().optional(),
|
|
660
|
+
metadata: z4.record(z4.string(), z4.unknown()).optional()
|
|
608
661
|
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
workspaceId: grant.workspaceId,
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}) : [];
|
|
617
|
-
return json({
|
|
618
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
619
|
-
window: {
|
|
620
|
-
since: sinceDate.toISOString(),
|
|
621
|
-
until: (/* @__PURE__ */ new Date()).toISOString()
|
|
622
|
-
},
|
|
623
|
-
documentBaseIds: documentBaseIds ?? [],
|
|
624
|
-
connections,
|
|
625
|
-
posts,
|
|
626
|
-
instructions: [
|
|
627
|
-
"Use docs MCP search tools for the supplied documentBaseIds when brand, campaign, or audience knowledge is needed.",
|
|
628
|
-
"Report data gaps explicitly when posts or metrics are missing.",
|
|
629
|
-
"Do not infer unpublished metrics or hidden platform data."
|
|
630
|
-
]
|
|
662
|
+
}, async (args) => {
|
|
663
|
+
const payload = CreateScheduledTaskRequest.parse(args);
|
|
664
|
+
requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
|
|
665
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "schedule:create", quantity: 1 });
|
|
666
|
+
const task = await createValidatedScheduledTask({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, payload, toolsProvided: scheduledTaskToolsProvided(args) });
|
|
667
|
+
await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
|
|
668
|
+
return json(task);
|
|
631
669
|
});
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() }
|
|
704
|
-
}, async ({ id, triggerId }) => {
|
|
705
|
-
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
706
|
-
await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
|
|
707
|
-
const triggerToken = scheduledTaskTriggerToken(triggerId);
|
|
708
|
-
const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken);
|
|
709
|
-
const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
|
|
710
|
-
await deps.workflowClient.triggerScheduledTask({ task, agentRunUsageIdempotencyKey, triggerWorkflowId });
|
|
711
|
-
await recordWorkspaceUsage(deps, {
|
|
712
|
-
accountId: grant.accountId,
|
|
713
|
-
workspaceId: grant.workspaceId,
|
|
714
|
-
subjectId: grant.subjectId,
|
|
715
|
-
eventType: "agent_run.created",
|
|
716
|
-
quantity: 1,
|
|
717
|
-
unit: "run",
|
|
718
|
-
sourceResourceType: "scheduled_task",
|
|
719
|
-
sourceResourceId: task.id,
|
|
720
|
-
idempotencyKey: agentRunUsageIdempotencyKey
|
|
670
|
+
server.registerTool("scheduled_tasks_update", {
|
|
671
|
+
description: "Update a scheduled task.",
|
|
672
|
+
inputSchema: {
|
|
673
|
+
id: z4.string().uuid(),
|
|
674
|
+
name: z4.string().optional(),
|
|
675
|
+
schedule: z4.unknown().optional(),
|
|
676
|
+
runMode: z4.string().optional(),
|
|
677
|
+
overlapPolicy: z4.string().optional(),
|
|
678
|
+
agentConfig: z4.unknown().optional(),
|
|
679
|
+
status: z4.string().optional(),
|
|
680
|
+
environmentId: z4.string().uuid().nullable().optional(),
|
|
681
|
+
metadata: z4.record(z4.string(), z4.unknown()).optional()
|
|
682
|
+
}
|
|
683
|
+
}, async ({ id, ...raw }) => {
|
|
684
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
685
|
+
const payload = UpdateScheduledTaskRequest.parse(raw);
|
|
686
|
+
requireEnvironmentsUseForMcpAttachment(grant, payload.environmentId);
|
|
687
|
+
const update = await validatedScheduledTaskUpdate({ settings: deps.settings, db: deps.db, objectStorage: deps.objectStorage, grant, existing, payload, toolsProvided: scheduledTaskToolsProvided(raw) });
|
|
688
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, update);
|
|
689
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
690
|
+
return json(task);
|
|
691
|
+
});
|
|
692
|
+
server.registerTool("scheduled_tasks_pause", {
|
|
693
|
+
description: "Pause a scheduled task.",
|
|
694
|
+
inputSchema: { id: z4.string().uuid() }
|
|
695
|
+
}, async ({ id }) => {
|
|
696
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
697
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "paused" });
|
|
698
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
699
|
+
return json(task);
|
|
700
|
+
});
|
|
701
|
+
server.registerTool("scheduled_tasks_resume", {
|
|
702
|
+
description: "Resume a scheduled task.",
|
|
703
|
+
inputSchema: { id: z4.string().uuid() }
|
|
704
|
+
}, async ({ id }) => {
|
|
705
|
+
const existing = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
706
|
+
const task = await updateScheduledTask(deps.db, grant.workspaceId, id, { status: "active" });
|
|
707
|
+
await syncUpdatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, previous: existing, task });
|
|
708
|
+
return json(task);
|
|
709
|
+
});
|
|
710
|
+
server.registerTool("scheduled_tasks_trigger", {
|
|
711
|
+
description: "Trigger a scheduled task immediately. Pass a stable triggerId to make a retried trigger idempotent (one charge, one run).",
|
|
712
|
+
inputSchema: { id: z4.string().uuid(), triggerId: z4.string().min(1).max(128).optional() }
|
|
713
|
+
}, async ({ id, triggerId }) => {
|
|
714
|
+
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
715
|
+
await requireLimit(deps, { accountId: grant.accountId, workspaceId: grant.workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
|
|
716
|
+
const triggerToken = scheduledTaskTriggerToken(triggerId);
|
|
717
|
+
const agentRunUsageIdempotencyKey = manualScheduledTaskTriggerUsageKey(grant.workspaceId, task.id, triggerToken);
|
|
718
|
+
const triggerWorkflowId = manualScheduledTaskTriggerWorkflowId(task.id, triggerToken);
|
|
719
|
+
await deps.workflowClient.triggerScheduledTask({ task, agentRunUsageIdempotencyKey, triggerWorkflowId });
|
|
720
|
+
await recordWorkspaceUsage(deps, {
|
|
721
|
+
accountId: grant.accountId,
|
|
722
|
+
workspaceId: grant.workspaceId,
|
|
723
|
+
subjectId: grant.subjectId,
|
|
724
|
+
eventType: "agent_run.created",
|
|
725
|
+
quantity: 1,
|
|
726
|
+
unit: "run",
|
|
727
|
+
sourceResourceType: "scheduled_task",
|
|
728
|
+
sourceResourceId: task.id,
|
|
729
|
+
idempotencyKey: agentRunUsageIdempotencyKey
|
|
730
|
+
});
|
|
731
|
+
return json(task);
|
|
732
|
+
});
|
|
733
|
+
server.registerTool("scheduled_tasks_delete", {
|
|
734
|
+
description: "Delete a scheduled task.",
|
|
735
|
+
inputSchema: { id: z4.string().uuid() }
|
|
736
|
+
}, async ({ id }) => {
|
|
737
|
+
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
738
|
+
await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
|
|
739
|
+
await deleteScheduledTask(deps.db, grant.workspaceId, id);
|
|
740
|
+
return json({ ok: true });
|
|
721
741
|
});
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const task = await requireScheduledTask(deps.db, grant.workspaceId, id);
|
|
729
|
-
await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
|
|
730
|
-
await deleteScheduledTask(deps.db, grant.workspaceId, id);
|
|
731
|
-
return json({ ok: true });
|
|
732
|
-
});
|
|
733
|
-
server.registerTool("scheduled_task_runs_list", {
|
|
734
|
-
description: "List runs for a scheduled task.",
|
|
735
|
-
inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() }
|
|
736
|
-
}, async ({ taskId, limit }) => json({ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100) }));
|
|
742
|
+
server.registerTool("scheduled_task_runs_list", {
|
|
743
|
+
description: "List runs for a scheduled task.",
|
|
744
|
+
inputSchema: { taskId: z4.string().uuid(), limit: z4.number().int().positive().optional() }
|
|
745
|
+
}, async ({ taskId, limit }) => json({ runs: await listScheduledTaskRuns(deps.db, grant.workspaceId, taskId, limit ?? 100) }));
|
|
746
|
+
}
|
|
747
|
+
registerToolspaceProxyTools(server, options.toolspace ?? null);
|
|
737
748
|
return server;
|
|
738
749
|
}
|
|
750
|
+
function registerToolspaceProxyTools(server, surface) {
|
|
751
|
+
if (!surface) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
for (const tool of surface.tools) {
|
|
755
|
+
server.registerTool(tool.name, {
|
|
756
|
+
...tool.description ? { description: tool.description } : {},
|
|
757
|
+
inputSchema: z4.object({}).passthrough(),
|
|
758
|
+
_meta: {
|
|
759
|
+
opengeni: {
|
|
760
|
+
origin: "toolspace",
|
|
761
|
+
subjectId: surface.subjectId,
|
|
762
|
+
sessionId: surface.sessionId,
|
|
763
|
+
...tool.inputSchema ? { inputSchema: tool.inputSchema } : {}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}, async (args) => await tool.call(args));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
739
769
|
function registerGoalTools(server, deps, grant, sessionId, json) {
|
|
740
770
|
server.registerTool("goal_set", {
|
|
741
771
|
description: "Set or replace this session's goal. While a goal is active the session keeps working: idle moments synthesize continuation turns until goal_complete or goal_pause is called. Replacing a goal reactivates it and resets the continuation budget.",
|
|
@@ -1215,6 +1245,448 @@ function parseMcpDate(raw, label) {
|
|
|
1215
1245
|
return date;
|
|
1216
1246
|
}
|
|
1217
1247
|
|
|
1248
|
+
// src/mcp/toolspace.ts
|
|
1249
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
1250
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
1251
|
+
import { environmentsEncryptionKeyBytes } from "@opengeni/config";
|
|
1252
|
+
import { prefixedMcpToolName } from "@opengeni/contracts";
|
|
1253
|
+
import { hasPermission as hasPermission2, settingsWithEnabledCapabilityMcpServers } from "@opengeni/core";
|
|
1254
|
+
import {
|
|
1255
|
+
buildConnectionTokenResolver,
|
|
1256
|
+
listSessionMcpServerMetadata,
|
|
1257
|
+
listSessionMcpServersForRun,
|
|
1258
|
+
requireSession as requireSession2,
|
|
1259
|
+
reserveToolspaceCallForTurn
|
|
1260
|
+
} from "@opengeni/db";
|
|
1261
|
+
import { appendAndPublishEvents as appendAndPublishEvents2 } from "@opengeni/events";
|
|
1262
|
+
var APPROVAL_REQUIRED_MESSAGE = "requires approval - invoke via the agent";
|
|
1263
|
+
var TOOLSPACE_AUTH_NEEDED_ERROR_CODE = -32001;
|
|
1264
|
+
var TOOLSPACE_AUTH_NEEDED_MESSAGE = "Authentication required - a connection link was posted to the session.";
|
|
1265
|
+
var TOOLSPACE_NO_ACTIVE_TURN_MESSAGE = "no active turn - toolspace calls require an in-flight turn";
|
|
1266
|
+
var FIRST_PARTY_PROXY_IDS = /* @__PURE__ */ new Set(["files", "docs"]);
|
|
1267
|
+
var TOOLSPACE_TOOL_LIST_TTL_MS = 3e4;
|
|
1268
|
+
var TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES = 2e3;
|
|
1269
|
+
var toolListCache = /* @__PURE__ */ new Map();
|
|
1270
|
+
function isToolspaceGrant(settings, grant) {
|
|
1271
|
+
return settings.toolspaceEnabled && hasPermission2(grant.permissions, "toolspace:call") && typeof grant.metadata?.sessionId === "string";
|
|
1272
|
+
}
|
|
1273
|
+
async function prepareToolspaceMcpSurface(input) {
|
|
1274
|
+
const { deps, grant } = input;
|
|
1275
|
+
if (!isToolspaceGrant(deps.settings, grant)) {
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
const sessionId = grant.metadata.sessionId;
|
|
1279
|
+
const session = await requireSession2(deps.db, grant.workspaceId, sessionId);
|
|
1280
|
+
const selectedIds = selectedMcpServerIds(session.tools, session.mcpServers.map((server) => server.id));
|
|
1281
|
+
const proxyableIds = [...selectedIds].filter((id) => toolspaceCanProxyServerId(id));
|
|
1282
|
+
if (proxyableIds.length === 0) {
|
|
1283
|
+
return emptyToolspaceSurface(sessionId, grant.subjectId);
|
|
1284
|
+
}
|
|
1285
|
+
let registryPromise = null;
|
|
1286
|
+
const getRegistry = () => registryPromise ??= buildToolspaceRegistry(deps, grant.workspaceId, sessionId);
|
|
1287
|
+
const listing = await resolveToolListing({
|
|
1288
|
+
deps,
|
|
1289
|
+
grant,
|
|
1290
|
+
sessionId,
|
|
1291
|
+
proxyableIds,
|
|
1292
|
+
activeTurnId: session.activeTurnId ?? null,
|
|
1293
|
+
getRegistry
|
|
1294
|
+
});
|
|
1295
|
+
const tools = listing.map((entry) => toolspaceToolFor({ deps, grant, sessionId, entry, getRegistry }));
|
|
1296
|
+
return {
|
|
1297
|
+
sessionId,
|
|
1298
|
+
subjectId: grant.subjectId,
|
|
1299
|
+
tools,
|
|
1300
|
+
// Connections are opened lazily and closed inline (per listing pass, per
|
|
1301
|
+
// call), so there is nothing persistent to tear down here.
|
|
1302
|
+
close: async () => {
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function emptyToolspaceSurface(sessionId, subjectId) {
|
|
1307
|
+
return { sessionId, subjectId, tools: [], close: async () => {
|
|
1308
|
+
} };
|
|
1309
|
+
}
|
|
1310
|
+
async function buildToolspaceRegistry(deps, workspaceId, sessionId) {
|
|
1311
|
+
const runtimeSettings = await settingsWithEnabledCapabilityMcpServers(deps.db, workspaceId, deps.settings);
|
|
1312
|
+
const withSessionServers = await settingsWithSessionMcpServersForToolspace(deps, workspaceId, sessionId, runtimeSettings);
|
|
1313
|
+
return new Map(withSessionServers.mcpServers.map((server) => [server.id, server]));
|
|
1314
|
+
}
|
|
1315
|
+
async function resolveToolListing(input) {
|
|
1316
|
+
const { deps, grant, sessionId, proxyableIds, activeTurnId, getRegistry } = input;
|
|
1317
|
+
const cacheKey = await toolListCacheKey(deps, grant.workspaceId, sessionId, proxyableIds);
|
|
1318
|
+
const cached = readToolListCache(cacheKey);
|
|
1319
|
+
if (cached) {
|
|
1320
|
+
return cached;
|
|
1321
|
+
}
|
|
1322
|
+
if (!activeTurnId) {
|
|
1323
|
+
return [];
|
|
1324
|
+
}
|
|
1325
|
+
const registry = await getRegistry();
|
|
1326
|
+
const entries = [];
|
|
1327
|
+
for (const serverId of proxyableIds) {
|
|
1328
|
+
const config = registry.get(serverId);
|
|
1329
|
+
if (!config || !toolspaceCanProxyServer(config)) {
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(() => null);
|
|
1333
|
+
if (!connection) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
const listed = await connection.client.listTools(void 0, toolspaceRequestOptions(config)).catch(() => ({ tools: [] }));
|
|
1338
|
+
for (const tool of listed.tools) {
|
|
1339
|
+
if (!tool?.name || !allowedByConfig(config, tool.name)) {
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
entries.push({ serverId, tool, requireApproval: config.requireApproval });
|
|
1343
|
+
}
|
|
1344
|
+
} finally {
|
|
1345
|
+
await connection.close();
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
writeToolListCache(cacheKey, entries);
|
|
1349
|
+
return entries;
|
|
1350
|
+
}
|
|
1351
|
+
async function toolListCacheKey(deps, workspaceId, sessionId, proxyableIds) {
|
|
1352
|
+
const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
|
|
1353
|
+
const versions = new Map(metadata.map((server) => [server.id, server.credentialVersion]));
|
|
1354
|
+
const signature = proxyableIds.slice().sort().map((id) => `${id}@${versions.get(id) ?? 0}`).join(",");
|
|
1355
|
+
return `${workspaceId}:${sessionId}:${signature}`;
|
|
1356
|
+
}
|
|
1357
|
+
function readToolListCache(key) {
|
|
1358
|
+
const hit = toolListCache.get(key);
|
|
1359
|
+
if (!hit) {
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
if (hit.expiresAt <= Date.now()) {
|
|
1363
|
+
toolListCache.delete(key);
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
return hit.entries;
|
|
1367
|
+
}
|
|
1368
|
+
function writeToolListCache(key, entries) {
|
|
1369
|
+
if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
|
|
1370
|
+
const now = Date.now();
|
|
1371
|
+
for (const [existingKey, value] of toolListCache) {
|
|
1372
|
+
if (value.expiresAt <= now) {
|
|
1373
|
+
toolListCache.delete(existingKey);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (toolListCache.size >= TOOLSPACE_TOOL_LIST_CACHE_MAX_ENTRIES) {
|
|
1377
|
+
toolListCache.clear();
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
toolListCache.set(key, { expiresAt: Date.now() + TOOLSPACE_TOOL_LIST_TTL_MS, entries });
|
|
1381
|
+
}
|
|
1382
|
+
async function settingsWithSessionMcpServersForToolspace(deps, workspaceId, sessionId, settings) {
|
|
1383
|
+
const encryptionKey = environmentsEncryptionKeyBytes(settings);
|
|
1384
|
+
if (!encryptionKey) {
|
|
1385
|
+
const metadata = await listSessionMcpServerMetadata(deps.db, workspaceId, sessionId);
|
|
1386
|
+
if (metadata.length === 0) {
|
|
1387
|
+
return settings;
|
|
1388
|
+
}
|
|
1389
|
+
throw new Error("session MCP server credentials require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY");
|
|
1390
|
+
}
|
|
1391
|
+
const servers = await listSessionMcpServersForRun(deps.db, workspaceId, sessionId, encryptionKey);
|
|
1392
|
+
if (servers.length === 0) {
|
|
1393
|
+
return settings;
|
|
1394
|
+
}
|
|
1395
|
+
const sessionIds = new Set(servers.map((server) => server.id));
|
|
1396
|
+
return {
|
|
1397
|
+
...settings,
|
|
1398
|
+
mcpServers: [
|
|
1399
|
+
...settings.mcpServers.filter((server) => !sessionIds.has(server.id)),
|
|
1400
|
+
...servers.map((server) => ({
|
|
1401
|
+
id: server.id,
|
|
1402
|
+
...server.name ? { name: server.name } : {},
|
|
1403
|
+
url: server.url,
|
|
1404
|
+
...server.allowedTools ? { allowedTools: server.allowedTools } : {},
|
|
1405
|
+
...server.timeoutMs ? { timeoutMs: server.timeoutMs } : {},
|
|
1406
|
+
cacheToolsList: server.cacheToolsList ?? false,
|
|
1407
|
+
...server.requireApproval !== void 0 ? { requireApproval: server.requireApproval } : {},
|
|
1408
|
+
headers: server.headers
|
|
1409
|
+
}))
|
|
1410
|
+
]
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
async function connectToolspaceServer(input) {
|
|
1414
|
+
const baseFetch = input.config.connectionRef ? connectionBrokerFetch(globalThis.fetch, input) : globalThis.fetch;
|
|
1415
|
+
const client = new Client({ name: `opengeni-toolspace-${input.config.id}`, version: "1.0.0" }, { capabilities: {} });
|
|
1416
|
+
const transport = new StreamableHTTPClientTransport(new URL(input.config.url), {
|
|
1417
|
+
...baseFetch !== globalThis.fetch ? { fetch: baseFetch } : {},
|
|
1418
|
+
requestInit: {
|
|
1419
|
+
headers: toolspaceServerHeaders(input.config)
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
await client.connect(transport, toolspaceRequestOptions(input.config));
|
|
1423
|
+
return {
|
|
1424
|
+
config: input.config,
|
|
1425
|
+
client,
|
|
1426
|
+
close: async () => {
|
|
1427
|
+
await client.close().catch(() => void 0);
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
function toolspaceToolFor(input) {
|
|
1432
|
+
const { deps, grant, sessionId, entry, getRegistry } = input;
|
|
1433
|
+
const { serverId, tool } = entry;
|
|
1434
|
+
const name = prefixedMcpToolName(serverId, tool.name);
|
|
1435
|
+
const approvalRequired = mcpToolRequiresApproval(entry.requireApproval, tool.name);
|
|
1436
|
+
const description = approvalRequired ? `${tool.description ?? tool.name} (unavailable: ${APPROVAL_REQUIRED_MESSAGE})` : tool.description;
|
|
1437
|
+
return {
|
|
1438
|
+
name,
|
|
1439
|
+
...description ? { description } : {},
|
|
1440
|
+
...tool.inputSchema ? { inputSchema: tool.inputSchema } : {},
|
|
1441
|
+
call: async (args) => {
|
|
1442
|
+
if (approvalRequired) {
|
|
1443
|
+
return mcpError(APPROVAL_REQUIRED_MESSAGE);
|
|
1444
|
+
}
|
|
1445
|
+
const reservation = await reserveActiveTurnCall(deps, grant.workspaceId, sessionId);
|
|
1446
|
+
if (reservation.status === "no_active_turn") {
|
|
1447
|
+
return mcpError(TOOLSPACE_NO_ACTIVE_TURN_MESSAGE);
|
|
1448
|
+
}
|
|
1449
|
+
if (reservation.status === "budget_exhausted") {
|
|
1450
|
+
return mcpError(`toolspace call budget exhausted (${deps.settings.toolspaceMaxCallsPerTurn}/turn)`);
|
|
1451
|
+
}
|
|
1452
|
+
const turnId = reservation.turnId;
|
|
1453
|
+
const registry = await getRegistry();
|
|
1454
|
+
const config = registry.get(serverId);
|
|
1455
|
+
if (!config || !toolspaceCanProxyServer(config) || !allowedByConfig(config, tool.name)) {
|
|
1456
|
+
return mcpError(`upstream tool failed: ${name}`);
|
|
1457
|
+
}
|
|
1458
|
+
if (mcpToolRequiresApproval(config.requireApproval, tool.name)) {
|
|
1459
|
+
return mcpError(APPROVAL_REQUIRED_MESSAGE);
|
|
1460
|
+
}
|
|
1461
|
+
const connection = await connectToolspaceServer({ deps, grant, config, sessionId }).catch(() => null);
|
|
1462
|
+
if (!connection) {
|
|
1463
|
+
return mcpError(`upstream tool failed: ${name}`);
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
const callId = crypto.randomUUID();
|
|
1467
|
+
await appendAndPublishEvents2(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
1468
|
+
type: "agent.toolCall.created",
|
|
1469
|
+
turnId,
|
|
1470
|
+
producerId: grant.subjectId,
|
|
1471
|
+
payload: {
|
|
1472
|
+
id: callId,
|
|
1473
|
+
name,
|
|
1474
|
+
arguments: args,
|
|
1475
|
+
origin: "toolspace",
|
|
1476
|
+
subjectId: grant.subjectId,
|
|
1477
|
+
raw: {
|
|
1478
|
+
type: "toolspace_call",
|
|
1479
|
+
serverId,
|
|
1480
|
+
toolName: tool.name
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}]);
|
|
1484
|
+
const output = await callRemoteTool(deps, connection, tool.name, args);
|
|
1485
|
+
await appendAndPublishEvents2(deps.db, deps.bus, grant.workspaceId, sessionId, [{
|
|
1486
|
+
type: "agent.toolCall.output",
|
|
1487
|
+
turnId,
|
|
1488
|
+
producerId: grant.subjectId,
|
|
1489
|
+
payload: {
|
|
1490
|
+
id: callId,
|
|
1491
|
+
output,
|
|
1492
|
+
origin: "toolspace",
|
|
1493
|
+
subjectId: grant.subjectId
|
|
1494
|
+
}
|
|
1495
|
+
}]);
|
|
1496
|
+
return output;
|
|
1497
|
+
} finally {
|
|
1498
|
+
await connection.close();
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
async function callRemoteTool(deps, server, toolName, args) {
|
|
1504
|
+
try {
|
|
1505
|
+
return await server.client.callTool({
|
|
1506
|
+
name: toolName,
|
|
1507
|
+
arguments: args
|
|
1508
|
+
}, void 0, toolspaceRequestOptions(server.config));
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
if (isToolspaceAuthNeededError(error)) {
|
|
1511
|
+
return mcpError(TOOLSPACE_AUTH_NEEDED_MESSAGE);
|
|
1512
|
+
}
|
|
1513
|
+
deps.observability?.warn("toolspace upstream tool call failed", {
|
|
1514
|
+
serverId: server.config.id,
|
|
1515
|
+
toolName,
|
|
1516
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1517
|
+
});
|
|
1518
|
+
return mcpError(`upstream tool failed: ${prefixedMcpToolName(server.config.id, toolName)}`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
async function reserveActiveTurnCall(deps, workspaceId, sessionId) {
|
|
1522
|
+
const session = await requireSession2(deps.db, workspaceId, sessionId);
|
|
1523
|
+
if (!session.activeTurnId) {
|
|
1524
|
+
return { status: "no_active_turn" };
|
|
1525
|
+
}
|
|
1526
|
+
const reservation = await reserveToolspaceCallForTurn(
|
|
1527
|
+
deps.db,
|
|
1528
|
+
workspaceId,
|
|
1529
|
+
sessionId,
|
|
1530
|
+
session.activeTurnId,
|
|
1531
|
+
deps.settings.toolspaceMaxCallsPerTurn
|
|
1532
|
+
);
|
|
1533
|
+
return reservation.reserved ? { status: "ok", turnId: session.activeTurnId } : { status: "budget_exhausted" };
|
|
1534
|
+
}
|
|
1535
|
+
function selectedMcpServerIds(tools, sessionServerIds) {
|
|
1536
|
+
const out = new Set(sessionServerIds);
|
|
1537
|
+
for (const tool of tools) {
|
|
1538
|
+
if (tool.kind === "mcp") {
|
|
1539
|
+
out.add(tool.id);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return out;
|
|
1543
|
+
}
|
|
1544
|
+
function toolspaceCanProxyServerId(serverId) {
|
|
1545
|
+
return serverId !== "opengeni" && !FIRST_PARTY_PROXY_IDS.has(serverId);
|
|
1546
|
+
}
|
|
1547
|
+
function toolspaceCanProxyServer(config) {
|
|
1548
|
+
return toolspaceCanProxyServerId(config.id);
|
|
1549
|
+
}
|
|
1550
|
+
function toolspaceServerHeaders(config) {
|
|
1551
|
+
const headers = {};
|
|
1552
|
+
for (const [name, value] of Object.entries(config.headers ?? {})) {
|
|
1553
|
+
headers[name] = value;
|
|
1554
|
+
}
|
|
1555
|
+
return headers;
|
|
1556
|
+
}
|
|
1557
|
+
function allowedByConfig(config, toolName) {
|
|
1558
|
+
return !config.allowedTools || config.allowedTools.includes(toolName);
|
|
1559
|
+
}
|
|
1560
|
+
function mcpToolRequiresApproval(policy, unprefixedName) {
|
|
1561
|
+
if (policy === true) {
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
return Array.isArray(policy) && policy.includes(unprefixedName);
|
|
1565
|
+
}
|
|
1566
|
+
function mcpError(message) {
|
|
1567
|
+
return {
|
|
1568
|
+
isError: true,
|
|
1569
|
+
content: [{ type: "text", text: message }]
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
function toolspaceRequestOptions(config) {
|
|
1573
|
+
return config.timeoutMs ? { timeout: config.timeoutMs, maxTotalTimeout: config.timeoutMs } : {};
|
|
1574
|
+
}
|
|
1575
|
+
function connectionBrokerFetch(baseFetch, input) {
|
|
1576
|
+
const connectionRef = input.config.connectionRef;
|
|
1577
|
+
if (!connectionRef) {
|
|
1578
|
+
return baseFetch;
|
|
1579
|
+
}
|
|
1580
|
+
const resolveCredential = buildConnectionTokenResolver(input.deps.db, input.deps.settings);
|
|
1581
|
+
return async (requestInput, init) => {
|
|
1582
|
+
const request = await mcpRequestInfo(requestInput, init);
|
|
1583
|
+
const first = await resolveCredential({
|
|
1584
|
+
workspaceId: input.grant.workspaceId,
|
|
1585
|
+
serverId: input.config.id,
|
|
1586
|
+
connectionRef,
|
|
1587
|
+
forceRefresh: false,
|
|
1588
|
+
...request.toolName ? { toolId: request.toolName } : {},
|
|
1589
|
+
subjectId: input.grant.subjectId
|
|
1590
|
+
});
|
|
1591
|
+
if (first.status === "auth_needed") {
|
|
1592
|
+
return await authNeededFetchResponse(input, request, first);
|
|
1593
|
+
}
|
|
1594
|
+
const response = await baseFetch(fetchInputForAttempt(requestInput), withConnectionHeaders(requestInput, init, first.headers));
|
|
1595
|
+
if (response.status === 401) {
|
|
1596
|
+
const refreshed = await resolveCredential({
|
|
1597
|
+
workspaceId: input.grant.workspaceId,
|
|
1598
|
+
serverId: input.config.id,
|
|
1599
|
+
connectionRef,
|
|
1600
|
+
forceRefresh: true,
|
|
1601
|
+
...request.toolName ? { toolId: request.toolName } : {},
|
|
1602
|
+
subjectId: input.grant.subjectId
|
|
1603
|
+
});
|
|
1604
|
+
if (refreshed.status === "auth_needed") {
|
|
1605
|
+
return await authNeededFetchResponse(input, request, refreshed);
|
|
1606
|
+
}
|
|
1607
|
+
return await baseFetch(fetchInputForAttempt(requestInput), withConnectionHeaders(requestInput, init, refreshed.headers));
|
|
1608
|
+
}
|
|
1609
|
+
if (response.status === 403) {
|
|
1610
|
+
return await authNeededFetchResponse(input, request, authNeededFromStatus(input.config, first, "insufficient_scope"));
|
|
1611
|
+
}
|
|
1612
|
+
return response;
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
function authNeededFromStatus(config, first, reason) {
|
|
1616
|
+
const connectionRef = config.connectionRef;
|
|
1617
|
+
return {
|
|
1618
|
+
status: "auth_needed",
|
|
1619
|
+
reason,
|
|
1620
|
+
providerDomain: connectionRef.providerDomain,
|
|
1621
|
+
connectionId: first.connectionId,
|
|
1622
|
+
...connectionRef.scopes ? { scopes: connectionRef.scopes } : {},
|
|
1623
|
+
...connectionRef.resource ? { resource: connectionRef.resource } : {}
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
async function authNeededFetchResponse(input, request, auth) {
|
|
1627
|
+
await appendAndPublishEvents2(input.deps.db, input.deps.bus, input.grant.workspaceId, input.sessionId, [{
|
|
1628
|
+
type: "tool.auth_needed",
|
|
1629
|
+
producerId: input.grant.subjectId,
|
|
1630
|
+
payload: {
|
|
1631
|
+
serverId: input.config.id,
|
|
1632
|
+
toolName: request.toolName ?? null,
|
|
1633
|
+
providerDomain: auth.providerDomain,
|
|
1634
|
+
reason: auth.reason,
|
|
1635
|
+
...auth.connectionId ? { connectionId: auth.connectionId } : {},
|
|
1636
|
+
...auth.scopes ? { scopes: auth.scopes } : {},
|
|
1637
|
+
...auth.resource ? { resource: auth.resource } : {},
|
|
1638
|
+
...auth.authorizationUrl ? { authorizationUrl: auth.authorizationUrl } : {},
|
|
1639
|
+
subjectId: input.grant.subjectId
|
|
1640
|
+
}
|
|
1641
|
+
}]).catch(() => void 0);
|
|
1642
|
+
if (request.method === "tools/call") {
|
|
1643
|
+
return new Response(JSON.stringify({
|
|
1644
|
+
jsonrpc: "2.0",
|
|
1645
|
+
id: request.id ?? null,
|
|
1646
|
+
error: {
|
|
1647
|
+
code: TOOLSPACE_AUTH_NEEDED_ERROR_CODE,
|
|
1648
|
+
message: TOOLSPACE_AUTH_NEEDED_MESSAGE
|
|
1649
|
+
}
|
|
1650
|
+
}), {
|
|
1651
|
+
status: 200,
|
|
1652
|
+
headers: { "content-type": "application/json" }
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
return new Response("Authentication required for MCP server connection", { status: 401 });
|
|
1656
|
+
}
|
|
1657
|
+
async function mcpRequestInfo(_input, init) {
|
|
1658
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
1659
|
+
if (!body) {
|
|
1660
|
+
return {};
|
|
1661
|
+
}
|
|
1662
|
+
try {
|
|
1663
|
+
const parsed = JSON.parse(body);
|
|
1664
|
+
const method = typeof parsed.method === "string" ? parsed.method : void 0;
|
|
1665
|
+
const id = typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null ? parsed.id : void 0;
|
|
1666
|
+
const toolName = method === "tools/call" && typeof parsed.params?.name === "string" ? parsed.params.name : void 0;
|
|
1667
|
+
return {
|
|
1668
|
+
...method ? { method } : {},
|
|
1669
|
+
...id !== void 0 ? { id } : {},
|
|
1670
|
+
...toolName ? { toolName } : {}
|
|
1671
|
+
};
|
|
1672
|
+
} catch {
|
|
1673
|
+
return {};
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
function withConnectionHeaders(_input, init, authHeaders) {
|
|
1677
|
+
const headers = new Headers(init?.headers);
|
|
1678
|
+
for (const [name, value] of Object.entries(authHeaders)) {
|
|
1679
|
+
headers.set(name, value);
|
|
1680
|
+
}
|
|
1681
|
+
return { ...init, headers };
|
|
1682
|
+
}
|
|
1683
|
+
function fetchInputForAttempt(input) {
|
|
1684
|
+
return input;
|
|
1685
|
+
}
|
|
1686
|
+
function isToolspaceAuthNeededError(error) {
|
|
1687
|
+
return error instanceof Error && (error.code === TOOLSPACE_AUTH_NEEDED_ERROR_CODE || error.message.includes(TOOLSPACE_AUTH_NEEDED_MESSAGE));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1218
1690
|
// src/routes/install.ts
|
|
1219
1691
|
import { readFile, stat } from "fs/promises";
|
|
1220
1692
|
import { HTTPException } from "hono/http-exception";
|
|
@@ -1361,13 +1833,16 @@ function isAuthExempt(c, settings) {
|
|
|
1361
1833
|
if (path === "/v1/github/setup" || path === "/v1/github/install/callback" || path === "/v1/github/oauth/callback" || path === "/v1/github/app-manifest/callback") {
|
|
1362
1834
|
return true;
|
|
1363
1835
|
}
|
|
1836
|
+
if (path === "/v1/integrations/oauth/callback" || path === "/v1/integrations/oauth/client-metadata.json") {
|
|
1837
|
+
return true;
|
|
1838
|
+
}
|
|
1364
1839
|
if (githubConnectPathPattern.test(path)) {
|
|
1365
1840
|
return true;
|
|
1366
1841
|
}
|
|
1367
1842
|
if (installExactPaths.has(path) || isInstallRedirectPath(path)) {
|
|
1368
1843
|
return true;
|
|
1369
1844
|
}
|
|
1370
|
-
if (settings.authAllowHealth && path === "/healthz") {
|
|
1845
|
+
if (settings.authAllowHealth && (path === "/healthz" || path === "/readyz")) {
|
|
1371
1846
|
return true;
|
|
1372
1847
|
}
|
|
1373
1848
|
if (settings.authAllowMetrics && path === "/metrics") {
|
|
@@ -1490,7 +1965,7 @@ function registerCapabilityRoutes(app, deps) {
|
|
|
1490
1965
|
}
|
|
1491
1966
|
|
|
1492
1967
|
// src/routes/codex.ts
|
|
1493
|
-
import { environmentsEncryptionKeyBytes } from "@opengeni/config";
|
|
1968
|
+
import { environmentsEncryptionKeyBytes as environmentsEncryptionKeyBytes2 } from "@opengeni/config";
|
|
1494
1969
|
import {
|
|
1495
1970
|
accessTokenExpiry,
|
|
1496
1971
|
buildCodexUsageWindowFromCache,
|
|
@@ -1603,7 +2078,7 @@ function registerCodexRoutes(app, deps) {
|
|
|
1603
2078
|
throw new HTTPException3(502, { message: error instanceof CodexDeviceError ? error.message : "codex token exchange failed" });
|
|
1604
2079
|
}
|
|
1605
2080
|
const id = parseIdToken(tokens.idToken);
|
|
1606
|
-
const key =
|
|
2081
|
+
const key = environmentsEncryptionKeyBytes2(settings);
|
|
1607
2082
|
if (!key) {
|
|
1608
2083
|
throw new HTTPException3(500, { message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured" });
|
|
1609
2084
|
}
|
|
@@ -1746,71 +2221,960 @@ function registerCodexRoutes(app, deps) {
|
|
|
1746
2221
|
if (!row) {
|
|
1747
2222
|
throw new HTTPException3(404, { message: "codex account not found" });
|
|
1748
2223
|
}
|
|
1749
|
-
return c.json(codexAccountJson(row));
|
|
2224
|
+
return c.json(codexAccountJson(row));
|
|
2225
|
+
});
|
|
2226
|
+
app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
|
|
2227
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2228
|
+
await requireAccessGrant2(c, deps, workspaceId, "workspace:admin");
|
|
2229
|
+
const accountId = c.req.param("accountId");
|
|
2230
|
+
const result = await disconnectCodexAccount(db, workspaceId, accountId);
|
|
2231
|
+
return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
|
|
2232
|
+
});
|
|
2233
|
+
app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
|
|
2234
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2235
|
+
await requireAccessGrant2(c, deps, workspaceId, "workspace:admin");
|
|
2236
|
+
const removed = await disconnectAllCodexAccounts(db, workspaceId);
|
|
2237
|
+
return c.json({ disconnected: removed > 0 });
|
|
2238
|
+
});
|
|
2239
|
+
app.get("/v1/workspaces/:workspaceId/codex/usage", async (c) => {
|
|
2240
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2241
|
+
await requireAccessGrant2(c, deps, workspaceId, "workspace:read");
|
|
2242
|
+
const status = await getCodexCredentialStatus(db, workspaceId);
|
|
2243
|
+
if (!status?.credentialId) {
|
|
2244
|
+
throw new HTTPException3(404, { message: "codex subscription is not connected" });
|
|
2245
|
+
}
|
|
2246
|
+
const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
|
|
2247
|
+
return c.json(codexUsageJson(payload));
|
|
2248
|
+
});
|
|
2249
|
+
app.get("/v1/workspaces/:workspaceId/codex/accounts/:accountId/usage", async (c) => {
|
|
2250
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2251
|
+
await requireAccessGrant2(c, deps, workspaceId, "workspace:read");
|
|
2252
|
+
const accountId = c.req.param("accountId");
|
|
2253
|
+
const accounts = await listCodexAccountStatuses(db, workspaceId);
|
|
2254
|
+
if (!accounts.some((account) => account.id === accountId)) {
|
|
2255
|
+
throw new HTTPException3(404, { message: "codex account not found" });
|
|
2256
|
+
}
|
|
2257
|
+
const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, accountId);
|
|
2258
|
+
return c.json(codexUsageJson(payload));
|
|
2259
|
+
});
|
|
2260
|
+
app.post("/v1/workspaces/:workspaceId/codex/usage/refresh", async (c) => {
|
|
2261
|
+
const workspaceId = c.req.param("workspaceId");
|
|
2262
|
+
await requireAccessGrant2(c, deps, workspaceId, "workspace:read");
|
|
2263
|
+
const accounts = await listCodexAccountStatuses(db, workspaceId);
|
|
2264
|
+
const usage = {};
|
|
2265
|
+
const queue = [...accounts];
|
|
2266
|
+
const CONCURRENCY = 4;
|
|
2267
|
+
const worker = async () => {
|
|
2268
|
+
for (; ; ) {
|
|
2269
|
+
const account = queue.shift();
|
|
2270
|
+
if (!account) return;
|
|
2271
|
+
const settled = await Promise.allSettled([fetchCodexUsageForAccount(db, settings, workspaceId, account.id)]);
|
|
2272
|
+
const result = settled[0];
|
|
2273
|
+
usage[account.id] = result.status === "fulfilled" ? codexUsageJson(result.value) : { status: "error", usage: { status: "error", planType: null, fiveHour: null, weekly: null, limitReached: false, fetchedAt: (/* @__PURE__ */ new Date()).toISOString() } };
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, Math.max(1, accounts.length)) }, () => worker()));
|
|
2277
|
+
return c.json({ usage });
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// src/routes/connections.ts
|
|
2282
|
+
import {
|
|
2283
|
+
ConnectionResponse,
|
|
2284
|
+
CreateConnectionRequest,
|
|
2285
|
+
IntegrationClientMetadata,
|
|
2286
|
+
ListConnectionsResponse,
|
|
2287
|
+
OAuthStartRequest,
|
|
2288
|
+
OAuthStartResponse as OAuthStartResponse2,
|
|
2289
|
+
UpdateConnectionRequest
|
|
2290
|
+
} from "@opengeni/contracts";
|
|
2291
|
+
import { requireAccessGrant as requireAccessGrant3, requireEnvironmentEncryption as requireEnvironmentEncryption3 } from "@opengeni/core";
|
|
2292
|
+
import {
|
|
2293
|
+
createConnection as createConnection2,
|
|
2294
|
+
encryptEnvironmentValue as encryptEnvironmentValue4,
|
|
2295
|
+
getConnectionMetadata as getConnectionMetadata2,
|
|
2296
|
+
listConnectionsMetadata,
|
|
2297
|
+
revokeConnection,
|
|
2298
|
+
updateConnection as updateConnection2
|
|
2299
|
+
} from "@opengeni/db";
|
|
2300
|
+
import { HTTPException as HTTPException5 } from "hono/http-exception";
|
|
2301
|
+
|
|
2302
|
+
// src/integrations/oauth-client.ts
|
|
2303
|
+
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2304
|
+
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
2305
|
+
import { parseIntegrationsOauthClientsJson } from "@opengeni/config";
|
|
2306
|
+
import { OAuthStartResponse } from "@opengeni/contracts";
|
|
2307
|
+
import { requireEnvironmentEncryption as requireEnvironmentEncryption2 } from "@opengeni/core";
|
|
2308
|
+
import {
|
|
2309
|
+
consumeIntegrationOAuthStateNonce,
|
|
2310
|
+
createConnection,
|
|
2311
|
+
decryptEnvironmentValue,
|
|
2312
|
+
encryptEnvironmentValue as encryptEnvironmentValue3,
|
|
2313
|
+
getConnectionMetadata,
|
|
2314
|
+
isPrivateAddress,
|
|
2315
|
+
loadIntegrationOAuthClient,
|
|
2316
|
+
storeIntegrationOAuthClient,
|
|
2317
|
+
updateConnection
|
|
2318
|
+
} from "@opengeni/db";
|
|
2319
|
+
import { createSignedState as createSignedState3, readSignedState as readSignedState2 } from "@opengeni/github";
|
|
2320
|
+
import { Buffer } from "buffer";
|
|
2321
|
+
import { createHash, randomBytes } from "crypto";
|
|
2322
|
+
import { lookup } from "dns/promises";
|
|
2323
|
+
import { isIP } from "net";
|
|
2324
|
+
import { HTTPException as HTTPException4 } from "hono/http-exception";
|
|
2325
|
+
var oauthStateTtlMs = 10 * 60 * 1e3;
|
|
2326
|
+
async function startMcpOAuth(deps, context) {
|
|
2327
|
+
const { db, settings } = deps;
|
|
2328
|
+
const resource = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
2329
|
+
const providerDomain = canonicalProviderDomain(context.payload.providerDomain ?? new URL(resource).hostname);
|
|
2330
|
+
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
2331
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
2332
|
+
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
2333
|
+
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
2334
|
+
const existing = context.payload.connectionId ? await getConnectionMetadata(db, context.workspaceId, context.payload.connectionId, context.subjectId) : null;
|
|
2335
|
+
if (context.payload.connectionId && !existing) {
|
|
2336
|
+
throw new HTTPException4(404, { message: "connection not found" });
|
|
2337
|
+
}
|
|
2338
|
+
const discovery = await discoverMcpOAuth(resource, settings);
|
|
2339
|
+
const client = await registerOAuthClient(db, settings, discovery.as, metadataUrl, redirectUri);
|
|
2340
|
+
const verifier = randomPkceVerifier();
|
|
2341
|
+
const authorizeScopes = chooseAuthorizeScopes(context.payload.requestedScopes, discovery.challenge.scope, discovery.prm.scopesSupported);
|
|
2342
|
+
const key = requireEnvironmentEncryption2(settings);
|
|
2343
|
+
const state = createSignedState3(requireIntegrationsStateSecret(settings), {
|
|
2344
|
+
accountId: context.accountId,
|
|
2345
|
+
workspaceId: context.workspaceId,
|
|
2346
|
+
subjectId: context.subjectId,
|
|
2347
|
+
providerDomain,
|
|
2348
|
+
resource,
|
|
2349
|
+
requestedScopes: uniqueStrings(context.payload.requestedScopes ?? []),
|
|
2350
|
+
authorizeScopes,
|
|
2351
|
+
encryptedPkceVerifier: encryptEnvironmentValue3(key, verifier),
|
|
2352
|
+
clientId: client.clientId,
|
|
2353
|
+
tokenEndpoint: discovery.as.tokenEndpoint,
|
|
2354
|
+
authorizationServer: client.authorizationServer,
|
|
2355
|
+
issuer: client.issuer,
|
|
2356
|
+
clientRegistrationMethod: client.method,
|
|
2357
|
+
tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
|
|
2358
|
+
returnPath,
|
|
2359
|
+
...existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}
|
|
2360
|
+
});
|
|
2361
|
+
const authorizationUrl = buildAuthorizationUrl({
|
|
2362
|
+
endpoint: discovery.as.authorizationEndpoint,
|
|
2363
|
+
clientId: client.clientId,
|
|
2364
|
+
redirectUri,
|
|
2365
|
+
state,
|
|
2366
|
+
resource,
|
|
2367
|
+
verifier,
|
|
2368
|
+
scopes: authorizeScopes
|
|
2369
|
+
});
|
|
2370
|
+
return OAuthStartResponse.parse({
|
|
2371
|
+
state,
|
|
2372
|
+
authorizationUrl,
|
|
2373
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString()
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
async function completeMcpOAuthCallback(deps, input) {
|
|
2377
|
+
const { db, settings } = deps;
|
|
2378
|
+
if (!input.state) {
|
|
2379
|
+
throw new HTTPException4(400, { message: "missing OAuth state" });
|
|
2380
|
+
}
|
|
2381
|
+
const state = readOAuthState(input.state, settings);
|
|
2382
|
+
if (!input.code) {
|
|
2383
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }) };
|
|
2384
|
+
}
|
|
2385
|
+
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
2386
|
+
accountId: state.accountId,
|
|
2387
|
+
workspaceId: state.workspaceId,
|
|
2388
|
+
subjectId: state.subjectId,
|
|
2389
|
+
nonce: state.nonce,
|
|
2390
|
+
expiresAt: new Date(state.iat * 1e3 + oauthStateTtlMs),
|
|
2391
|
+
now: /* @__PURE__ */ new Date()
|
|
2392
|
+
});
|
|
2393
|
+
if (!consumed) {
|
|
2394
|
+
throw new HTTPException4(400, { message: "OAuth state has already been used" });
|
|
2395
|
+
}
|
|
2396
|
+
try {
|
|
2397
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, input.requestUrl);
|
|
2398
|
+
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
2399
|
+
const key = requireEnvironmentEncryption2(settings);
|
|
2400
|
+
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
2401
|
+
const client = await clientForState(db, settings, state);
|
|
2402
|
+
const token = await exchangeAuthorizationCode(settings, {
|
|
2403
|
+
code: input.code,
|
|
2404
|
+
verifier,
|
|
2405
|
+
redirectUri,
|
|
2406
|
+
resource: state.resource,
|
|
2407
|
+
tokenEndpoint: state.tokenEndpoint,
|
|
2408
|
+
client
|
|
2409
|
+
});
|
|
2410
|
+
const tools = await verifyMcpToolsList(settings, state.resource, token);
|
|
2411
|
+
const scopes = grantedScopes(token.scopeText, state.authorizeScopes);
|
|
2412
|
+
const credential = credentialBundle(token, state, client);
|
|
2413
|
+
const metadata = {
|
|
2414
|
+
resource: state.resource,
|
|
2415
|
+
authorizationServer: state.authorizationServer,
|
|
2416
|
+
authorizationServerIssuer: state.issuer,
|
|
2417
|
+
tokenEndpoint: state.tokenEndpoint,
|
|
2418
|
+
clientId: client.clientId,
|
|
2419
|
+
clientRegistrationMethod: state.clientRegistrationMethod,
|
|
2420
|
+
mcpTools: tools
|
|
2421
|
+
};
|
|
2422
|
+
const credentialEncrypted = encryptEnvironmentValue3(key, JSON.stringify(credential));
|
|
2423
|
+
const connection = state.connectionId ? await updateConnection(db, {
|
|
2424
|
+
workspaceId: state.workspaceId,
|
|
2425
|
+
connectionId: state.connectionId,
|
|
2426
|
+
visibleToSubjectId: state.subjectId,
|
|
2427
|
+
expectedVersion: state.connectionVersion,
|
|
2428
|
+
providerDomain: state.providerDomain,
|
|
2429
|
+
kind: "oauth2",
|
|
2430
|
+
status: "active",
|
|
2431
|
+
credentialEncrypted,
|
|
2432
|
+
grantedScopes: scopes,
|
|
2433
|
+
expiresAt: token.expiresAt,
|
|
2434
|
+
metadata,
|
|
2435
|
+
updatedBySubjectId: state.subjectId
|
|
2436
|
+
}) : await createConnection(db, {
|
|
2437
|
+
accountId: state.accountId,
|
|
2438
|
+
workspaceId: state.workspaceId,
|
|
2439
|
+
subjectId: null,
|
|
2440
|
+
providerDomain: state.providerDomain,
|
|
2441
|
+
kind: "oauth2",
|
|
2442
|
+
credentialEncrypted,
|
|
2443
|
+
grantedScopes: scopes,
|
|
2444
|
+
expiresAt: token.expiresAt,
|
|
2445
|
+
metadata,
|
|
2446
|
+
createdBySubjectId: state.subjectId
|
|
2447
|
+
});
|
|
2448
|
+
if (!connection) {
|
|
2449
|
+
throw new HTTPException4(409, { message: "connection changed during OAuth reconnect; start again" });
|
|
2450
|
+
}
|
|
2451
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "success", { connectionId: connection.id }) };
|
|
2452
|
+
} catch (error) {
|
|
2453
|
+
if (error instanceof HTTPException4 && error.status >= 400 && error.status < 500) {
|
|
2454
|
+
throw error;
|
|
2455
|
+
}
|
|
2456
|
+
return { redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "oauth_callback_failed" }) };
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
function integrationBaseUrl(publicBaseUrl, requestUrl) {
|
|
2460
|
+
return (publicBaseUrl ?? new URL(requestUrl).origin).replace(/\/+$/, "");
|
|
2461
|
+
}
|
|
2462
|
+
function requireIntegrationsStateSecret(settings) {
|
|
2463
|
+
const secret = settings.integrationsStateSecret?.trim();
|
|
2464
|
+
if (!secret) {
|
|
2465
|
+
throw new HTTPException4(503, { message: "integrations OAuth requires OPENGENI_INTEGRATIONS_STATE_SECRET" });
|
|
2466
|
+
}
|
|
2467
|
+
return secret;
|
|
2468
|
+
}
|
|
2469
|
+
async function discoverMcpOAuth(resource, settings) {
|
|
2470
|
+
const challenge = await probeMcpChallenge(resource, settings);
|
|
2471
|
+
const prm = await discoverProtectedResourceMetadata(resource, settings, challenge.resourceMetadata);
|
|
2472
|
+
const authorizationServer = prm.authorizationServers[0];
|
|
2473
|
+
if (!authorizationServer) {
|
|
2474
|
+
throw new HTTPException4(422, { message: "MCP protected resource metadata did not advertise an authorization server" });
|
|
2475
|
+
}
|
|
2476
|
+
const as = await discoverAuthorizationServerMetadata(authorizationServer, settings);
|
|
2477
|
+
if (!as.codeChallengeMethodsSupported.includes("S256")) {
|
|
2478
|
+
throw new HTTPException4(422, { message: "authorization server does not support required PKCE S256" });
|
|
2479
|
+
}
|
|
2480
|
+
return { challenge, prm, as };
|
|
2481
|
+
}
|
|
2482
|
+
async function probeMcpChallenge(resource, settings) {
|
|
2483
|
+
const response = await fetchOAuth(resource, settings, {
|
|
2484
|
+
method: "GET",
|
|
2485
|
+
headers: { accept: "application/json" }
|
|
2486
|
+
});
|
|
2487
|
+
if (response.status !== 401) {
|
|
2488
|
+
return {};
|
|
2489
|
+
}
|
|
2490
|
+
return parseWwwAuthenticate(response.headers.get("www-authenticate"));
|
|
2491
|
+
}
|
|
2492
|
+
async function discoverProtectedResourceMetadata(resource, settings, advertisedUrl) {
|
|
2493
|
+
const candidates = uniqueStrings([
|
|
2494
|
+
...advertisedUrl ? [advertisedUrl] : [],
|
|
2495
|
+
...wellKnownCandidates(resource, "oauth-protected-resource")
|
|
2496
|
+
]);
|
|
2497
|
+
for (const candidate of candidates) {
|
|
2498
|
+
const payload = await fetchJsonObject(candidate, settings).catch((error) => {
|
|
2499
|
+
if (error instanceof HTTPException4) {
|
|
2500
|
+
throw error;
|
|
2501
|
+
}
|
|
2502
|
+
return null;
|
|
2503
|
+
});
|
|
2504
|
+
if (!payload) {
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
const authorizationServers = stringArray(payload.authorization_servers);
|
|
2508
|
+
if (authorizationServers.length === 0) {
|
|
2509
|
+
continue;
|
|
2510
|
+
}
|
|
2511
|
+
return {
|
|
2512
|
+
authorizationServers,
|
|
2513
|
+
scopesSupported: stringArray(payload.scopes_supported),
|
|
2514
|
+
raw: payload,
|
|
2515
|
+
...stringValue(payload.resource) ? { resource: stringValue(payload.resource) } : {}
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
throw new HTTPException4(422, { message: "could not discover MCP protected resource metadata" });
|
|
2519
|
+
}
|
|
2520
|
+
async function discoverAuthorizationServerMetadata(authorizationServer, settings) {
|
|
2521
|
+
const candidates = uniqueStrings([
|
|
2522
|
+
authorizationServer,
|
|
2523
|
+
...wellKnownCandidates(authorizationServer, "oauth-authorization-server"),
|
|
2524
|
+
...wellKnownCandidates(authorizationServer, "openid-configuration")
|
|
2525
|
+
]);
|
|
2526
|
+
for (const candidate of candidates) {
|
|
2527
|
+
const payload = await fetchJsonObject(candidate, settings).catch((error) => {
|
|
2528
|
+
if (error instanceof HTTPException4) {
|
|
2529
|
+
throw error;
|
|
2530
|
+
}
|
|
2531
|
+
return null;
|
|
2532
|
+
});
|
|
2533
|
+
if (!payload) {
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
const authorizationEndpoint = stringValue(payload.authorization_endpoint);
|
|
2537
|
+
const tokenEndpoint = stringValue(payload.token_endpoint);
|
|
2538
|
+
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
2541
|
+
return {
|
|
2542
|
+
issuer: stringValue(payload.issuer) ?? authorizationServer.replace(/\/+$/, ""),
|
|
2543
|
+
authorizationServer: authorizationServer.replace(/\/+$/, ""),
|
|
2544
|
+
authorizationEndpoint,
|
|
2545
|
+
tokenEndpoint,
|
|
2546
|
+
clientIdMetadataDocumentSupported: payload.client_id_metadata_document_supported === true,
|
|
2547
|
+
codeChallengeMethodsSupported: stringArray(payload.code_challenge_methods_supported),
|
|
2548
|
+
raw: payload,
|
|
2549
|
+
...stringValue(payload.registration_endpoint) ? { registrationEndpoint: stringValue(payload.registration_endpoint) } : {}
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
throw new HTTPException4(422, { message: "could not discover OAuth authorization server metadata" });
|
|
2553
|
+
}
|
|
2554
|
+
async function registerOAuthClient(db, settings, as, metadataUrl, redirectUri) {
|
|
2555
|
+
const operator = operatorClientForAs(settings, as);
|
|
2556
|
+
if (operator) {
|
|
2557
|
+
return operator;
|
|
2558
|
+
}
|
|
2559
|
+
if (as.clientIdMetadataDocumentSupported) {
|
|
2560
|
+
return {
|
|
2561
|
+
method: "cimd",
|
|
2562
|
+
issuer: as.issuer,
|
|
2563
|
+
authorizationServer: as.authorizationServer,
|
|
2564
|
+
clientId: metadataUrl,
|
|
2565
|
+
tokenEndpointAuthMethod: "none"
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
2569
|
+
if (storedClient) {
|
|
2570
|
+
return {
|
|
2571
|
+
method: "dcr",
|
|
2572
|
+
issuer: storedClient.issuer,
|
|
2573
|
+
authorizationServer: storedClient.authorizationServer,
|
|
2574
|
+
clientId: storedClient.clientId,
|
|
2575
|
+
...storedClient.clientSecret ? { clientSecret: storedClient.clientSecret } : {},
|
|
2576
|
+
tokenEndpointAuthMethod: tokenAuthMethod(storedClient.tokenEndpointAuthMethod, Boolean(storedClient.clientSecret))
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
if (!as.registrationEndpoint) {
|
|
2580
|
+
throw new HTTPException4(422, {
|
|
2581
|
+
message: "manual OAuth client credentials are required for this authorization server"
|
|
2582
|
+
});
|
|
2583
|
+
}
|
|
2584
|
+
const dcr = await dynamicClientRegistration(settings, as, redirectUri);
|
|
2585
|
+
const key = dcr.clientSecret ? requireEnvironmentEncryption2(settings) : null;
|
|
2586
|
+
const storedWinner = await storeIntegrationOAuthClient(db, {
|
|
2587
|
+
issuer: as.issuer,
|
|
2588
|
+
authorizationServer: as.authorizationServer,
|
|
2589
|
+
clientId: dcr.clientId,
|
|
2590
|
+
clientSecretEncrypted: dcr.clientSecret && key ? encryptEnvironmentValue3(key, dcr.clientSecret) : null,
|
|
2591
|
+
tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
|
|
2592
|
+
metadata: {
|
|
2593
|
+
registrationEndpoint: as.registrationEndpoint,
|
|
2594
|
+
registeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
if (storedWinner.clientId !== dcr.clientId) {
|
|
2598
|
+
const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
|
|
2599
|
+
if (!winner) {
|
|
2600
|
+
throw new HTTPException4(422, { message: "OAuth client registration could not be loaded after a registration race" });
|
|
2601
|
+
}
|
|
2602
|
+
return dcrRegistrationFromStored(winner);
|
|
2603
|
+
}
|
|
2604
|
+
return dcr;
|
|
2605
|
+
}
|
|
2606
|
+
function dcrRegistrationFromStored(stored) {
|
|
2607
|
+
return {
|
|
2608
|
+
method: "dcr",
|
|
2609
|
+
issuer: stored.issuer,
|
|
2610
|
+
authorizationServer: stored.authorizationServer,
|
|
2611
|
+
clientId: stored.clientId,
|
|
2612
|
+
...stored.clientSecret ? { clientSecret: stored.clientSecret } : {},
|
|
2613
|
+
tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret))
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
function operatorClientForAs(settings, as) {
|
|
2617
|
+
const entry = operatorClientEntryFor(settings, [as.issuer, as.authorizationServer]);
|
|
2618
|
+
if (!entry) {
|
|
2619
|
+
return null;
|
|
2620
|
+
}
|
|
2621
|
+
return {
|
|
2622
|
+
method: "operator",
|
|
2623
|
+
issuer: as.issuer,
|
|
2624
|
+
authorizationServer: as.authorizationServer,
|
|
2625
|
+
clientId: entry.clientId,
|
|
2626
|
+
...entry.clientSecret ? { clientSecret: entry.clientSecret } : {},
|
|
2627
|
+
tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret))
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
function operatorClientEntryFor(settings, candidates) {
|
|
2631
|
+
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
2632
|
+
const exactKeys = uniqueStrings(candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]));
|
|
2633
|
+
for (const key of exactKeys) {
|
|
2634
|
+
const entry = configured[key];
|
|
2635
|
+
if (entry) {
|
|
2636
|
+
return entry;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
2640
|
+
for (const [key, entry] of Object.entries(configured)) {
|
|
2641
|
+
if (normalizedCandidates.has(normalizedIssuerKey(key))) {
|
|
2642
|
+
return entry;
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
return null;
|
|
2646
|
+
}
|
|
2647
|
+
function normalizedIssuerKey(value) {
|
|
2648
|
+
return value.replace(/\/+$/, "");
|
|
2649
|
+
}
|
|
2650
|
+
async function dynamicClientRegistration(settings, as, redirectUri) {
|
|
2651
|
+
if (!as.registrationEndpoint) {
|
|
2652
|
+
throw new HTTPException4(422, { message: "authorization server does not support dynamic client registration" });
|
|
2653
|
+
}
|
|
2654
|
+
await assertOAuthFetchAllowed(as.registrationEndpoint, settings);
|
|
2655
|
+
const response = await fetchOAuth(as.registrationEndpoint, settings, {
|
|
2656
|
+
method: "POST",
|
|
2657
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
2658
|
+
body: JSON.stringify({
|
|
2659
|
+
client_name: "OpenGeni",
|
|
2660
|
+
redirect_uris: [redirectUri],
|
|
2661
|
+
token_endpoint_auth_method: "none",
|
|
2662
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
2663
|
+
response_types: ["code"]
|
|
2664
|
+
})
|
|
2665
|
+
});
|
|
2666
|
+
if (!response.ok) {
|
|
2667
|
+
throw new HTTPException4(422, { message: `dynamic client registration failed with HTTP ${response.status}` });
|
|
2668
|
+
}
|
|
2669
|
+
const payload = await response.json();
|
|
2670
|
+
const clientId = stringValue(payload.client_id);
|
|
2671
|
+
if (!clientId) {
|
|
2672
|
+
throw new HTTPException4(422, { message: "dynamic client registration response did not include client_id" });
|
|
2673
|
+
}
|
|
2674
|
+
const clientSecret = stringValue(payload.client_secret);
|
|
2675
|
+
return {
|
|
2676
|
+
method: "dcr",
|
|
2677
|
+
issuer: as.issuer,
|
|
2678
|
+
authorizationServer: as.authorizationServer,
|
|
2679
|
+
clientId,
|
|
2680
|
+
...clientSecret ? { clientSecret } : {},
|
|
2681
|
+
tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.token_endpoint_auth_method), Boolean(clientSecret))
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
function buildAuthorizationUrl(input) {
|
|
2685
|
+
const url = new URL(input.endpoint);
|
|
2686
|
+
url.searchParams.set("response_type", "code");
|
|
2687
|
+
url.searchParams.set("client_id", input.clientId);
|
|
2688
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
2689
|
+
url.searchParams.set("state", input.state);
|
|
2690
|
+
url.searchParams.set("resource", input.resource);
|
|
2691
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
2692
|
+
url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
|
|
2693
|
+
if (input.scopes.length > 0) {
|
|
2694
|
+
url.searchParams.set("scope", input.scopes.join(" "));
|
|
2695
|
+
}
|
|
2696
|
+
return url.toString();
|
|
2697
|
+
}
|
|
2698
|
+
function readOAuthState(state, settings) {
|
|
2699
|
+
const payload = readSignedState2(state, requireIntegrationsStateSecret(settings));
|
|
2700
|
+
if (!payload) {
|
|
2701
|
+
throw new HTTPException4(400, { message: "invalid or expired OAuth state" });
|
|
2702
|
+
}
|
|
2703
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2704
|
+
const iat = numberValue(payload.iat);
|
|
2705
|
+
if (iat === void 0 || nowSeconds - iat > oauthStateTtlMs / 1e3 || nowSeconds < iat) {
|
|
2706
|
+
throw new HTTPException4(400, { message: "invalid or expired OAuth state" });
|
|
2707
|
+
}
|
|
2708
|
+
const parsed = {
|
|
2709
|
+
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
2710
|
+
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
2711
|
+
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
2712
|
+
providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
|
|
2713
|
+
resource: requiredString(payload.resource, "state.resource"),
|
|
2714
|
+
requestedScopes: stringArray(payload.requestedScopes),
|
|
2715
|
+
authorizeScopes: stringArray(payload.authorizeScopes),
|
|
2716
|
+
encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier, "state.encryptedPkceVerifier"),
|
|
2717
|
+
clientId: requiredString(payload.clientId, "state.clientId"),
|
|
2718
|
+
tokenEndpoint: requiredString(payload.tokenEndpoint, "state.tokenEndpoint"),
|
|
2719
|
+
authorizationServer: requiredString(payload.authorizationServer, "state.authorizationServer"),
|
|
2720
|
+
issuer: requiredString(payload.issuer, "state.issuer"),
|
|
2721
|
+
clientRegistrationMethod: registrationMethod(payload.clientRegistrationMethod),
|
|
2722
|
+
tokenEndpointAuthMethod: tokenAuthMethod(stringValue(payload.tokenEndpointAuthMethod), false),
|
|
2723
|
+
returnPath: safeReturnPath(stringValue(payload.returnPath) ?? "/integrations"),
|
|
2724
|
+
nonce: requiredString(payload.nonce, "state.nonce"),
|
|
2725
|
+
iat
|
|
2726
|
+
};
|
|
2727
|
+
const connectionId = stringValue(payload.connectionId);
|
|
2728
|
+
const connectionVersion = numberValue(payload.connectionVersion);
|
|
2729
|
+
return {
|
|
2730
|
+
...parsed,
|
|
2731
|
+
...connectionId ? { connectionId } : {},
|
|
2732
|
+
...connectionVersion !== void 0 ? { connectionVersion } : {}
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
async function clientForState(db, settings, state) {
|
|
2736
|
+
if (state.clientRegistrationMethod === "cimd") {
|
|
2737
|
+
return {
|
|
2738
|
+
method: "cimd",
|
|
2739
|
+
issuer: state.issuer,
|
|
2740
|
+
authorizationServer: state.authorizationServer,
|
|
2741
|
+
clientId: state.clientId,
|
|
2742
|
+
tokenEndpointAuthMethod: "none"
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
if (state.clientRegistrationMethod === "dcr") {
|
|
2746
|
+
const stored = await loadIntegrationOAuthClient(db, settings, state.issuer);
|
|
2747
|
+
if (!stored || stored.clientId !== state.clientId) {
|
|
2748
|
+
throw new HTTPException4(400, { message: "OAuth client registration is no longer available" });
|
|
2749
|
+
}
|
|
2750
|
+
return {
|
|
2751
|
+
method: "dcr",
|
|
2752
|
+
issuer: stored.issuer,
|
|
2753
|
+
authorizationServer: stored.authorizationServer,
|
|
2754
|
+
clientId: stored.clientId,
|
|
2755
|
+
...stored.clientSecret ? { clientSecret: stored.clientSecret } : {},
|
|
2756
|
+
tokenEndpointAuthMethod: tokenAuthMethod(stored.tokenEndpointAuthMethod, Boolean(stored.clientSecret))
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
const entry = operatorClientEntryFor(settings, [state.issuer, state.authorizationServer]);
|
|
2760
|
+
if (!entry || entry.clientId !== state.clientId) {
|
|
2761
|
+
throw new HTTPException4(400, { message: "operator OAuth client credentials are no longer available" });
|
|
2762
|
+
}
|
|
2763
|
+
return {
|
|
2764
|
+
method: "operator",
|
|
2765
|
+
issuer: state.issuer,
|
|
2766
|
+
authorizationServer: state.authorizationServer,
|
|
2767
|
+
clientId: entry.clientId,
|
|
2768
|
+
...entry.clientSecret ? { clientSecret: entry.clientSecret } : {},
|
|
2769
|
+
tokenEndpointAuthMethod: tokenAuthMethod(entry.tokenEndpointAuthMethod, Boolean(entry.clientSecret))
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
async function exchangeAuthorizationCode(settings, input) {
|
|
2773
|
+
await assertOAuthFetchAllowed(input.tokenEndpoint, settings);
|
|
2774
|
+
const body = new URLSearchParams();
|
|
2775
|
+
body.set("grant_type", "authorization_code");
|
|
2776
|
+
body.set("code", input.code);
|
|
2777
|
+
body.set("redirect_uri", input.redirectUri);
|
|
2778
|
+
body.set("code_verifier", input.verifier);
|
|
2779
|
+
body.set("resource", input.resource);
|
|
2780
|
+
body.set("client_id", input.client.clientId);
|
|
2781
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
2782
|
+
if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_post") {
|
|
2783
|
+
body.set("client_secret", input.client.clientSecret);
|
|
2784
|
+
} else if (input.client.clientSecret && input.client.tokenEndpointAuthMethod === "client_secret_basic") {
|
|
2785
|
+
headers.authorization = `Basic ${Buffer.from(`${input.client.clientId}:${input.client.clientSecret}`).toString("base64")}`;
|
|
2786
|
+
}
|
|
2787
|
+
const response = await fetchOAuth(input.tokenEndpoint, settings, { method: "POST", headers, body });
|
|
2788
|
+
if (!response.ok) {
|
|
2789
|
+
throw new Error(`OAuth token endpoint returned HTTP ${response.status}`);
|
|
2790
|
+
}
|
|
2791
|
+
const payload = await response.json();
|
|
2792
|
+
const accessToken = stringValue(payload.access_token);
|
|
2793
|
+
if (!accessToken) {
|
|
2794
|
+
throw new Error("OAuth token response did not include access_token");
|
|
2795
|
+
}
|
|
2796
|
+
return {
|
|
2797
|
+
accessToken,
|
|
2798
|
+
tokenType: stringValue(payload.token_type) ?? "Bearer",
|
|
2799
|
+
expiresAt: expiresAtFromTokenResponse(payload),
|
|
2800
|
+
raw: payload,
|
|
2801
|
+
...stringValue(payload.refresh_token) ? { refreshToken: stringValue(payload.refresh_token) } : {},
|
|
2802
|
+
...stringValue(payload.scope) ? { scopeText: stringValue(payload.scope) } : {}
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
async function verifyMcpToolsList(settings, resource, token) {
|
|
2806
|
+
await assertOAuthFetchAllowed(resource, settings);
|
|
2807
|
+
const client = new Client2({ name: "opengeni-integration-verify", version: "0.1.0" }, { capabilities: {} });
|
|
2808
|
+
try {
|
|
2809
|
+
const transport = new StreamableHTTPClientTransport2(new URL(resource), {
|
|
2810
|
+
requestInit: {
|
|
2811
|
+
headers: { authorization: `${token.tokenType} ${token.accessToken}` }
|
|
2812
|
+
},
|
|
2813
|
+
fetch: (url, init) => fetchOAuth(url.toString(), settings, init)
|
|
2814
|
+
});
|
|
2815
|
+
await client.connect(transport, { timeout: 1e4, maxTotalTimeout: 1e4 });
|
|
2816
|
+
const listed = await client.listTools(void 0, { timeout: 1e4, maxTotalTimeout: 1e4 });
|
|
2817
|
+
return listed.tools.map((tool) => ({
|
|
2818
|
+
name: tool.name,
|
|
2819
|
+
...tool.description ? { description: tool.description } : {}
|
|
2820
|
+
}));
|
|
2821
|
+
} finally {
|
|
2822
|
+
await client.close().catch(() => void 0);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
function credentialBundle(token, state, client) {
|
|
2826
|
+
return {
|
|
2827
|
+
access_token: token.accessToken,
|
|
2828
|
+
...token.refreshToken ? { refresh_token: token.refreshToken } : {},
|
|
2829
|
+
token_type: token.tokenType,
|
|
2830
|
+
...token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {},
|
|
2831
|
+
resource: state.resource,
|
|
2832
|
+
...token.scopeText ? { scope: token.scopeText } : state.authorizeScopes.length ? { scope: state.authorizeScopes.join(" ") } : {},
|
|
2833
|
+
token_endpoint: state.tokenEndpoint,
|
|
2834
|
+
client_id: client.clientId,
|
|
2835
|
+
...client.clientSecret ? { client_secret: client.clientSecret, token_endpoint_auth_method: client.tokenEndpointAuthMethod } : {}
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
function callbackReturnPath(returnPath, status, params) {
|
|
2839
|
+
const url = new URL(returnPath, "https://opengeni.local");
|
|
2840
|
+
url.searchParams.set("integration_oauth", status);
|
|
2841
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2842
|
+
url.searchParams.set(key, value);
|
|
2843
|
+
}
|
|
2844
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
2845
|
+
}
|
|
2846
|
+
function canonicalMcpResource(value) {
|
|
2847
|
+
if (!value) {
|
|
2848
|
+
throw new HTTPException4(400, { message: "mcpUrl is required" });
|
|
2849
|
+
}
|
|
2850
|
+
let url;
|
|
2851
|
+
try {
|
|
2852
|
+
url = new URL(value);
|
|
2853
|
+
} catch {
|
|
2854
|
+
throw new HTTPException4(422, { message: "MCP resource URL is invalid" });
|
|
2855
|
+
}
|
|
2856
|
+
url.hash = "";
|
|
2857
|
+
return url.toString();
|
|
2858
|
+
}
|
|
2859
|
+
function canonicalProviderDomain(value) {
|
|
2860
|
+
return value.trim().toLowerCase().replace(/^www\./, "");
|
|
2861
|
+
}
|
|
2862
|
+
function safeReturnPath(value) {
|
|
2863
|
+
if (!value.startsWith("/") || value.startsWith("//")) {
|
|
2864
|
+
throw new HTTPException4(400, { message: "OAuth returnPath must be a relative path" });
|
|
2865
|
+
}
|
|
2866
|
+
const parsed = new URL(value, "https://opengeni.local");
|
|
2867
|
+
if (parsed.origin !== "https://opengeni.local") {
|
|
2868
|
+
throw new HTTPException4(400, { message: "OAuth returnPath must be a relative path" });
|
|
2869
|
+
}
|
|
2870
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
2871
|
+
}
|
|
2872
|
+
async function fetchJsonObject(url, settings) {
|
|
2873
|
+
const response = await fetchOAuth(url, settings, { headers: { accept: "application/json" } });
|
|
2874
|
+
if (!response.ok) {
|
|
2875
|
+
throw new Error(`HTTP ${response.status}`);
|
|
2876
|
+
}
|
|
2877
|
+
const payload = await response.json();
|
|
2878
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2879
|
+
throw new Error("metadata response was not a JSON object");
|
|
2880
|
+
}
|
|
2881
|
+
return payload;
|
|
2882
|
+
}
|
|
2883
|
+
async function fetchOAuth(rawUrl, settings, init = {}, hop = 0) {
|
|
2884
|
+
await assertOAuthFetchAllowed(rawUrl, settings);
|
|
2885
|
+
const response = await fetch(rawUrl, { ...init, redirect: "manual" });
|
|
2886
|
+
if (response.status < 300 || response.status >= 400) {
|
|
2887
|
+
return response;
|
|
2888
|
+
}
|
|
2889
|
+
if (hop >= 3) {
|
|
2890
|
+
throw new HTTPException4(422, { message: "OAuth fetch exceeded maximum redirect hops" });
|
|
2891
|
+
}
|
|
2892
|
+
const location = response.headers.get("location");
|
|
2893
|
+
if (!location) {
|
|
2894
|
+
throw new HTTPException4(422, { message: "OAuth fetch redirect was missing Location" });
|
|
2895
|
+
}
|
|
2896
|
+
let nextUrl;
|
|
2897
|
+
try {
|
|
2898
|
+
nextUrl = new URL(location, rawUrl).toString();
|
|
2899
|
+
} catch {
|
|
2900
|
+
throw new HTTPException4(422, { message: "OAuth fetch redirect Location was invalid" });
|
|
2901
|
+
}
|
|
2902
|
+
return await fetchOAuth(nextUrl, settings, init, hop + 1);
|
|
2903
|
+
}
|
|
2904
|
+
async function assertOAuthFetchAllowed(rawUrl, settings) {
|
|
2905
|
+
const url = new URL(rawUrl);
|
|
2906
|
+
if (!["https:", "http:"].includes(url.protocol)) {
|
|
2907
|
+
throw new HTTPException4(422, { message: "OAuth discovery only supports http and https URLs" });
|
|
2908
|
+
}
|
|
2909
|
+
if (settings.integrationsAllowPrivateNetworkTargets || ["local", "test"].includes(settings.environment)) {
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
if (url.protocol !== "https:") {
|
|
2913
|
+
throw new HTTPException4(422, { message: "OAuth discovery targets must use https outside local/test" });
|
|
2914
|
+
}
|
|
2915
|
+
const hostname = url.hostname.toLowerCase();
|
|
2916
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
2917
|
+
throw new HTTPException4(422, { message: "OAuth discovery may not target localhost" });
|
|
2918
|
+
}
|
|
2919
|
+
const literal2 = isIP(hostname);
|
|
2920
|
+
const addresses = literal2 ? [hostname] : (await lookup(hostname, { all: true })).map((entry) => entry.address);
|
|
2921
|
+
if (addresses.some(isPrivateAddress)) {
|
|
2922
|
+
throw new HTTPException4(422, { message: "OAuth discovery may not target private network addresses" });
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
function parseWwwAuthenticate(header) {
|
|
2926
|
+
if (!header) {
|
|
2927
|
+
return {};
|
|
2928
|
+
}
|
|
2929
|
+
const bearerIndex = header.toLowerCase().indexOf("bearer");
|
|
2930
|
+
if (bearerIndex < 0) {
|
|
2931
|
+
return {};
|
|
2932
|
+
}
|
|
2933
|
+
const paramsText = header.slice(bearerIndex + "bearer".length);
|
|
2934
|
+
const params = {};
|
|
2935
|
+
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
|
|
2936
|
+
let match;
|
|
2937
|
+
while ((match = re.exec(paramsText)) !== null) {
|
|
2938
|
+
const raw = match[2];
|
|
2939
|
+
params[match[1].toLowerCase()] = raw.startsWith('"') ? raw.slice(1, -1).replace(/\\"/g, '"') : raw;
|
|
2940
|
+
}
|
|
2941
|
+
return {
|
|
2942
|
+
...params.resource_metadata ? { resourceMetadata: params.resource_metadata } : {},
|
|
2943
|
+
...params.scope ? { scope: params.scope.split(/\s+/).filter(Boolean) } : {},
|
|
2944
|
+
...params.error ? { error: params.error } : {}
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
function wellKnownCandidates(rawUrl, name) {
|
|
2948
|
+
const url = new URL(rawUrl);
|
|
2949
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
2950
|
+
return uniqueStrings([
|
|
2951
|
+
`${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
|
|
2952
|
+
`${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
|
|
2953
|
+
`${url.origin}/.well-known/${name}`
|
|
2954
|
+
]);
|
|
2955
|
+
}
|
|
2956
|
+
function chooseAuthorizeScopes(requested, challenged, supported) {
|
|
2957
|
+
if (requested?.length) {
|
|
2958
|
+
return uniqueStrings(requested);
|
|
2959
|
+
}
|
|
2960
|
+
if (challenged?.length) {
|
|
2961
|
+
return uniqueStrings(challenged);
|
|
2962
|
+
}
|
|
2963
|
+
return uniqueStrings(supported);
|
|
2964
|
+
}
|
|
2965
|
+
function grantedScopes(scopeText, fallback) {
|
|
2966
|
+
if (scopeText) {
|
|
2967
|
+
return uniqueStrings(scopeText.split(/\s+/).filter(Boolean));
|
|
2968
|
+
}
|
|
2969
|
+
return fallback;
|
|
2970
|
+
}
|
|
2971
|
+
function tokenAuthMethod(raw, hasSecret) {
|
|
2972
|
+
if (raw === "client_secret_post" || raw === "client_secret_basic") {
|
|
2973
|
+
return raw;
|
|
2974
|
+
}
|
|
2975
|
+
return hasSecret ? "client_secret_post" : "none";
|
|
2976
|
+
}
|
|
2977
|
+
function registrationMethod(value) {
|
|
2978
|
+
if (value === "operator" || value === "cimd" || value === "dcr") {
|
|
2979
|
+
return value;
|
|
2980
|
+
}
|
|
2981
|
+
throw new HTTPException4(400, { message: "invalid OAuth state" });
|
|
2982
|
+
}
|
|
2983
|
+
function expiresAtFromTokenResponse(payload) {
|
|
2984
|
+
const expiresAt = stringValue(payload.expires_at);
|
|
2985
|
+
if (expiresAt) {
|
|
2986
|
+
const parsed = new Date(expiresAt);
|
|
2987
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
2988
|
+
}
|
|
2989
|
+
const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : Number(payload.expires_in);
|
|
2990
|
+
if (Number.isFinite(expiresIn) && expiresIn > 0) {
|
|
2991
|
+
return new Date(Date.now() + expiresIn * 1e3);
|
|
2992
|
+
}
|
|
2993
|
+
return null;
|
|
2994
|
+
}
|
|
2995
|
+
function pkceChallenge(verifier) {
|
|
2996
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
2997
|
+
}
|
|
2998
|
+
function randomPkceVerifier() {
|
|
2999
|
+
return randomBytes(32).toString("base64url");
|
|
3000
|
+
}
|
|
3001
|
+
function uniqueStrings(values) {
|
|
3002
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
3003
|
+
}
|
|
3004
|
+
function stringArray(value) {
|
|
3005
|
+
return Array.isArray(value) ? uniqueStrings(value.filter((entry) => typeof entry === "string")) : [];
|
|
3006
|
+
}
|
|
3007
|
+
function stringValue(value) {
|
|
3008
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
3009
|
+
}
|
|
3010
|
+
function numberValue(value) {
|
|
3011
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
3012
|
+
}
|
|
3013
|
+
function requiredString(value, field) {
|
|
3014
|
+
const result = stringValue(value);
|
|
3015
|
+
if (!result) {
|
|
3016
|
+
throw new HTTPException4(400, { message: `invalid OAuth state: missing ${field}` });
|
|
3017
|
+
}
|
|
3018
|
+
return result;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// src/routes/connections.ts
|
|
3022
|
+
function registerConnectionRoutes(app, deps) {
|
|
3023
|
+
const { db, settings } = deps;
|
|
3024
|
+
function assertIntegrationsEnabled() {
|
|
3025
|
+
if (!settings.integrationsEnabled) {
|
|
3026
|
+
throw new HTTPException5(404, { message: "integrations are not enabled for this deployment" });
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
app.get("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
3030
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3031
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:read");
|
|
3032
|
+
return c.json(ListConnectionsResponse.parse({
|
|
3033
|
+
connections: await listConnectionsMetadata(db, workspaceId, grant.subjectId)
|
|
3034
|
+
}));
|
|
1750
3035
|
});
|
|
1751
|
-
app.
|
|
3036
|
+
app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
1752
3037
|
const workspaceId = c.req.param("workspaceId");
|
|
1753
|
-
await
|
|
1754
|
-
const
|
|
1755
|
-
const
|
|
1756
|
-
|
|
3038
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
3039
|
+
const payload = CreateConnectionRequest.parse(await c.req.json());
|
|
3040
|
+
const key = requireEnvironmentEncryption3(settings);
|
|
3041
|
+
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
3042
|
+
const connection = await createConnection2(db, {
|
|
3043
|
+
accountId: grant.accountId,
|
|
3044
|
+
workspaceId,
|
|
3045
|
+
subjectId,
|
|
3046
|
+
providerDomain: payload.providerDomain,
|
|
3047
|
+
kind: payload.kind,
|
|
3048
|
+
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
3049
|
+
grantedScopes: payload.grantedScopes,
|
|
3050
|
+
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
|
|
3051
|
+
metadata: payload.metadata,
|
|
3052
|
+
createdBySubjectId: grant.subjectId
|
|
3053
|
+
});
|
|
3054
|
+
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
1757
3055
|
});
|
|
1758
|
-
app.
|
|
3056
|
+
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
1759
3057
|
const workspaceId = c.req.param("workspaceId");
|
|
1760
|
-
await
|
|
1761
|
-
const
|
|
1762
|
-
|
|
3058
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:read");
|
|
3059
|
+
const connection = await getConnectionMetadata2(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
|
|
3060
|
+
if (!connection) {
|
|
3061
|
+
throw new HTTPException5(404, { message: "connection not found" });
|
|
3062
|
+
}
|
|
3063
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
1763
3064
|
});
|
|
1764
|
-
app.
|
|
3065
|
+
app.patch("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
1765
3066
|
const workspaceId = c.req.param("workspaceId");
|
|
1766
|
-
await
|
|
1767
|
-
const
|
|
1768
|
-
if (
|
|
1769
|
-
|
|
3067
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
3068
|
+
const payload = UpdateConnectionRequest.parse(await c.req.json());
|
|
3069
|
+
if (payload.status !== void 0) {
|
|
3070
|
+
if (payload.status !== "active") {
|
|
3071
|
+
throw new HTTPException5(400, { message: 'status can only be set to "active"; use DELETE to revoke' });
|
|
3072
|
+
}
|
|
3073
|
+
if (payload.credential === void 0) {
|
|
3074
|
+
throw new HTTPException5(400, { message: "reactivating a connection requires a new credential" });
|
|
3075
|
+
}
|
|
1770
3076
|
}
|
|
1771
|
-
const
|
|
1772
|
-
|
|
3077
|
+
const key = payload.credential === void 0 ? null : requireEnvironmentEncryption3(settings);
|
|
3078
|
+
const subjectId = payload.subjectId === void 0 ? void 0 : writableSubjectId(payload.subjectId, grant.subjectId);
|
|
3079
|
+
const connection = await updateConnection2(db, {
|
|
3080
|
+
workspaceId,
|
|
3081
|
+
connectionId: c.req.param("connectionId"),
|
|
3082
|
+
visibleToSubjectId: grant.subjectId,
|
|
3083
|
+
updatedBySubjectId: grant.subjectId,
|
|
3084
|
+
...payload.providerDomain !== void 0 ? { providerDomain: payload.providerDomain } : {},
|
|
3085
|
+
...subjectId !== void 0 ? { subjectId } : {},
|
|
3086
|
+
...payload.kind !== void 0 ? { kind: payload.kind } : {},
|
|
3087
|
+
...payload.status !== void 0 ? { status: payload.status } : {},
|
|
3088
|
+
...payload.credential !== void 0 && key ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) } : {},
|
|
3089
|
+
...payload.grantedScopes !== void 0 ? { grantedScopes: payload.grantedScopes } : {},
|
|
3090
|
+
...payload.expiresAt !== void 0 ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {},
|
|
3091
|
+
...payload.metadata !== void 0 ? { metadata: payload.metadata } : {}
|
|
3092
|
+
});
|
|
3093
|
+
if (!connection) {
|
|
3094
|
+
throw new HTTPException5(404, { message: "connection not found" });
|
|
3095
|
+
}
|
|
3096
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
1773
3097
|
});
|
|
1774
|
-
app.
|
|
3098
|
+
app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
1775
3099
|
const workspaceId = c.req.param("workspaceId");
|
|
1776
|
-
await
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
throw new HTTPException3(404, { message: "codex account not found" });
|
|
3100
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
3101
|
+
const connection = await revokeConnection(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
|
|
3102
|
+
if (!connection) {
|
|
3103
|
+
throw new HTTPException5(404, { message: "connection not found" });
|
|
1781
3104
|
}
|
|
1782
|
-
|
|
1783
|
-
return c.json(codexUsageJson(payload));
|
|
3105
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
1784
3106
|
});
|
|
1785
|
-
app.post("/v1/workspaces/:workspaceId/
|
|
3107
|
+
app.post("/v1/workspaces/:workspaceId/connections/oauth/start", async (c) => {
|
|
3108
|
+
assertIntegrationsEnabled();
|
|
1786
3109
|
const workspaceId = c.req.param("workspaceId");
|
|
1787
|
-
await
|
|
1788
|
-
const
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
const
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
3110
|
+
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
3111
|
+
const parsed = OAuthStartRequest.safeParse(await c.req.json());
|
|
3112
|
+
if (!parsed.success) {
|
|
3113
|
+
throw new HTTPException5(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
|
|
3114
|
+
}
|
|
3115
|
+
const payload = parsed.data;
|
|
3116
|
+
const result = await startMcpOAuth({ db, settings }, {
|
|
3117
|
+
accountId: grant.accountId,
|
|
3118
|
+
workspaceId,
|
|
3119
|
+
subjectId: grant.subjectId,
|
|
3120
|
+
requestUrl: c.req.url,
|
|
3121
|
+
payload
|
|
3122
|
+
});
|
|
3123
|
+
return c.json(OAuthStartResponse2.parse(result));
|
|
3124
|
+
});
|
|
3125
|
+
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
3126
|
+
assertIntegrationsEnabled();
|
|
3127
|
+
const result = await completeMcpOAuthCallback({ db, settings }, {
|
|
3128
|
+
code: c.req.query("code"),
|
|
3129
|
+
state: c.req.query("state"),
|
|
3130
|
+
requestUrl: c.req.url
|
|
3131
|
+
});
|
|
3132
|
+
return c.redirect(result.redirectTo, 302);
|
|
3133
|
+
});
|
|
3134
|
+
app.get("/v1/integrations/oauth/client-metadata.json", (c) => {
|
|
3135
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
3136
|
+
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
3137
|
+
return c.json(IntegrationClientMetadata.parse({
|
|
3138
|
+
client_id: metadataUrl,
|
|
3139
|
+
client_name: "OpenGeni",
|
|
3140
|
+
redirect_uris: [`${baseUrl}/v1/integrations/oauth/callback`],
|
|
3141
|
+
token_endpoint_auth_method: "none",
|
|
3142
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
3143
|
+
response_types: ["code"]
|
|
3144
|
+
}));
|
|
1803
3145
|
});
|
|
1804
3146
|
}
|
|
3147
|
+
function writableSubjectId(requested, grantSubjectId) {
|
|
3148
|
+
if (requested == null) {
|
|
3149
|
+
return null;
|
|
3150
|
+
}
|
|
3151
|
+
if (requested !== grantSubjectId) {
|
|
3152
|
+
throw new HTTPException5(403, { message: "cannot write a connection for another subject" });
|
|
3153
|
+
}
|
|
3154
|
+
return requested;
|
|
3155
|
+
}
|
|
3156
|
+
function encryptCredentialBundle(key, credential) {
|
|
3157
|
+
return encryptEnvironmentValue4(key, JSON.stringify(credential));
|
|
3158
|
+
}
|
|
1805
3159
|
|
|
1806
3160
|
// src/routes/documents.ts
|
|
1807
3161
|
import {
|
|
1808
3162
|
AddDocumentRequest,
|
|
3163
|
+
CreateKnowledgeMemoryRequest,
|
|
1809
3164
|
CreateDocumentBaseRequest,
|
|
1810
3165
|
Document,
|
|
1811
3166
|
DocumentBase,
|
|
1812
|
-
DocumentSearchRequest
|
|
3167
|
+
DocumentSearchRequest,
|
|
3168
|
+
KnowledgeMemory,
|
|
3169
|
+
KnowledgeMemorySearchRequest,
|
|
3170
|
+
UpdateKnowledgeMemoryRequest
|
|
1813
3171
|
} from "@opengeni/contracts";
|
|
3172
|
+
import {
|
|
3173
|
+
createKnowledgeMemory as createKnowledgeMemory2,
|
|
3174
|
+
getKnowledgeMemory,
|
|
3175
|
+
listKnowledgeMemories as listKnowledgeMemories2,
|
|
3176
|
+
updateKnowledgeMemory
|
|
3177
|
+
} from "@opengeni/db";
|
|
1814
3178
|
import {
|
|
1815
3179
|
addDocumentToBase,
|
|
1816
3180
|
createDocumentBase,
|
|
@@ -1823,8 +3187,8 @@ import {
|
|
|
1823
3187
|
searchDocuments as searchDocuments2
|
|
1824
3188
|
} from "@opengeni/documents";
|
|
1825
3189
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
1826
|
-
import { HTTPException as
|
|
1827
|
-
import { requireAccessGrant as
|
|
3190
|
+
import { HTTPException as HTTPException6 } from "hono/http-exception";
|
|
3191
|
+
import { requireAccessGrant as requireAccessGrant4 } from "@opengeni/core";
|
|
1828
3192
|
import { recordWorkspaceUsage as recordWorkspaceUsage2, requireLimit as requireLimit2 } from "@opengeni/core";
|
|
1829
3193
|
|
|
1830
3194
|
// src/mcp/documents.ts
|
|
@@ -1833,9 +3197,29 @@ import {
|
|
|
1833
3197
|
listDocumentBases,
|
|
1834
3198
|
searchDocuments
|
|
1835
3199
|
} from "@opengeni/documents";
|
|
3200
|
+
import {
|
|
3201
|
+
createKnowledgeMemory,
|
|
3202
|
+
listKnowledgeMemories
|
|
3203
|
+
} from "@opengeni/db";
|
|
1836
3204
|
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1837
3205
|
import * as z from "zod/v4";
|
|
1838
|
-
|
|
3206
|
+
var SearchInputSchema = {
|
|
3207
|
+
query: z.string().min(1),
|
|
3208
|
+
baseIds: z.array(z.string().uuid()).optional(),
|
|
3209
|
+
limit: z.number().int().positive().max(50).optional(),
|
|
3210
|
+
mode: z.enum(["hybrid", "vector", "keyword"]).optional(),
|
|
3211
|
+
sourceKinds: z.array(z.enum(["manual_upload", "meeting_transcript", "repository", "email", "chat", "document", "web", "other"])).optional(),
|
|
3212
|
+
aclTags: z.array(z.string().min(1)).optional()
|
|
3213
|
+
};
|
|
3214
|
+
var MemoryKindSchema = z.enum(["semantic", "episodic", "procedural", "decision", "preference"]);
|
|
3215
|
+
var SourceRefSchema = z.object({
|
|
3216
|
+
kind: z.enum(["document_chunk", "document", "session_event", "memory", "external"]),
|
|
3217
|
+
id: z.string().min(1),
|
|
3218
|
+
uri: z.string().min(1).optional(),
|
|
3219
|
+
title: z.string().min(1).optional(),
|
|
3220
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
3221
|
+
});
|
|
3222
|
+
function buildDocumentsMcpServer(db, accountId, workspaceId, documentServices, options = {}) {
|
|
1839
3223
|
const server = new McpServer2({
|
|
1840
3224
|
name: "opengeni-documents",
|
|
1841
3225
|
version: "1.0.0"
|
|
@@ -1847,27 +3231,29 @@ function buildDocumentsMcpServer(db, workspaceId, documentServices) {
|
|
|
1847
3231
|
content: [{ type: "text", text: JSON.stringify(await listDocumentBases(db, workspaceId)) }]
|
|
1848
3232
|
}));
|
|
1849
3233
|
server.registerTool("search_documents", {
|
|
1850
|
-
description: "Search indexed documents.",
|
|
1851
|
-
inputSchema:
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
}, async (
|
|
1857
|
-
content: [{
|
|
1858
|
-
type: "text",
|
|
1859
|
-
text: JSON.stringify(await searchDocuments(db, {
|
|
1860
|
-
workspaceId,
|
|
1861
|
-
query,
|
|
1862
|
-
...baseIds ? { baseIds } : {},
|
|
1863
|
-
...limit ? { limit } : {}
|
|
1864
|
-
}, documentServices))
|
|
1865
|
-
}]
|
|
1866
|
-
}));
|
|
3234
|
+
description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
|
|
3235
|
+
inputSchema: SearchInputSchema
|
|
3236
|
+
}, async (input) => searchContent(db, workspaceId, documentServices, input));
|
|
3237
|
+
server.registerTool("knowledge_search", {
|
|
3238
|
+
description: "Search company knowledge sources with optional base, source-kind, ACL, and retrieval-mode filters.",
|
|
3239
|
+
inputSchema: SearchInputSchema
|
|
3240
|
+
}, async (input) => searchContent(db, workspaceId, documentServices, input));
|
|
1867
3241
|
server.registerTool("fetch_document_chunk", {
|
|
1868
3242
|
description: "Fetch one indexed document chunk by id.",
|
|
1869
3243
|
inputSchema: {
|
|
1870
|
-
chunkId: z.string()
|
|
3244
|
+
chunkId: z.string().uuid()
|
|
3245
|
+
}
|
|
3246
|
+
}, async ({ chunkId }) => {
|
|
3247
|
+
const found = await getDocumentChunk(db, workspaceId, chunkId);
|
|
3248
|
+
return {
|
|
3249
|
+
content: [{ type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` }],
|
|
3250
|
+
isError: !found
|
|
3251
|
+
};
|
|
3252
|
+
});
|
|
3253
|
+
server.registerTool("knowledge_fetch", {
|
|
3254
|
+
description: "Fetch one knowledge source chunk by id.",
|
|
3255
|
+
inputSchema: {
|
|
3256
|
+
chunkId: z.string().uuid()
|
|
1871
3257
|
}
|
|
1872
3258
|
}, async ({ chunkId }) => {
|
|
1873
3259
|
const found = await getDocumentChunk(db, workspaceId, chunkId);
|
|
@@ -1876,42 +3262,99 @@ function buildDocumentsMcpServer(db, workspaceId, documentServices) {
|
|
|
1876
3262
|
isError: !found
|
|
1877
3263
|
};
|
|
1878
3264
|
});
|
|
3265
|
+
server.registerTool("memory_search", {
|
|
3266
|
+
description: "Search approved company memory records.",
|
|
3267
|
+
inputSchema: {
|
|
3268
|
+
query: z.string().min(1).optional(),
|
|
3269
|
+
kind: MemoryKindSchema.optional(),
|
|
3270
|
+
scope: z.string().min(1).optional(),
|
|
3271
|
+
limit: z.number().int().positive().max(100).optional()
|
|
3272
|
+
}
|
|
3273
|
+
}, async ({ query, kind, scope, limit }) => ({
|
|
3274
|
+
content: [{ type: "text", text: JSON.stringify(await listKnowledgeMemories(db, workspaceId, {
|
|
3275
|
+
...query ? { query } : {},
|
|
3276
|
+
status: "approved",
|
|
3277
|
+
...kind ? { kind } : {},
|
|
3278
|
+
...scope ? { scope } : {},
|
|
3279
|
+
...limit ? { limit } : {}
|
|
3280
|
+
})) }]
|
|
3281
|
+
}));
|
|
3282
|
+
server.registerTool("memory_propose", {
|
|
3283
|
+
description: "Propose a company memory record for human review.",
|
|
3284
|
+
inputSchema: {
|
|
3285
|
+
text: z.string().min(1),
|
|
3286
|
+
kind: MemoryKindSchema.optional(),
|
|
3287
|
+
scope: z.string().min(1).optional(),
|
|
3288
|
+
sourceRefs: z.array(SourceRefSchema).optional(),
|
|
3289
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
3290
|
+
metadata: z.record(z.string(), z.unknown()).optional()
|
|
3291
|
+
}
|
|
3292
|
+
}, async ({ text, kind, scope, sourceRefs, confidence, metadata }) => ({
|
|
3293
|
+
content: [{ type: "text", text: JSON.stringify(await createKnowledgeMemory(db, {
|
|
3294
|
+
accountId,
|
|
3295
|
+
workspaceId,
|
|
3296
|
+
status: "proposed",
|
|
3297
|
+
kind: kind ?? "semantic",
|
|
3298
|
+
scope: scope ?? "workspace",
|
|
3299
|
+
text,
|
|
3300
|
+
sourceRefs: sourceRefs?.map((sourceRef) => ({ ...sourceRef, metadata: sourceRef.metadata ?? {} })) ?? [],
|
|
3301
|
+
confidence: confidence ?? 0.5,
|
|
3302
|
+
metadata: metadata ?? {},
|
|
3303
|
+
createdBySessionId: options.createdBySessionId
|
|
3304
|
+
})) }]
|
|
3305
|
+
}));
|
|
1879
3306
|
return server;
|
|
1880
3307
|
}
|
|
3308
|
+
async function searchContent(db, workspaceId, documentServices, input) {
|
|
3309
|
+
return {
|
|
3310
|
+
content: [{
|
|
3311
|
+
type: "text",
|
|
3312
|
+
text: JSON.stringify(await searchDocuments(db, {
|
|
3313
|
+
workspaceId,
|
|
3314
|
+
query: input.query,
|
|
3315
|
+
...input.baseIds ? { baseIds: input.baseIds } : {},
|
|
3316
|
+
...input.limit ? { limit: input.limit } : {},
|
|
3317
|
+
...input.mode ? { mode: input.mode } : {},
|
|
3318
|
+
...input.sourceKinds ? { sourceKinds: input.sourceKinds } : {},
|
|
3319
|
+
...input.aclTags ? { aclTags: input.aclTags } : {}
|
|
3320
|
+
}, documentServices))
|
|
3321
|
+
}]
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
1881
3324
|
|
|
1882
3325
|
// src/routes/documents.ts
|
|
1883
3326
|
function registerDocumentRoutes(app, deps) {
|
|
1884
3327
|
const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
|
|
1885
3328
|
app.post("/v1/workspaces/:workspaceId/document-bases", async (c) => {
|
|
1886
3329
|
const workspaceId = c.req.param("workspaceId");
|
|
1887
|
-
const grant = await
|
|
3330
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
1888
3331
|
const payload = CreateDocumentBaseRequest.parse(await c.req.json());
|
|
1889
3332
|
return c.json(DocumentBase.parse(await createDocumentBase(db, { ...payload, accountId: grant.accountId, workspaceId })), 201);
|
|
1890
3333
|
});
|
|
1891
3334
|
app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
|
|
1892
3335
|
const workspaceId = c.req.param("workspaceId");
|
|
1893
|
-
await
|
|
3336
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
1894
3337
|
return c.json((await listDocumentBases2(db, workspaceId)).map((base) => DocumentBase.parse(base)));
|
|
1895
3338
|
});
|
|
1896
3339
|
app.get("/v1/workspaces/:workspaceId/document-bases/:baseId", async (c) => {
|
|
1897
3340
|
const workspaceId = c.req.param("workspaceId");
|
|
1898
|
-
await
|
|
3341
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
1899
3342
|
const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
|
|
1900
3343
|
if (!base) {
|
|
1901
|
-
throw new
|
|
3344
|
+
throw new HTTPException6(404, { message: "document base not found" });
|
|
1902
3345
|
}
|
|
1903
3346
|
return c.json(DocumentBase.parse(base));
|
|
1904
3347
|
});
|
|
1905
3348
|
app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
|
|
1906
3349
|
const workspaceId = c.req.param("workspaceId");
|
|
1907
|
-
const grant = await
|
|
3350
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
1908
3351
|
if (!objectStorage) {
|
|
1909
|
-
throw new
|
|
3352
|
+
throw new HTTPException6(503, { message: "object storage is not configured" });
|
|
1910
3353
|
}
|
|
1911
3354
|
await requireLimit2(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
|
|
1912
3355
|
const payload = AddDocumentRequest.parse(await c.req.json());
|
|
1913
3356
|
try {
|
|
1914
|
-
const document = await addDocumentToBase(db, { accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId")
|
|
3357
|
+
const document = await addDocumentToBase(db, { ...payload, accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId") });
|
|
1915
3358
|
const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
|
|
1916
3359
|
const indexed = document.status === "ready" ? document : await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? document;
|
|
1917
3360
|
if (indexed.status === "ready") {
|
|
@@ -1934,12 +3377,12 @@ function registerDocumentRoutes(app, deps) {
|
|
|
1934
3377
|
});
|
|
1935
3378
|
app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
|
|
1936
3379
|
const workspaceId = c.req.param("workspaceId");
|
|
1937
|
-
await
|
|
3380
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
1938
3381
|
return c.json((await listDocuments(db, workspaceId, c.req.param("baseId"))).map((document) => Document.parse(document)));
|
|
1939
3382
|
});
|
|
1940
3383
|
app.delete("/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId", async (c) => {
|
|
1941
3384
|
const workspaceId = c.req.param("workspaceId");
|
|
1942
|
-
const grant = await
|
|
3385
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
1943
3386
|
try {
|
|
1944
3387
|
await deleteDocumentFromBase(db, {
|
|
1945
3388
|
accountId: grant.accountId,
|
|
@@ -1954,21 +3397,21 @@ function registerDocumentRoutes(app, deps) {
|
|
|
1954
3397
|
});
|
|
1955
3398
|
app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex", async (c) => {
|
|
1956
3399
|
const workspaceId = c.req.param("workspaceId");
|
|
1957
|
-
const grant = await
|
|
3400
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
1958
3401
|
if (!objectStorage) {
|
|
1959
|
-
throw new
|
|
3402
|
+
throw new HTTPException6(503, { message: "object storage is not configured" });
|
|
1960
3403
|
}
|
|
1961
3404
|
await requireLimit2(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
|
|
1962
3405
|
try {
|
|
1963
3406
|
const document = await getDocument(db, workspaceId, c.req.param("documentId"));
|
|
1964
3407
|
if (!document) {
|
|
1965
|
-
throw new
|
|
3408
|
+
throw new HTTPException6(404, { message: "document not found" });
|
|
1966
3409
|
}
|
|
1967
3410
|
if (document.status !== "failed") {
|
|
1968
|
-
throw new
|
|
3411
|
+
throw new HTTPException6(422, { message: "only failed documents can be retried" });
|
|
1969
3412
|
}
|
|
1970
3413
|
if (document.baseId !== c.req.param("baseId")) {
|
|
1971
|
-
throw new
|
|
3414
|
+
throw new HTTPException6(404, { message: "document not found" });
|
|
1972
3415
|
}
|
|
1973
3416
|
const queued = await queueDocumentForReindex(db, workspaceId, document.id);
|
|
1974
3417
|
const indexed = await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? queued;
|
|
@@ -1987,7 +3430,7 @@ function registerDocumentRoutes(app, deps) {
|
|
|
1987
3430
|
}
|
|
1988
3431
|
return c.json(Document.parse(indexed));
|
|
1989
3432
|
} catch (error) {
|
|
1990
|
-
if (error instanceof
|
|
3433
|
+
if (error instanceof HTTPException6) {
|
|
1991
3434
|
throw error;
|
|
1992
3435
|
}
|
|
1993
3436
|
throw documentHttpException(error);
|
|
@@ -1995,26 +3438,94 @@ function registerDocumentRoutes(app, deps) {
|
|
|
1995
3438
|
});
|
|
1996
3439
|
app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
|
|
1997
3440
|
const workspaceId = c.req.param("workspaceId");
|
|
1998
|
-
await
|
|
3441
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
1999
3442
|
const payload = DocumentSearchRequest.parse(await c.req.json());
|
|
2000
3443
|
const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
|
|
2001
3444
|
if (!base) {
|
|
2002
|
-
throw new
|
|
3445
|
+
throw new HTTPException6(404, { message: "document base not found" });
|
|
2003
3446
|
}
|
|
2004
3447
|
return c.json({
|
|
2005
3448
|
results: await searchDocuments2(db, {
|
|
2006
3449
|
workspaceId,
|
|
2007
3450
|
baseIds: [base.id],
|
|
2008
3451
|
query: payload.query,
|
|
2009
|
-
limit: payload.limit
|
|
3452
|
+
limit: payload.limit,
|
|
3453
|
+
mode: payload.mode,
|
|
3454
|
+
sourceKinds: payload.sourceKinds,
|
|
3455
|
+
aclTags: payload.aclTags
|
|
3456
|
+
}, getDocumentServices())
|
|
3457
|
+
});
|
|
3458
|
+
});
|
|
3459
|
+
app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
|
|
3460
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3461
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
3462
|
+
const payload = DocumentSearchRequest.parse(await c.req.json());
|
|
3463
|
+
return c.json({
|
|
3464
|
+
results: await searchDocuments2(db, {
|
|
3465
|
+
workspaceId,
|
|
3466
|
+
query: payload.query,
|
|
3467
|
+
baseIds: payload.baseIds,
|
|
3468
|
+
limit: payload.limit,
|
|
3469
|
+
mode: payload.mode,
|
|
3470
|
+
sourceKinds: payload.sourceKinds,
|
|
3471
|
+
aclTags: payload.aclTags
|
|
2010
3472
|
}, getDocumentServices())
|
|
2011
3473
|
});
|
|
2012
3474
|
});
|
|
3475
|
+
app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
|
|
3476
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3477
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
3478
|
+
const parsed = KnowledgeMemorySearchRequest.safeParse({
|
|
3479
|
+
query: c.req.query("query") || void 0,
|
|
3480
|
+
status: c.req.query("status") || void 0,
|
|
3481
|
+
kind: c.req.query("kind") || void 0,
|
|
3482
|
+
scope: c.req.query("scope") || void 0,
|
|
3483
|
+
limit: c.req.query("limit") ? Number(c.req.query("limit")) : void 0
|
|
3484
|
+
});
|
|
3485
|
+
if (!parsed.success) {
|
|
3486
|
+
throw new HTTPException6(400, { message: "invalid knowledge memory query parameters" });
|
|
3487
|
+
}
|
|
3488
|
+
return c.json((await listKnowledgeMemories2(db, workspaceId, parsed.data)).map((memory) => KnowledgeMemory.parse(memory)));
|
|
3489
|
+
});
|
|
3490
|
+
app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
|
|
3491
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3492
|
+
await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
3493
|
+
const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
|
|
3494
|
+
if (!memory) {
|
|
3495
|
+
throw new HTTPException6(404, { message: "knowledge memory not found" });
|
|
3496
|
+
}
|
|
3497
|
+
return c.json(KnowledgeMemory.parse(memory));
|
|
3498
|
+
});
|
|
3499
|
+
app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
|
|
3500
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3501
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
3502
|
+
const payload = CreateKnowledgeMemoryRequest.parse(await c.req.json());
|
|
3503
|
+
return c.json(KnowledgeMemory.parse(await createKnowledgeMemory2(db, {
|
|
3504
|
+
...payload,
|
|
3505
|
+
accountId: grant.accountId,
|
|
3506
|
+
workspaceId
|
|
3507
|
+
})), 201);
|
|
3508
|
+
});
|
|
3509
|
+
app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
|
|
3510
|
+
const workspaceId = c.req.param("workspaceId");
|
|
3511
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:manage");
|
|
3512
|
+
const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
|
|
3513
|
+
const reviewedBy = payload.reviewedBy ?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : void 0);
|
|
3514
|
+
try {
|
|
3515
|
+
return c.json(KnowledgeMemory.parse(await updateKnowledgeMemory(db, workspaceId, c.req.param("memoryId"), {
|
|
3516
|
+
...payload,
|
|
3517
|
+
...reviewedBy ? { reviewedBy } : {}
|
|
3518
|
+
})));
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
throw documentHttpException(error);
|
|
3521
|
+
}
|
|
3522
|
+
});
|
|
2013
3523
|
app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
|
|
2014
3524
|
const workspaceId = c.req.param("workspaceId");
|
|
2015
|
-
await
|
|
3525
|
+
const grant = await requireAccessGrant4(c, deps, workspaceId, "documents:search");
|
|
3526
|
+
const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : void 0;
|
|
2016
3527
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
2017
|
-
const server = buildDocumentsMcpServer(db, workspaceId, getDocumentServices());
|
|
3528
|
+
const server = buildDocumentsMcpServer(db, grant.accountId, workspaceId, getDocumentServices(), { createdBySessionId: sessionId });
|
|
2018
3529
|
await server.connect(transport);
|
|
2019
3530
|
return await transport.handleRequest(c.req.raw);
|
|
2020
3531
|
});
|
|
@@ -2022,12 +3533,12 @@ function registerDocumentRoutes(app, deps) {
|
|
|
2022
3533
|
function documentHttpException(error) {
|
|
2023
3534
|
const message = error instanceof Error ? error.message : String(error);
|
|
2024
3535
|
if (message.includes("not found")) {
|
|
2025
|
-
return new
|
|
3536
|
+
return new HTTPException6(404, { message });
|
|
2026
3537
|
}
|
|
2027
3538
|
if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
|
|
2028
|
-
return new
|
|
3539
|
+
return new HTTPException6(422, { message });
|
|
2029
3540
|
}
|
|
2030
|
-
return new
|
|
3541
|
+
return new HTTPException6(500, { message });
|
|
2031
3542
|
}
|
|
2032
3543
|
|
|
2033
3544
|
// src/routes/enrollments.ts
|
|
@@ -2053,11 +3564,11 @@ import {
|
|
|
2053
3564
|
listEnrollments,
|
|
2054
3565
|
revokeEnrollment
|
|
2055
3566
|
} from "@opengeni/db";
|
|
2056
|
-
import { HTTPException as
|
|
2057
|
-
import { requireAccessGrant as
|
|
3567
|
+
import { HTTPException as HTTPException7 } from "hono/http-exception";
|
|
3568
|
+
import { requireAccessGrant as requireAccessGrant5 } from "@opengeni/core";
|
|
2058
3569
|
|
|
2059
3570
|
// src/sandbox/enrollment.ts
|
|
2060
|
-
import { randomBytes } from "crypto";
|
|
3571
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2061
3572
|
import {
|
|
2062
3573
|
resolveEnrollmentSigningSecret,
|
|
2063
3574
|
resolveRelayTokenSecret
|
|
@@ -2087,11 +3598,11 @@ var ENROLLMENT_BEARER_TTL_SECONDS = 30 * 24 * 3600;
|
|
|
2087
3598
|
var RELAY_TOKEN_TTL_SECONDS = 30 * 24 * 3600;
|
|
2088
3599
|
var ENROLL_TOKEN_TTL_SECONDS = 3600;
|
|
2089
3600
|
function mintDeviceCode() {
|
|
2090
|
-
return
|
|
3601
|
+
return randomBytes2(32).toString("base64url");
|
|
2091
3602
|
}
|
|
2092
3603
|
var USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2093
3604
|
function mintUserCode() {
|
|
2094
|
-
const bytes =
|
|
3605
|
+
const bytes = randomBytes2(8);
|
|
2095
3606
|
let out = "";
|
|
2096
3607
|
for (let i = 0; i < 8; i += 1) {
|
|
2097
3608
|
out += USER_CODE_ALPHABET[bytes[i] % USER_CODE_ALPHABET.length];
|
|
@@ -2169,18 +3680,18 @@ async function lookupDeviceEnrollment(services, input) {
|
|
|
2169
3680
|
const { db } = services;
|
|
2170
3681
|
return await getPendingDeviceEnrollmentRequestByUserCodeGlobal(db, input.userCode);
|
|
2171
3682
|
}
|
|
2172
|
-
function toLookupResponse(
|
|
3683
|
+
function toLookupResponse(record3) {
|
|
2173
3684
|
return {
|
|
2174
|
-
workspaceId:
|
|
2175
|
-
userCode:
|
|
3685
|
+
workspaceId: record3.workspaceId,
|
|
3686
|
+
userCode: record3.userCode,
|
|
2176
3687
|
machine: {
|
|
2177
|
-
machineName:
|
|
2178
|
-
os:
|
|
2179
|
-
arch:
|
|
2180
|
-
canOfferDisplay:
|
|
2181
|
-
requestsScreenControl:
|
|
3688
|
+
machineName: record3.machineName,
|
|
3689
|
+
os: record3.os,
|
|
3690
|
+
arch: record3.arch,
|
|
3691
|
+
canOfferDisplay: record3.canOfferDisplay,
|
|
3692
|
+
requestsScreenControl: record3.requestsScreenControl
|
|
2182
3693
|
},
|
|
2183
|
-
expiresAt:
|
|
3694
|
+
expiresAt: record3.expiresAt
|
|
2184
3695
|
};
|
|
2185
3696
|
}
|
|
2186
3697
|
async function denyDeviceEnrollment(services, input) {
|
|
@@ -2337,7 +3848,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2337
3848
|
const { settings, db } = deps;
|
|
2338
3849
|
function assertSelfhostedEnabled() {
|
|
2339
3850
|
if (!settings.sandboxSelfhostedEnabled) {
|
|
2340
|
-
throw new
|
|
3851
|
+
throw new HTTPException7(404, { message: "selfhosted enrollment is not enabled for this deployment" });
|
|
2341
3852
|
}
|
|
2342
3853
|
}
|
|
2343
3854
|
const startLimiter = new TokenBucket({ capacity: 10, refillPerSecond: 0.5 });
|
|
@@ -2347,7 +3858,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2347
3858
|
function rateLimit(c, limiter) {
|
|
2348
3859
|
const ip = clientIp(c);
|
|
2349
3860
|
if (!limiter.take(ip)) {
|
|
2350
|
-
throw new
|
|
3861
|
+
throw new HTTPException7(429, { message: "too many requests; slow down" });
|
|
2351
3862
|
}
|
|
2352
3863
|
}
|
|
2353
3864
|
app.post("/v1/enrollments/device/start", async (c) => {
|
|
@@ -2355,12 +3866,12 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2355
3866
|
rateLimit(c, startLimiter);
|
|
2356
3867
|
const parsed = DeviceEnrollmentStartRequest.safeParse(await c.req.json().catch(() => null));
|
|
2357
3868
|
if (!parsed.success) {
|
|
2358
|
-
throw new
|
|
3869
|
+
throw new HTTPException7(400, { message: "invalid device-start request" });
|
|
2359
3870
|
}
|
|
2360
3871
|
const body = parsed.data;
|
|
2361
3872
|
const workspace = await getWorkspace(db, body.workspaceId);
|
|
2362
3873
|
if (!workspace) {
|
|
2363
|
-
throw new
|
|
3874
|
+
throw new HTTPException7(404, { message: "workspace not found" });
|
|
2364
3875
|
}
|
|
2365
3876
|
const result = await startDeviceEnrollment({ db, settings }, {
|
|
2366
3877
|
accountId: workspace.accountId,
|
|
@@ -2381,7 +3892,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2381
3892
|
rateLimit(c, pollLimiter);
|
|
2382
3893
|
const parsed = DeviceEnrollmentPollRequest.safeParse(await c.req.json().catch(() => null));
|
|
2383
3894
|
if (!parsed.success) {
|
|
2384
|
-
throw new
|
|
3895
|
+
throw new HTTPException7(400, { message: "invalid device-poll request" });
|
|
2385
3896
|
}
|
|
2386
3897
|
const result = await pollDeviceEnrollment({ db, settings }, { deviceCode: parsed.data.deviceCode });
|
|
2387
3898
|
return c.json(result, 200);
|
|
@@ -2391,25 +3902,25 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2391
3902
|
rateLimit(c, lookupLimiter);
|
|
2392
3903
|
const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
|
|
2393
3904
|
if (!parsed.success) {
|
|
2394
|
-
throw new
|
|
3905
|
+
throw new HTTPException7(400, { message: "invalid device-lookup request" });
|
|
2395
3906
|
}
|
|
2396
|
-
const
|
|
2397
|
-
if (!
|
|
2398
|
-
throw new
|
|
3907
|
+
const record3 = await lookupDeviceEnrollment({ db, settings }, { userCode: parsed.data.userCode });
|
|
3908
|
+
if (!record3) {
|
|
3909
|
+
throw new HTTPException7(404, { message: "no pending enrollment for that code" });
|
|
2399
3910
|
}
|
|
2400
3911
|
try {
|
|
2401
|
-
await
|
|
3912
|
+
await requireAccessGrant5(c, deps, record3.workspaceId, "enrollments:read");
|
|
2402
3913
|
} catch {
|
|
2403
|
-
throw new
|
|
3914
|
+
throw new HTTPException7(404, { message: "no pending enrollment for that code" });
|
|
2404
3915
|
}
|
|
2405
|
-
return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(
|
|
3916
|
+
return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record3)), 200);
|
|
2406
3917
|
});
|
|
2407
3918
|
app.post("/v1/enrollments/token/exchange", async (c) => {
|
|
2408
3919
|
assertSelfhostedEnabled();
|
|
2409
3920
|
rateLimit(c, exchangeLimiter);
|
|
2410
3921
|
const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
|
|
2411
3922
|
if (!parsed.success) {
|
|
2412
|
-
throw new
|
|
3923
|
+
throw new HTTPException7(400, { message: "invalid enroll-token-exchange request" });
|
|
2413
3924
|
}
|
|
2414
3925
|
const body = parsed.data;
|
|
2415
3926
|
const result = await exchangeEnrollToken({ db, settings }, {
|
|
@@ -2422,19 +3933,19 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2422
3933
|
});
|
|
2423
3934
|
if (!result.ok) {
|
|
2424
3935
|
if (result.reason === "disabled") {
|
|
2425
|
-
throw new
|
|
3936
|
+
throw new HTTPException7(503, { message: "enrollment credential plane is not configured" });
|
|
2426
3937
|
}
|
|
2427
|
-
throw new
|
|
3938
|
+
throw new HTTPException7(401, { message: "invalid or expired enroll token" });
|
|
2428
3939
|
}
|
|
2429
3940
|
return c.json(EnrollTokenExchangeResponse.parse({ credentials: result.credentials }), 201);
|
|
2430
3941
|
});
|
|
2431
3942
|
app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
|
|
2432
3943
|
const workspaceId = c.req.param("workspaceId");
|
|
2433
|
-
const grant = await
|
|
3944
|
+
const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
|
|
2434
3945
|
assertSelfhostedEnabled();
|
|
2435
3946
|
const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
|
|
2436
3947
|
if (!parsed.success) {
|
|
2437
|
-
throw new
|
|
3948
|
+
throw new HTTPException7(400, { message: "invalid device-approve request" });
|
|
2438
3949
|
}
|
|
2439
3950
|
const body = parsed.data;
|
|
2440
3951
|
const approved = await approveDeviceEnrollment({ db, settings }, {
|
|
@@ -2447,7 +3958,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2447
3958
|
approvedBySubjectLabel: grant.subjectLabel ?? null
|
|
2448
3959
|
});
|
|
2449
3960
|
if (!approved) {
|
|
2450
|
-
throw new
|
|
3961
|
+
throw new HTTPException7(404, { message: "no pending enrollment for that code" });
|
|
2451
3962
|
}
|
|
2452
3963
|
return c.json(DeviceEnrollmentApproveResponse.parse({
|
|
2453
3964
|
approved: true,
|
|
@@ -2458,11 +3969,11 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2458
3969
|
});
|
|
2459
3970
|
app.post("/v1/workspaces/:workspaceId/enrollments/device/deny", async (c) => {
|
|
2460
3971
|
const workspaceId = c.req.param("workspaceId");
|
|
2461
|
-
const grant = await
|
|
3972
|
+
const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
|
|
2462
3973
|
assertSelfhostedEnabled();
|
|
2463
3974
|
const parsed = DeviceEnrollmentDenyRequest.safeParse(await c.req.json().catch(() => null));
|
|
2464
3975
|
if (!parsed.success) {
|
|
2465
|
-
throw new
|
|
3976
|
+
throw new HTTPException7(400, { message: "invalid device-deny request" });
|
|
2466
3977
|
}
|
|
2467
3978
|
const result = await denyDeviceEnrollment({ db, settings }, {
|
|
2468
3979
|
accountId: grant.accountId,
|
|
@@ -2473,11 +3984,11 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2473
3984
|
});
|
|
2474
3985
|
app.post("/v1/workspaces/:workspaceId/enrollments/token", async (c) => {
|
|
2475
3986
|
const workspaceId = c.req.param("workspaceId");
|
|
2476
|
-
const grant = await
|
|
3987
|
+
const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
|
|
2477
3988
|
assertSelfhostedEnabled();
|
|
2478
3989
|
const parsed = MintEnrollTokenRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
2479
3990
|
if (!parsed.success) {
|
|
2480
|
-
throw new
|
|
3991
|
+
throw new HTTPException7(400, { message: "invalid mint-enroll-token request" });
|
|
2481
3992
|
}
|
|
2482
3993
|
const minted = await mintEnrollToken({ db, settings }, {
|
|
2483
3994
|
accountId: grant.accountId,
|
|
@@ -2485,13 +3996,13 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2485
3996
|
allowScreenControl: parsed.data.allowScreenControl
|
|
2486
3997
|
});
|
|
2487
3998
|
if (!minted) {
|
|
2488
|
-
throw new
|
|
3999
|
+
throw new HTTPException7(503, { message: "enrollment credential plane is not configured" });
|
|
2489
4000
|
}
|
|
2490
4001
|
return c.json(MintEnrollTokenResponse.parse(minted), 201);
|
|
2491
4002
|
});
|
|
2492
4003
|
app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
|
|
2493
4004
|
const workspaceId = c.req.param("workspaceId");
|
|
2494
|
-
await
|
|
4005
|
+
await requireAccessGrant5(c, deps, workspaceId, "enrollments:read");
|
|
2495
4006
|
assertSelfhostedEnabled();
|
|
2496
4007
|
const statusFilter = c.req.query("status");
|
|
2497
4008
|
const rows = await listEnrollments(db, workspaceId, statusFilter === "active" ? { status: "active" } : {});
|
|
@@ -2501,6 +4012,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2501
4012
|
pubkey: row.pubkey,
|
|
2502
4013
|
exposure: row.exposure,
|
|
2503
4014
|
hasDisplay: row.hasDisplay,
|
|
4015
|
+
desktopUnavailableReason: row.desktopUnavailableReason,
|
|
2504
4016
|
allowScreenControl: row.allowScreenControl,
|
|
2505
4017
|
status: row.status,
|
|
2506
4018
|
os: row.os,
|
|
@@ -2513,7 +4025,7 @@ function registerEnrollmentRoutes(app, deps) {
|
|
|
2513
4025
|
});
|
|
2514
4026
|
app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
|
|
2515
4027
|
const workspaceId = c.req.param("workspaceId");
|
|
2516
|
-
const grant = await
|
|
4028
|
+
const grant = await requireAccessGrant5(c, deps, workspaceId, "enrollments:manage");
|
|
2517
4029
|
assertSelfhostedEnabled();
|
|
2518
4030
|
const result = await revokeEnrollment(db, {
|
|
2519
4031
|
accountId: grant.accountId,
|
|
@@ -2568,8 +4080,8 @@ import {
|
|
|
2568
4080
|
getEnrollment as getEnrollment2,
|
|
2569
4081
|
readMachineMetricsSeries
|
|
2570
4082
|
} from "@opengeni/db";
|
|
2571
|
-
import { HTTPException as
|
|
2572
|
-
import { requireAccessGrant as
|
|
4083
|
+
import { HTTPException as HTTPException8 } from "hono/http-exception";
|
|
4084
|
+
import { requireAccessGrant as requireAccessGrant6 } from "@opengeni/core";
|
|
2573
4085
|
import { buildFleetContextForSession as buildFleetContextForSession2, swapActiveSandbox as swapActiveSandbox2 } from "@opengeni/core";
|
|
2574
4086
|
|
|
2575
4087
|
// src/sandbox/machines.ts
|
|
@@ -2684,6 +4196,7 @@ async function listMachines(services, input) {
|
|
|
2684
4196
|
os: "linux",
|
|
2685
4197
|
arch: "x86_64",
|
|
2686
4198
|
hasDisplay: false,
|
|
4199
|
+
desktopUnavailableReason: null,
|
|
2687
4200
|
allowScreenControl: false,
|
|
2688
4201
|
sharedSessionCount: 1,
|
|
2689
4202
|
lastSeenAt: null,
|
|
@@ -2720,6 +4233,7 @@ async function listMachines(services, input) {
|
|
|
2720
4233
|
os: enrollment.os,
|
|
2721
4234
|
arch: enrollment.arch,
|
|
2722
4235
|
hasDisplay: enrollment.hasDisplay,
|
|
4236
|
+
desktopUnavailableReason: enrollment.desktopUnavailableReason,
|
|
2723
4237
|
allowScreenControl: enrollment.allowScreenControl,
|
|
2724
4238
|
sharedSessionCount,
|
|
2725
4239
|
lastSeenAt: enrollment.lastSeenAt,
|
|
@@ -2741,12 +4255,12 @@ function registerMachineRoutes(app, deps) {
|
|
|
2741
4255
|
const { settings, db, bus } = deps;
|
|
2742
4256
|
function assertSelfhostedEnabled() {
|
|
2743
4257
|
if (!settings.sandboxSelfhostedEnabled) {
|
|
2744
|
-
throw new
|
|
4258
|
+
throw new HTTPException8(404, { message: "selfhosted machines are not enabled for this deployment" });
|
|
2745
4259
|
}
|
|
2746
4260
|
}
|
|
2747
4261
|
app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
|
|
2748
4262
|
const workspaceId = c.req.param("workspaceId");
|
|
2749
|
-
await
|
|
4263
|
+
await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
|
|
2750
4264
|
assertSelfhostedEnabled();
|
|
2751
4265
|
const sessionId = c.req.query("sessionId") ?? null;
|
|
2752
4266
|
const response = await listMachines({ db, settings, bus }, { workspaceId, sessionId });
|
|
@@ -2754,12 +4268,12 @@ function registerMachineRoutes(app, deps) {
|
|
|
2754
4268
|
});
|
|
2755
4269
|
app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
|
|
2756
4270
|
const workspaceId = c.req.param("workspaceId");
|
|
2757
|
-
await
|
|
4271
|
+
await requireAccessGrant6(c, deps, workspaceId, "enrollments:read");
|
|
2758
4272
|
assertSelfhostedEnabled();
|
|
2759
4273
|
const enrollmentId = c.req.param("enrollmentId");
|
|
2760
4274
|
const enrollment = await getEnrollment2(db, workspaceId, enrollmentId);
|
|
2761
4275
|
if (!enrollment) {
|
|
2762
|
-
throw new
|
|
4276
|
+
throw new HTTPException8(404, { message: "machine not found in this workspace" });
|
|
2763
4277
|
}
|
|
2764
4278
|
const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
|
|
2765
4279
|
const since = new Date(Date.now() - windowMs);
|
|
@@ -2770,7 +4284,7 @@ function registerMachineRoutes(app, deps) {
|
|
|
2770
4284
|
});
|
|
2771
4285
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/active-sandbox", async (c) => {
|
|
2772
4286
|
const workspaceId = c.req.param("workspaceId");
|
|
2773
|
-
const grant = await
|
|
4287
|
+
const grant = await requireAccessGrant6(c, deps, workspaceId, "sessions:control");
|
|
2774
4288
|
assertSelfhostedEnabled();
|
|
2775
4289
|
const sessionId = c.req.param("sessionId");
|
|
2776
4290
|
const body = SwapActiveSandboxRequest.parse(await c.req.json());
|
|
@@ -2798,51 +4312,51 @@ import {
|
|
|
2798
4312
|
createWorkspaceEnvironment as createWorkspaceEnvironment2,
|
|
2799
4313
|
deleteWorkspaceEnvironment,
|
|
2800
4314
|
deleteWorkspaceEnvironmentVariable,
|
|
2801
|
-
encryptEnvironmentValue as
|
|
4315
|
+
encryptEnvironmentValue as encryptEnvironmentValue5,
|
|
2802
4316
|
getWorkspaceEnvironmentByName as getWorkspaceEnvironmentByName2,
|
|
2803
4317
|
listWorkspaceEnvironments as listWorkspaceEnvironments2,
|
|
2804
4318
|
setWorkspaceEnvironmentVariable as setWorkspaceEnvironmentVariable2,
|
|
2805
4319
|
updateWorkspaceEnvironment
|
|
2806
4320
|
} from "@opengeni/db";
|
|
2807
|
-
import { HTTPException as
|
|
2808
|
-
import { requireAccessGrant as
|
|
4321
|
+
import { HTTPException as HTTPException9 } from "hono/http-exception";
|
|
4322
|
+
import { requireAccessGrant as requireAccessGrant7 } from "@opengeni/core";
|
|
2809
4323
|
import {
|
|
2810
4324
|
assertAllowedEnvironmentVariableName as assertAllowedEnvironmentVariableName2,
|
|
2811
4325
|
MAX_ENVIRONMENTS_PER_WORKSPACE as MAX_ENVIRONMENTS_PER_WORKSPACE2,
|
|
2812
4326
|
MAX_VARIABLES_PER_ENVIRONMENT as MAX_VARIABLES_PER_ENVIRONMENT2,
|
|
2813
4327
|
recordEnvironmentAuditEvent as recordEnvironmentAuditEvent2,
|
|
2814
|
-
requireEnvironmentEncryption as
|
|
4328
|
+
requireEnvironmentEncryption as requireEnvironmentEncryption4,
|
|
2815
4329
|
requireEnvironmentForApi
|
|
2816
4330
|
} from "@opengeni/core";
|
|
2817
4331
|
function registerEnvironmentRoutes(app, deps) {
|
|
2818
4332
|
const { settings, db } = deps;
|
|
2819
4333
|
app.get("/v1/workspaces/:workspaceId/environments", async (c) => {
|
|
2820
4334
|
const workspaceId = c.req.param("workspaceId");
|
|
2821
|
-
await
|
|
4335
|
+
await requireAccessGrant7(c, deps, workspaceId, "environments:use");
|
|
2822
4336
|
return c.json(await listWorkspaceEnvironments2(db, workspaceId));
|
|
2823
4337
|
});
|
|
2824
4338
|
app.post("/v1/workspaces/:workspaceId/environments", async (c) => {
|
|
2825
4339
|
const workspaceId = c.req.param("workspaceId");
|
|
2826
|
-
const grant = await
|
|
2827
|
-
const key =
|
|
4340
|
+
const grant = await requireAccessGrant7(c, deps, workspaceId, "environments:manage");
|
|
4341
|
+
const key = requireEnvironmentEncryption4(settings);
|
|
2828
4342
|
const payload = CreateWorkspaceEnvironmentRequest.parse(await c.req.json());
|
|
2829
4343
|
const name = trimmedEnvironmentName(payload.name);
|
|
2830
4344
|
if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT2) {
|
|
2831
|
-
throw new
|
|
4345
|
+
throw new HTTPException9(422, { message: `an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables` });
|
|
2832
4346
|
}
|
|
2833
4347
|
const variableNames = /* @__PURE__ */ new Set();
|
|
2834
4348
|
for (const variable of payload.variables) {
|
|
2835
4349
|
assertAllowedEnvironmentVariableName2(variable.name);
|
|
2836
4350
|
if (variableNames.has(variable.name)) {
|
|
2837
|
-
throw new
|
|
4351
|
+
throw new HTTPException9(422, { message: `duplicate environment variable name: ${variable.name}` });
|
|
2838
4352
|
}
|
|
2839
4353
|
variableNames.add(variable.name);
|
|
2840
4354
|
}
|
|
2841
4355
|
if (await countWorkspaceEnvironments2(db, workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE2) {
|
|
2842
|
-
throw new
|
|
4356
|
+
throw new HTTPException9(422, { message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE2} environments` });
|
|
2843
4357
|
}
|
|
2844
4358
|
if (await getWorkspaceEnvironmentByName2(db, workspaceId, name)) {
|
|
2845
|
-
throw new
|
|
4359
|
+
throw new HTTPException9(409, { message: `environment name is already in use: ${name}` });
|
|
2846
4360
|
}
|
|
2847
4361
|
const created = await createWorkspaceEnvironment2(db, {
|
|
2848
4362
|
accountId: grant.accountId,
|
|
@@ -2851,7 +4365,7 @@ function registerEnvironmentRoutes(app, deps) {
|
|
|
2851
4365
|
description: payload.description ?? null,
|
|
2852
4366
|
variables: payload.variables.map((variable) => ({
|
|
2853
4367
|
name: variable.name,
|
|
2854
|
-
valueEncrypted:
|
|
4368
|
+
valueEncrypted: encryptEnvironmentValue5(key, variable.value)
|
|
2855
4369
|
}))
|
|
2856
4370
|
});
|
|
2857
4371
|
await recordEnvironmentAuditEvent2(db, { grant, action: "environment.created", environmentId: created.id });
|
|
@@ -2859,19 +4373,19 @@ function registerEnvironmentRoutes(app, deps) {
|
|
|
2859
4373
|
});
|
|
2860
4374
|
app.get("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
|
|
2861
4375
|
const workspaceId = c.req.param("workspaceId");
|
|
2862
|
-
await
|
|
4376
|
+
await requireAccessGrant7(c, deps, workspaceId, "environments:use");
|
|
2863
4377
|
return c.json(await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId")));
|
|
2864
4378
|
});
|
|
2865
4379
|
app.patch("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
|
|
2866
4380
|
const workspaceId = c.req.param("workspaceId");
|
|
2867
|
-
const grant = await
|
|
4381
|
+
const grant = await requireAccessGrant7(c, deps, workspaceId, "environments:manage");
|
|
2868
4382
|
const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
|
|
2869
4383
|
const payload = UpdateWorkspaceEnvironmentRequest.parse(await c.req.json());
|
|
2870
4384
|
const name = payload.name !== void 0 ? trimmedEnvironmentName(payload.name) : void 0;
|
|
2871
4385
|
if (name !== void 0 && name !== environment.name) {
|
|
2872
4386
|
const existing = await getWorkspaceEnvironmentByName2(db, workspaceId, name);
|
|
2873
4387
|
if (existing && existing.id !== environment.id) {
|
|
2874
|
-
throw new
|
|
4388
|
+
throw new HTTPException9(409, { message: `environment name is already in use: ${name}` });
|
|
2875
4389
|
}
|
|
2876
4390
|
}
|
|
2877
4391
|
const updated = await updateWorkspaceEnvironment(db, workspaceId, environment.id, {
|
|
@@ -2883,15 +4397,15 @@ function registerEnvironmentRoutes(app, deps) {
|
|
|
2883
4397
|
});
|
|
2884
4398
|
app.delete("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
|
|
2885
4399
|
const workspaceId = c.req.param("workspaceId");
|
|
2886
|
-
const grant = await
|
|
4400
|
+
const grant = await requireAccessGrant7(c, deps, workspaceId, "environments:manage");
|
|
2887
4401
|
const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
|
|
2888
4402
|
const attachedTasks = await countScheduledTasksUsingEnvironment(db, workspaceId, environment.id);
|
|
2889
4403
|
if (attachedTasks > 0) {
|
|
2890
|
-
throw new
|
|
4404
|
+
throw new HTTPException9(409, { message: `environment is attached to ${attachedTasks} scheduled task(s); detach first` });
|
|
2891
4405
|
}
|
|
2892
4406
|
const activeSessions = await countActiveSessionsUsingEnvironment(db, workspaceId, environment.id);
|
|
2893
4407
|
if (activeSessions > 0) {
|
|
2894
|
-
throw new
|
|
4408
|
+
throw new HTTPException9(409, { message: `environment is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first` });
|
|
2895
4409
|
}
|
|
2896
4410
|
await deleteWorkspaceEnvironment(db, workspaceId, environment.id);
|
|
2897
4411
|
await recordEnvironmentAuditEvent2(db, { grant, action: "environment.deleted", environmentId: environment.id });
|
|
@@ -2899,33 +4413,33 @@ function registerEnvironmentRoutes(app, deps) {
|
|
|
2899
4413
|
});
|
|
2900
4414
|
app.put("/v1/workspaces/:workspaceId/environments/:environmentId/variables/:name", async (c) => {
|
|
2901
4415
|
const workspaceId = c.req.param("workspaceId");
|
|
2902
|
-
const grant = await
|
|
2903
|
-
const key =
|
|
4416
|
+
const grant = await requireAccessGrant7(c, deps, workspaceId, "environments:manage");
|
|
4417
|
+
const key = requireEnvironmentEncryption4(settings);
|
|
2904
4418
|
const name = parseVariableName(c.req.param("name"));
|
|
2905
4419
|
const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
|
|
2906
4420
|
const payload = SetWorkspaceEnvironmentVariableRequest.parse(await c.req.json());
|
|
2907
4421
|
const exists = environment.variables.some((variable) => variable.name === name);
|
|
2908
4422
|
if (!exists && environment.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT2) {
|
|
2909
|
-
throw new
|
|
4423
|
+
throw new HTTPException9(422, { message: `an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT2} variables` });
|
|
2910
4424
|
}
|
|
2911
4425
|
const metadata = await setWorkspaceEnvironmentVariable2(db, {
|
|
2912
4426
|
accountId: grant.accountId,
|
|
2913
4427
|
workspaceId,
|
|
2914
4428
|
environmentId: environment.id,
|
|
2915
4429
|
name,
|
|
2916
|
-
valueEncrypted:
|
|
4430
|
+
valueEncrypted: encryptEnvironmentValue5(key, payload.value)
|
|
2917
4431
|
});
|
|
2918
4432
|
await recordEnvironmentAuditEvent2(db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: name });
|
|
2919
4433
|
return c.json(metadata);
|
|
2920
4434
|
});
|
|
2921
4435
|
app.delete("/v1/workspaces/:workspaceId/environments/:environmentId/variables/:name", async (c) => {
|
|
2922
4436
|
const workspaceId = c.req.param("workspaceId");
|
|
2923
|
-
const grant = await
|
|
4437
|
+
const grant = await requireAccessGrant7(c, deps, workspaceId, "environments:manage");
|
|
2924
4438
|
const name = parseVariableName(c.req.param("name"));
|
|
2925
4439
|
const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
|
|
2926
4440
|
const deleted = await deleteWorkspaceEnvironmentVariable(db, workspaceId, environment.id, name);
|
|
2927
4441
|
if (!deleted) {
|
|
2928
|
-
throw new
|
|
4442
|
+
throw new HTTPException9(404, { message: "environment variable not found" });
|
|
2929
4443
|
}
|
|
2930
4444
|
await recordEnvironmentAuditEvent2(db, { grant, action: "environment.variable.deleted", environmentId: environment.id, variableName: name });
|
|
2931
4445
|
return c.json({ ok: true });
|
|
@@ -2934,7 +4448,7 @@ function registerEnvironmentRoutes(app, deps) {
|
|
|
2934
4448
|
function parseVariableName(raw) {
|
|
2935
4449
|
const parsed = WorkspaceEnvironmentVariableName2.safeParse(raw);
|
|
2936
4450
|
if (!parsed.success) {
|
|
2937
|
-
throw new
|
|
4451
|
+
throw new HTTPException9(422, { message: "environment variable names must match ^[A-Z][A-Z0-9_]*$" });
|
|
2938
4452
|
}
|
|
2939
4453
|
assertAllowedEnvironmentVariableName2(parsed.data);
|
|
2940
4454
|
return parsed.data;
|
|
@@ -2942,7 +4456,7 @@ function parseVariableName(raw) {
|
|
|
2942
4456
|
function trimmedEnvironmentName(name) {
|
|
2943
4457
|
const trimmed = name.trim();
|
|
2944
4458
|
if (!trimmed) {
|
|
2945
|
-
throw new
|
|
4459
|
+
throw new HTTPException9(422, { message: "environment name is required" });
|
|
2946
4460
|
}
|
|
2947
4461
|
return trimmed;
|
|
2948
4462
|
}
|
|
@@ -2962,21 +4476,21 @@ import {
|
|
|
2962
4476
|
markFileUploadFailed,
|
|
2963
4477
|
requireFile as requireFile2
|
|
2964
4478
|
} from "@opengeni/db";
|
|
2965
|
-
import { HTTPException as
|
|
2966
|
-
import { requireAccessGrant as
|
|
4479
|
+
import { HTTPException as HTTPException10 } from "hono/http-exception";
|
|
4480
|
+
import { requireAccessGrant as requireAccessGrant8 } from "@opengeni/core";
|
|
2967
4481
|
import { recordWorkspaceUsage as recordWorkspaceUsage3, requireLimit as requireLimit3 } from "@opengeni/core";
|
|
2968
4482
|
function registerFileRoutes(app, deps) {
|
|
2969
4483
|
const { db, objectStorage } = deps;
|
|
2970
4484
|
app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {
|
|
2971
4485
|
const workspaceId = c.req.param("workspaceId");
|
|
2972
|
-
const grant = await
|
|
4486
|
+
const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
|
|
2973
4487
|
if (!objectStorage) {
|
|
2974
|
-
throw new
|
|
4488
|
+
throw new HTTPException10(503, { message: "object storage is not configured" });
|
|
2975
4489
|
}
|
|
2976
4490
|
const payload = CreateFileUploadRequest.parse(await c.req.json());
|
|
2977
4491
|
await requireLimit3(deps, { accountId: grant.accountId, workspaceId, action: "file:upload", quantity: payload.sizeBytes });
|
|
2978
4492
|
if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
|
|
2979
|
-
throw new
|
|
4493
|
+
throw new HTTPException10(413, { message: `file exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes` });
|
|
2980
4494
|
}
|
|
2981
4495
|
const fileId = crypto.randomUUID();
|
|
2982
4496
|
const safeFilename = sanitizeFilename(payload.filename);
|
|
@@ -3010,35 +4524,35 @@ function registerFileRoutes(app, deps) {
|
|
|
3010
4524
|
});
|
|
3011
4525
|
app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
|
|
3012
4526
|
const workspaceId = c.req.param("workspaceId");
|
|
3013
|
-
const grant = await
|
|
4527
|
+
const grant = await requireAccessGrant8(c, deps, workspaceId, "files:upload");
|
|
3014
4528
|
if (!objectStorage) {
|
|
3015
|
-
throw new
|
|
4529
|
+
throw new HTTPException10(503, { message: "object storage is not configured" });
|
|
3016
4530
|
}
|
|
3017
4531
|
const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
|
|
3018
4532
|
if (!upload) {
|
|
3019
|
-
throw new
|
|
4533
|
+
throw new HTTPException10(404, { message: "file upload not found" });
|
|
3020
4534
|
}
|
|
3021
4535
|
if (upload.status !== "pending") {
|
|
3022
|
-
throw new
|
|
4536
|
+
throw new HTTPException10(409, { message: `file upload is ${upload.status}` });
|
|
3023
4537
|
}
|
|
3024
4538
|
if (upload.expiresAt.getTime() < Date.now()) {
|
|
3025
4539
|
await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
|
|
3026
|
-
throw new
|
|
4540
|
+
throw new HTTPException10(409, { message: "file upload has expired" });
|
|
3027
4541
|
}
|
|
3028
4542
|
const head = await objectStorage.headFile(upload.file).catch((error) => {
|
|
3029
|
-
throw new
|
|
4543
|
+
throw new HTTPException10(409, { message: `uploaded object is not available: ${error instanceof Error ? error.message : String(error)}` });
|
|
3030
4544
|
});
|
|
3031
4545
|
if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
|
|
3032
4546
|
await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
|
|
3033
|
-
throw new
|
|
4547
|
+
throw new HTTPException10(422, { message: "uploaded object size does not match file metadata" });
|
|
3034
4548
|
}
|
|
3035
4549
|
if (upload.file.contentType && head.ContentType && head.ContentType !== upload.file.contentType) {
|
|
3036
4550
|
await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
|
|
3037
|
-
throw new
|
|
4551
|
+
throw new HTTPException10(422, { message: "uploaded object content type does not match file metadata" });
|
|
3038
4552
|
}
|
|
3039
4553
|
if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
|
|
3040
4554
|
await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
|
|
3041
|
-
throw new
|
|
4555
|
+
throw new HTTPException10(422, { message: "uploaded object checksum metadata does not match file metadata" });
|
|
3042
4556
|
}
|
|
3043
4557
|
const file = await completeFileUpload(db, workspaceId, upload.id);
|
|
3044
4558
|
await recordWorkspaceUsage3(deps, {
|
|
@@ -3056,25 +4570,25 @@ function registerFileRoutes(app, deps) {
|
|
|
3056
4570
|
});
|
|
3057
4571
|
app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
|
|
3058
4572
|
const workspaceId = c.req.param("workspaceId");
|
|
3059
|
-
await
|
|
4573
|
+
await requireAccessGrant8(c, deps, workspaceId, "files:read");
|
|
3060
4574
|
const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
|
|
3061
4575
|
if (!file) {
|
|
3062
|
-
throw new
|
|
4576
|
+
throw new HTTPException10(404, { message: "file not found" });
|
|
3063
4577
|
}
|
|
3064
4578
|
return c.json(FileAsset.parse(file));
|
|
3065
4579
|
});
|
|
3066
4580
|
app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
|
|
3067
4581
|
const workspaceId = c.req.param("workspaceId");
|
|
3068
|
-
await
|
|
4582
|
+
await requireAccessGrant8(c, deps, workspaceId, "files:read");
|
|
3069
4583
|
if (!objectStorage) {
|
|
3070
|
-
throw new
|
|
4584
|
+
throw new HTTPException10(503, { message: "object storage is not configured" });
|
|
3071
4585
|
}
|
|
3072
4586
|
const file = await requireFile2(db, workspaceId, c.req.param("fileId")).catch(() => null);
|
|
3073
4587
|
if (!file) {
|
|
3074
|
-
throw new
|
|
4588
|
+
throw new HTTPException10(404, { message: "file not found" });
|
|
3075
4589
|
}
|
|
3076
4590
|
if (file.status !== "ready") {
|
|
3077
|
-
throw new
|
|
4591
|
+
throw new HTTPException10(409, { message: `file is ${file.status}` });
|
|
3078
4592
|
}
|
|
3079
4593
|
const signed = await objectStorage.createGetUrl({ key: file.objectKey });
|
|
3080
4594
|
return c.json(FileDownloadUrlResponse.parse({
|
|
@@ -3093,18 +4607,18 @@ function sanitizeFilename(filename) {
|
|
|
3093
4607
|
import { CreateApiKeyRequest, CreateApiKeyResponse } from "@opengeni/contracts";
|
|
3094
4608
|
import { createApiKey, listApiKeys, revokeApiKey } from "@opengeni/db";
|
|
3095
4609
|
import { zValidator } from "@hono/zod-validator";
|
|
3096
|
-
import { HTTPException as
|
|
3097
|
-
import { requireAccessGrant as
|
|
4610
|
+
import { HTTPException as HTTPException11 } from "hono/http-exception";
|
|
4611
|
+
import { requireAccessGrant as requireAccessGrant9 } from "@opengeni/core";
|
|
3098
4612
|
import { requireLimit as requireLimit4 } from "@opengeni/core";
|
|
3099
4613
|
function registerApiKeyRoutes(app, deps) {
|
|
3100
4614
|
app.get("/v1/workspaces/:workspaceId/api-keys", async (c) => {
|
|
3101
4615
|
const workspaceId = c.req.param("workspaceId");
|
|
3102
|
-
await
|
|
4616
|
+
await requireAccessGrant9(c, deps, workspaceId, "api_keys:manage");
|
|
3103
4617
|
return c.json({ apiKeys: await listApiKeys(deps.db, workspaceId) });
|
|
3104
4618
|
});
|
|
3105
4619
|
app.post("/v1/workspaces/:workspaceId/api-keys", zValidator("json", CreateApiKeyRequest.omit({ workspaceId: true })), async (c) => {
|
|
3106
4620
|
const workspaceId = c.req.param("workspaceId");
|
|
3107
|
-
const grant = await
|
|
4621
|
+
const grant = await requireAccessGrant9(c, deps, workspaceId, "api_keys:manage");
|
|
3108
4622
|
const body = c.req.valid("json");
|
|
3109
4623
|
const permissions = body.permissions.length > 0 ? body.permissions : ["workspace:read"];
|
|
3110
4624
|
ensureDelegablePermissions(grant.permissions, permissions);
|
|
@@ -3124,7 +4638,7 @@ function registerApiKeyRoutes(app, deps) {
|
|
|
3124
4638
|
});
|
|
3125
4639
|
app.delete("/v1/workspaces/:workspaceId/api-keys/:apiKeyId", async (c) => {
|
|
3126
4640
|
const workspaceId = c.req.param("workspaceId");
|
|
3127
|
-
await
|
|
4641
|
+
await requireAccessGrant9(c, deps, workspaceId, "api_keys:manage");
|
|
3128
4642
|
return c.json(await revokeApiKey(deps.db, workspaceId, c.req.param("apiKeyId")));
|
|
3129
4643
|
});
|
|
3130
4644
|
}
|
|
@@ -3134,7 +4648,7 @@ function ensureDelegablePermissions(grantPermissions, requested) {
|
|
|
3134
4648
|
}
|
|
3135
4649
|
const missing = requested.filter((permission) => !grantPermissions.includes(permission));
|
|
3136
4650
|
if (missing.length > 0) {
|
|
3137
|
-
throw new
|
|
4651
|
+
throw new HTTPException11(403, { message: `cannot delegate missing permissions: ${missing.join(", ")}` });
|
|
3138
4652
|
}
|
|
3139
4653
|
}
|
|
3140
4654
|
function generateApiKeyToken() {
|
|
@@ -3166,7 +4680,7 @@ import {
|
|
|
3166
4680
|
recordStripeWebhookEvent,
|
|
3167
4681
|
upsertBillingCustomer
|
|
3168
4682
|
} from "@opengeni/db";
|
|
3169
|
-
import { HTTPException as
|
|
4683
|
+
import { HTTPException as HTTPException12 } from "hono/http-exception";
|
|
3170
4684
|
import Stripe from "stripe";
|
|
3171
4685
|
import { requireAccessContext } from "@opengeni/core";
|
|
3172
4686
|
function registerBillingRoutes(app, deps) {
|
|
@@ -3180,7 +4694,7 @@ function registerBillingRoutes(app, deps) {
|
|
|
3180
4694
|
const accountId = requireSelectedAccount(context, c.req.query("accountId"), "billing:read");
|
|
3181
4695
|
const workspaceId = c.req.query("workspaceId");
|
|
3182
4696
|
if (workspaceId && !context.workspaceGrants.some((grant) => grant.accountId === accountId && grant.workspaceId === workspaceId)) {
|
|
3183
|
-
throw new
|
|
4697
|
+
throw new HTTPException12(403, { message: "missing workspace access for usage query" });
|
|
3184
4698
|
}
|
|
3185
4699
|
return c.json({
|
|
3186
4700
|
balance: await getBillingBalance(deps.db, accountId),
|
|
@@ -3198,12 +4712,12 @@ function registerBillingRoutes(app, deps) {
|
|
|
3198
4712
|
});
|
|
3199
4713
|
app.post("/v1/billing/checkout", async (c) => {
|
|
3200
4714
|
if (deps.settings.billingMode !== "stripe") {
|
|
3201
|
-
throw new
|
|
4715
|
+
throw new HTTPException12(404, { message: "stripe billing is not enabled" });
|
|
3202
4716
|
}
|
|
3203
4717
|
const context = await requireAccessContext(c, deps);
|
|
3204
4718
|
const parsed = CreateCheckoutRequest.safeParse(await c.req.json());
|
|
3205
4719
|
if (!parsed.success) {
|
|
3206
|
-
throw new
|
|
4720
|
+
throw new HTTPException12(400, { message: parsed.error.issues[0]?.message ?? "invalid checkout request" });
|
|
3207
4721
|
}
|
|
3208
4722
|
const body = parsed.data;
|
|
3209
4723
|
const accountId = requireSelectedAccount(context, body.accountId, "billing:manage");
|
|
@@ -3224,7 +4738,7 @@ function registerBillingRoutes(app, deps) {
|
|
|
3224
4738
|
idempotencyKey
|
|
3225
4739
|
}), { idempotencyKey });
|
|
3226
4740
|
if (!session.url) {
|
|
3227
|
-
throw new
|
|
4741
|
+
throw new HTTPException12(502, { message: "Stripe did not return a checkout URL" });
|
|
3228
4742
|
}
|
|
3229
4743
|
return c.json(CreateCheckoutResponse.parse({
|
|
3230
4744
|
checkoutSessionId: session.id,
|
|
@@ -3233,18 +4747,18 @@ function registerBillingRoutes(app, deps) {
|
|
|
3233
4747
|
});
|
|
3234
4748
|
app.post("/v1/webhooks/stripe", async (c) => {
|
|
3235
4749
|
if (deps.settings.billingMode !== "stripe") {
|
|
3236
|
-
throw new
|
|
4750
|
+
throw new HTTPException12(404, { message: "stripe billing is not enabled" });
|
|
3237
4751
|
}
|
|
3238
4752
|
const signature = c.req.header("stripe-signature");
|
|
3239
4753
|
if (!signature) {
|
|
3240
|
-
throw new
|
|
4754
|
+
throw new HTTPException12(400, { message: "missing stripe-signature" });
|
|
3241
4755
|
}
|
|
3242
4756
|
const payload = await c.req.text();
|
|
3243
4757
|
let event;
|
|
3244
4758
|
try {
|
|
3245
4759
|
event = await stripeClient(deps).webhooks.constructEventAsync(payload, signature, deps.settings.stripeWebhookSecret);
|
|
3246
4760
|
} catch (error) {
|
|
3247
|
-
throw new
|
|
4761
|
+
throw new HTTPException12(400, { message: error instanceof Error ? error.message : "invalid stripe signature" });
|
|
3248
4762
|
}
|
|
3249
4763
|
const firstSeen = await recordStripeWebhookEvent(deps.db, {
|
|
3250
4764
|
id: event.id,
|
|
@@ -3262,7 +4776,7 @@ function registerBillingRoutes(app, deps) {
|
|
|
3262
4776
|
await markStripeWebhookProcessed(deps.db, event.id);
|
|
3263
4777
|
return c.json({ received: true });
|
|
3264
4778
|
} catch (error) {
|
|
3265
|
-
throw new
|
|
4779
|
+
throw new HTTPException12(500, { message: error instanceof Error ? error.message : String(error) });
|
|
3266
4780
|
}
|
|
3267
4781
|
});
|
|
3268
4782
|
}
|
|
@@ -3314,7 +4828,7 @@ function stripeCheckoutSessionCreateParams(input) {
|
|
|
3314
4828
|
}
|
|
3315
4829
|
function checkoutReturnUrl(publicBaseUrl, candidate, fallbackPath, field) {
|
|
3316
4830
|
if (!publicBaseUrl) {
|
|
3317
|
-
throw new
|
|
4831
|
+
throw new HTTPException12(500, { message: "OPENGENI_PUBLIC_BASE_URL is required for Stripe checkout" });
|
|
3318
4832
|
}
|
|
3319
4833
|
const base = new URL(publicBaseUrl);
|
|
3320
4834
|
const fallback = new URL(fallbackPath, base).toString();
|
|
@@ -3323,7 +4837,7 @@ function checkoutReturnUrl(publicBaseUrl, candidate, fallbackPath, field) {
|
|
|
3323
4837
|
}
|
|
3324
4838
|
const parsed = new URL(candidate);
|
|
3325
4839
|
if (parsed.origin !== base.origin) {
|
|
3326
|
-
throw new
|
|
4840
|
+
throw new HTTPException12(400, { message: `${field} must use the OpenGeni public origin` });
|
|
3327
4841
|
}
|
|
3328
4842
|
return parsed.toString();
|
|
3329
4843
|
}
|
|
@@ -3394,6 +4908,7 @@ async function handleCheckoutSessionCompleted(deps, event) {
|
|
|
3394
4908
|
stripeCreditAmountUsd: credit.amountUsd
|
|
3395
4909
|
}
|
|
3396
4910
|
});
|
|
4911
|
+
recordCreditMicrosMetric(deps, "topup", credit.amountMicros);
|
|
3397
4912
|
}
|
|
3398
4913
|
async function mirrorPaymentIntentCustomer(deps, event) {
|
|
3399
4914
|
const intent = event.data.object;
|
|
@@ -3426,18 +4941,23 @@ async function applyRefundDebit(deps, stripe, refund) {
|
|
|
3426
4941
|
if (!accountId) {
|
|
3427
4942
|
return;
|
|
3428
4943
|
}
|
|
4944
|
+
const idempotencyKey = `stripe:refund:${refund.id}`;
|
|
4945
|
+
if (await hasCreditLedgerEntry(deps.db, accountId, idempotencyKey)) {
|
|
4946
|
+
return;
|
|
4947
|
+
}
|
|
3429
4948
|
await applyCreditLedgerEntry(deps.db, {
|
|
3430
4949
|
accountId,
|
|
3431
4950
|
type: "credit_refund",
|
|
3432
4951
|
amountMicros: -centsToMicros(refund.amount),
|
|
3433
4952
|
sourceType: "stripe_refund",
|
|
3434
4953
|
sourceId: refund.id,
|
|
3435
|
-
idempotencyKey
|
|
4954
|
+
idempotencyKey,
|
|
3436
4955
|
metadata: {
|
|
3437
4956
|
stripeRefundId: refund.id,
|
|
3438
4957
|
stripePaymentIntentId: paymentIntentId(refund.payment_intent)
|
|
3439
4958
|
}
|
|
3440
4959
|
});
|
|
4960
|
+
recordCreditMicrosMetric(deps, "refund", centsToMicros(refund.amount));
|
|
3441
4961
|
}
|
|
3442
4962
|
async function holdDisputedCredits(deps, stripe, event) {
|
|
3443
4963
|
const dispute = event.data.object;
|
|
@@ -3492,6 +5012,17 @@ async function mirrorCustomer(deps, event, customer) {
|
|
|
3492
5012
|
email: typeof customer.email === "string" ? customer.email : null
|
|
3493
5013
|
});
|
|
3494
5014
|
}
|
|
5015
|
+
function recordCreditMicrosMetric(deps, kind, amountMicros) {
|
|
5016
|
+
if (amountMicros <= 0) {
|
|
5017
|
+
return;
|
|
5018
|
+
}
|
|
5019
|
+
deps.observability?.incrementCounter({
|
|
5020
|
+
name: "opengeni_credit_micros_total",
|
|
5021
|
+
help: "Total credit micros recorded by kind.",
|
|
5022
|
+
labels: { kind },
|
|
5023
|
+
amount: amountMicros
|
|
5024
|
+
});
|
|
5025
|
+
}
|
|
3495
5026
|
async function metadataForRefund(stripe, refund) {
|
|
3496
5027
|
if (Object.keys(refund.metadata ?? {}).length > 0) {
|
|
3497
5028
|
return refund.metadata;
|
|
@@ -3538,7 +5069,7 @@ async function getOrCreateStripeCustomer(deps, stripe, context, accountId) {
|
|
|
3538
5069
|
}
|
|
3539
5070
|
const account = await getManagedAccount(deps.db, accountId);
|
|
3540
5071
|
if (!account) {
|
|
3541
|
-
throw new
|
|
5072
|
+
throw new HTTPException12(404, { message: "account not found" });
|
|
3542
5073
|
}
|
|
3543
5074
|
const customer = await stripe.customers.create({
|
|
3544
5075
|
name: account.name,
|
|
@@ -3564,17 +5095,17 @@ function stripeCustomerProvider(input) {
|
|
|
3564
5095
|
function requireSelectedAccount(context, requested, permission) {
|
|
3565
5096
|
const accountId = requested ?? context.defaultAccountId ?? void 0;
|
|
3566
5097
|
if (!accountId) {
|
|
3567
|
-
throw new
|
|
5098
|
+
throw new HTTPException12(409, { message: "account selection is required" });
|
|
3568
5099
|
}
|
|
3569
5100
|
const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
|
|
3570
5101
|
if (!grant || !grant.permissions.includes(permission) && !grant.permissions.includes("account:admin")) {
|
|
3571
|
-
throw new
|
|
5102
|
+
throw new HTTPException12(403, { message: `missing permission: ${permission}` });
|
|
3572
5103
|
}
|
|
3573
5104
|
return accountId;
|
|
3574
5105
|
}
|
|
3575
5106
|
function stripeClient(deps) {
|
|
3576
5107
|
if (!deps.settings.stripeSecretKey) {
|
|
3577
|
-
throw new
|
|
5108
|
+
throw new HTTPException12(500, { message: "Stripe secret key is not configured" });
|
|
3578
5109
|
}
|
|
3579
5110
|
return new Stripe(deps.settings.stripeSecretKey);
|
|
3580
5111
|
}
|
|
@@ -3603,7 +5134,7 @@ import {
|
|
|
3603
5134
|
import {
|
|
3604
5135
|
buildGitHubAppManifest,
|
|
3605
5136
|
convertGitHubAppManifest,
|
|
3606
|
-
createSignedState as
|
|
5137
|
+
createSignedState as createSignedState4,
|
|
3607
5138
|
envLinesFromGitHubManifestConversion,
|
|
3608
5139
|
GitHubAppApiError,
|
|
3609
5140
|
GitHubAppConfigurationError as GitHubAppConfigurationError2,
|
|
@@ -3612,23 +5143,23 @@ import {
|
|
|
3612
5143
|
listGitHubAppRepositories as listGitHubAppRepositories2,
|
|
3613
5144
|
organizationAppManifestUrl,
|
|
3614
5145
|
personalAppManifestUrl,
|
|
3615
|
-
readSignedState as
|
|
5146
|
+
readSignedState as readSignedState3,
|
|
3616
5147
|
stateMaxAgeSeconds as stateMaxAgeSeconds2,
|
|
3617
5148
|
verifyGitHubInstallationAccessForUser,
|
|
3618
5149
|
verifySignedState
|
|
3619
5150
|
} from "@opengeni/github";
|
|
3620
5151
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
3621
|
-
import { HTTPException as
|
|
3622
|
-
import { requireAccessGrant as
|
|
5152
|
+
import { HTTPException as HTTPException13 } from "hono/http-exception";
|
|
5153
|
+
import { requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
|
|
3623
5154
|
var githubStateCookie = "opengeni_github_state";
|
|
3624
5155
|
function registerGitHubRoutes(app, deps) {
|
|
3625
5156
|
const { db, settings, githubStateSecret } = deps;
|
|
3626
5157
|
app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
|
|
3627
5158
|
const workspaceId = c.req.param("workspaceId");
|
|
3628
|
-
const grant = await
|
|
5159
|
+
const grant = await requireAccessGrant10(c, deps, workspaceId, "github:use");
|
|
3629
5160
|
const missing = githubAppMissingSettings2(settings);
|
|
3630
5161
|
const slug = settings.githubAppSlug?.trim() || null;
|
|
3631
|
-
const state =
|
|
5162
|
+
const state = createSignedState4(githubStateSecret, {
|
|
3632
5163
|
accountId: grant.accountId,
|
|
3633
5164
|
workspaceId: grant.workspaceId
|
|
3634
5165
|
});
|
|
@@ -3646,49 +5177,49 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3646
5177
|
const workspaceId = c.req.param("workspaceId");
|
|
3647
5178
|
const state = c.req.query("state");
|
|
3648
5179
|
if (!state) {
|
|
3649
|
-
throw new
|
|
5180
|
+
throw new HTTPException13(400, { message: "missing GitHub installation state" });
|
|
3650
5181
|
}
|
|
3651
|
-
const statePayload =
|
|
5182
|
+
const statePayload = readSignedState3(state, githubStateSecret);
|
|
3652
5183
|
if (!statePayload || statePayload.workspaceId !== workspaceId) {
|
|
3653
|
-
throw new
|
|
5184
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub installation state" });
|
|
3654
5185
|
}
|
|
3655
5186
|
const slug = settings.githubAppSlug?.trim();
|
|
3656
5187
|
if (!slug) {
|
|
3657
|
-
throw new
|
|
5188
|
+
throw new HTTPException13(409, { message: JSON.stringify({ message: "GitHub App is not configured", missing: githubAppMissingSettings2(settings) }) });
|
|
3658
5189
|
}
|
|
3659
5190
|
setGitHubStateCookie(c, deps, state);
|
|
3660
5191
|
return c.redirect(`https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`);
|
|
3661
5192
|
});
|
|
3662
5193
|
app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
|
|
3663
5194
|
const workspaceId = c.req.param("workspaceId");
|
|
3664
|
-
await
|
|
5195
|
+
await requireAccessGrant10(c, deps, workspaceId, "github:use");
|
|
3665
5196
|
try {
|
|
3666
5197
|
return c.json({ repositories: await listWorkspaceGitHubRepositories(deps, workspaceId) });
|
|
3667
5198
|
} catch (error) {
|
|
3668
5199
|
if (error instanceof GitHubAppConfigurationError2) {
|
|
3669
|
-
throw new
|
|
5200
|
+
throw new HTTPException13(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
3670
5201
|
}
|
|
3671
|
-
throw new
|
|
5202
|
+
throw new HTTPException13(502, { message: error instanceof Error ? error.message : String(error) });
|
|
3672
5203
|
}
|
|
3673
5204
|
});
|
|
3674
5205
|
app.post("/v1/workspaces/:workspaceId/github/repositories/sync", async (c) => {
|
|
3675
5206
|
const workspaceId = c.req.param("workspaceId");
|
|
3676
|
-
await
|
|
5207
|
+
await requireAccessGrant10(c, deps, workspaceId, "github:use");
|
|
3677
5208
|
try {
|
|
3678
5209
|
return c.json({ repositories: await listWorkspaceGitHubRepositories(deps, workspaceId) });
|
|
3679
5210
|
} catch (error) {
|
|
3680
5211
|
if (error instanceof GitHubAppConfigurationError2) {
|
|
3681
|
-
throw new
|
|
5212
|
+
throw new HTTPException13(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
3682
5213
|
}
|
|
3683
|
-
throw new
|
|
5214
|
+
throw new HTTPException13(502, { message: error instanceof Error ? error.message : String(error) });
|
|
3684
5215
|
}
|
|
3685
5216
|
});
|
|
3686
5217
|
app.post("/v1/workspaces/:workspaceId/github/app-manifest", async (c) => {
|
|
3687
5218
|
const workspaceId = c.req.param("workspaceId");
|
|
3688
|
-
const grant = await
|
|
5219
|
+
const grant = await requireAccessGrant10(c, deps, workspaceId, "github:manage");
|
|
3689
5220
|
const payload = GitHubAppManifestCreate.parse(await c.req.json());
|
|
3690
5221
|
const baseUrl = (settings.githubAppManifestBaseUrl ?? new URL(c.req.url).origin).replace(/\/+$/, "");
|
|
3691
|
-
const state =
|
|
5222
|
+
const state = createSignedState4(githubStateSecret, {
|
|
3692
5223
|
accountId: grant.accountId,
|
|
3693
5224
|
workspaceId: grant.workspaceId
|
|
3694
5225
|
});
|
|
@@ -3712,10 +5243,10 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3712
5243
|
const code = c.req.query("code");
|
|
3713
5244
|
const state = c.req.query("state");
|
|
3714
5245
|
if (!code) {
|
|
3715
|
-
throw new
|
|
5246
|
+
throw new HTTPException13(400, { message: "missing GitHub manifest code" });
|
|
3716
5247
|
}
|
|
3717
5248
|
if (!state || !verifySignedState(state, githubStateSecret)) {
|
|
3718
|
-
throw new
|
|
5249
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub manifest state" });
|
|
3719
5250
|
}
|
|
3720
5251
|
try {
|
|
3721
5252
|
const conversion = await convertGitHubAppManifest(code);
|
|
@@ -3726,7 +5257,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3726
5257
|
return c.html(githubSuccessHtml(envLines, installUrl));
|
|
3727
5258
|
} catch (error) {
|
|
3728
5259
|
const message = error instanceof GitHubAppApiError ? error.message : String(error);
|
|
3729
|
-
throw new
|
|
5260
|
+
throw new HTTPException13(502, { message });
|
|
3730
5261
|
}
|
|
3731
5262
|
});
|
|
3732
5263
|
const handleGitHubInstallCallback = async (c) => {
|
|
@@ -3735,30 +5266,30 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3735
5266
|
const installationIdRaw = c.req.query("installation_id");
|
|
3736
5267
|
const setupAction = c.req.query("setup_action") ?? null;
|
|
3737
5268
|
if (!state) {
|
|
3738
|
-
throw new
|
|
5269
|
+
throw new HTTPException13(400, { message: "missing GitHub installation state" });
|
|
3739
5270
|
}
|
|
3740
|
-
const statePayload =
|
|
5271
|
+
const statePayload = readSignedState3(state, githubStateSecret);
|
|
3741
5272
|
if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string") {
|
|
3742
|
-
throw new
|
|
5273
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub installation state" });
|
|
3743
5274
|
}
|
|
3744
5275
|
requireGitHubStateCookie(c, state);
|
|
3745
|
-
const grant = await
|
|
5276
|
+
const grant = await requireAccessGrant10(c, deps, statePayload.workspaceId, "github:manage");
|
|
3746
5277
|
if (grant.accountId !== statePayload.accountId) {
|
|
3747
|
-
throw new
|
|
5278
|
+
throw new HTTPException13(403, { message: "GitHub installation state does not match this workspace" });
|
|
3748
5279
|
}
|
|
3749
5280
|
if (setupAction === "request" && !installationIdRaw) {
|
|
3750
5281
|
return c.html(githubSetupPendingHtml());
|
|
3751
5282
|
}
|
|
3752
5283
|
const installationId = parsePositiveInteger(installationIdRaw);
|
|
3753
5284
|
if (installationId === null) {
|
|
3754
|
-
throw new
|
|
5285
|
+
throw new HTTPException13(400, { message: "missing or invalid GitHub installation_id" });
|
|
3755
5286
|
}
|
|
3756
5287
|
if (!code) {
|
|
3757
5288
|
const clientId = settings.githubClientId?.trim();
|
|
3758
5289
|
if (!clientId) {
|
|
3759
|
-
throw new
|
|
5290
|
+
throw new HTTPException13(409, { message: JSON.stringify({ message: "GitHub App is not configured", missing: ["OPENGENI_GITHUB_CLIENT_ID"] }) });
|
|
3760
5291
|
}
|
|
3761
|
-
const oauthState =
|
|
5292
|
+
const oauthState = createSignedState4(githubStateSecret, {
|
|
3762
5293
|
accountId: grant.accountId,
|
|
3763
5294
|
workspaceId: grant.workspaceId,
|
|
3764
5295
|
installationId
|
|
@@ -3783,15 +5314,15 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3783
5314
|
const code = c.req.query("code");
|
|
3784
5315
|
const state = c.req.query("state");
|
|
3785
5316
|
if (!code) {
|
|
3786
|
-
throw new
|
|
5317
|
+
throw new HTTPException13(400, { message: "missing GitHub OAuth code" });
|
|
3787
5318
|
}
|
|
3788
5319
|
if (!state) {
|
|
3789
|
-
throw new
|
|
5320
|
+
throw new HTTPException13(400, { message: "missing GitHub OAuth state" });
|
|
3790
5321
|
}
|
|
3791
|
-
const statePayload =
|
|
5322
|
+
const statePayload = readSignedState3(state, githubStateSecret);
|
|
3792
5323
|
const installationId = parsePositiveInteger(String(statePayload?.installationId ?? ""));
|
|
3793
5324
|
if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || installationId === null) {
|
|
3794
|
-
throw new
|
|
5325
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub OAuth state" });
|
|
3795
5326
|
}
|
|
3796
5327
|
requireGitHubStateCookie(c, state);
|
|
3797
5328
|
return await completeGitHubInstallationBinding(deps, c, {
|
|
@@ -3804,19 +5335,19 @@ function registerGitHubRoutes(app, deps) {
|
|
|
3804
5335
|
async function completeGitHubInstallationBinding(deps, c, input) {
|
|
3805
5336
|
const { db, settings } = deps;
|
|
3806
5337
|
if (!input.statePayload.workspaceId || !input.statePayload.accountId) {
|
|
3807
|
-
throw new
|
|
5338
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub installation state" });
|
|
3808
5339
|
}
|
|
3809
|
-
const grant = await
|
|
5340
|
+
const grant = await requireAccessGrant10(c, deps, input.statePayload.workspaceId, "github:manage");
|
|
3810
5341
|
if (grant.accountId !== input.statePayload.accountId) {
|
|
3811
|
-
throw new
|
|
5342
|
+
throw new HTTPException13(403, { message: "GitHub installation state does not match this workspace" });
|
|
3812
5343
|
}
|
|
3813
5344
|
try {
|
|
3814
5345
|
const installation = await verifyGitHubInstallationAccessForUser(settings, { code: input.code, installationId: input.installationId });
|
|
3815
5346
|
if (!installation) {
|
|
3816
|
-
throw new
|
|
5347
|
+
throw new HTTPException13(404, { message: "GitHub App installation was not found for this app" });
|
|
3817
5348
|
}
|
|
3818
5349
|
if (installation.suspended) {
|
|
3819
|
-
throw new
|
|
5350
|
+
throw new HTTPException13(409, { message: "GitHub App installation is suspended" });
|
|
3820
5351
|
}
|
|
3821
5352
|
await upsertGitHubInstallation(db, {
|
|
3822
5353
|
accountId: grant.accountId,
|
|
@@ -3829,13 +5360,13 @@ async function completeGitHubInstallationBinding(deps, c, input) {
|
|
|
3829
5360
|
deleteCookie(c, githubStateCookie, { path: "/v1/github" });
|
|
3830
5361
|
return c.html(githubSetupSuccessHtml(installation.accountLogin ?? `installation ${input.installationId}`, returnUrl));
|
|
3831
5362
|
} catch (error) {
|
|
3832
|
-
if (error instanceof
|
|
5363
|
+
if (error instanceof HTTPException13) {
|
|
3833
5364
|
throw error;
|
|
3834
5365
|
}
|
|
3835
5366
|
if (error instanceof GitHubAppConfigurationError2) {
|
|
3836
|
-
throw new
|
|
5367
|
+
throw new HTTPException13(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
3837
5368
|
}
|
|
3838
|
-
throw new
|
|
5369
|
+
throw new HTTPException13(502, { message: error instanceof Error ? error.message : String(error) });
|
|
3839
5370
|
}
|
|
3840
5371
|
}
|
|
3841
5372
|
function setGitHubStateCookie(c, deps, state) {
|
|
@@ -3849,7 +5380,7 @@ function setGitHubStateCookie(c, deps, state) {
|
|
|
3849
5380
|
}
|
|
3850
5381
|
function requireGitHubStateCookie(c, state) {
|
|
3851
5382
|
if (getCookie(c, githubStateCookie) !== state) {
|
|
3852
|
-
throw new
|
|
5383
|
+
throw new HTTPException13(400, { message: "invalid or expired GitHub installation browser state" });
|
|
3853
5384
|
}
|
|
3854
5385
|
}
|
|
3855
5386
|
function isSecureRequest(c, deps) {
|
|
@@ -3916,8 +5447,8 @@ import {
|
|
|
3916
5447
|
updatePackInstallationStatus
|
|
3917
5448
|
} from "@opengeni/db";
|
|
3918
5449
|
import { getDocumentBase as getDocumentBase2 } from "@opengeni/documents";
|
|
3919
|
-
import { HTTPException as
|
|
3920
|
-
import { requireAccessGrant as
|
|
5450
|
+
import { HTTPException as HTTPException14 } from "hono/http-exception";
|
|
5451
|
+
import { requireAccessGrant as requireAccessGrant11 } from "@opengeni/core";
|
|
3921
5452
|
import { requireLimit as requireLimit5 } from "@opengeni/core";
|
|
3922
5453
|
import { validateEnvironmentAttachment } from "@opengeni/core";
|
|
3923
5454
|
import {
|
|
@@ -3936,7 +5467,7 @@ function registerPackRoutes(app, deps) {
|
|
|
3936
5467
|
const { settings, db, objectStorage, workflowClient } = deps;
|
|
3937
5468
|
app.get("/v1/workspaces/:workspaceId/packs", async (c) => {
|
|
3938
5469
|
const workspaceId = c.req.param("workspaceId");
|
|
3939
|
-
await
|
|
5470
|
+
await requireAccessGrant11(c, deps, workspaceId, "workspace:read");
|
|
3940
5471
|
return c.json({
|
|
3941
5472
|
packs: await listWorkspaceCapabilityPacks(db, workspaceId),
|
|
3942
5473
|
installations: await listPackInstallations(db, workspaceId)
|
|
@@ -3944,10 +5475,10 @@ function registerPackRoutes(app, deps) {
|
|
|
3944
5475
|
});
|
|
3945
5476
|
app.post("/v1/workspaces/:workspaceId/packs", async (c) => {
|
|
3946
5477
|
const workspaceId = c.req.param("workspaceId");
|
|
3947
|
-
const grant = await
|
|
5478
|
+
const grant = await requireAccessGrant11(c, deps, workspaceId, "workspace:admin");
|
|
3948
5479
|
const manifest = RegisterCapabilityPackRequest.parse(await c.req.json());
|
|
3949
5480
|
if (isBuiltInCapabilityPack(manifest.id)) {
|
|
3950
|
-
throw new
|
|
5481
|
+
throw new HTTPException14(409, { message: `pack id ${manifest.id} is a built-in pack and cannot be replaced` });
|
|
3951
5482
|
}
|
|
3952
5483
|
const { pack, created } = await registerWorkspacePack(db, {
|
|
3953
5484
|
accountId: grant.accountId,
|
|
@@ -3958,13 +5489,13 @@ function registerPackRoutes(app, deps) {
|
|
|
3958
5489
|
});
|
|
3959
5490
|
app.delete("/v1/workspaces/:workspaceId/packs/:packId", async (c) => {
|
|
3960
5491
|
const workspaceId = c.req.param("workspaceId");
|
|
3961
|
-
await
|
|
5492
|
+
await requireAccessGrant11(c, deps, workspaceId, "workspace:admin");
|
|
3962
5493
|
const packId = c.req.param("packId");
|
|
3963
5494
|
if (isBuiltInCapabilityPack(packId)) {
|
|
3964
|
-
throw new
|
|
5495
|
+
throw new HTTPException14(409, { message: "built-in packs cannot be unregistered" });
|
|
3965
5496
|
}
|
|
3966
5497
|
if (!await getWorkspacePack(db, workspaceId, packId)) {
|
|
3967
|
-
throw new
|
|
5498
|
+
throw new HTTPException14(404, { message: "pack not found" });
|
|
3968
5499
|
}
|
|
3969
5500
|
const installation = await getPackInstallation(db, workspaceId, packId);
|
|
3970
5501
|
if (installation && installation.status === "active") {
|
|
@@ -3979,12 +5510,12 @@ function registerPackRoutes(app, deps) {
|
|
|
3979
5510
|
});
|
|
3980
5511
|
app.get("/v1/workspaces/:workspaceId/packs/installations", async (c) => {
|
|
3981
5512
|
const workspaceId = c.req.param("workspaceId");
|
|
3982
|
-
await
|
|
5513
|
+
await requireAccessGrant11(c, deps, workspaceId, "workspace:read");
|
|
3983
5514
|
return c.json(await listPackInstallations(db, workspaceId));
|
|
3984
5515
|
});
|
|
3985
5516
|
app.get("/v1/workspaces/:workspaceId/packs/:packId", async (c) => {
|
|
3986
5517
|
const workspaceId = c.req.param("workspaceId");
|
|
3987
|
-
await
|
|
5518
|
+
await requireAccessGrant11(c, deps, workspaceId, "workspace:read");
|
|
3988
5519
|
const pack = await requirePack(db, workspaceId, c.req.param("packId"));
|
|
3989
5520
|
return c.json({
|
|
3990
5521
|
pack,
|
|
@@ -3993,7 +5524,7 @@ function registerPackRoutes(app, deps) {
|
|
|
3993
5524
|
});
|
|
3994
5525
|
app.post("/v1/workspaces/:workspaceId/packs/:packId/enable", async (c) => {
|
|
3995
5526
|
const workspaceId = c.req.param("workspaceId");
|
|
3996
|
-
const grant = await
|
|
5527
|
+
const grant = await requireAccessGrant11(c, deps, workspaceId, "workspace:admin");
|
|
3997
5528
|
const pack = await requirePack(db, workspaceId, c.req.param("packId"));
|
|
3998
5529
|
await assertPackSandboxImageCompatible(db, workspaceId, pack);
|
|
3999
5530
|
const existing = await getPackInstallation(db, workspaceId, pack.id);
|
|
@@ -4001,13 +5532,13 @@ function registerPackRoutes(app, deps) {
|
|
|
4001
5532
|
const storedEnvironmentId = typeof existing?.metadata.environmentId === "string" ? existing.metadata.environmentId : void 0;
|
|
4002
5533
|
const environmentId = payload.environmentId ?? storedEnvironmentId;
|
|
4003
5534
|
if (pack.environment?.required && !environmentId) {
|
|
4004
|
-
throw new
|
|
5535
|
+
throw new HTTPException14(422, { message: "this pack requires an environment attachment; pass environmentId" });
|
|
4005
5536
|
}
|
|
4006
5537
|
if (environmentId) {
|
|
4007
5538
|
const environment = await validateEnvironmentAttachment({ settings, db }, grant, workspaceId, environmentId, { preauthorized: !payload.environmentId });
|
|
4008
5539
|
const missing = (pack.environment?.requiredVariables ?? []).filter((name) => !environment.variables.some((variable) => variable.name === name));
|
|
4009
5540
|
if (missing.length > 0) {
|
|
4010
|
-
throw new
|
|
5541
|
+
throw new HTTPException14(422, { message: `environment is missing required variable(s): ${missing.join(", ")}` });
|
|
4011
5542
|
}
|
|
4012
5543
|
}
|
|
4013
5544
|
const installation = await enablePackInstallation(db, {
|
|
@@ -4024,17 +5555,17 @@ function registerPackRoutes(app, deps) {
|
|
|
4024
5555
|
});
|
|
4025
5556
|
app.post("/v1/workspaces/:workspaceId/packs/marketing-social-daily-analysis/scheduled-tasks", async (c) => {
|
|
4026
5557
|
const workspaceId = c.req.param("workspaceId");
|
|
4027
|
-
const grant = await
|
|
5558
|
+
const grant = await requireAccessGrant11(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4028
5559
|
const pack = await requirePack(db, workspaceId, MARKETING_SOCIAL_PACK_ID);
|
|
4029
5560
|
const installation = await getPackInstallation(db, workspaceId, pack.id);
|
|
4030
5561
|
if (installation?.status !== "active") {
|
|
4031
|
-
throw new
|
|
5562
|
+
throw new HTTPException14(409, { message: "enable the marketing social pack before creating its scheduled tasks" });
|
|
4032
5563
|
}
|
|
4033
5564
|
const payload = MarketingDailyAnalysisTaskRequest.parse(await c.req.json());
|
|
4034
5565
|
await requireLimit5(deps, { accountId: grant.accountId, workspaceId, action: "schedule:create", quantity: 1 });
|
|
4035
5566
|
const connections = await resolveSocialConnections(db, workspaceId, payload.connectionIds);
|
|
4036
5567
|
if (connections.length === 0) {
|
|
4037
|
-
throw new
|
|
5568
|
+
throw new HTTPException14(422, { message: "at least one connected social account is required" });
|
|
4038
5569
|
}
|
|
4039
5570
|
await validateDocumentBaseIds(db, workspaceId, payload.documentBaseIds);
|
|
4040
5571
|
const agentConfig = buildMarketingDailyAnalysisAgentConfig({
|
|
@@ -4078,7 +5609,7 @@ function registerPackRoutes(app, deps) {
|
|
|
4078
5609
|
async function requirePack(db, workspaceId, packId) {
|
|
4079
5610
|
const pack = await resolveCapabilityPack(db, workspaceId, packId);
|
|
4080
5611
|
if (!pack) {
|
|
4081
|
-
throw new
|
|
5612
|
+
throw new HTTPException14(404, { message: "pack not found" });
|
|
4082
5613
|
}
|
|
4083
5614
|
return pack;
|
|
4084
5615
|
}
|
|
@@ -4087,13 +5618,13 @@ async function resolveSocialConnections(db, workspaceId, connectionIds) {
|
|
|
4087
5618
|
const connections = ids.length > 0 ? await Promise.all(ids.map(async (id) => {
|
|
4088
5619
|
const connection = await getSocialConnection(db, workspaceId, id);
|
|
4089
5620
|
if (!connection) {
|
|
4090
|
-
throw new
|
|
5621
|
+
throw new HTTPException14(422, { message: `unknown social connection: ${id}` });
|
|
4091
5622
|
}
|
|
4092
5623
|
return connection;
|
|
4093
5624
|
})) : (await listSocialConnections2(db, workspaceId, 500)).filter((connection) => connection.status === "connected");
|
|
4094
5625
|
const inactive = connections.find((connection) => connection.status !== "connected");
|
|
4095
5626
|
if (inactive) {
|
|
4096
|
-
throw new
|
|
5627
|
+
throw new HTTPException14(422, { message: `social connection ${inactive.id} is ${inactive.status}` });
|
|
4097
5628
|
}
|
|
4098
5629
|
return connections;
|
|
4099
5630
|
}
|
|
@@ -4101,7 +5632,7 @@ async function validateDocumentBaseIds(db, workspaceId, documentBaseIds) {
|
|
|
4101
5632
|
for (const baseId of [...new Set(documentBaseIds)]) {
|
|
4102
5633
|
const base = await getDocumentBase2(db, workspaceId, baseId);
|
|
4103
5634
|
if (!base) {
|
|
4104
|
-
throw new
|
|
5635
|
+
throw new HTTPException14(422, { message: `unknown document base: ${baseId}` });
|
|
4105
5636
|
}
|
|
4106
5637
|
}
|
|
4107
5638
|
}
|
|
@@ -4114,7 +5645,7 @@ import {
|
|
|
4114
5645
|
listScheduledTasks as listScheduledTasks2,
|
|
4115
5646
|
updateScheduledTask as updateScheduledTask2
|
|
4116
5647
|
} from "@opengeni/db";
|
|
4117
|
-
import { requireAccessGrant as
|
|
5648
|
+
import { requireAccessGrant as requireAccessGrant12 } from "@opengeni/core";
|
|
4118
5649
|
import { recordWorkspaceUsage as recordWorkspaceUsage4, requireLimit as requireLimit6 } from "@opengeni/core";
|
|
4119
5650
|
import {
|
|
4120
5651
|
createValidatedScheduledTask as createValidatedScheduledTask3,
|
|
@@ -4131,7 +5662,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4131
5662
|
const { settings, db, workflowClient, objectStorage } = deps;
|
|
4132
5663
|
app.post("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
|
|
4133
5664
|
const workspaceId = c.req.param("workspaceId");
|
|
4134
|
-
const grant = await
|
|
5665
|
+
const grant = await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4135
5666
|
const rawPayload = await c.req.json();
|
|
4136
5667
|
const payload = CreateScheduledTaskRequest2.parse(rawPayload);
|
|
4137
5668
|
await requireLimit6(deps, { accountId: grant.accountId, workspaceId, action: "schedule:create", quantity: 1 });
|
|
@@ -4141,17 +5672,17 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4141
5672
|
});
|
|
4142
5673
|
app.get("/v1/workspaces/:workspaceId/scheduled-tasks", async (c) => {
|
|
4143
5674
|
const workspaceId = c.req.param("workspaceId");
|
|
4144
|
-
await
|
|
5675
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:run");
|
|
4145
5676
|
return c.json(await listScheduledTasks2(db, workspaceId, boundedLimit(c.req.query("limit"))));
|
|
4146
5677
|
});
|
|
4147
5678
|
app.get("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
|
|
4148
5679
|
const workspaceId = c.req.param("workspaceId");
|
|
4149
|
-
await
|
|
5680
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:run");
|
|
4150
5681
|
return c.json(await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId")));
|
|
4151
5682
|
});
|
|
4152
5683
|
app.patch("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
|
|
4153
5684
|
const workspaceId = c.req.param("workspaceId");
|
|
4154
|
-
const grant = await
|
|
5685
|
+
const grant = await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4155
5686
|
const taskId = c.req.param("taskId");
|
|
4156
5687
|
const existing = await requireScheduledTaskForApi(db, workspaceId, taskId);
|
|
4157
5688
|
const rawPayload = await c.req.json();
|
|
@@ -4163,7 +5694,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4163
5694
|
});
|
|
4164
5695
|
app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/pause", async (c) => {
|
|
4165
5696
|
const workspaceId = c.req.param("workspaceId");
|
|
4166
|
-
await
|
|
5697
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4167
5698
|
const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
4168
5699
|
const task = await updateScheduledTask2(db, workspaceId, existing.id, { status: "paused" });
|
|
4169
5700
|
await syncUpdatedScheduledTask2({ db, workflowClient, previous: existing, task });
|
|
@@ -4171,7 +5702,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4171
5702
|
});
|
|
4172
5703
|
app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/resume", async (c) => {
|
|
4173
5704
|
const workspaceId = c.req.param("workspaceId");
|
|
4174
|
-
await
|
|
5705
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4175
5706
|
const existing = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
4176
5707
|
const task = await updateScheduledTask2(db, workspaceId, existing.id, { status: "active" });
|
|
4177
5708
|
await syncUpdatedScheduledTask2({ db, workflowClient, previous: existing, task });
|
|
@@ -4179,7 +5710,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4179
5710
|
});
|
|
4180
5711
|
app.post("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/trigger", async (c) => {
|
|
4181
5712
|
const workspaceId = c.req.param("workspaceId");
|
|
4182
|
-
const grant = await
|
|
5713
|
+
const grant = await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:run");
|
|
4183
5714
|
const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
4184
5715
|
await requireLimit6(deps, { accountId: grant.accountId, workspaceId, action: "agent_run:create", quantity: 1, model: task.agentConfig.model ?? deps.settings.openaiModel });
|
|
4185
5716
|
const body = await c.req.json().catch(() => ({}));
|
|
@@ -4203,7 +5734,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4203
5734
|
});
|
|
4204
5735
|
app.delete("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId", async (c) => {
|
|
4205
5736
|
const workspaceId = c.req.param("workspaceId");
|
|
4206
|
-
await
|
|
5737
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:manage");
|
|
4207
5738
|
const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
4208
5739
|
await workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId });
|
|
4209
5740
|
await deleteScheduledTask2(db, workspaceId, task.id);
|
|
@@ -4211,7 +5742,7 @@ function registerScheduledTaskRoutes(app, deps) {
|
|
|
4211
5742
|
});
|
|
4212
5743
|
app.get("/v1/workspaces/:workspaceId/scheduled-tasks/:taskId/runs", async (c) => {
|
|
4213
5744
|
const workspaceId = c.req.param("workspaceId");
|
|
4214
|
-
await
|
|
5745
|
+
await requireAccessGrant12(c, deps, workspaceId, "scheduled_tasks:run");
|
|
4215
5746
|
const task = await requireScheduledTaskForApi(db, workspaceId, c.req.param("taskId"));
|
|
4216
5747
|
return c.json(await listScheduledTaskRuns2(db, workspaceId, task.id, boundedLimit(c.req.query("limit"))));
|
|
4217
5748
|
});
|
|
@@ -4263,14 +5794,14 @@ import {
|
|
|
4263
5794
|
recordStreamAcknowledgment,
|
|
4264
5795
|
reorderQueuedSessionTurns,
|
|
4265
5796
|
requestSessionCompaction,
|
|
4266
|
-
requireSession as
|
|
5797
|
+
requireSession as requireSession3,
|
|
4267
5798
|
setSessionCodexPin,
|
|
4268
5799
|
revokeViewer,
|
|
4269
5800
|
setSessionGoalStatus as setSessionGoalStatus2,
|
|
4270
5801
|
updatePtySessionActivity,
|
|
4271
5802
|
updateQueuedSessionTurn
|
|
4272
5803
|
} from "@opengeni/db";
|
|
4273
|
-
import { appendAndPublishEvents as
|
|
5804
|
+
import { appendAndPublishEvents as appendAndPublishEvents5, coalesceSessionEventDeltas } from "@opengeni/events";
|
|
4274
5805
|
|
|
4275
5806
|
// src/sandbox/channel-a.ts
|
|
4276
5807
|
import { applyGitAuthPointerEnvironment, hasGitHubRepositorySelection, stableSandboxEnvironmentForRun } from "@opengeni/config";
|
|
@@ -4284,8 +5815,8 @@ import {
|
|
|
4284
5815
|
readLease as readLease2,
|
|
4285
5816
|
releaseLeaseHolder
|
|
4286
5817
|
} from "@opengeni/db";
|
|
4287
|
-
import { appendAndPublishEvents as
|
|
4288
|
-
import { HTTPException as
|
|
5818
|
+
import { appendAndPublishEvents as appendAndPublishEvents3 } from "@opengeni/events";
|
|
5819
|
+
import { HTTPException as HTTPException15 } from "hono/http-exception";
|
|
4289
5820
|
import {
|
|
4290
5821
|
establishSandboxSessionFromEnvelope,
|
|
4291
5822
|
serializeEstablishedSandboxEnvelope,
|
|
@@ -4300,7 +5831,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4300
5831
|
const { db, settings, bus } = services;
|
|
4301
5832
|
const { accountId, workspaceId, session, subjectId } = ctx;
|
|
4302
5833
|
if (session.sandboxBackend === "none") {
|
|
4303
|
-
throw new
|
|
5834
|
+
throw new HTTPException15(409, { message: "sandbox not available" });
|
|
4304
5835
|
}
|
|
4305
5836
|
const sandboxGroupId = session.sandboxGroupId;
|
|
4306
5837
|
const viewerId = crypto.randomUUID();
|
|
@@ -4328,7 +5859,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4328
5859
|
});
|
|
4329
5860
|
if (acquired.role === "fenced") {
|
|
4330
5861
|
await release();
|
|
4331
|
-
throw new
|
|
5862
|
+
throw new HTTPException15(409, { message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); retry` });
|
|
4332
5863
|
}
|
|
4333
5864
|
let established;
|
|
4334
5865
|
let leaseSnapshot = acquired.lease;
|
|
@@ -4336,7 +5867,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4336
5867
|
const envelope = await getSandboxSessionEnvelope(db, workspaceId, session.id);
|
|
4337
5868
|
const workspaceEnvironment = await loadWorkspaceEnvironmentForRun(db, settings, workspaceId, session.environmentId);
|
|
4338
5869
|
const settingsForSession = session.sandboxBackend !== settings.sandboxBackend ? { ...settings, sandboxBackend: session.sandboxBackend } : settings;
|
|
4339
|
-
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
|
|
5870
|
+
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
|
|
4340
5871
|
if (hasGitHubRepositorySelection(session.resources)) {
|
|
4341
5872
|
applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(settings));
|
|
4342
5873
|
}
|
|
@@ -4351,7 +5882,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4351
5882
|
});
|
|
4352
5883
|
} catch (error) {
|
|
4353
5884
|
await failWarmingToCold(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
|
|
4354
|
-
throw new
|
|
5885
|
+
throw new HTTPException15(409, { message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})` });
|
|
4355
5886
|
}
|
|
4356
5887
|
const resumeEnvelope = await serializeEstablishedSandboxEnvelope(established) ?? envelope ?? null;
|
|
4357
5888
|
const committed = await commitWarmingToWarm(db, {
|
|
@@ -4366,7 +5897,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4366
5897
|
leaseTtlMs
|
|
4367
5898
|
});
|
|
4368
5899
|
if (!committed.committed || !committed.lease) {
|
|
4369
|
-
throw new
|
|
5900
|
+
throw new HTTPException15(409, { message: `sandbox lease superseded (epoch ${expectedEpoch}); retry` });
|
|
4370
5901
|
}
|
|
4371
5902
|
leaseSnapshot = committed.lease;
|
|
4372
5903
|
} else {
|
|
@@ -4380,7 +5911,7 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4380
5911
|
});
|
|
4381
5912
|
}
|
|
4382
5913
|
const emit = async (events) => {
|
|
4383
|
-
await
|
|
5914
|
+
await appendAndPublishEvents3(
|
|
4384
5915
|
db,
|
|
4385
5916
|
bus,
|
|
4386
5917
|
workspaceId,
|
|
@@ -4405,11 +5936,11 @@ async function withChannelA(services, ctx, fn) {
|
|
|
4405
5936
|
}
|
|
4406
5937
|
}
|
|
4407
5938
|
function mapChannelAError(error) {
|
|
4408
|
-
if (error instanceof
|
|
4409
|
-
if (error instanceof ChannelAValidationError) return new
|
|
4410
|
-
if (error instanceof ChannelANotFoundError) return new
|
|
4411
|
-
if (error instanceof ChannelAConflictError) return new
|
|
4412
|
-
if (error instanceof ChannelAUnsupportedError) return new
|
|
5939
|
+
if (error instanceof HTTPException15) return error;
|
|
5940
|
+
if (error instanceof ChannelAValidationError) return new HTTPException15(400, { message: error.message });
|
|
5941
|
+
if (error instanceof ChannelANotFoundError) return new HTTPException15(404, { message: error.message });
|
|
5942
|
+
if (error instanceof ChannelAConflictError) return new HTTPException15(409, { message: error.message });
|
|
5943
|
+
if (error instanceof ChannelAUnsupportedError) return new HTTPException15(409, { message: error.message });
|
|
4413
5944
|
return error;
|
|
4414
5945
|
}
|
|
4415
5946
|
async function dropEstablishedHandle(established) {
|
|
@@ -4418,11 +5949,11 @@ async function dropEstablishedHandle(established) {
|
|
|
4418
5949
|
|
|
4419
5950
|
// src/routes/sessions.ts
|
|
4420
5951
|
import { negotiateCapabilities } from "@opengeni/runtime/sandbox";
|
|
4421
|
-
import { HTTPException as
|
|
4422
|
-
import { requireAccessGrant as
|
|
5952
|
+
import { HTTPException as HTTPException17 } from "hono/http-exception";
|
|
5953
|
+
import { requireAccessGrant as requireAccessGrant13 } from "@opengeni/core";
|
|
4423
5954
|
|
|
4424
5955
|
// src/sandbox/viewer.ts
|
|
4425
|
-
import { createHash } from "crypto";
|
|
5956
|
+
import { createHash as createHash2 } from "crypto";
|
|
4426
5957
|
import { applyGitAuthPointerEnvironment as applyGitAuthPointerEnvironment2, hasGitHubRepositorySelection as hasGitHubRepositorySelection2, resolveStreamTokenSecret, stableSandboxEnvironmentForRun as stableSandboxEnvironmentForRun2 } from "@opengeni/config";
|
|
4427
5958
|
import { githubAppBotIdentity as githubAppBotIdentity2 } from "@opengeni/github";
|
|
4428
5959
|
import {
|
|
@@ -4439,8 +5970,8 @@ import {
|
|
|
4439
5970
|
releaseLeaseHolder as releaseLeaseHolder2,
|
|
4440
5971
|
SandboxLeaseSupersededError as SandboxLeaseSupersededError2
|
|
4441
5972
|
} from "@opengeni/db";
|
|
4442
|
-
import { appendAndPublishEvents as
|
|
4443
|
-
import { HTTPException as
|
|
5973
|
+
import { appendAndPublishEvents as appendAndPublishEvents4 } from "@opengeni/events";
|
|
5974
|
+
import { HTTPException as HTTPException16 } from "hono/http-exception";
|
|
4444
5975
|
import {
|
|
4445
5976
|
DESKTOP_STREAM_PORT,
|
|
4446
5977
|
ensureDisplayStack,
|
|
@@ -4467,7 +5998,7 @@ async function sessionAttachEnvironment(services, workspaceId, session) {
|
|
|
4467
5998
|
session.environmentId
|
|
4468
5999
|
);
|
|
4469
6000
|
const settingsForSession = session.sandboxBackend !== services.settings.sandboxBackend ? { ...services.settings, sandboxBackend: session.sandboxBackend } : services.settings;
|
|
4470
|
-
const environment = stableSandboxEnvironmentForRun2(settingsForSession, workspaceEnvironment?.values ?? {});
|
|
6001
|
+
const environment = stableSandboxEnvironmentForRun2(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
|
|
4471
6002
|
if (hasGitHubRepositorySelection2(session.resources)) {
|
|
4472
6003
|
applyGitAuthPointerEnvironment2(environment, githubAppBotIdentity2(services.settings));
|
|
4473
6004
|
}
|
|
@@ -4502,7 +6033,7 @@ async function attachViewer(services, input) {
|
|
|
4502
6033
|
});
|
|
4503
6034
|
if (acquired.role === "fenced") {
|
|
4504
6035
|
await release();
|
|
4505
|
-
throw new
|
|
6036
|
+
throw new HTTPException16(409, { message: `sandbox lease superseded (epoch ${acquired.lease.leaseEpoch}); re-read capabilities and re-attach` });
|
|
4506
6037
|
}
|
|
4507
6038
|
if (acquired.role === "spawner") {
|
|
4508
6039
|
const expectedEpoch = acquired.lease.leaseEpoch;
|
|
@@ -4543,12 +6074,12 @@ async function attachViewer(services, input) {
|
|
|
4543
6074
|
};
|
|
4544
6075
|
} catch (error) {
|
|
4545
6076
|
if (error instanceof SandboxLeaseSupersededError2) {
|
|
4546
|
-
throw new
|
|
6077
|
+
throw new HTTPException16(409, { message: `sandbox lease superseded (epoch ${error.leaseEpoch}); re-read capabilities and re-attach` });
|
|
4547
6078
|
}
|
|
4548
6079
|
await failWarmingToCold2(db, { accountId, workspaceId, sandboxGroupId, expectedEpoch });
|
|
4549
6080
|
await release();
|
|
4550
|
-
if (error instanceof
|
|
4551
|
-
throw new
|
|
6081
|
+
if (error instanceof HTTPException16) throw error;
|
|
6082
|
+
throw new HTTPException16(409, { message: `sandbox not available (${error instanceof Error ? error.message : "spawn failed"})` });
|
|
4552
6083
|
} finally {
|
|
4553
6084
|
await dropEstablishedHandle2(established);
|
|
4554
6085
|
}
|
|
@@ -4697,7 +6228,7 @@ async function mintDesktopStream(services, input) {
|
|
|
4697
6228
|
viewerId
|
|
4698
6229
|
};
|
|
4699
6230
|
try {
|
|
4700
|
-
await
|
|
6231
|
+
await appendAndPublishEvents4(db, bus, workspaceId, session.id, [
|
|
4701
6232
|
{ type: "stream.url.rotated", payload }
|
|
4702
6233
|
]);
|
|
4703
6234
|
} catch {
|
|
@@ -4892,7 +6423,7 @@ function viewerIdAsUuid(rawViewerId) {
|
|
|
4892
6423
|
if (UUID_RE.test(rawViewerId)) {
|
|
4893
6424
|
return rawViewerId;
|
|
4894
6425
|
}
|
|
4895
|
-
const hex =
|
|
6426
|
+
const hex = createHash2("sha256").update(`opengeni:stream-viewer:${rawViewerId}`).digest("hex");
|
|
4896
6427
|
const b = hex.slice(0, 32).split("");
|
|
4897
6428
|
b[12] = "5";
|
|
4898
6429
|
const variantNibble = parseInt(b[16], 16) & 3 | 8;
|
|
@@ -4902,7 +6433,7 @@ function viewerIdAsUuid(rawViewerId) {
|
|
|
4902
6433
|
}
|
|
4903
6434
|
|
|
4904
6435
|
// src/routes/sessions.ts
|
|
4905
|
-
import { settingsWithEnabledCapabilityMcpServers, settingsWithSessionMcpServerMetadata } from "@opengeni/core";
|
|
6436
|
+
import { settingsWithEnabledCapabilityMcpServers as settingsWithEnabledCapabilityMcpServers2, settingsWithSessionMcpServerMetadata } from "@opengeni/core";
|
|
4906
6437
|
import {
|
|
4907
6438
|
normalizeResources,
|
|
4908
6439
|
validateFileResources,
|
|
@@ -5001,76 +6532,76 @@ function registerSessionRoutes(app, deps) {
|
|
|
5001
6532
|
const { settings, db, bus, workflowClient, objectStorage } = deps;
|
|
5002
6533
|
app.post("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
5003
6534
|
const workspaceId = c.req.param("workspaceId");
|
|
5004
|
-
const grant = await
|
|
6535
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:create");
|
|
5005
6536
|
const session = await createSessionForRequest2(deps, grant, workspaceId, await c.req.json());
|
|
5006
6537
|
return c.json(session, 202);
|
|
5007
6538
|
});
|
|
5008
6539
|
app.get("/v1/workspaces/:workspaceId/sessions", async (c) => {
|
|
5009
6540
|
const workspaceId = c.req.param("workspaceId");
|
|
5010
|
-
await
|
|
6541
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5011
6542
|
return c.json(await listSessions2(db, workspaceId, boundedLimit(c.req.query("limit"))));
|
|
5012
6543
|
});
|
|
5013
6544
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
5014
6545
|
const workspaceId = c.req.param("workspaceId");
|
|
5015
|
-
await
|
|
6546
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5016
6547
|
const session = await getSession4(db, workspaceId, c.req.param("sessionId"));
|
|
5017
6548
|
if (!session) {
|
|
5018
|
-
throw new
|
|
6549
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5019
6550
|
}
|
|
5020
6551
|
return c.json(session);
|
|
5021
6552
|
});
|
|
5022
6553
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-account", async (c) => {
|
|
5023
6554
|
const workspaceId = c.req.param("workspaceId");
|
|
5024
|
-
await
|
|
6555
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5025
6556
|
const sessionId = c.req.param("sessionId");
|
|
5026
6557
|
const body = await c.req.json();
|
|
5027
6558
|
const target = typeof body.target === "string" ? body.target : "";
|
|
5028
6559
|
if (!target) {
|
|
5029
|
-
throw new
|
|
6560
|
+
throw new HTTPException17(400, { message: 'target is required ("auto" or an account id)' });
|
|
5030
6561
|
}
|
|
5031
6562
|
const pinned = target === "auto" ? null : target;
|
|
5032
6563
|
const ok = await setSessionCodexPin(db, workspaceId, sessionId, pinned);
|
|
5033
6564
|
if (!ok) {
|
|
5034
|
-
throw new
|
|
6565
|
+
throw new HTTPException17(404, { message: "session or codex account not found" });
|
|
5035
6566
|
}
|
|
5036
6567
|
return c.json({ pinned: target === "auto" ? "auto" : target });
|
|
5037
6568
|
});
|
|
5038
6569
|
app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId", async (c) => {
|
|
5039
6570
|
const workspaceId = c.req.param("workspaceId");
|
|
5040
|
-
await
|
|
6571
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5041
6572
|
const sessionId = c.req.param("sessionId");
|
|
5042
6573
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5043
6574
|
const payload = UpdateSessionRequest.parse(await c.req.json());
|
|
5044
6575
|
await updateSessionTitle2({ db, bus }, workspaceId, sessionId, payload.title, "user");
|
|
5045
6576
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5046
6577
|
if (!session) {
|
|
5047
|
-
throw new
|
|
6578
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5048
6579
|
}
|
|
5049
6580
|
return c.json(session);
|
|
5050
6581
|
});
|
|
5051
6582
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
5052
6583
|
const workspaceId = c.req.param("workspaceId");
|
|
5053
|
-
await
|
|
6584
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5054
6585
|
const sessionId = c.req.param("sessionId");
|
|
5055
6586
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5056
6587
|
const goal = await getSessionGoal2(db, workspaceId, sessionId);
|
|
5057
6588
|
if (!goal) {
|
|
5058
|
-
throw new
|
|
6589
|
+
throw new HTTPException17(404, { message: "session goal not found" });
|
|
5059
6590
|
}
|
|
5060
6591
|
return c.json(goal);
|
|
5061
6592
|
});
|
|
5062
6593
|
app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/goal", async (c) => {
|
|
5063
6594
|
const workspaceId = c.req.param("workspaceId");
|
|
5064
|
-
const grant = await
|
|
6595
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5065
6596
|
const sessionId = c.req.param("sessionId");
|
|
5066
6597
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5067
6598
|
const payload = UpdateSessionGoalRequest.parse(await c.req.json());
|
|
5068
6599
|
const existing = await getSessionGoal2(db, workspaceId, sessionId);
|
|
5069
6600
|
if (!existing) {
|
|
5070
|
-
throw new
|
|
6601
|
+
throw new HTTPException17(404, { message: "session goal not found" });
|
|
5071
6602
|
}
|
|
5072
6603
|
if (existing.status === "completed") {
|
|
5073
|
-
throw new
|
|
6604
|
+
throw new HTTPException17(409, { message: "session goal is completed; set a new goal instead" });
|
|
5074
6605
|
}
|
|
5075
6606
|
if (payload.status === "paused") {
|
|
5076
6607
|
const { goal: goal2, changed: changed2 } = await setSessionGoalStatus2(db, workspaceId, sessionId, {
|
|
@@ -5079,7 +6610,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5079
6610
|
pausedReason: "api"
|
|
5080
6611
|
});
|
|
5081
6612
|
if (changed2) {
|
|
5082
|
-
await
|
|
6613
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5083
6614
|
type: "goal.paused",
|
|
5084
6615
|
payload: {
|
|
5085
6616
|
goalId: goal2.id,
|
|
@@ -5094,11 +6625,11 @@ function registerSessionRoutes(app, deps) {
|
|
|
5094
6625
|
return c.json(goal2);
|
|
5095
6626
|
}
|
|
5096
6627
|
if (existing.status !== "paused") {
|
|
5097
|
-
throw new
|
|
6628
|
+
throw new HTTPException17(409, { message: `session goal is ${existing.status}; only paused goals can be resumed` });
|
|
5098
6629
|
}
|
|
5099
6630
|
const { goal, changed } = await setSessionGoalStatus2(db, workspaceId, sessionId, { status: "active" });
|
|
5100
6631
|
if (changed) {
|
|
5101
|
-
await
|
|
6632
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5102
6633
|
type: "goal.resumed",
|
|
5103
6634
|
payload: {
|
|
5104
6635
|
goalId: goal.id,
|
|
@@ -5114,19 +6645,19 @@ function registerSessionRoutes(app, deps) {
|
|
|
5114
6645
|
});
|
|
5115
6646
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/clear", async (c) => {
|
|
5116
6647
|
const workspaceId = c.req.param("workspaceId");
|
|
5117
|
-
const grant = await
|
|
6648
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5118
6649
|
const sessionId = c.req.param("sessionId");
|
|
5119
6650
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5120
6651
|
const clearBody = ClearSessionContextRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
5121
6652
|
if (!clearBody.success) {
|
|
5122
|
-
throw new
|
|
6653
|
+
throw new HTTPException17(400, { message: "context clear requires an explicit { confirm: true }" });
|
|
5123
6654
|
}
|
|
5124
|
-
const session = await
|
|
6655
|
+
const session = await requireSession3(db, workspaceId, sessionId);
|
|
5125
6656
|
if (session.status === "queued" || session.status === "running" || session.status === "requires_action") {
|
|
5126
|
-
throw new
|
|
6657
|
+
throw new HTTPException17(409, { message: `session is ${session.status}; cannot clear context mid-turn \u2014 stop the turn first` });
|
|
5127
6658
|
}
|
|
5128
6659
|
const result = await clearSessionContext(db, { accountId: grant.accountId, workspaceId, sessionId });
|
|
5129
|
-
await
|
|
6660
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5130
6661
|
type: "session.context.cleared",
|
|
5131
6662
|
payload: {
|
|
5132
6663
|
clearedBy: "api",
|
|
@@ -5138,7 +6669,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5138
6669
|
});
|
|
5139
6670
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/context/compact", async (c) => {
|
|
5140
6671
|
const workspaceId = c.req.param("workspaceId");
|
|
5141
|
-
await
|
|
6672
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5142
6673
|
const sessionId = c.req.param("sessionId");
|
|
5143
6674
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5144
6675
|
CompactSessionContextRequest.parse(await c.req.json().catch(() => ({})) ?? {});
|
|
@@ -5154,7 +6685,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5154
6685
|
});
|
|
5155
6686
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
|
|
5156
6687
|
const workspaceId = c.req.param("workspaceId");
|
|
5157
|
-
await
|
|
6688
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5158
6689
|
const sessionId = c.req.param("sessionId");
|
|
5159
6690
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5160
6691
|
const after = eventSequence(c.req.query("after"), 0);
|
|
@@ -5170,7 +6701,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5170
6701
|
});
|
|
5171
6702
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/events/stream", async (c) => {
|
|
5172
6703
|
const workspaceId = c.req.param("workspaceId");
|
|
5173
|
-
await
|
|
6704
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5174
6705
|
const sessionId = c.req.param("sessionId");
|
|
5175
6706
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5176
6707
|
const after = Number(c.req.query("after") ?? c.req.header("Last-Event-ID") ?? 0);
|
|
@@ -5178,29 +6709,29 @@ function registerSessionRoutes(app, deps) {
|
|
|
5178
6709
|
});
|
|
5179
6710
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/turns", async (c) => {
|
|
5180
6711
|
const workspaceId = c.req.param("workspaceId");
|
|
5181
|
-
await
|
|
6712
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5182
6713
|
const sessionId = c.req.param("sessionId");
|
|
5183
6714
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5184
6715
|
return c.json(await listSessionTurns(db, workspaceId, sessionId, boundedLimit(c.req.query("limit"))));
|
|
5185
6716
|
});
|
|
5186
6717
|
app.patch("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
|
|
5187
6718
|
const workspaceId = c.req.param("workspaceId");
|
|
5188
|
-
await
|
|
6719
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5189
6720
|
const sessionId = c.req.param("sessionId");
|
|
5190
6721
|
const turnId = c.req.param("turnId");
|
|
5191
6722
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5192
6723
|
const existing = await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
|
|
5193
6724
|
const payload = UpdateSessionTurnRequest.parse(await c.req.json());
|
|
5194
6725
|
assertConfiguredModel(settings, payload.model);
|
|
5195
|
-
const session = await
|
|
6726
|
+
const session = await requireSession3(db, workspaceId, sessionId);
|
|
5196
6727
|
const runtimeSettings = settingsWithSessionMcpServerMetadata(
|
|
5197
|
-
await
|
|
6728
|
+
await settingsWithEnabledCapabilityMcpServers2(db, workspaceId, settings),
|
|
5198
6729
|
session.mcpServers
|
|
5199
6730
|
);
|
|
5200
6731
|
const resources = payload.resources !== void 0 ? normalizeResources(payload.resources) : existing.resources;
|
|
5201
6732
|
const tools = payload.tools !== void 0 ? validateToolRefs(payload.tools, runtimeSettings) : existing.tools;
|
|
5202
6733
|
if (resources.some((resource) => resource.kind === "file") && !objectStorage) {
|
|
5203
|
-
throw new
|
|
6734
|
+
throw new HTTPException17(503, { message: "object storage is not configured" });
|
|
5204
6735
|
}
|
|
5205
6736
|
await validateFileResources(db, workspaceId, resources);
|
|
5206
6737
|
await validateGitHubRepositorySelection(db, workspaceId, [...session.resources, ...resources]);
|
|
@@ -5213,7 +6744,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5213
6744
|
resources,
|
|
5214
6745
|
tools
|
|
5215
6746
|
});
|
|
5216
|
-
await
|
|
6747
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5217
6748
|
type: "turn.updated",
|
|
5218
6749
|
turnId: turn.id,
|
|
5219
6750
|
payload: { turnId: turn.id }
|
|
@@ -5222,12 +6753,12 @@ function registerSessionRoutes(app, deps) {
|
|
|
5222
6753
|
});
|
|
5223
6754
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/reorder", async (c) => {
|
|
5224
6755
|
const workspaceId = c.req.param("workspaceId");
|
|
5225
|
-
const grant = await
|
|
6756
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5226
6757
|
const sessionId = c.req.param("sessionId");
|
|
5227
6758
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5228
6759
|
const payload = ReorderSessionTurnsRequest.parse(await c.req.json());
|
|
5229
6760
|
const turns = await reorderQueuedSessionTurns(db, workspaceId, sessionId, payload.turnIds);
|
|
5230
|
-
await
|
|
6761
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5231
6762
|
type: "turn.updated",
|
|
5232
6763
|
payload: { reorderedTurnIds: payload.turnIds }
|
|
5233
6764
|
}]);
|
|
@@ -5236,13 +6767,13 @@ function registerSessionRoutes(app, deps) {
|
|
|
5236
6767
|
});
|
|
5237
6768
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/turns/:turnId", async (c) => {
|
|
5238
6769
|
const workspaceId = c.req.param("workspaceId");
|
|
5239
|
-
await
|
|
6770
|
+
await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5240
6771
|
const sessionId = c.req.param("sessionId");
|
|
5241
6772
|
const turnId = c.req.param("turnId");
|
|
5242
6773
|
await assertSessionExists(db, workspaceId, sessionId);
|
|
5243
6774
|
await requireQueuedTurnForApi(db, workspaceId, sessionId, turnId);
|
|
5244
6775
|
const turn = await cancelQueuedSessionTurn(db, workspaceId, turnId);
|
|
5245
|
-
await
|
|
6776
|
+
await appendAndPublishEvents5(db, bus, workspaceId, sessionId, [{
|
|
5246
6777
|
type: "turn.cancelled",
|
|
5247
6778
|
turnId: turn.id,
|
|
5248
6779
|
payload: { turnId: turn.id, triggerEventId: turn.triggerEventId }
|
|
@@ -5251,7 +6782,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5251
6782
|
});
|
|
5252
6783
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/events", async (c) => {
|
|
5253
6784
|
const workspaceId = c.req.param("workspaceId");
|
|
5254
|
-
const grant = await
|
|
6785
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:control");
|
|
5255
6786
|
const sessionId = c.req.param("sessionId");
|
|
5256
6787
|
const rawEvent = await c.req.json();
|
|
5257
6788
|
const event = ClientSessionEvent.parse(rawEvent);
|
|
@@ -5268,19 +6799,19 @@ function registerSessionRoutes(app, deps) {
|
|
|
5268
6799
|
});
|
|
5269
6800
|
return c.json(accepted2, 202);
|
|
5270
6801
|
}
|
|
5271
|
-
const session = await
|
|
6802
|
+
const session = await requireSession3(db, workspaceId, sessionId);
|
|
5272
6803
|
if (event.type === "user.approvalDecision" && session.status !== "requires_action") {
|
|
5273
|
-
throw new
|
|
6804
|
+
throw new HTTPException17(409, { message: `session is ${session.status}; no approval is pending` });
|
|
5274
6805
|
}
|
|
5275
6806
|
const eventsToAppend = [{
|
|
5276
6807
|
type: event.type,
|
|
5277
6808
|
payload: event.payload,
|
|
5278
6809
|
...event.clientEventId ? { clientEventId: event.clientEventId } : {}
|
|
5279
6810
|
}];
|
|
5280
|
-
const appended = await
|
|
6811
|
+
const appended = await appendAndPublishEvents5(db, bus, workspaceId, sessionId, eventsToAppend);
|
|
5281
6812
|
const accepted = appended[0];
|
|
5282
6813
|
if (!accepted) {
|
|
5283
|
-
throw new
|
|
6814
|
+
throw new HTTPException17(500, { message: "failed to append client event" });
|
|
5284
6815
|
}
|
|
5285
6816
|
const workflowId = workflowIdForSession2(sessionId);
|
|
5286
6817
|
if (event.type === "user.approvalDecision") {
|
|
@@ -5298,7 +6829,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5298
6829
|
});
|
|
5299
6830
|
function assertOwnershipEnabled() {
|
|
5300
6831
|
if (!settings.sandboxOwnershipEnabled) {
|
|
5301
|
-
throw new
|
|
6832
|
+
throw new HTTPException17(404, { message: "sandbox ownership is not enabled for this deployment" });
|
|
5302
6833
|
}
|
|
5303
6834
|
}
|
|
5304
6835
|
async function resolveSharedExposure(workspaceId, session) {
|
|
@@ -5308,12 +6839,12 @@ function registerSessionRoutes(app, deps) {
|
|
|
5308
6839
|
}
|
|
5309
6840
|
app.get("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities", async (c) => {
|
|
5310
6841
|
const workspaceId = c.req.param("workspaceId");
|
|
5311
|
-
const grant = await
|
|
6842
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "sessions:read");
|
|
5312
6843
|
assertOwnershipEnabled();
|
|
5313
6844
|
const sessionId = c.req.param("sessionId");
|
|
5314
6845
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5315
6846
|
if (!session) {
|
|
5316
|
-
throw new
|
|
6847
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5317
6848
|
}
|
|
5318
6849
|
const lease = await readGroupLease({ db, settings }, { workspaceId, sandboxGroupId: session.sandboxGroupId });
|
|
5319
6850
|
const { shared, sharedSessionIds } = await resolveSharedExposure(workspaceId, session);
|
|
@@ -5403,16 +6934,16 @@ function registerSessionRoutes(app, deps) {
|
|
|
5403
6934
|
});
|
|
5404
6935
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/stream-capabilities/acknowledge", async (c) => {
|
|
5405
6936
|
const workspaceId = c.req.param("workspaceId");
|
|
5406
|
-
const grant = await
|
|
6937
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "stream:acknowledge");
|
|
5407
6938
|
assertOwnershipEnabled();
|
|
5408
6939
|
const sessionId = c.req.param("sessionId");
|
|
5409
6940
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5410
6941
|
if (!session) {
|
|
5411
|
-
throw new
|
|
6942
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5412
6943
|
}
|
|
5413
6944
|
const parsed = AcknowledgeStreamRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
5414
6945
|
if (!parsed.success) {
|
|
5415
|
-
throw new
|
|
6946
|
+
throw new HTTPException17(400, { message: "invalid stream acknowledgment request" });
|
|
5416
6947
|
}
|
|
5417
6948
|
const recorded = await recordStreamAcknowledgment(db, {
|
|
5418
6949
|
accountId: grant.accountId,
|
|
@@ -5426,26 +6957,26 @@ function registerSessionRoutes(app, deps) {
|
|
|
5426
6957
|
});
|
|
5427
6958
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers", async (c) => {
|
|
5428
6959
|
const workspaceId = c.req.param("workspaceId");
|
|
5429
|
-
const grant = await
|
|
6960
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "stream:view");
|
|
5430
6961
|
assertOwnershipEnabled();
|
|
5431
6962
|
const sessionId = c.req.param("sessionId");
|
|
5432
6963
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5433
6964
|
if (!session) {
|
|
5434
|
-
throw new
|
|
6965
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5435
6966
|
}
|
|
5436
6967
|
const parsed = AttachViewerRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
5437
6968
|
if (!parsed.success) {
|
|
5438
|
-
throw new
|
|
6969
|
+
throw new HTTPException17(400, { message: "invalid viewer attach request" });
|
|
5439
6970
|
}
|
|
5440
6971
|
const wantDesktop = parsed.data.desktop ?? false;
|
|
5441
6972
|
const { shared } = await resolveSharedExposure(workspaceId, session);
|
|
5442
6973
|
if (wantDesktop) {
|
|
5443
6974
|
const ack = await getStreamAcknowledgment(db, { workspaceId, sandboxGroupId: session.sandboxGroupId, subjectId: grant.subjectId });
|
|
5444
6975
|
if (!ack?.acknowledgedUnredacted) {
|
|
5445
|
-
throw new
|
|
6976
|
+
throw new HTTPException17(409, { message: "stream_acknowledgment_required" });
|
|
5446
6977
|
}
|
|
5447
6978
|
if (shared && !ack.acknowledgedShared) {
|
|
5448
|
-
throw new
|
|
6979
|
+
throw new HTTPException17(409, { message: "shared_acknowledgment_required" });
|
|
5449
6980
|
}
|
|
5450
6981
|
}
|
|
5451
6982
|
const activeSandbox = session.activeSandboxId ? await getSandbox2(db, workspaceId, session.activeSandboxId) : null;
|
|
@@ -5539,16 +7070,16 @@ function registerSessionRoutes(app, deps) {
|
|
|
5539
7070
|
});
|
|
5540
7071
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/heartbeat", async (c) => {
|
|
5541
7072
|
const workspaceId = c.req.param("workspaceId");
|
|
5542
|
-
const grant = await
|
|
7073
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "stream:view");
|
|
5543
7074
|
assertOwnershipEnabled();
|
|
5544
7075
|
const sessionId = c.req.param("sessionId");
|
|
5545
7076
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5546
7077
|
if (!session) {
|
|
5547
|
-
throw new
|
|
7078
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5548
7079
|
}
|
|
5549
7080
|
const parsed = ViewerHeartbeatRequest.safeParse(await c.req.json().catch(() => ({})));
|
|
5550
7081
|
if (!parsed.success) {
|
|
5551
|
-
throw new
|
|
7082
|
+
throw new HTTPException17(400, { message: "viewer heartbeat requires { leaseEpoch }" });
|
|
5552
7083
|
}
|
|
5553
7084
|
const alive = await heartbeatViewer({ db, settings }, {
|
|
5554
7085
|
accountId: grant.accountId,
|
|
@@ -5561,12 +7092,12 @@ function registerSessionRoutes(app, deps) {
|
|
|
5561
7092
|
});
|
|
5562
7093
|
app.delete("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId", async (c) => {
|
|
5563
7094
|
const workspaceId = c.req.param("workspaceId");
|
|
5564
|
-
const grant = await
|
|
7095
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "stream:view");
|
|
5565
7096
|
assertOwnershipEnabled();
|
|
5566
7097
|
const sessionId = c.req.param("sessionId");
|
|
5567
7098
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5568
7099
|
if (!session) {
|
|
5569
|
-
throw new
|
|
7100
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5570
7101
|
}
|
|
5571
7102
|
await detachViewer({ db, settings }, {
|
|
5572
7103
|
accountId: grant.accountId,
|
|
@@ -5578,12 +7109,12 @@ function registerSessionRoutes(app, deps) {
|
|
|
5578
7109
|
});
|
|
5579
7110
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/viewers/:viewerId/revoke", async (c) => {
|
|
5580
7111
|
const workspaceId = c.req.param("workspaceId");
|
|
5581
|
-
const grant = await
|
|
7112
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, "stream:view");
|
|
5582
7113
|
assertOwnershipEnabled();
|
|
5583
7114
|
const sessionId = c.req.param("sessionId");
|
|
5584
7115
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5585
7116
|
if (!session) {
|
|
5586
|
-
throw new
|
|
7117
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5587
7118
|
}
|
|
5588
7119
|
const result = await revokeViewer(db, {
|
|
5589
7120
|
accountId: grant.accountId,
|
|
@@ -5596,12 +7127,12 @@ function registerSessionRoutes(app, deps) {
|
|
|
5596
7127
|
});
|
|
5597
7128
|
async function channelAPreamble(c, permission) {
|
|
5598
7129
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
5599
|
-
const grant = await
|
|
7130
|
+
const grant = await requireAccessGrant13(c, deps, workspaceId, permission);
|
|
5600
7131
|
assertOwnershipEnabled();
|
|
5601
7132
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
5602
7133
|
const session = await getSession4(db, workspaceId, sessionId);
|
|
5603
7134
|
if (!session) {
|
|
5604
|
-
throw new
|
|
7135
|
+
throw new HTTPException17(404, { message: "session not found" });
|
|
5605
7136
|
}
|
|
5606
7137
|
return { accountId: grant.accountId, workspaceId, session, subjectId: grant.subjectId };
|
|
5607
7138
|
}
|
|
@@ -5609,7 +7140,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5609
7140
|
const raw = await c.req.json().catch(() => void 0);
|
|
5610
7141
|
const result = schema.safeParse(raw ?? {});
|
|
5611
7142
|
if (!result.success) {
|
|
5612
|
-
throw new
|
|
7143
|
+
throw new HTTPException17(400, { message: "invalid request body" });
|
|
5613
7144
|
}
|
|
5614
7145
|
return result.data;
|
|
5615
7146
|
}
|
|
@@ -5704,7 +7235,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5704
7235
|
const delta = { ptyId, stream: "stdout", chunk: opened.initialOutput, seq: 0 };
|
|
5705
7236
|
events.push({ type: "terminal.pty.output.delta", payload: delta });
|
|
5706
7237
|
}
|
|
5707
|
-
await
|
|
7238
|
+
await appendAndPublishEvents5(db, bus, ctx.workspaceId, ctx.session.id, events);
|
|
5708
7239
|
return opened.response;
|
|
5709
7240
|
});
|
|
5710
7241
|
return c.json(out, 201);
|
|
@@ -5714,10 +7245,10 @@ function registerSessionRoutes(app, deps) {
|
|
|
5714
7245
|
const req = await parseChannelABody(c, PtyWriteRequest);
|
|
5715
7246
|
const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
|
|
5716
7247
|
if (!pty) {
|
|
5717
|
-
throw new
|
|
7248
|
+
throw new HTTPException17(404, { message: "pty not found or closed" });
|
|
5718
7249
|
}
|
|
5719
7250
|
if (pty.execSessionId === null) {
|
|
5720
|
-
throw new
|
|
7251
|
+
throw new HTTPException17(409, { message: "interactive terminal unsupported on this backend" });
|
|
5721
7252
|
}
|
|
5722
7253
|
let seq = 1;
|
|
5723
7254
|
await withChannelA({ db, settings, bus }, ctx, async ({ service }) => {
|
|
@@ -5725,7 +7256,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5725
7256
|
await updatePtySessionActivity(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId, execSessionId: pty.execSessionId });
|
|
5726
7257
|
if (output) {
|
|
5727
7258
|
const delta = { ptyId: req.ptyId, stream: "stdout", chunk: output, seq: seq++ };
|
|
5728
|
-
await
|
|
7259
|
+
await appendAndPublishEvents5(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.output.delta", payload: delta }]);
|
|
5729
7260
|
}
|
|
5730
7261
|
});
|
|
5731
7262
|
return c.body(null, 204);
|
|
@@ -5735,7 +7266,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5735
7266
|
const req = await parseChannelABody(c, PtyResizeRequest);
|
|
5736
7267
|
const pty = await getOpenPtySession(db, ctx.workspaceId, req.ptyId);
|
|
5737
7268
|
if (!pty) {
|
|
5738
|
-
throw new
|
|
7269
|
+
throw new HTTPException17(404, { message: "pty not found or closed" });
|
|
5739
7270
|
}
|
|
5740
7271
|
if (pty.execSessionId !== null) {
|
|
5741
7272
|
await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyResize(req, pty.execSessionId));
|
|
@@ -5751,7 +7282,7 @@ function registerSessionRoutes(app, deps) {
|
|
|
5751
7282
|
await withChannelA({ db, settings, bus }, ctx, ({ service }) => service.ptyClose(req, pty.execSessionId));
|
|
5752
7283
|
await closePtySession(db, { accountId: ctx.accountId, workspaceId: ctx.workspaceId, ptyId: req.ptyId });
|
|
5753
7284
|
const exited = { ptyId: req.ptyId, exitCode: 0, reason: "exit" };
|
|
5754
|
-
await
|
|
7285
|
+
await appendAndPublishEvents5(db, bus, ctx.workspaceId, ctx.session.id, [{ type: "terminal.pty.exited", payload: exited }]);
|
|
5755
7286
|
}
|
|
5756
7287
|
return c.body(null, 204);
|
|
5757
7288
|
});
|
|
@@ -5805,19 +7336,19 @@ import {
|
|
|
5805
7336
|
listSocialConnections as listSocialConnections3,
|
|
5806
7337
|
listSocialPosts as listSocialPosts2
|
|
5807
7338
|
} from "@opengeni/db";
|
|
5808
|
-
import { HTTPException as
|
|
7339
|
+
import { HTTPException as HTTPException18 } from "hono/http-exception";
|
|
5809
7340
|
import { z as z2 } from "zod";
|
|
5810
|
-
import { requireAccessGrant as
|
|
7341
|
+
import { requireAccessGrant as requireAccessGrant14 } from "@opengeni/core";
|
|
5811
7342
|
function registerSocialRoutes(app, deps) {
|
|
5812
7343
|
const { db } = deps;
|
|
5813
7344
|
app.get("/v1/workspaces/:workspaceId/social/connections", async (c) => {
|
|
5814
7345
|
const workspaceId = c.req.param("workspaceId");
|
|
5815
|
-
await
|
|
7346
|
+
await requireAccessGrant14(c, deps, workspaceId, "workspace:read");
|
|
5816
7347
|
return c.json(await listSocialConnections3(db, workspaceId, boundedLimit(c.req.query("limit"))));
|
|
5817
7348
|
});
|
|
5818
7349
|
app.post("/v1/workspaces/:workspaceId/social/connections", async (c) => {
|
|
5819
7350
|
const workspaceId = c.req.param("workspaceId");
|
|
5820
|
-
const grant = await
|
|
7351
|
+
const grant = await requireAccessGrant14(c, deps, workspaceId, "workspace:admin");
|
|
5821
7352
|
const payload = CreateSocialConnectionRequest.parse(await c.req.json());
|
|
5822
7353
|
try {
|
|
5823
7354
|
return c.json(await createSocialConnection(db, {
|
|
@@ -5839,7 +7370,7 @@ function registerSocialRoutes(app, deps) {
|
|
|
5839
7370
|
});
|
|
5840
7371
|
app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
5841
7372
|
const workspaceId = c.req.param("workspaceId");
|
|
5842
|
-
await
|
|
7373
|
+
await requireAccessGrant14(c, deps, workspaceId, "workspace:read");
|
|
5843
7374
|
const since = parseSince(c.req.query("since"));
|
|
5844
7375
|
const connectionIds = parseConnectionIds(c.req.query("connectionIds") ?? c.req.query("connectionId"));
|
|
5845
7376
|
return c.json(await listSocialPosts2(db, {
|
|
@@ -5851,7 +7382,7 @@ function registerSocialRoutes(app, deps) {
|
|
|
5851
7382
|
});
|
|
5852
7383
|
app.post("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
5853
7384
|
const workspaceId = c.req.param("workspaceId");
|
|
5854
|
-
const grant = await
|
|
7385
|
+
const grant = await requireAccessGrant14(c, deps, workspaceId, "workspace:admin");
|
|
5855
7386
|
const payload = CreateSocialPostRequest.parse(await c.req.json());
|
|
5856
7387
|
try {
|
|
5857
7388
|
return c.json(await createSocialPost(db, {
|
|
@@ -5877,7 +7408,7 @@ function parseSince(raw) {
|
|
|
5877
7408
|
}
|
|
5878
7409
|
const since = new Date(raw);
|
|
5879
7410
|
if (Number.isNaN(since.getTime())) {
|
|
5880
|
-
throw new
|
|
7411
|
+
throw new HTTPException18(422, { message: "since must be an ISO date-time" });
|
|
5881
7412
|
}
|
|
5882
7413
|
return since;
|
|
5883
7414
|
}
|
|
@@ -5888,7 +7419,7 @@ function parseConnectionIds(raw) {
|
|
|
5888
7419
|
const values = raw.split(",").map((value) => value.trim()).filter(Boolean);
|
|
5889
7420
|
const parsed = z2.array(z2.string().uuid()).safeParse(values);
|
|
5890
7421
|
if (!parsed.success) {
|
|
5891
|
-
throw new
|
|
7422
|
+
throw new HTTPException18(422, { message: "connectionIds must be a comma-separated list of UUIDs" });
|
|
5892
7423
|
}
|
|
5893
7424
|
const ids = parsed.data;
|
|
5894
7425
|
return [...new Set(ids)];
|
|
@@ -5896,12 +7427,12 @@ function parseConnectionIds(raw) {
|
|
|
5896
7427
|
function socialHttpException(error) {
|
|
5897
7428
|
const message = error instanceof Error ? error.message : String(error);
|
|
5898
7429
|
if (message.includes("not found")) {
|
|
5899
|
-
return new
|
|
7430
|
+
return new HTTPException18(404, { message });
|
|
5900
7431
|
}
|
|
5901
7432
|
if (message.includes("duplicate key")) {
|
|
5902
|
-
return new
|
|
7433
|
+
return new HTTPException18(409, { message: "social connection or post already exists" });
|
|
5903
7434
|
}
|
|
5904
|
-
return new
|
|
7435
|
+
return new HTTPException18(500, { message });
|
|
5905
7436
|
}
|
|
5906
7437
|
|
|
5907
7438
|
// src/routes/workspaces.ts
|
|
@@ -5929,8 +7460,8 @@ import {
|
|
|
5929
7460
|
requireWorkspace,
|
|
5930
7461
|
updateWorkspace
|
|
5931
7462
|
} from "@opengeni/db";
|
|
5932
|
-
import { HTTPException as
|
|
5933
|
-
import { hasPermission as
|
|
7463
|
+
import { HTTPException as HTTPException19 } from "hono/http-exception";
|
|
7464
|
+
import { hasPermission as hasPermission3, requireAccessContext as requireAccessContext2, requireAccessGrant as requireAccessGrant15 } from "@opengeni/core";
|
|
5934
7465
|
import { requireLimit as requireLimit7 } from "@opengeni/core";
|
|
5935
7466
|
import { assertWorkspaceDeletable, assertWorkspaceMemberRemovable, resolveMemberSubjectId } from "@opengeni/core";
|
|
5936
7467
|
function registerWorkspaceRoutes(app, deps) {
|
|
@@ -5939,7 +7470,7 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
5939
7470
|
});
|
|
5940
7471
|
app.get("/v1/workspaces", async (c) => {
|
|
5941
7472
|
const context = await requireAccessContext2(c, deps);
|
|
5942
|
-
const readableWorkspaceIds = [...new Set(context.workspaceGrants.filter((grant) =>
|
|
7473
|
+
const readableWorkspaceIds = [...new Set(context.workspaceGrants.filter((grant) => hasPermission3(grant.permissions, "workspace:read")).map((grant) => grant.workspaceId))];
|
|
5943
7474
|
if (readableWorkspaceIds.length > 0) {
|
|
5944
7475
|
const workspaces = await Promise.all(readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)));
|
|
5945
7476
|
return c.json(workspaces.map((workspace) => Workspace.parse(workspace)));
|
|
@@ -5951,7 +7482,7 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
5951
7482
|
const payload = CreateWorkspaceRequest.parse(await c.req.json());
|
|
5952
7483
|
const accountId = payload.accountId ?? context.defaultAccountId;
|
|
5953
7484
|
if (!accountId) {
|
|
5954
|
-
throw new
|
|
7485
|
+
throw new HTTPException19(409, { message: "account selection is required" });
|
|
5955
7486
|
}
|
|
5956
7487
|
requireAccountPermission(context, accountId, "workspace:create");
|
|
5957
7488
|
await requireLimit7(deps, { accountId, action: "workspace:create", quantity: 1 });
|
|
@@ -5975,12 +7506,12 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
5975
7506
|
});
|
|
5976
7507
|
app.get("/v1/workspaces/:workspaceId", async (c) => {
|
|
5977
7508
|
const workspaceId = c.req.param("workspaceId");
|
|
5978
|
-
await
|
|
7509
|
+
await requireAccessGrant15(c, deps, workspaceId, "workspace:read");
|
|
5979
7510
|
return c.json(Workspace.parse(await requireWorkspace(deps.db, workspaceId)));
|
|
5980
7511
|
});
|
|
5981
7512
|
app.patch("/v1/workspaces/:workspaceId", async (c) => {
|
|
5982
7513
|
const workspaceId = c.req.param("workspaceId");
|
|
5983
|
-
await
|
|
7514
|
+
await requireAccessGrant15(c, deps, workspaceId, "workspace:admin");
|
|
5984
7515
|
const payload = UpdateWorkspaceRequest.parse(await c.req.json());
|
|
5985
7516
|
const workspace = await updateWorkspace(deps.db, workspaceId, {
|
|
5986
7517
|
...payload.name !== void 0 ? { name: payload.name.trim() } : {},
|
|
@@ -5991,7 +7522,7 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
5991
7522
|
});
|
|
5992
7523
|
app.delete("/v1/workspaces/:workspaceId", async (c) => {
|
|
5993
7524
|
const workspaceId = c.req.param("workspaceId");
|
|
5994
|
-
const grant = await
|
|
7525
|
+
const grant = await requireAccessGrant15(c, deps, workspaceId, "workspace:admin");
|
|
5995
7526
|
const [workspaceCountForAccount, activeSessionCount] = await Promise.all([
|
|
5996
7527
|
countWorkspacesForAccount(deps.db, grant.accountId),
|
|
5997
7528
|
countActiveSessionsForWorkspace(deps.db, workspaceId)
|
|
@@ -6006,13 +7537,13 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
6006
7537
|
});
|
|
6007
7538
|
app.get("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
6008
7539
|
const workspaceId = c.req.param("workspaceId");
|
|
6009
|
-
await
|
|
7540
|
+
await requireAccessGrant15(c, deps, workspaceId, "workspace:read");
|
|
6010
7541
|
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
6011
7542
|
return c.json(ListWorkspaceMembersResponse.parse({ members }));
|
|
6012
7543
|
});
|
|
6013
7544
|
app.post("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
6014
7545
|
const workspaceId = c.req.param("workspaceId");
|
|
6015
|
-
const grant = await
|
|
7546
|
+
const grant = await requireAccessGrant15(c, deps, workspaceId, "members:manage");
|
|
6016
7547
|
const payload = AddWorkspaceMemberRequest.parse(await c.req.json());
|
|
6017
7548
|
const email = payload.email.trim();
|
|
6018
7549
|
const subjectId = resolveMemberSubjectId(await getManagedUserByEmail(deps.db, email));
|
|
@@ -6027,19 +7558,19 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
6027
7558
|
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
6028
7559
|
const member = members.find((candidate) => candidate.subjectId === subjectId);
|
|
6029
7560
|
if (!member) {
|
|
6030
|
-
throw new
|
|
7561
|
+
throw new HTTPException19(500, { message: "failed to add member" });
|
|
6031
7562
|
}
|
|
6032
7563
|
return c.json(WorkspaceMember.parse(member), 201);
|
|
6033
7564
|
});
|
|
6034
7565
|
app.patch("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
|
|
6035
7566
|
const workspaceId = c.req.param("workspaceId");
|
|
6036
|
-
const grant = await
|
|
7567
|
+
const grant = await requireAccessGrant15(c, deps, workspaceId, "members:manage");
|
|
6037
7568
|
const subjectId = decodeURIComponent(c.req.param("subjectId"));
|
|
6038
7569
|
const payload = UpdateWorkspaceMemberRequest.parse(await c.req.json());
|
|
6039
7570
|
const existing = await listWorkspaceMembers(deps.db, workspaceId);
|
|
6040
7571
|
const current = existing.find((member2) => member2.subjectId === subjectId);
|
|
6041
7572
|
if (!current) {
|
|
6042
|
-
throw new
|
|
7573
|
+
throw new HTTPException19(404, { message: "member not found" });
|
|
6043
7574
|
}
|
|
6044
7575
|
await grantWorkspaceAccess(deps.db, {
|
|
6045
7576
|
accountId: grant.accountId,
|
|
@@ -6052,13 +7583,13 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
6052
7583
|
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
6053
7584
|
const member = members.find((candidate) => candidate.subjectId === subjectId);
|
|
6054
7585
|
if (!member) {
|
|
6055
|
-
throw new
|
|
7586
|
+
throw new HTTPException19(500, { message: "failed to update member" });
|
|
6056
7587
|
}
|
|
6057
7588
|
return c.json(WorkspaceMember.parse(member));
|
|
6058
7589
|
});
|
|
6059
7590
|
app.delete("/v1/workspaces/:workspaceId/members/:subjectId", async (c) => {
|
|
6060
7591
|
const workspaceId = c.req.param("workspaceId");
|
|
6061
|
-
const grant = await
|
|
7592
|
+
const grant = await requireAccessGrant15(c, deps, workspaceId, "members:manage");
|
|
6062
7593
|
const subjectId = decodeURIComponent(c.req.param("subjectId"));
|
|
6063
7594
|
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
6064
7595
|
assertWorkspaceMemberRemovable({ members, subjectId, callerSubjectId: grant.subjectId });
|
|
@@ -6076,7 +7607,7 @@ function normalizeAgentInstructions(value) {
|
|
|
6076
7607
|
function requireAccountPermission(context, accountId, permission) {
|
|
6077
7608
|
const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
|
|
6078
7609
|
if (!grant || !grant.permissions.includes(permission) && !grant.permissions.includes("account:admin")) {
|
|
6079
|
-
throw new
|
|
7610
|
+
throw new HTTPException19(403, { message: `missing permission: ${permission}` });
|
|
6080
7611
|
}
|
|
6081
7612
|
}
|
|
6082
7613
|
|
|
@@ -6103,7 +7634,7 @@ function createApp(deps) {
|
|
|
6103
7634
|
const documentIndexer = deps.documentIndexer ?? {
|
|
6104
7635
|
indexDocument: async ({ accountId, workspaceId, documentId }) => {
|
|
6105
7636
|
if (!objectStorage) {
|
|
6106
|
-
throw new
|
|
7637
|
+
throw new HTTPException20(503, { message: "object storage is not configured" });
|
|
6107
7638
|
}
|
|
6108
7639
|
return await indexDocumentNow(deps.db, objectStorage, workspaceId, documentId, getDocumentServices(), {
|
|
6109
7640
|
beforeEmbed: async ({ chunkCount }) => {
|
|
@@ -6194,13 +7725,19 @@ function createApp(deps) {
|
|
|
6194
7725
|
service: deps.settings.serviceName,
|
|
6195
7726
|
environment: deps.settings.environment,
|
|
6196
7727
|
deploymentRevision: deps.settings.deploymentRevision,
|
|
7728
|
+
...deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {},
|
|
6197
7729
|
ok: true
|
|
6198
7730
|
}));
|
|
6199
|
-
app.get("/
|
|
7731
|
+
app.get("/readyz", async (c) => {
|
|
7732
|
+
const result = await runReadinessChecks(readinessChecks(deps), 2e3);
|
|
7733
|
+
return c.json(result, result.ok ? 200 : 503);
|
|
7734
|
+
});
|
|
7735
|
+
app.get("/metrics", async (c) => c.text(await observability.prometheusMetrics(), 200, {
|
|
6200
7736
|
"content-type": "text/plain; version=0.0.4; charset=utf-8"
|
|
6201
7737
|
}));
|
|
6202
7738
|
app.get("/v1/config/client", (c) => c.json(ClientConfig.parse({
|
|
6203
7739
|
deploymentRevision: deps.settings.deploymentRevision,
|
|
7740
|
+
...deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {},
|
|
6204
7741
|
defaultModel: deps.settings.openaiModel,
|
|
6205
7742
|
allowedModels: configuredAllowedModels(deps.settings),
|
|
6206
7743
|
// Provider-grouped model list for the picker. configuredModels() carries the
|
|
@@ -6234,11 +7771,19 @@ function createApp(deps) {
|
|
|
6234
7771
|
})));
|
|
6235
7772
|
app.all("/v1/workspaces/:workspaceId/mcp", async (c) => {
|
|
6236
7773
|
const workspaceId = c.req.param("workspaceId");
|
|
6237
|
-
const grant = await
|
|
7774
|
+
const grant = await requireMcpAccessGrant(c, routeDeps, workspaceId);
|
|
7775
|
+
const toolspace = isToolspaceGrant(routeDeps.settings, grant) ? await prepareToolspaceMcpSurface({ deps: routeDeps, grant }) : null;
|
|
6238
7776
|
const transport = new WebStandardStreamableHTTPServerTransport2({ enableJsonResponse: true });
|
|
6239
|
-
const mcp = buildOpenGeniMcpServer(routeDeps, grant, {
|
|
6240
|
-
|
|
6241
|
-
|
|
7777
|
+
const mcp = buildOpenGeniMcpServer(routeDeps, grant, {
|
|
7778
|
+
requestOrigin: new URL(c.req.url).origin,
|
|
7779
|
+
toolspace
|
|
7780
|
+
});
|
|
7781
|
+
try {
|
|
7782
|
+
await mcp.connect(transport);
|
|
7783
|
+
return await transport.handleRequest(c.req.raw);
|
|
7784
|
+
} finally {
|
|
7785
|
+
await toolspace?.close().catch(() => void 0);
|
|
7786
|
+
}
|
|
6242
7787
|
});
|
|
6243
7788
|
registerFileRoutes(app, routeDeps);
|
|
6244
7789
|
registerApiKeyRoutes(app, routeDeps);
|
|
@@ -6248,6 +7793,7 @@ function createApp(deps) {
|
|
|
6248
7793
|
registerInstallRoutes(app, routeDeps);
|
|
6249
7794
|
registerWorkspaceRoutes(app, routeDeps);
|
|
6250
7795
|
registerSocialRoutes(app, routeDeps);
|
|
7796
|
+
registerConnectionRoutes(app, routeDeps);
|
|
6251
7797
|
registerCapabilityRoutes(app, routeDeps);
|
|
6252
7798
|
registerEnrollmentRoutes(app, routeDeps);
|
|
6253
7799
|
registerMachineRoutes(app, routeDeps);
|
|
@@ -6258,6 +7804,17 @@ function createApp(deps) {
|
|
|
6258
7804
|
registerCodexRoutes(app, routeDeps);
|
|
6259
7805
|
return app;
|
|
6260
7806
|
}
|
|
7807
|
+
async function requireMcpAccessGrant(c, deps, workspaceId) {
|
|
7808
|
+
const grant = await requireAccessGrant16(c, deps, workspaceId);
|
|
7809
|
+
if (hasPermission4(grant.permissions, "workspace:read")) {
|
|
7810
|
+
return grant;
|
|
7811
|
+
}
|
|
7812
|
+
if (isToolspaceGrant(deps.settings, grant)) {
|
|
7813
|
+
return grant;
|
|
7814
|
+
}
|
|
7815
|
+
requirePermission(grant, "workspace:read");
|
|
7816
|
+
return grant;
|
|
7817
|
+
}
|
|
6261
7818
|
function clientAuthConfig(settings) {
|
|
6262
7819
|
if (settings.productAccessMode === "managed") {
|
|
6263
7820
|
return { mode: "managedSession", session: "cookie" };
|
|
@@ -6278,13 +7835,61 @@ function allowedCorsOrigin(pattern, origin) {
|
|
|
6278
7835
|
return new RegExp(`^(?:${pattern})$`).test(origin);
|
|
6279
7836
|
}
|
|
6280
7837
|
function httpStatusForError(error) {
|
|
6281
|
-
if (error instanceof
|
|
7838
|
+
if (error instanceof HTTPException20) {
|
|
6282
7839
|
return error.status;
|
|
6283
7840
|
}
|
|
6284
7841
|
return 500;
|
|
6285
7842
|
}
|
|
7843
|
+
function readinessChecks(deps) {
|
|
7844
|
+
return {
|
|
7845
|
+
db: deps.readinessChecks?.db ?? (async () => {
|
|
7846
|
+
await deps.db.execute(dbSql`select 1`);
|
|
7847
|
+
}),
|
|
7848
|
+
nats: deps.readinessChecks?.nats ?? (() => {
|
|
7849
|
+
if (deps.bus.isConnected && !deps.bus.isConnected()) {
|
|
7850
|
+
throw new Error("NATS is not connected");
|
|
7851
|
+
}
|
|
7852
|
+
}),
|
|
7853
|
+
temporal: deps.readinessChecks?.temporal ?? deps.workflowClient.check ?? (() => {
|
|
7854
|
+
throw new Error("Temporal readiness check unavailable");
|
|
7855
|
+
})
|
|
7856
|
+
};
|
|
7857
|
+
}
|
|
7858
|
+
async function runReadinessChecks(checks, timeoutMs) {
|
|
7859
|
+
const entries = await Promise.all(
|
|
7860
|
+
Object.entries(checks).map(async ([name, check]) => {
|
|
7861
|
+
try {
|
|
7862
|
+
await withTimeout(Promise.resolve().then(check), timeoutMs);
|
|
7863
|
+
return [name, { ok: true }];
|
|
7864
|
+
} catch (error) {
|
|
7865
|
+
return [name, { ok: false, error: error instanceof Error ? error.message : String(error) }];
|
|
7866
|
+
}
|
|
7867
|
+
})
|
|
7868
|
+
);
|
|
7869
|
+
const result = Object.fromEntries(entries);
|
|
7870
|
+
return {
|
|
7871
|
+
ok: Object.values(result).every((check) => check.ok),
|
|
7872
|
+
checks: result
|
|
7873
|
+
};
|
|
7874
|
+
}
|
|
7875
|
+
async function withTimeout(promise, timeoutMs) {
|
|
7876
|
+
let timer;
|
|
7877
|
+
try {
|
|
7878
|
+
return await Promise.race([
|
|
7879
|
+
promise,
|
|
7880
|
+
new Promise((_, reject) => {
|
|
7881
|
+
timer = setTimeout(() => reject(new Error(`readiness check timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
7882
|
+
})
|
|
7883
|
+
]);
|
|
7884
|
+
} finally {
|
|
7885
|
+
if (timer) {
|
|
7886
|
+
clearTimeout(timer);
|
|
7887
|
+
}
|
|
7888
|
+
}
|
|
7889
|
+
}
|
|
6286
7890
|
var routeLabelPatterns = [
|
|
6287
7891
|
{ pattern: /^\/healthz$/, label: "/healthz" },
|
|
7892
|
+
{ pattern: /^\/readyz$/, label: "/readyz" },
|
|
6288
7893
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/codex\/connect\/start$/, label: "/v1/workspaces/:workspaceId/codex/connect/start" },
|
|
6289
7894
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/codex\/connect\/poll$/, label: "/v1/workspaces/:workspaceId/codex/connect/poll" },
|
|
6290
7895
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/codex\/status$/, label: "/v1/workspaces/:workspaceId/codex/status" },
|
|
@@ -6329,6 +7934,9 @@ var routeLabelPatterns = [
|
|
|
6329
7934
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/document-bases\/[^/]+\/documents$/, label: "/v1/workspaces/:workspaceId/document-bases/:id/documents" },
|
|
6330
7935
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/document-bases\/[^/]+\/search$/, label: "/v1/workspaces/:workspaceId/document-bases/:id/search" },
|
|
6331
7936
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/document-bases\/[^/]+$/, label: "/v1/workspaces/:workspaceId/document-bases/:id" },
|
|
7937
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/knowledge\/search$/, label: "/v1/workspaces/:workspaceId/knowledge/search" },
|
|
7938
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/knowledge\/memories\/[^/]+$/, label: "/v1/workspaces/:workspaceId/knowledge/memories/:id" },
|
|
7939
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/knowledge\/memories$/, label: "/v1/workspaces/:workspaceId/knowledge/memories" },
|
|
6332
7940
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/github\/app$/, label: "/v1/workspaces/:workspaceId/github/app" },
|
|
6333
7941
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/github\/repositories$/, label: "/v1/workspaces/:workspaceId/github/repositories" },
|
|
6334
7942
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/github\/repositories\/sync$/, label: "/v1/workspaces/:workspaceId/github/repositories/sync" },
|
|
@@ -6347,6 +7955,11 @@ var routeLabelPatterns = [
|
|
|
6347
7955
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/packs\/[^/]+$/, label: "/v1/workspaces/:workspaceId/packs/:id" },
|
|
6348
7956
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/social\/connections$/, label: "/v1/workspaces/:workspaceId/social/connections" },
|
|
6349
7957
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/social\/posts$/, label: "/v1/workspaces/:workspaceId/social/posts" },
|
|
7958
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections$/, label: "/v1/workspaces/:workspaceId/connections" },
|
|
7959
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/oauth\/start$/, label: "/v1/workspaces/:workspaceId/connections/oauth/start" },
|
|
7960
|
+
{ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/, label: "/v1/workspaces/:workspaceId/connections/:connectionId" },
|
|
7961
|
+
{ pattern: /^\/v1\/integrations\/oauth\/callback$/, label: "/v1/integrations/oauth/callback" },
|
|
7962
|
+
{ pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/, label: "/v1/integrations/oauth/client-metadata.json" },
|
|
6350
7963
|
{ pattern: /^\/v1\/enrollments\/device\/start$/, label: "/v1/enrollments/device/start" },
|
|
6351
7964
|
{ pattern: /^\/v1\/enrollments\/device\/poll$/, label: "/v1/enrollments/device/poll" },
|
|
6352
7965
|
{ pattern: /^\/v1\/workspaces\/[^/]+\/enrollments\/device\/approve$/, label: "/v1/workspaces/:workspaceId/enrollments/device/approve" },
|
|
@@ -6384,4 +7997,4 @@ export {
|
|
|
6384
7997
|
withDefaultEnabledCapabilityMcpTools,
|
|
6385
7998
|
workflowIdForSession3 as workflowIdForSession
|
|
6386
7999
|
};
|
|
6387
|
-
//# sourceMappingURL=chunk-
|
|
8000
|
+
//# sourceMappingURL=chunk-DQ5TIRDZ.js.map
|