@apifuse/provider-sdk 2.1.0-beta.0 → 2.1.0-beta.10

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 (213) hide show
  1. package/AUTHORING.md +218 -21
  2. package/CHANGELOG.md +54 -0
  3. package/README.md +147 -10
  4. package/SUBMISSION.md +87 -0
  5. package/bin/apifuse-check.ts +86 -4
  6. package/bin/apifuse-dev.ts +87 -13
  7. package/bin/apifuse-pack-check.ts +120 -0
  8. package/bin/apifuse-pack-smoke.ts +423 -0
  9. package/bin/apifuse-perf.ts +142 -49
  10. package/bin/apifuse-record.ts +182 -104
  11. package/bin/apifuse-submit-check.ts +2538 -0
  12. package/bin/apifuse.ts +1 -1
  13. package/dist/ceremonies/index.d.ts +41 -0
  14. package/dist/ceremonies/index.js +490 -0
  15. package/dist/choice-token.d.ts +24 -0
  16. package/dist/choice-token.js +74 -0
  17. package/dist/cli/commands.d.ts +10 -0
  18. package/dist/cli/commands.js +80 -0
  19. package/dist/cli/create.d.ts +47 -0
  20. package/dist/cli/create.js +762 -0
  21. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  22. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  23. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  24. package/dist/cli/templates/provider/README.md.tpl +160 -0
  25. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  26. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  27. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  28. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  29. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  30. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  31. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  32. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  33. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  34. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  35. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  36. package/dist/config/loader.d.ts +107 -0
  37. package/dist/config/loader.js +935 -0
  38. package/dist/contract-json.d.ts +9 -0
  39. package/dist/contract-json.js +51 -0
  40. package/dist/contract-serialization.d.ts +4 -0
  41. package/dist/contract-serialization.js +78 -0
  42. package/dist/contract-types.d.ts +49 -0
  43. package/dist/contract-types.js +1 -0
  44. package/dist/contract.d.ts +6 -0
  45. package/dist/contract.js +155 -0
  46. package/dist/define.d.ts +97 -0
  47. package/dist/define.js +1320 -0
  48. package/dist/dev.d.ts +9 -0
  49. package/dist/dev.js +15 -0
  50. package/dist/errors.d.ts +59 -0
  51. package/dist/errors.js +97 -0
  52. package/dist/i18n/catalog.d.ts +29 -0
  53. package/dist/i18n/catalog.js +159 -0
  54. package/dist/i18n/index.d.ts +2 -0
  55. package/dist/i18n/index.js +2 -0
  56. package/dist/i18n/keys.d.ts +10 -0
  57. package/dist/i18n/keys.js +34 -0
  58. package/dist/index.d.ts +41 -0
  59. package/dist/index.js +37 -0
  60. package/dist/lint.d.ts +73 -0
  61. package/dist/lint.js +702 -0
  62. package/dist/observability.d.ts +5 -0
  63. package/dist/observability.js +39 -0
  64. package/dist/provider.d.ts +9 -0
  65. package/dist/provider.js +8 -0
  66. package/dist/public-schema-field-lint.d.ts +2 -0
  67. package/dist/public-schema-field-lint.js +158 -0
  68. package/dist/recipes/gov-api.d.ts +19 -0
  69. package/dist/recipes/gov-api.js +72 -0
  70. package/dist/recipes/rest-api.d.ts +21 -0
  71. package/dist/recipes/rest-api.js +115 -0
  72. package/dist/runtime/auth-flow.d.ts +14 -0
  73. package/dist/runtime/auth-flow.js +44 -0
  74. package/dist/runtime/browser.d.ts +25 -0
  75. package/dist/runtime/browser.js +1034 -0
  76. package/dist/runtime/cache.d.ts +10 -0
  77. package/dist/runtime/cache.js +372 -0
  78. package/dist/runtime/choice.d.ts +15 -0
  79. package/dist/runtime/choice.js +435 -0
  80. package/dist/runtime/credential.d.ts +8 -0
  81. package/dist/runtime/credential.js +61 -0
  82. package/dist/runtime/env.d.ts +2 -0
  83. package/dist/runtime/env.js +10 -0
  84. package/dist/runtime/executor.d.ts +16 -0
  85. package/dist/runtime/executor.js +51 -0
  86. package/dist/runtime/http.d.ts +8 -0
  87. package/dist/runtime/http.js +706 -0
  88. package/dist/runtime/insights.d.ts +9 -0
  89. package/dist/runtime/insights.js +324 -0
  90. package/dist/runtime/instrumentation.d.ts +8 -0
  91. package/dist/runtime/instrumentation.js +269 -0
  92. package/dist/runtime/key-derivation.d.ts +24 -0
  93. package/dist/runtime/key-derivation.js +73 -0
  94. package/dist/runtime/keyring.d.ts +25 -0
  95. package/dist/runtime/keyring.js +93 -0
  96. package/dist/runtime/namespace.d.ts +9 -0
  97. package/dist/runtime/namespace.js +19 -0
  98. package/dist/runtime/otlp.d.ts +39 -0
  99. package/dist/runtime/otlp.js +103 -0
  100. package/dist/runtime/perf.d.ts +12 -0
  101. package/dist/runtime/perf.js +52 -0
  102. package/dist/runtime/prevalidate.d.ts +12 -0
  103. package/dist/runtime/prevalidate.js +173 -0
  104. package/dist/runtime/provider.d.ts +2 -0
  105. package/dist/runtime/provider.js +11 -0
  106. package/dist/runtime/proxy-errors.d.ts +21 -0
  107. package/dist/runtime/proxy-errors.js +83 -0
  108. package/dist/runtime/proxy-telemetry.d.ts +8 -0
  109. package/dist/runtime/proxy-telemetry.js +174 -0
  110. package/dist/runtime/redis.d.ts +17 -0
  111. package/dist/runtime/redis.js +82 -0
  112. package/dist/runtime/request-options.d.ts +3 -0
  113. package/dist/runtime/request-options.js +42 -0
  114. package/dist/runtime/state.d.ts +17 -0
  115. package/dist/runtime/state.js +344 -0
  116. package/dist/runtime/stealth.d.ts +18 -0
  117. package/dist/runtime/stealth.js +834 -0
  118. package/dist/runtime/stt.d.ts +22 -0
  119. package/dist/runtime/stt.js +480 -0
  120. package/dist/runtime/trace.d.ts +26 -0
  121. package/dist/runtime/trace.js +142 -0
  122. package/dist/runtime/waterfall.d.ts +12 -0
  123. package/dist/runtime/waterfall.js +147 -0
  124. package/dist/schema.d.ts +74 -0
  125. package/dist/schema.js +243 -0
  126. package/dist/serve.d.ts +1 -0
  127. package/dist/serve.js +1 -0
  128. package/dist/server/index.d.ts +3 -0
  129. package/dist/server/index.js +2 -0
  130. package/dist/server/serve.d.ts +64 -0
  131. package/dist/server/serve.js +1110 -0
  132. package/dist/server/types.d.ts +136 -0
  133. package/dist/server/types.js +86 -0
  134. package/dist/stealth/profiles.d.ts +4 -0
  135. package/dist/stealth/profiles.js +259 -0
  136. package/dist/stream.d.ts +44 -0
  137. package/dist/stream.js +151 -0
  138. package/dist/testing/helpers.d.ts +23 -0
  139. package/dist/testing/helpers.js +95 -0
  140. package/dist/testing/index.d.ts +2 -0
  141. package/dist/testing/index.js +2 -0
  142. package/dist/testing/run.d.ts +34 -0
  143. package/dist/testing/run.js +303 -0
  144. package/dist/types.d.ts +1326 -0
  145. package/dist/types.js +61 -0
  146. package/dist/utils/date.d.ts +6 -0
  147. package/dist/utils/date.js +101 -0
  148. package/dist/utils/parse.d.ts +16 -0
  149. package/dist/utils/parse.js +51 -0
  150. package/dist/utils/text.d.ts +4 -0
  151. package/dist/utils/text.js +14 -0
  152. package/dist/utils/transform.d.ts +8 -0
  153. package/dist/utils/transform.js +48 -0
  154. package/package.json +57 -29
  155. package/src/ceremonies/index.ts +30 -3
  156. package/src/choice-token.ts +165 -0
  157. package/src/cli/commands.ts +34 -11
  158. package/src/cli/create.ts +214 -52
  159. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  160. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  161. package/src/cli/templates/provider/README.md.tpl +134 -2
  162. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  163. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  164. package/src/cli/templates/provider/index.ts.tpl +5 -44
  165. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  166. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  167. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  168. package/src/cli/templates/provider/operations/ping.ts.tpl +24 -0
  169. package/src/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  170. package/src/cli/templates/provider/start.ts.tpl +1 -1
  171. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  172. package/src/config/loader.ts +1282 -7
  173. package/src/contract-json.ts +75 -0
  174. package/src/contract-serialization.ts +89 -0
  175. package/src/contract-types.ts +52 -0
  176. package/src/contract.ts +215 -0
  177. package/src/define.ts +1726 -48
  178. package/src/errors.ts +27 -0
  179. package/src/i18n/catalog.ts +277 -0
  180. package/src/i18n/index.ts +2 -0
  181. package/src/i18n/keys.ts +64 -0
  182. package/src/index.ts +174 -15
  183. package/src/lint.ts +547 -73
  184. package/src/observability.ts +41 -0
  185. package/src/provider.ts +104 -5
  186. package/src/public-schema-field-lint.ts +237 -0
  187. package/src/runtime/auth-flow.ts +7 -0
  188. package/src/runtime/browser.ts +762 -51
  189. package/src/runtime/cache.ts +528 -0
  190. package/src/runtime/choice.ts +760 -0
  191. package/src/runtime/executor.ts +32 -3
  192. package/src/runtime/http.ts +945 -185
  193. package/src/runtime/insights.ts +11 -11
  194. package/src/runtime/instrumentation.ts +12 -4
  195. package/src/runtime/key-derivation.ts +1 -1
  196. package/src/runtime/keyring.ts +4 -3
  197. package/src/runtime/proxy-errors.ts +132 -0
  198. package/src/runtime/proxy-telemetry.ts +253 -0
  199. package/src/runtime/redis.ts +116 -0
  200. package/src/runtime/request-options.ts +66 -0
  201. package/src/runtime/state.ts +563 -0
  202. package/src/runtime/stealth.ts +1159 -0
  203. package/src/runtime/stt.ts +629 -0
  204. package/src/runtime/trace.ts +1 -1
  205. package/src/schema.ts +363 -1
  206. package/src/server/serve.ts +1172 -76
  207. package/src/server/types.ts +37 -0
  208. package/src/stream.ts +210 -0
  209. package/src/testing/run.ts +31 -5
  210. package/src/types.ts +1118 -44
  211. package/src/composite.ts +0 -43
  212. package/src/runtime/tls.ts +0 -425
  213. package/src/types/playwright-stealth.d.ts +0 -9
package/src/define.ts CHANGED
@@ -8,22 +8,66 @@ import type {
8
8
  HealthCheckCase,
9
9
  HealthCheckSuite,
10
10
  HealthCheckUnsupported,
11
+ HealthJourneyDefinition,
12
+ HealthJourneySchedule,
11
13
  InferSchemaOutput,
12
14
  OperationDefinition,
13
- ProbeInterval,
15
+ OperationHandlerResult,
16
+ OperationHttpStreamTransport,
17
+ OperationSseTransport,
18
+ OperationTransport,
19
+ OperationWebSocketTransport,
20
+ ProviderAccessConfig,
14
21
  ProviderDefinition,
15
22
  ProviderHealthMonitorConfig,
23
+ ProviderProxyConfig,
24
+ ProviderPublicProfile,
16
25
  ProviderReviewed,
17
26
  ProviderSecretDeclaration,
27
+ ProviderStreamEvent,
28
+ ProviderSttConfig,
18
29
  SchemaLike,
30
+ SmsOtpMatcherDefinition,
19
31
  StealthPlatform,
20
32
  } from "./types";
21
33
  import {
34
+ HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
35
+ HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
36
+ HEALTH_CHECK_TIMEOUT_MS_MAX,
37
+ HEALTH_CHECK_TIMEOUT_MS_MIN,
22
38
  OPERATION_TIMEOUT_MS_MAX,
23
39
  OPERATION_TIMEOUT_MS_MIN,
24
- PROBE_INTERVALS,
40
+ STREAM_CHUNK_BYTES_MAX,
41
+ STREAM_CHUNK_BYTES_MIN,
42
+ STREAM_HEARTBEAT_MS_MAX,
43
+ STREAM_HEARTBEAT_MS_MIN,
44
+ STREAM_IDLE_TIMEOUT_MS_MAX,
45
+ STREAM_IDLE_TIMEOUT_MS_MIN,
46
+ STREAM_MAX_DURATION_MS_MAX,
47
+ STREAM_MAX_DURATION_MS_MIN,
25
48
  } from "./types";
26
49
 
50
+ type ProviderImplementationSourceAccess =
51
+ | "official_api"
52
+ | "private_api"
53
+ | "browser_flow"
54
+ | "hybrid";
55
+
56
+ type ProviderImplementationCredentialStrategy =
57
+ | "apifuse_managed"
58
+ | "workspace_secret"
59
+ | "user_oauth"
60
+ | "user_session"
61
+ | "none";
62
+
63
+ interface ProviderImplementationProfile {
64
+ sourceAccess: ProviderImplementationSourceAccess;
65
+ credentialStrategy: ProviderImplementationCredentialStrategy;
66
+ officialDocsUrl?: string;
67
+ operatorNotes?: string;
68
+ visibility: "internal" | "operator";
69
+ }
70
+
27
71
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
28
72
  const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
29
73
  const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
@@ -33,7 +77,91 @@ const VALID_AUTH_MODES = [
33
77
  "credentials",
34
78
  "oauth2",
35
79
  ] as const;
80
+ const VALID_PROVIDER_ACCESS_VISIBILITIES = ["public", "early_access"] as const;
81
+ const VALID_PROVIDER_PROXY_MODES = [
82
+ "disabled",
83
+ "optional",
84
+ "required",
85
+ ] as const;
86
+ const VALID_PROVIDER_PROXY_PROVIDERS = [
87
+ "smartproxy",
88
+ "decodo",
89
+ "custom",
90
+ ] as const;
91
+ const VALID_PROVIDER_PROXY_AFFINITIES = [
92
+ "request",
93
+ "operation",
94
+ "auth-flow",
95
+ "connection",
96
+ ] as const;
97
+ const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
98
+ const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
36
99
  const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
100
+ const MCP_TOOL_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
101
+ const VALID_OPERATION_RISK_CLASSES = [
102
+ "read",
103
+ "write",
104
+ "destructive",
105
+ "external-send",
106
+ ] as const;
107
+ const VALID_OPERATION_APPROVAL_POLICIES = [
108
+ "never",
109
+ "risk-based",
110
+ "always",
111
+ ] as const;
112
+ const VALID_OPERATION_TRANSPORT_KINDS = [
113
+ "json",
114
+ "sse",
115
+ "http-stream",
116
+ "websocket",
117
+ ] as const;
118
+ const SSE_EVENT_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
119
+ const WEBSOCKET_SUBPROTOCOL_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
120
+
121
+ const MS_DURATION_UNITS = new Set([
122
+ "years",
123
+ "year",
124
+ "yrs",
125
+ "yr",
126
+ "y",
127
+ "weeks",
128
+ "week",
129
+ "w",
130
+ "days",
131
+ "day",
132
+ "d",
133
+ "hours",
134
+ "hour",
135
+ "hrs",
136
+ "hr",
137
+ "h",
138
+ "minutes",
139
+ "minute",
140
+ "mins",
141
+ "min",
142
+ "m",
143
+ "seconds",
144
+ "second",
145
+ "secs",
146
+ "sec",
147
+ "s",
148
+ "milliseconds",
149
+ "millisecond",
150
+ "msecs",
151
+ "msec",
152
+ "ms",
153
+ ]);
154
+ const MS_DURATION_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)?$/;
155
+
156
+ function isPositiveMsDurationString(value: unknown): value is string {
157
+ if (typeof value !== "string") return false;
158
+ const match = value.trim().match(MS_DURATION_PATTERN);
159
+ if (!match) return false;
160
+ const amount = Number(match[1]);
161
+ if (!Number.isFinite(amount) || amount <= 0) return false;
162
+ const unit = match[2]?.toLowerCase();
163
+ return unit === undefined || MS_DURATION_UNITS.has(unit);
164
+ }
37
165
 
38
166
  type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
39
167
  type OperationConfig<
@@ -43,7 +171,9 @@ type OperationConfig<
43
171
  handler(
44
172
  ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
45
173
  input: InferSchemaOutput<TInput>,
46
- ): Promise<InferSchemaOutput<TOutput>>;
174
+ ):
175
+ | OperationHandlerResult<InferSchemaOutput<TOutput>>
176
+ | Promise<OperationHandlerResult<InferSchemaOutput<TOutput>>>;
47
177
  };
48
178
  type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
49
179
  {
@@ -51,9 +181,66 @@ type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
51
181
  infer TInput,
52
182
  infer TOutput
53
183
  >
54
- ? OperationConfig<TInput, TOutput>
184
+ ? OperationConfig<TInput, TOutput> | OperationDefinition<TInput, TOutput>
55
185
  : never;
56
186
  };
187
+ type StreamOperationConfig<
188
+ TInput extends SchemaLike,
189
+ TOutput extends SchemaLike,
190
+ > =
191
+ | SseOperationConfig<TInput, TOutput>
192
+ | HttpStreamOperationConfig<TInput, TOutput>
193
+ | WebSocketOperationConfig<TInput, TOutput>;
194
+ type SseOperationConfig<
195
+ TInput extends SchemaLike,
196
+ TOutput extends SchemaLike,
197
+ > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
198
+ transport: OperationSseTransport;
199
+ handler(
200
+ ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
201
+ input: InferSchemaOutput<TInput>,
202
+ ):
203
+ | AsyncIterable<ProviderStreamEvent>
204
+ | Promise<AsyncIterable<ProviderStreamEvent>>;
205
+ };
206
+ type HttpStreamOperationConfig<
207
+ TInput extends SchemaLike,
208
+ TOutput extends SchemaLike,
209
+ > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
210
+ transport: OperationHttpStreamTransport;
211
+ handler(
212
+ ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
213
+ input: InferSchemaOutput<TInput>,
214
+ ):
215
+ | Response
216
+ | ReadableStream<Uint8Array>
217
+ | Promise<Response | ReadableStream<Uint8Array>>;
218
+ };
219
+ type WebSocketOperationConfig<
220
+ TInput extends SchemaLike,
221
+ TOutput extends SchemaLike,
222
+ > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
223
+ transport: OperationWebSocketTransport;
224
+ handler(
225
+ ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
226
+ input: InferSchemaOutput<TInput>,
227
+ ):
228
+ | Response
229
+ | ReadableStream<Uint8Array>
230
+ | Promise<Response | ReadableStream<Uint8Array>>;
231
+ };
232
+
233
+ type AuthStartNoInputGuard<TConfig> = TConfig extends {
234
+ auth?: { flow?: { start: infer TStart } };
235
+ }
236
+ ? TStart extends (...args: infer TArgs) => unknown
237
+ ? TArgs extends [unknown]
238
+ ? unknown
239
+ : {
240
+ "auth start handlers must not declare input parameters; return a form turn from start and receive user input in continue": never;
241
+ }
242
+ : unknown
243
+ : unknown;
57
244
 
58
245
  export interface ProviderConfig<
59
246
  TOperations extends Record<string, ProviderOperation>,
@@ -62,29 +249,43 @@ export interface ProviderConfig<
62
249
  version: string;
63
250
  runtime: "standard" | "shared" | "browser";
64
251
  allowedHosts?: string[];
65
- stealth?: { profile: string; platform: StealthPlatform };
66
- proxy?: boolean;
252
+ stealth?: {
253
+ profile: string;
254
+ platform: StealthPlatform;
255
+ };
256
+ proxy?: ProviderProxyConfig;
257
+ stt?: ProviderSttConfig;
67
258
  browser?: { engine: BrowserEngine };
68
259
  auth?: AuthConfig;
69
260
  reviewed?: ProviderReviewed;
261
+ access?: ProviderAccessConfig;
70
262
  secrets?: ProviderSecretDeclaration[];
71
263
  credential?: CredentialDeclaration;
72
264
  context?: ContextDeclaration;
73
265
  meta: {
74
266
  displayName: string;
75
- description?: string;
267
+ displayNameKey?: string;
268
+ descriptionKey: string;
76
269
  category: string;
77
- tags?: string[];
270
+ tags?: readonly string[];
78
271
  icon?: string;
79
- docTitle?: string;
80
- docDescription?: string;
81
- docSummary?: string;
82
- normalizationNotes?: string[];
272
+ docTitleKey?: string;
273
+ docDescriptionKey?: string;
274
+ docSummaryKey?: string;
275
+ docMarkdownKey?: string;
276
+ normalizationNotesKeys?: readonly string[];
83
277
  environment?: "staging";
84
278
  purpose?: string;
279
+ purposeKey?: string;
280
+ publicProfile?: ProviderPublicProfile;
281
+ implementationProfile?: ProviderImplementationProfile;
282
+ contract?: {
283
+ publicSchemaFieldNames?: "normalized";
284
+ };
85
285
  };
86
286
  operations: OperationMapConfig<TOperations>;
87
287
  healthMonitor?: ProviderHealthMonitorConfig;
288
+ healthJourneys?: readonly HealthJourneyDefinition[];
88
289
  }
89
290
 
90
291
  /** Define one provider operation with schema-driven handler inference. */
@@ -97,6 +298,16 @@ export function defineOperation<
97
298
  return operation;
98
299
  }
99
300
 
301
+ /** Define a non-JSON provider operation with explicit transport metadata. */
302
+ export function defineStreamOperation<
303
+ TInput extends SchemaLike,
304
+ TOutput extends SchemaLike,
305
+ >(
306
+ operation: StreamOperationConfig<TInput, TOutput>,
307
+ ): OperationDefinition<TInput, TOutput> {
308
+ return operation;
309
+ }
310
+
100
311
  function assertObjectConfig(
101
312
  value: unknown,
102
313
  ): asserts value is Record<string, unknown> {
@@ -163,7 +374,206 @@ function validateProviderShape(config: unknown): void {
163
374
  VALID_AUTH_MODES,
164
375
  String(config.id),
165
376
  );
377
+ if (
378
+ auth &&
379
+ typeof auth === "object" &&
380
+ "flow" in auth &&
381
+ auth.flow &&
382
+ typeof auth.flow === "object" &&
383
+ "start" in auth.flow &&
384
+ typeof auth.flow.start === "function" &&
385
+ auth.flow.start.length > 1
386
+ ) {
387
+ throw new ProviderError(
388
+ `Provider "${String(config.id)}" auth.flow.start must not declare an input parameter`,
389
+ {
390
+ fix: "Return a form turn from start(ctx), then receive user input in continue(ctx, input).",
391
+ },
392
+ );
393
+ }
394
+ const access = config.access;
395
+ if (access !== undefined) {
396
+ if (!access || typeof access !== "object" || Array.isArray(access)) {
397
+ throw new ValidationError(
398
+ `Provider "${String(config.id)}" has invalid access: must be an object.`,
399
+ {
400
+ fix: `Set access to { visibility?: "public" | "early_access" }.`,
401
+ },
402
+ );
403
+ }
404
+ const accessRecord: Record<string, unknown> = Object.fromEntries(
405
+ Object.entries(access),
406
+ );
407
+ for (const key of Object.keys(accessRecord)) {
408
+ if (key !== "visibility") {
409
+ throw new ValidationError(`Unknown field "${key}" on access.`, {
410
+ fix: `Remove access.${key} or rename it to visibility.`,
411
+ });
412
+ }
413
+ }
414
+ const visibility = accessRecord.visibility;
415
+ if (visibility !== undefined) {
416
+ if (typeof visibility !== "string") {
417
+ throw new ValidationError(
418
+ `Provider "${String(config.id)}" has invalid access.visibility: must be "public" or "early_access".`,
419
+ {
420
+ fix: `Set access.visibility to "public" or "early_access".`,
421
+ },
422
+ );
423
+ }
424
+ assertLiteralField(
425
+ visibility,
426
+ "access.visibility",
427
+ VALID_PROVIDER_ACCESS_VISIBILITIES,
428
+ String(config.id),
429
+ );
430
+ }
431
+ }
166
432
  }
433
+
434
+ function validateProviderProxy(config: {
435
+ id: string;
436
+ proxy?: ProviderProxyConfig;
437
+ secrets?: ProviderSecretDeclaration[];
438
+ }): void {
439
+ const proxy = config.proxy;
440
+ if (proxy === undefined || typeof proxy === "boolean") {
441
+ return;
442
+ }
443
+ if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) {
444
+ throw new ValidationError(
445
+ `Provider "${config.id}" has invalid proxy: must be a boolean or provider proxy policy object.`,
446
+ {
447
+ fix: `Use proxy: { mode: "required", provider: "smartproxy", geo: { country: "KR" }, session: { affinity: "connection", lifetimeMinutes: 30 } }`,
448
+ },
449
+ );
450
+ }
451
+ rejectUnknownFields(
452
+ proxy,
453
+ new Set(["mode", "provider", "geo", "session"]),
454
+ "proxy",
455
+ );
456
+ assertLiteralField(
457
+ proxy.mode,
458
+ "proxy.mode",
459
+ VALID_PROVIDER_PROXY_MODES,
460
+ config.id,
461
+ );
462
+ if (proxy.provider !== undefined) {
463
+ assertLiteralField(
464
+ proxy.provider,
465
+ "proxy.provider",
466
+ VALID_PROVIDER_PROXY_PROVIDERS,
467
+ config.id,
468
+ );
469
+ }
470
+ if (proxy.geo !== undefined) {
471
+ if (
472
+ !proxy.geo ||
473
+ typeof proxy.geo !== "object" ||
474
+ Array.isArray(proxy.geo)
475
+ ) {
476
+ throw new ValidationError(
477
+ `Provider "${config.id}" has invalid proxy.geo: must be an object.`,
478
+ {
479
+ fix: `Use proxy.geo: { country: "KR" } with ISO alpha-2 country codes.`,
480
+ },
481
+ );
482
+ }
483
+ rejectUnknownFields(
484
+ proxy.geo,
485
+ new Set(["country", "subdivision", "city"]),
486
+ "proxy.geo",
487
+ );
488
+ if (proxy.geo.country !== undefined) {
489
+ assertIsoCountry(proxy.geo.country, "proxy.geo.country");
490
+ }
491
+ for (const field of ["subdivision", "city"] as const) {
492
+ const value = proxy.geo[field];
493
+ if (value !== undefined && (typeof value !== "string" || !value.trim())) {
494
+ throw new ValidationError(
495
+ `Provider "${config.id}" has invalid proxy.geo.${field}: must be a non-empty string.`,
496
+ );
497
+ }
498
+ }
499
+ }
500
+ if (proxy.session !== undefined) {
501
+ if (
502
+ !proxy.session ||
503
+ typeof proxy.session !== "object" ||
504
+ Array.isArray(proxy.session)
505
+ ) {
506
+ throw new ValidationError(
507
+ `Provider "${config.id}" has invalid proxy.session: must be an object.`,
508
+ {
509
+ fix: `Use proxy.session: { affinity: "connection", lifetimeMinutes: 30 }.`,
510
+ },
511
+ );
512
+ }
513
+ rejectUnknownFields(
514
+ proxy.session,
515
+ new Set(["affinity", "lifetimeMinutes", "poolSize"]),
516
+ "proxy.session",
517
+ );
518
+ if (proxy.session.affinity !== undefined) {
519
+ assertLiteralField(
520
+ proxy.session.affinity,
521
+ "proxy.session.affinity",
522
+ VALID_PROVIDER_PROXY_AFFINITIES,
523
+ config.id,
524
+ );
525
+ }
526
+ const lifetime = proxy.session.lifetimeMinutes;
527
+ if (
528
+ lifetime !== undefined &&
529
+ (!Number.isFinite(lifetime) || lifetime <= 0)
530
+ ) {
531
+ throw new ValidationError(
532
+ `Provider "${config.id}" has invalid proxy.session.lifetimeMinutes: must be a positive number of minutes.`,
533
+ );
534
+ }
535
+ const poolSize = proxy.session.poolSize;
536
+ if (
537
+ poolSize !== undefined &&
538
+ (!Number.isInteger(poolSize) || poolSize <= 0)
539
+ ) {
540
+ throw new ValidationError(
541
+ `Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`,
542
+ );
543
+ }
544
+ }
545
+ if (proxy.mode === "required" && proxy.provider === "smartproxy") {
546
+ const hasSmartproxySecret = config.secrets?.some(
547
+ (secret) =>
548
+ secret.name === SMARTPROXY_APP_KEY_SECRET && secret.required !== false,
549
+ );
550
+ if (!hasSmartproxySecret) {
551
+ throw new ValidationError(
552
+ `Provider "${config.id}" requires Smartproxy egress but does not declare ${SMARTPROXY_APP_KEY_SECRET}.`,
553
+ {
554
+ fix: `Add secrets: [{ name: "${SMARTPROXY_APP_KEY_SECRET}", required: true }] to the provider.`,
555
+ },
556
+ );
557
+ }
558
+ }
559
+ }
560
+
561
+ function validateProviderStt(config: {
562
+ id: string;
563
+ stt?: ProviderSttConfig;
564
+ }): void {
565
+ const stt = config.stt;
566
+ if (stt === undefined) return;
567
+ if (!stt || typeof stt !== "object" || Array.isArray(stt)) {
568
+ throw new ValidationError(
569
+ `Provider "${config.id}" has invalid stt: must be an object.`,
570
+ { fix: `Use stt: { mode: "required" } or stt: { mode: "optional" }.` },
571
+ );
572
+ }
573
+ rejectUnknownFields(stt, new Set(["mode"]), "stt");
574
+ assertLiteralField(stt.mode, "stt.mode", VALID_PROVIDER_STT_MODES, config.id);
575
+ }
576
+
167
577
  function validateOperationIds(
168
578
  providerId: string,
169
579
  operations: Record<string, ProviderOperation>,
@@ -185,6 +595,155 @@ function validateOperationIds(
185
595
  );
186
596
  }
187
597
  }
598
+ const OPERATION_CONTRACT_VERSION_REGEX =
599
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
600
+ const OPERATION_SENSITIVE_PATH_REGEX =
601
+ /^(?:[A-Za-z0-9_$-]+|\*)(?:\.(?:[A-Za-z0-9_$-]+|\*))*$/;
602
+ const VALID_OPERATION_LIFECYCLES = [
603
+ "stable",
604
+ "beta",
605
+ "deprecated",
606
+ "removed",
607
+ ] as const;
608
+
609
+ function assertNonEmptyString(
610
+ value: unknown,
611
+ field: string,
612
+ providerId: string,
613
+ operationName: string,
614
+ ): asserts value is string {
615
+ if (typeof value !== "string" || value.trim().length === 0) {
616
+ throw new ValidationError(
617
+ `Provider "${providerId}" operation "${operationName}" has invalid ${field}: must be a non-empty string.`,
618
+ { fix: `Set ${field} to a non-empty customer-facing value.` },
619
+ );
620
+ }
621
+ }
622
+
623
+ function validateToolRouterMetadata(
624
+ providerId: string,
625
+ operations: Record<string, ProviderOperation>,
626
+ ): void {
627
+ for (const [operationName, operation] of Object.entries(operations)) {
628
+ const toolRouter = operation.toolRouter;
629
+ if (toolRouter === undefined) continue;
630
+ if (!toolRouter || typeof toolRouter !== "object") {
631
+ throw new ValidationError(
632
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter: must be an object.`,
633
+ {
634
+ fix: `Remove operations.${operationName}.toolRouter or provide MCP-safe metadata.`,
635
+ },
636
+ );
637
+ }
638
+ if (
639
+ toolRouter.name !== undefined &&
640
+ !MCP_TOOL_NAME_REGEX.test(toolRouter.name)
641
+ ) {
642
+ throw new ValidationError(
643
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter.name: expected an MCP-safe name.`,
644
+ {
645
+ fix: `Use letters, numbers, and underscores only, starting with a letter, for example "${providerId.replace(/[^A-Za-z0-9]+/g, "_")}__${operationName.replace(/[^A-Za-z0-9]+/g, "_")}".`,
646
+ },
647
+ );
648
+ }
649
+ if (toolRouter.riskClass !== undefined) {
650
+ assertLiteralField(
651
+ toolRouter.riskClass,
652
+ `operations.${operationName}.toolRouter.riskClass`,
653
+ VALID_OPERATION_RISK_CLASSES,
654
+ providerId,
655
+ );
656
+ }
657
+ if (toolRouter.approval !== undefined) {
658
+ assertLiteralField(
659
+ toolRouter.approval,
660
+ `operations.${operationName}.toolRouter.approval`,
661
+ VALID_OPERATION_APPROVAL_POLICIES,
662
+ providerId,
663
+ );
664
+ }
665
+ if (
666
+ toolRouter.connectionExternalRefParam !== undefined &&
667
+ (typeof toolRouter.connectionExternalRefParam !== "string" ||
668
+ toolRouter.connectionExternalRefParam.trim().length === 0)
669
+ ) {
670
+ throw new ValidationError(
671
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter.connectionExternalRefParam: must be a non-empty string.`,
672
+ {
673
+ fix: `Use "externalRef" unless the operation has a documented public alias.`,
674
+ },
675
+ );
676
+ }
677
+ }
678
+ }
679
+
680
+ function validateOperationContracts(
681
+ providerId: string,
682
+ operations: Record<string, ProviderOperation>,
683
+ ): void {
684
+ for (const [operationName, operation] of Object.entries(operations)) {
685
+ const contract = operation.contract;
686
+ if (contract === undefined) continue;
687
+ if (!contract || typeof contract !== "object") {
688
+ throw new ValidationError(
689
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.contract: must be an object.`,
690
+ {
691
+ fix: `Remove operations.${operationName}.contract or provide { version, lifecycle, deprecation }.`,
692
+ },
693
+ );
694
+ }
695
+ if (
696
+ contract.version !== undefined &&
697
+ (typeof contract.version !== "string" ||
698
+ !OPERATION_CONTRACT_VERSION_REGEX.test(contract.version))
699
+ ) {
700
+ throw new ValidationError(
701
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.contract.version: expected semver major.minor.patch.`,
702
+ { fix: `Use an operation contract version such as "1.0.0".` },
703
+ );
704
+ }
705
+ if (contract.lifecycle !== undefined) {
706
+ assertLiteralField(
707
+ contract.lifecycle,
708
+ `operations.${operationName}.contract.lifecycle`,
709
+ VALID_OPERATION_LIFECYCLES,
710
+ providerId,
711
+ );
712
+ }
713
+ if (
714
+ contract.lifecycle === "deprecated" ||
715
+ contract.lifecycle === "removed"
716
+ ) {
717
+ if (!contract.deprecation || typeof contract.deprecation !== "object") {
718
+ throw new ValidationError(
719
+ `Provider "${providerId}" operation "${operationName}" is ${contract.lifecycle} but lacks operations.${operationName}.contract.deprecation metadata.`,
720
+ {
721
+ fix: `Add announcedAt, removalAfter, and migrationGuide to operations.${operationName}.contract.deprecation.`,
722
+ },
723
+ );
724
+ }
725
+ assertNonEmptyString(
726
+ contract.deprecation.announcedAt,
727
+ `operations.${operationName}.contract.deprecation.announcedAt`,
728
+ providerId,
729
+ operationName,
730
+ );
731
+ assertNonEmptyString(
732
+ contract.deprecation.removalAfter,
733
+ `operations.${operationName}.contract.deprecation.removalAfter`,
734
+ providerId,
735
+ operationName,
736
+ );
737
+ assertNonEmptyString(
738
+ contract.deprecation.migrationGuide,
739
+ `operations.${operationName}.contract.deprecation.migrationGuide`,
740
+ providerId,
741
+ operationName,
742
+ );
743
+ }
744
+ }
745
+ }
746
+
188
747
  function validateOperationAnnotations(
189
748
  providerId: string,
190
749
  operations: Record<string, ProviderOperation>,
@@ -215,9 +774,350 @@ function validateOperationAnnotations(
215
774
  }
216
775
  }
217
776
 
777
+ function validateOperationObservability(
778
+ providerId: string,
779
+ operations: Record<string, ProviderOperation>,
780
+ ): void {
781
+ for (const [operationName, operation] of Object.entries(operations)) {
782
+ const observability = operation.observability;
783
+ if (observability === undefined) continue;
784
+ if (!observability || typeof observability !== "object") {
785
+ throw new ValidationError(
786
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability: must be an object.`,
787
+ {
788
+ fix: `Use observability: { sensitive: { input: ["field"], output: ["items.*.secret"] } }.`,
789
+ },
790
+ );
791
+ }
792
+ rejectUnknownFields(
793
+ observability,
794
+ new Set(["sensitive"]),
795
+ `operations.${operationName}.observability`,
796
+ );
797
+ const sensitive = observability.sensitive;
798
+ if (sensitive === undefined) continue;
799
+ if (!sensitive || typeof sensitive !== "object") {
800
+ throw new ValidationError(
801
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive: must be an object.`,
802
+ );
803
+ }
804
+ rejectUnknownFields(
805
+ sensitive,
806
+ new Set(["input", "output"]),
807
+ `operations.${operationName}.observability.sensitive`,
808
+ );
809
+ for (const side of ["input", "output"] as const) {
810
+ const paths = sensitive[side];
811
+ if (paths === undefined) continue;
812
+ if (!Array.isArray(paths)) {
813
+ throw new ValidationError(
814
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive.${side}: must be an array of dot paths.`,
815
+ );
816
+ }
817
+ for (const [index, path] of paths.entries()) {
818
+ if (
819
+ typeof path !== "string" ||
820
+ path.trim() !== path ||
821
+ !OPERATION_SENSITIVE_PATH_REGEX.test(path)
822
+ ) {
823
+ throw new ValidationError(
824
+ `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.observability.sensitive.${side}[${index}]: expected dot path segments or "*" wildcards.`,
825
+ {
826
+ fix: `Use paths like "password" or "items.*.phone"; do not include empty segments, brackets, or leading/trailing spaces.`,
827
+ },
828
+ );
829
+ }
830
+ }
831
+ }
832
+ }
833
+ }
834
+
835
+ const JSON_TRANSPORT_FIELDS = new Set(["kind"]);
836
+ const SSE_TRANSPORT_FIELDS = new Set([
837
+ "kind",
838
+ "heartbeatMs",
839
+ "idleTimeoutMs",
840
+ "maxDurationMs",
841
+ "maxEventBytes",
842
+ "resumable",
843
+ "events",
844
+ ]);
845
+ const HTTP_STREAM_TRANSPORT_FIELDS = new Set([
846
+ "kind",
847
+ "contentType",
848
+ "idleTimeoutMs",
849
+ "maxDurationMs",
850
+ "maxChunkBytes",
851
+ ]);
852
+ const WEBSOCKET_TRANSPORT_FIELDS = new Set([
853
+ "kind",
854
+ "subprotocols",
855
+ "idleTimeoutMs",
856
+ "maxDurationMs",
857
+ "maxFrameBytes",
858
+ "dispatch",
859
+ ]);
860
+
861
+ function assertTransportObject(
862
+ transport: unknown,
863
+ fieldPath: string,
864
+ providerId: string,
865
+ operationName: string,
866
+ ): asserts transport is OperationTransport {
867
+ if (!transport || typeof transport !== "object" || Array.isArray(transport)) {
868
+ throw new ValidationError(
869
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must be a transport object.`,
870
+ {
871
+ fix: `Use ${fieldPath}: { kind: "sse", ... } or omit ${fieldPath} for JSON operations.`,
872
+ },
873
+ );
874
+ }
875
+ }
876
+
877
+ function assertStreamMs(
878
+ value: unknown,
879
+ fieldPath: string,
880
+ min: number,
881
+ max: number,
882
+ label: string,
883
+ ): void {
884
+ if (value === undefined) return;
885
+ assertBoundedIntegerMs(value, fieldPath, { min, max, label });
886
+ }
887
+
888
+ function assertPositiveBytes(value: unknown, fieldPath: string): void {
889
+ if (value === undefined) return;
890
+ if (
891
+ typeof value !== "number" ||
892
+ !Number.isInteger(value) ||
893
+ value < STREAM_CHUNK_BYTES_MIN ||
894
+ value > STREAM_CHUNK_BYTES_MAX
895
+ ) {
896
+ throw new ValidationError(
897
+ `${fieldPath} must be an integer byte size in [${STREAM_CHUNK_BYTES_MIN}, ${STREAM_CHUNK_BYTES_MAX}].`,
898
+ {
899
+ fix: `Set ${fieldPath} to an integer byte size no larger than ${STREAM_CHUNK_BYTES_MAX}.`,
900
+ },
901
+ );
902
+ }
903
+ }
904
+
905
+ function validateSseEvents(
906
+ value: unknown,
907
+ fieldPath: string,
908
+ providerId: string,
909
+ operationName: string,
910
+ ): void {
911
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
912
+ throw new ValidationError(
913
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must be an object keyed by SSE event name.`,
914
+ {
915
+ fix: `Set ${fieldPath} to an object, for example delta: z.object({ ... }). SSE transports require explicit event schemas.`,
916
+ },
917
+ );
918
+ }
919
+ if (Object.keys(value).length === 0) {
920
+ throw new ValidationError(
921
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}: must declare at least one SSE event schema.`,
922
+ {
923
+ fix: `Declare every emitted event, for example ${fieldPath}: { delta: z.object({ ... }) }.`,
924
+ },
925
+ );
926
+ }
927
+ for (const [eventName, schema] of Object.entries(value)) {
928
+ if (!SSE_EVENT_NAME_REGEX.test(eventName)) {
929
+ throw new ValidationError(
930
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.${eventName}: event names must be SSE-safe identifiers.`,
931
+ {
932
+ fix: `Use letters, numbers, underscore, dash, or dot, starting with a letter.`,
933
+ },
934
+ );
935
+ }
936
+ if (!schema || typeof schema !== "object") {
937
+ throw new ValidationError(
938
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.${eventName}: event schema must be a schema object.`,
939
+ {
940
+ fix: `Set ${fieldPath}.${eventName} to a Zod or Standard Schema object.`,
941
+ },
942
+ );
943
+ }
944
+ }
945
+ }
946
+
947
+ function validateOperationTransports(
948
+ providerId: string,
949
+ operations: Record<string, ProviderOperation>,
950
+ ): void {
951
+ for (const [operationName, operation] of Object.entries(operations)) {
952
+ const transport = operation.transport;
953
+ if (transport === undefined) continue;
954
+ const fieldPath = `operations.${operationName}.transport`;
955
+ assertTransportObject(transport, fieldPath, providerId, operationName);
956
+ const kind = Reflect.get(transport, "kind");
957
+ if (typeof kind !== "string") {
958
+ throw new ValidationError(
959
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.kind: must be a string.`,
960
+ {
961
+ fix: `Set ${fieldPath}.kind to one of ${VALID_OPERATION_TRANSPORT_KINDS.map((item) => `"${item}"`).join(", ")}.`,
962
+ },
963
+ );
964
+ }
965
+ assertLiteralField(
966
+ kind,
967
+ `${fieldPath}.kind`,
968
+ VALID_OPERATION_TRANSPORT_KINDS,
969
+ providerId,
970
+ );
971
+
972
+ switch (kind) {
973
+ case "json":
974
+ rejectUnknownFields(transport, JSON_TRANSPORT_FIELDS, fieldPath);
975
+ break;
976
+ case "sse": {
977
+ rejectUnknownFields(transport, SSE_TRANSPORT_FIELDS, fieldPath);
978
+ const heartbeatMs = Reflect.get(transport, "heartbeatMs");
979
+ const idleTimeoutMs = Reflect.get(transport, "idleTimeoutMs");
980
+ const maxDurationMs = Reflect.get(transport, "maxDurationMs");
981
+ assertStreamMs(
982
+ heartbeatMs,
983
+ `${fieldPath}.heartbeatMs`,
984
+ STREAM_HEARTBEAT_MS_MIN,
985
+ STREAM_HEARTBEAT_MS_MAX,
986
+ "heartbeat",
987
+ );
988
+ assertStreamMs(
989
+ idleTimeoutMs,
990
+ `${fieldPath}.idleTimeoutMs`,
991
+ STREAM_IDLE_TIMEOUT_MS_MIN,
992
+ STREAM_IDLE_TIMEOUT_MS_MAX,
993
+ "idle timeout",
994
+ );
995
+ assertStreamMs(
996
+ maxDurationMs,
997
+ `${fieldPath}.maxDurationMs`,
998
+ STREAM_MAX_DURATION_MS_MIN,
999
+ STREAM_MAX_DURATION_MS_MAX,
1000
+ "max duration",
1001
+ );
1002
+ assertPositiveBytes(
1003
+ Reflect.get(transport, "maxEventBytes"),
1004
+ `${fieldPath}.maxEventBytes`,
1005
+ );
1006
+ const resumable = Reflect.get(transport, "resumable");
1007
+ if (
1008
+ resumable !== undefined &&
1009
+ resumable !== false &&
1010
+ resumable !== "last-event-id"
1011
+ ) {
1012
+ throw new ValidationError(
1013
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.resumable: expected false or "last-event-id".`,
1014
+ {
1015
+ fix: `Use ${fieldPath}.resumable: "last-event-id" for SSE Last-Event-ID resume support, or false to disable resume.`,
1016
+ },
1017
+ );
1018
+ }
1019
+ validateSseEvents(
1020
+ Reflect.get(transport, "events"),
1021
+ `${fieldPath}.events`,
1022
+ providerId,
1023
+ operationName,
1024
+ );
1025
+ break;
1026
+ }
1027
+ case "http-stream": {
1028
+ rejectUnknownFields(transport, HTTP_STREAM_TRANSPORT_FIELDS, fieldPath);
1029
+ const contentType = Reflect.get(transport, "contentType");
1030
+ if (contentType !== undefined) {
1031
+ assertNonEmptyString(
1032
+ contentType,
1033
+ `${fieldPath}.contentType`,
1034
+ providerId,
1035
+ operationName,
1036
+ );
1037
+ }
1038
+ assertStreamMs(
1039
+ Reflect.get(transport, "idleTimeoutMs"),
1040
+ `${fieldPath}.idleTimeoutMs`,
1041
+ STREAM_IDLE_TIMEOUT_MS_MIN,
1042
+ STREAM_IDLE_TIMEOUT_MS_MAX,
1043
+ "idle timeout",
1044
+ );
1045
+ assertStreamMs(
1046
+ Reflect.get(transport, "maxDurationMs"),
1047
+ `${fieldPath}.maxDurationMs`,
1048
+ STREAM_MAX_DURATION_MS_MIN,
1049
+ STREAM_MAX_DURATION_MS_MAX,
1050
+ "max duration",
1051
+ );
1052
+ assertPositiveBytes(
1053
+ Reflect.get(transport, "maxChunkBytes"),
1054
+ `${fieldPath}.maxChunkBytes`,
1055
+ );
1056
+ break;
1057
+ }
1058
+ case "websocket": {
1059
+ rejectUnknownFields(transport, WEBSOCKET_TRANSPORT_FIELDS, fieldPath);
1060
+ const dispatch = Reflect.get(transport, "dispatch");
1061
+ if (dispatch !== "unsupported") {
1062
+ throw new ValidationError(
1063
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.dispatch: websocket dispatch is future-ready only.`,
1064
+ {
1065
+ fix: `Use ${fieldPath}.dispatch: "unsupported" until gateway-managed sessions are implemented.`,
1066
+ },
1067
+ );
1068
+ }
1069
+ const subprotocols = Reflect.get(transport, "subprotocols");
1070
+ if (subprotocols !== undefined) {
1071
+ if (!Array.isArray(subprotocols)) {
1072
+ throw new ValidationError(
1073
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.subprotocols: must be an array.`,
1074
+ {
1075
+ fix: `Set ${fieldPath}.subprotocols to an array of WebSocket subprotocol tokens.`,
1076
+ },
1077
+ );
1078
+ }
1079
+ for (const subprotocol of subprotocols) {
1080
+ if (
1081
+ typeof subprotocol !== "string" ||
1082
+ !WEBSOCKET_SUBPROTOCOL_REGEX.test(subprotocol)
1083
+ ) {
1084
+ throw new ValidationError(
1085
+ `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.subprotocols: each subprotocol must be an RFC token string.`,
1086
+ {
1087
+ fix: `Use values such as "apifuse.v1" without spaces or separators that are invalid for Sec-WebSocket-Protocol.`,
1088
+ },
1089
+ );
1090
+ }
1091
+ }
1092
+ }
1093
+ assertStreamMs(
1094
+ Reflect.get(transport, "idleTimeoutMs"),
1095
+ `${fieldPath}.idleTimeoutMs`,
1096
+ STREAM_IDLE_TIMEOUT_MS_MIN,
1097
+ STREAM_IDLE_TIMEOUT_MS_MAX,
1098
+ "idle timeout",
1099
+ );
1100
+ assertStreamMs(
1101
+ Reflect.get(transport, "maxDurationMs"),
1102
+ `${fieldPath}.maxDurationMs`,
1103
+ STREAM_MAX_DURATION_MS_MIN,
1104
+ STREAM_MAX_DURATION_MS_MAX,
1105
+ "max duration",
1106
+ );
1107
+ assertPositiveBytes(
1108
+ Reflect.get(transport, "maxFrameBytes"),
1109
+ `${fieldPath}.maxFrameBytes`,
1110
+ );
1111
+ break;
1112
+ }
1113
+ }
1114
+ }
1115
+ }
1116
+
218
1117
  const HEALTH_CHECK_SUITE_FIELDS = new Set([
219
1118
  "interval",
220
1119
  "timeoutMs",
1120
+ "degradedThresholdMs",
221
1121
  "cases",
222
1122
  "requiresConnection",
223
1123
  ]);
@@ -225,16 +1125,27 @@ const HEALTH_CHECK_CASE_FIELDS = new Set([
225
1125
  "name",
226
1126
  "description",
227
1127
  "input",
1128
+ "prepareInput",
228
1129
  "assertions",
229
1130
  "degradedThresholdMs",
1131
+ "timeoutMs",
230
1132
  "expectedStatus",
231
1133
  "enabled",
232
1134
  ]);
233
1135
  const HEALTH_CHECK_UNSUPPORTED_FIELDS = new Set(["reason", "trackedIn"]);
234
1136
  const PROVIDER_HEALTH_MONITOR_FIELDS = new Set([
1137
+ "defaultProbeTimeoutMs",
1138
+ "defaultDegradedThresholdMs",
235
1139
  "requiredSecrets",
1140
+ "credentialInputs",
1141
+ "probeOverrides",
236
1142
  "serviceAccount",
237
1143
  ]);
1144
+ const PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS = new Set([
1145
+ "interval",
1146
+ "timeoutMs",
1147
+ "degradedThresholdMs",
1148
+ ]);
238
1149
 
239
1150
  function levenshtein(a: string, b: string): number {
240
1151
  const m = a.length;
@@ -275,7 +1186,7 @@ function suggestField(
275
1186
  }
276
1187
 
277
1188
  function rejectUnknownFields(
278
- value: Record<string, unknown>,
1189
+ value: object,
279
1190
  allowed: ReadonlySet<string>,
280
1191
  fieldPath: string,
281
1192
  ): void {
@@ -291,6 +1202,26 @@ function rejectUnknownFields(
291
1202
  }
292
1203
  }
293
1204
 
1205
+ function assertBoundedIntegerMs(
1206
+ value: unknown,
1207
+ fieldPath: string,
1208
+ options: { min: number; max: number; label: string },
1209
+ ): void {
1210
+ if (
1211
+ typeof value !== "number" ||
1212
+ !Number.isInteger(value) ||
1213
+ value < options.min ||
1214
+ value > options.max
1215
+ ) {
1216
+ throw new ValidationError(
1217
+ `${fieldPath} must be an integer ${options.label} in [${options.min}, ${options.max}] ms.`,
1218
+ {
1219
+ fix: `Set ${fieldPath} to an integer in [${options.min}, ${options.max}] ms.`,
1220
+ },
1221
+ );
1222
+ }
1223
+ }
1224
+
294
1225
  function validateProviderHealthMonitor(
295
1226
  providerId: string,
296
1227
  healthMonitor: unknown,
@@ -307,27 +1238,132 @@ function validateProviderHealthMonitor(
307
1238
  fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
308
1239
  },
309
1240
  );
1241
+ const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
310
1242
  rejectUnknownFields(
311
- healthMonitor as Record<string, unknown>,
1243
+ healthMonitorRecord,
312
1244
  PROVIDER_HEALTH_MONITOR_FIELDS,
313
1245
  "healthMonitor",
314
1246
  );
315
- const requiredSecrets = (healthMonitor as ProviderHealthMonitorConfig)
316
- .requiredSecrets;
1247
+ if (healthMonitorRecord.defaultProbeTimeoutMs !== undefined) {
1248
+ assertBoundedIntegerMs(
1249
+ healthMonitorRecord.defaultProbeTimeoutMs,
1250
+ `Provider "${providerId}" healthMonitor.defaultProbeTimeoutMs`,
1251
+ {
1252
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1253
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1254
+ label: "timeout",
1255
+ },
1256
+ );
1257
+ }
1258
+ if (healthMonitorRecord.defaultDegradedThresholdMs !== undefined) {
1259
+ assertBoundedIntegerMs(
1260
+ healthMonitorRecord.defaultDegradedThresholdMs,
1261
+ `Provider "${providerId}" healthMonitor.defaultDegradedThresholdMs`,
1262
+ {
1263
+ min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1264
+ max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
1265
+ label: "degraded threshold",
1266
+ },
1267
+ );
1268
+ }
1269
+ const requiredSecrets = healthMonitorRecord.requiredSecrets;
317
1270
  if (requiredSecrets !== undefined) {
318
1271
  if (!Array.isArray(requiredSecrets))
319
1272
  throw new ValidationError(
320
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
1273
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
1274
+ );
1275
+ for (const [index, secret] of requiredSecrets.entries()) {
1276
+ if (typeof secret !== "string" || secret.length === 0)
1277
+ throw new ValidationError(
1278
+ `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
1279
+ );
1280
+ }
1281
+ }
1282
+ const credentialInputs = healthMonitorRecord.credentialInputs;
1283
+ if (credentialInputs !== undefined) {
1284
+ if (
1285
+ !credentialInputs ||
1286
+ typeof credentialInputs !== "object" ||
1287
+ Array.isArray(credentialInputs)
1288
+ ) {
1289
+ throw new ValidationError(
1290
+ `Provider "${providerId}" has invalid healthMonitor.credentialInputs: must be an object mapping auth input fields to env var names.`,
1291
+ );
1292
+ }
1293
+ for (const [field, envVar] of Object.entries(credentialInputs)) {
1294
+ if (field.trim().length === 0) {
1295
+ throw new ValidationError(
1296
+ `Provider "${providerId}" has invalid healthMonitor.credentialInputs key: must be a non-empty auth input field.`,
1297
+ );
1298
+ }
1299
+ if (typeof envVar !== "string" || envVar.trim().length === 0) {
1300
+ throw new ValidationError(
1301
+ `Provider "${providerId}" has invalid healthMonitor.credentialInputs.${field}: must be a non-empty env var name.`,
1302
+ );
1303
+ }
1304
+ if (Array.isArray(requiredSecrets) && !requiredSecrets.includes(envVar)) {
1305
+ throw new ValidationError(
1306
+ `Provider "${providerId}" healthMonitor.credentialInputs.${field} references ${envVar}, which must also be listed in healthMonitor.requiredSecrets.`,
1307
+ );
1308
+ }
1309
+ }
1310
+ }
1311
+
1312
+ const probeOverrides = healthMonitorRecord.probeOverrides;
1313
+ if (probeOverrides !== undefined) {
1314
+ if (
1315
+ !probeOverrides ||
1316
+ typeof probeOverrides !== "object" ||
1317
+ Array.isArray(probeOverrides)
1318
+ )
1319
+ throw new ValidationError(
1320
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
321
1321
  );
322
- for (const [index, secret] of requiredSecrets.entries()) {
323
- if (typeof secret !== "string" || secret.length === 0)
1322
+ for (const [probeId, override] of Object.entries(probeOverrides)) {
1323
+ if (probeId.length === 0)
324
1324
  throw new ValidationError(
325
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
1325
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
1326
+ );
1327
+ if (!override || typeof override !== "object" || Array.isArray(override))
1328
+ throw new ValidationError(
1329
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
1330
+ );
1331
+ const overrideRecord = Object.fromEntries(Object.entries(override));
1332
+ rejectUnknownFields(
1333
+ overrideRecord,
1334
+ PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
1335
+ `healthMonitor.probeOverrides["${probeId}"]`,
1336
+ );
1337
+ const interval = overrideRecord.interval;
1338
+ if (interval !== undefined && !isPositiveMsDurationString(interval))
1339
+ throw new ValidationError(
1340
+ `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"].interval: must be a positive ms-style duration string such as 30s, 5m, 8h, or 1 day.`,
1341
+ );
1342
+ if (overrideRecord.timeoutMs !== undefined) {
1343
+ assertBoundedIntegerMs(
1344
+ overrideRecord.timeoutMs,
1345
+ `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].timeoutMs`,
1346
+ {
1347
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1348
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1349
+ label: "timeout",
1350
+ },
326
1351
  );
1352
+ }
1353
+ if (overrideRecord.degradedThresholdMs !== undefined) {
1354
+ assertBoundedIntegerMs(
1355
+ overrideRecord.degradedThresholdMs,
1356
+ `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].degradedThresholdMs`,
1357
+ {
1358
+ min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1359
+ max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
1360
+ label: "degraded threshold",
1361
+ },
1362
+ );
1363
+ }
327
1364
  }
328
1365
  }
329
- const serviceAccount = (healthMonitor as ProviderHealthMonitorConfig)
330
- .serviceAccount;
1366
+ const serviceAccount = healthMonitorRecord.serviceAccount;
331
1367
  if (
332
1368
  serviceAccount !== undefined &&
333
1369
  (typeof serviceAccount !== "string" || serviceAccount.length === 0)
@@ -365,15 +1401,31 @@ function validateHealthCheckCase(
365
1401
  fix: `Set ${fieldPath}.assertions to (ctx) => { ... } that throws on failure.`,
366
1402
  },
367
1403
  );
1404
+ if (c.prepareInput !== undefined && typeof c.prepareInput !== "function")
1405
+ throw new ValidationError(
1406
+ `Provider "${providerId}" ${fieldPath}.prepareInput must be a function.`,
1407
+ );
368
1408
  if (
369
1409
  c.degradedThresholdMs !== undefined &&
370
1410
  (typeof c.degradedThresholdMs !== "number" ||
371
- !Number.isFinite(c.degradedThresholdMs) ||
372
- c.degradedThresholdMs <= 0)
1411
+ !Number.isInteger(c.degradedThresholdMs) ||
1412
+ c.degradedThresholdMs < HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN ||
1413
+ c.degradedThresholdMs > HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX)
373
1414
  )
374
1415
  throw new ValidationError(
375
- `Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be a positive number.`,
1416
+ `Provider "${providerId}" ${fieldPath}.degradedThresholdMs must be an integer degraded threshold in [${HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN}, ${HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX}] ms.`,
1417
+ );
1418
+ if (c.timeoutMs !== undefined) {
1419
+ assertBoundedIntegerMs(
1420
+ c.timeoutMs,
1421
+ `Provider "${providerId}" ${fieldPath}.timeoutMs`,
1422
+ {
1423
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1424
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1425
+ label: "timeout",
1426
+ },
376
1427
  );
1428
+ }
377
1429
  if (
378
1430
  c.expectedStatus !== undefined &&
379
1431
  c.expectedStatus !== "ok" &&
@@ -404,25 +1456,34 @@ function validateHealthCheckSuite(
404
1456
  fieldPath,
405
1457
  );
406
1458
  const s = suite as HealthCheckSuite;
407
- if (
408
- typeof s.interval !== "string" ||
409
- !PROBE_INTERVALS.includes(s.interval as ProbeInterval)
410
- )
1459
+ if (!isPositiveMsDurationString(s.interval))
411
1460
  throw new ValidationError(
412
- `Provider "${providerId}" ${fieldPath}.interval must be one of ${PROBE_INTERVALS.join(", ")}.`,
1461
+ `Provider "${providerId}" ${fieldPath}.interval must be a positive ms-style duration string such as 30s, 5m, 8h, or 1 day.`,
413
1462
  {
414
- fix: `Set ${fieldPath}.interval to a supported probe interval.`,
1463
+ fix: `Set ${fieldPath}.interval to a positive ms-style duration string.`,
415
1464
  },
416
1465
  );
417
1466
  if (s.timeoutMs !== undefined) {
418
- if (
419
- typeof s.timeoutMs !== "number" ||
420
- !Number.isInteger(s.timeoutMs) ||
421
- s.timeoutMs <= 0
422
- )
423
- throw new ValidationError(
424
- `Provider "${providerId}" ${fieldPath}.timeoutMs must be a positive integer (ms).`,
425
- );
1467
+ assertBoundedIntegerMs(
1468
+ s.timeoutMs,
1469
+ `Provider "${providerId}" ${fieldPath}.timeoutMs`,
1470
+ {
1471
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1472
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1473
+ label: "timeout",
1474
+ },
1475
+ );
1476
+ }
1477
+ if (s.degradedThresholdMs !== undefined) {
1478
+ assertBoundedIntegerMs(
1479
+ s.degradedThresholdMs,
1480
+ `Provider "${providerId}" ${fieldPath}.degradedThresholdMs`,
1481
+ {
1482
+ min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1483
+ max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
1484
+ label: "degraded threshold",
1485
+ },
1486
+ );
426
1487
  }
427
1488
  if (
428
1489
  s.requiresConnection !== undefined &&
@@ -486,9 +1547,607 @@ function validateHealthCheckUnsupported(
486
1547
  );
487
1548
  }
488
1549
 
1550
+ const HEALTH_JOURNEY_FIELDS = new Set([
1551
+ "id",
1552
+ "title",
1553
+ "description",
1554
+ "schedule",
1555
+ "coversOperations",
1556
+ "timeout",
1557
+ "cooldown",
1558
+ "smsMatchers",
1559
+ "requiredSecrets",
1560
+ "manualTrigger",
1561
+ "steps",
1562
+ "run",
1563
+ ]);
1564
+ const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter"]);
1565
+ const HEALTH_JOURNEY_STEP_FIELDS = new Set([
1566
+ "id",
1567
+ "description",
1568
+ "operationId",
1569
+ "usesSmsMatcher",
1570
+ "coversOperations",
1571
+ "safeBoundary",
1572
+ "kind",
1573
+ ]);
1574
+
1575
+ const HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS = new Set([
1576
+ "enabled",
1577
+ "reason",
1578
+ "requiresAcknowledgement",
1579
+ "risk",
1580
+ "minManualInterval",
1581
+ "publicRationale",
1582
+ ]);
1583
+ const HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS = new Set([
1584
+ "enabled",
1585
+ "reason",
1586
+ ]);
1587
+ const HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS = new Set([
1588
+ "enabled",
1589
+ "requiresAcknowledgement",
1590
+ "risk",
1591
+ "minManualInterval",
1592
+ "publicRationale",
1593
+ ]);
1594
+ const HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS = new Set([
1595
+ "read_only",
1596
+ "writes_external_state",
1597
+ "sms_or_payment",
1598
+ ]);
1599
+
1600
+ function validateHealthJourneyManualTrigger(
1601
+ providerId: string,
1602
+ journeyId: string,
1603
+ manualTrigger: unknown,
1604
+ ): void {
1605
+ const fieldPath = `healthJourneys.${journeyId}.manualTrigger`;
1606
+ if (
1607
+ !manualTrigger ||
1608
+ typeof manualTrigger !== "object" ||
1609
+ Array.isArray(manualTrigger)
1610
+ ) {
1611
+ throw new ValidationError(
1612
+ `Provider "${providerId}" ${fieldPath} must be an object when present.`,
1613
+ );
1614
+ }
1615
+ rejectUnknownFields(
1616
+ manualTrigger,
1617
+ HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS,
1618
+ fieldPath,
1619
+ );
1620
+ const enabled = Reflect.get(manualTrigger, "enabled");
1621
+ if (typeof enabled !== "boolean") {
1622
+ throw new ValidationError(
1623
+ `Provider "${providerId}" ${fieldPath}.enabled must be a boolean.`,
1624
+ );
1625
+ }
1626
+ if (enabled === false) {
1627
+ rejectUnknownFields(
1628
+ manualTrigger,
1629
+ HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS,
1630
+ fieldPath,
1631
+ );
1632
+ if (
1633
+ Reflect.get(manualTrigger, "reason") !== undefined &&
1634
+ (typeof Reflect.get(manualTrigger, "reason") !== "string" ||
1635
+ Reflect.get(manualTrigger, "reason") === "")
1636
+ ) {
1637
+ throw new ValidationError(
1638
+ `Provider "${providerId}" ${fieldPath}.reason must be a non-empty string when present.`,
1639
+ );
1640
+ }
1641
+ return;
1642
+ }
1643
+ rejectUnknownFields(
1644
+ manualTrigger,
1645
+ HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS,
1646
+ fieldPath,
1647
+ );
1648
+ const requiresAcknowledgement = Reflect.get(
1649
+ manualTrigger,
1650
+ "requiresAcknowledgement",
1651
+ );
1652
+ if (typeof requiresAcknowledgement !== "boolean") {
1653
+ throw new ValidationError(
1654
+ `Provider "${providerId}" ${fieldPath}.requiresAcknowledgement must be a boolean.`,
1655
+ );
1656
+ }
1657
+ const risk = Reflect.get(manualTrigger, "risk");
1658
+ if (
1659
+ typeof risk !== "string" ||
1660
+ !HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS.has(risk)
1661
+ ) {
1662
+ throw new ValidationError(
1663
+ `Provider "${providerId}" ${fieldPath}.risk must be one of read_only, writes_external_state, or sms_or_payment.`,
1664
+ );
1665
+ }
1666
+ if (risk !== "read_only" && requiresAcknowledgement !== true) {
1667
+ throw new ValidationError(
1668
+ `Provider "${providerId}" ${fieldPath}.requiresAcknowledgement must be true when risk is writes_external_state or sms_or_payment.`,
1669
+ );
1670
+ }
1671
+ const minManualInterval = Reflect.get(manualTrigger, "minManualInterval");
1672
+ assertIsoDuration(
1673
+ minManualInterval,
1674
+ `Provider "${providerId}" ${fieldPath}.minManualInterval`,
1675
+ );
1676
+ if (isoDurationMs(minManualInterval) <= 0) {
1677
+ throw new ValidationError(
1678
+ `Provider "${providerId}" ${fieldPath}.minManualInterval must be a positive duration.`,
1679
+ );
1680
+ }
1681
+ const rationale = Reflect.get(manualTrigger, "publicRationale");
1682
+ if (typeof rationale !== "string" || rationale.trim().length === 0) {
1683
+ throw new ValidationError(
1684
+ `Provider "${providerId}" ${fieldPath}.publicRationale must be a non-empty string.`,
1685
+ );
1686
+ }
1687
+ }
1688
+
1689
+ const SMS_OTP_MATCHER_FIELDS = new Set([
1690
+ "id",
1691
+ "country",
1692
+ "locale",
1693
+ "phoneNumber",
1694
+ "origins",
1695
+ "code",
1696
+ "maxAge",
1697
+ "waitTimeout",
1698
+ "clockSkew",
1699
+ "extractOtp",
1700
+ ]);
1701
+ const SMS_OTP_CODE_FIELDS = new Set(["pattern", "capture"]);
1702
+ const SMS_ORIGIN_FIELDS_BY_KIND: Record<string, ReadonlySet<string>> = {
1703
+ e164: new Set(["kind", "value", "display"]),
1704
+ nationalServiceCode: new Set(["kind", "country", "value", "display"]),
1705
+ };
1706
+ const DURATION_RE =
1707
+ /^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
1708
+ const E164_RE = /^\+[1-9]\d{1,14}$/;
1709
+ const ISO_COUNTRY_RE = /^[A-Z]{2}$/;
1710
+ const NATIONAL_SERVICE_CODE_RE = /^[0-9]{2,15}$/;
1711
+ const BCP47_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
1712
+ const JOURNEY_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1713
+
1714
+ function assertIsoDuration(
1715
+ value: unknown,
1716
+ fieldPath: string,
1717
+ ): asserts value is string {
1718
+ if (typeof value !== "string" || !DURATION_RE.test(value)) {
1719
+ throw new ValidationError(
1720
+ `${fieldPath} must be an ISO 8601 duration for example PT8H or PT2M30S.`,
1721
+ );
1722
+ }
1723
+ }
1724
+
1725
+ function isoDurationMs(value: string): number {
1726
+ const match = DURATION_RE.exec(value);
1727
+ if (!match) return 0;
1728
+ const days = Number(/(\d+)D/.exec(value)?.[1] ?? 0);
1729
+ const hours = Number(/(\d+)H/.exec(value)?.[1] ?? 0);
1730
+ const minutes = Number(/(\d+)M/.exec(value)?.[1] ?? 0);
1731
+ const seconds = Number(/(\d+(?:\.\d+)?)S/.exec(value)?.[1] ?? 0);
1732
+ return (
1733
+ days * 86_400_000 + hours * 3_600_000 + minutes * 60_000 + seconds * 1_000
1734
+ );
1735
+ }
1736
+
1737
+ function assertIsoCountry(
1738
+ value: unknown,
1739
+ fieldPath: string,
1740
+ ): asserts value is string {
1741
+ if (typeof value !== "string" || !ISO_COUNTRY_RE.test(value)) {
1742
+ throw new ValidationError(
1743
+ `${fieldPath} must be an ISO 3166-1 alpha-2 country code for example KR.`,
1744
+ );
1745
+ }
1746
+ }
1747
+
1748
+ function normalizeIntervalDuration(input: string): string {
1749
+ const trimmed = input.trim();
1750
+ const shorthand = /^(\d+)(s|m|h|d)$/i.exec(trimmed);
1751
+ if (shorthand) {
1752
+ const amount = Number(shorthand[1]);
1753
+ if (!Number.isInteger(amount) || amount <= 0) {
1754
+ throw new ValidationError(
1755
+ `Journey schedule interval must be a positive duration.`,
1756
+ );
1757
+ }
1758
+ const unit = shorthand[2]?.toLowerCase();
1759
+ if (unit === "s") return `PT${amount}S`;
1760
+ if (unit === "m") return `PT${amount}M`;
1761
+ if (unit === "h") return `PT${amount}H`;
1762
+ if (unit === "d") return `P${amount}D`;
1763
+ }
1764
+ assertIsoDuration(trimmed, "journey schedule interval");
1765
+ return trimmed;
1766
+ }
1767
+
1768
+ export function every(
1769
+ interval: string,
1770
+ options: { jitter?: string } = {},
1771
+ ): HealthJourneySchedule {
1772
+ const schedule: HealthJourneySchedule = {
1773
+ kind: "interval",
1774
+ interval: normalizeIntervalDuration(interval),
1775
+ };
1776
+ if (options.jitter !== undefined) {
1777
+ schedule.jitter = normalizeIntervalDuration(options.jitter);
1778
+ }
1779
+ return schedule;
1780
+ }
1781
+
1782
+ function countCapturingGroups(pattern: RegExp): number {
1783
+ let count = 0;
1784
+ const source = pattern.source;
1785
+ let inCharacterClass = false;
1786
+ for (let i = 0; i < source.length; i++) {
1787
+ const char = source[i];
1788
+ if (isRegexCharEscaped(source, i)) continue;
1789
+ if (char === "[") {
1790
+ inCharacterClass = true;
1791
+ continue;
1792
+ }
1793
+ if (char === "]") {
1794
+ inCharacterClass = false;
1795
+ continue;
1796
+ }
1797
+ if (inCharacterClass || char !== "(") continue;
1798
+ const next = source[i + 1];
1799
+ if (next === "?" && source[i + 2] !== "<") continue;
1800
+ if (
1801
+ next === "?" &&
1802
+ source[i + 2] === "<" &&
1803
+ (source[i + 3] === "=" || source[i + 3] === "!")
1804
+ )
1805
+ continue;
1806
+ count += 1;
1807
+ }
1808
+ return count;
1809
+ }
1810
+
1811
+ function isRegexCharEscaped(source: string, index: number): boolean {
1812
+ let backslashes = 0;
1813
+ for (let i = index - 1; i >= 0 && source[i] === "\\"; i--) backslashes += 1;
1814
+ return backslashes % 2 === 1;
1815
+ }
1816
+
1817
+ function validateSmsOrigin(origin: unknown, fieldPath: string): void {
1818
+ if (!origin || typeof origin !== "object" || Array.isArray(origin)) {
1819
+ throw new ValidationError(`${fieldPath} must be an object.`);
1820
+ }
1821
+ const kind = Reflect.get(origin, "kind");
1822
+ if (kind !== "e164" && kind !== "nationalServiceCode") {
1823
+ throw new ValidationError(
1824
+ `${fieldPath}.kind must be "e164" or "nationalServiceCode".`,
1825
+ );
1826
+ }
1827
+ rejectUnknownFields(origin, SMS_ORIGIN_FIELDS_BY_KIND[kind], fieldPath);
1828
+ if (kind === "e164") {
1829
+ if (
1830
+ typeof Reflect.get(origin, "value") !== "string" ||
1831
+ !E164_RE.test(Reflect.get(origin, "value"))
1832
+ ) {
1833
+ throw new ValidationError(
1834
+ `${fieldPath}.value must be an ITU-T E.164 number for example +821012345678.`,
1835
+ );
1836
+ }
1837
+ } else {
1838
+ assertIsoCountry(Reflect.get(origin, "country"), `${fieldPath}.country`);
1839
+ if (
1840
+ typeof Reflect.get(origin, "value") !== "string" ||
1841
+ !NATIONAL_SERVICE_CODE_RE.test(Reflect.get(origin, "value"))
1842
+ ) {
1843
+ throw new ValidationError(
1844
+ `${fieldPath}.value must be digits only for a national service code.`,
1845
+ );
1846
+ }
1847
+ }
1848
+ if (
1849
+ Reflect.get(origin, "display") !== undefined &&
1850
+ typeof Reflect.get(origin, "display") !== "string"
1851
+ ) {
1852
+ throw new ValidationError(
1853
+ `${fieldPath}.display must be a string when present.`,
1854
+ );
1855
+ }
1856
+ }
1857
+
1858
+ function validateSmsOtpMatcher(
1859
+ matcher: unknown,
1860
+ fieldPath: string,
1861
+ ): asserts matcher is SmsOtpMatcherDefinition {
1862
+ if (!matcher || typeof matcher !== "object" || Array.isArray(matcher)) {
1863
+ throw new ValidationError(`${fieldPath} must be an object.`);
1864
+ }
1865
+ rejectUnknownFields(matcher, SMS_OTP_MATCHER_FIELDS, fieldPath);
1866
+ const matcherId = Reflect.get(matcher, "id");
1867
+ if (typeof matcherId !== "string" || !JOURNEY_ID_RE.test(matcherId)) {
1868
+ throw new ValidationError(
1869
+ `${fieldPath}.id must be a kebab-case identifier.`,
1870
+ );
1871
+ }
1872
+ assertIsoCountry(Reflect.get(matcher, "country"), `${fieldPath}.country`);
1873
+ if (
1874
+ Reflect.get(matcher, "locale") !== undefined &&
1875
+ (typeof Reflect.get(matcher, "locale") !== "string" ||
1876
+ !BCP47_RE.test(Reflect.get(matcher, "locale")))
1877
+ ) {
1878
+ throw new ValidationError(
1879
+ `${fieldPath}.locale must be a BCP 47 locale for example ko-KR.`,
1880
+ );
1881
+ }
1882
+ if (
1883
+ Reflect.get(matcher, "phoneNumber") !== undefined &&
1884
+ (typeof Reflect.get(matcher, "phoneNumber") !== "string" ||
1885
+ !E164_RE.test(Reflect.get(matcher, "phoneNumber")))
1886
+ ) {
1887
+ throw new ValidationError(
1888
+ `${fieldPath}.phoneNumber must be an ITU-T E.164 number.`,
1889
+ );
1890
+ }
1891
+ const origins = Reflect.get(matcher, "origins");
1892
+ if (!Array.isArray(origins) || origins.length === 0) {
1893
+ throw new ValidationError(
1894
+ `${fieldPath}.origins must be a non-empty array.`,
1895
+ );
1896
+ }
1897
+ for (const [index, origin] of origins.entries()) {
1898
+ validateSmsOrigin(origin, `${fieldPath}.origins[${index}]`);
1899
+ }
1900
+ if (
1901
+ !Reflect.get(matcher, "code") ||
1902
+ typeof Reflect.get(matcher, "code") !== "object" ||
1903
+ Array.isArray(Reflect.get(matcher, "code"))
1904
+ ) {
1905
+ throw new ValidationError(`${fieldPath}.code must be an object.`);
1906
+ }
1907
+ const code = Reflect.get(matcher, "code");
1908
+ rejectUnknownFields(code, SMS_OTP_CODE_FIELDS, `${fieldPath}.code`);
1909
+ const pattern = Reflect.get(code, "pattern");
1910
+ if (!(pattern instanceof RegExp) && typeof pattern !== "string") {
1911
+ throw new ValidationError(
1912
+ `${fieldPath}.code.pattern must be a RegExp or pattern source string.`,
1913
+ );
1914
+ }
1915
+ const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern);
1916
+ if (
1917
+ countCapturingGroups(regex) !== 1 &&
1918
+ Reflect.get(code, "capture") === undefined
1919
+ ) {
1920
+ throw new ValidationError(
1921
+ `${fieldPath}.code.pattern must contain exactly one OTP capture or declare code.capture.`,
1922
+ );
1923
+ }
1924
+ if (
1925
+ Reflect.get(code, "capture") !== undefined &&
1926
+ typeof Reflect.get(code, "capture") !== "string" &&
1927
+ typeof Reflect.get(code, "capture") !== "number"
1928
+ ) {
1929
+ throw new ValidationError(
1930
+ `${fieldPath}.code.capture must be a string or number when present.`,
1931
+ );
1932
+ }
1933
+ assertIsoDuration(Reflect.get(matcher, "maxAge"), `${fieldPath}.maxAge`);
1934
+ assertIsoDuration(
1935
+ Reflect.get(matcher, "waitTimeout"),
1936
+ `${fieldPath}.waitTimeout`,
1937
+ );
1938
+ if (Reflect.get(matcher, "clockSkew") !== undefined)
1939
+ assertIsoDuration(
1940
+ Reflect.get(matcher, "clockSkew"),
1941
+ `${fieldPath}.clockSkew`,
1942
+ );
1943
+ }
1944
+
1945
+ export function defineSmsOtpMatcher(
1946
+ config: Omit<SmsOtpMatcherDefinition, "extractOtp">,
1947
+ ): SmsOtpMatcherDefinition {
1948
+ const rawPattern = config.code.pattern;
1949
+ const pattern =
1950
+ rawPattern instanceof RegExp
1951
+ ? new RegExp(rawPattern.source, rawPattern.flags)
1952
+ : new RegExp(rawPattern);
1953
+ const matcher = {
1954
+ ...config,
1955
+ extractOtp(body: string): string | null {
1956
+ pattern.lastIndex = 0;
1957
+ const match = pattern.exec(body);
1958
+ pattern.lastIndex = 0;
1959
+ if (!match) return null;
1960
+ const capture = config.code.capture;
1961
+ const code =
1962
+ typeof capture === "string"
1963
+ ? match.groups?.[capture]
1964
+ : typeof capture === "number"
1965
+ ? match[capture]
1966
+ : match[1];
1967
+ return typeof code === "string" ? code : null;
1968
+ },
1969
+ };
1970
+ validateSmsOtpMatcher(matcher, "smsOtpMatcher");
1971
+ return matcher;
1972
+ }
1973
+
1974
+ export function defineHealthJourney(
1975
+ config: HealthJourneyDefinition,
1976
+ ): HealthJourneyDefinition {
1977
+ return config;
1978
+ }
1979
+
1980
+ function validateHealthJourneySchedule(
1981
+ providerId: string,
1982
+ journeyId: string,
1983
+ schedule: unknown,
1984
+ ): void {
1985
+ const fieldPath = `healthJourneys.${journeyId}.schedule`;
1986
+ if (!schedule || typeof schedule !== "object" || Array.isArray(schedule)) {
1987
+ throw new ValidationError(
1988
+ `Provider "${providerId}" ${fieldPath} must be an object.`,
1989
+ );
1990
+ }
1991
+ rejectUnknownFields(schedule, HEALTH_JOURNEY_SCHEDULE_FIELDS, fieldPath);
1992
+ if (Reflect.get(schedule, "kind") !== "interval")
1993
+ throw new ValidationError(
1994
+ `Provider "${providerId}" ${fieldPath}.kind must be "interval".`,
1995
+ );
1996
+ assertIsoDuration(
1997
+ Reflect.get(schedule, "interval"),
1998
+ `Provider "${providerId}" ${fieldPath}.interval`,
1999
+ );
2000
+ if (Reflect.get(schedule, "jitter") !== undefined)
2001
+ assertIsoDuration(
2002
+ Reflect.get(schedule, "jitter"),
2003
+ `Provider "${providerId}" ${fieldPath}.jitter`,
2004
+ );
2005
+ }
2006
+
2007
+ function validateHealthJourneys(
2008
+ providerId: string,
2009
+ operations: Record<string, ProviderOperation>,
2010
+ healthJourneys: readonly HealthJourneyDefinition[] | undefined,
2011
+ ): Set<string> {
2012
+ const covered = new Set<string>();
2013
+ if (healthJourneys === undefined) return covered;
2014
+ if (!Array.isArray(healthJourneys)) {
2015
+ throw new ValidationError(
2016
+ `Provider "${providerId}" healthJourneys must be an array.`,
2017
+ );
2018
+ }
2019
+ const journeyIds = new Set<string>();
2020
+ for (const [index, journey] of healthJourneys.entries()) {
2021
+ const prefix = `healthJourneys[${index}]`;
2022
+ if (!journey || typeof journey !== "object" || Array.isArray(journey)) {
2023
+ throw new ValidationError(
2024
+ `Provider "${providerId}" ${prefix} must be an object.`,
2025
+ );
2026
+ }
2027
+ rejectUnknownFields(journey, HEALTH_JOURNEY_FIELDS, prefix);
2028
+ if (typeof journey.id !== "string" || !JOURNEY_ID_RE.test(journey.id)) {
2029
+ throw new ValidationError(
2030
+ `Provider "${providerId}" ${prefix}.id must be a kebab-case identifier.`,
2031
+ );
2032
+ }
2033
+ if (journeyIds.has(journey.id))
2034
+ throw new ValidationError(
2035
+ `Provider "${providerId}" has duplicate health journey id "${journey.id}".`,
2036
+ );
2037
+ journeyIds.add(journey.id);
2038
+ validateHealthJourneySchedule(providerId, journey.id, journey.schedule);
2039
+ if (
2040
+ !Array.isArray(journey.coversOperations) ||
2041
+ journey.coversOperations.length === 0
2042
+ ) {
2043
+ throw new ValidationError(
2044
+ `Provider "${providerId}" healthJourneys.${journey.id}.coversOperations must be a non-empty array.`,
2045
+ );
2046
+ }
2047
+ for (const operationId of journey.coversOperations) {
2048
+ if (typeof operationId !== "string" || operationId.length === 0) {
2049
+ throw new ValidationError(
2050
+ `Provider "${providerId}" healthJourneys.${journey.id}.coversOperations contains an invalid operation id.`,
2051
+ );
2052
+ }
2053
+ if (!operations[operationId]) {
2054
+ throw new ValidationError(
2055
+ `Provider "${providerId}" health journey "${journey.id}" covers unknown operation "${operationId}".`,
2056
+ );
2057
+ }
2058
+ if (operations[operationId].healthCheckUnsupported) {
2059
+ throw new ValidationError(
2060
+ `Provider "${providerId}" health journey "${journey.id}" cannot cover unsupported operation "${operationId}".`,
2061
+ );
2062
+ }
2063
+ covered.add(operationId);
2064
+ }
2065
+ if (!Array.isArray(journey.steps) || journey.steps.length === 0) {
2066
+ throw new ValidationError(
2067
+ `Provider "${providerId}" healthJourneys.${journey.id}.steps must be a non-empty array.`,
2068
+ );
2069
+ }
2070
+ const matcherIds = new Set<string>();
2071
+ if (journey.smsMatchers !== undefined) {
2072
+ if (!Array.isArray(journey.smsMatchers))
2073
+ throw new ValidationError(
2074
+ `Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers must be an array.`,
2075
+ );
2076
+ for (const [matcherIndex, matcher] of journey.smsMatchers.entries()) {
2077
+ validateSmsOtpMatcher(
2078
+ matcher,
2079
+ `healthJourneys.${journey.id}.smsMatchers[${matcherIndex}]`,
2080
+ );
2081
+ if (matcherIds.has(matcher.id))
2082
+ throw new ValidationError(
2083
+ `Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers has duplicate matcher id "${matcher.id}".`,
2084
+ );
2085
+ matcherIds.add(matcher.id);
2086
+ }
2087
+ }
2088
+ for (const [stepIndex, step] of journey.steps.entries()) {
2089
+ const stepPath = `healthJourneys.${journey.id}.steps[${stepIndex}]`;
2090
+ if (!step || typeof step !== "object" || Array.isArray(step))
2091
+ throw new ValidationError(
2092
+ `Provider "${providerId}" ${stepPath} must be an object.`,
2093
+ );
2094
+ rejectUnknownFields(step, HEALTH_JOURNEY_STEP_FIELDS, stepPath);
2095
+ if (typeof step.id !== "string" || !JOURNEY_ID_RE.test(step.id))
2096
+ throw new ValidationError(
2097
+ `Provider "${providerId}" ${stepPath}.id must be a kebab-case identifier.`,
2098
+ );
2099
+ if (step.operationId !== undefined && !operations[step.operationId])
2100
+ throw new ValidationError(
2101
+ `Provider "${providerId}" ${stepPath}.operationId references unknown operation "${step.operationId}".`,
2102
+ );
2103
+ if (
2104
+ step.usesSmsMatcher !== undefined &&
2105
+ !matcherIds.has(step.usesSmsMatcher)
2106
+ )
2107
+ throw new ValidationError(
2108
+ `Provider "${providerId}" ${stepPath}.usesSmsMatcher references unknown matcher "${step.usesSmsMatcher}".`,
2109
+ );
2110
+ }
2111
+ if (journey.manualTrigger !== undefined)
2112
+ validateHealthJourneyManualTrigger(
2113
+ providerId,
2114
+ journey.id,
2115
+ journey.manualTrigger,
2116
+ );
2117
+ if (journey.timeout !== undefined)
2118
+ assertIsoDuration(
2119
+ journey.timeout,
2120
+ `Provider "${providerId}" healthJourneys.${journey.id}.timeout`,
2121
+ );
2122
+ if (journey.cooldown !== undefined)
2123
+ assertIsoDuration(
2124
+ journey.cooldown,
2125
+ `Provider "${providerId}" healthJourneys.${journey.id}.cooldown`,
2126
+ );
2127
+ if (journey.run !== undefined && typeof journey.run !== "function") {
2128
+ throw new ValidationError(
2129
+ `Provider "${providerId}" healthJourneys.${journey.id}.run must be a function when present.`,
2130
+ );
2131
+ }
2132
+ if (journey.requiredSecrets !== undefined) {
2133
+ if (!Array.isArray(journey.requiredSecrets))
2134
+ throw new ValidationError(
2135
+ `Provider "${providerId}" healthJourneys.${journey.id}.requiredSecrets must be an array.`,
2136
+ );
2137
+ for (const secret of journey.requiredSecrets)
2138
+ if (typeof secret !== "string" || secret.length === 0)
2139
+ throw new ValidationError(
2140
+ `Provider "${providerId}" healthJourneys.${journey.id}.requiredSecrets entries must be non-empty strings.`,
2141
+ );
2142
+ }
2143
+ }
2144
+ return covered;
2145
+ }
2146
+
489
2147
  function validateOperationHealthChecks(
490
2148
  providerId: string,
491
2149
  operations: Record<string, ProviderOperation>,
2150
+ journeyCoveredOperations: ReadonlySet<string> = new Set(),
492
2151
  ): void {
493
2152
  for (const [operationName, operation] of Object.entries(operations)) {
494
2153
  const hasCheck = operation.healthCheck !== undefined;
@@ -512,7 +2171,11 @@ function validateOperationHealthChecks(
512
2171
  operationName,
513
2172
  operation.healthCheckUnsupported,
514
2173
  );
515
- if (!hasCheck && !hasUnsupported)
2174
+ if (
2175
+ !hasCheck &&
2176
+ !hasUnsupported &&
2177
+ !journeyCoveredOperations.has(operationName)
2178
+ )
516
2179
  throw new ValidationError(
517
2180
  `Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
518
2181
  {
@@ -569,13 +2232,14 @@ function validateOperationFixtures(
569
2232
 
570
2233
  export function defineProvider<
571
2234
  TOperations extends Record<string, ProviderOperation>,
2235
+ TConfig extends ProviderConfig<TOperations>,
572
2236
  >(
573
- config: ProviderConfig<TOperations>,
2237
+ config: TConfig & AuthStartNoInputGuard<TConfig>,
574
2238
  ): ProviderDefinition & { operations: OperationMapConfig<TOperations> } {
575
2239
  validateProviderShape(config);
576
2240
  if (!CONNECTOR_ID_REGEX.test(config.id))
577
2241
  throw new ProviderError(`Invalid provider id: "${config.id}"`, {
578
- fix: 'Use lowercase alphanumeric with dashes, e.g., "airkorea-realtime"',
2242
+ fix: 'Use lowercase alphanumeric with dashes, e.g., "korea-air-quality"',
579
2243
  });
580
2244
  if (Object.keys(config.operations).length === 0)
581
2245
  throw new ProviderError(
@@ -584,14 +2248,29 @@ export function defineProvider<
584
2248
  );
585
2249
  validateOperationIds(config.id, config.operations);
586
2250
  validateOperationAnnotations(config.id, config.operations);
587
- validateOperationHealthChecks(config.id, config.operations);
2251
+ validateOperationObservability(config.id, config.operations);
2252
+ validateOperationTransports(config.id, config.operations);
2253
+ validateOperationContracts(config.id, config.operations);
2254
+ validateToolRouterMetadata(config.id, config.operations);
2255
+ const journeyCoveredOperations = validateHealthJourneys(
2256
+ config.id,
2257
+ config.operations,
2258
+ config.healthJourneys,
2259
+ );
2260
+ validateOperationHealthChecks(
2261
+ config.id,
2262
+ config.operations,
2263
+ journeyCoveredOperations,
2264
+ );
588
2265
  validateProviderHealthMonitor(config.id, config.healthMonitor);
589
2266
  validateOperationFixtures(config.id, config.operations);
2267
+ validateProviderProxy(config);
2268
+ validateProviderStt(config);
590
2269
  if (config.runtime === "browser" && !config.browser)
591
2270
  throw new ProviderError(
592
2271
  `Provider "${config.id}" must define browser.engine when runtime is "browser"`,
593
2272
  {
594
- fix: 'Add browser: { engine: "nodriver" } or another supported engine',
2273
+ fix: 'Add browser: { engine: "playwright-stealth" } for TypeScript providers, or another supported engine for your runtime',
595
2274
  },
596
2275
  );
597
2276
  if (config.browser && config.runtime !== "browser")
@@ -599,10 +2278,6 @@ export function defineProvider<
599
2278
  `Provider "${config.id}" cannot define browser config unless runtime is "browser"`,
600
2279
  { fix: 'Set runtime: "browser" or remove the browser config' },
601
2280
  );
602
- if (config.proxy && !config.stealth)
603
- console.warn(
604
- `[provider-sdk] Provider "${config.id}" enables proxy without a stealth profile.`,
605
- );
606
2281
  return {
607
2282
  id: config.id,
608
2283
  version: config.version,
@@ -610,14 +2285,17 @@ export function defineProvider<
610
2285
  allowedHosts: config.allowedHosts,
611
2286
  stealth: config.stealth,
612
2287
  proxy: config.proxy,
2288
+ stt: config.stt,
613
2289
  browser: config.browser,
614
2290
  auth: config.auth,
615
2291
  reviewed: config.reviewed,
2292
+ access: config.access,
616
2293
  secrets: config.secrets,
617
2294
  credential: config.credential,
618
2295
  context: config.context,
619
2296
  meta: config.meta,
620
2297
  operations: config.operations,
621
2298
  healthMonitor: config.healthMonitor,
2299
+ healthJourneys: config.healthJourneys,
622
2300
  };
623
2301
  }