@apifuse/provider-sdk 2.2.0-beta.14 → 2.2.0-beta.16

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/provider.js CHANGED
@@ -2,9 +2,9 @@ export { AuthAbortError, credentialsAuthChallenge, createAuthFlowHelpers, define
2
2
  export { createFormCeremony } from "./ceremonies/index.js";
3
3
  export { assertFreshProviderChoiceIssuedAt, createProviderChoiceToken, ProviderChoiceTokenError, parseProviderChoiceToken, } from "./choice-token.js";
4
4
  export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, every, } from "./define.js";
5
- export { AuthError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors.js";
5
+ export { AuthError, HttpRedirectError, isProviderError, isSessionExpiredError, isTransportError, ProviderError, SessionExpiredError, TransportError, ValidationError, } from "./errors.js";
6
6
  export { getProviderLocalePath, providerLocaleKey, qualifyProviderLocaleKey, } from "./i18n/index.js";
7
7
  export { createProviderChoiceContext, createTestProviderChoiceContext, PROVIDER_RUNTIME_CHOICE_TOKEN_MASTER_SECRET_ENV, } from "./runtime/choice.js";
8
8
  export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SENSITIVE_KIND_META_KEY, APIFUSE_SENSITIVE_META_KEY, collectSensitivePaths, describeKey, field, fields, isSensitiveSchema, redactPayload, sensitive, z, } from "./schema.js";
9
- export { createNativeNetworkClient, deriveNativeCredentialAffinityKey, NativeIdleTimeoutError, NativeNetworkError, NativeProxyExpiredError, resolveNativeGatewayProxy, } from "./runtime/native-network.js";
9
+ export { createNativeNetworkClient, deriveNativeCredentialAffinityKey, NativeEgressGrantExpiredError, NativeEgressNotDeclaredError, NativeIdleTimeoutError, NativeNetworkError, NativeProxyExpiredError, resolveNativeGatewayProxy, } from "./runtime/native-network.js";
10
10
  export { HttpRetryAfterPolicy, HttpRetryDelayStrategy, HttpRetryJitter, HttpRetryPreset, HttpRetryUnsafeMethodPolicy, } from "./types.js";
@@ -1,7 +1,8 @@
1
1
  import { policyRotatesTransportVendorChain, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, } from "../config/loader.js";
2
- import { ProviderError, TransportError } from "../errors.js";
2
+ import { HttpRedirectError, ProviderError, TransportError } from "../errors.js";
3
3
  import { parseSseStream, readableBytes, readableLines, readableTextChunks } from "../stream.js";
4
4
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, isProxyTransportRetryMethod, normalizeProxyTransportRetryOptions, proxyTransportRetryErrorCode, proxyTransportRetryErrorStatus, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
5
+ import { evaluateRedirectHop, isRedirectStatus, resolveRedirectUrl } from "./redirects.js";
5
6
  import { normalizeHttpRequestBody, redactSensitiveError, redactSensitiveRequestError, serializeRequestUrl, } from "./request-options.js";
6
7
  const DEFAULT_HTTP_BASE_URL = "http://localhost";
7
8
  function isHttpStatusOutcome(outcome) {
@@ -196,6 +197,181 @@ function isAbsoluteUrl(url) {
196
197
  function resolveHttpUrl(baseUrl, url) {
197
198
  return new URL(url, baseUrl ?? DEFAULT_HTTP_BASE_URL).toString();
198
199
  }
200
+ const MAX_HTTP_REDIRECT_HOPS = 20;
201
+ const HTTP_REDIRECT_POLICY_FIELDS = new Set(["mode", "maxHops"]);
202
+ const REDIRECT_BODY_HEADERS = new Set([
203
+ "content-encoding",
204
+ "content-language",
205
+ "content-location",
206
+ "content-type",
207
+ ]);
208
+ const MALFORMED_REDIRECT_TARGET = "[malformed redirect target]";
209
+ function invalidHttpRedirectPolicy(message, cause) {
210
+ return new TransportError(`Invalid ctx.http redirectPolicy: ${message}`, {
211
+ code: "http_redirect_policy_invalid",
212
+ ...(cause ? { cause } : {}),
213
+ });
214
+ }
215
+ /** Snapshot untrusted caller input synchronously, before proxy resolution or fetch. */
216
+ function normalizeHttpRedirectPolicy(value) {
217
+ if (value === undefined)
218
+ return undefined;
219
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
220
+ throw invalidHttpRedirectPolicy("expected an object");
221
+ }
222
+ try {
223
+ const keys = Reflect.ownKeys(value);
224
+ for (const key of keys) {
225
+ if (typeof key !== "string" || !HTTP_REDIRECT_POLICY_FIELDS.has(key)) {
226
+ throw invalidHttpRedirectPolicy(`unknown field ${String(key)}`);
227
+ }
228
+ }
229
+ for (const field of HTTP_REDIRECT_POLICY_FIELDS) {
230
+ const descriptor = Object.getOwnPropertyDescriptor(value, field);
231
+ if (!descriptor || !("value" in descriptor)) {
232
+ throw invalidHttpRedirectPolicy(`${field} must be an own data property`);
233
+ }
234
+ }
235
+ const record = value;
236
+ if (record.mode !== "same-origin") {
237
+ throw invalidHttpRedirectPolicy('mode must be "same-origin"');
238
+ }
239
+ if (typeof record.maxHops !== "number" ||
240
+ !Number.isInteger(record.maxHops) ||
241
+ record.maxHops < 0 ||
242
+ record.maxHops > MAX_HTTP_REDIRECT_HOPS) {
243
+ throw invalidHttpRedirectPolicy(`maxHops must be an integer from 0 to ${MAX_HTTP_REDIRECT_HOPS}`);
244
+ }
245
+ return { mode: "same-origin", maxHops: record.maxHops };
246
+ }
247
+ catch (error) {
248
+ if (error instanceof TransportError)
249
+ throw error;
250
+ throw invalidHttpRedirectPolicy("could not be inspected safely", error instanceof Error ? error : undefined);
251
+ }
252
+ }
253
+ function snapshotHttpRedirectPolicy(options) {
254
+ try {
255
+ return normalizeHttpRedirectPolicy(options.redirectPolicy);
256
+ }
257
+ catch (error) {
258
+ if (error instanceof TransportError)
259
+ throw error;
260
+ throw invalidHttpRedirectPolicy("could not be read safely", error instanceof Error ? error : undefined);
261
+ }
262
+ }
263
+ function withoutRedirectBodyHeaders(headers) {
264
+ const nextHeaders = new Headers(headers);
265
+ for (const name of REDIRECT_BODY_HEADERS)
266
+ nextHeaders.delete(name);
267
+ return nextHeaders;
268
+ }
269
+ function redirectDiagnosticTarget(value) {
270
+ try {
271
+ const parsed = new URL(value);
272
+ // Origin omits URL userinfo. Keeping only origin + path makes diagnostics
273
+ // useful while structurally excluding every query value and fragment,
274
+ // including attacker-chosen keys the provider did not declare sensitive.
275
+ const redactedQuery = parsed.search ? "?[REDACTED]" : "";
276
+ return parsed.origin === "null"
277
+ ? `${parsed.protocol}<opaque-target>`
278
+ : `${parsed.origin}${parsed.pathname}${redactedQuery}`;
279
+ }
280
+ catch {
281
+ return MALFORMED_REDIRECT_TARGET;
282
+ }
283
+ }
284
+ function discardRedirectResponseBody(response) {
285
+ try {
286
+ const cancellation = response.body?.cancel();
287
+ if (cancellation)
288
+ void cancellation.catch(() => undefined);
289
+ }
290
+ catch {
291
+ // The redirect decision is security-significant and must not be replaced
292
+ // or delayed by an upstream body's cancellation failure. Cancellation was
293
+ // attempted; redirect evaluation continues without awaiting its completion.
294
+ }
295
+ }
296
+ async function fetchWithHttpRedirectPolicy(requestUrl, requestInit, policy) {
297
+ if (!policy)
298
+ return fetch(requestUrl, requestInit);
299
+ const initialUrl = new URL(requestUrl);
300
+ if (initialUrl.protocol !== "http:" && initialUrl.protocol !== "https:") {
301
+ throw new TransportError("ctx.http redirectPolicy requires an HTTP(S) origin", {
302
+ code: "transport_invalid_url",
303
+ });
304
+ }
305
+ const initialOrigin = initialUrl.origin;
306
+ let currentUrl = requestUrl;
307
+ let method = normalizeHttpMethod(requestInit.method ?? "GET");
308
+ let body = requestInit.body;
309
+ let headers = requestInit.headers;
310
+ let followedHops = 0;
311
+ const visitedRequests = new Set([`${method} ${currentUrl}`]);
312
+ while (true) {
313
+ const response = await fetch(currentUrl, {
314
+ ...requestInit,
315
+ body,
316
+ headers,
317
+ method,
318
+ redirect: "manual",
319
+ });
320
+ if (!isRedirectStatus(response.status))
321
+ return response;
322
+ const location = response.headers.get("location");
323
+ discardRedirectResponseBody(response);
324
+ let nextUrlString;
325
+ try {
326
+ nextUrlString = resolveRedirectUrl(location || undefined, currentUrl);
327
+ }
328
+ catch {
329
+ const target = MALFORMED_REDIRECT_TARGET;
330
+ throw new HttpRedirectError(`Redirect response has malformed Location target ${target}`, {
331
+ reason: "missing_location",
332
+ target,
333
+ status: response.status,
334
+ });
335
+ }
336
+ const decision = evaluateRedirectHop({
337
+ status: response.status,
338
+ method,
339
+ nextUrl: nextUrlString,
340
+ shouldStop: nextUrlString ? new URL(nextUrlString).origin !== initialOrigin : false,
341
+ redirectCount: followedHops + 1,
342
+ maxHops: policy.maxHops,
343
+ visitedRequests,
344
+ });
345
+ if (decision.kind === "stop") {
346
+ const target = decision.nextUrl ? redirectDiagnosticTarget(decision.nextUrl) : undefined;
347
+ const message = (() => {
348
+ switch (decision.reason) {
349
+ case "stopped":
350
+ return `Redirect policy refused cross-origin target ${target}`;
351
+ case "max_hops":
352
+ return `Redirect policy reached maxHops before target ${target}`;
353
+ case "loop":
354
+ return `Redirect loop refused target ${target}`;
355
+ case "missing_location":
356
+ return `Redirect response from ${redirectDiagnosticTarget(currentUrl)} is missing Location`;
357
+ }
358
+ })();
359
+ throw new HttpRedirectError(message, {
360
+ reason: decision.reason,
361
+ ...(target ? { target } : {}),
362
+ status: response.status,
363
+ });
364
+ }
365
+ if (decision.nextMethod !== method) {
366
+ body = undefined;
367
+ headers = withoutRedirectBodyHeaders(headers);
368
+ }
369
+ method = decision.nextMethod;
370
+ currentUrl = decision.nextUrl;
371
+ followedHops += 1;
372
+ visitedRequests.add(`${method} ${currentUrl}`);
373
+ }
374
+ }
199
375
  async function resolveNativeProxy(options, clientOptions, warn, proxyAttemptOffset = 0) {
200
376
  const resolvedProxy = await resolveProxyConfigAsync({
201
377
  proxy: options.proxy ?? clientOptions.proxy,
@@ -271,9 +447,7 @@ async function fetchNativeHttp(baseUrl, url, method, options, clientOptions, war
271
447
  if (options.body !== undefined) {
272
448
  requestInit.body = normalizeNativeFetchBody(options.body);
273
449
  }
274
- const response = await fetch(requestUrl, {
275
- ...requestInit,
276
- });
450
+ const response = await fetchWithHttpRedirectPolicy(requestUrl, requestInit, options.redirectPolicy);
277
451
  const headers = Object.fromEntries(response.headers.entries());
278
452
  if (statusRetryCodes && response.status >= 400) {
279
453
  await drainNativeResponseBody(response);
@@ -325,9 +499,7 @@ async function fetchNativeHttpStream(baseUrl, url, method, options, clientOption
325
499
  if (options.body !== undefined) {
326
500
  requestInit.body = normalizeNativeFetchBody(options.body);
327
501
  }
328
- const response = await fetch(requestUrl, {
329
- ...requestInit,
330
- });
502
+ const response = await fetchWithHttpRedirectPolicy(requestUrl, requestInit, options.redirectPolicy);
331
503
  if (response.status >= 400 && options.throwOnHttpError !== false) {
332
504
  await drainNativeResponseBody(response);
333
505
  throw new TransportError(`Upstream request failed with status ${response.status}`, {
@@ -365,7 +537,11 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
365
537
  throw new TransportError("ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared", { code: "transport_invalid_url" });
366
538
  }
367
539
  assertNoHttpTransportOverrides(options);
368
- const headersOptions = withClientHeaders(options, clientOptions, options.body);
540
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
541
+ const headersOptions = {
542
+ ...withClientHeaders(options, clientOptions, options.body),
543
+ redirectPolicy,
544
+ };
369
545
  const methodName = normalizeHttpMethod(method);
370
546
  const explicitRetry = headersOptions.retry !== undefined;
371
547
  const retryOptions = normalizeProxyTransportRetryOptions(headersOptions.retry, {
@@ -543,8 +719,12 @@ export function createHttpClient(baseUrl, clientOptions = {}) {
543
719
  throw new TransportError("ctx.http requires an absolute URL when provider.upstream.baseUrl is not declared", { code: "transport_invalid_url" });
544
720
  }
545
721
  assertNoHttpTransportOverrides(options);
722
+ const redirectPolicy = snapshotHttpRedirectPolicy(options);
546
723
  return {
547
- headersOptions: withClientHeaders(options, clientOptions, options.body),
724
+ headersOptions: {
725
+ ...withClientHeaders(options, clientOptions, options.body),
726
+ redirectPolicy,
727
+ },
548
728
  methodName: normalizeHttpMethod(method),
549
729
  };
550
730
  }
@@ -1,8 +1,8 @@
1
1
  import { Socket } from "node:net";
2
2
  import { type TLSSocket } from "node:tls";
3
3
  import { TransportError } from "../errors.js";
4
- import type { NativeNetworkClient, NativeNetworkConnection, NativeNetworkDynamicGrantOptions, NativeNetworkEgressGrant, NativeProxyEgressInfo, ProviderProxyPolicy, ProviderProxyProvider } from "../types.js";
5
- export type NativeNetworkErrorCode = "native_connection_aborted" | "native_connection_closed" | "native_connection_failed" | "native_connection_idle_timeout" | "native_connection_timeout" | "native_dynamic_egress_unsupported" | "native_proxy_expired" | "native_proxy_invalid";
4
+ import type { NativeNetworkClient, NativeNetworkConnection, NativeNetworkConnectInput, NativeNetworkDynamicGrantOptions, NativeNetworkEgressGrant, NativeProviderConfig, NativeProxyEgressInfo, ProviderProxyPolicy, ProviderProxyProvider } from "../types.js";
5
+ export type NativeNetworkErrorCode = "native_connection_aborted" | "native_connection_closed" | "native_connection_failed" | "native_connection_idle_timeout" | "native_connection_timeout" | "native_egress_authorization_failed" | "native_egress_grant_expired" | "native_egress_grant_invalid" | "native_egress_grant_limit_exceeded" | "native_egress_input_invalid" | "native_egress_not_declared" | "native_egress_policy_invalid" | "native_dynamic_egress_unsupported" | "native_proxy_expired" | "native_proxy_invalid";
6
6
  export declare class NativeNetworkError extends TransportError {
7
7
  constructor(message: string, code: NativeNetworkErrorCode);
8
8
  get code(): NativeNetworkErrorCode;
@@ -11,6 +11,24 @@ export declare class NativeProxyExpiredError extends NativeNetworkError {
11
11
  readonly expiresAt: string;
12
12
  constructor(expiresAt: string);
13
13
  }
14
+ /** Raised before transport setup when a native destination is not authorized. */
15
+ export declare class NativeEgressNotDeclaredError extends NativeNetworkError {
16
+ readonly host: string;
17
+ readonly port: number;
18
+ readonly tls: "required" | "disabled";
19
+ constructor(host: string, port: number, tls: "required" | "disabled");
20
+ }
21
+ /**
22
+ * Raised when the destination was authorized by a grant whose TTL elapsed and
23
+ * its expiry remains in the client's bounded recent-expiry evidence window.
24
+ */
25
+ export declare class NativeEgressGrantExpiredError extends NativeNetworkError {
26
+ readonly host: string;
27
+ readonly port: number;
28
+ readonly tls: "required" | "disabled";
29
+ readonly expiresAt: string;
30
+ constructor(host: string, port: number, tls: "required" | "disabled", expiresAt: string);
31
+ }
14
32
  /** Raised when an established connection exceeds its opt-in read-idle window. */
15
33
  export declare class NativeIdleTimeoutError extends NativeNetworkError {
16
34
  constructor();
@@ -41,7 +59,12 @@ export type NativeNetworkClientOptions = {
41
59
  readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
42
60
  /** Warning-level lifecycle diagnostic sink. */
43
61
  readonly warn?: (message: string) => void;
44
- /** Delegate to the deployment's native egress authorization layer. */
62
+ /**
63
+ * Provider-declared native egress. Undefined preserves legacy unrestricted
64
+ * behavior; any provided declaration, including an empty object, is enforced.
65
+ */
66
+ readonly egress?: NonNullable<NativeProviderConfig["network"]>;
67
+ /** Additional deployment authorization layered on top of SDK enforcement. */
45
68
  readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
46
69
  };
47
70
  /** Domain-separated, process-independent affinity derived from credential identity. */
@@ -49,5 +72,17 @@ export declare function deriveNativeCredentialAffinityKey(credentialIdentity: st
49
72
  /** Resolve the first configured native gateway without invoking an allocator API. */
50
73
  export declare function resolveNativeGatewayProxy(input: NativeGatewayProxyResolutionInput): NativeGatewayProxy | undefined;
51
74
  export declare function createNativeNetworkConnection(socket: Socket | TLSSocket, proxy: NativeGatewayProxy | undefined, options: NativeNetworkClientOptions, idleTimeoutMs?: number): NativeNetworkConnection;
52
- /** Create the SDK byte-stream runtime; deployment egress authorization stays delegated. */
75
+ type NativeConnectTls = "required" | "disabled";
76
+ export declare const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
77
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
78
+ export declare function snapshotNativeConnectInput(input: NativeNetworkConnectInput): NativeNetworkConnectInput;
79
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
80
+ export declare function snapshotNativeGrantInput(input: NativeNetworkDynamicGrantOptions): NativeNetworkDynamicGrantOptions;
81
+ /** Internal authorization seam shared by production and SDK transport test doubles. */
82
+ export declare function createNativeEgressAuthorization(options: NativeNetworkClientOptions): {
83
+ assertConnect(input: NativeNetworkConnectInput, tls: NativeConnectTls): void;
84
+ grant(input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant;
85
+ };
86
+ /** Create the SDK byte-stream runtime with provider-declared egress enforcement. */
53
87
  export declare function createNativeNetworkClient(options?: NativeNetworkClientOptions): NativeNetworkClient;
88
+ export {};