@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.
Files changed (59) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-metrics.d.ts +2 -0
  10. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  11. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  12. package/dist/mcp/remember.d.ts +2 -2
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/session-view.d.ts +1 -0
  15. package/dist/mcp/session-wait.d.ts +19 -0
  16. package/dist/routes/api-keys.d.ts +2 -0
  17. package/dist/routes/browser-sessions.d.ts +1 -0
  18. package/dist/routes/computer-sessions.d.ts +12 -0
  19. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  20. package/dist/routes/workspaces.d.ts +1 -1
  21. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  22. package/dist/workspace-delete-observability.d.ts +10 -0
  23. package/package.json +15 -15
  24. package/src/app.ts +172 -20
  25. package/src/auth/managed-auth-attempt-context.ts +40 -3
  26. package/src/auth/managed-auth-session-adapter.ts +1 -0
  27. package/src/auth/managed-auth.ts +164 -4
  28. package/src/http/sse.ts +279 -45
  29. package/src/integrations/oauth-client.ts +8 -1
  30. package/src/integrations/provider-oauth.ts +12 -2
  31. package/src/interaction-metrics.ts +30 -0
  32. package/src/mcp/company-brain-governed-writes.ts +38 -23
  33. package/src/mcp/company-profile-agent-admin.ts +7 -7
  34. package/src/mcp/remember.ts +19 -8
  35. package/src/mcp/server.ts +318 -26
  36. package/src/mcp/session-wait.ts +56 -8
  37. package/src/routes/api-integrations.ts +2 -2
  38. package/src/routes/api-keys.ts +149 -7
  39. package/src/routes/browser-sessions.ts +10 -2
  40. package/src/routes/capabilities.ts +3 -3
  41. package/src/routes/codex.ts +483 -74
  42. package/src/routes/company-profile.ts +64 -0
  43. package/src/routes/computer-sessions.ts +103 -1
  44. package/src/routes/integration-facets.ts +8 -5
  45. package/src/routes/interaction-resources.ts +7 -1
  46. package/src/routes/managed-auth-session-sets.ts +199 -2
  47. package/src/routes/organization-memberships.ts +28 -8
  48. package/src/routes/packs.ts +5 -5
  49. package/src/routes/plugins.ts +2 -2
  50. package/src/routes/scheduled-tasks.ts +9 -0
  51. package/src/routes/sessions.ts +3 -6
  52. package/src/routes/skills.ts +3 -3
  53. package/src/routes/workspaces.ts +293 -54
  54. package/src/sandbox/channel-a.ts +10 -4
  55. package/src/sandbox/machines.ts +13 -6
  56. package/src/sandbox/metrics-ingestion.ts +157 -3
  57. package/src/sandbox/viewer.ts +20 -1
  58. package/src/workspace-delete-observability.ts +75 -0
  59. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -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 expectedOrigin = new URL(deps.settings.publicBaseUrl).origin;
223
- if (c.req.header("origin") !== expectedOrigin) {
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, "workspace:admin");
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, "workspace:admin");
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, "workspace:admin");
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
- await ensureCodexRotationSettings(db, grant.accountId, workspaceId);
692
- const mutation = await withCodexCapacityMutation(
693
- db,
694
- { workspaceId, reason: "codex_credential_connected" },
695
- async (tx) => {
696
- const upserted = await upsertCodexSubscriptionCredential(tx, {
697
- accountId: grant.accountId,
698
- workspaceId,
699
- credentialEncrypted: encryptEnvironmentValue(
700
- key,
701
- JSON.stringify({
702
- access_token: tokens.accessToken,
703
- refresh_token: tokens.refreshToken,
704
- id_token: tokens.idToken,
705
- }),
706
- ),
707
- chatgptAccountId: id.chatgptAccountId,
708
- scopes: null, // device grant scopes are discovered at runtime, not asserted here
709
- planType: id.planType,
710
- isFedramp: id.isFedramp,
711
- expiresAt: accessTokenExpiry(tokens.accessToken),
712
- lastRefreshAt: new Date(),
713
- accountEmail: id.email ?? null,
714
- label: id.email ?? id.chatgptAccountId ?? null,
715
- connectedBySubjectId:
716
- connectingHuman?.subjectId === grant.subjectId ? connectingHuman.subjectId : null,
717
- });
718
- return { result: upserted, changed: upserted.kind === "upserted" };
719
- },
720
- );
721
- const upserted = mutation.result;
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 listCodexAccountStatuses(db, workspaceId);
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, "workspace:admin");
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. admin access. sharded-rotation policy: the strategy picker is GONE —
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, "workspace:admin");
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, "workspace:admin");
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, "workspace:admin");
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, "workspace:admin");
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, "workspace:admin");
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, "workspace:admin")
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, "workspace:admin"),
1599
+ hasPermission(grant.permissions, "connections:write"),
1208
1600
  );
1209
1601
  const canRedeem = canResumeRedemption && account.status === "active";
1210
- const redemptionAccess = codexRedemptionAccess({
1211
- connectedBySubjectId: account.connectedBySubjectId,
1212
- grantSubjectId: grant.subjectId,
1213
- managedHumanSubjectId: human?.subjectId ?? null,
1214
- canManage: hasPermission(grant.permissions, "workspace:admin"),
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, "workspace:admin"),
1660
+ hasPermission(grant.permissions, "connections:write"),
1262
1661
  );
1263
1662
  const fallback = await fetchCodexAccountOverview(
1264
1663
  deps,
1265
1664
  workspaceId,
1266
1665
  account,
1267
- codexRedemptionAccess({
1268
- connectedBySubjectId: account.connectedBySubjectId,
1269
- grantSubjectId: grant.subjectId,
1270
- managedHumanSubjectId: human?.subjectId ?? null,
1271
- canManage: hasPermission(grant.permissions, "workspace:admin"),
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, {