@evomap/evolver-adapter-public 2.0.0-beta.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.
@@ -0,0 +1,116 @@
1
+ import type { hub } from '@evomap/evolver-core';
2
+ type HeadersLike = {
3
+ get(name: string): string | null | undefined;
4
+ } | Record<string, string | undefined>;
5
+ export interface HubFetchResponse {
6
+ status: number;
7
+ headers?: HeadersLike;
8
+ body: unknown | null;
9
+ json: () => Promise<unknown>;
10
+ text: () => Promise<string>;
11
+ }
12
+ export type FetchLike = (url: string, init: {
13
+ method: string;
14
+ headers: Record<string, string>;
15
+ body?: string;
16
+ }) => Promise<{
17
+ status: number;
18
+ headers?: HeadersLike;
19
+ body: unknown | null;
20
+ json: () => Promise<unknown>;
21
+ text: () => Promise<string>;
22
+ }>;
23
+ export declare const HUB_ERROR_TEXT_MAX_BYTES: number;
24
+ export declare const HUB_JSON_TEXT_MAX_BYTES: number;
25
+ export declare const HUB_UNREACHABLE_BACKOFF_BASE_MS = 60000;
26
+ export declare const HUB_UNREACHABLE_BACKOFF_MAX_MS: number;
27
+ export declare class AuthError extends Error {
28
+ readonly status: number;
29
+ readonly body?: unknown | undefined;
30
+ readonly errorCode: string | undefined;
31
+ constructor(status: number, body?: unknown | undefined);
32
+ }
33
+ export declare class HubClientError extends Error {
34
+ readonly status: number;
35
+ readonly body: unknown;
36
+ constructor(status: number, body: unknown);
37
+ }
38
+ export declare class HubUnreachableError extends Error {
39
+ readonly details: {
40
+ status?: number;
41
+ contentType?: string;
42
+ bodySnippet?: string;
43
+ context?: string;
44
+ retryAfterMs?: number;
45
+ };
46
+ readonly code = "HUB_UNREACHABLE";
47
+ constructor(message: string, details?: {
48
+ status?: number;
49
+ contentType?: string;
50
+ bodySnippet?: string;
51
+ context?: string;
52
+ retryAfterMs?: number;
53
+ });
54
+ get retryAfterMs(): number;
55
+ }
56
+ export interface HubFetchDeps {
57
+ baseUrl: string;
58
+ auth: hub.AuthProvider;
59
+ fetchFn: FetchLike;
60
+ senderId: () => string | undefined;
61
+ }
62
+ /**
63
+ * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: POST 通常注入 body; GET 与 strict hello envelope
64
+ * 走 **Authorization: Bearer <node_secret>** 头(hub 只从 header/body 读 node_secret, 绝不从 query — #8);
65
+ * sender_id 是标识非凭证, 留 query/body.
66
+ * 401/403→AuthError(reauth), 4xx→HubClientError(终态), 5xx→重试.
67
+ * 非 JSON Hub 响应(WAF/HTML/captive portal/gateway text)→HubUnreachableError, 不触发 auth recovery.
68
+ */
69
+ export declare class HubFetch {
70
+ private readonly deps;
71
+ constructor(deps: HubFetchDeps);
72
+ call<T>(method: string, path: string, bodyObj?: Record<string, unknown>, query?: Record<string, string | number | undefined>): Promise<T>;
73
+ }
74
+ export declare function hubResponseContentType(res: Pick<HubFetchResponse, 'headers'> | undefined): string;
75
+ export declare function isHubApiResponse(res: Pick<HubFetchResponse, 'headers'> | undefined): boolean;
76
+ export declare function hubUnreachableBackoffMs(failureCount: number): number;
77
+ export declare function isHubUnreachableError(err: unknown): boolean;
78
+ export declare function isHubUnreachableResponse(res: Pick<HubFetchResponse, 'headers'> | undefined): boolean;
79
+ export declare function drainHubResponse(res: Pick<HubFetchResponse, 'body'> | undefined): Promise<void>;
80
+ export declare function readHubResponseText(res: Pick<HubFetchResponse, 'body' | 'text'>, opts?: {
81
+ maxBytes?: number;
82
+ }): Promise<string>;
83
+ export declare function readHubResponseJson(res: Pick<HubFetchResponse, 'body' | 'text'>, opts?: {
84
+ maxBytes?: number;
85
+ }): Promise<unknown>;
86
+ export declare function throwIfHubUnreachableResponse(res: HubFetchResponse, context?: string): Promise<void>;
87
+ /** https-only scheme guard: throws on invalid URL or non-https (unless the escape hatch is set). Called at both request and transport layers (defense in depth). */
88
+ export declare function assertHubUrlSecure(url: string, env?: Record<string, string | undefined>): void;
89
+ export type HubIpFamilyPolicy = 'ipv4first' | 'ipv4only' | 'auto';
90
+ export declare const HUB_CONNECT_TIMEOUT_MS = 10000;
91
+ export declare const HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS = 2500;
92
+ interface HubConnectOptions {
93
+ rejectUnauthorized: true;
94
+ timeout: number;
95
+ family?: 4;
96
+ autoSelectFamily?: boolean;
97
+ autoSelectFamilyAttemptTimeout?: number;
98
+ }
99
+ export interface HubFetchTransportConfig {
100
+ hubIpFamily: HubIpFamilyPolicy;
101
+ connectTimeoutMs: number;
102
+ ipv4FirstPrimaryConnectTimeoutMs: number;
103
+ connectOpts: HubConnectOptions;
104
+ primaryConnectOpts: HubConnectOptions;
105
+ fallbackConnectOpts: HubConnectOptions | null;
106
+ }
107
+ export declare function resolveHubIpFamily(env?: Record<string, string | undefined>): HubIpFamilyPolicy;
108
+ export declare function _getHubFetchConfigForTest(env?: Record<string, string | undefined>): HubFetchTransportConfig;
109
+ export declare function _shouldFallbackFromIpv4ForTest(err: unknown, hubIpFamily?: HubIpFamilyPolicy): boolean;
110
+ type RawFetch = (url: string, init: Record<string, unknown>) => Promise<HubFetchResponse>;
111
+ export declare function _setFetchImplForTest(fn?: RawFetch): void;
112
+ /** Test seam: reset the one-time insecure-warning latch. */
113
+ export declare function _resetInsecureWarningForTest(): void;
114
+ /** Default production transport: secure mode = https guard + forced TLS dispatcher; escape-hatch mode = skip both (local dev). */
115
+ export declare const globalFetchLike: FetchLike;
116
+ export {};
@@ -0,0 +1,469 @@
1
+ import { Agent, buildConnector, fetch as undiciFetch } from 'undici';
2
+ import { Buffer } from 'node:buffer';
3
+ import { TextDecoder } from 'node:util';
4
+ export const HUB_ERROR_TEXT_MAX_BYTES = 8 * 1024;
5
+ export const HUB_JSON_TEXT_MAX_BYTES = 4 * 1024 * 1024;
6
+ export const HUB_UNREACHABLE_BACKOFF_BASE_MS = 60_000;
7
+ export const HUB_UNREACHABLE_BACKOFF_MAX_MS = 10 * 60_000;
8
+ export class AuthError extends Error {
9
+ status;
10
+ body;
11
+ errorCode;
12
+ constructor(status, body) {
13
+ const errorCode = hubErrorCode(body);
14
+ super(`hub auth error ${status}${errorCode ? `: ${errorCode}` : ''}`);
15
+ this.status = status;
16
+ this.body = body;
17
+ this.name = 'AuthError';
18
+ this.errorCode = errorCode;
19
+ }
20
+ }
21
+ export class HubClientError extends Error {
22
+ status;
23
+ body;
24
+ constructor(status, body) {
25
+ super(`hub ${status}`);
26
+ this.status = status;
27
+ this.body = body;
28
+ this.name = 'HubClientError';
29
+ }
30
+ }
31
+ export class HubUnreachableError extends Error {
32
+ details;
33
+ code = 'HUB_UNREACHABLE';
34
+ constructor(message, details = {}) {
35
+ super(message);
36
+ this.details = details;
37
+ this.name = 'HubUnreachableError';
38
+ }
39
+ get retryAfterMs() {
40
+ return this.details.retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS;
41
+ }
42
+ }
43
+ /**
44
+ * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: POST 通常注入 body; GET 与 strict hello envelope
45
+ * 走 **Authorization: Bearer <node_secret>** 头(hub 只从 header/body 读 node_secret, 绝不从 query — #8);
46
+ * sender_id 是标识非凭证, 留 query/body.
47
+ * 401/403→AuthError(reauth), 4xx→HubClientError(终态), 5xx→重试.
48
+ * 非 JSON Hub 响应(WAF/HTML/captive portal/gateway text)→HubUnreachableError, 不触发 auth recovery.
49
+ */
50
+ export class HubFetch {
51
+ deps;
52
+ constructor(deps) {
53
+ this.deps = deps;
54
+ }
55
+ async call(method, path, bodyObj, query) {
56
+ const draft = bodyObj !== undefined ? JSON.stringify(bodyObj) : '';
57
+ const signed = await this.deps.auth.authenticate({ method, path, ...(draft ? { body: draft } : {}) });
58
+ const sender = this.deps.senderId();
59
+ const creds = signed.bodyFields ?? {};
60
+ let url = `${this.deps.baseUrl}${path}`;
61
+ assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
62
+ let body;
63
+ const headers = { 'content-type': 'application/json', ...signed.headers };
64
+ if (method === 'GET') {
65
+ const qs = new URLSearchParams();
66
+ if (sender)
67
+ qs.set('sender_id', sender); // identifier, not a credential — query is fine
68
+ if (query)
69
+ for (const [k, v] of Object.entries(query))
70
+ if (v !== undefined)
71
+ qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
72
+ // #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
73
+ // node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
74
+ const nodeSecret = creds['node_secret'];
75
+ if (nodeSecret !== undefined && headers['authorization'] === undefined)
76
+ headers['authorization'] = `Bearer ${String(nodeSecret)}`;
77
+ const q = qs.toString();
78
+ if (q)
79
+ url += `?${q}`;
80
+ }
81
+ else {
82
+ const postCreds = { ...creds };
83
+ const nodeSecret = postCreds['node_secret'];
84
+ if ((path === '/a2a/hello' || path === '/a2a/mailbox/outbound') && nodeSecret !== undefined) {
85
+ if (headers['authorization'] === undefined)
86
+ headers['authorization'] = `Bearer ${String(nodeSecret)}`;
87
+ delete postCreds['node_secret'];
88
+ }
89
+ if (path === '/a2a/mailbox/outbound' && sender) {
90
+ const qs = new URLSearchParams({ sender_id: sender });
91
+ url += `?${qs.toString()}`;
92
+ }
93
+ body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...(bodyObj ?? {}) });
94
+ }
95
+ let res;
96
+ try {
97
+ res = await this.deps.fetchFn(url, { method, headers, ...(body ? { body } : {}) });
98
+ }
99
+ catch (err) {
100
+ if (isHubUnreachableError(err)) {
101
+ throw new HubUnreachableError(`${method} ${path} failed before a Hub API response arrived`, { context: `${method} ${path}`, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
102
+ }
103
+ throw err;
104
+ }
105
+ const parsed = await readHubResponseJsonForClassification(res);
106
+ throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`);
107
+ if (res.status === 401 || res.status === 403)
108
+ throw new AuthError(res.status, parsed.body);
109
+ if (res.status >= 400 && res.status < 500)
110
+ throw new HubClientError(res.status, parsed.ok ? parsed.body : {});
111
+ if (res.status >= 500)
112
+ throw new Error(`hub ${res.status}`);
113
+ return parsed.body;
114
+ }
115
+ }
116
+ function hubErrorCode(body) {
117
+ const record = body && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
118
+ if (!record)
119
+ return undefined;
120
+ const payload = record['payload'] && typeof record['payload'] === 'object' && !Array.isArray(record['payload'])
121
+ ? record['payload']
122
+ : undefined;
123
+ for (const source of [record, payload]) {
124
+ if (!source)
125
+ continue;
126
+ for (const key of ['error', 'code', 'error_code', 'errorCode', 'reason']) {
127
+ const value = source[key];
128
+ if (typeof value === 'string' && value.trim())
129
+ return value.trim();
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+ function headerValue(headers, name) {
135
+ if (!headers)
136
+ return '';
137
+ try {
138
+ if (typeof headers.get === 'function') {
139
+ return String(headers.get(name) ?? '');
140
+ }
141
+ const lower = name.toLowerCase();
142
+ for (const [k, v] of Object.entries(headers)) {
143
+ if (k.toLowerCase() === lower)
144
+ return String(v ?? '');
145
+ }
146
+ return '';
147
+ }
148
+ catch {
149
+ return '';
150
+ }
151
+ }
152
+ export function hubResponseContentType(res) {
153
+ return headerValue(res?.headers, 'content-type').toLowerCase();
154
+ }
155
+ export function isHubApiResponse(res) {
156
+ const contentType = hubResponseContentType(res);
157
+ return contentType.length === 0 || contentType.includes('json');
158
+ }
159
+ export function hubUnreachableBackoffMs(failureCount) {
160
+ const n = Math.max(1, Number.isFinite(failureCount) ? failureCount : 1);
161
+ return Math.min(HUB_UNREACHABLE_BACKOFF_BASE_MS * 2 ** (n - 1), HUB_UNREACHABLE_BACKOFF_MAX_MS);
162
+ }
163
+ const NETWORK_DISRUPTION_CODES = new Set([
164
+ 'ECONNRESET',
165
+ 'ECONNREFUSED',
166
+ 'ENETDOWN',
167
+ 'ENETUNREACH',
168
+ 'EHOSTUNREACH',
169
+ 'EAI_AGAIN',
170
+ 'ENOTFOUND',
171
+ 'ENOTCONN',
172
+ 'ETIMEDOUT',
173
+ 'ABORT_ERR',
174
+ 'UND_ERR_SOCKET',
175
+ 'UND_ERR_CONNECT_TIMEOUT',
176
+ 'UND_ERR_HEADERS_TIMEOUT',
177
+ 'UND_ERR_BODY_TIMEOUT',
178
+ ]);
179
+ export function isHubUnreachableError(err) {
180
+ const e = err;
181
+ if (!e)
182
+ return false;
183
+ if (err instanceof HubUnreachableError || e.code === 'HUB_UNREACHABLE')
184
+ return true;
185
+ if (typeof e.code === 'string' && NETWORK_DISRUPTION_CODES.has(e.code))
186
+ return true;
187
+ if (e.name === 'AbortError' || e.name === 'TimeoutError')
188
+ return true;
189
+ const c = e.cause;
190
+ return c?.name === 'AbortError'
191
+ || c?.name === 'TimeoutError'
192
+ || (typeof c?.code === 'string' && NETWORK_DISRUPTION_CODES.has(c.code));
193
+ }
194
+ export function isHubUnreachableResponse(res) {
195
+ return hubResponseContentType(res).length > 0 && !isHubApiResponse(res);
196
+ }
197
+ function bodyReader(body) {
198
+ if (body && typeof body.getReader === 'function') {
199
+ return body.getReader();
200
+ }
201
+ return null;
202
+ }
203
+ function toBytes(value) {
204
+ if (value instanceof Uint8Array)
205
+ return value;
206
+ if (typeof value === 'string')
207
+ return Buffer.from(value, 'utf8');
208
+ return Buffer.from(String(value ?? ''), 'utf8');
209
+ }
210
+ export async function drainHubResponse(res) {
211
+ const body = res?.body;
212
+ try {
213
+ if (body && typeof body.cancel === 'function') {
214
+ await body.cancel();
215
+ }
216
+ }
217
+ catch {
218
+ // Best-effort pool hygiene only.
219
+ }
220
+ }
221
+ export async function readHubResponseText(res, opts = {}) {
222
+ const maxBytes = Math.max(0, opts.maxBytes ?? HUB_ERROR_TEXT_MAX_BYTES);
223
+ const reader = bodyReader(res.body);
224
+ if (reader) {
225
+ const decoder = new TextDecoder();
226
+ const chunks = [];
227
+ let total = 0;
228
+ let truncated = false;
229
+ try {
230
+ for (;;) {
231
+ const part = await reader.read();
232
+ if (part.done)
233
+ break;
234
+ const bytes = toBytes(part.value);
235
+ const remaining = maxBytes - total;
236
+ if (bytes.byteLength > remaining) {
237
+ if (remaining > 0) {
238
+ chunks.push(bytes.subarray(0, remaining));
239
+ total += remaining;
240
+ }
241
+ truncated = true;
242
+ await reader.cancel?.();
243
+ break;
244
+ }
245
+ chunks.push(bytes);
246
+ total += bytes.byteLength;
247
+ }
248
+ }
249
+ catch (err) {
250
+ await reader.cancel?.();
251
+ throw err;
252
+ }
253
+ finally {
254
+ reader.releaseLock?.();
255
+ }
256
+ const text = decoder.decode(Buffer.concat(chunks, total));
257
+ return truncated ? `${text}\n...[truncated]` : text;
258
+ }
259
+ if (res.body === null)
260
+ return '';
261
+ throw new Error('hub response body stream missing');
262
+ }
263
+ export async function readHubResponseJson(res, opts = {}) {
264
+ const text = await readHubResponseText(res, { maxBytes: opts.maxBytes ?? HUB_JSON_TEXT_MAX_BYTES });
265
+ return JSON.parse(text);
266
+ }
267
+ export async function throwIfHubUnreachableResponse(res, context = 'hub') {
268
+ if (!isHubUnreachableResponse(res)) {
269
+ const parsed = await readHubResponseJsonForClassification(res);
270
+ throwIfParsedHubUnreachableResponse(res, parsed, context);
271
+ return;
272
+ }
273
+ const status = Number(res.status) || undefined;
274
+ const contentType = hubResponseContentType(res) || 'unknown content-type';
275
+ try {
276
+ await drainHubResponse(res);
277
+ }
278
+ catch {
279
+ // Best-effort pool hygiene only.
280
+ }
281
+ throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, { ...(status !== undefined ? { status } : {}), contentType, context, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
282
+ }
283
+ async function readHubResponseJsonForClassification(res) {
284
+ if (isHubUnreachableResponse(res)) {
285
+ await drainHubResponse(res);
286
+ return { ok: false, reason: 'non_api_content_type' };
287
+ }
288
+ try {
289
+ const text = await readHubResponseText(res, { maxBytes: HUB_JSON_TEXT_MAX_BYTES });
290
+ return { ok: true, body: JSON.parse(text) };
291
+ }
292
+ catch (err) {
293
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
294
+ }
295
+ }
296
+ function throwIfParsedHubUnreachableResponse(res, parsed, context) {
297
+ const status = Number(res.status) || undefined;
298
+ const contentType = hubResponseContentType(res);
299
+ if (!isHubApiResponse(res) || !parsed.ok) {
300
+ throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType || 'unknown content-type'})`, {
301
+ ...(status !== undefined ? { status } : {}),
302
+ contentType: contentType || 'unknown content-type',
303
+ context,
304
+ retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
305
+ });
306
+ }
307
+ }
308
+ /**
309
+ * Hub egress security chokepoint (ported from v1 src/gep/hubFetch.js):
310
+ * 1. https-only scheme guard — reject non-https to prevent plaintext egress leaking token/asset/sender_id;
311
+ * 2. TLS enforcement — an undici Agent dispatcher (connect.rejectUnauthorized:true) overrides a global
312
+ * NODE_TLS_REJECT_UNAUTHORIZED=0 (note: this forces cert verification against the system trust store,
313
+ * it is NOT CA/SPKI pinning).
314
+ * 3. Hub egress defaults to IPv4-first, then dual-stack fallback, so VPN/TUN setups do not leak Hub calls
315
+ * over local IPv6 and trip Cloudflare country/ASN rules. Set EVOMAP_HUB_IP_FAMILY=auto to restore
316
+ * dual-stack as primary, or ipv4-only to disable fallback.
317
+ * Escape hatch EVOMAP_HUB_ALLOW_INSECURE==='1' (exact string) disables BOTH, for local dev / mock hub
318
+ * (http / self-signed) only. See feedback_public_repo_no_internal_leak.
319
+ */
320
+ function insecureAllowed(env) {
321
+ return env['EVOMAP_HUB_ALLOW_INSECURE'] === '1';
322
+ }
323
+ /** https-only scheme guard: throws on invalid URL or non-https (unless the escape hatch is set). Called at both request and transport layers (defense in depth). */
324
+ export function assertHubUrlSecure(url, env = process.env) {
325
+ if (insecureAllowed(env))
326
+ return;
327
+ let parsed;
328
+ try {
329
+ parsed = new URL(url);
330
+ }
331
+ catch {
332
+ throw new Error(`[hubFetch] Hub URL is not a valid URL: ${JSON.stringify(url)}. Set EVOMAP_HUB_ALLOW_INSECURE=1 to bypass (local dev / mock hub only).`);
333
+ }
334
+ if (parsed.protocol !== 'https:') {
335
+ throw new Error(`[hubFetch] Hub URL must use https:// — got ${JSON.stringify(url)}. Set EVOMAP_HUB_ALLOW_INSECURE=1 to bypass (local dev / mock hub only).`);
336
+ }
337
+ }
338
+ export const HUB_CONNECT_TIMEOUT_MS = 10_000;
339
+ export const HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS = 2_500;
340
+ export function resolveHubIpFamily(env = process.env) {
341
+ const raw = String(env['EVOMAP_HUB_IP_FAMILY'] ?? 'ipv4first').trim().toLowerCase();
342
+ if (raw === 'ipv4' || raw === 'v4' || raw === '4' || raw === 'ipv4first' || raw === 'ipv4-first')
343
+ return 'ipv4first';
344
+ if (raw === 'ipv4only' || raw === 'ipv4-only')
345
+ return 'ipv4only';
346
+ if (raw === 'auto' || raw === 'dualstack' || raw === 'dual-stack')
347
+ return 'auto';
348
+ throw new Error(`[hubFetch] EVOMAP_HUB_IP_FAMILY must be "ipv4", "ipv4-only", or "auto" — got ${JSON.stringify(env['EVOMAP_HUB_IP_FAMILY'])}`);
349
+ }
350
+ function makeHubFetchTransportConfig(env = process.env) {
351
+ const hubIpFamily = resolveHubIpFamily(env);
352
+ const baseConnectOpts = {
353
+ rejectUnauthorized: true,
354
+ timeout: HUB_CONNECT_TIMEOUT_MS,
355
+ };
356
+ const ipv4OnlyConnectOpts = {
357
+ ...baseConnectOpts,
358
+ family: 4,
359
+ autoSelectFamily: false,
360
+ };
361
+ const ipv4FirstPrimaryConnectOpts = {
362
+ ...ipv4OnlyConnectOpts,
363
+ timeout: HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS,
364
+ };
365
+ const autoConnectOpts = {
366
+ ...baseConnectOpts,
367
+ autoSelectFamily: true,
368
+ autoSelectFamilyAttemptTimeout: 250,
369
+ };
370
+ const primaryConnectOpts = hubIpFamily === 'auto'
371
+ ? autoConnectOpts
372
+ : hubIpFamily === 'ipv4only'
373
+ ? ipv4OnlyConnectOpts
374
+ : ipv4FirstPrimaryConnectOpts;
375
+ return {
376
+ hubIpFamily,
377
+ connectTimeoutMs: HUB_CONNECT_TIMEOUT_MS,
378
+ ipv4FirstPrimaryConnectTimeoutMs: HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS,
379
+ connectOpts: { ...primaryConnectOpts },
380
+ primaryConnectOpts: { ...primaryConnectOpts },
381
+ fallbackConnectOpts: hubIpFamily === 'ipv4first' ? { ...autoConnectOpts } : null,
382
+ };
383
+ }
384
+ const IPV4_FALLBACK_CODES = new Set([
385
+ 'EADDRNOTAVAIL',
386
+ 'EAI_AGAIN',
387
+ 'ECONNREFUSED',
388
+ 'ETIMEDOUT',
389
+ 'ENETUNREACH',
390
+ 'EHOSTUNREACH',
391
+ 'ENOTFOUND',
392
+ 'UND_ERR_CONNECT_TIMEOUT',
393
+ ]);
394
+ function errorCode(err) {
395
+ const e = err;
396
+ if (typeof e?.code === 'string')
397
+ return e.code;
398
+ const cause = e?.cause;
399
+ return typeof cause?.code === 'string' ? cause.code : undefined;
400
+ }
401
+ function shouldFallbackFromIpv4(err, hubIpFamily) {
402
+ return hubIpFamily === 'ipv4first' && IPV4_FALLBACK_CODES.has(errorCode(err) ?? '');
403
+ }
404
+ function makeHubConnector(config) {
405
+ const primaryConnect = buildConnector(config.primaryConnectOpts);
406
+ const fallbackConnect = config.fallbackConnectOpts ? buildConnector(config.fallbackConnectOpts) : null;
407
+ const connector = (opts, cb) => {
408
+ primaryConnect(opts, (err, socket) => {
409
+ if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
410
+ fallbackConnect(opts, cb);
411
+ return;
412
+ }
413
+ if (err) {
414
+ cb(err, null);
415
+ return;
416
+ }
417
+ if (socket) {
418
+ cb(null, socket);
419
+ return;
420
+ }
421
+ cb(new Error('[hubFetch] undici connector returned no socket'), null);
422
+ });
423
+ };
424
+ const marked = connector;
425
+ marked.rejectUnauthorized = true;
426
+ return marked;
427
+ }
428
+ const HUB_FETCH_CONFIG = makeHubFetchTransportConfig(process.env);
429
+ // Singleton TLS-enforcing dispatcher: overrides a global NODE_TLS_REJECT_UNAUTHORIZED=0. The Agent and
430
+ // fetch MUST come from the same undici package (mixing an npm-undici Agent with Node's built-in global.fetch
431
+ // throws UND_ERR_INVALID_ARG). The connector applies TLS verification plus the selected Hub IP-family policy.
432
+ const STRICT_TLS_AGENT = new Agent({ connect: makeHubConnector(HUB_FETCH_CONFIG) });
433
+ export function _getHubFetchConfigForTest(env) {
434
+ return env ? makeHubFetchTransportConfig(env) : {
435
+ ...HUB_FETCH_CONFIG,
436
+ connectOpts: { ...HUB_FETCH_CONFIG.connectOpts },
437
+ primaryConnectOpts: { ...HUB_FETCH_CONFIG.primaryConnectOpts },
438
+ fallbackConnectOpts: HUB_FETCH_CONFIG.fallbackConnectOpts ? { ...HUB_FETCH_CONFIG.fallbackConnectOpts } : null,
439
+ };
440
+ }
441
+ export function _shouldFallbackFromIpv4ForTest(err, hubIpFamily = HUB_FETCH_CONFIG.hubIpFamily) {
442
+ return shouldFallbackFromIpv4(err, hubIpFamily);
443
+ }
444
+ // Test seam: lets unit tests swap the underlying fetch without forking the call path; production must never reassign it from outside.
445
+ let _fetchImpl = undiciFetch;
446
+ export function _setFetchImplForTest(fn) { _fetchImpl = fn ?? undiciFetch; }
447
+ // One-time loud warning when the escape hatch is active, so a misconfigured staging/prod
448
+ // (e.g. a leaked EVOMAP_HUB_ALLOW_INSECURE in a CI var / copied .env) does not SILENTLY downgrade
449
+ // egress security. Turns a silent downgrade into an observable one.
450
+ let _insecureWarned = false;
451
+ function warnInsecureOnce() {
452
+ if (_insecureWarned)
453
+ return;
454
+ _insecureWarned = true;
455
+ process.stderr.write('[hubFetch] WARNING: EVOMAP_HUB_ALLOW_INSECURE=1 — https guard and TLS enforcement are DISABLED. Hub egress may be plaintext/unverified. Local dev / mock hub only, never production.\n');
456
+ }
457
+ /** Test seam: reset the one-time insecure-warning latch. */
458
+ export function _resetInsecureWarningForTest() { _insecureWarned = false; }
459
+ /** Default production transport: secure mode = https guard + forced TLS dispatcher; escape-hatch mode = skip both (local dev). */
460
+ export const globalFetchLike = async (url, init) => {
461
+ const raw = init;
462
+ if (insecureAllowed(process.env)) {
463
+ warnInsecureOnce();
464
+ return (await _fetchImpl(url, raw));
465
+ }
466
+ assertHubUrlSecure(url);
467
+ // last-wins dispatcher: forces TLS, a caller cannot override it (leak/mitm-safety).
468
+ return (await _fetchImpl(url, { ...raw, dispatcher: STRICT_TLS_AGENT }));
469
+ };