@opengeni/api-router 0.22.2 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +0 -30
- package/dist/browser-controller-authority.d.ts +43 -0
- package/dist/browser-state-authority.d.ts +27 -0
- package/dist/{chunk-HWXJW5C7.js → chunk-JIKNR5YL.js} +27993 -14546
- package/dist/chunk-JIKNR5YL.js.map +1 -0
- package/dist/codemode.d.ts +23 -0
- package/dist/editable-artifact-live-hints.d.ts +11 -0
- package/dist/editable-artifact-native-kernel.d.ts +37 -0
- package/dist/editable-artifact-office-import.d.ts +22 -0
- package/dist/editable-artifact-production.d.ts +29 -0
- package/dist/editable-artifact-websocket.d.ts +49 -0
- package/dist/editable-artifact-workspace-files.d.ts +23 -0
- package/dist/github-browser-flow.d.ts +6 -0
- package/dist/http/cors.d.ts +1 -0
- package/dist/http/sse.d.ts +2 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1824 -30
- package/dist/index.js.map +1 -1
- package/dist/integrations/api-integrations.d.ts +24 -0
- package/dist/integrations/atlassian.d.ts +176 -0
- package/dist/integrations/github-skill-source.d.ts +5 -0
- package/dist/integrations/google-drive.d.ts +85 -0
- package/dist/integrations/oauth-client.d.ts +30 -1
- package/dist/integrations/provider-oauth.d.ts +19 -0
- package/dist/integrations/slack-bot.d.ts +4 -0
- package/dist/integrations/slack-interactions.d.ts +14 -2
- package/dist/integrations/social-api.d.ts +2 -1
- package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
- package/dist/mcp/editable-artifacts.d.ts +13 -0
- package/dist/mcp/receipts.d.ts +28 -0
- package/dist/mcp/scheduled-task-view.d.ts +518 -0
- package/dist/mcp/server.d.ts +14 -3
- package/dist/memory-slack-delivery.d.ts +9 -0
- package/dist/routes/api-integrations.d.ts +8 -0
- package/dist/routes/browser-identities.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +6 -0
- package/dist/routes/company-profile.d.ts +3 -0
- package/dist/routes/computer-sessions.d.ts +6 -0
- package/dist/routes/editable-artifacts.d.ts +44 -0
- package/dist/routes/integration-features.d.ts +3 -0
- package/dist/routes/memory-slack-publications.d.ts +6 -0
- package/dist/routes/plugins.d.ts +8 -0
- package/dist/routes/sessions.d.ts +17 -2
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/video-generation.d.ts +3 -0
- package/dist/sandbox/auth-callout.d.ts +2 -0
- package/dist/sandbox/channel-a.d.ts +59 -2
- package/dist/sandbox/metrics-ingestion.d.ts +6 -1
- package/dist/sandbox/viewer.d.ts +4 -2
- package/dist/temporal-schedule-cleanup.d.ts +26 -0
- package/package.json +19 -14
- package/src/app.ts +277 -41
- package/src/auth/managed-auth.ts +0 -16
- package/src/browser-controller-authority.ts +137 -0
- package/src/browser-state-authority.ts +236 -0
- package/src/codemode.ts +186 -0
- package/src/editable-artifact-live-hints.ts +64 -0
- package/src/editable-artifact-native-kernel.ts +659 -0
- package/src/editable-artifact-office-import.ts +230 -0
- package/src/editable-artifact-production.ts +419 -0
- package/src/editable-artifact-websocket.ts +311 -0
- package/src/editable-artifact-workspace-files.ts +186 -0
- package/src/github-browser-flow.ts +35 -6
- package/src/http/auth.ts +2 -0
- package/src/http/cors.ts +3 -0
- package/src/http/sse.ts +101 -6
- package/src/index.ts +147 -23
- package/src/integrations/api-integrations.ts +350 -0
- package/src/integrations/atlassian.ts +1621 -0
- package/src/integrations/github-skill-source.ts +142 -0
- package/src/integrations/google-drive.ts +1000 -64
- package/src/integrations/oauth-client.ts +159 -89
- package/src/integrations/provider-oauth.ts +777 -0
- package/src/integrations/slack-bot.ts +31 -2
- package/src/integrations/slack-interactions.ts +610 -42
- package/src/integrations/social-api.ts +11 -0
- package/src/mcp/documents.ts +74 -26
- package/src/mcp/editable-artifact-query-schema.ts +236 -0
- package/src/mcp/editable-artifacts.ts +448 -0
- package/src/mcp/receipts.ts +95 -0
- package/src/mcp/scheduled-task-view.ts +642 -0
- package/src/mcp/server.ts +1718 -310
- package/src/memory-slack-delivery.ts +209 -0
- package/src/observability.ts +3 -3
- package/src/routes/api-integrations.ts +407 -0
- package/src/routes/api-keys.ts +7 -1
- package/src/routes/browser-identities.ts +136 -0
- package/src/routes/browser-sessions.ts +2543 -0
- package/src/routes/codex.ts +7 -4
- package/src/routes/company-profile.ts +255 -0
- package/src/routes/computer-sessions.ts +1247 -0
- package/src/routes/connections.ts +358 -102
- package/src/routes/documents.ts +22 -3
- package/src/routes/editable-artifacts.ts +1159 -0
- package/src/routes/enrollments.ts +54 -12
- package/src/routes/environments.ts +60 -11
- package/src/routes/files.ts +277 -65
- package/src/routes/github.ts +18 -2
- package/src/routes/install.ts +38 -2
- package/src/routes/integration-features.ts +258 -0
- package/src/routes/machines.ts +1 -1
- package/src/routes/memory-slack-publications.ts +216 -0
- package/src/routes/packs.ts +437 -7
- package/src/routes/plugins.ts +751 -0
- package/src/routes/rigs.ts +77 -20
- package/src/routes/scheduled-tasks.ts +94 -42
- package/src/routes/sessions.ts +475 -234
- package/src/routes/skills.ts +174 -0
- package/src/routes/transcription-recordings.ts +65 -33
- package/src/routes/video-generation.ts +132 -0
- package/src/routes/workspaces.ts +46 -24
- package/src/sandbox/auth-callout.ts +16 -4
- package/src/sandbox/channel-a.ts +809 -85
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +1 -1
- package/src/sandbox/metrics-ingestion.ts +121 -3
- package/src/sandbox/rematerialize.ts +35 -47
- package/src/sandbox/viewer.ts +58 -29
- package/src/temporal-schedule-cleanup.ts +135 -0
- package/dist/chunk-HWXJW5C7.js.map +0 -1
- package/dist/mcp/toolspace.d.ts +0 -62
- package/src/mcp/toolspace.ts +0 -1186
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InstallSkillRequest,
|
|
3
|
+
InstalledSkill,
|
|
4
|
+
PreviewSkillImportRequest,
|
|
5
|
+
SkillImportPreview,
|
|
6
|
+
SkillUninstallPreview,
|
|
7
|
+
UninstallSkillRequest,
|
|
8
|
+
UninstallSkillResult,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import {
|
|
11
|
+
portableSkillCapabilityId,
|
|
12
|
+
portableSkillPluginKey,
|
|
13
|
+
requireAccessGrant,
|
|
14
|
+
resolveSkillImport,
|
|
15
|
+
type ApiRouteDeps,
|
|
16
|
+
type GitHubSkillSourceClient,
|
|
17
|
+
} from "@opengeni/core";
|
|
18
|
+
import {
|
|
19
|
+
getPortableSkillUninstallPreview,
|
|
20
|
+
installPortableSkill,
|
|
21
|
+
PortableSkillInstallationVersionConflictError,
|
|
22
|
+
PortableSkillInstallationVersionRequiredError,
|
|
23
|
+
uninstallPortableSkill,
|
|
24
|
+
} from "@opengeni/db";
|
|
25
|
+
import { HTTPException } from "hono/http-exception";
|
|
26
|
+
import type { Hono } from "hono";
|
|
27
|
+
|
|
28
|
+
import { createGitHubSkillSourceClient } from "../integrations/github-skill-source";
|
|
29
|
+
|
|
30
|
+
export type SkillRouteOverrides = Readonly<{
|
|
31
|
+
github?: GitHubSkillSourceClient;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
export function registerSkillRoutes(
|
|
35
|
+
app: Hono,
|
|
36
|
+
deps: ApiRouteDeps,
|
|
37
|
+
overrides: SkillRouteOverrides = {},
|
|
38
|
+
): void {
|
|
39
|
+
const github = overrides.github ?? createGitHubSkillSourceClient(deps.settings);
|
|
40
|
+
|
|
41
|
+
app.post("/v1/workspaces/:workspaceId/skills/preview", async (c) => {
|
|
42
|
+
const workspaceId = c.req.param("workspaceId");
|
|
43
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
44
|
+
const payload = PreviewSkillImportRequest.parse(await c.req.json());
|
|
45
|
+
const resolved = await resolveForRoute(payload.url, github);
|
|
46
|
+
const installed = await getPortableSkillUninstallPreview(
|
|
47
|
+
deps.db,
|
|
48
|
+
workspaceId,
|
|
49
|
+
portableSkillCapabilityId(resolved.preview),
|
|
50
|
+
);
|
|
51
|
+
return c.json(
|
|
52
|
+
SkillImportPreview.parse({
|
|
53
|
+
...resolved.preview,
|
|
54
|
+
installed: installed.installed,
|
|
55
|
+
installationVersion: installed.installationVersion,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.post("/v1/workspaces/:workspaceId/skills/install", async (c) => {
|
|
61
|
+
const workspaceId = c.req.param("workspaceId");
|
|
62
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
63
|
+
const payload = InstallSkillRequest.parse(await c.req.json());
|
|
64
|
+
const resolved = await resolveForRoute(payload.url, github);
|
|
65
|
+
if (
|
|
66
|
+
resolved.preview.sourceCommit !== payload.expectedSourceCommit ||
|
|
67
|
+
resolved.preview.contentSha256 !== payload.expectedContentSha256
|
|
68
|
+
) {
|
|
69
|
+
throw new HTTPException(409, {
|
|
70
|
+
message:
|
|
71
|
+
"The Skill source changed after preview. Review the new commit and contents before installing.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const fileSummaryByPath = new Map(
|
|
75
|
+
resolved.preview.files.map((file) => [file.path, file] as const),
|
|
76
|
+
);
|
|
77
|
+
try {
|
|
78
|
+
const installed = await installPortableSkill(deps.db, {
|
|
79
|
+
accountId: grant.accountId,
|
|
80
|
+
workspaceId,
|
|
81
|
+
subjectId: grant.subjectId,
|
|
82
|
+
capabilityId: portableSkillCapabilityId(resolved.preview),
|
|
83
|
+
pluginKey: portableSkillPluginKey(resolved.preview),
|
|
84
|
+
source: resolved.preview.source,
|
|
85
|
+
sourceUrl: resolved.preview.sourceUrl,
|
|
86
|
+
repositoryUrl: resolved.preview.repositoryUrl,
|
|
87
|
+
sourceCommit: resolved.preview.sourceCommit,
|
|
88
|
+
sourcePath: resolved.preview.sourcePath,
|
|
89
|
+
name: resolved.preview.name,
|
|
90
|
+
description: resolved.preview.description,
|
|
91
|
+
contentSha256: resolved.preview.contentSha256,
|
|
92
|
+
totalBytes: resolved.preview.totalBytes,
|
|
93
|
+
...(payload.expectedInstallationVersion !== undefined
|
|
94
|
+
? { expectedInstallationVersion: payload.expectedInstallationVersion }
|
|
95
|
+
: {}),
|
|
96
|
+
files: resolved.files.map((file) => {
|
|
97
|
+
const summary = fileSummaryByPath.get(file.path);
|
|
98
|
+
if (!summary) throw new Error(`Skill preview omitted ${file.path}`);
|
|
99
|
+
return {
|
|
100
|
+
path: file.path,
|
|
101
|
+
content: file.content,
|
|
102
|
+
byteSize: summary.byteSize,
|
|
103
|
+
contentSha256: summary.contentSha256,
|
|
104
|
+
};
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
return c.json(
|
|
108
|
+
InstalledSkill.parse({ ...installed, status: "installed" }),
|
|
109
|
+
payload.expectedInstallationVersion === undefined ? 201 : 200,
|
|
110
|
+
);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof PortableSkillInstallationVersionRequiredError) {
|
|
113
|
+
throw new HTTPException(400, { message: error.message });
|
|
114
|
+
}
|
|
115
|
+
if (error instanceof PortableSkillInstallationVersionConflictError) {
|
|
116
|
+
throw new HTTPException(409, {
|
|
117
|
+
message: "The Skill changed after preview. Review the current installation again.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.get("/v1/workspaces/:workspaceId/skills/:capabilityId/uninstall-preview", async (c) => {
|
|
125
|
+
const workspaceId = c.req.param("workspaceId");
|
|
126
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
127
|
+
const capabilityId = decodeURIComponent(c.req.param("capabilityId"));
|
|
128
|
+
return c.json(
|
|
129
|
+
SkillUninstallPreview.parse(
|
|
130
|
+
await getPortableSkillUninstallPreview(deps.db, workspaceId, capabilityId),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
app.delete("/v1/workspaces/:workspaceId/skills/:capabilityId", async (c) => {
|
|
136
|
+
const workspaceId = c.req.param("workspaceId");
|
|
137
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
138
|
+
const capabilityId = decodeURIComponent(c.req.param("capabilityId"));
|
|
139
|
+
const payload = UninstallSkillRequest.parse(await c.req.json());
|
|
140
|
+
try {
|
|
141
|
+
return c.json(
|
|
142
|
+
UninstallSkillResult.parse(
|
|
143
|
+
await uninstallPortableSkill(deps.db, {
|
|
144
|
+
accountId: grant.accountId,
|
|
145
|
+
workspaceId,
|
|
146
|
+
capabilityId,
|
|
147
|
+
expectedInstallationVersion: payload.expectedInstallationVersion,
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof PortableSkillInstallationVersionConflictError) {
|
|
153
|
+
throw new HTTPException(409, {
|
|
154
|
+
message: "The Skill changed after preview. Review uninstall impact again.",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveForRoute(
|
|
163
|
+
url: string,
|
|
164
|
+
github: Parameters<typeof resolveSkillImport>[1],
|
|
165
|
+
): ReturnType<typeof resolveSkillImport> {
|
|
166
|
+
try {
|
|
167
|
+
return await resolveSkillImport(url, github);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof HTTPException) throw error;
|
|
170
|
+
throw new HTTPException(502, {
|
|
171
|
+
message: error instanceof Error ? error.message : "The Skill source could not be read",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -35,14 +35,17 @@ import {
|
|
|
35
35
|
markTranscriptionRecordingObjectsCleaned,
|
|
36
36
|
reserveTranscriptionRecordingChunk,
|
|
37
37
|
reserveTranscriptionRecordingSegment,
|
|
38
|
+
runIdempotentPersistenceTransaction,
|
|
38
39
|
startTranscriptionRecordingSegmentProviderCall,
|
|
39
40
|
transcriptionRecordingObjectKeys,
|
|
40
41
|
TranscriptionRecordingConflictError,
|
|
41
42
|
TranscriptionRecordingNotFoundError,
|
|
42
43
|
TranscriptionRecordingStateError,
|
|
44
|
+
isSessionEventPersistenceError,
|
|
43
45
|
} from "@opengeni/db";
|
|
44
46
|
import { getWorkspace } from "@opengeni/db";
|
|
45
47
|
import type { Context, Hono } from "hono";
|
|
48
|
+
import { ApiHttpError } from "../http/api-error";
|
|
46
49
|
import { TranscriptionSegmenterError } from "../transcription/segmenter";
|
|
47
50
|
|
|
48
51
|
const CHUNK_SHA256_HEADER = "x-opengeni-chunk-sha256";
|
|
@@ -153,17 +156,26 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
|
|
|
153
156
|
) {
|
|
154
157
|
return c.json({ code: "not_supported" }, 415);
|
|
155
158
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
159
|
+
const persistenceCorrelationId = correlationId(c);
|
|
160
|
+
const reservation = await runIdempotentPersistenceTransaction(
|
|
161
|
+
{
|
|
162
|
+
stage: "transcription_recording_chunk_reservation",
|
|
163
|
+
eventTypes: ["transcription_recording_chunk"],
|
|
164
|
+
correlationId: persistenceCorrelationId,
|
|
165
|
+
},
|
|
166
|
+
() =>
|
|
167
|
+
reserveTranscriptionRecordingChunk(deps.db, {
|
|
168
|
+
...authority,
|
|
169
|
+
recordingId: existing.recording.id,
|
|
170
|
+
chunkNumber,
|
|
171
|
+
byteLength: body.byteLength,
|
|
172
|
+
sha256,
|
|
173
|
+
startMilliseconds,
|
|
174
|
+
durationMilliseconds,
|
|
175
|
+
maxTotalBytes: deps.settings.voiceInputResumableMaxSizeBytes,
|
|
176
|
+
maxDurationMilliseconds: deps.settings.voiceInputResumableMaxDurationSeconds * 1_000,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
167
179
|
if (!reservation.deduplicated) {
|
|
168
180
|
try {
|
|
169
181
|
// A concurrent same-hash retry may still observe the row while it is
|
|
@@ -180,12 +192,20 @@ export function registerResumableTranscriptionRoutes(app: Hono, deps: ApiRouteDe
|
|
|
180
192
|
throw new RecordingProcessingError("Chunk upload failed", "network", true);
|
|
181
193
|
}
|
|
182
194
|
}
|
|
183
|
-
const completed = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
195
|
+
const completed = await runIdempotentPersistenceTransaction(
|
|
196
|
+
{
|
|
197
|
+
stage: "transcription_recording_chunk_completion",
|
|
198
|
+
eventTypes: ["transcription_recording_chunk"],
|
|
199
|
+
correlationId: persistenceCorrelationId,
|
|
200
|
+
},
|
|
201
|
+
() =>
|
|
202
|
+
completeTranscriptionRecordingChunk(deps.db, {
|
|
203
|
+
workspaceId: authority.workspaceId,
|
|
204
|
+
subjectId: authority.subjectId,
|
|
205
|
+
recordingId: existing.recording.id,
|
|
206
|
+
chunkNumber,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
189
209
|
const response: UploadTranscriptionRecordingChunkResponse = {
|
|
190
210
|
recording: completed.recording.recording,
|
|
191
211
|
chunk: {
|
|
@@ -620,9 +640,25 @@ function routeError(c: Context, error: unknown): Response | Promise<Response> {
|
|
|
620
640
|
: error.code === "unavailable"
|
|
621
641
|
? 503
|
|
622
642
|
: 502;
|
|
643
|
+
if (error.retryable) {
|
|
644
|
+
throw new ApiHttpError(status, {
|
|
645
|
+
code: "upstream_unavailable",
|
|
646
|
+
message: "Transcription is temporarily unavailable.",
|
|
647
|
+
retryable: true,
|
|
648
|
+
details: { transcriptionCode: error.code },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
623
651
|
return c.json({ code: error.code }, status as never);
|
|
624
652
|
}
|
|
625
|
-
|
|
653
|
+
if (isSessionEventPersistenceError(error)) {
|
|
654
|
+
throw new ApiHttpError(503, {
|
|
655
|
+
code: "upstream_unavailable",
|
|
656
|
+
message: "Transcription is temporarily unavailable.",
|
|
657
|
+
retryable: true,
|
|
658
|
+
details: { persistenceCode: error.code },
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
throw error;
|
|
626
662
|
}
|
|
627
663
|
|
|
628
664
|
async function jsonBody(c: Context): Promise<unknown> {
|
|
@@ -688,22 +724,18 @@ async function readBoundedBody(request: Request, maxBytes: number): Promise<Uint
|
|
|
688
724
|
const reader = request.body.getReader();
|
|
689
725
|
const chunks: Uint8Array[] = [];
|
|
690
726
|
let total = 0;
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
702
|
-
}
|
|
703
|
-
chunks.push(next.value);
|
|
727
|
+
for (;;) {
|
|
728
|
+
if (request.signal.aborted) {
|
|
729
|
+
throw new RecordingProcessingError("Chunk upload was cancelled", "cancelled", true);
|
|
730
|
+
}
|
|
731
|
+
const next = await reader.read();
|
|
732
|
+
if (next.done) break;
|
|
733
|
+
total += next.value.byteLength;
|
|
734
|
+
if (total > maxBytes) {
|
|
735
|
+
await reader.cancel();
|
|
736
|
+
throw new RecordingProcessingError("Chunk is too large", "too_large", false);
|
|
704
737
|
}
|
|
705
|
-
|
|
706
|
-
reader.releaseLock();
|
|
738
|
+
chunks.push(next.value);
|
|
707
739
|
}
|
|
708
740
|
if (total === 0) {
|
|
709
741
|
throw new RecordingProcessingError("Chunk is required", "invalid_audio", false);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UpdateVideoGenerationPolicyRequest,
|
|
3
|
+
VideoGenerationOperationSummary,
|
|
4
|
+
VideoGenerationPolicy,
|
|
5
|
+
WorkspaceVideoGenerationSettings,
|
|
6
|
+
} from "@opengeni/contracts";
|
|
7
|
+
import {
|
|
8
|
+
getVideoGenerationOperationSummary,
|
|
9
|
+
getWorkspaceVercelAiGatewayConnectionMetadata,
|
|
10
|
+
getWorkspaceVideoGenerationPolicy,
|
|
11
|
+
updateWorkspaceVideoGenerationPolicy,
|
|
12
|
+
VideoGenerationConflictError,
|
|
13
|
+
} from "@opengeni/db";
|
|
14
|
+
import {
|
|
15
|
+
requireAccessGrant,
|
|
16
|
+
VIDEO_GENERATION_MODEL_CATALOG,
|
|
17
|
+
videoGenerationCapabilitiesForPolicy,
|
|
18
|
+
type ApiRouteDeps,
|
|
19
|
+
} from "@opengeni/core";
|
|
20
|
+
import type { Hono } from "hono";
|
|
21
|
+
import { HTTPException } from "hono/http-exception";
|
|
22
|
+
|
|
23
|
+
export function registerVideoGenerationRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
24
|
+
app.get("/v1/workspaces/:workspaceId/video-generation", async (c) => {
|
|
25
|
+
const workspaceId = c.req.param("workspaceId");
|
|
26
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
27
|
+
const [policy, connection] = await Promise.all([
|
|
28
|
+
getWorkspaceVideoGenerationPolicy(deps.db, workspaceId),
|
|
29
|
+
getWorkspaceVercelAiGatewayConnectionMetadata(deps.db, workspaceId),
|
|
30
|
+
]);
|
|
31
|
+
const fundingOptions = videoGenerationFundingOptions({
|
|
32
|
+
managedConfigured: managedVideoGenerationConfigured(deps),
|
|
33
|
+
workspaceGatewayConfigured: connection !== null,
|
|
34
|
+
});
|
|
35
|
+
const selectedFunding = fundingOptions.find((option) => option.source === policy.fundingSource);
|
|
36
|
+
const capabilities =
|
|
37
|
+
selectedFunding?.available && policy.defaultModelId && policy.enabledModelIds.length > 0
|
|
38
|
+
? videoGenerationCapabilitiesForPolicy({
|
|
39
|
+
policy,
|
|
40
|
+
credentialVersion:
|
|
41
|
+
policy.fundingSource === "workspace_gateway" ? (connection?.version ?? 0) : 1,
|
|
42
|
+
})
|
|
43
|
+
: null;
|
|
44
|
+
return c.json(
|
|
45
|
+
WorkspaceVideoGenerationSettings.parse({
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
policy,
|
|
48
|
+
fundingOptions,
|
|
49
|
+
availableModels: VIDEO_GENERATION_MODEL_CATALOG,
|
|
50
|
+
capabilities,
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.put("/v1/workspaces/:workspaceId/video-generation/policy", async (c) => {
|
|
56
|
+
const workspaceId = c.req.param("workspaceId");
|
|
57
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
58
|
+
const payload = UpdateVideoGenerationPolicyRequest.parse(await c.req.json());
|
|
59
|
+
const connection = await getWorkspaceVercelAiGatewayConnectionMetadata(deps.db, workspaceId);
|
|
60
|
+
const fundingOptions = videoGenerationFundingOptions({
|
|
61
|
+
managedConfigured: managedVideoGenerationConfigured(deps),
|
|
62
|
+
workspaceGatewayConfigured: connection !== null,
|
|
63
|
+
});
|
|
64
|
+
const selectedFunding = fundingOptions.find(
|
|
65
|
+
(option) => option.source === payload.fundingSource,
|
|
66
|
+
);
|
|
67
|
+
if (payload.enabledModelIds.length > 0 && !selectedFunding?.available) {
|
|
68
|
+
throw new HTTPException(422, {
|
|
69
|
+
message: selectedFunding?.unavailableReason ?? "Video generation funding is unavailable",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const policy = await updateWorkspaceVideoGenerationPolicy(deps.db, {
|
|
74
|
+
accountId: grant.accountId,
|
|
75
|
+
workspaceId,
|
|
76
|
+
subjectId: grant.subjectId,
|
|
77
|
+
...payload,
|
|
78
|
+
});
|
|
79
|
+
return c.json(VideoGenerationPolicy.parse(policy));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof VideoGenerationConflictError) {
|
|
82
|
+
throw new HTTPException(409, { message: error.message });
|
|
83
|
+
}
|
|
84
|
+
if (error instanceof Error && error.message.startsWith("Unknown video generation model:")) {
|
|
85
|
+
throw new HTTPException(422, { message: error.message });
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.get("/v1/workspaces/:workspaceId/video-generation/operations/:operationId", async (c) => {
|
|
92
|
+
const workspaceId = c.req.param("workspaceId");
|
|
93
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
94
|
+
const summary = await getVideoGenerationOperationSummary(
|
|
95
|
+
deps.db,
|
|
96
|
+
workspaceId,
|
|
97
|
+
c.req.param("operationId"),
|
|
98
|
+
);
|
|
99
|
+
if (!summary) throw new HTTPException(404, { message: "video generation operation not found" });
|
|
100
|
+
return c.json(VideoGenerationOperationSummary.parse(summary));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function managedVideoGenerationConfigured(deps: ApiRouteDeps): boolean {
|
|
105
|
+
return Boolean(deps.settings.vercelAiGatewayApiKey && deps.settings.environmentsEncryptionKey);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function videoGenerationFundingOptions(input: {
|
|
109
|
+
managedConfigured: boolean;
|
|
110
|
+
workspaceGatewayConfigured: boolean;
|
|
111
|
+
}) {
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
source: "opengeni_credits" as const,
|
|
115
|
+
label: "OpenGeni",
|
|
116
|
+
description: "Uses OpenGeni credits through the managed Gateway route.",
|
|
117
|
+
available: input.managedConfigured,
|
|
118
|
+
unavailableReason: input.managedConfigured
|
|
119
|
+
? null
|
|
120
|
+
: "OpenGeni-managed video generation is not configured.",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
source: "workspace_gateway" as const,
|
|
124
|
+
label: "Your Gateway",
|
|
125
|
+
description: "Uses your workspace Vercel AI Gateway key.",
|
|
126
|
+
available: input.workspaceGatewayConfigured,
|
|
127
|
+
unavailableReason: input.workspaceGatewayConfigured
|
|
128
|
+
? null
|
|
129
|
+
: "Connect a workspace Vercel AI Gateway key first.",
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -19,14 +19,11 @@ import {
|
|
|
19
19
|
} from "@opengeni/contracts";
|
|
20
20
|
import {
|
|
21
21
|
allWorkspacePermissions,
|
|
22
|
-
countActiveSessionsForWorkspace,
|
|
23
|
-
countWorkspacesForAccount,
|
|
24
22
|
createWorkspace,
|
|
25
|
-
|
|
23
|
+
deleteWorkspaceIfQuiescent,
|
|
26
24
|
getManagedUserByEmail,
|
|
27
25
|
getWorkspaceModelPolicy,
|
|
28
26
|
grantWorkspaceAccess,
|
|
29
|
-
listScheduledTasks,
|
|
30
27
|
listWorkspaceMembers,
|
|
31
28
|
listWorkspaceControlEvents,
|
|
32
29
|
listWorkspacesForSubject,
|
|
@@ -47,7 +44,6 @@ import { hasPermission, requireAccessContext, requireAccessGrant } from "@openge
|
|
|
47
44
|
import { requireLimit } from "@opengeni/core";
|
|
48
45
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
49
46
|
import {
|
|
50
|
-
assertWorkspaceDeletable,
|
|
51
47
|
assertWorkspaceMemberRemovable,
|
|
52
48
|
controlHumanWorkspace,
|
|
53
49
|
resolveMemberSubjectId,
|
|
@@ -55,6 +51,7 @@ import {
|
|
|
55
51
|
import { boundedLimit } from "../http/common";
|
|
56
52
|
import { sseWorkspaceControlStream } from "../http/sse";
|
|
57
53
|
import { buildWorkspaceModelCatalog } from "../model-catalog";
|
|
54
|
+
import { processTemporalScheduleCleanupClaims } from "../temporal-schedule-cleanup";
|
|
58
55
|
import {
|
|
59
56
|
AI_GATEWAY_REALTIME_MODELS,
|
|
60
57
|
CODEX_REALTIME_MODEL_ID,
|
|
@@ -330,26 +327,51 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
330
327
|
app.delete("/v1/workspaces/:workspaceId", async (c) => {
|
|
331
328
|
const workspaceId = c.req.param("workspaceId");
|
|
332
329
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
)
|
|
330
|
+
// The DB transaction locks the account/workspace and every existing
|
|
331
|
+
// session/lease before checking runtime quiescence, then returns the exact
|
|
332
|
+
// external schedules removed by the cascade. A racing cold->warm transition
|
|
333
|
+
// can therefore never erase the only provider/capture ownership receipt.
|
|
334
|
+
const deleted = await deleteWorkspaceIfQuiescent(deps.db, {
|
|
335
|
+
accountId: grant.accountId,
|
|
336
|
+
workspaceId,
|
|
337
|
+
});
|
|
338
|
+
if (deleted.status === "not_found") {
|
|
339
|
+
throw new HTTPException(404, { message: "workspace not found" });
|
|
340
|
+
}
|
|
341
|
+
if (deleted.status === "only_workspace") {
|
|
342
|
+
throw new HTTPException(409, { message: "cannot delete the account's only workspace" });
|
|
343
|
+
}
|
|
344
|
+
if (deleted.status === "active_sessions") {
|
|
345
|
+
throw new HTTPException(409, {
|
|
346
|
+
message: "stop the workspace's running sessions before deleting it",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (deleted.status === "active_video_generations") {
|
|
350
|
+
throw new HTTPException(409, {
|
|
351
|
+
message: "wait for the workspace's active video generations to finish before deleting it",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if (deleted.status === "live_sandboxes") {
|
|
355
|
+
throw new HTTPException(409, {
|
|
356
|
+
message: "wait for the workspace's active sandboxes to finish draining before deleting it",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (deleted.status !== "deleted") {
|
|
360
|
+
throw new Error(`Unhandled workspace deletion outcome: ${deleted.status}`);
|
|
361
|
+
}
|
|
362
|
+
// The cleanup claims were inserted in the same transaction as the cascade.
|
|
363
|
+
// Try them immediately; failures are released to the replica-safe outbox
|
|
364
|
+
// pump, so a process crash or Temporal outage cannot orphan the schedules.
|
|
365
|
+
await processTemporalScheduleCleanupClaims(
|
|
366
|
+
{
|
|
367
|
+
db: deps.db,
|
|
368
|
+
deleteSchedule: async (temporalScheduleId) => {
|
|
369
|
+
await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId });
|
|
370
|
+
},
|
|
371
|
+
...(deps.observability ? { observability: deps.observability } : {}),
|
|
372
|
+
},
|
|
373
|
+
deleted.temporalScheduleCleanups,
|
|
351
374
|
);
|
|
352
|
-
await deleteWorkspace(deps.db, workspaceId);
|
|
353
375
|
return c.body(null, 204);
|
|
354
376
|
});
|
|
355
377
|
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// to, the `server_id` for the response `aud`, and the presented `auth_token`);
|
|
11
11
|
// 2. VALIDATES the bearer with verifyEnrollmentBearer (HMAC, via
|
|
12
12
|
// resolveEnrollmentSigningSecret) — an invalid/expired/forged bearer is denied;
|
|
13
|
-
// 3. confirms the enrollment is still ACTIVE in the DB
|
|
14
|
-
//
|
|
13
|
+
// 3. confirms the enrollment is still ACTIVE in the DB at the exact credential
|
|
14
|
+
// generation (a revoked or re-enrolled machine denies an old bearer);
|
|
15
15
|
// 4. signs a NATS user JWT granting pub/sub ONLY `agent.<ws>.>` + `_INBOX.>`
|
|
16
16
|
// (deny-all-else by an allow-list) and returns it inside a signed
|
|
17
17
|
// authorization-response JWT.
|
|
@@ -48,6 +48,8 @@ import { observabilityEventLogger } from "../observability";
|
|
|
48
48
|
|
|
49
49
|
/** The NATS subject nats-server publishes authorization requests on (ADR-26). */
|
|
50
50
|
export const AUTH_CALLOUT_SUBJECT = "$SYS.REQ.USER.AUTH";
|
|
51
|
+
/** Keep live NATS credentials short-lived while never outliving the bearer. */
|
|
52
|
+
export const NATS_USER_JWT_TTL_SECONDS = 5 * 60;
|
|
51
53
|
|
|
52
54
|
export interface AuthCalloutDeps {
|
|
53
55
|
db: Database;
|
|
@@ -123,13 +125,23 @@ export async function handleAuthorizationRequest(
|
|
|
123
125
|
// Belt-and-braces: the bearer's agentId/enrollmentId must match the row we found.
|
|
124
126
|
// (verifyEnrollmentBearer already binds them; this guards a future schema where
|
|
125
127
|
// agentId != enrollmentId.)
|
|
126
|
-
if (
|
|
128
|
+
if (
|
|
129
|
+
enrollment.workspaceId !== claims.workspaceId ||
|
|
130
|
+
enrollment.id !== claims.enrollmentId ||
|
|
131
|
+
enrollment.id !== claims.agentId ||
|
|
132
|
+
claims.agentId !== claims.enrollmentId ||
|
|
133
|
+
claims.subjectPrefix !== `agent.${claims.workspaceId}.${claims.agentId}`
|
|
134
|
+
) {
|
|
127
135
|
return deny("enrollment identity mismatch");
|
|
128
136
|
}
|
|
137
|
+
if (enrollment.credentialGeneration !== claims.credentialGeneration) {
|
|
138
|
+
return deny("enrollment credential generation mismatch");
|
|
139
|
+
}
|
|
129
140
|
|
|
130
141
|
// GRANT: a user JWT scoped to ONLY this workspace's agent subtree + the reply
|
|
131
142
|
// inbox. This allow-list IS the per-workspace isolation boundary.
|
|
132
143
|
const permissions = workspaceAgentPermissions(claims.workspaceId);
|
|
144
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
133
145
|
const userJwt = mintUserJwt({
|
|
134
146
|
userPublicKey: decoded.userNkey,
|
|
135
147
|
accountSeed: deps.callout.accountSeed,
|
|
@@ -142,7 +154,7 @@ export async function handleAuthorizationRequest(
|
|
|
142
154
|
audienceAccount: deps.callout.accountName,
|
|
143
155
|
// Tie the credential's life to the bearer's remaining life: a revoked/expired
|
|
144
156
|
// enrollment cannot outlive its bearer at the NATS layer either.
|
|
145
|
-
expiresAtSeconds: claims.exp,
|
|
157
|
+
expiresAtSeconds: Math.min(claims.exp, nowSeconds + NATS_USER_JWT_TTL_SECONDS),
|
|
146
158
|
});
|
|
147
159
|
const response = mintAuthResponse({
|
|
148
160
|
userPublicKey: decoded.userNkey,
|