@opengeni/api-router 2.5.0 → 2.6.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/auth/managed-auth-attempt-context.d.ts +5 -1
- package/dist/auth/managed-auth.d.ts +24 -1
- package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
- package/dist/chunk-XTLI3CBH.js.map +1 -0
- package/dist/http/sse.d.ts +9 -0
- package/dist/index.js +104 -4
- package/dist/index.js.map +1 -1
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
- package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
- package/dist/mcp/remember.d.ts +2 -2
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/mcp/session-wait.d.ts +19 -0
- package/dist/routes/api-keys.d.ts +2 -0
- package/dist/routes/browser-sessions.d.ts +1 -0
- package/dist/routes/computer-sessions.d.ts +12 -0
- package/dist/routes/managed-auth-session-sets.d.ts +7 -0
- package/dist/routes/workspaces.d.ts +1 -1
- package/dist/sandbox/metrics-ingestion.d.ts +16 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/package.json +15 -15
- package/src/app.ts +172 -20
- package/src/auth/managed-auth-attempt-context.ts +40 -3
- package/src/auth/managed-auth-session-adapter.ts +1 -0
- package/src/auth/managed-auth.ts +164 -4
- package/src/http/sse.ts +279 -45
- package/src/integrations/oauth-client.ts +8 -1
- package/src/integrations/provider-oauth.ts +12 -2
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/company-brain-governed-writes.ts +38 -23
- package/src/mcp/company-profile-agent-admin.ts +7 -7
- package/src/mcp/remember.ts +19 -8
- package/src/mcp/server.ts +318 -26
- package/src/mcp/session-wait.ts +56 -8
- package/src/routes/api-integrations.ts +2 -2
- package/src/routes/api-keys.ts +149 -7
- package/src/routes/browser-sessions.ts +10 -2
- package/src/routes/capabilities.ts +3 -3
- package/src/routes/codex.ts +483 -74
- package/src/routes/company-profile.ts +64 -0
- package/src/routes/computer-sessions.ts +103 -1
- package/src/routes/integration-facets.ts +8 -5
- package/src/routes/interaction-resources.ts +7 -1
- package/src/routes/managed-auth-session-sets.ts +199 -2
- package/src/routes/organization-memberships.ts +28 -8
- package/src/routes/packs.ts +5 -5
- package/src/routes/plugins.ts +2 -2
- package/src/routes/scheduled-tasks.ts +9 -0
- package/src/routes/sessions.ts +3 -6
- package/src/routes/skills.ts +3 -3
- package/src/routes/workspaces.ts +293 -54
- package/src/sandbox/channel-a.ts +10 -4
- package/src/sandbox/machines.ts +13 -6
- package/src/sandbox/metrics-ingestion.ts +157 -3
- package/src/sandbox/viewer.ts +20 -1
- package/src/workspace-delete-observability.ts +75 -0
- package/dist/chunk-QESX7HDK.js.map +0 -1
package/src/routes/codex.ts
CHANGED
|
@@ -39,8 +39,10 @@ import {
|
|
|
39
39
|
designateCodexAppsCredential,
|
|
40
40
|
disconnectAllCodexAccounts,
|
|
41
41
|
disconnectCodexAccount,
|
|
42
|
+
disconnectOrganizationCodexAccount,
|
|
42
43
|
encryptEnvironmentValue,
|
|
43
44
|
ensureCodexRotationSettings,
|
|
45
|
+
ensureOrganizationCodexRotationSettings,
|
|
44
46
|
fetchCodexUsageForAccount,
|
|
45
47
|
fetchCodexRateLimitResetCreditsForAccount,
|
|
46
48
|
fenceCodexResetRedemptionSend,
|
|
@@ -48,18 +50,29 @@ import {
|
|
|
48
50
|
getCodexCredentialStatus,
|
|
49
51
|
getCodexAppsSettings,
|
|
50
52
|
getCodexRotationSettings,
|
|
53
|
+
getOrganizationCodexRotationSettings,
|
|
54
|
+
getWorkspaceCodexSubscriptionSource,
|
|
51
55
|
listPendingCodexCapacityWakeTargets,
|
|
52
56
|
listCodexAccountStatuses,
|
|
57
|
+
listOrganizationCodexAccountStatuses,
|
|
53
58
|
listCodexResetRedemptionRecoveries,
|
|
59
|
+
nestedPostgresSqlState,
|
|
54
60
|
releaseCodexResetRedemptionClaim,
|
|
55
61
|
updateCodexAllocatorEligibility,
|
|
56
62
|
loadCodexCredentialForRun,
|
|
57
63
|
renameCodexAccount,
|
|
64
|
+
renameOrganizationCodexAccount,
|
|
58
65
|
setActiveCodexCredential,
|
|
66
|
+
setActiveOrganizationCodexCredential,
|
|
59
67
|
setInitialActiveCodexCredential,
|
|
68
|
+
setWorkspaceCodexSubscriptionMode,
|
|
69
|
+
setWorkspaceCodexSubscriptionModeInTransaction,
|
|
70
|
+
updateOrganizationCodexRotationSettings,
|
|
60
71
|
updateCodexRotationSettings,
|
|
72
|
+
upsertOrganizationCodexSubscriptionCredential,
|
|
61
73
|
upsertCodexSubscriptionCredential,
|
|
62
74
|
withCodexCapacityMutation,
|
|
75
|
+
withSessionCodexCapacityMutation,
|
|
63
76
|
type CodexAccountStatus,
|
|
64
77
|
type CodexCapacityWakeTarget,
|
|
65
78
|
} from "@opengeni/db";
|
|
@@ -81,6 +94,7 @@ function codexAccountJson(
|
|
|
81
94
|
) {
|
|
82
95
|
return {
|
|
83
96
|
id: row.id,
|
|
97
|
+
source: row.source,
|
|
84
98
|
chatgptAccountId: row.chatgptAccountId,
|
|
85
99
|
label: row.label,
|
|
86
100
|
email: row.accountEmail,
|
|
@@ -110,6 +124,7 @@ function codexAccountJson(
|
|
|
110
124
|
exhaustedUntil: row.exhaustedUntil,
|
|
111
125
|
appsDesignated: options.appsCredentialId === row.id,
|
|
112
126
|
canEnableApps:
|
|
127
|
+
row.source === "workspace" &&
|
|
113
128
|
options.appsCredentialId === null &&
|
|
114
129
|
options.canManageApps === true &&
|
|
115
130
|
options.humanSubjectId !== null &&
|
|
@@ -154,6 +169,7 @@ import {
|
|
|
154
169
|
getManagedSession,
|
|
155
170
|
hasPermission,
|
|
156
171
|
requireAccessGrant,
|
|
172
|
+
requireCanonicalLocalAccountAdministrator,
|
|
157
173
|
type ApiRouteDeps,
|
|
158
174
|
} from "@opengeni/core";
|
|
159
175
|
import type { Context, Hono } from "hono";
|
|
@@ -207,6 +223,57 @@ async function managedCookieHuman(
|
|
|
207
223
|
};
|
|
208
224
|
}
|
|
209
225
|
|
|
226
|
+
async function requireOrganizationCodexHuman(
|
|
227
|
+
c: Context,
|
|
228
|
+
deps: ApiRouteDeps,
|
|
229
|
+
organizationId: string,
|
|
230
|
+
): Promise<ManagedCookieHuman> {
|
|
231
|
+
const parsed = z.string().uuid().safeParse(organizationId);
|
|
232
|
+
if (!parsed.success) throw new HTTPException(422, { message: "invalid organization id" });
|
|
233
|
+
let human = await managedCookieHuman(c, deps);
|
|
234
|
+
if (!human && deps.settings.productAccessMode === "local") {
|
|
235
|
+
const local = await requireCanonicalLocalAccountAdministrator(c, deps, organizationId);
|
|
236
|
+
human = {
|
|
237
|
+
subjectId: local.subjectId,
|
|
238
|
+
browserSessionHash: await hashCodexBrowserSession(`local:${local.subjectId}`),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (!human) {
|
|
242
|
+
throw new HTTPException(401, { message: "organization administrator session required" });
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
await getOrganizationCodexRotationSettings(deps.db, {
|
|
246
|
+
organizationId,
|
|
247
|
+
actorSubjectId: human.subjectId,
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const state = nestedPostgresSqlState(error);
|
|
251
|
+
if (state === "42501") {
|
|
252
|
+
throw new HTTPException(403, { message: "organization administration is not authorized" });
|
|
253
|
+
}
|
|
254
|
+
if (state === "P0002") {
|
|
255
|
+
throw new HTTPException(404, { message: "organization not found" });
|
|
256
|
+
}
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
return human;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function requireWorkspaceCodexManagementSource(
|
|
263
|
+
deps: ApiRouteDeps,
|
|
264
|
+
workspaceId: string,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const source = await getWorkspaceCodexSubscriptionSource(deps.db, workspaceId);
|
|
267
|
+
if (source.effectiveSource === "organization") {
|
|
268
|
+
throw new HTTPException(409, {
|
|
269
|
+
message: "this Codex subscription is managed in Organization settings",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (source.effectiveSource === "disabled") {
|
|
273
|
+
throw new HTTPException(409, { message: "Codex is disabled for this workspace" });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
210
277
|
function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void {
|
|
211
278
|
const contentType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
212
279
|
if (contentType !== "application/json") {
|
|
@@ -214,13 +281,17 @@ function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void
|
|
|
214
281
|
message: "JSON browser request required",
|
|
215
282
|
});
|
|
216
283
|
}
|
|
217
|
-
if (!deps.settings.publicBaseUrl) {
|
|
284
|
+
if (deps.settings.productAccessMode !== "local" && !deps.settings.publicBaseUrl) {
|
|
218
285
|
throw new HTTPException(503, {
|
|
219
286
|
message: "managed browser origin is not configured",
|
|
220
287
|
});
|
|
221
288
|
}
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
289
|
+
const origin = c.req.header("origin");
|
|
290
|
+
if (
|
|
291
|
+
deps.settings.productAccessMode === "local"
|
|
292
|
+
? !localBrowserOriginMatchesRequest(c, origin)
|
|
293
|
+
: origin !== new URL(deps.settings.publicBaseUrl!).origin
|
|
294
|
+
) {
|
|
224
295
|
throw new HTTPException(403, {
|
|
225
296
|
message: "same-origin browser request required",
|
|
226
297
|
});
|
|
@@ -232,6 +303,41 @@ function requireSameOriginBrowserMutation(c: Context, deps: ApiRouteDeps): void
|
|
|
232
303
|
}
|
|
233
304
|
}
|
|
234
305
|
|
|
306
|
+
function localBrowserOriginMatchesRequest(c: Context, value: string | undefined): boolean {
|
|
307
|
+
if (!value) return false;
|
|
308
|
+
let origin: URL;
|
|
309
|
+
try {
|
|
310
|
+
origin = new URL(value);
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (
|
|
315
|
+
origin.origin !== value ||
|
|
316
|
+
origin.origin === "null" ||
|
|
317
|
+
(origin.protocol !== "http:" && origin.protocol !== "https:")
|
|
318
|
+
) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const forwardedProtocol = c.req.header("x-forwarded-proto")?.trim().toLowerCase();
|
|
323
|
+
const protocol = forwardedProtocol ? `${forwardedProtocol}:` : new URL(c.req.url).protocol;
|
|
324
|
+
if (protocol !== "http:" && protocol !== "https:") return false;
|
|
325
|
+
const forwardedHost = c.req.header("x-forwarded-host") ?? c.req.header("host");
|
|
326
|
+
if (!forwardedHost || /[\s,/?#@\\]/u.test(forwardedHost)) return false;
|
|
327
|
+
|
|
328
|
+
let request: URL;
|
|
329
|
+
try {
|
|
330
|
+
request = new URL(`${protocol}//${forwardedHost}`);
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return (
|
|
335
|
+
origin.protocol === request.protocol &&
|
|
336
|
+
origin.hostname === request.hostname &&
|
|
337
|
+
(request.port === "" || origin.port === request.port)
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
235
341
|
async function requireRedemptionHuman(
|
|
236
342
|
c: Context,
|
|
237
343
|
deps: ApiRouteDeps,
|
|
@@ -258,7 +364,7 @@ async function requireRedemptionHuman(
|
|
|
258
364
|
message: "managed browser session required",
|
|
259
365
|
});
|
|
260
366
|
}
|
|
261
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
367
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
262
368
|
if (grant.subjectId !== human.subjectId) {
|
|
263
369
|
throw new HTTPException(403, {
|
|
264
370
|
message: "managed browser identity mismatch",
|
|
@@ -553,6 +659,8 @@ async function fetchCodexAccountOverview(
|
|
|
553
659
|
|
|
554
660
|
type CodexConnectState = {
|
|
555
661
|
workspaceId?: string;
|
|
662
|
+
organizationId?: string;
|
|
663
|
+
actorSubjectId?: string;
|
|
556
664
|
deviceAuthId?: string;
|
|
557
665
|
userCode?: string;
|
|
558
666
|
iat?: number;
|
|
@@ -597,11 +705,276 @@ const CODEX_DEVICE_EXPIRY_SECONDS = 15 * 60; // the device code expires 15 min a
|
|
|
597
705
|
export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
598
706
|
const { db, settings, githubStateSecret } = deps;
|
|
599
707
|
|
|
708
|
+
app.get("/v1/workspaces/:workspaceId/codex/source", async (c) => {
|
|
709
|
+
const workspaceId = c.req.param("workspaceId");
|
|
710
|
+
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
711
|
+
return c.json(await getWorkspaceCodexSubscriptionSource(db, workspaceId));
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
app.patch("/v1/workspaces/:workspaceId/codex/source", async (c) => {
|
|
715
|
+
const workspaceId = c.req.param("workspaceId");
|
|
716
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
717
|
+
const parsed = z
|
|
718
|
+
.object({ mode: z.enum(["automatic", "workspace", "organization", "disabled"]) })
|
|
719
|
+
.safeParse(await c.req.json().catch(() => null));
|
|
720
|
+
if (!parsed.success) {
|
|
721
|
+
throw new HTTPException(400, { message: "a valid Codex source mode is required" });
|
|
722
|
+
}
|
|
723
|
+
try {
|
|
724
|
+
return c.json(
|
|
725
|
+
await setWorkspaceCodexSubscriptionMode(db, {
|
|
726
|
+
accountId: grant.accountId,
|
|
727
|
+
workspaceId,
|
|
728
|
+
subjectId: grant.subjectId,
|
|
729
|
+
mode: parsed.data.mode,
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
if (
|
|
734
|
+
error instanceof Error &&
|
|
735
|
+
(error.message.includes("personal workspaces") ||
|
|
736
|
+
error.message.includes("active turns are using it"))
|
|
737
|
+
) {
|
|
738
|
+
throw new HTTPException(409, { message: error.message });
|
|
739
|
+
}
|
|
740
|
+
throw error;
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
app.get("/v1/organizations/:organizationId/codex/accounts", async (c) => {
|
|
745
|
+
const organizationId = c.req.param("organizationId");
|
|
746
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
747
|
+
const [accounts, rotation] = await Promise.all([
|
|
748
|
+
listOrganizationCodexAccountStatuses(db, {
|
|
749
|
+
organizationId,
|
|
750
|
+
actorSubjectId: human.subjectId,
|
|
751
|
+
}),
|
|
752
|
+
getOrganizationCodexRotationSettings(db, {
|
|
753
|
+
organizationId,
|
|
754
|
+
actorSubjectId: human.subjectId,
|
|
755
|
+
}),
|
|
756
|
+
]);
|
|
757
|
+
return c.json({
|
|
758
|
+
accounts: accounts.map((account) => codexAccountJson(account)),
|
|
759
|
+
activeAccountId: rotation?.activeCredentialId ?? null,
|
|
760
|
+
settings: {
|
|
761
|
+
rotationEnabled: rotation?.rotationEnabled ?? false,
|
|
762
|
+
rotationStrategy: "sharded",
|
|
763
|
+
activeCredentialId: rotation?.activeCredentialId ?? null,
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
app.post("/v1/organizations/:organizationId/codex/connect/start", async (c) => {
|
|
769
|
+
const organizationId = c.req.param("organizationId");
|
|
770
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
771
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
772
|
+
let start: Awaited<ReturnType<typeof startDeviceCode>>;
|
|
773
|
+
try {
|
|
774
|
+
start = await startDeviceCode();
|
|
775
|
+
} catch (error) {
|
|
776
|
+
throw new HTTPException(502, {
|
|
777
|
+
message:
|
|
778
|
+
error instanceof CodexDeviceError ? error.message : "failed to start Codex device login",
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
return c.json({
|
|
782
|
+
userCode: start.userCode,
|
|
783
|
+
verificationUri: start.verificationUri,
|
|
784
|
+
intervalSeconds: start.intervalSeconds,
|
|
785
|
+
state: createSignedState(githubStateSecret, {
|
|
786
|
+
organizationId,
|
|
787
|
+
actorSubjectId: human.subjectId,
|
|
788
|
+
deviceAuthId: start.deviceAuthId,
|
|
789
|
+
userCode: start.userCode,
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
app.post("/v1/organizations/:organizationId/codex/connect/poll", async (c) => {
|
|
795
|
+
const organizationId = c.req.param("organizationId");
|
|
796
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
797
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
798
|
+
const { state } = (await c.req.json().catch(() => null)) as { state?: string };
|
|
799
|
+
const payload = (state
|
|
800
|
+
? readSignedState(state, githubStateSecret)
|
|
801
|
+
: null) as unknown as CodexConnectState | null;
|
|
802
|
+
if (
|
|
803
|
+
!payload ||
|
|
804
|
+
payload.organizationId !== organizationId ||
|
|
805
|
+
payload.actorSubjectId !== human.subjectId ||
|
|
806
|
+
!payload.deviceAuthId ||
|
|
807
|
+
!payload.userCode
|
|
808
|
+
) {
|
|
809
|
+
throw new HTTPException(400, { message: "codex connect state is invalid or expired" });
|
|
810
|
+
}
|
|
811
|
+
if (
|
|
812
|
+
typeof payload.iat === "number" &&
|
|
813
|
+
Date.now() / 1000 - payload.iat > CODEX_DEVICE_EXPIRY_SECONDS
|
|
814
|
+
) {
|
|
815
|
+
return c.json({ status: "expired" });
|
|
816
|
+
}
|
|
817
|
+
let poll: Awaited<ReturnType<typeof pollDeviceCode>>;
|
|
818
|
+
try {
|
|
819
|
+
poll = await pollDeviceCode({
|
|
820
|
+
deviceAuthId: payload.deviceAuthId,
|
|
821
|
+
userCode: payload.userCode,
|
|
822
|
+
});
|
|
823
|
+
} catch (error) {
|
|
824
|
+
throw new HTTPException(502, {
|
|
825
|
+
message: error instanceof CodexDeviceError ? error.message : "codex device poll failed",
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
if (poll.status === "pending") return c.json({ status: "pending" });
|
|
829
|
+
if (poll.status === "expired") return c.json({ status: "expired" });
|
|
830
|
+
let tokens: Awaited<ReturnType<typeof exchangeDeviceCode>>;
|
|
831
|
+
try {
|
|
832
|
+
tokens = await exchangeDeviceCode({
|
|
833
|
+
authorizationCode: poll.authorizationCode,
|
|
834
|
+
codeVerifier: poll.codeVerifier,
|
|
835
|
+
});
|
|
836
|
+
} catch (error) {
|
|
837
|
+
throw new HTTPException(502, {
|
|
838
|
+
message: error instanceof CodexDeviceError ? error.message : "codex token exchange failed",
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const key = environmentsEncryptionKeyBytes(settings);
|
|
842
|
+
if (!key) {
|
|
843
|
+
throw new HTTPException(500, {
|
|
844
|
+
message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured",
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
const id = parseIdToken(tokens.idToken);
|
|
848
|
+
await ensureOrganizationCodexRotationSettings(db, {
|
|
849
|
+
organizationId,
|
|
850
|
+
actorSubjectId: human.subjectId,
|
|
851
|
+
});
|
|
852
|
+
const upserted = await upsertOrganizationCodexSubscriptionCredential(db, {
|
|
853
|
+
organizationId,
|
|
854
|
+
actorSubjectId: human.subjectId,
|
|
855
|
+
credentialEncrypted: encryptEnvironmentValue(
|
|
856
|
+
key,
|
|
857
|
+
JSON.stringify({
|
|
858
|
+
access_token: tokens.accessToken,
|
|
859
|
+
refresh_token: tokens.refreshToken,
|
|
860
|
+
id_token: tokens.idToken,
|
|
861
|
+
}),
|
|
862
|
+
),
|
|
863
|
+
chatgptAccountId: id.chatgptAccountId,
|
|
864
|
+
scopes: null,
|
|
865
|
+
planType: id.planType,
|
|
866
|
+
isFedramp: id.isFedramp,
|
|
867
|
+
expiresAt: accessTokenExpiry(tokens.accessToken),
|
|
868
|
+
lastRefreshAt: new Date(),
|
|
869
|
+
accountEmail: id.email ?? null,
|
|
870
|
+
label: id.email ?? id.chatgptAccountId ?? null,
|
|
871
|
+
});
|
|
872
|
+
await signalCodexCapacityTargets(deps, upserted.wakeTargets);
|
|
873
|
+
const rotation = await getOrganizationCodexRotationSettings(db, {
|
|
874
|
+
organizationId,
|
|
875
|
+
actorSubjectId: human.subjectId,
|
|
876
|
+
});
|
|
877
|
+
return c.json({
|
|
878
|
+
status: "connected",
|
|
879
|
+
plan: id.planType,
|
|
880
|
+
accountId: upserted.id,
|
|
881
|
+
isActive: rotation?.activeCredentialId === upserted.id,
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
app.post("/v1/organizations/:organizationId/codex/accounts/:accountId/activate", async (c) => {
|
|
886
|
+
const organizationId = c.req.param("organizationId");
|
|
887
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
888
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
889
|
+
const credentialId = c.req.param("accountId");
|
|
890
|
+
const activation = await setActiveOrganizationCodexCredential(db, {
|
|
891
|
+
organizationId,
|
|
892
|
+
actorSubjectId: human.subjectId,
|
|
893
|
+
credentialId,
|
|
894
|
+
});
|
|
895
|
+
if (!activation.activated) {
|
|
896
|
+
throw new HTTPException(404, { message: "codex account not found" });
|
|
897
|
+
}
|
|
898
|
+
await signalCodexCapacityTargets(deps, activation.wakeTargets);
|
|
899
|
+
return c.json({ activated: true, accountId: credentialId });
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
app.patch("/v1/organizations/:organizationId/codex/settings", async (c) => {
|
|
903
|
+
const organizationId = c.req.param("organizationId");
|
|
904
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
905
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
906
|
+
const parsed = z
|
|
907
|
+
.object({ rotationEnabled: z.boolean() })
|
|
908
|
+
.safeParse(await c.req.json().catch(() => null));
|
|
909
|
+
if (!parsed.success) {
|
|
910
|
+
throw new HTTPException(400, { message: "rotationEnabled is required" });
|
|
911
|
+
}
|
|
912
|
+
const updated = await updateOrganizationCodexRotationSettings(db, {
|
|
913
|
+
organizationId,
|
|
914
|
+
actorSubjectId: human.subjectId,
|
|
915
|
+
rotationEnabled: parsed.data.rotationEnabled,
|
|
916
|
+
});
|
|
917
|
+
if (!updated) throw new HTTPException(404, { message: "Codex settings not found" });
|
|
918
|
+
await signalCodexCapacityTargets(deps, updated.wakeTargets);
|
|
919
|
+
return c.json({
|
|
920
|
+
rotationEnabled: updated.rotationEnabled,
|
|
921
|
+
rotationStrategy: "sharded",
|
|
922
|
+
activeCredentialId: updated.activeCredentialId,
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
app.patch("/v1/organizations/:organizationId/codex/accounts/:accountId", async (c) => {
|
|
927
|
+
const organizationId = c.req.param("organizationId");
|
|
928
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
929
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
930
|
+
const body = (await c.req.json().catch(() => null)) as { label?: unknown } | null;
|
|
931
|
+
const renamed = await renameOrganizationCodexAccount(db, {
|
|
932
|
+
organizationId,
|
|
933
|
+
actorSubjectId: human.subjectId,
|
|
934
|
+
credentialId: c.req.param("accountId"),
|
|
935
|
+
label: typeof body?.label === "string" ? body.label : null,
|
|
936
|
+
});
|
|
937
|
+
if (!renamed) throw new HTTPException(404, { message: "codex account not found" });
|
|
938
|
+
const accounts = await listOrganizationCodexAccountStatuses(db, {
|
|
939
|
+
organizationId,
|
|
940
|
+
actorSubjectId: human.subjectId,
|
|
941
|
+
});
|
|
942
|
+
const row = accounts.find((account) => account.id === c.req.param("accountId"));
|
|
943
|
+
if (!row) throw new HTTPException(404, { message: "codex account not found" });
|
|
944
|
+
return c.json(codexAccountJson(row));
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
app.delete("/v1/organizations/:organizationId/codex/accounts/:accountId", async (c) => {
|
|
948
|
+
const organizationId = c.req.param("organizationId");
|
|
949
|
+
requireSameOriginBrowserMutation(c, deps);
|
|
950
|
+
const human = await requireOrganizationCodexHuman(c, deps, organizationId);
|
|
951
|
+
let result: Awaited<ReturnType<typeof disconnectOrganizationCodexAccount>>;
|
|
952
|
+
try {
|
|
953
|
+
result = await disconnectOrganizationCodexAccount(db, {
|
|
954
|
+
organizationId,
|
|
955
|
+
actorSubjectId: human.subjectId,
|
|
956
|
+
credentialId: c.req.param("accountId"),
|
|
957
|
+
});
|
|
958
|
+
} catch (error) {
|
|
959
|
+
const cause = (error as { cause?: unknown } | null)?.cause;
|
|
960
|
+
const message =
|
|
961
|
+
cause instanceof Error ? cause.message : error instanceof Error ? error.message : "";
|
|
962
|
+
if (message.includes("active turns are using it")) {
|
|
963
|
+
throw new HTTPException(409, {
|
|
964
|
+
message: "Codex subscription cannot disconnect while active turns are using it",
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
await signalCodexCapacityTargets(deps, result.wakeTargets);
|
|
970
|
+
return c.json({ disconnected: result.removed, newActiveId: result.newActiveCredentialId });
|
|
971
|
+
});
|
|
972
|
+
|
|
600
973
|
// Begin device-code login: returns the user code + verification URL and a
|
|
601
974
|
// signed state that carries the device_auth_id back to `poll`.
|
|
602
975
|
app.post("/v1/workspaces/:workspaceId/codex/connect/start", async (c) => {
|
|
603
976
|
const workspaceId = c.req.param("workspaceId");
|
|
604
|
-
await requireAccessGrant(c, deps, workspaceId, "
|
|
977
|
+
await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
605
978
|
let start: Awaited<ReturnType<typeof startDeviceCode>>;
|
|
606
979
|
try {
|
|
607
980
|
start = await startDeviceCode();
|
|
@@ -627,7 +1000,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
627
1000
|
// Poll for authorization: pending | expired | connected (persists on success).
|
|
628
1001
|
app.post("/v1/workspaces/:workspaceId/codex/connect/poll", async (c) => {
|
|
629
1002
|
const workspaceId = c.req.param("workspaceId");
|
|
630
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
1003
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
631
1004
|
const { state } = (await c.req.json()) as { state?: string };
|
|
632
1005
|
const payload = (state
|
|
633
1006
|
? readSignedState(state, githubStateSecret)
|
|
@@ -688,56 +1061,60 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
688
1061
|
message: "OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY is not configured",
|
|
689
1062
|
});
|
|
690
1063
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
return { result: upserted,
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
1064
|
+
const mutation = await withSessionCodexCapacityMutation<{
|
|
1065
|
+
upserted: Awaited<ReturnType<typeof upsertCodexSubscriptionCredential>>;
|
|
1066
|
+
isActive: boolean;
|
|
1067
|
+
}>(db, { workspaceId, reason: "codex_credential_connected" }, async (tx) => {
|
|
1068
|
+
const upserted = await upsertCodexSubscriptionCredential(tx, {
|
|
1069
|
+
accountId: grant.accountId,
|
|
1070
|
+
workspaceId,
|
|
1071
|
+
credentialEncrypted: encryptEnvironmentValue(
|
|
1072
|
+
key,
|
|
1073
|
+
JSON.stringify({
|
|
1074
|
+
access_token: tokens.accessToken,
|
|
1075
|
+
refresh_token: tokens.refreshToken,
|
|
1076
|
+
id_token: tokens.idToken,
|
|
1077
|
+
}),
|
|
1078
|
+
),
|
|
1079
|
+
chatgptAccountId: id.chatgptAccountId,
|
|
1080
|
+
scopes: null, // device grant scopes are discovered at runtime, not asserted here
|
|
1081
|
+
planType: id.planType,
|
|
1082
|
+
isFedramp: id.isFedramp,
|
|
1083
|
+
expiresAt: accessTokenExpiry(tokens.accessToken),
|
|
1084
|
+
lastRefreshAt: new Date(),
|
|
1085
|
+
accountEmail: id.email ?? null,
|
|
1086
|
+
label: id.email ?? id.chatgptAccountId ?? null,
|
|
1087
|
+
connectedBySubjectId:
|
|
1088
|
+
connectingHuman?.subjectId === grant.subjectId ? connectingHuman.subjectId : null,
|
|
1089
|
+
});
|
|
1090
|
+
if (upserted.kind === "unresolved_redemption") {
|
|
1091
|
+
return { result: { upserted, isActive: false }, changed: false };
|
|
1092
|
+
}
|
|
1093
|
+
await ensureCodexRotationSettings(tx, grant.accountId, workspaceId);
|
|
1094
|
+
await setInitialActiveCodexCredential(tx, workspaceId, upserted.id);
|
|
1095
|
+
const source = await getWorkspaceCodexSubscriptionSource(tx, workspaceId);
|
|
1096
|
+
await setWorkspaceCodexSubscriptionModeInTransaction(tx, {
|
|
1097
|
+
accountId: grant.accountId,
|
|
1098
|
+
workspaceId,
|
|
1099
|
+
subjectId: grant.subjectId,
|
|
1100
|
+
mode: source.workspaceKind === "personal" ? "automatic" : "workspace",
|
|
1101
|
+
});
|
|
1102
|
+
const rotation = await getCodexRotationSettings(tx, workspaceId);
|
|
1103
|
+
return {
|
|
1104
|
+
result: {
|
|
1105
|
+
upserted,
|
|
1106
|
+
isActive: rotation?.activeCredentialId === upserted.id,
|
|
1107
|
+
},
|
|
1108
|
+
changed: true,
|
|
1109
|
+
};
|
|
1110
|
+
});
|
|
1111
|
+
const { upserted, isActive } = mutation.result;
|
|
722
1112
|
if (upserted.kind === "unresolved_redemption") {
|
|
723
1113
|
throw new HTTPException(409, {
|
|
724
1114
|
message:
|
|
725
1115
|
"this subscription has an unresolved reset redemption; recover it before changing ownership",
|
|
726
1116
|
});
|
|
727
1117
|
}
|
|
728
|
-
// Ensure the per-workspace rotation-settings row exists, then auto-activate
|
|
729
|
-
// the FIRST account only. Additional new accounts do NOT auto-activate — a
|
|
730
|
-
// manual switch is required (no auto-rotation in P1). A re-connect of the
|
|
731
|
-
// already-active account is a no-op for the pointer.
|
|
732
|
-
// Keep both rotation bits false on first connect. The deployment flag makes
|
|
733
|
-
// the compatible allocator available, but the workspace-local cutover bit
|
|
734
|
-
// is enabled only by an explicit settings write after every worker replica
|
|
735
|
-
// understands leasing.
|
|
736
|
-
const rotation = await getCodexRotationSettings(db, workspaceId);
|
|
737
|
-
let isActive = rotation?.activeCredentialId === upserted.id;
|
|
738
|
-
if (!isActive && rotation?.activeCredentialId == null) {
|
|
739
|
-
isActive = await setInitialActiveCodexCredential(db, workspaceId, upserted.id);
|
|
740
|
-
}
|
|
741
1118
|
await signalCodexCapacityTargets(deps, mutation.wakeTargets);
|
|
742
1119
|
return c.json({ status: "connected", plan: id.planType, accountId: upserted.id, isActive });
|
|
743
1120
|
});
|
|
@@ -751,7 +1128,10 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
751
1128
|
if (!status) {
|
|
752
1129
|
return c.json({ connected: false });
|
|
753
1130
|
}
|
|
754
|
-
const accounts = await
|
|
1131
|
+
const [accounts, source] = await Promise.all([
|
|
1132
|
+
listCodexAccountStatuses(db, workspaceId),
|
|
1133
|
+
getWorkspaceCodexSubscriptionSource(db, workspaceId),
|
|
1134
|
+
]);
|
|
755
1135
|
const activeRow = accounts.find((account) => account.id === status.credentialId) ?? null;
|
|
756
1136
|
const activeAccount = activeRow
|
|
757
1137
|
? {
|
|
@@ -798,6 +1178,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
798
1178
|
models, // ClientModel[] the picker surfaces under the "no credits" group
|
|
799
1179
|
activeAccount, // the account a session runs on when unpinned (label for the indicator)
|
|
800
1180
|
accountCount: accounts.length,
|
|
1181
|
+
source,
|
|
801
1182
|
});
|
|
802
1183
|
});
|
|
803
1184
|
|
|
@@ -806,11 +1187,12 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
806
1187
|
app.get("/v1/workspaces/:workspaceId/codex/accounts", async (c) => {
|
|
807
1188
|
const workspaceId = c.req.param("workspaceId");
|
|
808
1189
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
809
|
-
const [accounts, rotation, apps, human] = await Promise.all([
|
|
1190
|
+
const [accounts, rotation, apps, human, source] = await Promise.all([
|
|
810
1191
|
listCodexAccountStatuses(db, workspaceId),
|
|
811
1192
|
getCodexRotationSettings(db, workspaceId),
|
|
812
1193
|
getCodexAppsSettings(db, workspaceId),
|
|
813
1194
|
managedCookieHuman(c, deps),
|
|
1195
|
+
getWorkspaceCodexSubscriptionSource(db, workspaceId),
|
|
814
1196
|
]);
|
|
815
1197
|
const activeAccountId = rotation?.activeCredentialId ?? null;
|
|
816
1198
|
const humanSubjectId = human?.subjectId === grant.subjectId ? human.subjectId : null;
|
|
@@ -825,6 +1207,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
825
1207
|
}),
|
|
826
1208
|
),
|
|
827
1209
|
activeAccountId,
|
|
1210
|
+
source,
|
|
828
1211
|
apps: {
|
|
829
1212
|
available: settings.codexConnectedAppsEnabled,
|
|
830
1213
|
credentialId: apps.credentialId,
|
|
@@ -844,6 +1227,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
844
1227
|
|
|
845
1228
|
app.post("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
|
|
846
1229
|
const workspaceId = c.req.param("workspaceId");
|
|
1230
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
847
1231
|
if (!settings.codexConnectedAppsEnabled) {
|
|
848
1232
|
throw new HTTPException(409, { message: "Codex Apps is disabled for this deployment" });
|
|
849
1233
|
}
|
|
@@ -889,6 +1273,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
889
1273
|
|
|
890
1274
|
app.delete("/v1/workspaces/:workspaceId/codex/apps", async (c) => {
|
|
891
1275
|
const workspaceId = c.req.param("workspaceId");
|
|
1276
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
892
1277
|
const { human, accountId } = await requireCodexAppsHuman(c, deps, workspaceId);
|
|
893
1278
|
const parsed = z
|
|
894
1279
|
.object({ expectedVersion: z.number().int().nonnegative() })
|
|
@@ -918,7 +1303,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
918
1303
|
// Pure pointer flip; in-flight turns pick it up on their next token fetch.
|
|
919
1304
|
app.post("/v1/workspaces/:workspaceId/codex/accounts/:accountId/activate", async (c) => {
|
|
920
1305
|
const workspaceId = c.req.param("workspaceId");
|
|
921
|
-
await requireAccessGrant(c, deps, workspaceId, "
|
|
1306
|
+
await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1307
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
922
1308
|
const accountId = c.req.param("accountId");
|
|
923
1309
|
const mutation = await withCodexCapacityMutation(
|
|
924
1310
|
db,
|
|
@@ -936,14 +1322,15 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
936
1322
|
return c.json({ activated: true, accountId });
|
|
937
1323
|
});
|
|
938
1324
|
|
|
939
|
-
// Update rotation settings.
|
|
1325
|
+
// Update rotation settings. Connection-management access. Sharded-rotation policy: the strategy picker is GONE —
|
|
940
1326
|
// rotation-enabled always behaves as sticky-sharded (worker-side
|
|
941
1327
|
// effectiveRotationStrategy normalization). `rotationStrategy` in the body is
|
|
942
1328
|
// ACCEPTED-BUT-IGNORED so no existing SDK/UI caller breaks (deprecation), and
|
|
943
1329
|
// the stored column is only legacy residue kept for old-binary rollback.
|
|
944
1330
|
app.patch("/v1/workspaces/:workspaceId/codex/settings", async (c) => {
|
|
945
1331
|
const workspaceId = c.req.param("workspaceId");
|
|
946
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
1332
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1333
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
947
1334
|
const body = (await c.req.json().catch(() => ({}))) as {
|
|
948
1335
|
rotationEnabled?: unknown;
|
|
949
1336
|
rotationStrategy?: unknown;
|
|
@@ -987,7 +1374,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
987
1374
|
// Rename an account (label only in P1).
|
|
988
1375
|
app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
|
|
989
1376
|
const workspaceId = c.req.param("workspaceId");
|
|
990
|
-
await requireAccessGrant(c, deps, workspaceId, "
|
|
1377
|
+
await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1378
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
991
1379
|
const accountId = c.req.param("accountId");
|
|
992
1380
|
const body = (await c.req.json()) as { label?: string | null };
|
|
993
1381
|
const label = typeof body.label === "string" ? body.label : null;
|
|
@@ -1008,7 +1396,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1008
1396
|
// an explicit 409 carrying the current version.
|
|
1009
1397
|
app.patch("/v1/workspaces/:workspaceId/codex/accounts/:accountId/allocator", async (c) => {
|
|
1010
1398
|
const workspaceId = c.req.param("workspaceId");
|
|
1011
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
1399
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1400
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
1012
1401
|
const parsed = z
|
|
1013
1402
|
.object({
|
|
1014
1403
|
enabled: z.boolean(),
|
|
@@ -1046,7 +1435,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1046
1435
|
// row was active (FK ON DELETE SET NULL + re-pick in the same RLS txn).
|
|
1047
1436
|
app.delete("/v1/workspaces/:workspaceId/codex/accounts/:accountId", async (c) => {
|
|
1048
1437
|
const workspaceId = c.req.param("workspaceId");
|
|
1049
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
1438
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1439
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
1050
1440
|
const accountId = c.req.param("accountId");
|
|
1051
1441
|
const mutation = await withCodexCapacityMutation(
|
|
1052
1442
|
db,
|
|
@@ -1071,7 +1461,8 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1071
1461
|
// the by-id route above.
|
|
1072
1462
|
app.delete("/v1/workspaces/:workspaceId/codex", async (c) => {
|
|
1073
1463
|
const workspaceId = c.req.param("workspaceId");
|
|
1074
|
-
const grant = await requireAccessGrant(c, deps, workspaceId, "
|
|
1464
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
1465
|
+
await requireWorkspaceCodexManagementSource(deps, workspaceId);
|
|
1075
1466
|
const mutation = await withCodexCapacityMutation(
|
|
1076
1467
|
db,
|
|
1077
1468
|
{ workspaceId, reason: "codex_credentials_disconnected" },
|
|
@@ -1181,7 +1572,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1181
1572
|
const ownerRecoveries =
|
|
1182
1573
|
human &&
|
|
1183
1574
|
human.subjectId === grant.subjectId &&
|
|
1184
|
-
hasPermission(grant.permissions, "
|
|
1575
|
+
hasPermission(grant.permissions, "connections:write")
|
|
1185
1576
|
? await listCodexResetRedemptionRecoveries(db, {
|
|
1186
1577
|
accountId: grant.accountId,
|
|
1187
1578
|
workspaceId,
|
|
@@ -1201,18 +1592,25 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1201
1592
|
const account = queue.shift();
|
|
1202
1593
|
if (!account) return;
|
|
1203
1594
|
const canResumeRedemption = Boolean(
|
|
1595
|
+
account.source === "workspace" &&
|
|
1204
1596
|
human &&
|
|
1205
1597
|
human.subjectId === grant.subjectId &&
|
|
1206
1598
|
human.subjectId === account.connectedBySubjectId &&
|
|
1207
|
-
hasPermission(grant.permissions, "
|
|
1599
|
+
hasPermission(grant.permissions, "connections:write"),
|
|
1208
1600
|
);
|
|
1209
1601
|
const canRedeem = canResumeRedemption && account.status === "active";
|
|
1210
|
-
const redemptionAccess =
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1602
|
+
const redemptionAccess: CodexRedemptionAccess =
|
|
1603
|
+
account.source === "organization"
|
|
1604
|
+
? {
|
|
1605
|
+
ownership: "managed_human_unavailable",
|
|
1606
|
+
canClaimUnownedViaReconnect: false,
|
|
1607
|
+
}
|
|
1608
|
+
: codexRedemptionAccess({
|
|
1609
|
+
connectedBySubjectId: account.connectedBySubjectId,
|
|
1610
|
+
grantSubjectId: grant.subjectId,
|
|
1611
|
+
managedHumanSubjectId: human?.subjectId ?? null,
|
|
1612
|
+
canManage: hasPermission(grant.permissions, "connections:write"),
|
|
1613
|
+
});
|
|
1216
1614
|
overview[account.id] = await fetchCodexAccountOverview(
|
|
1217
1615
|
deps,
|
|
1218
1616
|
workspaceId,
|
|
@@ -1255,21 +1653,27 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1255
1653
|
.filter((account) => overview[account.id] == null)
|
|
1256
1654
|
.map(async (account) => {
|
|
1257
1655
|
const canResumeRedemption = Boolean(
|
|
1656
|
+
account.source === "workspace" &&
|
|
1258
1657
|
human &&
|
|
1259
1658
|
human.subjectId === grant.subjectId &&
|
|
1260
1659
|
human.subjectId === account.connectedBySubjectId &&
|
|
1261
|
-
hasPermission(grant.permissions, "
|
|
1660
|
+
hasPermission(grant.permissions, "connections:write"),
|
|
1262
1661
|
);
|
|
1263
1662
|
const fallback = await fetchCodexAccountOverview(
|
|
1264
1663
|
deps,
|
|
1265
1664
|
workspaceId,
|
|
1266
1665
|
account,
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1666
|
+
account.source === "organization"
|
|
1667
|
+
? {
|
|
1668
|
+
ownership: "managed_human_unavailable",
|
|
1669
|
+
canClaimUnownedViaReconnect: false,
|
|
1670
|
+
}
|
|
1671
|
+
: codexRedemptionAccess({
|
|
1672
|
+
connectedBySubjectId: account.connectedBySubjectId,
|
|
1673
|
+
grantSubjectId: grant.subjectId,
|
|
1674
|
+
managedHumanSubjectId: human?.subjectId ?? null,
|
|
1675
|
+
canManage: hasPermission(grant.permissions, "connections:write"),
|
|
1676
|
+
}),
|
|
1273
1677
|
false,
|
|
1274
1678
|
canResumeRedemption,
|
|
1275
1679
|
canResumeRedemption
|
|
@@ -1311,6 +1715,11 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1311
1715
|
const accounts = await listCodexAccountStatuses(db, workspaceId);
|
|
1312
1716
|
const account = accounts.find((candidate) => candidate.id === credentialId);
|
|
1313
1717
|
if (!account) throw new HTTPException(404, { message: "codex account not found" });
|
|
1718
|
+
if (account.source === "organization") {
|
|
1719
|
+
throw new HTTPException(409, {
|
|
1720
|
+
message: "organization Codex subscriptions are managed in Organization settings",
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1314
1723
|
let existing = await getCodexResetRedemptionAttempt(db, workspaceId, parsed.data.attemptId);
|
|
1315
1724
|
if (account.connectedBySubjectId !== human.subjectId) {
|
|
1316
1725
|
throw new HTTPException(403, {
|