@opengeni/api-router 0.5.2 → 0.5.4
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.js +1 -1
- package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
- package/dist/chunk-DO2G3JSB.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +297 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -21
- package/src/app.ts +415 -147
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +27 -6
- package/src/index.ts +196 -74
- package/src/integrations/oauth-client.ts +592 -131
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1517 -692
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +72 -34
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +839 -329
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +159 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-YY6OAEL6.js.map +0 -1
package/src/routes/social.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CreateSocialConnectionRequest,
|
|
3
|
-
CreateSocialPostRequest,
|
|
4
|
-
} from "@opengeni/contracts";
|
|
1
|
+
import { CreateSocialConnectionRequest, CreateSocialPostRequest } from "@opengeni/contracts";
|
|
5
2
|
import {
|
|
6
3
|
createSocialConnection,
|
|
7
4
|
createSocialPost,
|
|
@@ -29,19 +26,22 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
29
26
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
30
27
|
const payload = CreateSocialConnectionRequest.parse(await c.req.json());
|
|
31
28
|
try {
|
|
32
|
-
return c.json(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
return c.json(
|
|
30
|
+
await createSocialConnection(db, {
|
|
31
|
+
accountId: grant.accountId,
|
|
32
|
+
workspaceId,
|
|
33
|
+
provider: payload.provider,
|
|
34
|
+
accountHandle: payload.accountHandle,
|
|
35
|
+
accountName: payload.accountName ?? null,
|
|
36
|
+
externalAccountId: payload.externalAccountId ?? null,
|
|
37
|
+
status: payload.status,
|
|
38
|
+
scopes: payload.scopes,
|
|
39
|
+
credentialRef: payload.credentialRef ?? null,
|
|
40
|
+
tokenMetadata: payload.tokenMetadata,
|
|
41
|
+
metadata: payload.metadata,
|
|
42
|
+
}),
|
|
43
|
+
201,
|
|
44
|
+
);
|
|
45
45
|
} catch (error) {
|
|
46
46
|
throw socialHttpException(error);
|
|
47
47
|
}
|
|
@@ -51,13 +51,17 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
51
51
|
const workspaceId = c.req.param("workspaceId");
|
|
52
52
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
53
53
|
const since = parseSince(c.req.query("since"));
|
|
54
|
-
const connectionIds = parseConnectionIds(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
const connectionIds = parseConnectionIds(
|
|
55
|
+
c.req.query("connectionIds") ?? c.req.query("connectionId"),
|
|
56
|
+
);
|
|
57
|
+
return c.json(
|
|
58
|
+
await listSocialPosts(db, {
|
|
59
|
+
workspaceId,
|
|
60
|
+
...(connectionIds?.length ? { connectionIds } : {}),
|
|
61
|
+
...(since ? { since } : {}),
|
|
62
|
+
limit: boundedLimit(c.req.query("limit")),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
app.post("/v1/workspaces/:workspaceId/social/posts", async (c) => {
|
|
@@ -65,18 +69,21 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
65
69
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
66
70
|
const payload = CreateSocialPostRequest.parse(await c.req.json());
|
|
67
71
|
try {
|
|
68
|
-
return c.json(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
return c.json(
|
|
73
|
+
await createSocialPost(db, {
|
|
74
|
+
accountId: grant.accountId,
|
|
75
|
+
workspaceId,
|
|
76
|
+
connectionId: payload.connectionId,
|
|
77
|
+
externalPostId: payload.externalPostId ?? null,
|
|
78
|
+
url: payload.url ?? null,
|
|
79
|
+
authorHandle: payload.authorHandle ?? null,
|
|
80
|
+
text: payload.text,
|
|
81
|
+
publishedAt: new Date(payload.publishedAt),
|
|
82
|
+
metrics: payload.metrics,
|
|
83
|
+
raw: payload.raw,
|
|
84
|
+
}),
|
|
85
|
+
201,
|
|
86
|
+
);
|
|
80
87
|
} catch (error) {
|
|
81
88
|
throw socialHttpException(error);
|
|
82
89
|
}
|
|
@@ -98,10 +105,15 @@ function parseConnectionIds(raw: string | undefined): string[] | undefined {
|
|
|
98
105
|
if (!raw) {
|
|
99
106
|
return undefined;
|
|
100
107
|
}
|
|
101
|
-
const values = raw
|
|
108
|
+
const values = raw
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((value) => value.trim())
|
|
111
|
+
.filter(Boolean);
|
|
102
112
|
const parsed = z.array(z.string().uuid()).safeParse(values);
|
|
103
113
|
if (!parsed.success) {
|
|
104
|
-
throw new HTTPException(422, {
|
|
114
|
+
throw new HTTPException(422, {
|
|
115
|
+
message: "connectionIds must be a comma-separated list of UUIDs",
|
|
116
|
+
});
|
|
105
117
|
}
|
|
106
118
|
const ids = parsed.data;
|
|
107
119
|
return [...new Set(ids)];
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Workbench v2 — capture READ serving (dossier §10.3).
|
|
2
|
+
//
|
|
3
|
+
// The two GET capture routes in `sessions.ts` are thin: grant-first (files:read),
|
|
4
|
+
// load the row (DB, RLS-scoped), then delegate the response SHAPING to the two
|
|
5
|
+
// pure functions here. Keeping the shaping decoupled from Hono + the DB lets the
|
|
6
|
+
// hermetic route tests exercise every branch ({available:false}, inline-vs-signed
|
|
7
|
+
// manifest, file resolve/marker/404) with in-memory fakes — no live stack.
|
|
8
|
+
//
|
|
9
|
+
// These functions NEVER touch a live sandbox box: a capture is served entirely
|
|
10
|
+
// from the durable `workspace_captures` row + its object-storage blobs. That is
|
|
11
|
+
// the whole point — the <200ms cold paint must not depend on a warm machine.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
GetWorkspaceCaptureFileResponse,
|
|
15
|
+
GetWorkspaceCaptureResponse,
|
|
16
|
+
WorkspaceCaptureDegradedReason,
|
|
17
|
+
WorkspaceCaptureManifest,
|
|
18
|
+
WorkspaceCaptureStats,
|
|
19
|
+
} from "@opengeni/contracts";
|
|
20
|
+
import type { WorkspaceCaptureRow } from "@opengeni/db";
|
|
21
|
+
import { HTTPException } from "hono/http-exception";
|
|
22
|
+
|
|
23
|
+
// Serve the manifest inline below this size (the overwhelmingly common case —
|
|
24
|
+
// the one API round-trip requirement); above it, a signed GET URL to the blob.
|
|
25
|
+
export const CAPTURE_INLINE_MANIFEST_MAX_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
// Serve a single after-image inline below this size; above it, a signed GET URL.
|
|
27
|
+
export const CAPTURE_INLINE_FILE_MAX_BYTES = 256 * 1024;
|
|
28
|
+
// Short-lived — the client fetches immediately after the metadata response.
|
|
29
|
+
export const CAPTURE_SIGNED_URL_TTL_SECONDS = 300;
|
|
30
|
+
|
|
31
|
+
// The slice of ObjectStorage the serving path needs. Structural so the tests can
|
|
32
|
+
// inject an in-memory map without standing up S3/minio.
|
|
33
|
+
export type CaptureStoragePort = {
|
|
34
|
+
getObjectBytes: (key: string) => Promise<{ bytes: Uint8Array } | null>;
|
|
35
|
+
createGetUrl: (args: {
|
|
36
|
+
key: string;
|
|
37
|
+
expiresInSeconds?: number;
|
|
38
|
+
}) => Promise<{ url: string; expiresAt: Date }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function signedUrl(signed: { url: string; expiresAt: Date }): { url: string; expiresAt: string } {
|
|
42
|
+
return { url: signed.url, expiresAt: signed.expiresAt.toISOString() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fetch + validate the manifest blob for a row. Returns null when the row has no
|
|
46
|
+
// manifest key, the blob is gone (GC'd), or the bytes fail to parse/validate — a
|
|
47
|
+
// malformed capture is treated as "no capture available" (the list route degrades
|
|
48
|
+
// to {available:false}, the file route to 404). Capture reads must NEVER be worse
|
|
49
|
+
// than the status-quo live/wake fallback (dossier §10.10), so a poison row can
|
|
50
|
+
// never 500 the workbench; it degrades and logs.
|
|
51
|
+
async function loadManifest(
|
|
52
|
+
row: WorkspaceCaptureRow,
|
|
53
|
+
storage: CaptureStoragePort,
|
|
54
|
+
): Promise<{ manifest: WorkspaceCaptureManifest; byteLength: number } | null> {
|
|
55
|
+
if (!row.manifestKey) return null;
|
|
56
|
+
const blob = await storage.getObjectBytes(row.manifestKey);
|
|
57
|
+
if (!blob) return null;
|
|
58
|
+
let json: unknown;
|
|
59
|
+
try {
|
|
60
|
+
json = JSON.parse(new TextDecoder().decode(blob.bytes));
|
|
61
|
+
} catch {
|
|
62
|
+
console.warn(
|
|
63
|
+
`workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
|
|
64
|
+
);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const parsed = WorkspaceCaptureManifest.safeParse(json);
|
|
68
|
+
if (!parsed.success) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
|
|
71
|
+
);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return { manifest: parsed.data, byteLength: blob.bytes.byteLength };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Shape the GET …/workspace/capture response from a loaded row. `{available:false}`
|
|
79
|
+
* when there is no capture yet, the row is not in the `available` state, or its
|
|
80
|
+
* manifest blob has been GC'd (all graceful cold-fallback states — never errors).
|
|
81
|
+
* Inline manifest for ≤2MB, signed URL above.
|
|
82
|
+
*/
|
|
83
|
+
export async function serveWorkspaceCapture(
|
|
84
|
+
row: WorkspaceCaptureRow | null,
|
|
85
|
+
storage: CaptureStoragePort,
|
|
86
|
+
): Promise<GetWorkspaceCaptureResponse> {
|
|
87
|
+
if (!row) {
|
|
88
|
+
return { available: false };
|
|
89
|
+
}
|
|
90
|
+
if (row.state === "failed") {
|
|
91
|
+
const reason = WorkspaceCaptureDegradedReason.safeParse(row.stats.degradedReason);
|
|
92
|
+
if (!reason.success) {
|
|
93
|
+
// `failed` was reserved before repository-discovery markers existed. Do
|
|
94
|
+
// not invent a cause for an older or malformed row; plain unavailable is
|
|
95
|
+
// the only truthful backwards-compatible response.
|
|
96
|
+
return { available: false };
|
|
97
|
+
}
|
|
98
|
+
return GetWorkspaceCaptureResponse.parse({
|
|
99
|
+
available: false,
|
|
100
|
+
degradedReason: reason.data,
|
|
101
|
+
revision: row.revision,
|
|
102
|
+
capturedAt: row.capturedAt,
|
|
103
|
+
turnId: row.turnId,
|
|
104
|
+
leaseEpoch: row.leaseEpoch,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (row.state !== "available" || !row.manifestKey) return { available: false };
|
|
108
|
+
const stats = WorkspaceCaptureStats.safeParse(row.stats);
|
|
109
|
+
if (!stats.success) {
|
|
110
|
+
// A row with malformed stats (or a synthetic/partial row) degrades to the
|
|
111
|
+
// cold-fallback state rather than 500.
|
|
112
|
+
console.warn(
|
|
113
|
+
`workspace capture read — row stats failed schema validation (session=${row.sessionId} rev=${row.revision})`,
|
|
114
|
+
);
|
|
115
|
+
return { available: false };
|
|
116
|
+
}
|
|
117
|
+
const meta = {
|
|
118
|
+
available: true as const,
|
|
119
|
+
revision: row.revision,
|
|
120
|
+
capturedAt: row.capturedAt,
|
|
121
|
+
turnId: row.turnId,
|
|
122
|
+
leaseEpoch: row.leaseEpoch,
|
|
123
|
+
sizeBytes: row.sizeBytes ?? 0,
|
|
124
|
+
stats: stats.data,
|
|
125
|
+
};
|
|
126
|
+
const blob = await storage.getObjectBytes(row.manifestKey);
|
|
127
|
+
if (!blob) {
|
|
128
|
+
// Manifest raced GC between the row read and the blob fetch — degrade to the
|
|
129
|
+
// cold-fallback state rather than 500.
|
|
130
|
+
return { available: false };
|
|
131
|
+
}
|
|
132
|
+
if (blob.bytes.byteLength <= CAPTURE_INLINE_MANIFEST_MAX_BYTES) {
|
|
133
|
+
let json: unknown;
|
|
134
|
+
try {
|
|
135
|
+
json = JSON.parse(new TextDecoder().decode(blob.bytes));
|
|
136
|
+
} catch {
|
|
137
|
+
console.warn(
|
|
138
|
+
`workspace capture read — manifest blob is not valid JSON (session=${row.sessionId} rev=${row.revision})`,
|
|
139
|
+
);
|
|
140
|
+
return { available: false };
|
|
141
|
+
}
|
|
142
|
+
const manifest = WorkspaceCaptureManifest.safeParse(json);
|
|
143
|
+
if (!manifest.success) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`workspace capture read — manifest failed schema validation (session=${row.sessionId} rev=${row.revision})`,
|
|
146
|
+
);
|
|
147
|
+
return { available: false };
|
|
148
|
+
}
|
|
149
|
+
return GetWorkspaceCaptureResponse.parse({
|
|
150
|
+
...meta,
|
|
151
|
+
manifest: manifest.data,
|
|
152
|
+
manifestUrl: null,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const signed = await storage.createGetUrl({
|
|
156
|
+
key: row.manifestKey,
|
|
157
|
+
expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
|
|
158
|
+
});
|
|
159
|
+
return GetWorkspaceCaptureResponse.parse({
|
|
160
|
+
...meta,
|
|
161
|
+
manifest: null,
|
|
162
|
+
manifestUrl: signedUrl(signed),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Shape the GET …/workspace/capture/file response from a loaded row (the row
|
|
168
|
+
* already resolved to the requested revision, or the latest). Throws
|
|
169
|
+
* HTTPException(404) when there is no capture, the path is not in the manifest,
|
|
170
|
+
* or the file was deleted. Returns a metadata-only marker for a tooLarge file (or
|
|
171
|
+
* a captured file whose after-image blob is missing). Inline content for ≤256KB,
|
|
172
|
+
* signed URL above.
|
|
173
|
+
*/
|
|
174
|
+
export async function serveWorkspaceCaptureFile(
|
|
175
|
+
row: WorkspaceCaptureRow | null,
|
|
176
|
+
path: string,
|
|
177
|
+
storage: CaptureStoragePort,
|
|
178
|
+
): Promise<GetWorkspaceCaptureFileResponse> {
|
|
179
|
+
const loaded = row ? await loadManifest(row, storage) : null;
|
|
180
|
+
if (!loaded) {
|
|
181
|
+
throw new HTTPException(404, { message: "capture not found" });
|
|
182
|
+
}
|
|
183
|
+
const { manifest } = loaded;
|
|
184
|
+
const file = manifest.files.find((f) => f.path === path);
|
|
185
|
+
if (!file) {
|
|
186
|
+
throw new HTTPException(404, { message: "path not in capture" });
|
|
187
|
+
}
|
|
188
|
+
if (file.deleted) {
|
|
189
|
+
// Parity with fs/read on a deleted path.
|
|
190
|
+
throw new HTTPException(404, { message: "file was deleted" });
|
|
191
|
+
}
|
|
192
|
+
const base = {
|
|
193
|
+
path: file.path,
|
|
194
|
+
revision: manifest.revision,
|
|
195
|
+
status: file.status,
|
|
196
|
+
hash: file.hash,
|
|
197
|
+
baseHash: file.baseHash,
|
|
198
|
+
sizeBytes: file.sizeBytes,
|
|
199
|
+
isBinary: file.isBinary,
|
|
200
|
+
tooLarge: file.tooLarge,
|
|
201
|
+
};
|
|
202
|
+
if (file.tooLarge || !file.contentRef) {
|
|
203
|
+
// Marker: content was not captured (guard tripped) or the blob is unavailable.
|
|
204
|
+
return GetWorkspaceCaptureFileResponse.parse({
|
|
205
|
+
...base,
|
|
206
|
+
encoding: null,
|
|
207
|
+
content: null,
|
|
208
|
+
contentUrl: null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (file.sizeBytes <= CAPTURE_INLINE_FILE_MAX_BYTES) {
|
|
212
|
+
const blob = await storage.getObjectBytes(file.contentRef);
|
|
213
|
+
if (!blob) {
|
|
214
|
+
// After-image GC'd out from under us → return the marker (client opens live).
|
|
215
|
+
return GetWorkspaceCaptureFileResponse.parse({
|
|
216
|
+
...base,
|
|
217
|
+
encoding: null,
|
|
218
|
+
content: null,
|
|
219
|
+
contentUrl: null,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const encoding = file.isBinary ? "base64" : "utf8";
|
|
223
|
+
const content = file.isBinary
|
|
224
|
+
? Buffer.from(blob.bytes).toString("base64")
|
|
225
|
+
: new TextDecoder().decode(blob.bytes);
|
|
226
|
+
return GetWorkspaceCaptureFileResponse.parse({ ...base, encoding, content, contentUrl: null });
|
|
227
|
+
}
|
|
228
|
+
const signed = await storage.createGetUrl({
|
|
229
|
+
key: file.contentRef,
|
|
230
|
+
expiresInSeconds: CAPTURE_SIGNED_URL_TTL_SECONDS,
|
|
231
|
+
});
|
|
232
|
+
return GetWorkspaceCaptureFileResponse.parse({
|
|
233
|
+
...base,
|
|
234
|
+
encoding: null,
|
|
235
|
+
content: null,
|
|
236
|
+
contentUrl: signedUrl(signed),
|
|
237
|
+
});
|
|
238
|
+
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -2,8 +2,12 @@ import {
|
|
|
2
2
|
AddWorkspaceMemberRequest,
|
|
3
3
|
CreateWorkspaceRequest,
|
|
4
4
|
ListWorkspaceMembersResponse,
|
|
5
|
+
SetWorkspaceDefaultRigRequest,
|
|
5
6
|
UpdateWorkspaceMemberRequest,
|
|
7
|
+
UpdateWorkspaceModelPolicyRequest,
|
|
6
8
|
UpdateWorkspaceRequest,
|
|
9
|
+
UpdateWorkspaceSettingsRequest,
|
|
10
|
+
WorkspaceInferenceControlRequest,
|
|
7
11
|
Workspace,
|
|
8
12
|
WorkspaceMember,
|
|
9
13
|
type AccessContext,
|
|
@@ -16,20 +20,30 @@ import {
|
|
|
16
20
|
createWorkspace,
|
|
17
21
|
deleteWorkspace,
|
|
18
22
|
getManagedUserByEmail,
|
|
23
|
+
getWorkspaceModelPolicy,
|
|
19
24
|
grantWorkspaceAccess,
|
|
20
25
|
listScheduledTasks,
|
|
21
26
|
listWorkspaceMembers,
|
|
22
27
|
listWorkspacesForSubject,
|
|
23
28
|
removeWorkspaceMember,
|
|
24
29
|
requireWorkspace,
|
|
30
|
+
getRig,
|
|
31
|
+
setWorkspaceDefaultRig,
|
|
25
32
|
updateWorkspace,
|
|
33
|
+
updateWorkspaceSettings,
|
|
34
|
+
upsertWorkspaceModelPolicy,
|
|
35
|
+
setWorkspaceInferenceControl,
|
|
26
36
|
} from "@opengeni/db";
|
|
27
37
|
import type { Hono } from "hono";
|
|
28
38
|
import { HTTPException } from "hono/http-exception";
|
|
29
39
|
import { hasPermission, requireAccessContext, requireAccessGrant } from "@opengeni/core";
|
|
30
40
|
import { requireLimit } from "@opengeni/core";
|
|
31
41
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
32
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
assertWorkspaceDeletable,
|
|
44
|
+
assertWorkspaceMemberRemovable,
|
|
45
|
+
resolveMemberSubjectId,
|
|
46
|
+
} from "@opengeni/core";
|
|
33
47
|
|
|
34
48
|
export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
35
49
|
app.get("/v1/access/me", async (c) => {
|
|
@@ -38,14 +52,24 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
38
52
|
|
|
39
53
|
app.get("/v1/workspaces", async (c) => {
|
|
40
54
|
const context = await requireAccessContext(c, deps);
|
|
41
|
-
const readableWorkspaceIds = [
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
const readableWorkspaceIds = [
|
|
56
|
+
...new Set(
|
|
57
|
+
context.workspaceGrants
|
|
58
|
+
.filter((grant) => hasPermission(grant.permissions, "workspace:read"))
|
|
59
|
+
.map((grant) => grant.workspaceId),
|
|
60
|
+
),
|
|
61
|
+
];
|
|
44
62
|
if (readableWorkspaceIds.length > 0) {
|
|
45
|
-
const workspaces = await Promise.all(
|
|
63
|
+
const workspaces = await Promise.all(
|
|
64
|
+
readableWorkspaceIds.map((workspaceId) => requireWorkspace(deps.db, workspaceId)),
|
|
65
|
+
);
|
|
46
66
|
return c.json(workspaces.map((workspace) => Workspace.parse(workspace)));
|
|
47
67
|
}
|
|
48
|
-
return c.json(
|
|
68
|
+
return c.json(
|
|
69
|
+
(await listWorkspacesForSubject(deps.db, context.subjectId)).map((workspace) =>
|
|
70
|
+
Workspace.parse(workspace),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
49
73
|
});
|
|
50
74
|
|
|
51
75
|
app.post("/v1/workspaces", async (c) => {
|
|
@@ -63,7 +87,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
63
87
|
slug: payload.slug?.trim() || null,
|
|
64
88
|
externalSource: payload.externalSource ?? null,
|
|
65
89
|
externalId: payload.externalId ?? null,
|
|
66
|
-
...(payload.agentInstructions !== undefined
|
|
90
|
+
...(payload.agentInstructions !== undefined
|
|
91
|
+
? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
|
|
92
|
+
: {}),
|
|
67
93
|
});
|
|
68
94
|
await grantWorkspaceAccess(deps.db, {
|
|
69
95
|
accountId,
|
|
@@ -89,8 +115,117 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
89
115
|
const workspace = await updateWorkspace(deps.db, workspaceId, {
|
|
90
116
|
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
|
|
91
117
|
...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
|
|
92
|
-
...(payload.agentInstructions !== undefined
|
|
118
|
+
...(payload.agentInstructions !== undefined
|
|
119
|
+
? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
return c.json(Workspace.parse(workspace));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Read is via GET /v1/workspaces/:workspaceId (Workspace.settings). This PATCH
|
|
126
|
+
// deep-merges (top-level) a settings patch, preserving unknown/future keys.
|
|
127
|
+
app.patch("/v1/workspaces/:workspaceId/settings", async (c) => {
|
|
128
|
+
const workspaceId = c.req.param("workspaceId");
|
|
129
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
130
|
+
const parsed = UpdateWorkspaceSettingsRequest.safeParse(await c.req.json());
|
|
131
|
+
if (!parsed.success) {
|
|
132
|
+
throw new HTTPException(400, { message: "invalid workspace settings patch" });
|
|
133
|
+
}
|
|
134
|
+
const workspace = await updateWorkspaceSettings(deps.db, workspaceId, parsed.data);
|
|
135
|
+
return c.json(Workspace.parse(workspace));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Per-workspace model/provider availability policy (the HARD blocker over
|
|
139
|
+
// which providers/models may serve a turn at all). Absent row reads as
|
|
140
|
+
// unrestricted {null, null}.
|
|
141
|
+
app.get("/v1/workspaces/:workspaceId/model-policy", async (c) => {
|
|
142
|
+
const workspaceId = c.req.param("workspaceId");
|
|
143
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
144
|
+
const policy = await getWorkspaceModelPolicy(deps.db, workspaceId);
|
|
145
|
+
return c.json({
|
|
146
|
+
allowedProviders: policy?.allowedProviders ?? null,
|
|
147
|
+
allowedModels: policy?.allowedModels ?? null,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Full replace (PUT, not merge): null/omitted = unrestricted for that
|
|
152
|
+
// dimension; an empty array is a valid explicit total block. Admin access —
|
|
153
|
+
// this decides whether turns can reach paid providers, so it is the same
|
|
154
|
+
// trust level as billing-affecting workspace settings.
|
|
155
|
+
app.put("/v1/workspaces/:workspaceId/model-policy", async (c) => {
|
|
156
|
+
const workspaceId = c.req.param("workspaceId");
|
|
157
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
158
|
+
const payload = UpdateWorkspaceModelPolicyRequest.parse(await c.req.json());
|
|
159
|
+
const policy = await upsertWorkspaceModelPolicy(deps.db, {
|
|
160
|
+
accountId: grant.accountId,
|
|
161
|
+
workspaceId,
|
|
162
|
+
allowedProviders: payload.allowedProviders ?? null,
|
|
163
|
+
allowedModels: payload.allowedModels ?? null,
|
|
93
164
|
});
|
|
165
|
+
return c.json(policy);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.post("/v1/workspaces/:workspaceId/inference-control", async (c) => {
|
|
169
|
+
const workspaceId = c.req.param("workspaceId");
|
|
170
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
171
|
+
const payload = WorkspaceInferenceControlRequest.parse(await c.req.json());
|
|
172
|
+
const result = await setWorkspaceInferenceControl(deps.db, {
|
|
173
|
+
accountId: grant.accountId,
|
|
174
|
+
workspaceId,
|
|
175
|
+
actor: grant.subjectId,
|
|
176
|
+
state: payload.state,
|
|
177
|
+
reason: payload.reason,
|
|
178
|
+
clientEventId: payload.clientEventId,
|
|
179
|
+
expectedState: payload.expectedState,
|
|
180
|
+
expectedGeneration: payload.expectedGeneration,
|
|
181
|
+
exceptSessionIds: payload.exceptSessionIds,
|
|
182
|
+
});
|
|
183
|
+
for (const broadcast of result.broadcasts) {
|
|
184
|
+
await deps.bus.publish(workspaceId, broadcast.sessionId, broadcast.events);
|
|
185
|
+
}
|
|
186
|
+
for (const control of result.controls) {
|
|
187
|
+
await deps.workflowClient.signalSessionControl({
|
|
188
|
+
accountId: control.accountId,
|
|
189
|
+
workspaceId,
|
|
190
|
+
sessionId: control.sessionId,
|
|
191
|
+
eventId: control.eventId,
|
|
192
|
+
workflowId: control.workflowId,
|
|
193
|
+
workflowWakeRevision: control.workflowWakeRevision,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
for (const wake of result.wakeSessions) {
|
|
197
|
+
await deps.workflowClient.wakeSessionWorkflow({
|
|
198
|
+
accountId: wake.accountId,
|
|
199
|
+
workspaceId,
|
|
200
|
+
sessionId: wake.sessionId,
|
|
201
|
+
workflowId: wake.workflowId,
|
|
202
|
+
wakeRevision: wake.workflowWakeRevision,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return c.json(
|
|
206
|
+
{
|
|
207
|
+
operationId: result.operationId,
|
|
208
|
+
state: result.state,
|
|
209
|
+
generation: result.generation,
|
|
210
|
+
affectedSessionIds: result.affectedSessionIds,
|
|
211
|
+
controlSessionIds: result.controls.map((entry) => entry.sessionId),
|
|
212
|
+
exceptionSessionIds: result.exceptionSessionIds,
|
|
213
|
+
},
|
|
214
|
+
202,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
app.put("/v1/workspaces/:workspaceId/default-rig", async (c) => {
|
|
219
|
+
const workspaceId = c.req.param("workspaceId");
|
|
220
|
+
await requireAccessGrant(c, deps, workspaceId, "rigs:manage");
|
|
221
|
+
const payload = SetWorkspaceDefaultRigRequest.parse(await c.req.json());
|
|
222
|
+
if (payload.rigId) {
|
|
223
|
+
const rig = await getRig(deps.db, workspaceId, payload.rigId);
|
|
224
|
+
if (!rig) {
|
|
225
|
+
throw new HTTPException(422, { message: `unknown rigId: ${payload.rigId}` });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const workspace = await setWorkspaceDefaultRig(deps.db, workspaceId, payload.rigId);
|
|
94
229
|
return c.json(Workspace.parse(workspace));
|
|
95
230
|
});
|
|
96
231
|
|
|
@@ -109,9 +244,13 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
109
244
|
// Clean external Temporal state the FK cascade can't reach: every scheduled
|
|
110
245
|
// task's schedule (best-effort, mirroring the scheduled-task delete path).
|
|
111
246
|
const tasks = await listScheduledTasks(deps.db, workspaceId, 1000);
|
|
112
|
-
await Promise.all(
|
|
113
|
-
|
|
114
|
-
|
|
247
|
+
await Promise.all(
|
|
248
|
+
tasks.map((task) =>
|
|
249
|
+
deps.workflowClient
|
|
250
|
+
.deleteScheduledTaskSchedule({ temporalScheduleId: task.temporalScheduleId })
|
|
251
|
+
.catch(() => undefined),
|
|
252
|
+
),
|
|
253
|
+
);
|
|
115
254
|
await deleteWorkspace(deps.db, workspaceId);
|
|
116
255
|
return c.body(null, 204);
|
|
117
256
|
});
|
|
@@ -198,9 +337,16 @@ function normalizeAgentInstructions(value: string | null): string | null {
|
|
|
198
337
|
return trimmed.length > 0 ? trimmed : null;
|
|
199
338
|
}
|
|
200
339
|
|
|
201
|
-
function requireAccountPermission(
|
|
340
|
+
function requireAccountPermission(
|
|
341
|
+
context: AccessContext,
|
|
342
|
+
accountId: string,
|
|
343
|
+
permission: Permission,
|
|
344
|
+
): void {
|
|
202
345
|
const grant = context.accountGrants.find((candidate) => candidate.accountId === accountId);
|
|
203
|
-
if (
|
|
346
|
+
if (
|
|
347
|
+
!grant ||
|
|
348
|
+
(!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))
|
|
349
|
+
) {
|
|
204
350
|
throw new HTTPException(403, { message: `missing permission: ${permission}` });
|
|
205
351
|
}
|
|
206
352
|
}
|
package/src/sandbox/access.ts
CHANGED
|
@@ -34,7 +34,10 @@ import type {
|
|
|
34
34
|
} from "@opengeni/core";
|
|
35
35
|
|
|
36
36
|
export class SandboxResumeError extends Error {
|
|
37
|
-
constructor(
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
readonly cause?: unknown,
|
|
40
|
+
) {
|
|
38
41
|
super(message);
|
|
39
42
|
this.name = "SandboxResumeError";
|
|
40
43
|
}
|
|
@@ -57,7 +60,9 @@ export function createApiSandboxClient(settings: Settings): ApiSandboxClient | u
|
|
|
57
60
|
* a live session for one in-process op. The caller drives exec/readFile and then
|
|
58
61
|
* drops the handle (resume → use → drop); it does NOT own the box.
|
|
59
62
|
*/
|
|
60
|
-
export function makeResumeBoxById(
|
|
63
|
+
export function makeResumeBoxById(
|
|
64
|
+
client: ApiSandboxClient | undefined,
|
|
65
|
+
): (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession> {
|
|
61
66
|
return async ({ backend, resumeState }: ResumeBoxByIdInput): Promise<ResumedSandboxSession> => {
|
|
62
67
|
if (!client) {
|
|
63
68
|
throw new SandboxResumeError(
|
|
@@ -77,7 +82,10 @@ export function makeResumeBoxById(client: ApiSandboxClient | undefined): (input:
|
|
|
77
82
|
let session: ApiSandboxSession;
|
|
78
83
|
try {
|
|
79
84
|
const state = await client.deserializeSessionState(resumeState);
|
|
80
|
-
|
|
85
|
+
// API-direct access borrows the live box. The lease remains its sole
|
|
86
|
+
// lifecycle owner, even when the serialized founding handle was owned.
|
|
87
|
+
// Clone instead of mutating the canonical resume envelope.
|
|
88
|
+
session = await client.resume({ ...state, ownsSandbox: false });
|
|
81
89
|
} catch (error) {
|
|
82
90
|
throw new SandboxResumeError(
|
|
83
91
|
`Failed to resume sandbox box by id on backend "${backend}": ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
// on its callout timeout). The bearer's `exp` caps the minted credential's life so a
|
|
29
29
|
// revoked/expired enrollment cannot outlive its bearer.
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
resolveEnrollmentSigningSecret,
|
|
33
|
+
type NatsCalloutConfig,
|
|
34
|
+
type Settings,
|
|
35
|
+
} from "@opengeni/config";
|
|
32
36
|
import { verifyEnrollmentBearer } from "@opengeni/contracts";
|
|
33
37
|
import { getEnrollment, type Database } from "@opengeni/db";
|
|
34
38
|
import {
|