@opengeni/api-router 0.5.2 → 0.5.4
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.js +1 -1
- package/dist/{chunk-YY6OAEL6.js → chunk-DO2G3JSB.js} +5333 -2205
- package/dist/chunk-DO2G3JSB.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +297 -54
- package/dist/index.js.map +1 -1
- package/package.json +21 -21
- package/src/app.ts +415 -147
- package/src/auth/managed-auth.ts +32 -16
- package/src/http/auth.ts +8 -1
- package/src/http/common.ts +6 -2
- package/src/http/sse.ts +27 -6
- package/src/index.ts +196 -74
- package/src/integrations/oauth-client.ts +592 -131
- package/src/integrations/provider-domain.ts +4 -1
- package/src/mcp/documents.ts +173 -94
- package/src/mcp/server.ts +1517 -692
- package/src/mcp/session-view.ts +8 -2
- package/src/mcp/toolspace.ts +175 -84
- package/src/observability.ts +7 -1
- package/src/routes/api-keys.ts +39 -23
- package/src/routes/billing.ts +180 -65
- package/src/routes/capabilities.ts +17 -8
- package/src/routes/catalog-assets.ts +5 -2
- package/src/routes/codex.ts +244 -63
- package/src/routes/connections.ts +72 -34
- package/src/routes/documents.ts +242 -92
- package/src/routes/enrollments.ts +100 -70
- package/src/routes/environments.ts +205 -136
- package/src/routes/files.ts +164 -39
- package/src/routes/github.ts +123 -50
- package/src/routes/install.ts +9 -2
- package/src/routes/machines.ts +9 -8
- package/src/routes/packs.ts +141 -89
- package/src/routes/rigs.ts +189 -0
- package/src/routes/scheduled-tasks.ts +51 -9
- package/src/routes/sessions.ts +839 -329
- package/src/routes/social.ts +50 -38
- package/src/routes/workspace-capture.ts +238 -0
- package/src/routes/workspaces.ts +159 -13
- package/src/sandbox/access.ts +11 -3
- package/src/sandbox/auth-callout.ts +5 -1
- package/src/sandbox/channel-a.ts +104 -27
- package/src/sandbox/enrollment.ts +13 -3
- package/src/sandbox/machines.ts +68 -59
- package/src/sandbox/metrics-ingestion.ts +238 -17
- package/src/sandbox/viewer.ts +172 -46
- package/dist/chunk-YY6OAEL6.js.map +0 -1
package/src/routes/codex.ts
CHANGED
|
@@ -33,14 +33,18 @@ import {
|
|
|
33
33
|
fetchCodexUsageForAccount,
|
|
34
34
|
getCodexCredentialStatus,
|
|
35
35
|
getCodexRotationSettings,
|
|
36
|
+
listPendingCodexCapacityWakeTargets,
|
|
36
37
|
listCodexAccountStatuses,
|
|
37
38
|
loadCodexCredentialForRun,
|
|
38
39
|
renameCodexAccount,
|
|
39
40
|
setActiveCodexCredential,
|
|
41
|
+
setInitialActiveCodexCredential,
|
|
40
42
|
updateCodexRotationSettings,
|
|
41
43
|
upsertCodexSubscriptionCredential,
|
|
44
|
+
withCodexCapacityMutation,
|
|
42
45
|
CODEX_ROTATION_STRATEGIES,
|
|
43
46
|
type CodexAccountStatus,
|
|
47
|
+
type CodexCapacityWakeTarget,
|
|
44
48
|
type CodexRotationStrategy,
|
|
45
49
|
} from "@opengeni/db";
|
|
46
50
|
|
|
@@ -63,8 +67,16 @@ function codexAccountJson(row: CodexAccountStatus) {
|
|
|
63
67
|
expiresAt: row.expiresAt,
|
|
64
68
|
lastRefreshAt: row.lastRefreshAt,
|
|
65
69
|
lastError: row.lastError,
|
|
66
|
-
fiveHour: buildCodexUsageWindowFromCache(
|
|
67
|
-
|
|
70
|
+
fiveHour: buildCodexUsageWindowFromCache(
|
|
71
|
+
row.primaryUsedPercent,
|
|
72
|
+
row.primaryResetAt,
|
|
73
|
+
CODEX_FIVE_HOUR_WINDOW_SECONDS,
|
|
74
|
+
),
|
|
75
|
+
weekly: buildCodexUsageWindowFromCache(
|
|
76
|
+
row.secondaryUsedPercent,
|
|
77
|
+
row.secondaryResetAt,
|
|
78
|
+
CODEX_WEEKLY_WINDOW_SECONDS,
|
|
79
|
+
),
|
|
68
80
|
usageCheckedAt: row.usageCheckedAt,
|
|
69
81
|
// P3 rotation cooldown: when set and in the future, this account is cooling-down.
|
|
70
82
|
exhaustedUntil: row.exhaustedUntil,
|
|
@@ -74,20 +86,28 @@ function codexAccountJson(row: CodexAccountStatus) {
|
|
|
74
86
|
// The /codex/usage{,/refresh,/:id} wire wrapper: the rich normalized payload
|
|
75
87
|
// carries its own `status`, surfaced at the top level for back-compat with the
|
|
76
88
|
// existing CodexUsage = { status; usage } shape.
|
|
77
|
-
function codexUsageJson(payload: CodexUsagePayload): {
|
|
89
|
+
function codexUsageJson(payload: CodexUsagePayload): {
|
|
90
|
+
status: CodexUsagePayload["status"];
|
|
91
|
+
usage: CodexUsagePayload;
|
|
92
|
+
} {
|
|
78
93
|
return { status: payload.status, usage: payload };
|
|
79
94
|
}
|
|
80
95
|
|
|
81
|
-
function codexModelsForPicker(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
96
|
+
export function codexModelsForPicker(
|
|
97
|
+
liveSlugs: readonly string[],
|
|
98
|
+
): Array<{ id: string; label: string; provider: string; providerLabel: string; api: "responses" }> {
|
|
99
|
+
const available = new Set(liveSlugs);
|
|
100
|
+
const missing = CODEX_FALLBACK_MODEL_SLUGS.filter((slug) => !available.has(slug));
|
|
101
|
+
if (missing.length > 0) {
|
|
102
|
+
throw new Error(`Codex catalog is missing required models: ${missing.join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
return CODEX_FALLBACK_MODEL_SLUGS.map((slug) => ({
|
|
105
|
+
id: `${CODEX_MODEL_ID_PREFIX}${slug}`,
|
|
106
|
+
label: slug.replace(/^gpt-/, "GPT-"),
|
|
107
|
+
provider: CODEX_PROVIDER_ID,
|
|
108
|
+
providerLabel: CODEX_PROVIDER_LABEL,
|
|
109
|
+
api: "responses" as const,
|
|
110
|
+
}));
|
|
91
111
|
}
|
|
92
112
|
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
93
113
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
@@ -95,7 +115,46 @@ import type { Hono } from "hono";
|
|
|
95
115
|
import { HTTPException } from "hono/http-exception";
|
|
96
116
|
import { requireAccessGrant } from "@opengeni/core";
|
|
97
117
|
|
|
98
|
-
type CodexConnectState = {
|
|
118
|
+
type CodexConnectState = {
|
|
119
|
+
workspaceId?: string;
|
|
120
|
+
deviceAuthId?: string;
|
|
121
|
+
userCode?: string;
|
|
122
|
+
iat?: number;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
async function signalCodexCapacityTargets(
|
|
126
|
+
deps: ApiRouteDeps,
|
|
127
|
+
targets: CodexCapacityWakeTarget[],
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
await Promise.allSettled(
|
|
130
|
+
targets.map((target) =>
|
|
131
|
+
deps.workflowClient.signalCodexCapacity
|
|
132
|
+
? deps.workflowClient.signalCodexCapacity({
|
|
133
|
+
accountId: target.accountId,
|
|
134
|
+
workspaceId: target.workspaceId,
|
|
135
|
+
sessionId: target.sessionId,
|
|
136
|
+
workflowId: target.workflowId,
|
|
137
|
+
wakeRevision: target.wakeRevision,
|
|
138
|
+
workflowWakeRevision: target.workflowWakeRevision,
|
|
139
|
+
})
|
|
140
|
+
: deps.workflowClient.wakeSessionWorkflow({
|
|
141
|
+
accountId: target.accountId,
|
|
142
|
+
workspaceId: target.workspaceId,
|
|
143
|
+
sessionId: target.sessionId,
|
|
144
|
+
workflowId: target.workflowId,
|
|
145
|
+
wakeRevision: target.workflowWakeRevision,
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function signalPendingCodexCapacityTargets(
|
|
152
|
+
deps: ApiRouteDeps,
|
|
153
|
+
workspaceId: string,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const targets = await listPendingCodexCapacityWakeTargets(deps.db, workspaceId).catch(() => []);
|
|
156
|
+
await signalCodexCapacityTargets(deps, targets);
|
|
157
|
+
}
|
|
99
158
|
|
|
100
159
|
const CODEX_DEVICE_EXPIRY_SECONDS = 15 * 60; // the device code expires 15 min after start (spec §1.1)
|
|
101
160
|
|
|
@@ -111,10 +170,22 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
111
170
|
try {
|
|
112
171
|
start = await startDeviceCode();
|
|
113
172
|
} catch (error) {
|
|
114
|
-
throw new HTTPException(502, {
|
|
173
|
+
throw new HTTPException(502, {
|
|
174
|
+
message:
|
|
175
|
+
error instanceof CodexDeviceError ? error.message : "failed to start Codex device login",
|
|
176
|
+
});
|
|
115
177
|
}
|
|
116
|
-
const state = createSignedState(githubStateSecret, {
|
|
117
|
-
|
|
178
|
+
const state = createSignedState(githubStateSecret, {
|
|
179
|
+
workspaceId,
|
|
180
|
+
deviceAuthId: start.deviceAuthId,
|
|
181
|
+
userCode: start.userCode,
|
|
182
|
+
});
|
|
183
|
+
return c.json({
|
|
184
|
+
userCode: start.userCode,
|
|
185
|
+
verificationUri: start.verificationUri,
|
|
186
|
+
intervalSeconds: start.intervalSeconds,
|
|
187
|
+
state,
|
|
188
|
+
});
|
|
118
189
|
});
|
|
119
190
|
|
|
120
191
|
// Poll for authorization: pending | expired | connected (persists on success).
|
|
@@ -122,21 +193,36 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
122
193
|
const workspaceId = c.req.param("workspaceId");
|
|
123
194
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
124
195
|
const { state } = (await c.req.json()) as { state?: string };
|
|
125
|
-
const payload = (state
|
|
126
|
-
|
|
196
|
+
const payload = (state
|
|
197
|
+
? readSignedState(state, githubStateSecret)
|
|
198
|
+
: null) as unknown as CodexConnectState | null;
|
|
199
|
+
if (
|
|
200
|
+
!payload ||
|
|
201
|
+
payload.workspaceId !== workspaceId ||
|
|
202
|
+
!payload.deviceAuthId ||
|
|
203
|
+
!payload.userCode
|
|
204
|
+
) {
|
|
127
205
|
throw new HTTPException(400, { message: "codex connect state is invalid or expired" });
|
|
128
206
|
}
|
|
129
207
|
// The device code itself expires 15 minutes after start; surface that to the
|
|
130
208
|
// client (the 1-hour signed-state TTL is longer than the device window).
|
|
131
|
-
if (
|
|
209
|
+
if (
|
|
210
|
+
typeof payload.iat === "number" &&
|
|
211
|
+
Date.now() / 1000 - payload.iat > CODEX_DEVICE_EXPIRY_SECONDS
|
|
212
|
+
) {
|
|
132
213
|
return c.json({ status: "expired" });
|
|
133
214
|
}
|
|
134
215
|
|
|
135
216
|
let poll: Awaited<ReturnType<typeof pollDeviceCode>>;
|
|
136
217
|
try {
|
|
137
|
-
poll = await pollDeviceCode({
|
|
218
|
+
poll = await pollDeviceCode({
|
|
219
|
+
deviceAuthId: payload.deviceAuthId,
|
|
220
|
+
userCode: payload.userCode,
|
|
221
|
+
});
|
|
138
222
|
} catch (error) {
|
|
139
|
-
throw new HTTPException(502, {
|
|
223
|
+
throw new HTTPException(502, {
|
|
224
|
+
message: error instanceof CodexDeviceError ? error.message : "codex device poll failed",
|
|
225
|
+
});
|
|
140
226
|
}
|
|
141
227
|
if (poll.status === "pending") {
|
|
142
228
|
return c.json({ status: "pending" });
|
|
@@ -147,42 +233,65 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
147
233
|
|
|
148
234
|
let tokens: Awaited<ReturnType<typeof exchangeDeviceCode>>;
|
|
149
235
|
try {
|
|
150
|
-
tokens = await exchangeDeviceCode({
|
|
236
|
+
tokens = await exchangeDeviceCode({
|
|
237
|
+
authorizationCode: poll.authorizationCode,
|
|
238
|
+
codeVerifier: poll.codeVerifier,
|
|
239
|
+
});
|
|
151
240
|
} catch (error) {
|
|
152
|
-
throw new HTTPException(502, {
|
|
241
|
+
throw new HTTPException(502, {
|
|
242
|
+
message: error instanceof CodexDeviceError ? error.message : "codex token exchange failed",
|
|
243
|
+
});
|
|
153
244
|
}
|
|
154
245
|
const id = parseIdToken(tokens.idToken);
|
|
155
246
|
const key = environmentsEncryptionKeyBytes(settings);
|
|
156
247
|
if (!key) {
|
|
157
|
-
throw new HTTPException(500, {
|
|
248
|
+
throw new HTTPException(500, {
|
|
249
|
+
message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured",
|
|
250
|
+
});
|
|
158
251
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
252
|
+
await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
|
|
253
|
+
const mutation = await withCodexCapacityMutation(
|
|
254
|
+
db,
|
|
255
|
+
{ workspaceId, reason: "codex_credential_connected" },
|
|
256
|
+
async (tx) => {
|
|
257
|
+
const upserted = await upsertCodexSubscriptionCredential(tx, {
|
|
258
|
+
accountId: grant.accountId,
|
|
259
|
+
workspaceId,
|
|
260
|
+
credentialEncrypted: encryptEnvironmentValue(
|
|
261
|
+
key,
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
access_token: tokens.accessToken,
|
|
264
|
+
refresh_token: tokens.refreshToken,
|
|
265
|
+
id_token: tokens.idToken,
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
chatgptAccountId: id.chatgptAccountId,
|
|
269
|
+
scopes: null, // device grant scopes are discovered at runtime, not asserted here
|
|
270
|
+
planType: id.planType,
|
|
271
|
+
isFedramp: id.isFedramp,
|
|
272
|
+
expiresAt: accessTokenExpiry(tokens.accessToken),
|
|
273
|
+
lastRefreshAt: new Date(),
|
|
274
|
+
accountEmail: id.email ?? null,
|
|
275
|
+
label: id.email ?? id.chatgptAccountId ?? null,
|
|
276
|
+
});
|
|
277
|
+
return { result: upserted, changed: true };
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
const upserted = mutation.result;
|
|
175
281
|
// Ensure the per-workspace rotation-settings row exists, then auto-activate
|
|
176
282
|
// the FIRST account only. Additional new accounts do NOT auto-activate — a
|
|
177
283
|
// manual switch is required (no auto-rotation in P1). A re-connect of the
|
|
178
284
|
// already-active account is a no-op for the pointer.
|
|
179
|
-
|
|
285
|
+
// Keep both rotation bits false on first connect. The deployment flag makes
|
|
286
|
+
// the compatible allocator available, but the workspace-local cutover bit
|
|
287
|
+
// is enabled only by an explicit settings write after every worker replica
|
|
288
|
+
// understands leasing.
|
|
180
289
|
const rotation = await getCodexRotationSettings(db, workspaceId);
|
|
181
290
|
let isActive = rotation?.activeCredentialId === upserted.id;
|
|
182
291
|
if (!isActive && rotation?.activeCredentialId == null) {
|
|
183
|
-
await
|
|
184
|
-
isActive = true;
|
|
292
|
+
isActive = await setInitialActiveCodexCredential(db, workspaceId, upserted.id);
|
|
185
293
|
}
|
|
294
|
+
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
186
295
|
return c.json({ status: "connected", plan: id.planType, accountId: upserted.id, isActive });
|
|
187
296
|
});
|
|
188
297
|
|
|
@@ -198,12 +307,23 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
198
307
|
const accounts = await listCodexAccountStatuses(db, workspaceId);
|
|
199
308
|
const activeRow = accounts.find((account) => account.id === status.credentialId) ?? null;
|
|
200
309
|
const activeAccount = activeRow
|
|
201
|
-
? {
|
|
310
|
+
? {
|
|
311
|
+
id: activeRow.id,
|
|
312
|
+
label:
|
|
313
|
+
activeRow.label ??
|
|
314
|
+
activeRow.accountEmail ??
|
|
315
|
+
activeRow.planType ??
|
|
316
|
+
activeRow.chatgptAccountId,
|
|
317
|
+
chatgptAccountId: activeRow.chatgptAccountId,
|
|
318
|
+
}
|
|
202
319
|
: null;
|
|
203
320
|
let valid = false;
|
|
204
|
-
let models =
|
|
321
|
+
let models: ReturnType<typeof codexModelsForPicker> = [];
|
|
322
|
+
let catalogError: string | null = null;
|
|
205
323
|
try {
|
|
206
|
-
const cred = status.credentialId
|
|
324
|
+
const cred = status.credentialId
|
|
325
|
+
? await loadCodexCredentialForRun(db, settings, workspaceId, status.credentialId)
|
|
326
|
+
: null;
|
|
207
327
|
if (cred) {
|
|
208
328
|
const live = await fetchCodexModels({
|
|
209
329
|
accessToken: cred.tokens.accessToken,
|
|
@@ -211,20 +331,23 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
211
331
|
isFedramp: cred.isFedramp,
|
|
212
332
|
clientVersion: CODEX_CLIENT_VERSION,
|
|
213
333
|
});
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
334
|
+
if (live.ok) {
|
|
335
|
+
models = codexModelsForPicker(live.slugs);
|
|
336
|
+
valid = true;
|
|
337
|
+
} else {
|
|
338
|
+
catalogError = `Codex models request failed with status ${live.status}`;
|
|
217
339
|
}
|
|
218
340
|
}
|
|
219
|
-
} catch {
|
|
341
|
+
} catch (error) {
|
|
220
342
|
valid = false;
|
|
343
|
+
catalogError = error instanceof Error ? error.message : String(error);
|
|
221
344
|
}
|
|
222
345
|
return c.json({
|
|
223
346
|
connected: status.connected,
|
|
224
347
|
plan: status.planType,
|
|
225
348
|
valid,
|
|
226
349
|
expiresAt: status.expiresAt,
|
|
227
|
-
lastError: status.lastError,
|
|
350
|
+
lastError: catalogError ?? status.lastError,
|
|
228
351
|
models, // ClientModel[] the picker surfaces under the "no credits" group
|
|
229
352
|
activeAccount, // the account a session runs on when unpinned (label for the indicator)
|
|
230
353
|
accountCount: accounts.length,
|
|
@@ -258,10 +381,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
258
381
|
const workspaceId = c.req.param("workspaceId");
|
|
259
382
|
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
260
383
|
const accountId = c.req.param("accountId");
|
|
261
|
-
const
|
|
384
|
+
const mutation = await withCodexCapacityMutation(
|
|
385
|
+
db,
|
|
386
|
+
{ workspaceId, reason: "codex_active_credential_changed" },
|
|
387
|
+
async (tx) => {
|
|
388
|
+
const activated = await setActiveCodexCredential(tx, workspaceId, accountId);
|
|
389
|
+
return { result: activated, changed: activated };
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
const activated = mutation.result;
|
|
262
393
|
if (!activated) {
|
|
263
394
|
throw new HTTPException(404, { message: "codex account not found" });
|
|
264
395
|
}
|
|
396
|
+
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
265
397
|
return c.json({ activated: true, accountId });
|
|
266
398
|
});
|
|
267
399
|
|
|
@@ -270,7 +402,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
270
402
|
app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
|
|
271
403
|
const workspaceId = c.req.param("workspaceId");
|
|
272
404
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
273
|
-
const body = (await c.req.json().catch(() => ({}))) as {
|
|
405
|
+
const body = (await c.req.json().catch(() => ({}))) as {
|
|
406
|
+
rotationEnabled?: unknown;
|
|
407
|
+
rotationStrategy?: unknown;
|
|
408
|
+
};
|
|
274
409
|
const patch: { rotationEnabled?: boolean; rotationStrategy?: CodexRotationStrategy } = {};
|
|
275
410
|
if (typeof body.rotationEnabled === "boolean") {
|
|
276
411
|
patch.rotationEnabled = body.rotationEnabled;
|
|
@@ -285,10 +420,19 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
285
420
|
throw new HTTPException(400, { message: "no settings to update" });
|
|
286
421
|
}
|
|
287
422
|
await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
|
|
288
|
-
const
|
|
423
|
+
const mutation = await withCodexCapacityMutation(
|
|
424
|
+
db,
|
|
425
|
+
{ workspaceId, reason: "codex_rotation_settings_changed" },
|
|
426
|
+
async (tx) => {
|
|
427
|
+
const updated = await updateCodexRotationSettings(tx, workspaceId, patch);
|
|
428
|
+
return { result: updated, changed: updated !== null };
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
const updated = mutation.result;
|
|
289
432
|
if (!updated) {
|
|
290
433
|
throw new HTTPException(404, { message: "codex rotation settings not found" });
|
|
291
434
|
}
|
|
435
|
+
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
292
436
|
return c.json({
|
|
293
437
|
rotationEnabled: updated.rotationEnabled,
|
|
294
438
|
rotationStrategy: updated.rotationStrategy,
|
|
@@ -321,7 +465,16 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
321
465
|
const workspaceId = c.req.param("workspaceId");
|
|
322
466
|
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
323
467
|
const accountId = c.req.param("accountId");
|
|
324
|
-
const
|
|
468
|
+
const mutation = await withCodexCapacityMutation(
|
|
469
|
+
db,
|
|
470
|
+
{ workspaceId, reason: "codex_credential_disconnected" },
|
|
471
|
+
async (tx) => {
|
|
472
|
+
const result = await disconnectCodexAccount(tx, workspaceId, accountId);
|
|
473
|
+
return { result, changed: result.removed };
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
const result = mutation.result;
|
|
477
|
+
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
325
478
|
return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
|
|
326
479
|
});
|
|
327
480
|
|
|
@@ -330,7 +483,16 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
330
483
|
app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
|
|
331
484
|
const workspaceId = c.req.param("workspaceId");
|
|
332
485
|
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
333
|
-
const
|
|
486
|
+
const mutation = await withCodexCapacityMutation(
|
|
487
|
+
db,
|
|
488
|
+
{ workspaceId, reason: "codex_credentials_disconnected" },
|
|
489
|
+
async (tx) => {
|
|
490
|
+
const removed = await disconnectAllCodexAccounts(tx, workspaceId);
|
|
491
|
+
return { result: removed, changed: removed > 0 };
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
const removed = mutation.result;
|
|
495
|
+
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
334
496
|
return c.json({ disconnected: removed > 0 });
|
|
335
497
|
});
|
|
336
498
|
|
|
@@ -345,6 +507,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
345
507
|
throw new HTTPException(404, { message: "codex subscription is not connected" });
|
|
346
508
|
}
|
|
347
509
|
const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, status.credentialId);
|
|
510
|
+
await signalPendingCodexCapacityTargets(deps, workspaceId);
|
|
348
511
|
return c.json(codexUsageJson(payload));
|
|
349
512
|
});
|
|
350
513
|
|
|
@@ -361,6 +524,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
361
524
|
throw new HTTPException(404, { message: "codex account not found" });
|
|
362
525
|
}
|
|
363
526
|
const payload = await fetchCodexUsageForAccount(db, settings, workspaceId, accountId);
|
|
527
|
+
await signalPendingCodexCapacityTargets(deps, workspaceId);
|
|
364
528
|
return c.json(codexUsageJson(payload));
|
|
365
529
|
});
|
|
366
530
|
|
|
@@ -373,21 +537,38 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
373
537
|
const workspaceId = c.req.param("workspaceId");
|
|
374
538
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
375
539
|
const accounts = await listCodexAccountStatuses(db, workspaceId);
|
|
376
|
-
const usage: Record<string, { status: CodexUsagePayload["status"]; usage: CodexUsagePayload }> =
|
|
540
|
+
const usage: Record<string, { status: CodexUsagePayload["status"]; usage: CodexUsagePayload }> =
|
|
541
|
+
{};
|
|
377
542
|
const queue = [...accounts];
|
|
378
543
|
const CONCURRENCY = 4;
|
|
379
544
|
const worker = async (): Promise<void> => {
|
|
380
545
|
for (;;) {
|
|
381
546
|
const account = queue.shift();
|
|
382
547
|
if (!account) return;
|
|
383
|
-
const settled = await Promise.allSettled([
|
|
548
|
+
const settled = await Promise.allSettled([
|
|
549
|
+
fetchCodexUsageForAccount(db, settings, workspaceId, account.id),
|
|
550
|
+
]);
|
|
384
551
|
const result = settled[0];
|
|
385
|
-
usage[account.id] =
|
|
386
|
-
|
|
387
|
-
|
|
552
|
+
usage[account.id] =
|
|
553
|
+
result.status === "fulfilled"
|
|
554
|
+
? codexUsageJson(result.value)
|
|
555
|
+
: {
|
|
556
|
+
status: "error",
|
|
557
|
+
usage: {
|
|
558
|
+
status: "error",
|
|
559
|
+
planType: null,
|
|
560
|
+
fiveHour: null,
|
|
561
|
+
weekly: null,
|
|
562
|
+
limitReached: false,
|
|
563
|
+
fetchedAt: new Date().toISOString(),
|
|
564
|
+
},
|
|
565
|
+
};
|
|
388
566
|
}
|
|
389
567
|
};
|
|
390
|
-
await Promise.all(
|
|
568
|
+
await Promise.all(
|
|
569
|
+
Array.from({ length: Math.min(CONCURRENCY, Math.max(1, accounts.length)) }, () => worker()),
|
|
570
|
+
);
|
|
571
|
+
await signalPendingCodexCapacityTargets(deps, workspaceId);
|
|
391
572
|
return c.json({ usage });
|
|
392
573
|
});
|
|
393
574
|
}
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
28
28
|
|
|
29
29
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
30
|
-
const { db, settings } = deps;
|
|
30
|
+
const { db, settings, observability } = deps;
|
|
31
31
|
|
|
32
32
|
function assertIntegrationsEnabled(): void {
|
|
33
33
|
if (!settings.integrationsEnabled) {
|
|
@@ -38,9 +38,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
38
38
|
app.get("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
39
39
|
const workspaceId = c.req.param("workspaceId");
|
|
40
40
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
41
|
-
return c.json(
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
return c.json(
|
|
42
|
+
ListConnectionsResponse.parse({
|
|
43
|
+
connections: await listConnectionsMetadata(db, workspaceId, grant.subjectId),
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
44
46
|
});
|
|
45
47
|
|
|
46
48
|
app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
@@ -67,7 +69,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
67
69
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
68
70
|
const workspaceId = c.req.param("workspaceId");
|
|
69
71
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
70
|
-
const connection = await getConnectionMetadata(
|
|
72
|
+
const connection = await getConnectionMetadata(
|
|
73
|
+
db,
|
|
74
|
+
workspaceId,
|
|
75
|
+
c.req.param("connectionId"),
|
|
76
|
+
grant.subjectId,
|
|
77
|
+
);
|
|
71
78
|
if (!connection) {
|
|
72
79
|
throw new HTTPException(404, { message: "connection not found" });
|
|
73
80
|
}
|
|
@@ -84,26 +91,39 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
84
91
|
// could clear the broker's re-auth signal while stale tokens stay in place.
|
|
85
92
|
if (payload.status !== undefined) {
|
|
86
93
|
if (payload.status !== "active") {
|
|
87
|
-
throw new HTTPException(400, {
|
|
94
|
+
throw new HTTPException(400, {
|
|
95
|
+
message: 'status can only be set to "active"; use DELETE to revoke',
|
|
96
|
+
});
|
|
88
97
|
}
|
|
89
98
|
if (payload.credential === undefined) {
|
|
90
|
-
throw new HTTPException(400, {
|
|
99
|
+
throw new HTTPException(400, {
|
|
100
|
+
message: "reactivating a connection requires a new credential",
|
|
101
|
+
});
|
|
91
102
|
}
|
|
92
103
|
}
|
|
93
104
|
const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
|
|
94
|
-
const subjectId =
|
|
105
|
+
const subjectId =
|
|
106
|
+
payload.subjectId === undefined
|
|
107
|
+
? undefined
|
|
108
|
+
: writableSubjectId(payload.subjectId, grant.subjectId);
|
|
95
109
|
const connection = await updateConnection(db, {
|
|
96
110
|
workspaceId,
|
|
97
111
|
connectionId: c.req.param("connectionId"),
|
|
98
112
|
visibleToSubjectId: grant.subjectId,
|
|
99
113
|
updatedBySubjectId: grant.subjectId,
|
|
100
|
-
...(payload.providerDomain !== undefined
|
|
114
|
+
...(payload.providerDomain !== undefined
|
|
115
|
+
? { providerDomain: canonicalProviderDomain(payload.providerDomain) }
|
|
116
|
+
: {}),
|
|
101
117
|
...(subjectId !== undefined ? { subjectId } : {}),
|
|
102
118
|
...(payload.kind !== undefined ? { kind: payload.kind } : {}),
|
|
103
119
|
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
104
|
-
...(payload.credential !== undefined && key
|
|
120
|
+
...(payload.credential !== undefined && key
|
|
121
|
+
? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) }
|
|
122
|
+
: {}),
|
|
105
123
|
...(payload.grantedScopes !== undefined ? { grantedScopes: payload.grantedScopes } : {}),
|
|
106
|
-
...(payload.expiresAt !== undefined
|
|
124
|
+
...(payload.expiresAt !== undefined
|
|
125
|
+
? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null }
|
|
126
|
+
: {}),
|
|
107
127
|
...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
|
|
108
128
|
});
|
|
109
129
|
if (!connection) {
|
|
@@ -115,7 +135,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
115
135
|
app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
116
136
|
const workspaceId = c.req.param("workspaceId");
|
|
117
137
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
118
|
-
const connection = await revokeConnection(
|
|
138
|
+
const connection = await revokeConnection(
|
|
139
|
+
db,
|
|
140
|
+
workspaceId,
|
|
141
|
+
c.req.param("connectionId"),
|
|
142
|
+
grant.subjectId,
|
|
143
|
+
);
|
|
119
144
|
if (!connection) {
|
|
120
145
|
throw new HTTPException(404, { message: "connection not found" });
|
|
121
146
|
}
|
|
@@ -128,44 +153,57 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
128
153
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
129
154
|
const parsed = OAuthStartRequest.safeParse(await c.req.json());
|
|
130
155
|
if (!parsed.success) {
|
|
131
|
-
throw new HTTPException(400, {
|
|
156
|
+
throw new HTTPException(400, {
|
|
157
|
+
message: parsed.error.issues[0]?.message ?? "invalid OAuth start request",
|
|
158
|
+
});
|
|
132
159
|
}
|
|
133
160
|
const payload = parsed.data;
|
|
134
|
-
const result = await startMcpOAuth(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
const result = await startMcpOAuth(
|
|
162
|
+
{ db, settings, observability },
|
|
163
|
+
{
|
|
164
|
+
accountId: grant.accountId,
|
|
165
|
+
workspaceId,
|
|
166
|
+
subjectId: grant.subjectId,
|
|
167
|
+
requestUrl: c.req.url,
|
|
168
|
+
payload,
|
|
169
|
+
},
|
|
170
|
+
);
|
|
141
171
|
return c.json(OAuthStartResponse.parse(result));
|
|
142
172
|
});
|
|
143
173
|
|
|
144
174
|
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
145
175
|
assertIntegrationsEnabled();
|
|
146
|
-
const result = await completeMcpOAuthCallback(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
176
|
+
const result = await completeMcpOAuthCallback(
|
|
177
|
+
{ db, settings, observability },
|
|
178
|
+
{
|
|
179
|
+
code: c.req.query("code"),
|
|
180
|
+
state: c.req.query("state"),
|
|
181
|
+
requestUrl: c.req.url,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
151
184
|
return c.redirect(result.redirectTo, 302);
|
|
152
185
|
});
|
|
153
186
|
|
|
154
187
|
app.get("/v1/integrations/oauth/client-metadata.json", (c) => {
|
|
155
188
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
156
189
|
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
157
|
-
return c.json(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
return c.json(
|
|
191
|
+
IntegrationClientMetadata.parse({
|
|
192
|
+
client_id: metadataUrl,
|
|
193
|
+
client_name: "OpenGeni",
|
|
194
|
+
redirect_uris: [`${baseUrl}/v1/integrations/oauth/callback`],
|
|
195
|
+
token_endpoint_auth_method: "none",
|
|
196
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
197
|
+
response_types: ["code"],
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
165
200
|
});
|
|
166
201
|
}
|
|
167
202
|
|
|
168
|
-
function writableSubjectId(
|
|
203
|
+
function writableSubjectId(
|
|
204
|
+
requested: string | null | undefined,
|
|
205
|
+
grantSubjectId: string,
|
|
206
|
+
): string | null {
|
|
169
207
|
if (requested == null) {
|
|
170
208
|
return null;
|
|
171
209
|
}
|