@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,357 @@
1
+ // apps/api/src/routes/enrollments.ts — the bring-your-own-compute enrollment
2
+ // device-flow routes (M5; dossier §10.2 + §18). Mirrors the other sandbox route
3
+ // modules (registerSessionRoutes / registerApiKeyRoutes): a thin route over a
4
+ // focused service (../sandbox/enrollment.ts), requireAccessGrant BEFORE any Zod
5
+ // parse on the USER-authenticated routes, explicit HTTPException(400) on a parse
6
+ // failure (never a raw ZodError → 500), and the whole router gated behind
7
+ // sandboxSelfhostedEnabled (default OFF → every route 404s, invisible).
8
+ //
9
+ // AUTH SEAM (the device-flow split):
10
+ // * device/start + device/poll are AGENT-side — user-UNAUTHENTICATED (the agent
11
+ // has no logged-in browser session; it presents only the deployment access key
12
+ // the app.use("*", requireAccessKey) edge already enforces). They are
13
+ // IP-rate-limited here. They DO NOT call requireAccessGrant.
14
+ // * device/approve + GET /enrollments + revoke are USER-authenticated +
15
+ // workspace-gated via requireAccessGrant (enrollments:manage / enrollments:read;
16
+ // workspace:admin is the super-wildcard).
17
+ //
18
+ // The cross-workspace safety of an unauthenticated start: start binds the request
19
+ // to a workspaceId the agent supplies, and ONLY a user holding a grant in THAT
20
+ // workspace can approve it (the approve's user_code lookup is workspace-scoped) — a
21
+ // start can never grant access to a workspace no authorized user later approves in.
22
+
23
+ import {
24
+ DeviceEnrollmentApproveRequest,
25
+ DeviceEnrollmentApproveResponse,
26
+ DeviceEnrollmentDenyRequest,
27
+ DeviceEnrollmentDenyResponse,
28
+ DeviceEnrollmentLookupRequest,
29
+ DeviceEnrollmentLookupResponse,
30
+ DeviceEnrollmentPollRequest,
31
+ DeviceEnrollmentStartRequest,
32
+ EnrollmentSummary,
33
+ EnrollTokenExchangeRequest,
34
+ EnrollTokenExchangeResponse,
35
+ ListEnrollmentsResponse,
36
+ MintEnrollTokenRequest,
37
+ MintEnrollTokenResponse,
38
+ RevokeEnrollmentResponse,
39
+ type EnrollmentArch,
40
+ type EnrollmentOs,
41
+ } from "@opengeni/contracts";
42
+ import {
43
+ getWorkspace,
44
+ listEnrollments,
45
+ revokeEnrollment,
46
+ } from "@opengeni/db";
47
+ import type { Context, Hono } from "hono";
48
+ import { HTTPException } from "hono/http-exception";
49
+ import { requireAccessGrant } from "@opengeni/core";
50
+ import type { ApiRouteDeps } from "@opengeni/core";
51
+ import {
52
+ approveDeviceEnrollment,
53
+ denyDeviceEnrollment,
54
+ exchangeEnrollToken,
55
+ lookupDeviceEnrollment,
56
+ mintEnrollToken,
57
+ pollDeviceEnrollment,
58
+ startDeviceEnrollment,
59
+ toLookupResponse,
60
+ } from "../sandbox/enrollment";
61
+
62
+ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
63
+ const { settings, db } = deps;
64
+
65
+ // The whole feature is behind sandboxSelfhostedEnabled. A 404 (not 403) keeps the
66
+ // surface invisible while disabled — it does not exist for this deployment yet.
67
+ function assertSelfhostedEnabled(): void {
68
+ if (!settings.sandboxSelfhostedEnabled) {
69
+ throw new HTTPException(404, { message: "selfhosted enrollment is not enabled for this deployment" });
70
+ }
71
+ }
72
+
73
+ // A tiny in-process IP token-bucket for the UNAUTHENTICATED agent routes (start/
74
+ // poll). The relay tier owns the heavy stream rate-limiting (dossier §10.5); this
75
+ // is the application-tier abuse cap on the device-flow endpoints. Per-IP buckets
76
+ // are pruned lazily. Not a distributed limiter (one replica per bucket) — that is
77
+ // acceptable for a bounded, access-key-gated, short-TTL flow.
78
+ const startLimiter = new TokenBucket({ capacity: 10, refillPerSecond: 0.5 });
79
+ const pollLimiter = new TokenBucket({ capacity: 60, refillPerSecond: 2 });
80
+ // The click-Grant approve-page lookup (authenticated, but capped against a
81
+ // user_code brute force — the lookup resolves a workspace from a short code).
82
+ const lookupLimiter = new TokenBucket({ capacity: 30, refillPerSecond: 1 });
83
+ // The headless token exchange (UNAUTHENTICATED — the token is the auth). Bounded
84
+ // against an enroll-token brute force; the `oget_` HMAC is the real boundary.
85
+ const exchangeLimiter = new TokenBucket({ capacity: 20, refillPerSecond: 0.5 });
86
+
87
+ function rateLimit(c: Context, limiter: TokenBucket): void {
88
+ const ip = clientIp(c);
89
+ if (!limiter.take(ip)) {
90
+ throw new HTTPException(429, { message: "too many requests; slow down" });
91
+ }
92
+ }
93
+
94
+ // ── POST /enrollments/device/start (agent-side, user-unauthenticated) ───────
95
+ app.post("/v1/enrollments/device/start", async (c) => {
96
+ assertSelfhostedEnabled();
97
+ rateLimit(c, startLimiter);
98
+ const parsed = DeviceEnrollmentStartRequest.safeParse(await c.req.json().catch(() => null));
99
+ if (!parsed.success) {
100
+ throw new HTTPException(400, { message: "invalid device-start request" });
101
+ }
102
+ const body = parsed.data;
103
+ // Resolve the (account) for the supplied workspace. An unknown workspace is a
104
+ // 404 (the same shape requireAccessGrant uses for an unknown workspace).
105
+ const workspace = await getWorkspace(db, body.workspaceId);
106
+ if (!workspace) {
107
+ throw new HTTPException(404, { message: "workspace not found" });
108
+ }
109
+ const result = await startDeviceEnrollment({ db, settings }, {
110
+ accountId: workspace.accountId,
111
+ workspaceId: workspace.id,
112
+ publicKey: body.publicKey,
113
+ os: body.os as EnrollmentOs,
114
+ arch: body.arch as EnrollmentArch,
115
+ machineName: body.machineName ?? null,
116
+ canOfferDisplay: body.canOfferDisplay,
117
+ requestsScreenControl: body.requestsScreenControl,
118
+ // The approve page is served at the SAME origin as this request.
119
+ verificationOrigin: new URL(c.req.url).origin,
120
+ });
121
+ return c.json(result, 201);
122
+ });
123
+
124
+ // ── POST /enrollments/device/poll (agent-side, user-unauthenticated) ────────
125
+ app.post("/v1/enrollments/device/poll", async (c) => {
126
+ assertSelfhostedEnabled();
127
+ rateLimit(c, pollLimiter);
128
+ const parsed = DeviceEnrollmentPollRequest.safeParse(await c.req.json().catch(() => null));
129
+ if (!parsed.success) {
130
+ throw new HTTPException(400, { message: "invalid device-poll request" });
131
+ }
132
+ const result = await pollDeviceEnrollment({ db, settings }, { deviceCode: parsed.data.deviceCode });
133
+ return c.json(result, 200);
134
+ });
135
+
136
+ // ── POST /enrollments/device/lookup (user-authed, NO workspace in path) ─────
137
+ // The click-Grant approve page reads machine details for a user_code WITHOUT
138
+ // consuming it. The user_code is globally unique among pending rows; we resolve
139
+ // its workspace, then assert the caller holds enrollments:read in THAT workspace.
140
+ // A failed grant OR no live pending row both → 404 (never reveal cross-workspace
141
+ // existence). Rate-limited against a user_code brute force.
142
+ app.post("/v1/enrollments/device/lookup", async (c) => {
143
+ assertSelfhostedEnabled();
144
+ rateLimit(c, lookupLimiter);
145
+ const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
146
+ if (!parsed.success) {
147
+ throw new HTTPException(400, { message: "invalid device-lookup request" });
148
+ }
149
+ const record = await lookupDeviceEnrollment({ db, settings }, { userCode: parsed.data.userCode });
150
+ if (!record) {
151
+ // Unknown / terminal / expired code → 404 (indistinguishable from an
152
+ // unauthorized one below, by design).
153
+ throw new HTTPException(404, { message: "no pending enrollment for that code" });
154
+ }
155
+ // Authorize the caller against the RESOLVED workspace. A missing grant throws
156
+ // 403/404 from requireAccessGrant; we normalize that to 404 so a caller cannot
157
+ // distinguish "code exists in a workspace I can't see" from "no such code".
158
+ try {
159
+ await requireAccessGrant(c, deps, record.workspaceId, "enrollments:read");
160
+ } catch {
161
+ throw new HTTPException(404, { message: "no pending enrollment for that code" });
162
+ }
163
+ return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record)), 200);
164
+ });
165
+
166
+ // ── POST /enrollments/token/exchange (UNAUTHENTICATED — the token is the auth) ─
167
+ // The headless / fleet enroll path. The agent presents the same identity fields
168
+ // it sends to device/start plus the `oget_` enroll token. The token IS the grant
169
+ // (no human approve). On a valid token we perform the SAME finalize as approve and
170
+ // return the IDENTICAL EnrollmentCredentials shape the poll authorized branch
171
+ // returns. Rate-limited against an enroll-token brute force.
172
+ app.post("/v1/enrollments/token/exchange", async (c) => {
173
+ assertSelfhostedEnabled();
174
+ rateLimit(c, exchangeLimiter);
175
+ const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
176
+ if (!parsed.success) {
177
+ throw new HTTPException(400, { message: "invalid enroll-token-exchange request" });
178
+ }
179
+ const body = parsed.data;
180
+ const result = await exchangeEnrollToken({ db, settings }, {
181
+ token: body.token,
182
+ publicKey: body.publicKey,
183
+ os: body.os as EnrollmentOs,
184
+ arch: body.arch as EnrollmentArch,
185
+ machineName: body.machineName ?? null,
186
+ canOfferDisplay: body.canOfferDisplay,
187
+ });
188
+ if (!result.ok) {
189
+ if (result.reason === "disabled") {
190
+ // The credential plane is off for this deployment (no signing secret).
191
+ throw new HTTPException(503, { message: "enrollment credential plane is not configured" });
192
+ }
193
+ // An invalid / expired / wrong-typ token — the token is the auth, so 401.
194
+ throw new HTTPException(401, { message: "invalid or expired enroll token" });
195
+ }
196
+ return c.json(EnrollTokenExchangeResponse.parse({ credentials: result.credentials }), 201);
197
+ });
198
+
199
+ // ── POST /workspaces/:workspaceId/enrollments/device/approve (user-authed) ──
200
+ // The LOUD CONSENT step. requireAccessGrant BEFORE the Zod parse.
201
+ app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
202
+ const workspaceId = c.req.param("workspaceId");
203
+ const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
204
+ assertSelfhostedEnabled();
205
+ const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
206
+ if (!parsed.success) {
207
+ throw new HTTPException(400, { message: "invalid device-approve request" });
208
+ }
209
+ const body = parsed.data;
210
+ const approved = await approveDeviceEnrollment({ db, settings }, {
211
+ accountId: grant.accountId,
212
+ workspaceId,
213
+ userCode: body.userCode,
214
+ allowScreenControl: body.allowScreenControl,
215
+ // The LOUD consent record: WHO consented (the authenticated subject + label).
216
+ approvedBySubjectId: grant.subjectId,
217
+ approvedBySubjectLabel: grant.subjectLabel ?? null,
218
+ });
219
+ if (!approved) {
220
+ // An unknown / expired / already-terminal user_code in this workspace.
221
+ throw new HTTPException(404, { message: "no pending enrollment for that code" });
222
+ }
223
+ return c.json(DeviceEnrollmentApproveResponse.parse({
224
+ approved: true,
225
+ enrollmentId: approved.enrollmentId,
226
+ sandboxId: approved.sandboxId,
227
+ allowScreenControl: approved.allowScreenControl,
228
+ }), 201);
229
+ });
230
+
231
+ // ── POST /workspaces/:workspaceId/enrollments/device/deny (user-authed) ─────
232
+ // The explicit "no" at the approve page. Mirrors approve (enrollments:manage,
233
+ // requireAccessGrant BEFORE the parse). Idempotent: a non-pending code → denied:false.
234
+ app.post("/v1/workspaces/:workspaceId/enrollments/device/deny", async (c) => {
235
+ const workspaceId = c.req.param("workspaceId");
236
+ const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
237
+ assertSelfhostedEnabled();
238
+ const parsed = DeviceEnrollmentDenyRequest.safeParse(await c.req.json().catch(() => null));
239
+ if (!parsed.success) {
240
+ throw new HTTPException(400, { message: "invalid device-deny request" });
241
+ }
242
+ const result = await denyDeviceEnrollment({ db, settings }, {
243
+ accountId: grant.accountId,
244
+ workspaceId,
245
+ userCode: parsed.data.userCode,
246
+ });
247
+ return c.json(DeviceEnrollmentDenyResponse.parse({ denied: result.denied }), 200);
248
+ });
249
+
250
+ // ── POST /workspaces/:workspaceId/enrollments/token (user-authed) ───────────
251
+ // Mint a short-TTL headless enroll token. Mirrors approve (enrollments:manage,
252
+ // requireAccessGrant BEFORE the parse). The accountId comes from the grant. No
253
+ // signing secret → 503/disabled (mirror poll). The token is SECRET — never logged.
254
+ app.post("/v1/workspaces/:workspaceId/enrollments/token", async (c) => {
255
+ const workspaceId = c.req.param("workspaceId");
256
+ const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
257
+ assertSelfhostedEnabled();
258
+ // All fields are optional/defaulted, so an empty POST body is valid (default
259
+ // allowScreenControl=false). Coalesce a missing/empty body to {} before parse.
260
+ const parsed = MintEnrollTokenRequest.safeParse(await c.req.json().catch(() => ({})));
261
+ if (!parsed.success) {
262
+ throw new HTTPException(400, { message: "invalid mint-enroll-token request" });
263
+ }
264
+ const minted = await mintEnrollToken({ db, settings }, {
265
+ accountId: grant.accountId,
266
+ workspaceId,
267
+ allowScreenControl: parsed.data.allowScreenControl,
268
+ });
269
+ if (!minted) {
270
+ // The credential plane is off (no signing secret) — mirror poll's disabled path.
271
+ throw new HTTPException(503, { message: "enrollment credential plane is not configured" });
272
+ }
273
+ return c.json(MintEnrollTokenResponse.parse(minted), 201);
274
+ });
275
+
276
+ // ── GET /workspaces/:workspaceId/enrollments (user-authed) ──────────────────
277
+ app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
278
+ const workspaceId = c.req.param("workspaceId");
279
+ await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
280
+ assertSelfhostedEnabled();
281
+ const statusFilter = c.req.query("status");
282
+ const rows = await listEnrollments(db, workspaceId, statusFilter === "active" ? { status: "active" } : {});
283
+ return c.json(ListEnrollmentsResponse.parse({
284
+ enrollments: rows.map((row) => EnrollmentSummary.parse({
285
+ id: row.id,
286
+ pubkey: row.pubkey,
287
+ exposure: row.exposure,
288
+ hasDisplay: row.hasDisplay,
289
+ allowScreenControl: row.allowScreenControl,
290
+ status: row.status,
291
+ os: row.os,
292
+ arch: row.arch,
293
+ lastSeenAt: row.lastSeenAt,
294
+ createdAt: row.createdAt,
295
+ revokedAt: row.revokedAt,
296
+ })),
297
+ }));
298
+ });
299
+
300
+ // ── POST /workspaces/:workspaceId/enrollments/:id/revoke (user-authed) ──────
301
+ app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
302
+ const workspaceId = c.req.param("workspaceId");
303
+ const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
304
+ assertSelfhostedEnabled();
305
+ const result = await revokeEnrollment(db, {
306
+ accountId: grant.accountId,
307
+ workspaceId,
308
+ enrollmentId: c.req.param("enrollmentId"),
309
+ });
310
+ return c.json(RevokeEnrollmentResponse.parse(result));
311
+ });
312
+ }
313
+
314
+ // The remote client IP for the per-IP rate-limit bucket. Honors the proxy's
315
+ // X-Forwarded-For (the first hop) when present, falling back to a constant key when
316
+ // neither is available (the bucket then caps the whole edge — still a useful cap).
317
+ function clientIp(c: Context): string {
318
+ const xff = c.req.header("x-forwarded-for");
319
+ if (xff) {
320
+ const first = xff.split(",")[0]?.trim();
321
+ if (first) return first;
322
+ }
323
+ return c.req.header("x-real-ip")?.trim() || "unknown";
324
+ }
325
+
326
+ // A minimal per-key token bucket. capacity = burst; refillPerSecond = sustained
327
+ // rate. Buckets are created lazily and reset their tokens by elapsed time on each
328
+ // take, so an idle key fully refills without a background timer.
329
+ class TokenBucket {
330
+ private readonly capacity: number;
331
+ private readonly refillPerSecond: number;
332
+ private readonly buckets = new Map<string, { tokens: number; updatedAt: number }>();
333
+
334
+ constructor(options: { capacity: number; refillPerSecond: number }) {
335
+ this.capacity = options.capacity;
336
+ this.refillPerSecond = options.refillPerSecond;
337
+ }
338
+
339
+ take(key: string, now = Date.now()): boolean {
340
+ const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
341
+ const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1000);
342
+ bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
343
+ bucket.updatedAt = now;
344
+ // Prune the map opportunistically so it never grows unbounded: a fully-refilled
345
+ // bucket carries no state worth keeping.
346
+ if (bucket.tokens >= this.capacity && this.buckets.size > 10_000) {
347
+ this.buckets.delete(key);
348
+ }
349
+ if (bucket.tokens < 1) {
350
+ this.buckets.set(key, bucket);
351
+ return false;
352
+ }
353
+ bucket.tokens -= 1;
354
+ this.buckets.set(key, bucket);
355
+ return true;
356
+ }
357
+ }
@@ -0,0 +1,175 @@
1
+ import {
2
+ CreateWorkspaceEnvironmentRequest,
3
+ SetWorkspaceEnvironmentVariableRequest,
4
+ UpdateWorkspaceEnvironmentRequest,
5
+ WorkspaceEnvironmentVariableName,
6
+ } from "@opengeni/contracts";
7
+ import {
8
+ countActiveSessionsUsingEnvironment,
9
+ countScheduledTasksUsingEnvironment,
10
+ countWorkspaceEnvironments,
11
+ createWorkspaceEnvironment,
12
+ deleteWorkspaceEnvironment,
13
+ deleteWorkspaceEnvironmentVariable,
14
+ encryptEnvironmentValue,
15
+ getWorkspaceEnvironmentByName,
16
+ listWorkspaceEnvironments,
17
+ setWorkspaceEnvironmentVariable,
18
+ updateWorkspaceEnvironment,
19
+ } from "@opengeni/db";
20
+ import type { Hono } from "hono";
21
+ import { HTTPException } from "hono/http-exception";
22
+ import { requireAccessGrant } from "@opengeni/core";
23
+ import type { ApiRouteDeps } from "@opengeni/core";
24
+ import {
25
+ assertAllowedEnvironmentVariableName,
26
+ MAX_ENVIRONMENTS_PER_WORKSPACE,
27
+ MAX_VARIABLES_PER_ENVIRONMENT,
28
+ recordEnvironmentAuditEvent,
29
+ requireEnvironmentEncryption,
30
+ requireEnvironmentForApi,
31
+ } from "@opengeni/core";
32
+
33
+ export function registerEnvironmentRoutes(app: Hono, deps: ApiRouteDeps): void {
34
+ const { settings, db } = deps;
35
+
36
+ app.get("/v1/workspaces/:workspaceId/environments", async (c) => {
37
+ const workspaceId = c.req.param("workspaceId");
38
+ await requireAccessGrant(c, deps, workspaceId, "environments:use");
39
+ return c.json(await listWorkspaceEnvironments(db, workspaceId));
40
+ });
41
+
42
+ app.post("/v1/workspaces/:workspaceId/environments", async (c) => {
43
+ const workspaceId = c.req.param("workspaceId");
44
+ const grant = await requireAccessGrant(c, deps, workspaceId, "environments:manage");
45
+ const key = requireEnvironmentEncryption(settings);
46
+ const payload = CreateWorkspaceEnvironmentRequest.parse(await c.req.json());
47
+ const name = trimmedEnvironmentName(payload.name);
48
+ if (payload.variables.length > MAX_VARIABLES_PER_ENVIRONMENT) {
49
+ throw new HTTPException(422, { message: `an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables` });
50
+ }
51
+ const variableNames = new Set<string>();
52
+ for (const variable of payload.variables) {
53
+ assertAllowedEnvironmentVariableName(variable.name);
54
+ if (variableNames.has(variable.name)) {
55
+ throw new HTTPException(422, { message: `duplicate environment variable name: ${variable.name}` });
56
+ }
57
+ variableNames.add(variable.name);
58
+ }
59
+ if (await countWorkspaceEnvironments(db, workspaceId) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
60
+ throw new HTTPException(422, { message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} environments` });
61
+ }
62
+ if (await getWorkspaceEnvironmentByName(db, workspaceId, name)) {
63
+ throw new HTTPException(409, { message: `environment name is already in use: ${name}` });
64
+ }
65
+ // Values are encrypted up front and the environment plus all initial
66
+ // variables are written in one transaction: a failure leaves nothing.
67
+ const created = await createWorkspaceEnvironment(db, {
68
+ accountId: grant.accountId,
69
+ workspaceId,
70
+ name,
71
+ description: payload.description ?? null,
72
+ variables: payload.variables.map((variable) => ({
73
+ name: variable.name,
74
+ valueEncrypted: encryptEnvironmentValue(key, variable.value),
75
+ })),
76
+ });
77
+ await recordEnvironmentAuditEvent(db, { grant, action: "environment.created", environmentId: created.id });
78
+ return c.json(created, 201);
79
+ });
80
+
81
+ app.get("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
82
+ const workspaceId = c.req.param("workspaceId");
83
+ await requireAccessGrant(c, deps, workspaceId, "environments:use");
84
+ return c.json(await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId")));
85
+ });
86
+
87
+ app.patch("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
88
+ const workspaceId = c.req.param("workspaceId");
89
+ const grant = await requireAccessGrant(c, deps, workspaceId, "environments:manage");
90
+ const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
91
+ const payload = UpdateWorkspaceEnvironmentRequest.parse(await c.req.json());
92
+ const name = payload.name !== undefined ? trimmedEnvironmentName(payload.name) : undefined;
93
+ if (name !== undefined && name !== environment.name) {
94
+ const existing = await getWorkspaceEnvironmentByName(db, workspaceId, name);
95
+ if (existing && existing.id !== environment.id) {
96
+ throw new HTTPException(409, { message: `environment name is already in use: ${name}` });
97
+ }
98
+ }
99
+ const updated = await updateWorkspaceEnvironment(db, workspaceId, environment.id, {
100
+ ...(name !== undefined ? { name } : {}),
101
+ ...(payload.description !== undefined ? { description: payload.description } : {}),
102
+ });
103
+ await recordEnvironmentAuditEvent(db, { grant, action: "environment.updated", environmentId: environment.id });
104
+ return c.json(updated);
105
+ });
106
+
107
+ app.delete("/v1/workspaces/:workspaceId/environments/:environmentId", async (c) => {
108
+ const workspaceId = c.req.param("workspaceId");
109
+ const grant = await requireAccessGrant(c, deps, workspaceId, "environments:manage");
110
+ const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
111
+ const attachedTasks = await countScheduledTasksUsingEnvironment(db, workspaceId, environment.id);
112
+ if (attachedTasks > 0) {
113
+ throw new HTTPException(409, { message: `environment is attached to ${attachedTasks} scheduled task(s); detach first` });
114
+ }
115
+ const activeSessions = await countActiveSessionsUsingEnvironment(db, workspaceId, environment.id);
116
+ if (activeSessions > 0) {
117
+ throw new HTTPException(409, { message: `environment is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first` });
118
+ }
119
+ await deleteWorkspaceEnvironment(db, workspaceId, environment.id);
120
+ await recordEnvironmentAuditEvent(db, { grant, action: "environment.deleted", environmentId: environment.id });
121
+ return c.json({ ok: true });
122
+ });
123
+
124
+ app.put("/v1/workspaces/:workspaceId/environments/:environmentId/variables/:name", async (c) => {
125
+ const workspaceId = c.req.param("workspaceId");
126
+ const grant = await requireAccessGrant(c, deps, workspaceId, "environments:manage");
127
+ const key = requireEnvironmentEncryption(settings);
128
+ const name = parseVariableName(c.req.param("name"));
129
+ const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
130
+ const payload = SetWorkspaceEnvironmentVariableRequest.parse(await c.req.json());
131
+ const exists = environment.variables.some((variable) => variable.name === name);
132
+ if (!exists && environment.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
133
+ throw new HTTPException(422, { message: `an environment supports at most ${MAX_VARIABLES_PER_ENVIRONMENT} variables` });
134
+ }
135
+ const metadata = await setWorkspaceEnvironmentVariable(db, {
136
+ accountId: grant.accountId,
137
+ workspaceId,
138
+ environmentId: environment.id,
139
+ name,
140
+ valueEncrypted: encryptEnvironmentValue(key, payload.value),
141
+ });
142
+ await recordEnvironmentAuditEvent(db, { grant, action: "environment.variable.set", environmentId: environment.id, variableName: name });
143
+ return c.json(metadata);
144
+ });
145
+
146
+ app.delete("/v1/workspaces/:workspaceId/environments/:environmentId/variables/:name", async (c) => {
147
+ const workspaceId = c.req.param("workspaceId");
148
+ const grant = await requireAccessGrant(c, deps, workspaceId, "environments:manage");
149
+ const name = parseVariableName(c.req.param("name"));
150
+ const environment = await requireEnvironmentForApi(db, workspaceId, c.req.param("environmentId"));
151
+ const deleted = await deleteWorkspaceEnvironmentVariable(db, workspaceId, environment.id, name);
152
+ if (!deleted) {
153
+ throw new HTTPException(404, { message: "environment variable not found" });
154
+ }
155
+ await recordEnvironmentAuditEvent(db, { grant, action: "environment.variable.deleted", environmentId: environment.id, variableName: name });
156
+ return c.json({ ok: true });
157
+ });
158
+ }
159
+
160
+ function parseVariableName(raw: string): string {
161
+ const parsed = WorkspaceEnvironmentVariableName.safeParse(raw);
162
+ if (!parsed.success) {
163
+ throw new HTTPException(422, { message: "environment variable names must match ^[A-Z][A-Z0-9_]*$" });
164
+ }
165
+ assertAllowedEnvironmentVariableName(parsed.data);
166
+ return parsed.data;
167
+ }
168
+
169
+ function trimmedEnvironmentName(name: string): string {
170
+ const trimmed = name.trim();
171
+ if (!trimmed) {
172
+ throw new HTTPException(422, { message: "environment name is required" });
173
+ }
174
+ return trimmed;
175
+ }
@@ -0,0 +1,148 @@
1
+ import {
2
+ CompleteFileUploadResponse,
3
+ CreateFileUploadRequest,
4
+ CreateFileUploadResponse,
5
+ FileAsset,
6
+ FileDownloadUrlResponse,
7
+ } from "@opengeni/contracts";
8
+ import {
9
+ completeFileUpload,
10
+ createFileUpload,
11
+ getFileUpload,
12
+ markFileUploadFailed,
13
+ requireFile,
14
+ } from "@opengeni/db";
15
+ import type { Hono } from "hono";
16
+ import { HTTPException } from "hono/http-exception";
17
+ import { requireAccessGrant } from "@opengeni/core";
18
+ import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
19
+ import type { ApiRouteDeps } from "@opengeni/core";
20
+
21
+ export function registerFileRoutes(app: Hono, deps: ApiRouteDeps): void {
22
+ const { db, objectStorage } = deps;
23
+
24
+ app.post("/v1/workspaces/:workspaceId/files/uploads", async (c) => {
25
+ const workspaceId = c.req.param("workspaceId");
26
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:upload");
27
+ if (!objectStorage) {
28
+ throw new HTTPException(503, { message: "object storage is not configured" });
29
+ }
30
+ const payload = CreateFileUploadRequest.parse(await c.req.json());
31
+ await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "file:upload", quantity: payload.sizeBytes });
32
+ if (payload.sizeBytes > objectStorage.maxSinglePutSizeBytes) {
33
+ throw new HTTPException(413, { message: `file exceeds single PUT limit of ${objectStorage.maxSinglePutSizeBytes} bytes` });
34
+ }
35
+ const fileId = crypto.randomUUID();
36
+ const safeFilename = sanitizeFilename(payload.filename);
37
+ const objectKey = `workspaces/${workspaceId}/files/${fileId}/original/${safeFilename}`;
38
+ const signed = await objectStorage.createPutUrl({
39
+ key: objectKey,
40
+ contentType: payload.contentType,
41
+ ...(payload.sha256 ? { sha256: payload.sha256 } : {}),
42
+ });
43
+ const upload = await createFileUpload(db, {
44
+ accountId: grant.accountId,
45
+ workspaceId,
46
+ fileId,
47
+ filename: payload.filename,
48
+ safeFilename,
49
+ contentType: payload.contentType,
50
+ sizeBytes: payload.sizeBytes,
51
+ sha256: payload.sha256 ?? null,
52
+ bucket: objectStorage.bucket,
53
+ objectKey,
54
+ expiresAt: signed.expiresAt,
55
+ });
56
+ return c.json(CreateFileUploadResponse.parse({
57
+ fileId: upload.file.id,
58
+ uploadId: upload.uploadId,
59
+ putUrl: signed.url,
60
+ requiredHeaders: signed.requiredHeaders,
61
+ expiresAt: upload.expiresAt,
62
+ maxSizeBytes: objectStorage.maxSinglePutSizeBytes,
63
+ }), 201);
64
+ });
65
+
66
+ app.post("/v1/workspaces/:workspaceId/files/uploads/:uploadId/complete", async (c) => {
67
+ const workspaceId = c.req.param("workspaceId");
68
+ const grant = await requireAccessGrant(c, deps, workspaceId, "files:upload");
69
+ if (!objectStorage) {
70
+ throw new HTTPException(503, { message: "object storage is not configured" });
71
+ }
72
+ const upload = await getFileUpload(db, workspaceId, c.req.param("uploadId"));
73
+ if (!upload) {
74
+ throw new HTTPException(404, { message: "file upload not found" });
75
+ }
76
+ if (upload.status !== "pending") {
77
+ throw new HTTPException(409, { message: `file upload is ${upload.status}` });
78
+ }
79
+ if (upload.expiresAt.getTime() < Date.now()) {
80
+ await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
81
+ throw new HTTPException(409, { message: "file upload has expired" });
82
+ }
83
+ const head = await objectStorage.headFile(upload.file).catch((error) => {
84
+ throw new HTTPException(409, { message: `uploaded object is not available: ${error instanceof Error ? error.message : String(error)}` });
85
+ });
86
+ if (Number(head.ContentLength ?? -1) !== upload.file.sizeBytes) {
87
+ await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
88
+ throw new HTTPException(422, { message: "uploaded object size does not match file metadata" });
89
+ }
90
+ if (upload.file.contentType && head.ContentType && head.ContentType !== upload.file.contentType) {
91
+ await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
92
+ throw new HTTPException(422, { message: "uploaded object content type does not match file metadata" });
93
+ }
94
+ if (upload.file.sha256 && head.Metadata?.sha256 !== upload.file.sha256) {
95
+ await markFileUploadFailed(db, workspaceId, upload.id, upload.file.id);
96
+ throw new HTTPException(422, { message: "uploaded object checksum metadata does not match file metadata" });
97
+ }
98
+ const file = await completeFileUpload(db, workspaceId, upload.id);
99
+ await recordWorkspaceUsage(deps, {
100
+ accountId: grant.accountId,
101
+ workspaceId,
102
+ subjectId: grant.subjectId,
103
+ eventType: "file.uploaded",
104
+ quantity: file.sizeBytes,
105
+ unit: "byte",
106
+ sourceResourceType: "file",
107
+ sourceResourceId: file.id,
108
+ idempotencyKey: `file.uploaded:${workspaceId}:${file.id}`,
109
+ });
110
+ return c.json(CompleteFileUploadResponse.parse({ file }));
111
+ });
112
+
113
+ app.get("/v1/workspaces/:workspaceId/files/:fileId", async (c) => {
114
+ const workspaceId = c.req.param("workspaceId");
115
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
116
+ const file = await requireFile(db, workspaceId, c.req.param("fileId")).catch(() => null);
117
+ if (!file) {
118
+ throw new HTTPException(404, { message: "file not found" });
119
+ }
120
+ return c.json(FileAsset.parse(file));
121
+ });
122
+
123
+ app.post("/v1/workspaces/:workspaceId/files/:fileId/download-url", async (c) => {
124
+ const workspaceId = c.req.param("workspaceId");
125
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
126
+ if (!objectStorage) {
127
+ throw new HTTPException(503, { message: "object storage is not configured" });
128
+ }
129
+ const file = await requireFile(db, workspaceId, c.req.param("fileId")).catch(() => null);
130
+ if (!file) {
131
+ throw new HTTPException(404, { message: "file not found" });
132
+ }
133
+ if (file.status !== "ready") {
134
+ throw new HTTPException(409, { message: `file is ${file.status}` });
135
+ }
136
+ const signed = await objectStorage.createGetUrl({ key: file.objectKey });
137
+ return c.json(FileDownloadUrlResponse.parse({
138
+ url: signed.url,
139
+ expiresAt: signed.expiresAt.toISOString(),
140
+ }));
141
+ });
142
+ }
143
+
144
+ function sanitizeFilename(filename: string): string {
145
+ const trimmed = filename.trim().replace(/[/\\]/g, "_");
146
+ const safe = trimmed.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim();
147
+ return safe || "file";
148
+ }