@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,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
|
+
}
|