@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,393 @@
1
+ // Codex (ChatGPT) subscription connect / status / usage routes.
2
+ //
3
+ // Connect uses the device-code flow split into two stateless calls: `start`
4
+ // returns a user code + verification URL and an HMAC-signed state carrying the
5
+ // device_auth_id; the client opens the URL, authorizes, then drives `poll` on the
6
+ // returned interval. No browser redirect and no 15-minute server block, so nothing
7
+ // is added to isAuthExempt. Secrets never leave the server: status/usage read the
8
+ // decrypted token only to call the codex backend; the token is never returned.
9
+
10
+ import { environmentsEncryptionKeyBytes } from "@opengeni/config";
11
+ import {
12
+ accessTokenExpiry,
13
+ buildCodexUsageWindowFromCache,
14
+ CODEX_CLIENT_VERSION,
15
+ CODEX_FALLBACK_MODEL_SLUGS,
16
+ CODEX_FIVE_HOUR_WINDOW_SECONDS,
17
+ CODEX_MODEL_ID_PREFIX,
18
+ CODEX_PROVIDER_ID,
19
+ CODEX_WEEKLY_WINDOW_SECONDS,
20
+ CodexDeviceError,
21
+ exchangeDeviceCode,
22
+ fetchCodexModels,
23
+ parseIdToken,
24
+ pollDeviceCode,
25
+ startDeviceCode,
26
+ type CodexUsagePayload,
27
+ } from "@opengeni/codex";
28
+ import {
29
+ disconnectAllCodexAccounts,
30
+ disconnectCodexAccount,
31
+ encryptEnvironmentValue,
32
+ ensureCodexRotationSettings,
33
+ fetchCodexUsageForAccount,
34
+ getCodexCredentialStatus,
35
+ getCodexRotationSettings,
36
+ listCodexAccountStatuses,
37
+ loadCodexCredentialForRun,
38
+ renameCodexAccount,
39
+ setActiveCodexCredential,
40
+ updateCodexRotationSettings,
41
+ upsertCodexSubscriptionCredential,
42
+ CODEX_ROTATION_STRATEGIES,
43
+ type CodexAccountStatus,
44
+ type CodexRotationStrategy,
45
+ } from "@opengeni/db";
46
+
47
+ // The picker surfaces codex models under their own "no credits" provider group so
48
+ // they read distinctly from the platform provider's same-named model.
49
+ const CODEX_PROVIDER_LABEL = "Codex subscription · no credits";
50
+
51
+ // The wire shape for one Codex account (metadata only; never the secret column).
52
+ // P2: fiveHour/weekly ride along, built from the CACHED usage columns (zero
53
+ // provider calls, zero decrypts) so the bars render instantly off this read.
54
+ function codexAccountJson(row: CodexAccountStatus) {
55
+ return {
56
+ id: row.id,
57
+ chatgptAccountId: row.chatgptAccountId,
58
+ label: row.label,
59
+ email: row.accountEmail,
60
+ plan: row.planType,
61
+ status: row.status,
62
+ active: row.isActive,
63
+ expiresAt: row.expiresAt,
64
+ lastRefreshAt: row.lastRefreshAt,
65
+ lastError: row.lastError,
66
+ fiveHour: buildCodexUsageWindowFromCache(row.primaryUsedPercent, row.primaryResetAt, CODEX_FIVE_HOUR_WINDOW_SECONDS),
67
+ weekly: buildCodexUsageWindowFromCache(row.secondaryUsedPercent, row.secondaryResetAt, CODEX_WEEKLY_WINDOW_SECONDS),
68
+ usageCheckedAt: row.usageCheckedAt,
69
+ // P3 rotation cooldown: when set and in the future, this account is cooling-down.
70
+ exhaustedUntil: row.exhaustedUntil,
71
+ };
72
+ }
73
+
74
+ // The /codex/usage{,/refresh,/:id} wire wrapper: the rich normalized payload
75
+ // carries its own `status`, surfaced at the top level for back-compat with the
76
+ // existing CodexUsage = { status; usage } shape.
77
+ function codexUsageJson(payload: CodexUsagePayload): { status: CodexUsagePayload["status"]; usage: CodexUsagePayload } {
78
+ return { status: payload.status, usage: payload };
79
+ }
80
+
81
+ function codexModelsForPicker(slugs: string[]): Array<{ id: string; label: string; provider: string; providerLabel: string; api: "responses" }> {
82
+ return slugs
83
+ .filter((slug) => (/^gpt-5/.test(slug) || slug.includes("codex")) && slug !== "codex-auto-review")
84
+ .map((slug) => ({
85
+ id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
86
+ label: slug.replace(/^gpt-/, "GPT-"),
87
+ provider: CODEX_PROVIDER_ID,
88
+ providerLabel: CODEX_PROVIDER_LABEL,
89
+ api: "responses" as const,
90
+ }));
91
+ }
92
+ import { createSignedState, readSignedState } from "@opengeni/github";
93
+ import type { ApiRouteDeps } from "@opengeni/core";
94
+ import type { Hono } from "hono";
95
+ import { HTTPException } from "hono/http-exception";
96
+ import { requireAccessGrant } from "@opengeni/core";
97
+
98
+ type CodexConnectState = { workspaceId?: string; deviceAuthId?: string; userCode?: string; iat?: number };
99
+
100
+ const CODEX_DEVICE_EXPIRY_SECONDS = 15 * 60; // the device code expires 15 min after start (spec §1.1)
101
+
102
+ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
103
+ const { db, settings, githubStateSecret } = deps;
104
+
105
+ // Begin device-code login: returns the user code + verification URL and a
106
+ // signed state that carries the device_auth_id back to `poll`.
107
+ app.post("/v1/workspaces/:workspaceId/codex/connect/start", async (c) => {
108
+ const workspaceId = c.req.param("workspaceId");
109
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
110
+ let start: Awaited<ReturnType<typeof startDeviceCode>>;
111
+ try {
112
+ start = await startDeviceCode();
113
+ } catch (error) {
114
+ throw new HTTPException(502, { message: error instanceof CodexDeviceError ? error.message : "failed to start Codex device login" });
115
+ }
116
+ const state = createSignedState(githubStateSecret, { workspaceId, deviceAuthId: start.deviceAuthId, userCode: start.userCode });
117
+ return c.json({ userCode: start.userCode, verificationUri: start.verificationUri, intervalSeconds: start.intervalSeconds, state });
118
+ });
119
+
120
+ // Poll for authorization: pending | expired | connected (persists on success).
121
+ app.post("/v1/workspaces/:workspaceId/codex/connect/poll", async (c) => {
122
+ const workspaceId = c.req.param("workspaceId");
123
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
124
+ const { state } = (await c.req.json()) as { state?: string };
125
+ const payload = (state ? readSignedState(state, githubStateSecret) : null) as unknown as CodexConnectState | null;
126
+ if (!payload || payload.workspaceId !== workspaceId || !payload.deviceAuthId || !payload.userCode) {
127
+ throw new HTTPException(400, { message: "codex connect state is invalid or expired" });
128
+ }
129
+ // The device code itself expires 15 minutes after start; surface that to the
130
+ // client (the 1-hour signed-state TTL is longer than the device window).
131
+ if (typeof payload.iat === "number" && Date.now() / 1000 - payload.iat > CODEX_DEVICE_EXPIRY_SECONDS) {
132
+ return c.json({ status: "expired" });
133
+ }
134
+
135
+ let poll: Awaited<ReturnType<typeof pollDeviceCode>>;
136
+ try {
137
+ poll = await pollDeviceCode({ deviceAuthId: payload.deviceAuthId, userCode: payload.userCode });
138
+ } catch (error) {
139
+ throw new HTTPException(502, { message: error instanceof CodexDeviceError ? error.message : "codex device poll failed" });
140
+ }
141
+ if (poll.status === "pending") {
142
+ return c.json({ status: "pending" });
143
+ }
144
+ if (poll.status === "expired") {
145
+ return c.json({ status: "expired" });
146
+ }
147
+
148
+ let tokens: Awaited<ReturnType<typeof exchangeDeviceCode>>;
149
+ try {
150
+ tokens = await exchangeDeviceCode({ authorizationCode: poll.authorizationCode, codeVerifier: poll.codeVerifier });
151
+ } catch (error) {
152
+ throw new HTTPException(502, { message: error instanceof CodexDeviceError ? error.message : "codex token exchange failed" });
153
+ }
154
+ const id = parseIdToken(tokens.idToken);
155
+ const key = environmentsEncryptionKeyBytes(settings);
156
+ if (!key) {
157
+ throw new HTTPException(500, { message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured" });
158
+ }
159
+ const upserted = await upsertCodexSubscriptionCredential(db, {
160
+ accountId: grant.accountId,
161
+ workspaceId,
162
+ credentialEncrypted: encryptEnvironmentValue(
163
+ key,
164
+ JSON.stringify({ access_token: tokens.accessToken, refresh_token: tokens.refreshToken, id_token: tokens.idToken }),
165
+ ),
166
+ chatgptAccountId: id.chatgptAccountId,
167
+ scopes: null, // device grant scopes are discovered at runtime, not asserted here
168
+ planType: id.planType,
169
+ isFedramp: id.isFedramp,
170
+ expiresAt: accessTokenExpiry(tokens.accessToken),
171
+ lastRefreshAt: new Date(),
172
+ accountEmail: id.email ?? null,
173
+ label: id.email ?? id.chatgptAccountId ?? null,
174
+ });
175
+ // Ensure the per-workspace rotation-settings row exists, then auto-activate
176
+ // the FIRST account only. Additional new accounts do NOT auto-activate — a
177
+ // manual switch is required (no auto-rotation in P1). A re-connect of the
178
+ // already-active account is a no-op for the pointer.
179
+ await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
180
+ const rotation = await getCodexRotationSettings(db, workspaceId);
181
+ let isActive = rotation?.activeCredentialId === upserted.id;
182
+ if (!isActive && rotation?.activeCredentialId == null) {
183
+ await setActiveCodexCredential(db, workspaceId, upserted.id);
184
+ isActive = true;
185
+ }
186
+ return c.json({ status: "connected", plan: id.planType, accountId: upserted.id, isActive });
187
+ });
188
+
189
+ // Connection health: the cheapest real call is GET /codex/models (a 200 proves
190
+ // the token is accepted). Never runs a generation. Never returns the token.
191
+ app.get("/v1/workspaces/:workspaceId/codex/status", async (c) => {
192
+ const workspaceId = c.req.param("workspaceId");
193
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
194
+ const status = await getCodexCredentialStatus(db, workspaceId);
195
+ if (!status) {
196
+ return c.json({ connected: false });
197
+ }
198
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
199
+ const activeRow = accounts.find((account) => account.id === status.credentialId) ?? null;
200
+ const activeAccount = activeRow
201
+ ? { id: activeRow.id, label: activeRow.label ?? activeRow.accountEmail ?? activeRow.planType ?? activeRow.chatgptAccountId, chatgptAccountId: activeRow.chatgptAccountId }
202
+ : null;
203
+ let valid = false;
204
+ let models = codexModelsForPicker([...CODEX_FALLBACK_MODEL_SLUGS]); // offline fallback list
205
+ try {
206
+ const cred = status.credentialId ? await loadCodexCredentialForRun(db, settings, workspaceId, status.credentialId) : null;
207
+ if (cred) {
208
+ const live = await fetchCodexModels({
209
+ accessToken: cred.tokens.accessToken,
210
+ chatgptAccountId: cred.chatgptAccountId,
211
+ isFedramp: cred.isFedramp,
212
+ clientVersion: CODEX_CLIENT_VERSION,
213
+ });
214
+ valid = live.ok;
215
+ if (live.ok && live.slugs.length > 0) {
216
+ models = codexModelsForPicker(live.slugs); // prefer the live catalog
217
+ }
218
+ }
219
+ } catch {
220
+ valid = false;
221
+ }
222
+ return c.json({
223
+ connected: status.connected,
224
+ plan: status.planType,
225
+ valid,
226
+ expiresAt: status.expiresAt,
227
+ lastError: status.lastError,
228
+ models, // ClientModel[] the picker surfaces under the "no credits" group
229
+ activeAccount, // the account a session runs on when unpinned (label for the indicator)
230
+ accountCount: accounts.length,
231
+ });
232
+ });
233
+
234
+ // List every connected Codex account (metadata only, never decrypts) + the
235
+ // workspace active pointer + rotation settings. Read access.
236
+ app.get("/v1/workspaces/:workspaceId/codex/accounts", async (c) => {
237
+ const workspaceId = c.req.param("workspaceId");
238
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
239
+ const [accounts, rotation] = await Promise.all([
240
+ listCodexAccountStatuses(db, workspaceId),
241
+ getCodexRotationSettings(db, workspaceId),
242
+ ]);
243
+ const activeAccountId = rotation?.activeCredentialId ?? null;
244
+ return c.json({
245
+ accounts: accounts.map(codexAccountJson),
246
+ activeAccountId,
247
+ settings: {
248
+ rotationEnabled: rotation?.rotationEnabled ?? false,
249
+ rotationStrategy: rotation?.rotationStrategy ?? "most_remaining",
250
+ activeCredentialId: activeAccountId,
251
+ },
252
+ });
253
+ });
254
+
255
+ // Manually switch the workspace ACTIVE account (the one unpinned sessions use).
256
+ // Pure pointer flip; in-flight turns pick it up on their next token fetch.
257
+ app.post("/v1/workspaces/:workspaceId/codex/accounts/:accountId/activate", async (c) => {
258
+ const workspaceId = c.req.param("workspaceId");
259
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
260
+ const accountId = c.req.param("accountId");
261
+ const activated = await setActiveCodexCredential(db, workspaceId, accountId);
262
+ if (!activated) {
263
+ throw new HTTPException(404, { message: "codex account not found" });
264
+ }
265
+ return c.json({ activated: true, accountId });
266
+ });
267
+
268
+ // P3: update rotation settings (enable auto-rotation + pick the strategy). admin access.
269
+ // ensureCodexRotationSettings guarantees the row exists, then a one-cell patch.
270
+ app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
271
+ const workspaceId = c.req.param("workspaceId");
272
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
273
+ const body = (await c.req.json().catch(() => ({}))) as { rotationEnabled?: unknown; rotationStrategy?: unknown };
274
+ const patch: { rotationEnabled?: boolean; rotationStrategy?: CodexRotationStrategy } = {};
275
+ if (typeof body.rotationEnabled === "boolean") {
276
+ patch.rotationEnabled = body.rotationEnabled;
277
+ }
278
+ if (typeof body.rotationStrategy === "string") {
279
+ if (!CODEX_ROTATION_STRATEGIES.includes(body.rotationStrategy as CodexRotationStrategy)) {
280
+ throw new HTTPException(400, { message: "invalid rotation strategy" });
281
+ }
282
+ patch.rotationStrategy = body.rotationStrategy as CodexRotationStrategy;
283
+ }
284
+ if (patch.rotationEnabled === undefined && patch.rotationStrategy === undefined) {
285
+ throw new HTTPException(400, { message: "no settings to update" });
286
+ }
287
+ await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
288
+ const updated = await updateCodexRotationSettings(db, workspaceId, patch);
289
+ if (!updated) {
290
+ throw new HTTPException(404, { message: "codex rotation settings not found" });
291
+ }
292
+ return c.json({
293
+ rotationEnabled: updated.rotationEnabled,
294
+ rotationStrategy: updated.rotationStrategy,
295
+ activeCredentialId: updated.activeCredentialId,
296
+ });
297
+ });
298
+
299
+ // Rename an account (label only in P1).
300
+ app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
301
+ const workspaceId = c.req.param("workspaceId");
302
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
303
+ const accountId = c.req.param("accountId");
304
+ const body = (await c.req.json()) as { label?: string | null };
305
+ const label = typeof body.label === "string" ? body.label : null;
306
+ const renamed = await renameCodexAccount(db, workspaceId, accountId, label);
307
+ if (!renamed) {
308
+ throw new HTTPException(404, { message: "codex account not found" });
309
+ }
310
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
311
+ const row = accounts.find((account) => account.id === accountId);
312
+ if (!row) {
313
+ throw new HTTPException(404, { message: "codex account not found" });
314
+ }
315
+ return c.json(codexAccountJson(row));
316
+ });
317
+
318
+ // Disconnect ONE account by id. The accessor re-picks active when the removed
319
+ // row was active (FK ON DELETE SET NULL + re-pick in the same RLS txn).
320
+ app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
321
+ const workspaceId = c.req.param("workspaceId");
322
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
323
+ const accountId = c.req.param("accountId");
324
+ const result = await disconnectCodexAccount(db, workspaceId, accountId);
325
+ return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
326
+ });
327
+
328
+ // Legacy "disconnect all" (old workspace-wide behavior), deprecated in favor of
329
+ // the by-id route above.
330
+ app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
331
+ const workspaceId = c.req.param("workspaceId");
332
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
333
+ const removed = await disconnectAllCodexAccounts(db, workspaceId);
334
+ return c.json({ disconnected: removed > 0 });
335
+ });
336
+
337
+ // Back-compat: remaining usage / limits for the ACTIVE account only. Repointed
338
+ // through the refreshing wrapper (P2) so it no longer 401s on an idle account's
339
+ // stale access token. Deprecated in favor of the /accounts + /usage/refresh pair.
340
+ app.get("/v1/workspaces/:workspaceId/codex/usage", async (c) => {
341
+ const workspaceId = c.req.param("workspaceId");
342
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
343
+ const status = await getCodexCredentialStatus(db, workspaceId);
344
+ if (!status?.credentialId) {
345
+ throw new HTTPException(404, { message: "codex subscription is not connected" });
346
+ }
347
+ const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
348
+ return c.json(codexUsageJson(payload));
349
+ });
350
+
351
+ // Single-account LIVE usage read (per-row manual refresh): refresh THIS account's
352
+ // bearer, hit /wham/usage, write the cache columns, return the normalized payload.
353
+ app.get("/v1/workspaces/:workspaceId/codex/accounts/:accountId/usage", async (c) => {
354
+ const workspaceId = c.req.param("workspaceId");
355
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
356
+ const accountId = c.req.param("accountId");
357
+ // Constrain to a real account in this workspace (RLS already scopes, but a 404
358
+ // for an unknown id is friendlier than an opaque needs_relogin payload).
359
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
360
+ if (!accounts.some((account) => account.id === accountId)) {
361
+ throw new HTTPException(404, { message: "codex account not found" });
362
+ }
363
+ const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, accountId);
364
+ return c.json(codexUsageJson(payload));
365
+ });
366
+
367
+ // Batched LIVE refresh across every connected account, keyed by credential id.
368
+ // A small concurrency cap + Promise.allSettled so one account's 401/error/timeout
369
+ // can't sink the batch; each entry is independently statused. Writes the cache
370
+ // columns as a side effect. This is what the "Refresh" button and an on-mount
371
+ // staleness check call — NEVER a browser interval.
372
+ app.post("/v1/workspaces/:workspaceId/codex/usage/refresh", async (c) => {
373
+ const workspaceId = c.req.param("workspaceId");
374
+ await requireAccessGrant(c, deps, workspaceId, "workspace:read");
375
+ const accounts = await listCodexAccountStatuses(db, workspaceId);
376
+ const usage: Record<string, { status: CodexUsagePayload["status"]; usage: CodexUsagePayload }> = {};
377
+ const queue = [...accounts];
378
+ const CONCURRENCY = 4;
379
+ const worker = async (): Promise<void> => {
380
+ for (;;) {
381
+ const account = queue.shift();
382
+ if (!account) return;
383
+ const settled = await Promise.allSettled([fetchCodexUsageForAccount(db, settings, workspaceId, account.id)]);
384
+ const result = settled[0];
385
+ usage[account.id] = result.status === "fulfilled"
386
+ ? codexUsageJson(result.value)
387
+ : { status: "error", usage: { status: "error", planType: null, fiveHour: null, weekly: null, limitReached: false, fetchedAt: new Date().toISOString() } };
388
+ }
389
+ };
390
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, Math.max(1, accounts.length)) }, () => worker()));
391
+ return c.json({ usage });
392
+ });
393
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ AddDocumentRequest,
3
+ CreateDocumentBaseRequest,
4
+ Document,
5
+ DocumentBase,
6
+ DocumentSearchRequest,
7
+ } from "@opengeni/contracts";
8
+ import {
9
+ addDocumentToBase,
10
+ createDocumentBase,
11
+ deleteDocumentFromBase,
12
+ getDocument,
13
+ getDocumentBase,
14
+ listDocumentBases,
15
+ listDocuments,
16
+ queueDocumentForReindex,
17
+ searchDocuments,
18
+ } from "@opengeni/documents";
19
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
20
+ import type { Hono } from "hono";
21
+ import { HTTPException } from "hono/http-exception";
22
+ import { requireAccessGrant } from "@opengeni/core";
23
+ import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
24
+ import type { ApiRouteDeps } from "@opengeni/core";
25
+ import { buildDocumentsMcpServer } from "../mcp/documents";
26
+
27
+ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
28
+ const { db, objectStorage, documentIndexer, getDocumentServices } = deps;
29
+
30
+ app.post("/v1/workspaces/:workspaceId/document-bases", async (c) => {
31
+ const workspaceId = c.req.param("workspaceId");
32
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
33
+ const payload = CreateDocumentBaseRequest.parse(await c.req.json());
34
+ return c.json(DocumentBase.parse(await createDocumentBase(db, { ...payload, accountId: grant.accountId, workspaceId })), 201);
35
+ });
36
+
37
+ app.get("/v1/workspaces/:workspaceId/document-bases", async (c) => {
38
+ const workspaceId = c.req.param("workspaceId");
39
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
40
+ return c.json((await listDocumentBases(db, workspaceId)).map((base) => DocumentBase.parse(base)));
41
+ });
42
+
43
+ app.get("/v1/workspaces/:workspaceId/document-bases/:baseId", async (c) => {
44
+ const workspaceId = c.req.param("workspaceId");
45
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
46
+ const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
47
+ if (!base) {
48
+ throw new HTTPException(404, { message: "document base not found" });
49
+ }
50
+ return c.json(DocumentBase.parse(base));
51
+ });
52
+
53
+ app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
54
+ const workspaceId = c.req.param("workspaceId");
55
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
56
+ if (!objectStorage) {
57
+ throw new HTTPException(503, { message: "object storage is not configured" });
58
+ }
59
+ await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
60
+ const payload = AddDocumentRequest.parse(await c.req.json());
61
+ try {
62
+ const document = await addDocumentToBase(db, { accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId"), fileId: payload.fileId });
63
+ const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
64
+ const indexed = document.status === "ready" ? document : (await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? document);
65
+ if (indexed.status === "ready") {
66
+ await recordWorkspaceUsage(deps, {
67
+ accountId: grant.accountId,
68
+ workspaceId,
69
+ subjectId: grant.subjectId,
70
+ eventType: "document.indexed",
71
+ quantity: indexed.chunkCount,
72
+ unit: "chunk",
73
+ sourceResourceType: "document",
74
+ sourceResourceId: indexed.id,
75
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`,
76
+ });
77
+ }
78
+ return c.json(Document.parse(indexed), wasCreated ? 201 : 200);
79
+ } catch (error) {
80
+ throw documentHttpException(error);
81
+ }
82
+ });
83
+
84
+ app.get("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
85
+ const workspaceId = c.req.param("workspaceId");
86
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
87
+ return c.json((await listDocuments(db, workspaceId, c.req.param("baseId"))).map((document) => Document.parse(document)));
88
+ });
89
+
90
+ app.delete("/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId", async (c) => {
91
+ const workspaceId = c.req.param("workspaceId");
92
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
93
+ try {
94
+ await deleteDocumentFromBase(db, {
95
+ accountId: grant.accountId,
96
+ workspaceId,
97
+ baseId: c.req.param("baseId"),
98
+ documentId: c.req.param("documentId"),
99
+ });
100
+ return c.body(null, 204);
101
+ } catch (error) {
102
+ throw documentHttpException(error);
103
+ }
104
+ });
105
+
106
+ app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId/reindex", async (c) => {
107
+ const workspaceId = c.req.param("workspaceId");
108
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
109
+ if (!objectStorage) {
110
+ throw new HTTPException(503, { message: "object storage is not configured" });
111
+ }
112
+ await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
113
+ try {
114
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"));
115
+ if (!document) {
116
+ throw new HTTPException(404, { message: "document not found" });
117
+ }
118
+ if (document.status !== "failed") {
119
+ throw new HTTPException(422, { message: "only failed documents can be retried" });
120
+ }
121
+ if (document.baseId !== c.req.param("baseId")) {
122
+ throw new HTTPException(404, { message: "document not found" });
123
+ }
124
+ const queued = await queueDocumentForReindex(db, workspaceId, document.id);
125
+ const indexed = await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? queued;
126
+ if (indexed.status === "ready") {
127
+ await recordWorkspaceUsage(deps, {
128
+ accountId: grant.accountId,
129
+ workspaceId,
130
+ subjectId: grant.subjectId,
131
+ eventType: "document.indexed",
132
+ quantity: indexed.chunkCount,
133
+ unit: "chunk",
134
+ sourceResourceType: "document",
135
+ sourceResourceId: indexed.id,
136
+ idempotencyKey: `document.indexed:${workspaceId}:${indexed.id}:${indexed.updatedAt}`,
137
+ });
138
+ }
139
+ return c.json(Document.parse(indexed));
140
+ } catch (error) {
141
+ if (error instanceof HTTPException) {
142
+ throw error;
143
+ }
144
+ throw documentHttpException(error);
145
+ }
146
+ });
147
+
148
+ app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/search", async (c) => {
149
+ const workspaceId = c.req.param("workspaceId");
150
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
151
+ const payload = DocumentSearchRequest.parse(await c.req.json());
152
+ const base = await getDocumentBase(db, workspaceId, c.req.param("baseId"));
153
+ if (!base) {
154
+ throw new HTTPException(404, { message: "document base not found" });
155
+ }
156
+ return c.json({
157
+ results: await searchDocuments(db, {
158
+ workspaceId,
159
+ baseIds: [base.id],
160
+ query: payload.query,
161
+ limit: payload.limit,
162
+ }, getDocumentServices()),
163
+ });
164
+ });
165
+
166
+ app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
167
+ const workspaceId = c.req.param("workspaceId");
168
+ await requireAccessGrant(c, deps, workspaceId, "documents:search");
169
+ const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
170
+ const server = buildDocumentsMcpServer(db, workspaceId, getDocumentServices());
171
+ await server.connect(transport);
172
+ return await transport.handleRequest(c.req.raw);
173
+ });
174
+ }
175
+
176
+ function documentHttpException(error: unknown): HTTPException {
177
+ const message = error instanceof Error ? error.message : String(error);
178
+ if (message.includes("not found")) {
179
+ return new HTTPException(404, { message });
180
+ }
181
+ if (message.includes("pending") || message.includes("failed") || message.includes("deleted")) {
182
+ return new HTTPException(422, { message });
183
+ }
184
+ return new HTTPException(500, { message });
185
+ }