@apifuse/provider-sdk 2.1.0-beta.9 → 2.2.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 (263) hide show
  1. package/AUTHORING.md +240 -0
  2. package/CHANGELOG.md +93 -0
  3. package/README.md +26 -10
  4. package/SUBMISSION.md +11 -12
  5. package/bin/apifuse-check.ts +44 -59
  6. package/bin/apifuse-create.ts +1 -1
  7. package/bin/apifuse-dev.ts +27 -52
  8. package/bin/apifuse-pack-check.ts +36 -0
  9. package/bin/apifuse-pack-smoke.ts +22 -81
  10. package/bin/apifuse-pack-types.ts +266 -0
  11. package/bin/apifuse-perf.ts +45 -127
  12. package/bin/apifuse-record.ts +53 -70
  13. package/bin/apifuse-submit-check.ts +2177 -353
  14. package/bin/apifuse-sync-assets.ts +117 -0
  15. package/bin/apifuse.ts +1 -1
  16. package/bin/submit-check-delimited-text.ts +50 -0
  17. package/bin/submit-check-xml-semantics.ts +204 -0
  18. package/bin/submit-check-xml.ts +134 -0
  19. package/dist/auth-turn/auth-turn.v1.schema.json +89 -0
  20. package/dist/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  21. package/dist/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  22. package/dist/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  23. package/dist/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  24. package/dist/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  25. package/dist/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  26. package/dist/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  27. package/dist/auth-turn/fixtures/valid/abort.json +8 -0
  28. package/dist/auth-turn/fixtures/valid/challenge.json +17 -0
  29. package/dist/auth-turn/fixtures/valid/complete.json +13 -0
  30. package/dist/auth-turn/fixtures/valid/form.json +14 -0
  31. package/dist/auth-turn/fixtures/valid/message.json +13 -0
  32. package/dist/auth-turn/fixtures/valid/multi_choice.json +15 -0
  33. package/dist/auth-turn/fixtures/valid/pending.json +5 -0
  34. package/dist/auth-turn/fixtures/valid/poll.json +9 -0
  35. package/dist/auth-turn/fixtures/valid/redirect.json +16 -0
  36. package/dist/auth-turn/fixtures/valid/retry.json +8 -0
  37. package/dist/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  38. package/dist/auth-turn/index.d.ts +195 -0
  39. package/dist/auth-turn/index.js +133 -0
  40. package/dist/auth.d.ts +76 -0
  41. package/dist/auth.js +427 -0
  42. package/dist/ceremonies/index.d.ts +1 -1
  43. package/dist/ceremonies/index.js +14 -48
  44. package/dist/cli/commands.d.ts +1 -1
  45. package/dist/cli/commands.js +8 -0
  46. package/dist/cli/create.d.ts +3 -0
  47. package/dist/cli/create.js +47 -33
  48. package/dist/cli/prompt-assets.d.ts +80 -0
  49. package/dist/cli/prompt-assets.js +743 -0
  50. package/dist/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  51. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  52. package/dist/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  53. package/dist/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  54. package/dist/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  55. package/dist/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  56. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  57. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  58. package/dist/cli/templates/provider/AGENTS.md.tpl +96 -0
  59. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  60. package/dist/cli/templates/provider/README.md.tpl +163 -0
  61. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  62. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  63. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  64. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  65. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  66. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  67. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  68. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  69. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  70. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  71. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  72. package/dist/config/loader.d.ts +149 -8
  73. package/dist/config/loader.js +378 -89
  74. package/dist/contract-serialization.d.ts +2 -2
  75. package/dist/contract-serialization.js +3 -6
  76. package/dist/contract-types.d.ts +2 -2
  77. package/dist/contract.d.ts +3 -3
  78. package/dist/contract.js +5 -6
  79. package/dist/define.d.ts +13 -1
  80. package/dist/define.js +245 -178
  81. package/dist/dev.d.ts +1 -1
  82. package/dist/dev.js +1 -1
  83. package/dist/errors.d.ts +4 -1
  84. package/dist/errors.js +48 -0
  85. package/dist/i18n/catalog.d.ts +2 -2
  86. package/dist/i18n/catalog.js +4 -10
  87. package/dist/i18n/index.d.ts +2 -2
  88. package/dist/i18n/index.js +2 -2
  89. package/dist/i18n/keys.d.ts +2 -2
  90. package/dist/index.d.ts +44 -41
  91. package/dist/index.js +39 -36
  92. package/dist/lint.d.ts +1 -0
  93. package/dist/lint.js +35 -15
  94. package/dist/provider.d.ts +11 -9
  95. package/dist/provider.js +9 -8
  96. package/dist/public-schema-field-lint.d.ts +1 -1
  97. package/dist/recipes/gov-api.js +1 -1
  98. package/dist/runtime/auth-flow.d.ts +1 -1
  99. package/dist/runtime/auth-flow.js +4 -2
  100. package/dist/runtime/browser.d.ts +1 -1
  101. package/dist/runtime/browser.js +214 -25
  102. package/dist/runtime/cache.d.ts +1 -1
  103. package/dist/runtime/cache.js +4 -8
  104. package/dist/runtime/choice.d.ts +1 -1
  105. package/dist/runtime/choice.js +31 -35
  106. package/dist/runtime/credential.d.ts +1 -1
  107. package/dist/runtime/credential.js +1 -1
  108. package/dist/runtime/env.d.ts +1 -1
  109. package/dist/runtime/executor.d.ts +1 -1
  110. package/dist/runtime/executor.js +15 -3
  111. package/dist/runtime/http.d.ts +2 -2
  112. package/dist/runtime/http.js +160 -344
  113. package/dist/runtime/insights.d.ts +1 -1
  114. package/dist/runtime/insights.js +6 -13
  115. package/dist/runtime/instrumentation.d.ts +2 -2
  116. package/dist/runtime/instrumentation.js +56 -19
  117. package/dist/runtime/keyring.js +1 -1
  118. package/dist/runtime/namespace.js +1 -1
  119. package/dist/runtime/otlp.d.ts +1 -1
  120. package/dist/runtime/perf.d.ts +1 -1
  121. package/dist/runtime/provider.d.ts +1 -1
  122. package/dist/runtime/provider.js +1 -2
  123. package/dist/runtime/proxy-errors.d.ts +1 -1
  124. package/dist/runtime/proxy-errors.js +9 -7
  125. package/dist/runtime/proxy-nodemaven.d.ts +35 -0
  126. package/dist/runtime/proxy-nodemaven.js +128 -0
  127. package/dist/runtime/proxy-retry-policy.d.ts +40 -0
  128. package/dist/runtime/proxy-retry-policy.js +326 -0
  129. package/dist/runtime/proxy-telemetry.d.ts +2 -1
  130. package/dist/runtime/proxy-telemetry.js +55 -52
  131. package/dist/runtime/redis.d.ts +1 -1
  132. package/dist/runtime/redis.js +2 -4
  133. package/dist/runtime/request-options.d.ts +1 -1
  134. package/dist/runtime/secrets.d.ts +27 -0
  135. package/dist/runtime/secrets.js +51 -0
  136. package/dist/runtime/state.d.ts +2 -2
  137. package/dist/runtime/state.js +15 -4
  138. package/dist/runtime/stealth.d.ts +7 -4
  139. package/dist/runtime/stealth.js +257 -215
  140. package/dist/runtime/stt.d.ts +1 -1
  141. package/dist/runtime/stt.js +11 -15
  142. package/dist/runtime/trace.d.ts +2 -2
  143. package/dist/runtime/trace.js +2 -4
  144. package/dist/runtime/waterfall.d.ts +1 -1
  145. package/dist/schema.d.ts +1 -1
  146. package/dist/schema.js +7 -15
  147. package/dist/serve.d.ts +1 -1
  148. package/dist/serve.js +1 -1
  149. package/dist/server/index.d.ts +7 -3
  150. package/dist/server/index.js +6 -2
  151. package/dist/server/self-test-input-tokens.d.ts +1 -0
  152. package/dist/server/self-test-input-tokens.js +37 -0
  153. package/dist/server/self-test-redaction.d.ts +20 -0
  154. package/dist/server/self-test-redaction.js +70 -0
  155. package/dist/server/self-test-token.d.ts +30 -0
  156. package/dist/server/self-test-token.js +50 -0
  157. package/dist/server/self-test.d.ts +199 -0
  158. package/dist/server/self-test.js +1113 -0
  159. package/dist/server/serve.d.ts +14 -3
  160. package/dist/server/serve.js +135 -64
  161. package/dist/server/types.d.ts +10 -9
  162. package/dist/server/types.js +3 -7
  163. package/dist/stealth/profiles.d.ts +1 -1
  164. package/dist/stealth/profiles.js +5 -14
  165. package/dist/stream.d.ts +1 -1
  166. package/dist/testing/index.d.ts +2 -2
  167. package/dist/testing/index.js +2 -2
  168. package/dist/testing/run.d.ts +1 -1
  169. package/dist/testing/run.js +12 -15
  170. package/dist/types.d.ts +237 -1
  171. package/dist/user-input.d.ts +30 -0
  172. package/dist/user-input.js +66 -0
  173. package/package.json +16 -5
  174. package/src/auth-turn/auth-turn.v1.schema.json +89 -0
  175. package/src/auth-turn/fixtures/invalid/empty-kind.json +4 -0
  176. package/src/auth-turn/fixtures/invalid/expires-at-not-string.json +5 -0
  177. package/src/auth-turn/fixtures/invalid/missing-kind.json +3 -0
  178. package/src/auth-turn/fixtures/invalid/missing-turn-id.json +3 -0
  179. package/src/auth-turn/fixtures/invalid/timing-unknown-field.json +7 -0
  180. package/src/auth-turn/fixtures/invalid/turn-id-snake-case.json +4 -0
  181. package/src/auth-turn/fixtures/invalid/unknown-top-level-field.json +5 -0
  182. package/src/auth-turn/fixtures/valid/abort.json +8 -0
  183. package/src/auth-turn/fixtures/valid/challenge.json +17 -0
  184. package/src/auth-turn/fixtures/valid/complete.json +13 -0
  185. package/src/auth-turn/fixtures/valid/form.json +14 -0
  186. package/src/auth-turn/fixtures/valid/message.json +13 -0
  187. package/src/auth-turn/fixtures/valid/multi_choice.json +15 -0
  188. package/src/auth-turn/fixtures/valid/pending.json +5 -0
  189. package/src/auth-turn/fixtures/valid/poll.json +9 -0
  190. package/src/auth-turn/fixtures/valid/redirect.json +16 -0
  191. package/src/auth-turn/fixtures/valid/retry.json +8 -0
  192. package/src/auth-turn/fixtures/valid/unknown-kind.json +7 -0
  193. package/src/auth-turn/index.ts +177 -0
  194. package/src/auth.ts +728 -0
  195. package/src/ceremonies/index.ts +33 -121
  196. package/src/cli/commands.ts +10 -0
  197. package/src/cli/create.ts +69 -99
  198. package/src/cli/prompt-assets.ts +865 -0
  199. package/src/cli/templates/provider/.agents/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
  200. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
  201. package/src/cli/templates/provider/.agents/skills/normalization-standards/SKILL.md.tpl +57 -0
  202. package/src/cli/templates/provider/.agents/skills/pagination-and-counts/SKILL.md.tpl +52 -0
  203. package/src/cli/templates/provider/.agents/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
  204. package/src/cli/templates/provider/.agents/skills/upstream-notes/README.md.tpl +13 -0
  205. package/src/cli/templates/provider/AGENTS.md.tpl +96 -0
  206. package/src/cli/templates/provider/README.md.tpl +7 -4
  207. package/src/config/loader.ts +543 -208
  208. package/src/contract-serialization.ts +5 -11
  209. package/src/contract-types.ts +2 -2
  210. package/src/contract.ts +13 -28
  211. package/src/define.ts +397 -528
  212. package/src/dev.ts +4 -9
  213. package/src/errors.ts +58 -5
  214. package/src/i18n/catalog.ts +10 -32
  215. package/src/i18n/index.ts +2 -2
  216. package/src/i18n/keys.ts +5 -11
  217. package/src/index.ts +64 -41
  218. package/src/lint.ts +122 -159
  219. package/src/provider.ts +40 -9
  220. package/src/public-schema-field-lint.ts +7 -33
  221. package/src/recipes/gov-api.ts +2 -5
  222. package/src/runtime/auth-flow.ts +6 -6
  223. package/src/runtime/browser.ts +320 -151
  224. package/src/runtime/cache.ts +20 -67
  225. package/src/runtime/choice.ts +79 -132
  226. package/src/runtime/credential.ts +2 -2
  227. package/src/runtime/env.ts +1 -1
  228. package/src/runtime/executor.ts +23 -20
  229. package/src/runtime/http.ts +216 -539
  230. package/src/runtime/insights.ts +15 -53
  231. package/src/runtime/instrumentation.ts +78 -65
  232. package/src/runtime/keyring.ts +7 -19
  233. package/src/runtime/namespace.ts +2 -7
  234. package/src/runtime/otlp.ts +12 -23
  235. package/src/runtime/perf.ts +1 -1
  236. package/src/runtime/provider.ts +4 -9
  237. package/src/runtime/proxy-errors.ts +29 -42
  238. package/src/runtime/proxy-nodemaven.ts +178 -0
  239. package/src/runtime/proxy-retry-policy.ts +469 -0
  240. package/src/runtime/proxy-telemetry.ts +79 -77
  241. package/src/runtime/redis.ts +4 -12
  242. package/src/runtime/request-options.ts +4 -13
  243. package/src/runtime/secrets.ts +64 -0
  244. package/src/runtime/state.ts +41 -110
  245. package/src/runtime/stealth.ts +331 -369
  246. package/src/runtime/stt.ts +38 -94
  247. package/src/runtime/trace.ts +14 -44
  248. package/src/runtime/waterfall.ts +5 -18
  249. package/src/schema.ts +23 -84
  250. package/src/serve.ts +1 -1
  251. package/src/server/index.ts +44 -3
  252. package/src/server/self-test-input-tokens.ts +46 -0
  253. package/src/server/self-test-redaction.ts +97 -0
  254. package/src/server/self-test-token.ts +70 -0
  255. package/src/server/self-test.ts +1450 -0
  256. package/src/server/serve.ts +206 -216
  257. package/src/server/types.ts +7 -19
  258. package/src/stealth/profiles.ts +10 -26
  259. package/src/stream.ts +8 -19
  260. package/src/testing/index.ts +2 -2
  261. package/src/testing/run.ts +24 -64
  262. package/src/types.ts +274 -1
  263. package/src/user-input.ts +118 -0
package/src/define.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { ProviderError, ValidationError } from "./errors";
2
- import { safeParseSchemaSync } from "./schema";
1
+ import ms from "ms";
2
+
3
+ import { ProviderError, ValidationError } from "./errors.js";
4
+ import { safeParseSchemaSync } from "./schema.js";
3
5
  import type {
4
6
  AuthConfig,
5
7
  BrowserEngine,
@@ -10,6 +12,7 @@ import type {
10
12
  HealthCheckUnsupported,
11
13
  HealthJourneyDefinition,
12
14
  HealthJourneySchedule,
15
+ HealthScheduleRandomization,
13
16
  InferSchemaOutput,
14
17
  OperationDefinition,
15
18
  OperationHandlerResult,
@@ -19,8 +22,10 @@ import type {
19
22
  OperationWebSocketTransport,
20
23
  ProviderAccessConfig,
21
24
  ProviderDefinition,
25
+ ProviderDeploymentOverrides,
22
26
  ProviderHealthMonitorConfig,
23
27
  ProviderProxyConfig,
28
+ ProviderProxyProvider,
24
29
  ProviderPublicProfile,
25
30
  ProviderReviewed,
26
31
  ProviderSecretDeclaration,
@@ -29,7 +34,7 @@ import type {
29
34
  SchemaLike,
30
35
  SmsOtpMatcherDefinition,
31
36
  StealthPlatform,
32
- } from "./types";
37
+ } from "./types.js";
33
38
  import {
34
39
  HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
35
40
  HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
@@ -45,7 +50,7 @@ import {
45
50
  STREAM_IDLE_TIMEOUT_MS_MIN,
46
51
  STREAM_MAX_DURATION_MS_MAX,
47
52
  STREAM_MAX_DURATION_MS_MIN,
48
- } from "./types";
53
+ } from "./types.js";
49
54
 
50
55
  type ProviderImplementationSourceAccess =
51
56
  | "official_api"
@@ -71,23 +76,10 @@ interface ProviderImplementationProfile {
71
76
  const CONNECTOR_ID_REGEX = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$/;
72
77
  const OPERATION_ID_REGEX = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
73
78
  const VALID_RUNTIMES = ["standard", "shared", "browser"] as const;
74
- const VALID_AUTH_MODES = [
75
- "none",
76
- "platform-managed",
77
- "credentials",
78
- "oauth2",
79
- ] as const;
79
+ const VALID_AUTH_MODES = ["none", "platform-managed", "credentials", "oauth2"] as const;
80
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;
81
+ const VALID_PROVIDER_PROXY_MODES = ["disabled", "optional", "required"] as const;
82
+ const VALID_PROVIDER_PROXY_PROVIDERS = ["smartproxy", "nodemaven", "decodo", "custom"] as const;
91
83
  const VALID_PROVIDER_PROXY_AFFINITIES = [
92
84
  "request",
93
85
  "operation",
@@ -96,78 +88,52 @@ const VALID_PROVIDER_PROXY_AFFINITIES = [
96
88
  ] as const;
97
89
  const VALID_PROVIDER_STT_MODES = ["optional", "required"] as const;
98
90
  const SMARTPROXY_APP_KEY_SECRET = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
91
+ const NODEMAVEN_USERNAME_SECRET = "APIFUSE__PROXY__NODEMAVEN_USERNAME";
92
+ const NODEMAVEN_PASSWORD_SECRET = "APIFUSE__PROXY__NODEMAVEN_PASSWORD";
93
+ // Per-vendor provider-declared credential secrets. A required-mode chain must
94
+ // declare every secret of every credentialed vendor it names, so a missing
95
+ // credential fails at build/validation time rather than during a live outage: a
96
+ // declared-but-uncredentialed fallback leg is a silently dead SPOF, which is
97
+ // exactly the failure class the multi-vendor chain exists to remove. Vendors
98
+ // absent from this map (e.g. `custom`/`decodo`, whose credentials come from the
99
+ // `APIFUSE__PROXY__URL` bring-your-own escape hatch, not provider secrets) impose
100
+ // no declaration requirement.
101
+ const VENDOR_REQUIRED_SECRETS: Partial<Record<ProviderProxyProvider, readonly string[]>> = {
102
+ smartproxy: [SMARTPROXY_APP_KEY_SECRET],
103
+ nodemaven: [NODEMAVEN_USERNAME_SECRET, NODEMAVEN_PASSWORD_SECRET],
104
+ };
99
105
  const RESERVED_OPERATION_IDS = new Set(["auth", "health"]);
100
106
  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;
107
+ const VALID_OPERATION_RISK_CLASSES = ["read", "write", "destructive", "external-send"] as const;
108
+ const VALID_OPERATION_APPROVAL_POLICIES = ["never", "risk-based", "always"] as const;
109
+ const VALID_OPERATION_TRANSPORT_KINDS = ["json", "sse", "http-stream", "websocket"] as const;
118
110
  const SSE_EVENT_NAME_REGEX = /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/;
119
111
  const WEBSOCKET_SUBPROTOCOL_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
120
112
 
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
113
  const MS_DURATION_PATTERN = /^([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*([a-zA-Z]+)?$/;
155
114
 
156
115
  function isPositiveMsDurationString(value: unknown): value is string {
157
116
  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);
117
+ return parsePositiveMsDuration(value) !== undefined;
118
+ }
119
+
120
+ function msDurationMs(value: string): number {
121
+ return parsePositiveMsDuration(value) ?? 0;
122
+ }
123
+
124
+ function parsePositiveMsDuration(value: string): number | undefined {
125
+ const trimmed = value.trim();
126
+ if (!MS_DURATION_PATTERN.test(trimmed)) return undefined;
127
+ const parsed = ms((trimmed.startsWith("+") ? trimmed.slice(1) : trimmed) as ms.StringValue);
128
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
129
+ return parsed;
164
130
  }
165
131
 
166
132
  type ProviderOperation = OperationDefinition<SchemaLike, SchemaLike>;
167
- type OperationConfig<
168
- TInput extends SchemaLike,
169
- TOutput extends SchemaLike,
170
- > = Omit<OperationDefinition<TInput, TOutput>, "handler"> & {
133
+ type OperationConfig<TInput extends SchemaLike, TOutput extends SchemaLike> = Omit<
134
+ OperationDefinition<TInput, TOutput>,
135
+ "handler"
136
+ > & {
171
137
  handler(
172
138
  ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
173
139
  input: InferSchemaOutput<TInput>,
@@ -175,59 +141,44 @@ type OperationConfig<
175
141
  | OperationHandlerResult<InferSchemaOutput<TOutput>>
176
142
  | Promise<OperationHandlerResult<InferSchemaOutput<TOutput>>>;
177
143
  };
178
- type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> =
179
- {
180
- [K in keyof TOperations]: TOperations[K] extends OperationDefinition<
181
- infer TInput,
182
- infer TOutput
183
- >
184
- ? OperationConfig<TInput, TOutput> | OperationDefinition<TInput, TOutput>
185
- : never;
186
- };
187
- type StreamOperationConfig<
188
- TInput extends SchemaLike,
189
- TOutput extends SchemaLike,
190
- > =
144
+ type OperationMapConfig<TOperations extends Record<string, ProviderOperation>> = {
145
+ [K in keyof TOperations]: TOperations[K] extends OperationDefinition<infer TInput, infer TOutput>
146
+ ? OperationConfig<TInput, TOutput> | OperationDefinition<TInput, TOutput>
147
+ : never;
148
+ };
149
+ type StreamOperationConfig<TInput extends SchemaLike, TOutput extends SchemaLike> =
191
150
  | SseOperationConfig<TInput, TOutput>
192
151
  | HttpStreamOperationConfig<TInput, TOutput>
193
152
  | WebSocketOperationConfig<TInput, TOutput>;
194
- type SseOperationConfig<
195
- TInput extends SchemaLike,
196
- TOutput extends SchemaLike,
197
- > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
153
+ type SseOperationConfig<TInput extends SchemaLike, TOutput extends SchemaLike> = Omit<
154
+ OperationConfig<TInput, TOutput>,
155
+ "handler" | "transport"
156
+ > & {
198
157
  transport: OperationSseTransport;
199
158
  handler(
200
159
  ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
201
160
  input: InferSchemaOutput<TInput>,
202
- ):
203
- | AsyncIterable<ProviderStreamEvent>
204
- | Promise<AsyncIterable<ProviderStreamEvent>>;
161
+ ): AsyncIterable<ProviderStreamEvent> | Promise<AsyncIterable<ProviderStreamEvent>>;
205
162
  };
206
- type HttpStreamOperationConfig<
207
- TInput extends SchemaLike,
208
- TOutput extends SchemaLike,
209
- > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
163
+ type HttpStreamOperationConfig<TInput extends SchemaLike, TOutput extends SchemaLike> = Omit<
164
+ OperationConfig<TInput, TOutput>,
165
+ "handler" | "transport"
166
+ > & {
210
167
  transport: OperationHttpStreamTransport;
211
168
  handler(
212
169
  ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
213
170
  input: InferSchemaOutput<TInput>,
214
- ):
215
- | Response
216
- | ReadableStream<Uint8Array>
217
- | Promise<Response | ReadableStream<Uint8Array>>;
171
+ ): Response | ReadableStream<Uint8Array> | Promise<Response | ReadableStream<Uint8Array>>;
218
172
  };
219
- type WebSocketOperationConfig<
220
- TInput extends SchemaLike,
221
- TOutput extends SchemaLike,
222
- > = Omit<OperationConfig<TInput, TOutput>, "handler" | "transport"> & {
173
+ type WebSocketOperationConfig<TInput extends SchemaLike, TOutput extends SchemaLike> = Omit<
174
+ OperationConfig<TInput, TOutput>,
175
+ "handler" | "transport"
176
+ > & {
223
177
  transport: OperationWebSocketTransport;
224
178
  handler(
225
179
  ctx: Parameters<OperationDefinition<TInput, TOutput>["handler"]>[0],
226
180
  input: InferSchemaOutput<TInput>,
227
- ):
228
- | Response
229
- | ReadableStream<Uint8Array>
230
- | Promise<Response | ReadableStream<Uint8Array>>;
181
+ ): Response | ReadableStream<Uint8Array> | Promise<Response | ReadableStream<Uint8Array>>;
231
182
  };
232
183
 
233
184
  type AuthStartNoInputGuard<TConfig> = TConfig extends {
@@ -242,12 +193,17 @@ type AuthStartNoInputGuard<TConfig> = TConfig extends {
242
193
  : unknown
243
194
  : unknown;
244
195
 
245
- export interface ProviderConfig<
246
- TOperations extends Record<string, ProviderOperation>,
247
- > {
196
+ export interface ProviderConfig<TOperations extends Record<string, ProviderOperation>> {
248
197
  id: string;
249
198
  version: string;
250
199
  runtime: "standard" | "shared" | "browser";
200
+ /**
201
+ * Optional deployment overrides, passed through verbatim onto the returned
202
+ * provider definition. The SDK types this field but does not deep-validate
203
+ * it — the APIFuse registry builder owns deployment validation and
204
+ * resolves omitted fields against the runtime deployment profiles.
205
+ */
206
+ deployment?: ProviderDeploymentOverrides;
251
207
  allowedHosts?: string[];
252
208
  stealth?: {
253
209
  profile: string;
@@ -285,39 +241,30 @@ export interface ProviderConfig<
285
241
  };
286
242
  operations: OperationMapConfig<TOperations>;
287
243
  healthMonitor?: ProviderHealthMonitorConfig;
244
+ /** New name for `healthMonitor` (transitional alias); declaring both is a ValidationError. */
245
+ healthProbe?: ProviderHealthMonitorConfig;
288
246
  healthJourneys?: readonly HealthJourneyDefinition[];
289
247
  }
290
248
 
291
249
  /** Define one provider operation with schema-driven handler inference. */
292
- export function defineOperation<
293
- TInput extends SchemaLike,
294
- TOutput extends SchemaLike,
295
- >(
250
+ export function defineOperation<TInput extends SchemaLike, TOutput extends SchemaLike>(
296
251
  operation: OperationConfig<TInput, TOutput>,
297
252
  ): OperationDefinition<TInput, TOutput> {
298
253
  return operation;
299
254
  }
300
255
 
301
256
  /** Define a non-JSON provider operation with explicit transport metadata. */
302
- export function defineStreamOperation<
303
- TInput extends SchemaLike,
304
- TOutput extends SchemaLike,
305
- >(
257
+ export function defineStreamOperation<TInput extends SchemaLike, TOutput extends SchemaLike>(
306
258
  operation: StreamOperationConfig<TInput, TOutput>,
307
259
  ): OperationDefinition<TInput, TOutput> {
308
260
  return operation;
309
261
  }
310
262
 
311
- function assertObjectConfig(
312
- value: unknown,
313
- ): asserts value is Record<string, unknown> {
263
+ function assertObjectConfig(value: unknown): asserts value is Record<string, unknown> {
314
264
  if (!value || typeof value !== "object") {
315
- throw new ProviderError(
316
- "defineProvider config must be an object. Offending field: config",
317
- {
318
- fix: "Pass defineProvider({ id, version, runtime, meta, operations })",
319
- },
320
- );
265
+ throw new ProviderError("defineProvider config must be an object. Offending field: config", {
266
+ fix: "Pass defineProvider({ id, version, runtime, meta, operations })",
267
+ });
321
268
  }
322
269
  }
323
270
  function assertRequiredField(
@@ -355,25 +302,18 @@ function validateProviderShape(config: unknown): void {
355
302
  assertRequiredField(config, "meta", String(config.id));
356
303
  assertRequiredField(config, "operations", String(config.id));
357
304
  if (typeof config.runtime === "string")
358
- assertLiteralField(
359
- config.runtime,
360
- "runtime",
361
- VALID_RUNTIMES,
362
- String(config.id),
363
- );
305
+ assertLiteralField(config.runtime, "runtime", VALID_RUNTIMES, String(config.id));
364
306
  const auth = config.auth;
365
- if (
366
- auth &&
367
- typeof auth === "object" &&
368
- "mode" in auth &&
369
- typeof auth.mode === "string"
370
- )
371
- assertLiteralField(
372
- auth.mode,
373
- "auth.mode",
374
- VALID_AUTH_MODES,
375
- String(config.id),
307
+ if (auth && typeof auth === "object" && "mode" in auth && typeof auth.mode === "string")
308
+ assertLiteralField(auth.mode, "auth.mode", VALID_AUTH_MODES, String(config.id));
309
+ if (auth && typeof auth === "object" && "exchange" in auth) {
310
+ throw new ProviderError(
311
+ `Provider "${String(config.id)}" auth.exchange is not part of the Provider SDK auth contract`,
312
+ {
313
+ 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.",
314
+ },
376
315
  );
316
+ }
377
317
  if (
378
318
  auth &&
379
319
  typeof auth === "object" &&
@@ -401,9 +341,7 @@ function validateProviderShape(config: unknown): void {
401
341
  },
402
342
  );
403
343
  }
404
- const accessRecord: Record<string, unknown> = Object.fromEntries(
405
- Object.entries(access),
406
- );
344
+ const accessRecord: Record<string, unknown> = Object.fromEntries(Object.entries(access));
407
345
  for (const key of Object.keys(accessRecord)) {
408
346
  if (key !== "visibility") {
409
347
  throw new ValidationError(`Unknown field "${key}" on access.`, {
@@ -448,31 +386,26 @@ function validateProviderProxy(config: {
448
386
  },
449
387
  );
450
388
  }
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
- );
389
+ rejectUnknownFields(proxy, new Set(["mode", "provider", "providers", "geo", "session"]), "proxy");
390
+ assertLiteralField(proxy.mode, "proxy.mode", VALID_PROVIDER_PROXY_MODES, config.id);
462
391
  if (proxy.provider !== undefined) {
463
- assertLiteralField(
464
- proxy.provider,
465
- "proxy.provider",
466
- VALID_PROVIDER_PROXY_PROVIDERS,
467
- config.id,
468
- );
392
+ assertLiteralField(proxy.provider, "proxy.provider", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
393
+ }
394
+ if (proxy.providers !== undefined) {
395
+ if (!Array.isArray(proxy.providers) || proxy.providers.length === 0) {
396
+ throw new ValidationError(
397
+ `Provider "${config.id}" has invalid proxy.providers: must be a non-empty array of proxy vendors.`,
398
+ {
399
+ fix: `Use proxy.providers: ["smartproxy", "nodemaven"] to declare an ordered fallback chain.`,
400
+ },
401
+ );
402
+ }
403
+ for (const vendor of proxy.providers) {
404
+ assertLiteralField(vendor, "proxy.providers[]", VALID_PROVIDER_PROXY_PROVIDERS, config.id);
405
+ }
469
406
  }
470
407
  if (proxy.geo !== undefined) {
471
- if (
472
- !proxy.geo ||
473
- typeof proxy.geo !== "object" ||
474
- Array.isArray(proxy.geo)
475
- ) {
408
+ if (!proxy.geo || typeof proxy.geo !== "object" || Array.isArray(proxy.geo)) {
476
409
  throw new ValidationError(
477
410
  `Provider "${config.id}" has invalid proxy.geo: must be an object.`,
478
411
  {
@@ -480,11 +413,7 @@ function validateProviderProxy(config: {
480
413
  },
481
414
  );
482
415
  }
483
- rejectUnknownFields(
484
- proxy.geo,
485
- new Set(["country", "subdivision", "city"]),
486
- "proxy.geo",
487
- );
416
+ rejectUnknownFields(proxy.geo, new Set(["country", "subdivision", "city"]), "proxy.geo");
488
417
  if (proxy.geo.country !== undefined) {
489
418
  assertIsoCountry(proxy.geo.country, "proxy.geo.country");
490
419
  }
@@ -498,11 +427,7 @@ function validateProviderProxy(config: {
498
427
  }
499
428
  }
500
429
  if (proxy.session !== undefined) {
501
- if (
502
- !proxy.session ||
503
- typeof proxy.session !== "object" ||
504
- Array.isArray(proxy.session)
505
- ) {
430
+ if (!proxy.session || typeof proxy.session !== "object" || Array.isArray(proxy.session)) {
506
431
  throw new ValidationError(
507
432
  `Provider "${config.id}" has invalid proxy.session: must be an object.`,
508
433
  {
@@ -524,51 +449,75 @@ function validateProviderProxy(config: {
524
449
  );
525
450
  }
526
451
  const lifetime = proxy.session.lifetimeMinutes;
527
- if (
528
- lifetime !== undefined &&
529
- (!Number.isFinite(lifetime) || lifetime <= 0)
530
- ) {
452
+ if (lifetime !== undefined && (!Number.isFinite(lifetime) || lifetime <= 0)) {
531
453
  throw new ValidationError(
532
454
  `Provider "${config.id}" has invalid proxy.session.lifetimeMinutes: must be a positive number of minutes.`,
533
455
  );
534
456
  }
535
457
  const poolSize = proxy.session.poolSize;
536
- if (
537
- poolSize !== undefined &&
538
- (!Number.isInteger(poolSize) || poolSize <= 0)
539
- ) {
458
+ if (poolSize !== undefined && (!Number.isInteger(poolSize) || poolSize <= 0)) {
540
459
  throw new ValidationError(
541
460
  `Provider "${config.id}" has invalid proxy.session.poolSize: must be a positive integer.`,
542
461
  );
543
462
  }
544
463
  }
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
- );
464
+ // Every credentialed vendor in a required-mode chain must declare its
465
+ // provider secret(s) so a missing credential fails at build/validation time,
466
+ // not during a live outage. This covers the fallback legs too (not just the
467
+ // first vendor): a declared-but-uncredentialed nodemaven fallback would leave
468
+ // the chain silently down to a single vendor, reintroducing the SPOF the chain
469
+ // removes.
470
+ const vendorChain =
471
+ proxy.providers && proxy.providers.length > 0
472
+ ? proxy.providers
473
+ : proxy.provider
474
+ ? [proxy.provider]
475
+ : [];
476
+ if (proxy.mode === "required") {
477
+ for (const vendor of vendorChain) {
478
+ const requiredSecrets = VENDOR_REQUIRED_SECRETS[vendor];
479
+ if (!requiredSecrets) continue;
480
+ for (const secretName of requiredSecrets) {
481
+ // Match the canonical runtime gate (assertRequiredSecretsPresent /
482
+ // listMissingRequiredSecrets), which enforces only `required === true`
483
+ // declarations. A declaration that omits `required` (defaulting to
484
+ // optional) is skipped at runtime, so accepting it here would pass
485
+ // validation while leaving the credential unenforced until proxy
486
+ // resolution during a live request — the fail-open gap this check exists
487
+ // to close.
488
+ const declared = config.secrets?.some(
489
+ (secret) => secret.name === secretName && secret.required === true,
490
+ );
491
+ if (!declared) {
492
+ throw new ValidationError(
493
+ `Provider "${config.id}" requires ${vendor} egress but does not declare ${secretName}.`,
494
+ {
495
+ fix: `Add secrets: [{ name: "${secretName}", required: true }] to the provider (every vendor in a required proxy chain must declare its credential secrets).`,
496
+ },
497
+ );
498
+ }
499
+ }
557
500
  }
558
501
  }
502
+ // `decodo`/`custom` are deprecated vendor values (string-union members, so the
503
+ // @deprecated symbol gate can't catch them — warn at validation time instead).
504
+ const deprecatedVendors = vendorChain.filter(
505
+ (vendor) => vendor === "decodo" || vendor === "custom",
506
+ );
507
+ if (deprecatedVendors.length > 0) {
508
+ console.warn(
509
+ `[provider-sdk] Provider "${config.id}" uses deprecated proxy vendor(s): ${deprecatedVendors.join(", ")}. Use "smartproxy"/"nodemaven", or the APIFUSE__PROXY__URL bring-your-own escape hatch.`,
510
+ );
511
+ }
559
512
  }
560
513
 
561
- function validateProviderStt(config: {
562
- id: string;
563
- stt?: ProviderSttConfig;
564
- }): void {
514
+ function validateProviderStt(config: { id: string; stt?: ProviderSttConfig }): void {
565
515
  const stt = config.stt;
566
516
  if (stt === undefined) return;
567
517
  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
- );
518
+ throw new ValidationError(`Provider "${config.id}" has invalid stt: must be an object.`, {
519
+ fix: `Use stt: { mode: "required" } or stt: { mode: "optional" }.`,
520
+ });
572
521
  }
573
522
  rejectUnknownFields(stt, new Set(["mode"]), "stt");
574
523
  assertLiteralField(stt.mode, "stt.mode", VALID_PROVIDER_STT_MODES, config.id);
@@ -595,16 +544,9 @@ function validateOperationIds(
595
544
  );
596
545
  }
597
546
  }
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;
547
+ const OPERATION_CONTRACT_VERSION_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
548
+ const OPERATION_SENSITIVE_PATH_REGEX = /^(?:[A-Za-z0-9_$-]+|\*)(?:\.(?:[A-Za-z0-9_$-]+|\*))*$/;
549
+ const VALID_OPERATION_LIFECYCLES = ["stable", "beta", "deprecated", "removed"] as const;
608
550
 
609
551
  function assertNonEmptyString(
610
552
  value: unknown,
@@ -635,10 +577,7 @@ function validateToolRouterMetadata(
635
577
  },
636
578
  );
637
579
  }
638
- if (
639
- toolRouter.name !== undefined &&
640
- !MCP_TOOL_NAME_REGEX.test(toolRouter.name)
641
- ) {
580
+ if (toolRouter.name !== undefined && !MCP_TOOL_NAME_REGEX.test(toolRouter.name)) {
642
581
  throw new ValidationError(
643
582
  `Provider "${providerId}" operation "${operationName}" has invalid operations.${operationName}.toolRouter.name: expected an MCP-safe name.`,
644
583
  {
@@ -710,10 +649,7 @@ function validateOperationContracts(
710
649
  providerId,
711
650
  );
712
651
  }
713
- if (
714
- contract.lifecycle === "deprecated" ||
715
- contract.lifecycle === "removed"
716
- ) {
652
+ if (contract.lifecycle === "deprecated" || contract.lifecycle === "removed") {
717
653
  if (!contract.deprecation || typeof contract.deprecation !== "object") {
718
654
  throw new ValidationError(
719
655
  `Provider "${providerId}" operation "${operationName}" is ${contract.lifecycle} but lacks operations.${operationName}.contract.deprecation metadata.`,
@@ -761,10 +697,7 @@ function validateOperationAnnotations(
761
697
  fix: `Set ${field} to an integer in [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] (milliseconds).`,
762
698
  },
763
699
  );
764
- if (
765
- timeoutMs < OPERATION_TIMEOUT_MS_MIN ||
766
- timeoutMs > OPERATION_TIMEOUT_MS_MAX
767
- )
700
+ if (timeoutMs < OPERATION_TIMEOUT_MS_MIN || timeoutMs > OPERATION_TIMEOUT_MS_MAX)
768
701
  throw new ValidationError(
769
702
  `Provider "${providerId}" has invalid ${field}: ${timeoutMs} is outside [${OPERATION_TIMEOUT_MS_MIN}, ${OPERATION_TIMEOUT_MS_MAX}] ms.`,
770
703
  {
@@ -962,12 +895,7 @@ function validateOperationTransports(
962
895
  },
963
896
  );
964
897
  }
965
- assertLiteralField(
966
- kind,
967
- `${fieldPath}.kind`,
968
- VALID_OPERATION_TRANSPORT_KINDS,
969
- providerId,
970
- );
898
+ assertLiteralField(kind, `${fieldPath}.kind`, VALID_OPERATION_TRANSPORT_KINDS, providerId);
971
899
 
972
900
  switch (kind) {
973
901
  case "json":
@@ -999,16 +927,9 @@ function validateOperationTransports(
999
927
  STREAM_MAX_DURATION_MS_MAX,
1000
928
  "max duration",
1001
929
  );
1002
- assertPositiveBytes(
1003
- Reflect.get(transport, "maxEventBytes"),
1004
- `${fieldPath}.maxEventBytes`,
1005
- );
930
+ assertPositiveBytes(Reflect.get(transport, "maxEventBytes"), `${fieldPath}.maxEventBytes`);
1006
931
  const resumable = Reflect.get(transport, "resumable");
1007
- if (
1008
- resumable !== undefined &&
1009
- resumable !== false &&
1010
- resumable !== "last-event-id"
1011
- ) {
932
+ if (resumable !== undefined && resumable !== false && resumable !== "last-event-id") {
1012
933
  throw new ValidationError(
1013
934
  `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.resumable: expected false or "last-event-id".`,
1014
935
  {
@@ -1028,12 +949,7 @@ function validateOperationTransports(
1028
949
  rejectUnknownFields(transport, HTTP_STREAM_TRANSPORT_FIELDS, fieldPath);
1029
950
  const contentType = Reflect.get(transport, "contentType");
1030
951
  if (contentType !== undefined) {
1031
- assertNonEmptyString(
1032
- contentType,
1033
- `${fieldPath}.contentType`,
1034
- providerId,
1035
- operationName,
1036
- );
952
+ assertNonEmptyString(contentType, `${fieldPath}.contentType`, providerId, operationName);
1037
953
  }
1038
954
  assertStreamMs(
1039
955
  Reflect.get(transport, "idleTimeoutMs"),
@@ -1049,10 +965,7 @@ function validateOperationTransports(
1049
965
  STREAM_MAX_DURATION_MS_MAX,
1050
966
  "max duration",
1051
967
  );
1052
- assertPositiveBytes(
1053
- Reflect.get(transport, "maxChunkBytes"),
1054
- `${fieldPath}.maxChunkBytes`,
1055
- );
968
+ assertPositiveBytes(Reflect.get(transport, "maxChunkBytes"), `${fieldPath}.maxChunkBytes`);
1056
969
  break;
1057
970
  }
1058
971
  case "websocket": {
@@ -1077,10 +990,7 @@ function validateOperationTransports(
1077
990
  );
1078
991
  }
1079
992
  for (const subprotocol of subprotocols) {
1080
- if (
1081
- typeof subprotocol !== "string" ||
1082
- !WEBSOCKET_SUBPROTOCOL_REGEX.test(subprotocol)
1083
- ) {
993
+ if (typeof subprotocol !== "string" || !WEBSOCKET_SUBPROTOCOL_REGEX.test(subprotocol)) {
1084
994
  throw new ValidationError(
1085
995
  `Provider "${providerId}" operation "${operationName}" has invalid ${fieldPath}.subprotocols: each subprotocol must be an RFC token string.`,
1086
996
  {
@@ -1104,10 +1014,7 @@ function validateOperationTransports(
1104
1014
  STREAM_MAX_DURATION_MS_MAX,
1105
1015
  "max duration",
1106
1016
  );
1107
- assertPositiveBytes(
1108
- Reflect.get(transport, "maxFrameBytes"),
1109
- `${fieldPath}.maxFrameBytes`,
1110
- );
1017
+ assertPositiveBytes(Reflect.get(transport, "maxFrameBytes"), `${fieldPath}.maxFrameBytes`);
1111
1018
  break;
1112
1019
  }
1113
1020
  }
@@ -1116,6 +1023,7 @@ function validateOperationTransports(
1116
1023
 
1117
1024
  const HEALTH_CHECK_SUITE_FIELDS = new Set([
1118
1025
  "interval",
1026
+ "schedule",
1119
1027
  "timeoutMs",
1120
1028
  "degradedThresholdMs",
1121
1029
  "cases",
@@ -1169,10 +1077,7 @@ function levenshtein(a: string, b: string): number {
1169
1077
  return prev[n] ?? 0;
1170
1078
  }
1171
1079
 
1172
- function suggestField(
1173
- unknown: string,
1174
- candidates: ReadonlySet<string>,
1175
- ): string | undefined {
1080
+ function suggestField(unknown: string, candidates: ReadonlySet<string>): string | undefined {
1176
1081
  let best: string | undefined;
1177
1082
  let bestDist = 3;
1178
1083
  for (const candidate of candidates) {
@@ -1185,11 +1090,7 @@ function suggestField(
1185
1090
  return best;
1186
1091
  }
1187
1092
 
1188
- function rejectUnknownFields(
1189
- value: object,
1190
- allowed: ReadonlySet<string>,
1191
- fieldPath: string,
1192
- ): void {
1093
+ function rejectUnknownFields(value: object, allowed: ReadonlySet<string>, fieldPath: string): void {
1193
1094
  for (const key of Object.keys(value)) {
1194
1095
  if (allowed.has(key)) continue;
1195
1096
  const hint = suggestField(key, allowed);
@@ -1225,29 +1126,19 @@ function assertBoundedIntegerMs(
1225
1126
  function validateProviderHealthMonitor(
1226
1127
  providerId: string,
1227
1128
  healthMonitor: unknown,
1129
+ field: "healthMonitor" | "healthProbe" = "healthMonitor",
1228
1130
  ): void {
1229
1131
  if (healthMonitor === undefined) return;
1230
- if (
1231
- !healthMonitor ||
1232
- typeof healthMonitor !== "object" ||
1233
- Array.isArray(healthMonitor)
1234
- )
1235
- throw new ValidationError(
1236
- `Provider "${providerId}" has invalid healthMonitor: must be an object.`,
1237
- {
1238
- fix: `Set healthMonitor to { requiredSecrets?: string[]; serviceAccount?: string }`,
1239
- },
1240
- );
1132
+ if (!healthMonitor || typeof healthMonitor !== "object" || Array.isArray(healthMonitor))
1133
+ throw new ValidationError(`Provider "${providerId}" has invalid ${field}: must be an object.`, {
1134
+ fix: `Set ${field} to { requiredSecrets?: string[]; serviceAccount?: string }`,
1135
+ });
1241
1136
  const healthMonitorRecord = Object.fromEntries(Object.entries(healthMonitor));
1242
- rejectUnknownFields(
1243
- healthMonitorRecord,
1244
- PROVIDER_HEALTH_MONITOR_FIELDS,
1245
- "healthMonitor",
1246
- );
1137
+ rejectUnknownFields(healthMonitorRecord, PROVIDER_HEALTH_MONITOR_FIELDS, field);
1247
1138
  if (healthMonitorRecord.defaultProbeTimeoutMs !== undefined) {
1248
1139
  assertBoundedIntegerMs(
1249
1140
  healthMonitorRecord.defaultProbeTimeoutMs,
1250
- `Provider "${providerId}" healthMonitor.defaultProbeTimeoutMs`,
1141
+ `Provider "${providerId}" ${field}.defaultProbeTimeoutMs`,
1251
1142
  {
1252
1143
  min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1253
1144
  max: HEALTH_CHECK_TIMEOUT_MS_MAX,
@@ -1258,7 +1149,7 @@ function validateProviderHealthMonitor(
1258
1149
  if (healthMonitorRecord.defaultDegradedThresholdMs !== undefined) {
1259
1150
  assertBoundedIntegerMs(
1260
1151
  healthMonitorRecord.defaultDegradedThresholdMs,
1261
- `Provider "${providerId}" healthMonitor.defaultDegradedThresholdMs`,
1152
+ `Provider "${providerId}" ${field}.defaultDegradedThresholdMs`,
1262
1153
  {
1263
1154
  min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1264
1155
  max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
@@ -1270,12 +1161,12 @@ function validateProviderHealthMonitor(
1270
1161
  if (requiredSecrets !== undefined) {
1271
1162
  if (!Array.isArray(requiredSecrets))
1272
1163
  throw new ValidationError(
1273
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets: must be string[].`,
1164
+ `Provider "${providerId}" has invalid ${field}.requiredSecrets: must be string[].`,
1274
1165
  );
1275
1166
  for (const [index, secret] of requiredSecrets.entries()) {
1276
1167
  if (typeof secret !== "string" || secret.length === 0)
1277
1168
  throw new ValidationError(
1278
- `Provider "${providerId}" has invalid healthMonitor.requiredSecrets[${index}]: must be a non-empty string.`,
1169
+ `Provider "${providerId}" has invalid ${field}.requiredSecrets[${index}]: must be a non-empty string.`,
1279
1170
  );
1280
1171
  }
1281
1172
  }
@@ -1287,23 +1178,23 @@ function validateProviderHealthMonitor(
1287
1178
  Array.isArray(credentialInputs)
1288
1179
  ) {
1289
1180
  throw new ValidationError(
1290
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs: must be an object mapping auth input fields to env var names.`,
1181
+ `Provider "${providerId}" has invalid ${field}.credentialInputs: must be an object mapping auth input fields to env var names.`,
1291
1182
  );
1292
1183
  }
1293
- for (const [field, envVar] of Object.entries(credentialInputs)) {
1294
- if (field.trim().length === 0) {
1184
+ for (const [inputField, envVar] of Object.entries(credentialInputs)) {
1185
+ if (inputField.trim().length === 0) {
1295
1186
  throw new ValidationError(
1296
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs key: must be a non-empty auth input field.`,
1187
+ `Provider "${providerId}" has invalid ${field}.credentialInputs key: must be a non-empty auth input field.`,
1297
1188
  );
1298
1189
  }
1299
1190
  if (typeof envVar !== "string" || envVar.trim().length === 0) {
1300
1191
  throw new ValidationError(
1301
- `Provider "${providerId}" has invalid healthMonitor.credentialInputs.${field}: must be a non-empty env var name.`,
1192
+ `Provider "${providerId}" has invalid ${field}.credentialInputs.${inputField}: must be a non-empty env var name.`,
1302
1193
  );
1303
1194
  }
1304
1195
  if (Array.isArray(requiredSecrets) && !requiredSecrets.includes(envVar)) {
1305
1196
  throw new ValidationError(
1306
- `Provider "${providerId}" healthMonitor.credentialInputs.${field} references ${envVar}, which must also be listed in healthMonitor.requiredSecrets.`,
1197
+ `Provider "${providerId}" ${field}.credentialInputs.${inputField} references ${envVar}, which must also be listed in ${field}.requiredSecrets.`,
1307
1198
  );
1308
1199
  }
1309
1200
  }
@@ -1311,38 +1202,34 @@ function validateProviderHealthMonitor(
1311
1202
 
1312
1203
  const probeOverrides = healthMonitorRecord.probeOverrides;
1313
1204
  if (probeOverrides !== undefined) {
1314
- if (
1315
- !probeOverrides ||
1316
- typeof probeOverrides !== "object" ||
1317
- Array.isArray(probeOverrides)
1318
- )
1205
+ if (!probeOverrides || typeof probeOverrides !== "object" || Array.isArray(probeOverrides))
1319
1206
  throw new ValidationError(
1320
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides: must be an object keyed by probe id.`,
1207
+ `Provider "${providerId}" has invalid ${field}.probeOverrides: must be an object keyed by probe id.`,
1321
1208
  );
1322
1209
  for (const [probeId, override] of Object.entries(probeOverrides)) {
1323
1210
  if (probeId.length === 0)
1324
1211
  throw new ValidationError(
1325
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides key: must be a non-empty probe id.`,
1212
+ `Provider "${providerId}" has invalid ${field}.probeOverrides key: must be a non-empty probe id.`,
1326
1213
  );
1327
1214
  if (!override || typeof override !== "object" || Array.isArray(override))
1328
1215
  throw new ValidationError(
1329
- `Provider "${providerId}" has invalid healthMonitor.probeOverrides["${probeId}"]: must be an object.`,
1216
+ `Provider "${providerId}" has invalid ${field}.probeOverrides["${probeId}"]: must be an object.`,
1330
1217
  );
1331
1218
  const overrideRecord = Object.fromEntries(Object.entries(override));
1332
1219
  rejectUnknownFields(
1333
1220
  overrideRecord,
1334
1221
  PROVIDER_HEALTH_MONITOR_PROBE_OVERRIDE_FIELDS,
1335
- `healthMonitor.probeOverrides["${probeId}"]`,
1222
+ `${field}.probeOverrides["${probeId}"]`,
1336
1223
  );
1337
1224
  const interval = overrideRecord.interval;
1338
1225
  if (interval !== undefined && !isPositiveMsDurationString(interval))
1339
1226
  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.`,
1227
+ `Provider "${providerId}" has invalid ${field}.probeOverrides["${probeId}"].interval: must be a positive ms-style duration string such as 30s, 5m, 8h, or 1 day.`,
1341
1228
  );
1342
1229
  if (overrideRecord.timeoutMs !== undefined) {
1343
1230
  assertBoundedIntegerMs(
1344
1231
  overrideRecord.timeoutMs,
1345
- `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].timeoutMs`,
1232
+ `Provider "${providerId}" ${field}.probeOverrides["${probeId}"].timeoutMs`,
1346
1233
  {
1347
1234
  min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1348
1235
  max: HEALTH_CHECK_TIMEOUT_MS_MAX,
@@ -1353,7 +1240,7 @@ function validateProviderHealthMonitor(
1353
1240
  if (overrideRecord.degradedThresholdMs !== undefined) {
1354
1241
  assertBoundedIntegerMs(
1355
1242
  overrideRecord.degradedThresholdMs,
1356
- `Provider "${providerId}" healthMonitor.probeOverrides["${probeId}"].degradedThresholdMs`,
1243
+ `Provider "${providerId}" ${field}.probeOverrides["${probeId}"].degradedThresholdMs`,
1357
1244
  {
1358
1245
  min: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MIN,
1359
1246
  max: HEALTH_CHECK_DEGRADED_THRESHOLD_MS_MAX,
@@ -1369,7 +1256,7 @@ function validateProviderHealthMonitor(
1369
1256
  (typeof serviceAccount !== "string" || serviceAccount.length === 0)
1370
1257
  )
1371
1258
  throw new ValidationError(
1372
- `Provider "${providerId}" has invalid healthMonitor.serviceAccount: must be a non-empty string.`,
1259
+ `Provider "${providerId}" has invalid ${field}.serviceAccount: must be a non-empty string.`,
1373
1260
  );
1374
1261
  }
1375
1262
 
@@ -1381,14 +1268,8 @@ function validateHealthCheckCase(
1381
1268
  ): void {
1382
1269
  const fieldPath = `operations.${operationName}.healthCheck.cases[${caseIndex}]`;
1383
1270
  if (!caseValue || typeof caseValue !== "object" || Array.isArray(caseValue))
1384
- throw new ValidationError(
1385
- `Provider "${providerId}" ${fieldPath} must be an object.`,
1386
- );
1387
- rejectUnknownFields(
1388
- caseValue as Record<string, unknown>,
1389
- HEALTH_CHECK_CASE_FIELDS,
1390
- fieldPath,
1391
- );
1271
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath} must be an object.`);
1272
+ rejectUnknownFields(caseValue as Record<string, unknown>, HEALTH_CHECK_CASE_FIELDS, fieldPath);
1392
1273
  const c = caseValue as HealthCheckCase;
1393
1274
  if (typeof c.name !== "string" || c.name.length === 0)
1394
1275
  throw new ValidationError(
@@ -1416,15 +1297,11 @@ function validateHealthCheckCase(
1416
1297
  `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
1298
  );
1418
1299
  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
- },
1427
- );
1300
+ assertBoundedIntegerMs(c.timeoutMs, `Provider "${providerId}" ${fieldPath}.timeoutMs`, {
1301
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1302
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1303
+ label: "timeout",
1304
+ });
1428
1305
  }
1429
1306
  if (
1430
1307
  c.expectedStatus !== undefined &&
@@ -1440,21 +1317,11 @@ function validateHealthCheckCase(
1440
1317
  );
1441
1318
  }
1442
1319
 
1443
- function validateHealthCheckSuite(
1444
- providerId: string,
1445
- operationName: string,
1446
- suite: unknown,
1447
- ): void {
1320
+ function validateHealthCheckSuite(providerId: string, operationName: string, suite: unknown): void {
1448
1321
  const fieldPath = `operations.${operationName}.healthCheck`;
1449
1322
  if (!suite || typeof suite !== "object" || Array.isArray(suite))
1450
- throw new ValidationError(
1451
- `Provider "${providerId}" ${fieldPath} must be an object.`,
1452
- );
1453
- rejectUnknownFields(
1454
- suite as Record<string, unknown>,
1455
- HEALTH_CHECK_SUITE_FIELDS,
1456
- fieldPath,
1457
- );
1323
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath} must be an object.`);
1324
+ rejectUnknownFields(suite as Record<string, unknown>, HEALTH_CHECK_SUITE_FIELDS, fieldPath);
1458
1325
  const s = suite as HealthCheckSuite;
1459
1326
  if (!isPositiveMsDurationString(s.interval))
1460
1327
  throw new ValidationError(
@@ -1463,16 +1330,33 @@ function validateHealthCheckSuite(
1463
1330
  fix: `Set ${fieldPath}.interval to a positive ms-style duration string.`,
1464
1331
  },
1465
1332
  );
1333
+ if (s.schedule !== undefined) {
1334
+ if (!s.schedule || typeof s.schedule !== "object" || Array.isArray(s.schedule)) {
1335
+ throw new ValidationError(
1336
+ `Provider "${providerId}" ${fieldPath}.schedule must be an object.`,
1337
+ );
1338
+ }
1339
+ if (Reflect.get(s.schedule, "jitter") !== undefined) {
1340
+ throw new ValidationError(
1341
+ `Provider "${providerId}" ${fieldPath}.schedule.jitter is not supported for operation healthCheck schedules. Use schedule.randomize instead.`,
1342
+ );
1343
+ }
1344
+ rejectUnknownFields(s.schedule, new Set(["randomize"]), `${fieldPath}.schedule`);
1345
+ const randomize = Reflect.get(s.schedule, "randomize");
1346
+ if (randomize !== undefined) {
1347
+ validateScheduleRandomization(
1348
+ randomize,
1349
+ `Provider "${providerId}" ${fieldPath}.schedule.randomize`,
1350
+ msDurationMs(s.interval),
1351
+ );
1352
+ }
1353
+ }
1466
1354
  if (s.timeoutMs !== undefined) {
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
- );
1355
+ assertBoundedIntegerMs(s.timeoutMs, `Provider "${providerId}" ${fieldPath}.timeoutMs`, {
1356
+ min: HEALTH_CHECK_TIMEOUT_MS_MIN,
1357
+ max: HEALTH_CHECK_TIMEOUT_MS_MAX,
1358
+ label: "timeout",
1359
+ });
1476
1360
  }
1477
1361
  if (s.degradedThresholdMs !== undefined) {
1478
1362
  assertBoundedIntegerMs(
@@ -1485,10 +1369,7 @@ function validateHealthCheckSuite(
1485
1369
  },
1486
1370
  );
1487
1371
  }
1488
- if (
1489
- s.requiresConnection !== undefined &&
1490
- typeof s.requiresConnection !== "boolean"
1491
- )
1372
+ if (s.requiresConnection !== undefined && typeof s.requiresConnection !== "boolean")
1492
1373
  throw new ValidationError(
1493
1374
  `Provider "${providerId}" ${fieldPath}.requiresConnection must be a boolean.`,
1494
1375
  );
@@ -1520,14 +1401,8 @@ function validateHealthCheckUnsupported(
1520
1401
  unsupported: unknown,
1521
1402
  ): void {
1522
1403
  const fieldPath = `operations.${operationName}.healthCheckUnsupported`;
1523
- if (
1524
- !unsupported ||
1525
- typeof unsupported !== "object" ||
1526
- Array.isArray(unsupported)
1527
- )
1528
- throw new ValidationError(
1529
- `Provider "${providerId}" ${fieldPath} must be an object.`,
1530
- );
1404
+ if (!unsupported || typeof unsupported !== "object" || Array.isArray(unsupported))
1405
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath} must be an object.`);
1531
1406
  rejectUnknownFields(
1532
1407
  unsupported as Record<string, unknown>,
1533
1408
  HEALTH_CHECK_UNSUPPORTED_FIELDS,
@@ -1561,7 +1436,7 @@ const HEALTH_JOURNEY_FIELDS = new Set([
1561
1436
  "steps",
1562
1437
  "run",
1563
1438
  ]);
1564
- const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter"]);
1439
+ const HEALTH_JOURNEY_SCHEDULE_FIELDS = new Set(["kind", "interval", "jitter", "randomize"]);
1565
1440
  const HEALTH_JOURNEY_STEP_FIELDS = new Set([
1566
1441
  "id",
1567
1442
  "description",
@@ -1580,10 +1455,7 @@ const HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS = new Set([
1580
1455
  "minManualInterval",
1581
1456
  "publicRationale",
1582
1457
  ]);
1583
- const HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS = new Set([
1584
- "enabled",
1585
- "reason",
1586
- ]);
1458
+ const HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS = new Set(["enabled", "reason"]);
1587
1459
  const HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS = new Set([
1588
1460
  "enabled",
1589
1461
  "requiresAcknowledgement",
@@ -1603,32 +1475,18 @@ function validateHealthJourneyManualTrigger(
1603
1475
  manualTrigger: unknown,
1604
1476
  ): void {
1605
1477
  const fieldPath = `healthJourneys.${journeyId}.manualTrigger`;
1606
- if (
1607
- !manualTrigger ||
1608
- typeof manualTrigger !== "object" ||
1609
- Array.isArray(manualTrigger)
1610
- ) {
1478
+ if (!manualTrigger || typeof manualTrigger !== "object" || Array.isArray(manualTrigger)) {
1611
1479
  throw new ValidationError(
1612
1480
  `Provider "${providerId}" ${fieldPath} must be an object when present.`,
1613
1481
  );
1614
1482
  }
1615
- rejectUnknownFields(
1616
- manualTrigger,
1617
- HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS,
1618
- fieldPath,
1619
- );
1483
+ rejectUnknownFields(manualTrigger, HEALTH_JOURNEY_MANUAL_TRIGGER_FIELDS, fieldPath);
1620
1484
  const enabled = Reflect.get(manualTrigger, "enabled");
1621
1485
  if (typeof enabled !== "boolean") {
1622
- throw new ValidationError(
1623
- `Provider "${providerId}" ${fieldPath}.enabled must be a boolean.`,
1624
- );
1486
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath}.enabled must be a boolean.`);
1625
1487
  }
1626
1488
  if (enabled === false) {
1627
- rejectUnknownFields(
1628
- manualTrigger,
1629
- HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS,
1630
- fieldPath,
1631
- );
1489
+ rejectUnknownFields(manualTrigger, HEALTH_JOURNEY_MANUAL_TRIGGER_DISABLED_FIELDS, fieldPath);
1632
1490
  if (
1633
1491
  Reflect.get(manualTrigger, "reason") !== undefined &&
1634
1492
  (typeof Reflect.get(manualTrigger, "reason") !== "string" ||
@@ -1640,25 +1498,15 @@ function validateHealthJourneyManualTrigger(
1640
1498
  }
1641
1499
  return;
1642
1500
  }
1643
- rejectUnknownFields(
1644
- manualTrigger,
1645
- HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS,
1646
- fieldPath,
1647
- );
1648
- const requiresAcknowledgement = Reflect.get(
1649
- manualTrigger,
1650
- "requiresAcknowledgement",
1651
- );
1501
+ rejectUnknownFields(manualTrigger, HEALTH_JOURNEY_MANUAL_TRIGGER_ENABLED_FIELDS, fieldPath);
1502
+ const requiresAcknowledgement = Reflect.get(manualTrigger, "requiresAcknowledgement");
1652
1503
  if (typeof requiresAcknowledgement !== "boolean") {
1653
1504
  throw new ValidationError(
1654
1505
  `Provider "${providerId}" ${fieldPath}.requiresAcknowledgement must be a boolean.`,
1655
1506
  );
1656
1507
  }
1657
1508
  const risk = Reflect.get(manualTrigger, "risk");
1658
- if (
1659
- typeof risk !== "string" ||
1660
- !HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS.has(risk)
1661
- ) {
1509
+ if (typeof risk !== "string" || !HEALTH_JOURNEY_MANUAL_TRIGGER_RISKS.has(risk)) {
1662
1510
  throw new ValidationError(
1663
1511
  `Provider "${providerId}" ${fieldPath}.risk must be one of read_only, writes_external_state, or sms_or_payment.`,
1664
1512
  );
@@ -1669,10 +1517,7 @@ function validateHealthJourneyManualTrigger(
1669
1517
  );
1670
1518
  }
1671
1519
  const minManualInterval = Reflect.get(manualTrigger, "minManualInterval");
1672
- assertIsoDuration(
1673
- minManualInterval,
1674
- `Provider "${providerId}" ${fieldPath}.minManualInterval`,
1675
- );
1520
+ assertIsoDuration(minManualInterval, `Provider "${providerId}" ${fieldPath}.minManualInterval`);
1676
1521
  if (isoDurationMs(minManualInterval) <= 0) {
1677
1522
  throw new ValidationError(
1678
1523
  `Provider "${providerId}" ${fieldPath}.minManualInterval must be a positive duration.`,
@@ -1703,18 +1548,14 @@ const SMS_ORIGIN_FIELDS_BY_KIND: Record<string, ReadonlySet<string>> = {
1703
1548
  e164: new Set(["kind", "value", "display"]),
1704
1549
  nationalServiceCode: new Set(["kind", "country", "value", "display"]),
1705
1550
  };
1706
- const DURATION_RE =
1707
- /^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
1551
+ const DURATION_RE = /^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
1708
1552
  const E164_RE = /^\+[1-9]\d{1,14}$/;
1709
1553
  const ISO_COUNTRY_RE = /^[A-Z]{2}$/;
1710
1554
  const NATIONAL_SERVICE_CODE_RE = /^[0-9]{2,15}$/;
1711
1555
  const BCP47_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
1712
1556
  const JOURNEY_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
1713
1557
 
1714
- function assertIsoDuration(
1715
- value: unknown,
1716
- fieldPath: string,
1717
- ): asserts value is string {
1558
+ function assertIsoDuration(value: unknown, fieldPath: string): asserts value is string {
1718
1559
  if (typeof value !== "string" || !DURATION_RE.test(value)) {
1719
1560
  throw new ValidationError(
1720
1561
  `${fieldPath} must be an ISO 8601 duration for example PT8H or PT2M30S.`,
@@ -1729,15 +1570,49 @@ function isoDurationMs(value: string): number {
1729
1570
  const hours = Number(/(\d+)H/.exec(value)?.[1] ?? 0);
1730
1571
  const minutes = Number(/(\d+)M/.exec(value)?.[1] ?? 0);
1731
1572
  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
- );
1573
+ return days * 86_400_000 + hours * 3_600_000 + minutes * 60_000 + seconds * 1_000;
1735
1574
  }
1736
1575
 
1737
- function assertIsoCountry(
1738
- value: unknown,
1576
+ function scheduleRandomizationMs(randomize: unknown, fieldPath: string): number {
1577
+ const mode = Reflect.get(randomize as object, "mode");
1578
+ switch (mode) {
1579
+ case "centered": {
1580
+ const maxOffset = Reflect.get(randomize as object, "maxOffset");
1581
+ assertIsoDuration(maxOffset, `${fieldPath}.maxOffset`);
1582
+ return isoDurationMs(maxOffset);
1583
+ }
1584
+ case "delayed": {
1585
+ const maxDelay = Reflect.get(randomize as object, "maxDelay");
1586
+ assertIsoDuration(maxDelay, `${fieldPath}.maxDelay`);
1587
+ return isoDurationMs(maxDelay);
1588
+ }
1589
+ default:
1590
+ throw new ValidationError(`${fieldPath}.mode must be "centered" or "delayed".`);
1591
+ }
1592
+ }
1593
+
1594
+ function validateScheduleRandomization(
1595
+ randomize: unknown,
1739
1596
  fieldPath: string,
1740
- ): asserts value is string {
1597
+ intervalMs: number,
1598
+ ): void {
1599
+ if (!randomize || typeof randomize !== "object" || Array.isArray(randomize)) {
1600
+ throw new ValidationError(`${fieldPath} must be an object.`);
1601
+ }
1602
+ const mode = Reflect.get(randomize, "mode");
1603
+ const allowedFields =
1604
+ mode === "centered" ? new Set(["mode", "maxOffset"]) : new Set(["mode", "maxDelay"]);
1605
+ rejectUnknownFields(randomize, allowedFields, fieldPath);
1606
+ const offsetMs = scheduleRandomizationMs(randomize, fieldPath);
1607
+ if (offsetMs <= 0) {
1608
+ throw new ValidationError(`${fieldPath} duration must be positive.`);
1609
+ }
1610
+ if (offsetMs >= intervalMs) {
1611
+ throw new ValidationError(`${fieldPath} duration must be shorter than schedule interval.`);
1612
+ }
1613
+ }
1614
+
1615
+ function assertIsoCountry(value: unknown, fieldPath: string): asserts value is string {
1741
1616
  if (typeof value !== "string" || !ISO_COUNTRY_RE.test(value)) {
1742
1617
  throw new ValidationError(
1743
1618
  `${fieldPath} must be an ISO 3166-1 alpha-2 country code for example KR.`,
@@ -1749,13 +1624,19 @@ function normalizeIntervalDuration(input: string): string {
1749
1624
  const trimmed = input.trim();
1750
1625
  const shorthand = /^(\d+)(s|m|h|d)$/i.exec(trimmed);
1751
1626
  if (shorthand) {
1752
- const amount = Number(shorthand[1]);
1627
+ const durationMs = msDurationMs(trimmed);
1628
+ const unit = shorthand[2]?.toLowerCase();
1629
+ const amount =
1630
+ unit === "s"
1631
+ ? durationMs / 1_000
1632
+ : unit === "m"
1633
+ ? durationMs / 60_000
1634
+ : unit === "h"
1635
+ ? durationMs / 3_600_000
1636
+ : durationMs / 86_400_000;
1753
1637
  if (!Number.isInteger(amount) || amount <= 0) {
1754
- throw new ValidationError(
1755
- `Journey schedule interval must be a positive duration.`,
1756
- );
1638
+ throw new ValidationError(`Journey schedule interval must be a positive duration.`);
1757
1639
  }
1758
- const unit = shorthand[2]?.toLowerCase();
1759
1640
  if (unit === "s") return `PT${amount}S`;
1760
1641
  if (unit === "m") return `PT${amount}M`;
1761
1642
  if (unit === "h") return `PT${amount}H`;
@@ -1767,18 +1648,34 @@ function normalizeIntervalDuration(input: string): string {
1767
1648
 
1768
1649
  export function every(
1769
1650
  interval: string,
1770
- options: { jitter?: string } = {},
1651
+ options: { jitter?: string; randomize?: HealthScheduleRandomization } = {},
1771
1652
  ): HealthJourneySchedule {
1653
+ if (options.jitter !== undefined && options.randomize !== undefined) {
1654
+ throw new ValidationError(
1655
+ `Schedule cannot define both jitter and randomize. Use randomize instead.`,
1656
+ );
1657
+ }
1772
1658
  const schedule: HealthJourneySchedule = {
1773
1659
  kind: "interval",
1774
1660
  interval: normalizeIntervalDuration(interval),
1775
1661
  };
1662
+ if (options.randomize !== undefined) {
1663
+ schedule.randomize = options.randomize;
1664
+ }
1776
1665
  if (options.jitter !== undefined) {
1777
1666
  schedule.jitter = normalizeIntervalDuration(options.jitter);
1778
1667
  }
1779
1668
  return schedule;
1780
1669
  }
1781
1670
 
1671
+ export function centered(maxOffset: string): HealthScheduleRandomization {
1672
+ return { mode: "centered", maxOffset: normalizeIntervalDuration(maxOffset) };
1673
+ }
1674
+
1675
+ export function delayed(maxDelay: string): HealthScheduleRandomization {
1676
+ return { mode: "delayed", maxDelay: normalizeIntervalDuration(maxDelay) };
1677
+ }
1678
+
1782
1679
  function countCapturingGroups(pattern: RegExp): number {
1783
1680
  let count = 0;
1784
1681
  const source = pattern.source;
@@ -1797,11 +1694,7 @@ function countCapturingGroups(pattern: RegExp): number {
1797
1694
  if (inCharacterClass || char !== "(") continue;
1798
1695
  const next = source[i + 1];
1799
1696
  if (next === "?" && source[i + 2] !== "<") continue;
1800
- if (
1801
- next === "?" &&
1802
- source[i + 2] === "<" &&
1803
- (source[i + 3] === "=" || source[i + 3] === "!")
1804
- )
1697
+ if (next === "?" && source[i + 2] === "<" && (source[i + 3] === "=" || source[i + 3] === "!"))
1805
1698
  continue;
1806
1699
  count += 1;
1807
1700
  }
@@ -1820,9 +1713,7 @@ function validateSmsOrigin(origin: unknown, fieldPath: string): void {
1820
1713
  }
1821
1714
  const kind = Reflect.get(origin, "kind");
1822
1715
  if (kind !== "e164" && kind !== "nationalServiceCode") {
1823
- throw new ValidationError(
1824
- `${fieldPath}.kind must be "e164" or "nationalServiceCode".`,
1825
- );
1716
+ throw new ValidationError(`${fieldPath}.kind must be "e164" or "nationalServiceCode".`);
1826
1717
  }
1827
1718
  rejectUnknownFields(origin, SMS_ORIGIN_FIELDS_BY_KIND[kind], fieldPath);
1828
1719
  if (kind === "e164") {
@@ -1849,9 +1740,7 @@ function validateSmsOrigin(origin: unknown, fieldPath: string): void {
1849
1740
  Reflect.get(origin, "display") !== undefined &&
1850
1741
  typeof Reflect.get(origin, "display") !== "string"
1851
1742
  ) {
1852
- throw new ValidationError(
1853
- `${fieldPath}.display must be a string when present.`,
1854
- );
1743
+ throw new ValidationError(`${fieldPath}.display must be a string when present.`);
1855
1744
  }
1856
1745
  }
1857
1746
 
@@ -1865,9 +1754,7 @@ function validateSmsOtpMatcher(
1865
1754
  rejectUnknownFields(matcher, SMS_OTP_MATCHER_FIELDS, fieldPath);
1866
1755
  const matcherId = Reflect.get(matcher, "id");
1867
1756
  if (typeof matcherId !== "string" || !JOURNEY_ID_RE.test(matcherId)) {
1868
- throw new ValidationError(
1869
- `${fieldPath}.id must be a kebab-case identifier.`,
1870
- );
1757
+ throw new ValidationError(`${fieldPath}.id must be a kebab-case identifier.`);
1871
1758
  }
1872
1759
  assertIsoCountry(Reflect.get(matcher, "country"), `${fieldPath}.country`);
1873
1760
  if (
@@ -1875,24 +1762,18 @@ function validateSmsOtpMatcher(
1875
1762
  (typeof Reflect.get(matcher, "locale") !== "string" ||
1876
1763
  !BCP47_RE.test(Reflect.get(matcher, "locale")))
1877
1764
  ) {
1878
- throw new ValidationError(
1879
- `${fieldPath}.locale must be a BCP 47 locale for example ko-KR.`,
1880
- );
1765
+ throw new ValidationError(`${fieldPath}.locale must be a BCP 47 locale for example ko-KR.`);
1881
1766
  }
1882
1767
  if (
1883
1768
  Reflect.get(matcher, "phoneNumber") !== undefined &&
1884
1769
  (typeof Reflect.get(matcher, "phoneNumber") !== "string" ||
1885
1770
  !E164_RE.test(Reflect.get(matcher, "phoneNumber")))
1886
1771
  ) {
1887
- throw new ValidationError(
1888
- `${fieldPath}.phoneNumber must be an ITU-T E.164 number.`,
1889
- );
1772
+ throw new ValidationError(`${fieldPath}.phoneNumber must be an ITU-T E.164 number.`);
1890
1773
  }
1891
1774
  const origins = Reflect.get(matcher, "origins");
1892
1775
  if (!Array.isArray(origins) || origins.length === 0) {
1893
- throw new ValidationError(
1894
- `${fieldPath}.origins must be a non-empty array.`,
1895
- );
1776
+ throw new ValidationError(`${fieldPath}.origins must be a non-empty array.`);
1896
1777
  }
1897
1778
  for (const [index, origin] of origins.entries()) {
1898
1779
  validateSmsOrigin(origin, `${fieldPath}.origins[${index}]`);
@@ -1913,10 +1794,7 @@ function validateSmsOtpMatcher(
1913
1794
  );
1914
1795
  }
1915
1796
  const regex = pattern instanceof RegExp ? pattern : new RegExp(pattern);
1916
- if (
1917
- countCapturingGroups(regex) !== 1 &&
1918
- Reflect.get(code, "capture") === undefined
1919
- ) {
1797
+ if (countCapturingGroups(regex) !== 1 && Reflect.get(code, "capture") === undefined) {
1920
1798
  throw new ValidationError(
1921
1799
  `${fieldPath}.code.pattern must contain exactly one OTP capture or declare code.capture.`,
1922
1800
  );
@@ -1926,20 +1804,12 @@ function validateSmsOtpMatcher(
1926
1804
  typeof Reflect.get(code, "capture") !== "string" &&
1927
1805
  typeof Reflect.get(code, "capture") !== "number"
1928
1806
  ) {
1929
- throw new ValidationError(
1930
- `${fieldPath}.code.capture must be a string or number when present.`,
1931
- );
1807
+ throw new ValidationError(`${fieldPath}.code.capture must be a string or number when present.`);
1932
1808
  }
1933
1809
  assertIsoDuration(Reflect.get(matcher, "maxAge"), `${fieldPath}.maxAge`);
1934
- assertIsoDuration(
1935
- Reflect.get(matcher, "waitTimeout"),
1936
- `${fieldPath}.waitTimeout`,
1937
- );
1810
+ assertIsoDuration(Reflect.get(matcher, "waitTimeout"), `${fieldPath}.waitTimeout`);
1938
1811
  if (Reflect.get(matcher, "clockSkew") !== undefined)
1939
- assertIsoDuration(
1940
- Reflect.get(matcher, "clockSkew"),
1941
- `${fieldPath}.clockSkew`,
1942
- );
1812
+ assertIsoDuration(Reflect.get(matcher, "clockSkew"), `${fieldPath}.clockSkew`);
1943
1813
  }
1944
1814
 
1945
1815
  export function defineSmsOtpMatcher(
@@ -1971,9 +1841,7 @@ export function defineSmsOtpMatcher(
1971
1841
  return matcher;
1972
1842
  }
1973
1843
 
1974
- export function defineHealthJourney(
1975
- config: HealthJourneyDefinition,
1976
- ): HealthJourneyDefinition {
1844
+ export function defineHealthJourney(config: HealthJourneyDefinition): HealthJourneyDefinition {
1977
1845
  return config;
1978
1846
  }
1979
1847
 
@@ -1984,24 +1852,31 @@ function validateHealthJourneySchedule(
1984
1852
  ): void {
1985
1853
  const fieldPath = `healthJourneys.${journeyId}.schedule`;
1986
1854
  if (!schedule || typeof schedule !== "object" || Array.isArray(schedule)) {
1987
- throw new ValidationError(
1988
- `Provider "${providerId}" ${fieldPath} must be an object.`,
1989
- );
1855
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath} must be an object.`);
1990
1856
  }
1991
1857
  rejectUnknownFields(schedule, HEALTH_JOURNEY_SCHEDULE_FIELDS, fieldPath);
1992
1858
  if (Reflect.get(schedule, "kind") !== "interval")
1859
+ throw new ValidationError(`Provider "${providerId}" ${fieldPath}.kind must be "interval".`);
1860
+ const interval = Reflect.get(schedule, "interval");
1861
+ assertIsoDuration(interval, `Provider "${providerId}" ${fieldPath}.interval`);
1862
+ const randomize = Reflect.get(schedule, "randomize");
1863
+ if (Reflect.get(schedule, "jitter") !== undefined && randomize !== undefined) {
1993
1864
  throw new ValidationError(
1994
- `Provider "${providerId}" ${fieldPath}.kind must be "interval".`,
1865
+ `Provider "${providerId}" ${fieldPath} cannot define both jitter and randomize.`,
1995
1866
  );
1996
- assertIsoDuration(
1997
- Reflect.get(schedule, "interval"),
1998
- `Provider "${providerId}" ${fieldPath}.interval`,
1999
- );
1867
+ }
2000
1868
  if (Reflect.get(schedule, "jitter") !== undefined)
2001
1869
  assertIsoDuration(
2002
1870
  Reflect.get(schedule, "jitter"),
2003
1871
  `Provider "${providerId}" ${fieldPath}.jitter`,
2004
1872
  );
1873
+ if (randomize !== undefined) {
1874
+ validateScheduleRandomization(
1875
+ randomize,
1876
+ `Provider "${providerId}" ${fieldPath}.randomize`,
1877
+ isoDurationMs(interval),
1878
+ );
1879
+ }
2005
1880
  }
2006
1881
 
2007
1882
  function validateHealthJourneys(
@@ -2012,17 +1887,13 @@ function validateHealthJourneys(
2012
1887
  const covered = new Set<string>();
2013
1888
  if (healthJourneys === undefined) return covered;
2014
1889
  if (!Array.isArray(healthJourneys)) {
2015
- throw new ValidationError(
2016
- `Provider "${providerId}" healthJourneys must be an array.`,
2017
- );
1890
+ throw new ValidationError(`Provider "${providerId}" healthJourneys must be an array.`);
2018
1891
  }
2019
1892
  const journeyIds = new Set<string>();
2020
1893
  for (const [index, journey] of healthJourneys.entries()) {
2021
1894
  const prefix = `healthJourneys[${index}]`;
2022
1895
  if (!journey || typeof journey !== "object" || Array.isArray(journey)) {
2023
- throw new ValidationError(
2024
- `Provider "${providerId}" ${prefix} must be an object.`,
2025
- );
1896
+ throw new ValidationError(`Provider "${providerId}" ${prefix} must be an object.`);
2026
1897
  }
2027
1898
  rejectUnknownFields(journey, HEALTH_JOURNEY_FIELDS, prefix);
2028
1899
  if (typeof journey.id !== "string" || !JOURNEY_ID_RE.test(journey.id)) {
@@ -2036,10 +1907,7 @@ function validateHealthJourneys(
2036
1907
  );
2037
1908
  journeyIds.add(journey.id);
2038
1909
  validateHealthJourneySchedule(providerId, journey.id, journey.schedule);
2039
- if (
2040
- !Array.isArray(journey.coversOperations) ||
2041
- journey.coversOperations.length === 0
2042
- ) {
1910
+ if (!Array.isArray(journey.coversOperations) || journey.coversOperations.length === 0) {
2043
1911
  throw new ValidationError(
2044
1912
  `Provider "${providerId}" healthJourneys.${journey.id}.coversOperations must be a non-empty array.`,
2045
1913
  );
@@ -2074,10 +1942,7 @@ function validateHealthJourneys(
2074
1942
  `Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers must be an array.`,
2075
1943
  );
2076
1944
  for (const [matcherIndex, matcher] of journey.smsMatchers.entries()) {
2077
- validateSmsOtpMatcher(
2078
- matcher,
2079
- `healthJourneys.${journey.id}.smsMatchers[${matcherIndex}]`,
2080
- );
1945
+ validateSmsOtpMatcher(matcher, `healthJourneys.${journey.id}.smsMatchers[${matcherIndex}]`);
2081
1946
  if (matcherIds.has(matcher.id))
2082
1947
  throw new ValidationError(
2083
1948
  `Provider "${providerId}" healthJourneys.${journey.id}.smsMatchers has duplicate matcher id "${matcher.id}".`,
@@ -2088,9 +1953,7 @@ function validateHealthJourneys(
2088
1953
  for (const [stepIndex, step] of journey.steps.entries()) {
2089
1954
  const stepPath = `healthJourneys.${journey.id}.steps[${stepIndex}]`;
2090
1955
  if (!step || typeof step !== "object" || Array.isArray(step))
2091
- throw new ValidationError(
2092
- `Provider "${providerId}" ${stepPath} must be an object.`,
2093
- );
1956
+ throw new ValidationError(`Provider "${providerId}" ${stepPath} must be an object.`);
2094
1957
  rejectUnknownFields(step, HEALTH_JOURNEY_STEP_FIELDS, stepPath);
2095
1958
  if (typeof step.id !== "string" || !JOURNEY_ID_RE.test(step.id))
2096
1959
  throw new ValidationError(
@@ -2100,20 +1963,13 @@ function validateHealthJourneys(
2100
1963
  throw new ValidationError(
2101
1964
  `Provider "${providerId}" ${stepPath}.operationId references unknown operation "${step.operationId}".`,
2102
1965
  );
2103
- if (
2104
- step.usesSmsMatcher !== undefined &&
2105
- !matcherIds.has(step.usesSmsMatcher)
2106
- )
1966
+ if (step.usesSmsMatcher !== undefined && !matcherIds.has(step.usesSmsMatcher))
2107
1967
  throw new ValidationError(
2108
1968
  `Provider "${providerId}" ${stepPath}.usesSmsMatcher references unknown matcher "${step.usesSmsMatcher}".`,
2109
1969
  );
2110
1970
  }
2111
1971
  if (journey.manualTrigger !== undefined)
2112
- validateHealthJourneyManualTrigger(
2113
- providerId,
2114
- journey.id,
2115
- journey.manualTrigger,
2116
- );
1972
+ validateHealthJourneyManualTrigger(providerId, journey.id, journey.manualTrigger);
2117
1973
  if (journey.timeout !== undefined)
2118
1974
  assertIsoDuration(
2119
1975
  journey.timeout,
@@ -2159,23 +2015,10 @@ function validateOperationHealthChecks(
2159
2015
  fix: `Remove either operations.${operationName}.healthCheck or operations.${operationName}.healthCheckUnsupported.`,
2160
2016
  },
2161
2017
  );
2162
- if (hasCheck)
2163
- validateHealthCheckSuite(
2164
- providerId,
2165
- operationName,
2166
- operation.healthCheck,
2167
- );
2018
+ if (hasCheck) validateHealthCheckSuite(providerId, operationName, operation.healthCheck);
2168
2019
  if (hasUnsupported)
2169
- validateHealthCheckUnsupported(
2170
- providerId,
2171
- operationName,
2172
- operation.healthCheckUnsupported,
2173
- );
2174
- if (
2175
- !hasCheck &&
2176
- !hasUnsupported &&
2177
- !journeyCoveredOperations.has(operationName)
2178
- )
2020
+ validateHealthCheckUnsupported(providerId, operationName, operation.healthCheckUnsupported);
2021
+ if (!hasCheck && !hasUnsupported && !journeyCoveredOperations.has(operationName))
2179
2022
  throw new ValidationError(
2180
2023
  `Provider "${providerId}" operation "${operationName}" declares neither healthCheck nor healthCheckUnsupported.`,
2181
2024
  {
@@ -2230,6 +2073,19 @@ function validateOperationFixtures(
2230
2073
  }
2231
2074
  }
2232
2075
 
2076
+ /**
2077
+ * Shallow shape guard only: the `deployment` object is passed through
2078
+ * verbatim and deliberately not deep-validated by the SDK — the APIFuse
2079
+ * registry builder owns deployment validation and profile resolution.
2080
+ */
2081
+ function validateProviderDeployment(providerId: string, deployment: unknown): void {
2082
+ if (deployment === undefined) return;
2083
+ if (!deployment || typeof deployment !== "object" || Array.isArray(deployment))
2084
+ throw new ProviderError(`Provider "${providerId}" deployment must be an object when present`, {
2085
+ fix: 'Pass deployment: { runtime: "shared" | "dedicated" | "browser", ... } or remove the field',
2086
+ });
2087
+ }
2088
+
2233
2089
  export function defineProvider<
2234
2090
  TOperations extends Record<string, ProviderOperation>,
2235
2091
  TConfig extends ProviderConfig<TOperations>,
@@ -2242,10 +2098,9 @@ export function defineProvider<
2242
2098
  fix: 'Use lowercase alphanumeric with dashes, e.g., "korea-air-quality"',
2243
2099
  });
2244
2100
  if (Object.keys(config.operations).length === 0)
2245
- throw new ProviderError(
2246
- `Provider "${config.id}" must define at least one operation`,
2247
- { fix: "Add at least one operation to the operations object" },
2248
- );
2101
+ throw new ProviderError(`Provider "${config.id}" must define at least one operation`, {
2102
+ fix: "Add at least one operation to the operations object",
2103
+ });
2249
2104
  validateOperationIds(config.id, config.operations);
2250
2105
  validateOperationAnnotations(config.id, config.operations);
2251
2106
  validateOperationObservability(config.id, config.operations);
@@ -2257,13 +2112,21 @@ export function defineProvider<
2257
2112
  config.operations,
2258
2113
  config.healthJourneys,
2259
2114
  );
2260
- validateOperationHealthChecks(
2115
+ validateOperationHealthChecks(config.id, config.operations, journeyCoveredOperations);
2116
+ if (config.healthMonitor !== undefined && config.healthProbe !== undefined)
2117
+ throw new ValidationError(
2118
+ `Provider "${config.id}" declares both healthMonitor and healthProbe. They are aliases; declare exactly one.`,
2119
+ {
2120
+ fix: "Keep healthProbe (the new name) and delete the healthMonitor block.",
2121
+ },
2122
+ );
2123
+ validateProviderHealthMonitor(
2261
2124
  config.id,
2262
- config.operations,
2263
- journeyCoveredOperations,
2125
+ config.healthProbe ?? config.healthMonitor,
2126
+ config.healthProbe !== undefined ? "healthProbe" : "healthMonitor",
2264
2127
  );
2265
- validateProviderHealthMonitor(config.id, config.healthMonitor);
2266
2128
  validateOperationFixtures(config.id, config.operations);
2129
+ validateProviderDeployment(config.id, config.deployment);
2267
2130
  validateProviderProxy(config);
2268
2131
  validateProviderStt(config);
2269
2132
  if (config.runtime === "browser" && !config.browser)
@@ -2282,6 +2145,9 @@ export function defineProvider<
2282
2145
  id: config.id,
2283
2146
  version: config.version,
2284
2147
  runtime: config.runtime,
2148
+ // Verbatim passthrough: deployment validation and profile resolution
2149
+ // are owned by the APIFuse registry builder, not the SDK.
2150
+ deployment: config.deployment,
2285
2151
  allowedHosts: config.allowedHosts,
2286
2152
  stealth: config.stealth,
2287
2153
  proxy: config.proxy,
@@ -2295,7 +2161,10 @@ export function defineProvider<
2295
2161
  context: config.context,
2296
2162
  meta: config.meta,
2297
2163
  operations: config.operations,
2298
- healthMonitor: config.healthMonitor,
2164
+ // Transitional healthMonitor → healthProbe alias: mirror whichever field
2165
+ // was declared onto both so old and new consumers keep working.
2166
+ healthMonitor: config.healthMonitor ?? config.healthProbe,
2167
+ healthProbe: config.healthProbe ?? config.healthMonitor,
2299
2168
  healthJourneys: config.healthJourneys,
2300
2169
  };
2301
2170
  }