@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.
- package/dist/{chunk-YFQ7SGE4.js → chunk-BMFDXFPA.js} +23 -1
- package/dist/chunk-BMFDXFPA.js.map +1 -0
- package/dist/chunk-KW526IJA.js +127 -0
- package/dist/chunk-KW526IJA.js.map +1 -0
- package/dist/{chunk-B22X3IEZ.js → chunk-P6PKXY5W.js} +604 -32
- package/dist/chunk-P6PKXY5W.js.map +1 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7408 -2276
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +29 -4
- package/dist/migrate.js +3 -1
- package/dist/provision-roles.d.ts +1122 -73
- package/dist/provision-roles.js +1 -1
- package/dist/{schema-BN5mB9xZ.d.ts → schema-CqkzrBRS.d.ts} +6967 -4457
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +19 -1
- package/drizzle/0053_codex_credential_leases.sql +2 -2
- package/drizzle/0057_durable_queue_control.sql +1 -1
- package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
- package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
- package/drizzle/0063_session_control_mega_foundation.sql +1 -1
- package/drizzle/0064_rotation_strategy_sharded_backfill.sql +15 -0
- package/drizzle/0065_codex_subscription_overview.sql +168 -0
- package/drizzle/0065_session_attempt_quiescence.sql +26 -0
- package/drizzle/0065_session_tool_policy.sql +38 -0
- package/drizzle/0066_session_interruption_attempt_lookup.sql +4 -0
- package/drizzle/0067_session_event_payload_bounds.sql +209 -0
- package/drizzle/0068_workspace_control_event_bounds.sql +134 -0
- package/drizzle/0069_session_event_history_backfill.sql +32 -0
- package/drizzle/0070_session_event_type_sequence_lookup.sql +4 -0
- package/drizzle/0071_session_event_monitoring_tail.sql +10 -0
- package/drizzle/0072_sessions_workspace_created_id_idx.sql +4 -0
- package/drizzle/0073_sessions_workspace_updated_id_idx.sql +4 -0
- package/drizzle/0074_session_activity_revisions.sql +72 -0
- package/drizzle/0075_sessions_workspace_activity_revision_idx.sql +4 -0
- package/drizzle/0076_session_workflow_wake_acl.sql +13 -0
- package/drizzle/0077_session_attempt_latest_lookup.sql +4 -0
- package/drizzle/0094_quarantine_credential_bearing_catalog_urls.sql +51 -0
- package/drizzle/0095_github_existing_installations.sql +84 -0
- package/drizzle/0096_session_turn_initiators.sql +97 -0
- package/drizzle/0097_host_export_outbox.sql +1220 -0
- package/drizzle/0098_usage_events_workspace_session_idx.sql +4 -0
- package/drizzle/0099_session_human_input_attempt_owner_index.sql +6 -0
- package/drizzle/0100_session_human_input_requests.sql +89 -0
- package/drizzle/0101_session_mcp_connection_refs.sql +30 -0
- package/drizzle/0102_session_command_receipt_service_actor.sql +16 -0
- package/drizzle/0103_host_export_root_session.sql +166 -0
- package/drizzle/0104_host_export_root_session_backfill.sql +27 -0
- package/drizzle/0105_session_turn_instructions.sql +9 -0
- package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
- package/drizzle/0107_host_export_lineage_contract.sql +381 -0
- package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
- package/package.json +5 -4
- package/src/codex-token-resolver.ts +175 -14
- package/src/connection-token-resolver.ts +428 -119
- package/src/event-payload-sanitizer.ts +88 -20
- package/src/index.ts +7208 -1273
- package/src/memory-domain.ts +1 -1
- package/src/migrate.ts +86 -23
- package/src/persistence-errors.ts +252 -0
- package/src/provision-roles.ts +42 -0
- package/src/schema.ts +658 -34
- package/src/session-control.ts +520 -38
- package/src/session-queue-commands.ts +385 -61
- package/src/session-tool-call-settlement.ts +58 -7
- package/src/turn-initiator.ts +155 -0
- package/dist/chunk-7LDU7F5P.js +0 -80
- package/dist/chunk-7LDU7F5P.js.map +0 -1
- package/dist/chunk-B22X3IEZ.js.map +0 -1
- 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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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 =
|
|
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
|
|
468
|
-
|
|
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
|
}
|