@apifuse/provider-sdk 2.1.0-beta.2 → 2.1.0-beta.21

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