@apifuse/provider-sdk 2.2.0-beta.13 → 2.2.0-beta.15

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 (44) hide show
  1. package/AUTHORING.md +70 -6
  2. package/CHANGELOG.md +12 -0
  3. package/dist/define.js +9 -0
  4. package/dist/errors.d.ts +13 -0
  5. package/dist/errors.js +25 -0
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/native-egress-policy.d.ts +27 -0
  9. package/dist/native-egress-policy.js +225 -0
  10. package/dist/provider.d.ts +3 -3
  11. package/dist/provider.js +2 -2
  12. package/dist/runtime/executor.js +17 -2
  13. package/dist/runtime/http.js +189 -9
  14. package/dist/runtime/native-network.d.ts +39 -4
  15. package/dist/runtime/native-network.js +365 -20
  16. package/dist/runtime/redirects.d.ts +29 -0
  17. package/dist/runtime/redirects.js +36 -0
  18. package/dist/runtime/stealth.js +16 -44
  19. package/dist/server/index.d.ts +1 -1
  20. package/dist/server/index.js +1 -1
  21. package/dist/server/serve.d.ts +9 -0
  22. package/dist/server/serve.js +190 -51
  23. package/dist/server/types.d.ts +3 -0
  24. package/dist/server/types.js +1 -0
  25. package/dist/stateful/stateful-provider-owner-forwarder.js +9 -1
  26. package/dist/testing/run.js +32 -13
  27. package/dist/types.d.ts +23 -2
  28. package/package.json +1 -1
  29. package/src/define.ts +11 -0
  30. package/src/errors.ts +37 -0
  31. package/src/index.ts +12 -1
  32. package/src/native-egress-policy.ts +285 -0
  33. package/src/provider.ts +7 -0
  34. package/src/runtime/executor.ts +22 -2
  35. package/src/runtime/http.ts +217 -9
  36. package/src/runtime/native-network.ts +474 -22
  37. package/src/runtime/redirects.ts +66 -0
  38. package/src/runtime/stealth.ts +20 -47
  39. package/src/server/index.ts +2 -0
  40. package/src/server/serve.ts +226 -68
  41. package/src/server/types.ts +1 -0
  42. package/src/stateful/stateful-provider-owner-forwarder.ts +9 -1
  43. package/src/testing/run.ts +39 -14
  44. package/src/types.ts +32 -2
package/src/errors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ProviderErrorCategory } from "./observability.js";
2
+ import type { HttpRedirectFailureReason } from "./types.js";
2
3
 
3
4
  // Versioned, cross-realm brands. `Symbol.for` resolves to the same symbol in
4
5
  // any copy/entrypoint of this SDK major version, so an error created by a
@@ -10,6 +11,7 @@ const PROVIDER_ERROR_BRAND = Symbol.for("@apifuse/provider-sdk/error-brand@1");
10
11
  const PROVIDER_ERROR_BRAND_VALUE = 1;
11
12
  const SESSION_EXPIRED_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/session-expired@1");
12
13
  const TRANSPORT_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/transport@1");
14
+ const VALIDATION_BRAND = Symbol.for("@apifuse/provider-sdk/error-kind/validation@1");
13
15
 
14
16
  // Defines a non-enumerable, non-writable, non-configurable own data property.
15
17
  // Immutable + own means a guard can trust it via a single descriptor read
@@ -125,6 +127,7 @@ export class ValidationError extends ProviderError {
125
127
  super(message, options);
126
128
  this.name = "ValidationError";
127
129
  this.zodError = options?.zodError;
130
+ defineErrorBrand(this, VALIDATION_BRAND, true);
128
131
  }
129
132
  }
130
133
 
@@ -146,6 +149,33 @@ export class TransportError extends ProviderError {
146
149
  }
147
150
  }
148
151
 
152
+ export type HttpRedirectErrorOptions = TransportErrorOptions & {
153
+ reason: HttpRedirectFailureReason;
154
+ /** Redacted redirect target suitable for provider diagnostics. */
155
+ target?: string;
156
+ };
157
+
158
+ /** Raised when an opt-in ctx.http redirect policy refuses or cannot resolve a hop. */
159
+ export class HttpRedirectError extends TransportError {
160
+ readonly reason: HttpRedirectFailureReason;
161
+ readonly target?: string;
162
+
163
+ constructor(message: string, options: HttpRedirectErrorOptions) {
164
+ const { reason, target, ...transportOptions } = options;
165
+ super(message, {
166
+ ...transportOptions,
167
+ code: `http_redirect_${reason}`,
168
+ details: {
169
+ reason,
170
+ ...(target ? { target } : {}),
171
+ },
172
+ });
173
+ this.name = "HttpRedirectError";
174
+ this.reason = reason;
175
+ this.target = target;
176
+ }
177
+ }
178
+
149
179
  // Cross-module type guards. Prefer these over `instanceof` at any boundary that
150
180
  // may receive an error from a different copy/entrypoint of the SDK (see the HTTP
151
181
  // server error boundary). They recognize branded errors regardless of which
@@ -162,6 +192,13 @@ export function isTransportError(value: unknown): value is TransportError {
162
192
  return isProviderError(value) && hasOwnBrand(value, TRANSPORT_BRAND, true);
163
193
  }
164
194
 
195
+ export function isValidationError(value: unknown): value is ValidationError {
196
+ return (
197
+ isProviderError(value) &&
198
+ (hasOwnBrand(value, VALIDATION_BRAND, true) || value.name === "ValidationError")
199
+ );
200
+ }
201
+
165
202
  export class ProviderSecretError extends ProviderError {
166
203
  constructor(message: string, options?: ProviderErrorOptions) {
167
204
  super(message, { code: "provider_secret_error", ...options });
package/src/index.ts CHANGED
@@ -73,6 +73,8 @@ export { createHttpClient } from "./runtime/http.js";
73
73
  export {
74
74
  createNativeNetworkClient,
75
75
  deriveNativeCredentialAffinityKey,
76
+ NativeEgressGrantExpiredError,
77
+ NativeEgressNotDeclaredError,
76
78
  NativeIdleTimeoutError,
77
79
  NativeNetworkError,
78
80
  NativeProxyExpiredError,
@@ -135,7 +137,12 @@ export {
135
137
  sensitive,
136
138
  z,
137
139
  } from "./schema.js";
138
- export { createServerApp, type ServeOptions, serve } from "./server/index.js";
140
+ export {
141
+ createServerApp,
142
+ ERROR_OBSERVABILITY_HEADER,
143
+ type ServeOptions,
144
+ serve,
145
+ } from "./server/index.js";
139
146
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles.js";
140
147
  export * from "./stream.js";
141
148
  export type {
@@ -184,6 +191,9 @@ export type {
184
191
  HealthJourneyStep,
185
192
  HttpClient,
186
193
  HttpMethod,
194
+ HttpRedirectFailureReason,
195
+ HttpRedirectPolicy,
196
+ HttpRedirectPolicyMode,
187
197
  HttpResponse,
188
198
  HttpRetryOptions,
189
199
  HttpRetrySummary,
@@ -275,6 +285,7 @@ export type {
275
285
  ProviderSttMode,
276
286
  ProviderSupportLevel,
277
287
  RequestOptions,
288
+ RedirectRunReason,
278
289
  Rfc3339Instant,
279
290
  SchemaLike,
280
291
  SmsOrigin,
@@ -0,0 +1,285 @@
1
+ import type {
2
+ NativeProviderConfig,
3
+ NativeTcpDynamicEgressRule,
4
+ NativeTcpEgressRule,
5
+ NativeTcpPortRange,
6
+ NativeTcpTlsMode,
7
+ } from "./types.js";
8
+
9
+ type NativeNetworkDeclaration = NonNullable<NativeProviderConfig["network"]>;
10
+
11
+ const NATIVE_PROVIDER_FIELD_RECORD = {
12
+ network: true,
13
+ } satisfies { readonly [K in keyof Required<NativeProviderConfig>]: true };
14
+ const NATIVE_NETWORK_FIELD_RECORD = {
15
+ tcp: true,
16
+ dynamicTcp: true,
17
+ } satisfies { readonly [K in keyof Required<NativeNetworkDeclaration>]: true };
18
+ const NATIVE_TCP_RULE_FIELD_RECORD = {
19
+ host: true,
20
+ ports: true,
21
+ tls: true,
22
+ } satisfies { readonly [K in keyof Required<NativeTcpEgressRule>]: true };
23
+ const NATIVE_DYNAMIC_TCP_RULE_FIELD_RECORD = {
24
+ sourceHost: true,
25
+ sourceHostSuffixes: true,
26
+ sourcePorts: true,
27
+ sourcePortRanges: true,
28
+ targetHostSuffixes: true,
29
+ targetPorts: true,
30
+ targetPortRanges: true,
31
+ tls: true,
32
+ ttlMs: true,
33
+ maxGrants: true,
34
+ } satisfies { readonly [K in keyof Required<NativeTcpDynamicEgressRule>]: true };
35
+ const NATIVE_TCP_PORT_RANGE_FIELD_RECORD = {
36
+ start: true,
37
+ end: true,
38
+ } satisfies { readonly [K in keyof Required<NativeTcpPortRange>]: true };
39
+
40
+ const NATIVE_PROVIDER_FIELDS = Object.keys(NATIVE_PROVIDER_FIELD_RECORD);
41
+ const NATIVE_NETWORK_FIELDS = Object.keys(NATIVE_NETWORK_FIELD_RECORD);
42
+ const NATIVE_TCP_RULE_FIELDS = Object.keys(NATIVE_TCP_RULE_FIELD_RECORD);
43
+ const NATIVE_DYNAMIC_TCP_RULE_FIELDS = Object.keys(NATIVE_DYNAMIC_TCP_RULE_FIELD_RECORD);
44
+ const NATIVE_TCP_PORT_RANGE_FIELDS = Object.keys(NATIVE_TCP_PORT_RANGE_FIELD_RECORD);
45
+
46
+ export type StaticEgressRuleSnapshot = {
47
+ readonly host: string;
48
+ readonly ports: readonly number[];
49
+ readonly tls: NativeTcpTlsMode;
50
+ };
51
+
52
+ export type DynamicEgressRuleSnapshot = {
53
+ readonly sourceHost?: string;
54
+ readonly sourceHostSuffixes: readonly string[];
55
+ readonly sourcePorts: readonly number[];
56
+ readonly sourcePortRanges: readonly NativeTcpPortRange[];
57
+ readonly targetHostSuffixes: readonly string[];
58
+ readonly targetPorts: readonly number[];
59
+ readonly targetPortRanges: readonly NativeTcpPortRange[];
60
+ readonly tls: NativeTcpTlsMode;
61
+ readonly ttlMs?: number;
62
+ readonly maxGrants?: number;
63
+ };
64
+
65
+ export type NativeEgressPolicySnapshot = {
66
+ readonly staticRules: readonly StaticEgressRuleSnapshot[];
67
+ readonly dynamicRules: readonly DynamicEgressRuleSnapshot[];
68
+ };
69
+
70
+ export class NativeEgressPolicyValidationError extends Error {
71
+ constructor(message: string) {
72
+ super(message);
73
+ this.name = "NativeEgressPolicyValidationError";
74
+ }
75
+ }
76
+
77
+ function fail(message: string): never {
78
+ throw new NativeEgressPolicyValidationError(message);
79
+ }
80
+
81
+ function dataRecord(
82
+ value: unknown,
83
+ fieldPath: string,
84
+ allowed: readonly string[],
85
+ ): Record<string, unknown> {
86
+ if (!value || typeof value !== "object" || Array.isArray(value))
87
+ fail(`${fieldPath} must be an object`);
88
+ const prototype = Reflect.getPrototypeOf(value);
89
+ if (prototype !== Object.prototype && prototype !== null)
90
+ fail(`${fieldPath} must be a plain object`);
91
+ const record: Record<string, unknown> = {};
92
+ for (const key of Reflect.ownKeys(value)) {
93
+ if (typeof key !== "string") fail(`${fieldPath} must not contain symbol fields`);
94
+ if (!allowed.includes(key)) fail(`Unknown field ${fieldPath}.${key}`);
95
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
96
+ if (!descriptor || !("value" in descriptor)) fail(`${fieldPath}.${key} must be a data field`);
97
+ record[key] = descriptor.value;
98
+ }
99
+ return record;
100
+ }
101
+
102
+ function dataArray(value: unknown, fieldPath: string): readonly unknown[] {
103
+ if (!Array.isArray(value)) fail(`${fieldPath} must be an array`);
104
+ const result: unknown[] = [];
105
+ for (const key of Reflect.ownKeys(value)) {
106
+ if (key === "length") continue;
107
+ if (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/.test(key))
108
+ fail(`${fieldPath} must not contain non-index fields`);
109
+ const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
110
+ if (!descriptor || !("value" in descriptor)) fail(`${fieldPath}[${key}] must be a data field`);
111
+ result[Number(key)] = descriptor.value;
112
+ }
113
+ if (result.length !== value.length) fail(`${fieldPath} must not be sparse`);
114
+ for (let index = 0; index < result.length; index += 1) {
115
+ if (!(index in result)) fail(`${fieldPath} must not be sparse`);
116
+ }
117
+ return result;
118
+ }
119
+
120
+ function hasControlCharacter(value: string): boolean {
121
+ for (let index = 0; index < value.length; index += 1) {
122
+ const code = value.charCodeAt(index);
123
+ if (code <= 31 || code === 127) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ function host(value: unknown, fieldPath: string, suffix = false): string {
129
+ if (
130
+ typeof value !== "string" ||
131
+ !value.trim() ||
132
+ hasControlCharacter(value) ||
133
+ /\s/.test(value) ||
134
+ value.includes("://")
135
+ )
136
+ fail(`${fieldPath} must be a non-empty hostname`);
137
+ if (value.includes("*"))
138
+ fail(`${fieldPath} must be an exact ${suffix ? "DNS suffix" : "hostname"}, not a wildcard`);
139
+ const normalized = value.trim().toLowerCase().replace(/\.$/, "");
140
+ if (!normalized) fail(`${fieldPath} must be a non-empty hostname`);
141
+ return normalized;
142
+ }
143
+
144
+ function port(value: unknown, fieldPath: string): number {
145
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 65_535)
146
+ fail(`${fieldPath} must be an integer from 1 to 65535`);
147
+ return value;
148
+ }
149
+
150
+ function ports(value: unknown, fieldPath: string): readonly number[] {
151
+ return dataArray(value, fieldPath).map((value, index) => port(value, `${fieldPath}[${index}]`));
152
+ }
153
+
154
+ function hostSuffixes(value: unknown, fieldPath: string): readonly string[] {
155
+ return dataArray(value, fieldPath).map((value, index) =>
156
+ host(value, `${fieldPath}[${index}]`, true),
157
+ );
158
+ }
159
+
160
+ function ranges(value: unknown, fieldPath: string): readonly NativeTcpPortRange[] {
161
+ return dataArray(value, fieldPath).map((value, index) => {
162
+ const rangePath = `${fieldPath}[${index}]`;
163
+ const record = dataRecord(value, rangePath, NATIVE_TCP_PORT_RANGE_FIELDS);
164
+ const start = port(record.start, `${rangePath}.start`);
165
+ const end = port(record.end, `${rangePath}.end`);
166
+ if (start > end) fail(`${rangePath}.start must not exceed end`);
167
+ return { start, end };
168
+ });
169
+ }
170
+
171
+ function tls(value: unknown, fieldPath: string): NativeTcpTlsMode {
172
+ if (value !== "required" && value !== "allowed" && value !== "disabled")
173
+ fail(`${fieldPath} must be required, allowed, or disabled`);
174
+ return value;
175
+ }
176
+
177
+ function positiveInteger(value: unknown, fieldPath: string): number {
178
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0)
179
+ fail(`${fieldPath} must be a positive integer`);
180
+ return value;
181
+ }
182
+
183
+ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnapshot {
184
+ try {
185
+ const policy = dataRecord(value, "native.network", NATIVE_NETWORK_FIELDS);
186
+ const staticRules =
187
+ policy.tcp === undefined
188
+ ? []
189
+ : dataArray(policy.tcp, "native.network.tcp").map((value, index) => {
190
+ const fieldPath = `native.network.tcp[${index}]`;
191
+ const rule = dataRecord(value, fieldPath, NATIVE_TCP_RULE_FIELDS);
192
+ const declaredPorts = ports(rule.ports, `${fieldPath}.ports`);
193
+ if (declaredPorts.length === 0) fail(`${fieldPath}.ports must not be empty`);
194
+ return {
195
+ host: host(rule.host, `${fieldPath}.host`),
196
+ ports: declaredPorts,
197
+ tls: tls(rule.tls, `${fieldPath}.tls`),
198
+ };
199
+ });
200
+ const dynamicRules =
201
+ policy.dynamicTcp === undefined
202
+ ? []
203
+ : dataArray(policy.dynamicTcp, "native.network.dynamicTcp").map((value, index) => {
204
+ const fieldPath = `native.network.dynamicTcp[${index}]`;
205
+ const rule = dataRecord(value, fieldPath, NATIVE_DYNAMIC_TCP_RULE_FIELDS);
206
+ const sourceHost =
207
+ rule.sourceHost === undefined
208
+ ? undefined
209
+ : host(rule.sourceHost, `${fieldPath}.sourceHost`);
210
+ const sourceHostSuffixes =
211
+ rule.sourceHostSuffixes === undefined
212
+ ? []
213
+ : hostSuffixes(rule.sourceHostSuffixes, `${fieldPath}.sourceHostSuffixes`);
214
+ if (sourceHost === undefined && sourceHostSuffixes.length === 0)
215
+ fail(
216
+ `${fieldPath} must declare sourceHost or a non-empty sourceHostSuffixes list`,
217
+ );
218
+ const sourcePorts =
219
+ rule.sourcePorts === undefined
220
+ ? []
221
+ : ports(rule.sourcePorts, `${fieldPath}.sourcePorts`);
222
+ const sourcePortRanges =
223
+ rule.sourcePortRanges === undefined
224
+ ? []
225
+ : ranges(rule.sourcePortRanges, `${fieldPath}.sourcePortRanges`);
226
+ if (sourcePorts.length === 0 && sourcePortRanges.length === 0)
227
+ fail(
228
+ `${fieldPath} must declare a non-empty sourcePorts or sourcePortRanges list`,
229
+ );
230
+ const targetHostSuffixes = hostSuffixes(
231
+ rule.targetHostSuffixes,
232
+ `${fieldPath}.targetHostSuffixes`,
233
+ );
234
+ if (targetHostSuffixes.length === 0)
235
+ fail(`${fieldPath}.targetHostSuffixes must not be empty`);
236
+ const targetPorts =
237
+ rule.targetPorts === undefined
238
+ ? []
239
+ : ports(rule.targetPorts, `${fieldPath}.targetPorts`);
240
+ const targetPortRanges =
241
+ rule.targetPortRanges === undefined
242
+ ? []
243
+ : ranges(rule.targetPortRanges, `${fieldPath}.targetPortRanges`);
244
+ if (targetPorts.length === 0 && targetPortRanges.length === 0)
245
+ fail(
246
+ `${fieldPath} must declare a non-empty targetPorts or targetPortRanges list`,
247
+ );
248
+ return {
249
+ ...(sourceHost === undefined ? {} : { sourceHost }),
250
+ sourceHostSuffixes,
251
+ sourcePorts,
252
+ sourcePortRanges,
253
+ targetHostSuffixes,
254
+ targetPorts,
255
+ targetPortRanges,
256
+ tls: tls(rule.tls, `${fieldPath}.tls`),
257
+ ...(rule.ttlMs === undefined
258
+ ? {}
259
+ : { ttlMs: positiveInteger(rule.ttlMs, `${fieldPath}.ttlMs`) }),
260
+ ...(rule.maxGrants === undefined
261
+ ? {}
262
+ : { maxGrants: positiveInteger(rule.maxGrants, `${fieldPath}.maxGrants`) }),
263
+ };
264
+ });
265
+ return { staticRules, dynamicRules };
266
+ } catch (error) {
267
+ if (error instanceof NativeEgressPolicyValidationError) throw error;
268
+ throw new NativeEgressPolicyValidationError(
269
+ "Native egress policy could not be inspected safely",
270
+ );
271
+ }
272
+ }
273
+
274
+ export function validateNativeProviderConfig(value: unknown): void {
275
+ if (value === undefined) return;
276
+ try {
277
+ const native = dataRecord(value, "native", NATIVE_PROVIDER_FIELDS);
278
+ if (native.network !== undefined) parseNativeEgressPolicy(native.network);
279
+ } catch (error) {
280
+ if (error instanceof NativeEgressPolicyValidationError) throw error;
281
+ throw new NativeEgressPolicyValidationError(
282
+ "Native provider config could not be inspected safely",
283
+ );
284
+ }
285
+ }
package/src/provider.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  } from "./define.js";
38
38
  export {
39
39
  AuthError,
40
+ HttpRedirectError,
40
41
  isProviderError,
41
42
  isSessionExpiredError,
42
43
  isTransportError,
@@ -91,6 +92,9 @@ export type {
91
92
  HealthJourneyRunContext,
92
93
  HealthJourneyRunResult,
93
94
  HealthScheduleRandomization,
95
+ HttpRedirectFailureReason,
96
+ HttpRedirectPolicy,
97
+ HttpRedirectPolicyMode,
94
98
  HttpRetryOptions,
95
99
  HttpRetrySummary,
96
100
  InferSchemaOutput,
@@ -150,6 +154,7 @@ export type {
150
154
  ProviderStateDurationString,
151
155
  ProviderStateNamespace,
152
156
  ProviderSupportLevel,
157
+ RedirectRunReason,
153
158
  SchemaLike,
154
159
  SmsOtpMatcherDefinition,
155
160
  StandardSchemaV1,
@@ -161,6 +166,8 @@ export type {
161
166
  export {
162
167
  createNativeNetworkClient,
163
168
  deriveNativeCredentialAffinityKey,
169
+ NativeEgressGrantExpiredError,
170
+ NativeEgressNotDeclaredError,
164
171
  NativeIdleTimeoutError,
165
172
  NativeNetworkError,
166
173
  NativeProxyExpiredError,
@@ -1,4 +1,11 @@
1
- import { isSessionExpiredError, ProviderError, SessionExpiredError } from "../errors.js";
1
+ import {
2
+ isSessionExpiredError,
3
+ isValidationError,
4
+ ProviderError,
5
+ SessionExpiredError,
6
+ ValidationError,
7
+ } from "../errors.js";
8
+ import { z } from "zod";
2
9
  import { parseSchema } from "../schema.js";
3
10
  import type { ProviderContext, ProviderDefinition } from "../types.js";
4
11
  import { assertRequiredSecretsPresent } from "./secrets.js";
@@ -81,5 +88,18 @@ export async function executeOperation(
81
88
  return result;
82
89
  }
83
90
 
84
- return parseSchema(operation.output, result, `operations.${operationId}.output`);
91
+ try {
92
+ return await parseSchema(operation.output, result, `operations.${operationId}.output`);
93
+ } catch (cause) {
94
+ if (!(cause instanceof z.ZodError) && !isValidationError(cause)) {
95
+ throw cause;
96
+ }
97
+ throw new ValidationError(`Operation handler output failed schema validation.`, {
98
+ code: "OUTPUT_VALIDATION_FAILED",
99
+ category: "output_validation",
100
+ retryable: false,
101
+ zodError: isValidationError(cause) ? cause.zodError : cause,
102
+ ...(cause instanceof Error ? { cause } : {}),
103
+ });
104
+ }
85
105
  }