@opengeni/api-router 0.2.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/dist/app.d.ts +16 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/chunk-XSYUDIX3.js +6331 -0
- package/dist/chunk-XSYUDIX3.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +567 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/app.ts +351 -0
- package/src/auth/managed-auth.ts +237 -0
- package/src/http/auth.ts +92 -0
- package/src/http/common.ts +16 -0
- package/src/http/sse.ts +89 -0
- package/src/index.ts +362 -0
- package/src/mcp/documents.ts +57 -0
- package/src/mcp/server.ts +961 -0
- package/src/mcp/session-view.ts +281 -0
- package/src/routes/api-keys.ts +65 -0
- package/src/routes/billing.ts +495 -0
- package/src/routes/capabilities.ts +80 -0
- package/src/routes/codex.ts +393 -0
- package/src/routes/documents.ts +185 -0
- package/src/routes/enrollments.ts +357 -0
- package/src/routes/environments.ts +175 -0
- package/src/routes/files.ts +148 -0
- package/src/routes/github.ts +341 -0
- package/src/routes/install.ts +218 -0
- package/src/routes/machines.ts +107 -0
- package/src/routes/packs.ts +241 -0
- package/src/routes/scheduled-tasks.ts +126 -0
- package/src/routes/sessions.ts +1083 -0
- package/src/routes/social.ts +119 -0
- package/src/routes/workspaces.ts +206 -0
- package/src/sandbox/access.ts +89 -0
- package/src/sandbox/auth-callout.ts +178 -0
- package/src/sandbox/channel-a.ts +265 -0
- package/src/sandbox/enrollment.ts +498 -0
- package/src/sandbox/machines.ts +255 -0
- package/src/sandbox/metrics-ingestion.ts +289 -0
- package/src/sandbox/viewer.ts +993 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { GitHubAppManifestCreate } from "@opengeni/contracts";
|
|
2
|
+
import {
|
|
3
|
+
listGitHubInstallationIdsForWorkspace,
|
|
4
|
+
upsertGitHubInstallation,
|
|
5
|
+
} from "@opengeni/db";
|
|
6
|
+
import {
|
|
7
|
+
buildGitHubAppManifest,
|
|
8
|
+
convertGitHubAppManifest,
|
|
9
|
+
createSignedState,
|
|
10
|
+
envLinesFromGitHubManifestConversion,
|
|
11
|
+
GitHubAppApiError,
|
|
12
|
+
GitHubAppConfigurationError,
|
|
13
|
+
githubOAuthAuthorizeUrl,
|
|
14
|
+
githubAppMissingSettings,
|
|
15
|
+
listGitHubAppRepositories,
|
|
16
|
+
organizationAppManifestUrl,
|
|
17
|
+
personalAppManifestUrl,
|
|
18
|
+
readSignedState,
|
|
19
|
+
stateMaxAgeSeconds,
|
|
20
|
+
verifyGitHubInstallationAccessForUser,
|
|
21
|
+
verifySignedState,
|
|
22
|
+
} from "@opengeni/github";
|
|
23
|
+
import type { Context, Hono } from "hono";
|
|
24
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
25
|
+
import { HTTPException } from "hono/http-exception";
|
|
26
|
+
import { requireAccessGrant } from "@opengeni/core";
|
|
27
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
28
|
+
|
|
29
|
+
const githubStateCookie = "opengeni_github_state";
|
|
30
|
+
|
|
31
|
+
export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
32
|
+
const { db, settings, githubStateSecret } = deps;
|
|
33
|
+
|
|
34
|
+
app.get("/v1/workspaces/:workspaceId/github/app", async (c) => {
|
|
35
|
+
const workspaceId = c.req.param("workspaceId");
|
|
36
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
37
|
+
const missing = githubAppMissingSettings(settings);
|
|
38
|
+
const slug = settings.githubAppSlug?.trim() || null;
|
|
39
|
+
const state = createSignedState(githubStateSecret, {
|
|
40
|
+
accountId: grant.accountId,
|
|
41
|
+
workspaceId: grant.workspaceId,
|
|
42
|
+
});
|
|
43
|
+
setGitHubStateCookie(c, deps, state);
|
|
44
|
+
return c.json({
|
|
45
|
+
configured: missing.length === 0,
|
|
46
|
+
appId: settings.githubAppId ?? null,
|
|
47
|
+
clientId: settings.githubClientId ?? null,
|
|
48
|
+
appSlug: slug,
|
|
49
|
+
installUrl: slug ? `https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}` : null,
|
|
50
|
+
missing,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Browser entry point for install links issued outside a browser context
|
|
55
|
+
// (the first-party MCP github_connect_link tool): it plants the CSRF state
|
|
56
|
+
// cookie the install/OAuth callbacks require and forwards to GitHub.
|
|
57
|
+
// Deliberately unauthenticated: the signed state is only ever minted for
|
|
58
|
+
// grants holding github:use, expires after stateMaxAgeSeconds, and is bound
|
|
59
|
+
// to this workspace; completing the installation binding still requires an
|
|
60
|
+
// authenticated github:manage grant in the same browser at the callback.
|
|
61
|
+
app.get("/v1/workspaces/:workspaceId/github/connect", async (c) => {
|
|
62
|
+
const workspaceId = c.req.param("workspaceId");
|
|
63
|
+
const state = c.req.query("state");
|
|
64
|
+
if (!state) {
|
|
65
|
+
throw new HTTPException(400, { message: "missing GitHub installation state" });
|
|
66
|
+
}
|
|
67
|
+
const statePayload = readSignedState(state, githubStateSecret);
|
|
68
|
+
if (!statePayload || statePayload.workspaceId !== workspaceId) {
|
|
69
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
70
|
+
}
|
|
71
|
+
const slug = settings.githubAppSlug?.trim();
|
|
72
|
+
if (!slug) {
|
|
73
|
+
throw new HTTPException(409, { message: JSON.stringify({ message: "GitHub App is not configured", missing: githubAppMissingSettings(settings) }) });
|
|
74
|
+
}
|
|
75
|
+
setGitHubStateCookie(c, deps, state);
|
|
76
|
+
return c.redirect(`https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.get("/v1/workspaces/:workspaceId/github/repositories", async (c) => {
|
|
80
|
+
const workspaceId = c.req.param("workspaceId");
|
|
81
|
+
await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
82
|
+
try {
|
|
83
|
+
return c.json({ repositories: await listWorkspaceGitHubRepositories(deps, workspaceId) });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
86
|
+
throw new HTTPException(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
87
|
+
}
|
|
88
|
+
throw new HTTPException(502, { message: error instanceof Error ? error.message : String(error) });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
app.post("/v1/workspaces/:workspaceId/github/repositories/sync", async (c) => {
|
|
93
|
+
const workspaceId = c.req.param("workspaceId");
|
|
94
|
+
await requireAccessGrant(c, deps, workspaceId, "github:use");
|
|
95
|
+
try {
|
|
96
|
+
return c.json({ repositories: await listWorkspaceGitHubRepositories(deps, workspaceId) });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
99
|
+
throw new HTTPException(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
100
|
+
}
|
|
101
|
+
throw new HTTPException(502, { message: error instanceof Error ? error.message : String(error) });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.post("/v1/workspaces/:workspaceId/github/app-manifest", async (c) => {
|
|
106
|
+
const workspaceId = c.req.param("workspaceId");
|
|
107
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "github:manage");
|
|
108
|
+
const payload = GitHubAppManifestCreate.parse(await c.req.json());
|
|
109
|
+
const baseUrl = (settings.githubAppManifestBaseUrl ?? new URL(c.req.url).origin).replace(/\/+$/, "");
|
|
110
|
+
const state = createSignedState(githubStateSecret, {
|
|
111
|
+
accountId: grant.accountId,
|
|
112
|
+
workspaceId: grant.workspaceId,
|
|
113
|
+
});
|
|
114
|
+
setGitHubStateCookie(c, deps, state);
|
|
115
|
+
const appName = payload.appName?.trim() || "OpenGeni";
|
|
116
|
+
const manifest = buildGitHubAppManifest({
|
|
117
|
+
appName,
|
|
118
|
+
baseUrl,
|
|
119
|
+
public: payload.public,
|
|
120
|
+
includeCiPermissions: payload.includeCiPermissions,
|
|
121
|
+
setupUrl: `${baseUrl}/v1/github/setup`,
|
|
122
|
+
});
|
|
123
|
+
const organization = payload.organization?.trim();
|
|
124
|
+
return c.json({
|
|
125
|
+
actionUrl: organization ? organizationAppManifestUrl(organization, state) : personalAppManifestUrl(state),
|
|
126
|
+
state,
|
|
127
|
+
manifest,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
app.get("/v1/github/app-manifest/callback", async (c) => {
|
|
132
|
+
const code = c.req.query("code");
|
|
133
|
+
const state = c.req.query("state");
|
|
134
|
+
if (!code) {
|
|
135
|
+
throw new HTTPException(400, { message: "missing GitHub manifest code" });
|
|
136
|
+
}
|
|
137
|
+
if (!state || !verifySignedState(state, githubStateSecret)) {
|
|
138
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub manifest state" });
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const conversion = await convertGitHubAppManifest(code);
|
|
142
|
+
const envLines = envLinesFromGitHubManifestConversion(conversion);
|
|
143
|
+
const slug = String(conversion.slug ?? "");
|
|
144
|
+
const installUrl = slug ? `https://github.com/apps/${slug}/installations/new?state=${encodeURIComponent(state)}` : "";
|
|
145
|
+
setGitHubStateCookie(c, deps, state);
|
|
146
|
+
return c.html(githubSuccessHtml(envLines, installUrl));
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const message = error instanceof GitHubAppApiError ? error.message : String(error);
|
|
149
|
+
throw new HTTPException(502, { message });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const handleGitHubInstallCallback = async (c: Context) => {
|
|
154
|
+
const code = c.req.query("code");
|
|
155
|
+
const state = c.req.query("state");
|
|
156
|
+
const installationIdRaw = c.req.query("installation_id");
|
|
157
|
+
const setupAction = c.req.query("setup_action") ?? null;
|
|
158
|
+
if (!state) {
|
|
159
|
+
throw new HTTPException(400, { message: "missing GitHub installation state" });
|
|
160
|
+
}
|
|
161
|
+
const statePayload = readSignedState(state, githubStateSecret);
|
|
162
|
+
if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string") {
|
|
163
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
164
|
+
}
|
|
165
|
+
requireGitHubStateCookie(c, state);
|
|
166
|
+
const grant = await requireAccessGrant(c, deps, statePayload.workspaceId, "github:manage");
|
|
167
|
+
if (grant.accountId !== statePayload.accountId) {
|
|
168
|
+
throw new HTTPException(403, { message: "GitHub installation state does not match this workspace" });
|
|
169
|
+
}
|
|
170
|
+
if (setupAction === "request" && !installationIdRaw) {
|
|
171
|
+
return c.html(githubSetupPendingHtml());
|
|
172
|
+
}
|
|
173
|
+
const installationId = parsePositiveInteger(installationIdRaw);
|
|
174
|
+
if (installationId === null) {
|
|
175
|
+
throw new HTTPException(400, { message: "missing or invalid GitHub installation_id" });
|
|
176
|
+
}
|
|
177
|
+
if (!code) {
|
|
178
|
+
const clientId = settings.githubClientId?.trim();
|
|
179
|
+
if (!clientId) {
|
|
180
|
+
throw new HTTPException(409, { message: JSON.stringify({ message: "GitHub App is not configured", missing: ["OPENGENI_GITHUB_CLIENT_ID"] }) });
|
|
181
|
+
}
|
|
182
|
+
const oauthState = createSignedState(githubStateSecret, {
|
|
183
|
+
accountId: grant.accountId,
|
|
184
|
+
workspaceId: grant.workspaceId,
|
|
185
|
+
installationId,
|
|
186
|
+
});
|
|
187
|
+
const baseUrl = (settings.githubAppManifestBaseUrl ?? settings.publicBaseUrl ?? new URL(c.req.url).origin).replace(/\/+$/, "");
|
|
188
|
+
setGitHubStateCookie(c, deps, oauthState);
|
|
189
|
+
return c.redirect(githubOAuthAuthorizeUrl({
|
|
190
|
+
clientId,
|
|
191
|
+
state: oauthState,
|
|
192
|
+
redirectUri: `${baseUrl}/v1/github/oauth/callback`,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
return await completeGitHubInstallationBinding(deps, c, {
|
|
196
|
+
code,
|
|
197
|
+
statePayload,
|
|
198
|
+
installationId,
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
app.get("/v1/github/setup", handleGitHubInstallCallback);
|
|
203
|
+
app.get("/v1/github/install/callback", handleGitHubInstallCallback);
|
|
204
|
+
|
|
205
|
+
app.get("/v1/github/oauth/callback", async (c) => {
|
|
206
|
+
const code = c.req.query("code");
|
|
207
|
+
const state = c.req.query("state");
|
|
208
|
+
if (!code) {
|
|
209
|
+
throw new HTTPException(400, { message: "missing GitHub OAuth code" });
|
|
210
|
+
}
|
|
211
|
+
if (!state) {
|
|
212
|
+
throw new HTTPException(400, { message: "missing GitHub OAuth state" });
|
|
213
|
+
}
|
|
214
|
+
const statePayload = readSignedState(state, githubStateSecret);
|
|
215
|
+
const installationId = parsePositiveInteger(String(statePayload?.installationId ?? ""));
|
|
216
|
+
if (!statePayload || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || installationId === null) {
|
|
217
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub OAuth state" });
|
|
218
|
+
}
|
|
219
|
+
requireGitHubStateCookie(c, state);
|
|
220
|
+
return await completeGitHubInstallationBinding(deps, c, {
|
|
221
|
+
code,
|
|
222
|
+
statePayload,
|
|
223
|
+
installationId,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function completeGitHubInstallationBinding(
|
|
229
|
+
deps: ApiRouteDeps,
|
|
230
|
+
c: Context,
|
|
231
|
+
input: {
|
|
232
|
+
code: string;
|
|
233
|
+
statePayload: { accountId?: string; workspaceId?: string };
|
|
234
|
+
installationId: number;
|
|
235
|
+
},
|
|
236
|
+
) {
|
|
237
|
+
const { db, settings } = deps;
|
|
238
|
+
if (!input.statePayload.workspaceId || !input.statePayload.accountId) {
|
|
239
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub installation state" });
|
|
240
|
+
}
|
|
241
|
+
const grant = await requireAccessGrant(c, deps, input.statePayload.workspaceId, "github:manage");
|
|
242
|
+
if (grant.accountId !== input.statePayload.accountId) {
|
|
243
|
+
throw new HTTPException(403, { message: "GitHub installation state does not match this workspace" });
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const installation = await verifyGitHubInstallationAccessForUser(settings, { code: input.code, installationId: input.installationId });
|
|
247
|
+
if (!installation) {
|
|
248
|
+
throw new HTTPException(404, { message: "GitHub App installation was not found for this app" });
|
|
249
|
+
}
|
|
250
|
+
if (installation.suspended) {
|
|
251
|
+
throw new HTTPException(409, { message: "GitHub App installation is suspended" });
|
|
252
|
+
}
|
|
253
|
+
await upsertGitHubInstallation(db, {
|
|
254
|
+
accountId: grant.accountId,
|
|
255
|
+
workspaceId: grant.workspaceId,
|
|
256
|
+
installationId: input.installationId,
|
|
257
|
+
accountLogin: installation.accountLogin,
|
|
258
|
+
accountType: installation.accountType,
|
|
259
|
+
});
|
|
260
|
+
const returnUrl = openGeniReturnUrl(settings, c, input.statePayload.workspaceId);
|
|
261
|
+
deleteCookie(c, githubStateCookie, { path: "/v1/github" });
|
|
262
|
+
return c.html(githubSetupSuccessHtml(installation.accountLogin ?? `installation ${input.installationId}`, returnUrl));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (error instanceof HTTPException) {
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
if (error instanceof GitHubAppConfigurationError) {
|
|
268
|
+
throw new HTTPException(409, { message: JSON.stringify({ message: error.message, missing: error.missing }) });
|
|
269
|
+
}
|
|
270
|
+
throw new HTTPException(502, { message: error instanceof Error ? error.message : String(error) });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function setGitHubStateCookie(c: Context, deps: ApiRouteDeps, state: string): void {
|
|
275
|
+
setCookie(c, githubStateCookie, state, {
|
|
276
|
+
httpOnly: true,
|
|
277
|
+
sameSite: "Lax",
|
|
278
|
+
secure: isSecureRequest(c, deps),
|
|
279
|
+
path: "/v1/github",
|
|
280
|
+
maxAge: stateMaxAgeSeconds,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function requireGitHubStateCookie(c: Context, state: string): void {
|
|
285
|
+
if (getCookie(c, githubStateCookie) !== state) {
|
|
286
|
+
throw new HTTPException(400, { message: "invalid or expired GitHub installation browser state" });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isSecureRequest(c: Context, deps: ApiRouteDeps): boolean {
|
|
291
|
+
return deps.settings.publicBaseUrl?.startsWith("https://")
|
|
292
|
+
|| c.req.header("x-forwarded-proto") === "https"
|
|
293
|
+
|| new URL(c.req.url).protocol === "https:";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function listWorkspaceGitHubRepositories(deps: ApiRouteDeps, workspaceId: string) {
|
|
297
|
+
const installationIds = await listGitHubInstallationIdsForWorkspace(deps.db, workspaceId);
|
|
298
|
+
return await listGitHubAppRepositories(deps.settings, { installationIds });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function githubSuccessHtml(envLines: string[], installUrl: string): string {
|
|
302
|
+
const envText = envLines.join("\n");
|
|
303
|
+
const escaped = escapeHtml(envText);
|
|
304
|
+
const install = installUrl ? `<a class="button secondary" href="${escapeHtml(installUrl)}">Install on repositories</a>` : "";
|
|
305
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Created</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(760px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.env-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:22px 0 8px}.env-header h2{margin:0;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.08em;color:#a1a1aa}pre{white-space:pre-wrap;word-break:break-word;max-height:380px;overflow:auto;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:16px;font-size:13px;line-height:1.5}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}.button,button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none;cursor:pointer}.button.secondary{background:transparent;color:#fafafa}.button.secondary:hover,button.secondary:hover{background:#27272a}button:disabled{cursor:not-allowed;opacity:.7}</style></head><body><main><h1>GitHub App created</h1><p>Add these values to .env, then restart API and worker.</p><div class="env-header"><h2>Environment variables</h2><button id="copy-env" type="button">Copy env</button></div><pre id="env-lines">${escaped}</pre><div class="actions">${install}</div><script>(()=>{const button=document.getElementById("copy-env");const env=document.getElementById("env-lines");async function copyText(text){if(navigator.clipboard&&window.isSecureContext){await navigator.clipboard.writeText(text);return;}const area=document.createElement("textarea");area.value=text;area.setAttribute("readonly","");area.style.position="fixed";area.style.inset="-9999px";document.body.append(area);area.select();document.execCommand("copy");area.remove();}button?.addEventListener("click",async()=>{try{await copyText(env?.textContent||"");button.textContent="Copied";setTimeout(()=>button.textContent="Copy env",1600);}catch{button.textContent="Copy failed";setTimeout(()=>button.textContent="Copy env",2200);}});})();</script></main></body></html>`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function githubSetupSuccessHtml(account: string, returnUrl: string): string {
|
|
309
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Connected</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0 0 18px;color:#d4d4d8}.button{display:inline-flex;align-items:center;justify-content:center;min-height:36px;border-radius:6px;border:1px solid #3f3f46;padding:0 12px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;text-decoration:none}.button:hover{background:#e4e4e7}</style></head><body><main><h1>GitHub App connected</h1><p>${escapeHtml(account)} is now available to this OpenGeni workspace.</p><a class="button" href="${escapeHtml(returnUrl)}">Back to OpenGeni</a></main></body></html>`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function githubSetupPendingHtml(): string {
|
|
313
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>GitHub App Requested</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:8px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px;line-height:1.2}p{margin:0;color:#d4d4d8}</style></head><body><main><h1>GitHub App request sent</h1><p>An organization administrator must approve the installation before OpenGeni can connect it to this workspace.</p></main></body></html>`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parsePositiveInteger(value: string | undefined | null): number | null {
|
|
317
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
const parsed = Number(value);
|
|
321
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function escapeHtml(value: string): string {
|
|
325
|
+
return value.replace(/[&<>"']/g, (char) => ({
|
|
326
|
+
"&": "&",
|
|
327
|
+
"<": "<",
|
|
328
|
+
">": ">",
|
|
329
|
+
'"': """,
|
|
330
|
+
"'": "'",
|
|
331
|
+
}[char] ?? char));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function openGeniReturnUrl(settings: ApiRouteDeps["settings"], c: Context, workspaceId: string | undefined): string {
|
|
335
|
+
const base = (settings.publicBaseUrl ?? new URL(c.req.url).origin).replace(/\/+$/, "");
|
|
336
|
+
const url = new URL(base || new URL(c.req.url).origin);
|
|
337
|
+
if (workspaceId) {
|
|
338
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
339
|
+
}
|
|
340
|
+
return url.toString();
|
|
341
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import type { Hono } from "hono";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
5
|
+
|
|
6
|
+
// The get.<domain> install-serving routes (dossier §23.1). These are
|
|
7
|
+
// UNAUTHENTICATED (see http/auth.ts isAuthExempt — the `installExemptPaths` set)
|
|
8
|
+
// so a fresh machine with no credentials can `curl -fsSL https://get.<domain>/install.sh`,
|
|
9
|
+
// read it first, then pipe to sh. They serve the IN-REPO committed script bodies
|
|
10
|
+
// (agent/install/*) verbatim — a single branded, audit-greppable trust root.
|
|
11
|
+
//
|
|
12
|
+
// The script BODIES contain NO secrets (POSIX sh, the device-flow captures the
|
|
13
|
+
// loud consent). The release-binary asset routes serve the agent BAKED into THIS
|
|
14
|
+
// control-plane image (the per-SHA Linux musl binary + its `.sha256`/`.minisig`)
|
|
15
|
+
// when present, and otherwise 302-redirect to the matching GitHub Release asset.
|
|
16
|
+
//
|
|
17
|
+
// "The agent ships inside the control-plane" (the owned decision): for every
|
|
18
|
+
// deployed env (preview/staging/managed-prod) the API image — already built
|
|
19
|
+
// per-SHA by GitHub Actions from the PR branch — bakes the SIGNED `opengeni-agent`
|
|
20
|
+
// binary matching that EXACT control-plane SHA into agent/install/baked/ (a CI step
|
|
21
|
+
// signs + COPYs; the signing key never enters the Docker build). install.sh then
|
|
22
|
+
// pulls a binary that is guaranteed in lockstep with the API it enrolls against —
|
|
23
|
+
// zero drift, zero new store. GitHub Releases remains the PUBLIC archive + the
|
|
24
|
+
// self-update channel + the install.sh fallback (mac/windows, and any asset this
|
|
25
|
+
// image did not bake), reached by the 302 below.
|
|
26
|
+
|
|
27
|
+
// The committed install artifacts, resolved relative to this module so the API
|
|
28
|
+
// (run from source under /app via bun) locates the sibling agent/install/ dir at
|
|
29
|
+
// runtime. apps/api/src/routes -> ../../../../agent/install.
|
|
30
|
+
const INSTALL_DIR = new URL("../../../../agent/install/", import.meta.url);
|
|
31
|
+
|
|
32
|
+
// The baked release-binary dir (a sibling of the committed scripts). The build's
|
|
33
|
+
// signing step writes the per-SHA Linux musl binaries + their `.sha256`/`.minisig`
|
|
34
|
+
// siblings here; in a plain `docker build` (or a source checkout) it holds only a
|
|
35
|
+
// `.gitkeep`, so every asset falls through to the GitHub-Releases redirect.
|
|
36
|
+
const BAKED_DIR = new URL("baked/", INSTALL_DIR);
|
|
37
|
+
|
|
38
|
+
// The static text artifacts served verbatim, with their content types. Each is
|
|
39
|
+
// read once at first request and memoized (committed files; immutable per deploy).
|
|
40
|
+
const TEXT_ASSETS: Record<string, { file: string; contentType: string }> = {
|
|
41
|
+
"/install.sh": { file: "install.sh", contentType: "text/x-shellscript; charset=utf-8" },
|
|
42
|
+
"/install.ps1": { file: "install.ps1", contentType: "text/plain; charset=utf-8" },
|
|
43
|
+
"/uninstall.sh": { file: "uninstall.sh", contentType: "text/x-shellscript; charset=utf-8" },
|
|
44
|
+
"/opengeni-agent-minisign.pub": { file: "opengeni-agent-minisign.pub", contentType: "text/plain; charset=utf-8" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const assetCache = new Map<string, string>();
|
|
48
|
+
|
|
49
|
+
async function loadAsset(file: string): Promise<string> {
|
|
50
|
+
const cached = assetCache.get(file);
|
|
51
|
+
if (cached !== undefined) {
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
const body = await readFile(new URL(file, INSTALL_DIR), "utf8");
|
|
55
|
+
assetCache.set(file, body);
|
|
56
|
+
return body;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// "The agent ships inside the control-plane" (cont.): the committed install
|
|
60
|
+
// scripts default their release-asset base URL to the public archive
|
|
61
|
+
// (get.opengeni.ai) so a from-source / standalone copy still works. But a
|
|
62
|
+
// DEPLOYED control plane must serve a script that pulls the agent from ITSELF —
|
|
63
|
+
// the per-SHA binary baked into THIS image, via the /agent/* routes below — so
|
|
64
|
+
// `curl https://<this-host>/install.sh | sh` installs the exact agent that
|
|
65
|
+
// matches the API it enrolls against, with NO dependency on a public CDN (which a
|
|
66
|
+
// private/air-gapped deploy may not even resolve). When a public base URL is
|
|
67
|
+
// configured we therefore rewrite each script's default-base-URL marker line to
|
|
68
|
+
// that origin before serving. The user's OPENGENI_INSTALL_BASE_URL env override
|
|
69
|
+
// still wins at run time (the scripts use `:-default`); only the built-in DEFAULT
|
|
70
|
+
// changes. The marker lines are kept shape-stable in agent/install/install.{sh,ps1}.
|
|
71
|
+
const DEFAULT_BASE_REWRITES: Record<string, (base: string) => { from: string; to: string }> = {
|
|
72
|
+
"install.sh": (base) => ({
|
|
73
|
+
from: 'OPENGENI_INSTALL_DEFAULT_BASE_URL="https://get.opengeni.ai"',
|
|
74
|
+
to: `OPENGENI_INSTALL_DEFAULT_BASE_URL="${base}"`,
|
|
75
|
+
}),
|
|
76
|
+
"install.ps1": (base) => ({
|
|
77
|
+
from: "$OpengeniInstallDefaultBaseUrl = 'https://get.opengeni.ai'",
|
|
78
|
+
to: `$OpengeniInstallDefaultBaseUrl = '${base}'`,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Rewrite a served script's default release-asset base URL to this deployment's
|
|
83
|
+
// own public origin. A no-op when no public base URL is configured (the public
|
|
84
|
+
// archive default stands), when the URL is not absolute http(s) (never serve a
|
|
85
|
+
// script with a broken base), or when the file has no marker (script drift — fail
|
|
86
|
+
// safe to serving it verbatim rather than crashing the install surface).
|
|
87
|
+
function rewriteDefaultBaseUrl(file: string, body: string, publicBaseUrl: string | undefined): string {
|
|
88
|
+
if (!publicBaseUrl || !/^https?:\/\//.test(publicBaseUrl)) {
|
|
89
|
+
return body;
|
|
90
|
+
}
|
|
91
|
+
const base = publicBaseUrl.replace(/\/+$/, "");
|
|
92
|
+
const rule = DEFAULT_BASE_REWRITES[file]?.(base);
|
|
93
|
+
if (!rule || !body.includes(rule.from)) {
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
return body.replace(rule.from, rule.to);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The content type for a baked release asset. The binary is an
|
|
100
|
+
// application/octet-stream download; its `.sha256`/`.minisig` sidecars are short
|
|
101
|
+
// text the install script parses line-by-line.
|
|
102
|
+
function bakedContentType(asset: string): string {
|
|
103
|
+
if (asset.endsWith(".sha256") || asset.endsWith(".minisig")) {
|
|
104
|
+
return "text/plain; charset=utf-8";
|
|
105
|
+
}
|
|
106
|
+
return "application/octet-stream";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Read a baked asset's bytes, or `null` when it is not baked into THIS image (the
|
|
110
|
+
// signal to fall through to the GitHub-Releases redirect). The asset name is
|
|
111
|
+
// already validated against ASSET_NAME by the caller (no traversal possible), and
|
|
112
|
+
// BAKED_DIR is a fixed sibling, so the resolved path cannot escape the dir.
|
|
113
|
+
async function readBaked(asset: string): Promise<ArrayBuffer | null> {
|
|
114
|
+
const url = new URL(asset, BAKED_DIR);
|
|
115
|
+
try {
|
|
116
|
+
const info = await stat(url);
|
|
117
|
+
if (!info.isFile()) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
// Return a standalone ArrayBuffer (a valid Response BodyInit). `readFile`'s
|
|
124
|
+
// Buffer may be a view into a larger pooled allocation, so copy out the exact
|
|
125
|
+
// byte range with `.slice` rather than handing over the backing store.
|
|
126
|
+
const buf = await readFile(url);
|
|
127
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// `/agent/latest/<asset>` and `/agent/v<ver>/<asset>` (+ the `.sha256` / `.minisig`
|
|
131
|
+
// siblings the install script fetches) — see agent/install/install.sh asset_url().
|
|
132
|
+
// `<asset>` and the version segment are constrained so the redirect cannot be used
|
|
133
|
+
// as an open redirector: only the agent asset-name shape + a `v`-prefixed version.
|
|
134
|
+
const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
135
|
+
// The install script's version path segment is the literal `v<ver>` (e.g. v1.2.3).
|
|
136
|
+
const VERSION_SEG = /^v[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
137
|
+
|
|
138
|
+
export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
139
|
+
const releasesBase = deps.settings.agentReleasesBaseUrl.replace(/\/+$/, "");
|
|
140
|
+
|
|
141
|
+
for (const [path, { file, contentType }] of Object.entries(TEXT_ASSETS)) {
|
|
142
|
+
app.get(path, async (c) => {
|
|
143
|
+
// Serve the committed body, but rewrite the install scripts' default
|
|
144
|
+
// asset base URL to THIS deployment's origin so the agent is pulled from
|
|
145
|
+
// the same control plane it enrolls against (see rewriteDefaultBaseUrl).
|
|
146
|
+
const body = rewriteDefaultBaseUrl(file, await loadAsset(file), deps.settings.publicBaseUrl);
|
|
147
|
+
return c.text(body, 200, {
|
|
148
|
+
"content-type": contentType,
|
|
149
|
+
// Short cache: the edge serves the latest committed copy; new installs
|
|
150
|
+
// should pick up script fixes promptly, but a brief cache absorbs bursts.
|
|
151
|
+
"cache-control": "public, max-age=300",
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Serve a release-binary asset: the BAKED per-SHA file if THIS image carries it
|
|
157
|
+
// (the Linux musl binary + sidecars that match the control plane exactly),
|
|
158
|
+
// otherwise 302 to the GitHub Release at `redirectUrl` (mac/windows + any
|
|
159
|
+
// un-baked asset). The baked path makes the agent the API enrolls against
|
|
160
|
+
// identical to the API that serves it — no version skew, no extra hop.
|
|
161
|
+
async function serveAsset(asset: string, redirectUrl: string): Promise<Response> {
|
|
162
|
+
const baked = await readBaked(asset);
|
|
163
|
+
if (baked !== null) {
|
|
164
|
+
return new Response(baked, {
|
|
165
|
+
status: 200,
|
|
166
|
+
headers: {
|
|
167
|
+
"content-type": bakedContentType(asset),
|
|
168
|
+
// The baked artifact is immutable for this image SHA: the binary, its
|
|
169
|
+
// checksum, and its signature never change once built. A long cache is
|
|
170
|
+
// safe; new installs land when a new image (new SHA) rolls out.
|
|
171
|
+
"cache-control": "public, max-age=3600",
|
|
172
|
+
"x-opengeni-agent-source": "baked",
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Not baked here → the GitHub Release is the source of truth (the public
|
|
177
|
+
// archive + the documented install.sh fallback). 302 so the client refetches.
|
|
178
|
+
return new Response(null, { status: 302, headers: { location: redirectUrl } });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// `latest` → the BAKED binary if present, else the dedicated moving
|
|
182
|
+
// `agent-latest` GitHub Release. We deliberately do NOT use GitHub's
|
|
183
|
+
// repo-global `releases/latest` alias here: in this monorepo that alias is
|
|
184
|
+
// perpetually shadowed by the frequent changesets package releases (e.g.
|
|
185
|
+
// `@opengeni/contracts@x.y.z`), which carry no agent binaries → 404. The
|
|
186
|
+
// `agent-latest` release is maintained by .github/workflows/agent-release.yml
|
|
187
|
+
// as a moving tag that always points at the newest signed mac/windows
|
|
188
|
+
// binaries (+ checksums, install scripts, minisign pubkey).
|
|
189
|
+
app.get("/agent/latest/:asset", async (c) => {
|
|
190
|
+
const asset = c.req.param("asset");
|
|
191
|
+
if (!ASSET_NAME.test(asset)) {
|
|
192
|
+
throw new HTTPException(400, { message: "invalid asset name" });
|
|
193
|
+
}
|
|
194
|
+
return serveAsset(asset, `${releasesBase}/download/agent-latest/${asset}`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// The version segment is the literal `v<ver>` (e.g. `v1.2.3`) — Hono cannot bind
|
|
198
|
+
// a param glued to a literal prefix, so the whole segment is the param and the
|
|
199
|
+
// `v` prefix is validated/stripped here. The release tag is `agent-v<ver>`.
|
|
200
|
+
app.get("/agent/:versionSeg/:asset", async (c) => {
|
|
201
|
+
const versionSeg = c.req.param("versionSeg");
|
|
202
|
+
const asset = c.req.param("asset");
|
|
203
|
+
// `/agent/latest/<asset>` is handled by the more specific route above; any
|
|
204
|
+
// other version segment must be the `v<ver>` shape.
|
|
205
|
+
if (!VERSION_SEG.test(versionSeg) || !ASSET_NAME.test(asset)) {
|
|
206
|
+
throw new HTTPException(400, { message: "invalid version or asset name" });
|
|
207
|
+
}
|
|
208
|
+
return serveAsset(asset, `${releasesBase}/download/agent-${versionSeg}/${asset}`);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The path prefixes/exact paths the install routes own — exported so the auth
|
|
213
|
+
// middleware can exempt them (they must be reachable with no credentials).
|
|
214
|
+
export const installExactPaths: ReadonlySet<string> = new Set(Object.keys(TEXT_ASSETS));
|
|
215
|
+
|
|
216
|
+
export function isInstallRedirectPath(path: string): boolean {
|
|
217
|
+
return path.startsWith("/agent/latest/") || path.startsWith("/agent/v");
|
|
218
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// apps/api/src/routes/machines.ts — the M10 Machines-dashboard + per-machine
|
|
2
|
+
// metrics-series ROUTES (dossier §10.7). Mirrors registerEnrollmentRoutes: thin
|
|
3
|
+
// routes over a focused service (../sandbox/machines.ts), requireAccessGrant
|
|
4
|
+
// BEFORE any work, the whole router gated behind sandboxSelfhostedEnabled
|
|
5
|
+
// (default OFF → 404, invisible). Both routes need perm enrollments:read.
|
|
6
|
+
//
|
|
7
|
+
// GET /v1/workspaces/:ws/machines[?sessionId=...] -> MachinesResponse
|
|
8
|
+
// The dashboard list: the workspace's enrolled selfhosted machines (state +
|
|
9
|
+
// latest metrics + sharedSessionCount) and, when sessionId is supplied, the
|
|
10
|
+
// session's synthetic Modal group box + the active-sandbox pointer.
|
|
11
|
+
//
|
|
12
|
+
// GET /v1/workspaces/:ws/machines/:enrollmentId/metrics/series?window=1h
|
|
13
|
+
// -> { samples: MetricSample[] }
|
|
14
|
+
// The downsampled (~1/min) history for ONE machine over a time window.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
MachineMetricsSeriesResponse,
|
|
18
|
+
MachinesResponse,
|
|
19
|
+
SwapActiveSandboxRequest,
|
|
20
|
+
SwapActiveSandboxResponse,
|
|
21
|
+
} from "@opengeni/contracts";
|
|
22
|
+
import {
|
|
23
|
+
getEnrollment,
|
|
24
|
+
readMachineMetricsSeries,
|
|
25
|
+
} from "@opengeni/db";
|
|
26
|
+
import type { Hono } from "hono";
|
|
27
|
+
import { HTTPException } from "hono/http-exception";
|
|
28
|
+
import { requireAccessGrant } from "@opengeni/core";
|
|
29
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
30
|
+
import { buildFleetContextForSession, swapActiveSandbox } from "@opengeni/core";
|
|
31
|
+
import { listMachines, metricRowToSample } from "../sandbox/machines";
|
|
32
|
+
|
|
33
|
+
// The supported series windows → milliseconds. An unknown/absent window defaults
|
|
34
|
+
// to 1h (the dossier default). Bounded so a caller cannot request an unbounded
|
|
35
|
+
// scan; longer ranges are a later concern (retention is ~N days).
|
|
36
|
+
const SERIES_WINDOWS_MS: Record<string, number> = {
|
|
37
|
+
"15m": 15 * 60_000,
|
|
38
|
+
"1h": 60 * 60_000,
|
|
39
|
+
"6h": 6 * 60 * 60_000,
|
|
40
|
+
"24h": 24 * 60 * 60_000,
|
|
41
|
+
};
|
|
42
|
+
const DEFAULT_SERIES_WINDOW_MS = SERIES_WINDOWS_MS["1h"]!;
|
|
43
|
+
|
|
44
|
+
export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
45
|
+
const { settings, db, bus } = deps;
|
|
46
|
+
|
|
47
|
+
// The whole surface is behind sandboxSelfhostedEnabled. A 404 (not 403) keeps it
|
|
48
|
+
// invisible while disabled — it does not exist for this deployment yet.
|
|
49
|
+
function assertSelfhostedEnabled(): void {
|
|
50
|
+
if (!settings.sandboxSelfhostedEnabled) {
|
|
51
|
+
throw new HTTPException(404, { message: "selfhosted machines are not enabled for this deployment" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── GET /workspaces/:ws/machines (the dashboard list) ───────────────────────
|
|
56
|
+
app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
|
|
57
|
+
const workspaceId = c.req.param("workspaceId");
|
|
58
|
+
await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
59
|
+
assertSelfhostedEnabled();
|
|
60
|
+
// sessionId is OPTIONAL: present → an in-session view (synthetic group box +
|
|
61
|
+
// active pointer); absent → the pure workspace dashboard.
|
|
62
|
+
const sessionId = c.req.query("sessionId") ?? null;
|
|
63
|
+
const response = await listMachines({ db, settings, bus }, { workspaceId, sessionId });
|
|
64
|
+
return c.json(MachinesResponse.parse(response));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── GET /workspaces/:ws/machines/:enrollmentId/metrics/series ───────────────
|
|
68
|
+
app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
|
|
69
|
+
const workspaceId = c.req.param("workspaceId");
|
|
70
|
+
await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
71
|
+
assertSelfhostedEnabled();
|
|
72
|
+
const enrollmentId = c.req.param("enrollmentId");
|
|
73
|
+
// Validate the machine belongs to this workspace (RLS already scopes the read,
|
|
74
|
+
// but a clear 404 beats an empty series for an unknown/cross-workspace id).
|
|
75
|
+
const enrollment = await getEnrollment(db, workspaceId, enrollmentId);
|
|
76
|
+
if (!enrollment) {
|
|
77
|
+
throw new HTTPException(404, { message: "machine not found in this workspace" });
|
|
78
|
+
}
|
|
79
|
+
const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
|
|
80
|
+
const since = new Date(Date.now() - windowMs);
|
|
81
|
+
const rows = await readMachineMetricsSeries(db, { workspaceId, enrollmentId, since });
|
|
82
|
+
return c.json(MachineMetricsSeriesResponse.parse({
|
|
83
|
+
samples: rows.map(metricRowToSample),
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── POST /workspaces/:ws/sessions/:sessionId/active-sandbox (swap) ───────────
|
|
88
|
+
// The user-authenticated equivalent of the M7 `sandbox_swap` MCP tool: repoint
|
|
89
|
+
// a session's active sandbox under the epoch fence. Same perm as PATCH session
|
|
90
|
+
// (sessions:control); gated behind sandboxSelfhostedEnabled (404 when off, the
|
|
91
|
+
// surface is invisible). All ownership/liveness/epoch validation lives inside
|
|
92
|
+
// swapActiveSandbox — the route only builds the session-scoped FleetContext.
|
|
93
|
+
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/active-sandbox", async (c) => {
|
|
94
|
+
const workspaceId = c.req.param("workspaceId");
|
|
95
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
96
|
+
assertSelfhostedEnabled();
|
|
97
|
+
const sessionId = c.req.param("sessionId");
|
|
98
|
+
const body = SwapActiveSandboxRequest.parse(await c.req.json());
|
|
99
|
+
const ctx = await buildFleetContextForSession(deps, {
|
|
100
|
+
accountId: grant.accountId,
|
|
101
|
+
workspaceId,
|
|
102
|
+
sessionId,
|
|
103
|
+
});
|
|
104
|
+
const result = await swapActiveSandbox({ db, settings, bus }, ctx, body.target);
|
|
105
|
+
return c.json(SwapActiveSandboxResponse.parse(result));
|
|
106
|
+
});
|
|
107
|
+
}
|