@opengeni/api-router 0.30.3 → 2.2.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -53,17 +53,31 @@ import {
53
53
  updateScheduledTask,
54
54
  upsertKnowledgeProvider,
55
55
  upsertKnowledgeSource,
56
+ withWorkspaceSubjectRls,
56
57
  } from "@opengeni/db";
57
58
  import { atlassianKnowledgeSourceIdentity } from "@opengeni/documents/atlassian";
58
59
  import { createSignedState, readSignedState } from "@opengeni/github";
59
60
  import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
60
61
  import { HTTPException } from "hono/http-exception";
62
+ import {
63
+ personalOnlyConnectionPrincipalMessage,
64
+ personalOwnerStateAccepted,
65
+ personalOwnerVerifiedInState,
66
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
67
+ } from "../connection-ownership";
61
68
  import {
62
69
  integrationBaseUrl,
63
70
  oauthStateTtlMs,
64
71
  requireIntegrationsStateSecret,
65
72
  } from "./oauth-client";
66
73
 
74
+ /**
75
+ * Flow discriminator for this connector's signed OAuth state. See the matching
76
+ * constant in `google-drive.ts`: this state carries no `ownership` field and no
77
+ * provider identity, and its return path is byte-identical to one the MCP OAuth
78
+ * start signs from caller input, so the flow kind is what binds a state here.
79
+ */
80
+ const ATLASSIAN_OAUTH_STATE_KIND = "atlassian_oauth";
67
81
  const ATLASSIAN_AUTHORIZATION_URL = "https://auth.atlassian.com/authorize";
68
82
  const ATLASSIAN_TOKEN_URL = "https://auth.atlassian.com/oauth/token";
69
83
  const ATLASSIAN_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources";
@@ -76,6 +90,8 @@ type AtlassianOAuthState = {
76
90
  accountId: string;
77
91
  workspaceId: string;
78
92
  subjectId: string;
93
+ /** Signed proof that a live managed human started this personal-only flow. */
94
+ personalOwnerVerified: boolean;
79
95
  returnPath: string;
80
96
  connectionId?: string;
81
97
  connectionVersion?: number;
@@ -121,6 +137,10 @@ export async function startAtlassianOAuth(
121
137
  accountId: input.accountId,
122
138
  workspaceId: input.workspaceId,
123
139
  subjectId: input.subjectId,
140
+ kind: ATLASSIAN_OAUTH_STATE_KIND,
141
+ // The route admits only a managed human, so reaching here is the proof; the
142
+ // callback has no live principal and enforces exactly this claim.
143
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
124
144
  returnPath: ATLASSIAN_RETURN_PATH(input.workspaceId),
125
145
  ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
126
146
  });
@@ -719,7 +739,7 @@ export async function disconnectAtlassian(
719
739
  }
720
740
  }
721
741
 
722
- export async function revokeAtlassianScheduleAuthorization(
742
+ export async function preflightAtlassianScheduleAuthorization(
723
743
  deps: ApiRouteDeps,
724
744
  input: { task: ScheduledTask; subjectId: string },
725
745
  ): Promise<void> {
@@ -737,50 +757,118 @@ export async function revokeAtlassianScheduleAuthorization(
737
757
  if (!externalSourceId) {
738
758
  throw new HTTPException(409, { message: "Atlassian schedule identity is incomplete" });
739
759
  }
740
- const connection = await getConnectionMetadata(
741
- deps.db,
742
- task.workspaceId,
743
- task.action.connection.connectionId,
744
- input.subjectId,
745
- );
746
- if (!connection) throw new HTTPException(409, { message: "Atlassian connection is unavailable" });
747
- const metadata = requireAtlassianConnection(connection, input.subjectId);
748
- const selected = metadata.selectedSources.find((source) => source.id === externalSourceId);
749
- let authorityVersion = connection.version;
750
- if (selected?.syncEnabled) {
751
- const updated = await transitionConnectionState(deps.db, {
752
- workspaceId: task.workspaceId,
753
- connectionId: connection.id,
754
- visibleToSubjectId: input.subjectId,
755
- expectedVersion: connection.version,
756
- metadata: AtlassianConnectionMetadata.parse({
757
- ...metadata,
758
- selectedSources: metadata.selectedSources.map((source) =>
759
- source.id === externalSourceId
760
- ? { ...source, syncEnabled: false, configGeneration: source.configGeneration + 1 }
761
- : source,
762
- ),
763
- }),
764
- updatedBySubjectId: input.subjectId,
760
+ // Current connector attachment is not deletion authority. The frozen task
761
+ // revision and generation-fenced cleanup descriptor are.
762
+ }
763
+
764
+ export async function revokeAtlassianScheduleAuthorization(
765
+ deps: ApiRouteDeps,
766
+ input: { task: ScheduledTask; subjectId: string },
767
+ ): Promise<void> {
768
+ const { task } = input;
769
+ if (task.action.kind !== "knowledge_source_sync") return;
770
+ if (
771
+ task.action.initiatingSubjectId !== input.subjectId ||
772
+ task.action.connection.ownerSubjectId !== input.subjectId
773
+ ) {
774
+ throw new HTTPException(403, {
775
+ message: "knowledge source schedule requires the exact initiating subject",
765
776
  });
766
- if (!updated) throw new HTTPException(409, { message: "Atlassian connection changed" });
767
- authorityVersion = updated.version;
768
777
  }
769
- const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
778
+ const externalSourceId = optionalString(task.metadata.externalSourceId);
779
+ if (!externalSourceId) {
780
+ throw new HTTPException(409, { message: "Atlassian schedule identity is incomplete" });
781
+ }
782
+ await cleanupAtlassianScheduleAuthorization(deps, {
783
+ taskId: task.id,
770
784
  accountId: task.accountId,
771
785
  workspaceId: task.workspaceId,
786
+ connectionId: task.action.connection.connectionId,
787
+ connectionVersion: task.action.connection.connectionVersion,
772
788
  sourceId: task.action.sourceId,
789
+ sourceLifecycleGeneration: task.action.sourceLifecycleGeneration,
790
+ sourceConfigGeneration: task.action.sourceConfigGeneration,
791
+ externalSourceId,
792
+ subjectId: input.subjectId,
793
+ });
794
+ }
795
+
796
+ export async function cleanupAtlassianScheduleAuthorization(
797
+ deps: ApiRouteDeps,
798
+ input: {
799
+ taskId: string;
800
+ accountId: string;
801
+ workspaceId: string;
802
+ connectionId: string;
803
+ connectionVersion: number;
804
+ sourceId: string;
805
+ sourceLifecycleGeneration: number;
806
+ sourceConfigGeneration: number;
807
+ externalSourceId: string;
808
+ subjectId: string;
809
+ },
810
+ ): Promise<void> {
811
+ const resolvedBeforeLock = await getKnowledgeSourceForSyncAuthority(deps.db, {
812
+ accountId: input.accountId,
813
+ workspaceId: input.workspaceId,
814
+ sourceId: input.sourceId,
773
815
  initiatingSubjectId: input.subjectId,
774
816
  });
775
- if (resolved?.source.lifecycleState === "active") {
776
- await recordKnowledgeLifecycleEvent(deps.db, {
777
- accountId: task.accountId,
778
- workspaceId: task.workspaceId,
817
+ if (
818
+ resolvedBeforeLock?.source.lifecycleState !== "active" ||
819
+ resolvedBeforeLock.source.lifecycleGeneration !== input.sourceLifecycleGeneration
820
+ ) {
821
+ return;
822
+ }
823
+ await withWorkspaceSubjectRls(deps.db, input.workspaceId, input.subjectId, async (tx) => {
824
+ const connection = await getConnectionMetadata(
825
+ tx,
826
+ input.workspaceId,
827
+ input.connectionId,
828
+ input.subjectId,
829
+ );
830
+ let authorityVersion = input.connectionVersion;
831
+ if (connection) {
832
+ const metadata = requireAtlassianConnection(connection, input.subjectId);
833
+ authorityVersion = connection.version;
834
+ const updated = await transitionConnectionState(tx, {
835
+ workspaceId: input.workspaceId,
836
+ connectionId: connection.id,
837
+ visibleToSubjectId: input.subjectId,
838
+ expectedVersion: connection.version,
839
+ metadata: AtlassianConnectionMetadata.parse({
840
+ ...metadata,
841
+ selectedSources: metadata.selectedSources.map((source) =>
842
+ source.id === input.externalSourceId
843
+ ? { ...source, syncEnabled: false, configGeneration: source.configGeneration + 1 }
844
+ : source,
845
+ ),
846
+ }),
847
+ updatedBySubjectId: input.subjectId,
848
+ });
849
+ if (!updated) throw new HTTPException(409, { message: "Atlassian connection changed" });
850
+ authorityVersion = updated.version;
851
+ }
852
+ const resolved = await getKnowledgeSourceForSyncAuthority(tx, {
853
+ accountId: input.accountId,
854
+ workspaceId: input.workspaceId,
855
+ sourceId: input.sourceId,
856
+ initiatingSubjectId: input.subjectId,
857
+ });
858
+ if (
859
+ resolved?.source.lifecycleState !== "active" ||
860
+ resolved.source.lifecycleGeneration !== input.sourceLifecycleGeneration
861
+ ) {
862
+ throw new HTTPException(409, { message: "Atlassian knowledge source changed" });
863
+ }
864
+ await recordKnowledgeLifecycleEvent(tx, {
865
+ accountId: input.accountId,
866
+ workspaceId: input.workspaceId,
779
867
  targetKind: "source",
780
868
  targetId: resolved.source.id,
781
869
  eventType: "deleted",
782
870
  expectedGeneration: resolved.source.lifecycleGeneration,
783
- operationId: `atlassian-schedule-delete:${task.id}:${authorityVersion}`,
871
+ operationId: `atlassian-schedule-delete:${input.taskId}:${authorityVersion}`,
784
872
  reasonCode: "schedule_deleted",
785
873
  actor: {
786
874
  kind: "human",
@@ -788,7 +876,7 @@ export async function revokeAtlassianScheduleAuthorization(
788
876
  initiatingHumanSubjectId: input.subjectId,
789
877
  },
790
878
  });
791
- }
879
+ });
792
880
  }
793
881
 
794
882
  async function materializeSchedules(
@@ -972,6 +1060,7 @@ async function materializeSchedules(
972
1060
  action,
973
1061
  runMode: "new_session_per_run",
974
1062
  targetSessionId: null,
1063
+ connectionAuthorities: [],
975
1064
  agentConfig: {
976
1065
  prompt: "Knowledge source synchronization",
977
1066
  resources: [],
@@ -1409,6 +1498,12 @@ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): A
1409
1498
  if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1_000) {
1410
1499
  throw new HTTPException(400, { message: "expired Atlassian OAuth state" });
1411
1500
  }
1501
+ // Reject a state minted by any other flow before reading anything else. A
1502
+ // pre-cutover state carries no kind and is refused; that is the same bounded
1503
+ // `oauthStateTtlMs` window the personal-owner claim already fails closed on.
1504
+ if (payload.kind !== ATLASSIAN_OAUTH_STATE_KIND) {
1505
+ throw new HTTPException(400, { message: "invalid Atlassian OAuth state" });
1506
+ }
1412
1507
  const accountId = requiredString(payload.accountId, "state.accountId");
1413
1508
  const workspaceId = requiredString(payload.workspaceId, "state.workspaceId");
1414
1509
  const subjectId = requiredString(payload.subjectId, "state.subjectId");
@@ -1425,6 +1520,7 @@ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): A
1425
1520
  accountId,
1426
1521
  workspaceId,
1427
1522
  subjectId,
1523
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
1428
1524
  returnPath,
1429
1525
  ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
1430
1526
  nonce: requiredString(payload.nonce, "state.nonce"),
@@ -1433,6 +1529,21 @@ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): A
1433
1529
  }
1434
1530
 
1435
1531
  async function requireCallbackGrant(deps: ApiRouteDeps, state: AtlassianOAuthState): Promise<void> {
1532
+ // This connector is personal-only by construction: its start request carries
1533
+ // no ownership and the callback always writes `subjectId: state.subjectId`.
1534
+ // A state minted before the start-side principal fence existed carries no
1535
+ // `personalOwnerVerified` claim and must not land a personal Connection.
1536
+ if (
1537
+ !personalOwnerStateAccepted({
1538
+ ownership: "personal",
1539
+ subjectId: state.subjectId,
1540
+ personalOwnerVerified: state.personalOwnerVerified,
1541
+ })
1542
+ ) {
1543
+ throw new HTTPException(422, {
1544
+ message: personalOnlyConnectionPrincipalMessage("Atlassian"),
1545
+ });
1546
+ }
1436
1547
  const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
1437
1548
  if (
1438
1549
  !grant ||
@@ -27,6 +27,7 @@ import {
27
27
  getConnectionMetadata,
28
28
  getWorkspaceGrant,
29
29
  listConnectionsMetadata,
30
+ loadConnectionCredentialForBroker,
30
31
  recordAuditEvent,
31
32
  setConnectionStatus,
32
33
  updateConnection,
@@ -102,6 +103,8 @@ type FikenContext = {
102
103
  sessionId: string | null;
103
104
  };
104
105
 
106
+ type FikenProviderAuthorization = () => Promise<boolean | void>;
107
+
105
108
  /**
106
109
  * Fiken allows only a single concurrent API request per credential; concurrent
107
110
  * requests can 429 and repeated violations can get the token banned. Serialize
@@ -281,6 +284,7 @@ export type FikenListInput = {
281
284
 
282
285
  export class FikenClient {
283
286
  private readonly resolveCredential: ReturnType<typeof buildConnectionTokenResolver>;
287
+ private expectedConnectionVersion: number;
284
288
 
285
289
  constructor(
286
290
  private readonly db: Database,
@@ -289,11 +293,22 @@ export class FikenClient {
289
293
  private readonly metadata: FikenConnectionMetadata,
290
294
  private readonly context: FikenContext,
291
295
  private readonly fetchImpl: FetchLike = fetch,
296
+ private readonly authorizeProviderRequest?: FikenProviderAuthorization,
292
297
  ) {
298
+ this.expectedConnectionVersion = connection.version;
293
299
  // OAuth-kind connections refresh through the generic broker; route that
294
- // token-endpoint transport through the same injectable Fiken fetch.
300
+ // token-endpoint transport through the same injectable Fiken fetch. Keep
301
+ // the optional authorization immediately before actual refresh I/O.
302
+ const refreshFetch: FetchLike = this.authorizeProviderRequest
303
+ ? async (input, init) => {
304
+ if ((await this.authorizeProviderRequest?.()) === false) {
305
+ throw new Error("the Fiken credential refresh is no longer authorized");
306
+ }
307
+ return await this.fetchImpl(input, init);
308
+ }
309
+ : fetchImpl;
295
310
  this.resolveCredential = buildConnectionTokenResolver(db, settings, undefined, {
296
- refreshTransport: { fetchImpl },
311
+ refreshTransport: { fetchImpl: refreshFetch },
297
312
  });
298
313
  }
299
314
 
@@ -619,17 +634,27 @@ export class FikenClient {
619
634
  for (const [key, value] of Object.entries(input.query ?? {})) {
620
635
  url.searchParams.set(key, value);
621
636
  }
622
- // Credential resolution (DB read + possible broker refresh) happens
623
- // outside the serialization chain: only the provider HTTP call itself
624
- // must obey Fiken's single-concurrent-request rule.
625
- const headers = await this.headersFor(operation, url.toString());
626
637
  const result = await serializedPerConnection(this.connection.id, async () => {
638
+ const authority = await this.currentAuthority(this.expectedConnectionVersion);
639
+ const credential = await this.credentialFor(operation, url.toString());
640
+ await this.currentAuthority(credential.connectionVersion, authority.authorityGeneration);
641
+ // The adapter callback is a fallible preflight. The canonical
642
+ // credential callback below records the physical-use fact, so it must
643
+ // be the final await before the target fetch.
644
+ if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) {
645
+ throw new Error("the Fiken provider request is no longer authorized");
646
+ }
647
+ await this.currentAuthority(credential.connectionVersion, authority.authorityGeneration);
648
+ if (credential.authorizeProviderRequest && !(await credential.authorizeProviderRequest())) {
649
+ throw new Error("the Fiken provider request is no longer authorized");
650
+ }
651
+ this.expectedConnectionVersion = credential.connectionVersion;
627
652
  let response: Response;
628
653
  try {
629
654
  response = await this.fetchImpl(url, {
630
655
  method,
631
656
  headers: {
632
- ...headers,
657
+ ...credential.headers,
633
658
  accept: "application/json",
634
659
  ...(input.body !== undefined ? { "content-type": "application/json" } : {}),
635
660
  },
@@ -642,7 +667,7 @@ export class FikenClient {
642
667
  } catch {
643
668
  throw new FikenProviderError("transport_error");
644
669
  }
645
- return await this.readResponse(operation, response);
670
+ return await this.readResponse(operation, response, credential.connectionVersion);
646
671
  });
647
672
  await this.recordAudit(operation, "succeeded");
648
673
  return result;
@@ -655,15 +680,14 @@ export class FikenClient {
655
680
  private async readResponse(
656
681
  operation: FikenOperation,
657
682
  response: Response,
683
+ credentialVersion: number,
658
684
  ): Promise<{ payload: unknown; page: FikenPage | null; locationId: number | null }> {
659
685
  if (response.status === 401) {
660
686
  await response.body?.cancel().catch(() => undefined);
661
- // A 401 after resolution means the credential is genuinely rejected
662
- // (revoked token or revoked OAuth grant). setConnectionStatus is a
663
- // strict version CAS and a broker refresh may have bumped the version
664
- // since this client resolved the row, so re-read the current version
665
- // instead of using the resolution-time snapshot.
666
- await this.markNeedsReauth().catch(() => undefined);
687
+ // Only the exact active credential generation sent on this request may
688
+ // transition. A late 401 must not poison a reconnect or revocation that
689
+ // became current while the provider response was in flight.
690
+ await this.markNeedsReauth(credentialVersion).catch(() => undefined);
667
691
  throw new FikenProviderError("credential_rejected", 401);
668
692
  }
669
693
  if (response.status === 429) {
@@ -699,27 +723,67 @@ export class FikenClient {
699
723
  return { payload, page: pageFromHeaders(response.headers), locationId };
700
724
  }
701
725
 
702
- private async markNeedsReauth(): Promise<void> {
726
+ private async markNeedsReauth(credentialVersion: number): Promise<void> {
703
727
  const current = await getConnectionMetadata(
704
728
  this.db,
705
729
  this.context.workspaceId,
706
730
  this.connection.id,
707
731
  null,
708
732
  );
709
- if (!current || current.status === "needs_reauth") return;
733
+ if (
734
+ !current ||
735
+ current.status !== "active" ||
736
+ current.version !== credentialVersion ||
737
+ !isFikenConnection(current)
738
+ ) {
739
+ return;
740
+ }
710
741
  await setConnectionStatus(
711
742
  this.db,
712
743
  this.context.workspaceId,
713
744
  "needs_reauth",
714
745
  "Fiken rejected the credential",
715
- { id: current.id, version: current.version, subjectId: null },
746
+ { id: current.id, version: credentialVersion, subjectId: null },
716
747
  );
717
748
  }
718
749
 
719
- private async headersFor(
750
+ private async currentAuthority(
751
+ expectedVersion: number,
752
+ expectedAuthorityGeneration?: number,
753
+ ): Promise<{ version: number; authorityGeneration: number }> {
754
+ const current = await loadConnectionCredentialForBroker(this.db, this.settings, {
755
+ workspaceId: this.context.workspaceId,
756
+ connectionId: this.connection.id,
757
+ providerDomain: FIKEN_PROVIDER_DOMAIN,
758
+ kind: this.connection.kind === "oauth2" ? "oauth2" : "api_key",
759
+ subjectId: null,
760
+ ...(expectedAuthorityGeneration !== undefined ? { expectedAuthorityGeneration } : {}),
761
+ });
762
+ if (
763
+ !current ||
764
+ current.accountId !== this.context.accountId ||
765
+ current.workspaceId !== this.context.workspaceId ||
766
+ current.subjectId !== null ||
767
+ current.providerDomain !== FIKEN_PROVIDER_DOMAIN ||
768
+ current.kind !== this.connection.kind ||
769
+ current.status !== "active" ||
770
+ current.version !== expectedVersion ||
771
+ current.authorityGeneration === undefined ||
772
+ !fikenConnectionMetadata(current.metadata)
773
+ ) {
774
+ throw new Error("the Fiken connection authority changed");
775
+ }
776
+ return { version: current.version, authorityGeneration: current.authorityGeneration };
777
+ }
778
+
779
+ private async credentialFor(
720
780
  operation: FikenOperation,
721
781
  destinationUrl: string,
722
- ): Promise<Record<string, string>> {
782
+ ): Promise<{
783
+ headers: Record<string, string>;
784
+ connectionVersion: number;
785
+ authorizeProviderRequest?: () => Promise<boolean>;
786
+ }> {
723
787
  const result = await this.resolveCredential({
724
788
  workspaceId: this.context.workspaceId,
725
789
  serverId: "opengeni-fiken",
@@ -732,10 +796,20 @@ export class FikenClient {
732
796
  },
733
797
  destinationUrl,
734
798
  });
735
- if (result.status !== "ok" || result.connectionId !== this.connection.id) {
799
+ if (
800
+ result.status !== "ok" ||
801
+ result.connectionId !== this.connection.id ||
802
+ result.connectionVersion === undefined
803
+ ) {
736
804
  throw new Error("the Fiken connection needs to be reconnected");
737
805
  }
738
- return result.headers;
806
+ return {
807
+ headers: result.headers,
808
+ connectionVersion: result.connectionVersion,
809
+ ...(result.authorizeProviderRequest
810
+ ? { authorizeProviderRequest: result.authorizeProviderRequest }
811
+ : {}),
812
+ };
739
813
  }
740
814
 
741
815
  private receipt(operation: FikenOperation, operationId?: string) {
@@ -771,7 +845,12 @@ export class FikenClient {
771
845
  }
772
846
 
773
847
  export function createFikenClient(
774
- deps: { db: Database; settings: Settings; fikenFetch?: typeof fetch },
848
+ deps: {
849
+ db: Database;
850
+ settings: Settings;
851
+ fikenFetch?: typeof fetch;
852
+ authorizeProviderRequest?: FikenProviderAuthorization;
853
+ },
775
854
  resolved: Awaited<ReturnType<typeof resolveFikenConnectionForTool>>,
776
855
  ): FikenClient {
777
856
  return new FikenClient(
@@ -781,6 +860,7 @@ export function createFikenClient(
781
860
  resolved.metadata,
782
861
  resolved.context,
783
862
  deps.fikenFetch,
863
+ deps.authorizeProviderRequest,
784
864
  );
785
865
  }
786
866