@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.
Files changed (41) hide show
  1. package/dist/app.d.ts +16 -0
  2. package/dist/app.js +35 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/chunk-XSYUDIX3.js +6331 -0
  5. package/dist/chunk-XSYUDIX3.js.map +1 -0
  6. package/dist/index.d.ts +19 -0
  7. package/dist/index.js +567 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +74 -0
  10. package/src/app.ts +351 -0
  11. package/src/auth/managed-auth.ts +237 -0
  12. package/src/http/auth.ts +92 -0
  13. package/src/http/common.ts +16 -0
  14. package/src/http/sse.ts +89 -0
  15. package/src/index.ts +362 -0
  16. package/src/mcp/documents.ts +57 -0
  17. package/src/mcp/server.ts +961 -0
  18. package/src/mcp/session-view.ts +281 -0
  19. package/src/routes/api-keys.ts +65 -0
  20. package/src/routes/billing.ts +495 -0
  21. package/src/routes/capabilities.ts +80 -0
  22. package/src/routes/codex.ts +393 -0
  23. package/src/routes/documents.ts +185 -0
  24. package/src/routes/enrollments.ts +357 -0
  25. package/src/routes/environments.ts +175 -0
  26. package/src/routes/files.ts +148 -0
  27. package/src/routes/github.ts +341 -0
  28. package/src/routes/install.ts +218 -0
  29. package/src/routes/machines.ts +107 -0
  30. package/src/routes/packs.ts +241 -0
  31. package/src/routes/scheduled-tasks.ts +126 -0
  32. package/src/routes/sessions.ts +1083 -0
  33. package/src/routes/social.ts +119 -0
  34. package/src/routes/workspaces.ts +206 -0
  35. package/src/sandbox/access.ts +89 -0
  36. package/src/sandbox/auth-callout.ts +178 -0
  37. package/src/sandbox/channel-a.ts +265 -0
  38. package/src/sandbox/enrollment.ts +498 -0
  39. package/src/sandbox/machines.ts +255 -0
  40. package/src/sandbox/metrics-ingestion.ts +289 -0
  41. 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
+ "&": "&amp;",
327
+ "<": "&lt;",
328
+ ">": "&gt;",
329
+ '"': "&quot;",
330
+ "'": "&#39;",
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
+ }