@opengeni/db 0.7.3 → 0.10.7

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 (70) hide show
  1. package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
  2. package/dist/chunk-BMFDXFPA.js.map +1 -0
  3. package/dist/chunk-KW526IJA.js +127 -0
  4. package/dist/chunk-KW526IJA.js.map +1 -0
  5. package/dist/{chunk-B22X3IEZ.js → chunk-P6PKXY5W.js} +604 -32
  6. package/dist/chunk-P6PKXY5W.js.map +1 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +7408 -2276
  9. package/dist/index.js.map +1 -1
  10. package/dist/migrate.d.ts +29 -4
  11. package/dist/migrate.js +3 -1
  12. package/dist/provision-roles.d.ts +1122 -73
  13. package/dist/provision-roles.js +1 -1
  14. package/dist/{schema-BN5mB9xZ.d.ts → schema-CqkzrBRS.d.ts} +6967 -4457
  15. package/dist/schema.d.ts +1 -1
  16. package/dist/schema.js +19 -1
  17. package/drizzle/0053_codex_credential_leases.sql +2 -2
  18. package/drizzle/0057_durable_queue_control.sql +1 -1
  19. package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
  20. package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
  21. package/drizzle/0063_session_control_mega_foundation.sql +1 -1
  22. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
  23. package/drizzle/0065_codex_subscription_overview.sql +168 -0
  24. package/drizzle/0065_session_attempt_quiescence.sql +26 -0
  25. package/drizzle/0065_session_tool_policy.sql +38 -0
  26. package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
  27. package/drizzle/0067_session_event_payload_bounds.sql +209 -0
  28. package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
  29. package/drizzle/0069_session_event_history_backfill.sql +32 -0
  30. package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
  31. package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
  32. package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
  33. package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
  34. package/drizzle/0074_session_activity_revisions.sql +72 -0
  35. package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
  36. package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
  37. package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
  38. package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
  39. package/drizzle/0095_github_existing_installations.sql +84 -0
  40. package/drizzle/0096_session_turn_initiators.sql +97 -0
  41. package/drizzle/0097_host_export_outbox.sql +1220 -0
  42. package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
  43. package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
  44. package/drizzle/0100_session_human_input_requests.sql +89 -0
  45. package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
  46. package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
  47. package/drizzle/0103_host_export_root_session.sql +166 -0
  48. package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
  49. package/drizzle/0105_session_turn_instructions.sql +9 -0
  50. package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
  51. package/drizzle/0107_host_export_lineage_contract.sql +381 -0
  52. package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
  53. package/package.json +5 -4
  54. package/src/codex-token-resolver.ts +175 -14
  55. package/src/connection-token-resolver.ts +428 -119
  56. package/src/event-payload-sanitizer.ts +88 -20
  57. package/src/index.ts +7208 -1273
  58. package/src/memory-domain.ts +1 -1
  59. package/src/migrate.ts +86 -23
  60. package/src/persistence-errors.ts +252 -0
  61. package/src/provision-roles.ts +42 -0
  62. package/src/schema.ts +658 -34
  63. package/src/session-control.ts +520 -38
  64. package/src/session-queue-commands.ts +385 -61
  65. package/src/session-tool-call-settlement.ts +58 -7
  66. package/src/turn-initiator.ts +155 -0
  67. package/dist/chunk-7LDU7F5P.js +0 -80
  68. package/dist/chunk-7LDU7F5P.js.map +0 -1
  69. package/dist/chunk-B22X3IEZ.js.map +0 -1
  70. package/dist/chunk-YFQ7SGE4.js.map +0 -1
@@ -3,8 +3,25 @@ import {
3
3
  type McpServerConnectionRef,
4
4
  type Settings,
5
5
  } from "@opengeni/config";
6
+ import type {
7
+ ConnectionCredentialsPort,
8
+ McpConnectionResourceScope,
9
+ McpCredentialAuthNeededReason,
10
+ McpCredentialsRequest,
11
+ TurnInitiator,
12
+ TurnInitiatorContext,
13
+ } from "@opengeni/contracts";
14
+ import {
15
+ OAUTH_MAX_RESPONSE_BYTES,
16
+ pinnedFetch,
17
+ readResponseJsonBounded,
18
+ undiciFetch,
19
+ validateHttpUrl,
20
+ type DnsLookup,
21
+ type FetchLike,
22
+ } from "@opengeni/network";
23
+ export { isPrivateAddress } from "@opengeni/network";
6
24
  import { Buffer } from "node:buffer";
7
- import { lookup } from "node:dns/promises";
8
25
  import { isIP } from "node:net";
9
26
  import { encryptEnvironmentValue } from "./environment-crypto";
10
27
  import {
@@ -20,11 +37,13 @@ export type ResolveConnectionCredentialResult =
20
37
  | { status: "ok"; headers: Record<string, string>; connectionId: string; expiresAt?: Date | null }
21
38
  | {
22
39
  status: "auth_needed";
23
- reason: "missing_connection" | "expired" | "insufficient_scope" | "refresh_failed";
40
+ reason: McpCredentialAuthNeededReason;
24
41
  providerDomain: string;
42
+ provider?: string;
25
43
  connectionId?: string;
26
44
  scopes?: string[];
27
45
  resource?: string;
46
+ selectedResources?: McpConnectionResourceScope[];
28
47
  authorizationUrl?: string;
29
48
  };
30
49
  type AuthNeededReason = Extract<
@@ -36,11 +55,277 @@ export type ResolveConnectionCredentialInput = {
36
55
  workspaceId: string;
37
56
  subjectId?: string;
38
57
  serverId: string;
58
+ toolName?: string;
59
+ /** @deprecated Use toolName. Retained for the API's pre-existing broker call shape. */
39
60
  toolId?: string;
40
61
  connectionRef: McpServerConnectionRef;
62
+ /** Exact MCP destination whose request would receive the resolved headers. */
63
+ destinationUrl: string;
41
64
  forceRefresh?: boolean;
42
65
  };
43
66
 
67
+ export type HostMcpCredentialResolverContext = {
68
+ accountId: string;
69
+ workspaceId: string;
70
+ sessionId: string;
71
+ rootSessionId: string;
72
+ turnId: string;
73
+ attemptId: string | null;
74
+ executionGeneration: number;
75
+ initiator: TurnInitiator;
76
+ initiatorContext: TurnInitiatorContext;
77
+ surface: McpCredentialsRequest["surface"];
78
+ };
79
+
80
+ export class HostMcpCredentialScopeError extends Error {
81
+ constructor(field: "accountId" | "workspaceId" | "sessionId") {
82
+ super(`host MCP credential ${field} scope mismatch`);
83
+ this.name = "HostMcpCredentialScopeError";
84
+ }
85
+ }
86
+
87
+ export class HostMcpCredentialBindingError extends Error {
88
+ constructor(
89
+ field:
90
+ | "provider"
91
+ | "providerDomain"
92
+ | "connectionId"
93
+ | "scopes"
94
+ | "resource"
95
+ | "selectedResources"
96
+ | "destinationUrl",
97
+ ) {
98
+ super(`host MCP credential ${field} binding mismatch`);
99
+ this.name = "HostMcpCredentialBindingError";
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Adapts the public embedding credential port to the runtime's connection
105
+ * resolver contract. Scope echoes are checked before credential headers can
106
+ * reach a request; the returned object is a fresh copy so a host cannot mutate
107
+ * headers after resolution.
108
+ */
109
+ export function buildHostConnectionTokenResolver(
110
+ resolve: NonNullable<ConnectionCredentialsPort["mcpCredentials"]>,
111
+ context: HostMcpCredentialResolverContext,
112
+ ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
113
+ return async (input) => {
114
+ if (input.workspaceId !== context.workspaceId) {
115
+ throw new HostMcpCredentialScopeError("workspaceId");
116
+ }
117
+ const destinationUrl = canonicalHttpUrl(input.destinationUrl);
118
+ if (
119
+ !destinationUrl ||
120
+ !destinationHostMatchesProvider(destinationUrl, input.connectionRef.providerDomain)
121
+ ) {
122
+ throw new HostMcpCredentialBindingError("destinationUrl");
123
+ }
124
+ const toolName = input.toolName ?? input.toolId;
125
+ const request: McpCredentialsRequest = {
126
+ accountId: context.accountId,
127
+ workspaceId: context.workspaceId,
128
+ sessionId: context.sessionId,
129
+ rootSessionId: context.rootSessionId,
130
+ turnId: context.turnId,
131
+ attemptId: context.attemptId,
132
+ executionGeneration: context.executionGeneration,
133
+ initiator: context.initiator,
134
+ initiatorContext: { ...context.initiatorContext },
135
+ surface: context.surface,
136
+ destinationUrl,
137
+ serverId: input.serverId,
138
+ connectionRef: {
139
+ providerDomain: input.connectionRef.providerDomain,
140
+ ...(input.connectionRef.provider ? { provider: input.connectionRef.provider } : {}),
141
+ ...(input.connectionRef.connectionId
142
+ ? { connectionId: input.connectionRef.connectionId }
143
+ : {}),
144
+ ...(input.connectionRef.kind ? { kind: input.connectionRef.kind } : {}),
145
+ ...(input.connectionRef.scopes ? { scopes: [...input.connectionRef.scopes] } : {}),
146
+ ...(input.connectionRef.resource ? { resource: input.connectionRef.resource } : {}),
147
+ ...(input.connectionRef.selectedResources
148
+ ? { selectedResources: copySelectedResources(input.connectionRef.selectedResources) }
149
+ : {}),
150
+ ...(input.connectionRef.subjectScope
151
+ ? { subjectScope: input.connectionRef.subjectScope }
152
+ : {}),
153
+ },
154
+ forceRefresh: input.forceRefresh === true,
155
+ ...(toolName ? { toolName } : {}),
156
+ ...(input.subjectId ? { callerSubjectId: input.subjectId } : {}),
157
+ };
158
+ const result = await resolve(request);
159
+ assertHostMcpCredentialScope(result, context);
160
+ assertHostMcpCredentialBinding(result, input.connectionRef);
161
+ if (result.status === "auth_needed") {
162
+ const authorizationUrl = normalizedAuthorizationUrl(result.authorizationUrl);
163
+ return {
164
+ status: "auth_needed",
165
+ reason: result.reason,
166
+ providerDomain: result.providerDomain,
167
+ ...(result.provider ? { provider: result.provider } : {}),
168
+ ...(result.connectionId ? { connectionId: result.connectionId } : {}),
169
+ ...(result.scopes ? { scopes: [...result.scopes] } : {}),
170
+ ...(result.resource ? { resource: result.resource } : {}),
171
+ ...(result.selectedResources
172
+ ? { selectedResources: copySelectedResources(result.selectedResources) }
173
+ : {}),
174
+ ...(authorizationUrl ? { authorizationUrl } : {}),
175
+ };
176
+ }
177
+ if (result.connectionId.length === 0) {
178
+ throw new Error("host MCP credential returned an empty connectionId");
179
+ }
180
+ const expiresAt = parseHostCredentialExpiry(result.expiresAt);
181
+ return {
182
+ status: "ok",
183
+ headers: normalizedHostCredentialHeaders(result.headers),
184
+ connectionId: result.connectionId,
185
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
186
+ };
187
+ };
188
+ }
189
+
190
+ function assertHostMcpCredentialBinding(
191
+ result: Awaited<ReturnType<NonNullable<ConnectionCredentialsPort["mcpCredentials"]>>>,
192
+ requested: McpServerConnectionRef,
193
+ ): void {
194
+ if (result.providerDomain !== requested.providerDomain) {
195
+ throw new HostMcpCredentialBindingError("providerDomain");
196
+ }
197
+ if (result.provider !== requested.provider) {
198
+ throw new HostMcpCredentialBindingError("provider");
199
+ }
200
+ if (requested.connectionId && result.connectionId !== requested.connectionId) {
201
+ throw new HostMcpCredentialBindingError("connectionId");
202
+ }
203
+ if (!sameSelectedResources(result.selectedResources, requested.selectedResources)) {
204
+ throw new HostMcpCredentialBindingError("selectedResources");
205
+ }
206
+ if (result.status === "ok") {
207
+ if (!sameStringSet(result.scopes, requested.scopes)) {
208
+ throw new HostMcpCredentialBindingError("scopes");
209
+ }
210
+ if (result.resource !== requested.resource) {
211
+ throw new HostMcpCredentialBindingError("resource");
212
+ }
213
+ }
214
+ }
215
+
216
+ function sameStringSet(left: string[] | undefined, right: string[] | undefined): boolean {
217
+ if (left === undefined || right === undefined) return left === right;
218
+ if (left.length !== right.length) return false;
219
+ const sortedLeft = [...left].sort();
220
+ const sortedRight = [...right].sort();
221
+ return sortedLeft.every((value, index) => value === sortedRight[index]);
222
+ }
223
+
224
+ function sameSelectedResources(
225
+ left: McpConnectionResourceScope[] | undefined,
226
+ right: McpConnectionResourceScope[] | undefined,
227
+ ): boolean {
228
+ if (left === undefined || right === undefined) return left === right;
229
+ const leftKeys = copySelectedResources(left)
230
+ .map((resource) => `${resource.kind}\0${resource.id}`)
231
+ .sort();
232
+ const rightKeys = copySelectedResources(right)
233
+ .map((resource) => `${resource.kind}\0${resource.id}`)
234
+ .sort();
235
+ return (
236
+ leftKeys.length === rightKeys.length &&
237
+ leftKeys.every((value, index) => value === rightKeys[index])
238
+ );
239
+ }
240
+
241
+ function copySelectedResources(
242
+ resources: McpConnectionResourceScope[],
243
+ ): McpConnectionResourceScope[] {
244
+ if (resources.length === 0 || resources.length > 256) {
245
+ throw new Error("host MCP credential returned an invalid selected resource count");
246
+ }
247
+ const seen = new Set<string>();
248
+ return resources.map((resource) => {
249
+ if (
250
+ resource.kind !== "repository" ||
251
+ typeof resource.id !== "string" ||
252
+ resource.id.length === 0 ||
253
+ resource.id.length > 512
254
+ ) {
255
+ throw new Error("host MCP credential returned an invalid selected resource");
256
+ }
257
+ const key = `${resource.kind}\0${resource.id}`;
258
+ if (seen.has(key)) {
259
+ throw new Error("host MCP credential returned duplicate selected resources");
260
+ }
261
+ seen.add(key);
262
+ return { kind: resource.kind, id: resource.id };
263
+ });
264
+ }
265
+
266
+ function assertHostMcpCredentialScope(
267
+ result: { accountId: string; workspaceId: string; sessionId: string },
268
+ context: HostMcpCredentialResolverContext,
269
+ ): void {
270
+ for (const field of ["accountId", "workspaceId", "sessionId"] as const) {
271
+ if (result[field] !== context[field]) {
272
+ throw new HostMcpCredentialScopeError(field);
273
+ }
274
+ }
275
+ }
276
+
277
+ function normalizedHostCredentialHeaders(headers: Record<string, string>): Record<string, string> {
278
+ const entries = Object.entries(headers);
279
+ if (entries.length === 0 || entries.length > 32) {
280
+ throw new Error("host MCP credential returned an invalid header count");
281
+ }
282
+ const normalized: Record<string, string> = {};
283
+ for (const [name, value] of entries) {
284
+ if (
285
+ name.length > 256 ||
286
+ !/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name) ||
287
+ value.length === 0 ||
288
+ value.length > 16_384 ||
289
+ /[\r\n\0]/.test(value)
290
+ ) {
291
+ throw new Error("host MCP credential returned an invalid header");
292
+ }
293
+ normalized[name] = value;
294
+ }
295
+ return normalized;
296
+ }
297
+
298
+ function normalizedAuthorizationUrl(value: string | undefined): string | undefined {
299
+ if (!value) return undefined;
300
+ let url: URL;
301
+ try {
302
+ url = new URL(value);
303
+ } catch {
304
+ throw new Error("host MCP credential returned an invalid authorizationUrl");
305
+ }
306
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) {
307
+ throw new Error("host MCP credential returned an invalid authorizationUrl");
308
+ }
309
+ return url.toString();
310
+ }
311
+
312
+ function isLoopbackHost(hostname: string): boolean {
313
+ if (hostname === "localhost" || hostname === "::1") return true;
314
+ if (isIP(hostname) !== 4) return false;
315
+ const [first] = hostname.split(".");
316
+ return first === "127";
317
+ }
318
+
319
+ function parseHostCredentialExpiry(value: string | null | undefined): Date | null | undefined {
320
+ if (value === undefined) return undefined;
321
+ if (value === null) return null;
322
+ const parsed = new Date(value);
323
+ if (!Number.isFinite(parsed.getTime())) {
324
+ throw new Error("host MCP credential returned an invalid expiresAt");
325
+ }
326
+ return parsed;
327
+ }
328
+
44
329
  export type ConnectionBrokerDeps = {
45
330
  loadCredential: typeof loadConnectionCredentialForBroker;
46
331
  recordRefresh: typeof recordConnectionTokenRefresh;
@@ -52,6 +337,11 @@ export type ConnectionBrokerDeps = {
52
337
  now: () => Date;
53
338
  };
54
339
 
340
+ export type RefreshTransportOptions = {
341
+ fetchImpl?: FetchLike;
342
+ dnsLookup?: DnsLookup;
343
+ };
344
+
55
345
  const defaultDeps: ConnectionBrokerDeps = {
56
346
  loadCredential: loadConnectionCredentialForBroker,
57
347
  recordRefresh: recordConnectionTokenRefresh,
@@ -65,14 +355,19 @@ const defaultDeps: ConnectionBrokerDeps = {
65
355
 
66
356
  const inflight = new Map<string, Promise<ConnectionCredentialForBroker>>();
67
357
  const REFRESH_WINDOW_MS = 60_000;
358
+ const CONNECTION_REFRESH_TIMEOUT_MS = 10_000;
68
359
 
69
360
  export function buildConnectionTokenResolver(
70
361
  db: Database,
71
362
  settings: Settings,
72
363
  deps: ConnectionBrokerDeps = defaultDeps,
73
364
  ): (input: ResolveConnectionCredentialInput) => Promise<ResolveConnectionCredentialResult> {
365
+ type CredentialLookupInput = Pick<
366
+ ResolveConnectionCredentialInput,
367
+ "workspaceId" | "connectionRef" | "subjectId"
368
+ >;
74
369
  const load = async (
75
- input: ResolveConnectionCredentialInput,
370
+ input: CredentialLookupInput,
76
371
  ): Promise<ConnectionCredentialForBroker | null> => {
77
372
  const request: Parameters<typeof loadConnectionCredentialForBroker>[2] = {
78
373
  workspaceId: input.workspaceId,
@@ -95,19 +390,27 @@ export function buildConnectionTokenResolver(
95
390
  const snapshot = async (
96
391
  cred: ConnectionCredentialForBroker,
97
392
  ref: McpServerConnectionRef,
393
+ destinationUrl: string,
98
394
  ): Promise<ResolveConnectionCredentialResult> => {
99
395
  if (cred.status !== "active") {
100
396
  return authNeededForStatus(cred, ref);
101
397
  }
398
+ if (!connectionBindingMatches(cred, ref, destinationUrl)) {
399
+ return authNeeded(ref, "missing_connection", cred.id);
400
+ }
102
401
  const missingScopes = missingRequestedScopes(ref.scopes, cred.grantedScopes);
103
402
  if (missingScopes.length > 0) {
104
403
  return {
105
404
  status: "auth_needed",
106
405
  reason: "insufficient_scope",
107
406
  providerDomain: ref.providerDomain,
407
+ ...(ref.provider ? { provider: ref.provider } : {}),
108
408
  connectionId: cred.id,
109
409
  scopes: missingScopes,
110
410
  ...(ref.resource ? { resource: ref.resource } : {}),
411
+ ...(ref.selectedResources
412
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
413
+ : {}),
111
414
  };
112
415
  }
113
416
  const headers = headersForCredential(cred);
@@ -116,9 +419,13 @@ export function buildConnectionTokenResolver(
116
419
  status: "auth_needed",
117
420
  reason: "refresh_failed",
118
421
  providerDomain: ref.providerDomain,
422
+ ...(ref.provider ? { provider: ref.provider } : {}),
119
423
  connectionId: cred.id,
120
424
  ...(ref.scopes ? { scopes: ref.scopes } : {}),
121
425
  ...(ref.resource ? { resource: ref.resource } : {}),
426
+ ...(ref.selectedResources
427
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
428
+ : {}),
122
429
  };
123
430
  }
124
431
  await deps.recordUsed(db, cred.workspaceId, cred.id);
@@ -154,7 +461,6 @@ export function buildConnectionTokenResolver(
154
461
  if (persisted) {
155
462
  const current = await load({
156
463
  workspaceId: cred.workspaceId,
157
- serverId: "",
158
464
  connectionRef: { ...ref, connectionId: cred.id },
159
465
  });
160
466
  if (current) {
@@ -163,7 +469,6 @@ export function buildConnectionTokenResolver(
163
469
  }
164
470
  const winner = await load({
165
471
  workspaceId: cred.workspaceId,
166
- serverId: "",
167
472
  connectionRef: { ...ref, connectionId: cred.id },
168
473
  });
169
474
  if (winner?.status === "active") {
@@ -192,6 +497,13 @@ export function buildConnectionTokenResolver(
192
497
 
193
498
  return async (input) => {
194
499
  const ref = input.connectionRef;
500
+ // Repository-scoped provider bindings require a broker that can prove the
501
+ // selected-resource boundary. The generic standalone credential store has
502
+ // no provider-specific containment adapter, so it must fail closed instead
503
+ // of handing an account-wide token to the configured endpoint.
504
+ if (ref.selectedResources) {
505
+ return authNeeded(ref, "resource_scope_unavailable", ref.connectionId);
506
+ }
195
507
  let cred: ConnectionCredentialForBroker | null;
196
508
  try {
197
509
  cred = await load(input);
@@ -204,6 +516,12 @@ export function buildConnectionTokenResolver(
204
516
  if (cred.status !== "active") {
205
517
  return authNeededForStatus(cred, ref);
206
518
  }
519
+ // Reject an audience/destination mismatch before any provider-side refresh
520
+ // or usage update. Refreshing first would still create an unauthorized
521
+ // external side effect even though the token was never sent to the target.
522
+ if (!connectionBindingMatches(cred, ref, input.destinationUrl)) {
523
+ return authNeeded(ref, "missing_connection", cred.id);
524
+ }
207
525
  if (shouldRefresh(cred, input.forceRefresh === true, deps.now())) {
208
526
  try {
209
527
  cred = await refreshSingleFlight(cred, ref);
@@ -227,10 +545,75 @@ export function buildConnectionTokenResolver(
227
545
  return authNeeded(ref, "refresh_failed", cred.id);
228
546
  }
229
547
  }
230
- return await snapshot(cred, ref);
548
+ return await snapshot(cred, ref, input.destinationUrl);
231
549
  };
232
550
  }
233
551
 
552
+ function connectionBindingMatches(
553
+ cred: ConnectionCredentialForBroker,
554
+ ref: McpServerConnectionRef,
555
+ destinationUrl: string,
556
+ ): boolean {
557
+ if (cred.providerDomain.toLowerCase() !== ref.providerDomain.toLowerCase()) return false;
558
+ if (ref.kind && cred.kind !== ref.kind) return false;
559
+
560
+ const credential = cred.credential as Record<string, unknown>;
561
+ const metadata = cred.metadata as Record<string, unknown>;
562
+ const boundMcpUrl = stringValue(credential.mcp_url) ?? stringValue(metadata.mcpUrl);
563
+ const destination = canonicalHttpUrl(destinationUrl);
564
+ if (!destination) return false;
565
+ if (boundMcpUrl) {
566
+ const binding = canonicalHttpUrl(boundMcpUrl);
567
+ if (!binding || destination !== binding) return false;
568
+ } else if (!destinationHostMatchesProvider(destination, cred.providerDomain)) {
569
+ // Legacy/manual API-key rows may predate mcpUrl metadata. They are still
570
+ // host-bound to their canonical provider domain, never usable as an
571
+ // arbitrary bearer/header source for an unrelated MCP destination.
572
+ return false;
573
+ }
574
+ if (cred.kind !== "oauth2") return true;
575
+ const boundResource = stringValue(credential.resource) ?? stringValue(metadata.resource);
576
+ if (ref.resource) {
577
+ if (!boundResource) return false;
578
+ if (canonicalResource(ref.resource) !== canonicalResource(boundResource)) return false;
579
+ }
580
+ return true;
581
+ }
582
+
583
+ function destinationHostMatchesProvider(destinationUrl: string, providerDomain: string): boolean {
584
+ const destinationHost = new URL(destinationUrl).hostname.toLowerCase();
585
+ const provider = providerDomain
586
+ .trim()
587
+ .toLowerCase()
588
+ .replace(/^\.+|\.+$/g, "");
589
+ return (
590
+ Boolean(provider) && (destinationHost === provider || destinationHost.endsWith(`.${provider}`))
591
+ );
592
+ }
593
+
594
+ function canonicalHttpUrl(value: string): string | null {
595
+ try {
596
+ const url = new URL(value);
597
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
598
+ url.hash = "";
599
+ url.hostname = url.hostname.toLowerCase();
600
+ if (
601
+ (url.protocol === "https:" && url.port === "443") ||
602
+ (url.protocol === "http:" && url.port === "80")
603
+ ) {
604
+ url.port = "";
605
+ }
606
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
607
+ return url.toString();
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ function canonicalResource(value: string): string {
614
+ return canonicalHttpUrl(value) ?? value.trim();
615
+ }
616
+
234
617
  export class ConnectionRefreshHttpError extends Error {
235
618
  readonly httpStatus: number;
236
619
 
@@ -276,9 +659,13 @@ function authNeeded(
276
659
  status: "auth_needed",
277
660
  reason,
278
661
  providerDomain: ref.providerDomain,
662
+ ...(ref.provider ? { provider: ref.provider } : {}),
279
663
  ...(connectionId ? { connectionId } : {}),
280
664
  ...(ref.scopes ? { scopes: ref.scopes } : {}),
281
665
  ...(ref.resource ? { resource: ref.resource } : {}),
666
+ ...(ref.selectedResources
667
+ ? { selectedResources: copySelectedResources(ref.selectedResources) }
668
+ : {}),
282
669
  };
283
670
  }
284
671
 
@@ -333,7 +720,8 @@ function headersForCredential(cred: ConnectionCredentialForBroker): Record<strin
333
720
  export async function refreshOAuthConnectionCredential(
334
721
  cred: ConnectionCredentialForBroker,
335
722
  ref: McpServerConnectionRef,
336
- settings?: Settings,
723
+ settings: Settings,
724
+ transportOptions: RefreshTransportOptions = {},
337
725
  ): Promise<{
338
726
  credential: Record<string, unknown>;
339
727
  expiresAt: Date | null;
@@ -354,8 +742,14 @@ export async function refreshOAuthConnectionCredential(
354
742
  if (!refreshToken || !tokenEndpoint) {
355
743
  throw new Error("connection has no refresh token endpoint");
356
744
  }
357
- if (settings) {
358
- await assertOAuthEndpointAllowed(tokenEndpoint, settings);
745
+ let validatedTokenEndpoint: string;
746
+ try {
747
+ validatedTokenEndpoint = validateHttpUrl(tokenEndpoint, {
748
+ label: "OAuth refresh token endpoint",
749
+ allowLoopbackHttp: settings.environment === "local" || settings.environment === "test",
750
+ });
751
+ } catch {
752
+ throw new Error("connection has an invalid refresh token endpoint");
359
753
  }
360
754
  const body = new URLSearchParams();
361
755
  body.set("grant_type", "refresh_token");
@@ -389,19 +783,35 @@ export async function refreshOAuthConnectionCredential(
389
783
  if (ref.scopes?.length) {
390
784
  body.set("scope", ref.scopes.join(" "));
391
785
  }
392
- const response = await fetch(tokenEndpoint, {
393
- method: "POST",
394
- headers,
395
- body,
396
- redirect: "manual",
397
- });
786
+ const response = await pinnedFetch(
787
+ validatedTokenEndpoint,
788
+ {
789
+ method: "POST",
790
+ headers,
791
+ body,
792
+ signal: AbortSignal.timeout(CONNECTION_REFRESH_TIMEOUT_MS),
793
+ },
794
+ settings,
795
+ {
796
+ fetchImpl: transportOptions.fetchImpl ?? undiciFetch,
797
+ ...(transportOptions.dnsLookup ? { dnsLookup: transportOptions.dnsLookup } : {}),
798
+ label: "OAuth token endpoint",
799
+ requireHttpsOutsideLocalTest: true,
800
+ },
801
+ );
398
802
  if (response.status >= 300 && response.status < 400) {
803
+ await cancelResponseBody(response);
399
804
  throw new ConnectionRefreshHttpError(response.status);
400
805
  }
401
806
  if (!response.ok) {
807
+ await cancelResponseBody(response);
402
808
  throw new ConnectionRefreshHttpError(response.status);
403
809
  }
404
- const payload = (await response.json()) as Record<string, unknown>;
810
+ const payload = await readResponseJsonBounded<Record<string, unknown>>(
811
+ response,
812
+ OAUTH_MAX_RESPONSE_BYTES,
813
+ "OAuth refresh token response",
814
+ );
405
815
  const accessToken = stringValue(payload.access_token);
406
816
  if (!accessToken) {
407
817
  throw new Error("connection refresh response did not include access_token");
@@ -464,107 +874,6 @@ function stringValue(value: unknown): string | undefined {
464
874
  return typeof value === "string" && value.length > 0 ? value : undefined;
465
875
  }
466
876
 
467
- async function assertOAuthEndpointAllowed(rawUrl: string, settings: Settings): Promise<void> {
468
- if (
469
- settings.integrationsAllowPrivateNetworkTargets ||
470
- ["local", "test"].includes(settings.environment)
471
- ) {
472
- return;
473
- }
474
- const url = new URL(rawUrl);
475
- if (url.protocol !== "https:") {
476
- throw new Error("OAuth token endpoint must use https outside local/test");
477
- }
478
- const hostname = url.hostname.toLowerCase();
479
- if (hostname === "localhost" || hostname.endsWith(".localhost")) {
480
- throw new Error("OAuth token endpoint may not target localhost");
481
- }
482
- const literal = isIP(hostname);
483
- const addresses = literal
484
- ? [hostname]
485
- : (await lookup(hostname, { all: true })).map((entry) => entry.address);
486
- if (addresses.some(isPrivateAddress)) {
487
- throw new Error("OAuth token endpoint may not target a private network address");
488
- }
489
- }
490
-
491
- export function isPrivateAddress(address: string): boolean {
492
- const normalized = normalizeAddress(address);
493
- const mapped = ipv4FromMappedIpv6(normalized);
494
- if (mapped) {
495
- return isPrivateIpv4Address(mapped);
496
- }
497
- if (normalized.includes(":")) {
498
- if (isIP(normalized) !== 6) {
499
- return true;
500
- }
501
- return (
502
- normalized === "::1" ||
503
- normalized === "::" ||
504
- normalized.startsWith("fc") ||
505
- normalized.startsWith("fd") ||
506
- normalized.startsWith("fe8") ||
507
- normalized.startsWith("fe9") ||
508
- normalized.startsWith("fea") ||
509
- normalized.startsWith("feb")
510
- );
511
- }
512
- return isPrivateIpv4Address(normalized);
513
- }
514
-
515
- function normalizeAddress(address: string): string {
516
- const trimmed = address.trim().toLowerCase();
517
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
518
- return trimmed.slice(1, -1);
519
- }
520
- return trimmed;
521
- }
522
-
523
- function ipv4FromMappedIpv6(address: string): string | null {
524
- if (!address.startsWith("::ffff:")) {
525
- return null;
526
- }
527
- const embedded = address.slice("::ffff:".length);
528
- if (embedded.includes(".")) {
529
- return embedded;
530
- }
531
- const parts = embedded.split(":");
532
- if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) {
533
- return null;
534
- }
535
- const high = Number.parseInt(parts[0]!, 16);
536
- const low = Number.parseInt(parts[1]!, 16);
537
- if (
538
- !Number.isInteger(high) ||
539
- !Number.isInteger(low) ||
540
- high < 0 ||
541
- high > 0xffff ||
542
- low < 0 ||
543
- low > 0xffff
544
- ) {
545
- return null;
546
- }
547
- return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
548
- }
549
-
550
- function isPrivateIpv4Address(address: string): boolean {
551
- if (isIP(address) !== 4) {
552
- return true;
553
- }
554
- const parts = address.split(".").map((part) => Number(part));
555
- if (
556
- parts.length !== 4 ||
557
- parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
558
- ) {
559
- return true;
560
- }
561
- const [a, b] = parts as [number, number, number, number];
562
- return (
563
- a === 0 ||
564
- a === 10 ||
565
- a === 127 ||
566
- (a === 169 && b === 254) ||
567
- (a === 172 && b >= 16 && b <= 31) ||
568
- (a === 192 && b === 168)
569
- );
877
+ async function cancelResponseBody(response: Response): Promise<void> {
878
+ await response.body?.cancel().catch(() => undefined);
570
879
  }